From 702cdc9c59c688a48a240c2961bbf16b491e0ace Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 15 Oct 2026 20:56:59 +0300 Subject: [PATCH 001/222] docs(docs): record wave 2 merge and next work Record PR #94 completion on main and join final reviewed-source evidence. Reconcile living guides, exit criteria and related ADR landing notes while preserving historical ADR bodies, approved limits and open phase work. Validation: merge tree equals reviewed head; relative links and anchors resolve; all 25 ADR edits are append-only; git diff --check passes. This documentation-only update starts no Wave 3 implementation. Refs: #94, ADR-0105, ADR-0106, ADR-0107, ADR-0108, ADR-0109 Co-Authored-By: Codex --- AGENTS.md | 61 +++------- CLAUDE.md | 61 +++------- README.md | 2 +- .../0006-os-keychain-for-api-keys.md | 9 ++ .../0019-cli-node-keychain-library.md | 9 ++ .../0026-session-export-to-workflow.md | 9 ++ docs/decisions/0029-tool-policy-hardening.md | 9 ++ ...-llm-seam-shape-amendment-multimodal-io.md | 9 ++ .../0037-engine-tool-execution-boundary.md | 9 ++ ...substrate-mediastore-deinline-retention.md | 9 ++ ...-egress-failover-rematerialization-ssrf.md | 9 ++ ...cess-governance-read-media-save-to-cost.md | 9 ++ .../0050-cli-history-db-at-rest-posture.md | 9 ++ ...54-cli-bare-invocation-interactive-home.md | 9 ++ ...apability-seam-tool-environment-factory.md | 9 ++ ...57-cli-chat-modes-and-per-tool-approval.md | 9 ++ ...t-layer-file-injection-and-shell-escape.md | 9 ++ .../0063-cli-config-write-contract.md | 9 ++ ...65-provider-economics-and-extensibility.md | 9 ++ ...a-resume-that-verifies-its-own-identity.md | 9 ++ .../0084-consent-before-a-local-mcp-spawn.md | 9 ++ .../0089-media-correctness-four-boundaries.md | 9 ++ ...-local-producers-before-its-host-closes.md | 9 ++ ...invocation-grants-not-project-authority.md | 9 ++ ...ication-requires-bounded-verified-bytes.md | 9 ++ ...rst-run-secret-inputs-use-bounded-stdin.md | 9 ++ ...aders-stay-gated-until-keychain-custody.md | 9 ++ ...nership-is-explicit-iterator-provenance.md | 9 ++ docs/roadmap/current.md | 88 +++++++------- docs/roadmap/deferred-tasks.md | 3 +- .../phase-2.5.5-hardening-and-remediation.md | 109 +++++++++++++++++- .../phase-2.6-conversational-authoring.md | 5 +- 32 files changed, 411 insertions(+), 143 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ed522807..dd8aa736 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ joins original whole-wave acceptance, maintainer corrections and final reviewed- PR/push CI and SonarCloud success. Published ordinary/enforced coverage records 511 files / 11,026 passing tests / 12 established skips; all four wire and 23 usage mechanisms pass. CR-70/93, CR-95's long continuation and exact-capacity/scoped residuals remain approved deferrals. -The joint [Wave 2 — Shut the doors](docs/roadmap/current.md#next-work--wave-2--shut-the-doors) is now accepted on `development`; +The joint [Wave 2 — Shut the doors](docs/roadmap/current.md#next-work--wave-2--shut-the-doors) merged to `main` on 2026-10-15 as PR #94; next is [Wave 3 — Clear the ground](docs/roadmap/current.md#wave-3--clear-the-ground). Phase 2.5.5 and Phase 2.6 remain in progress. No W8 implementation or landing gate remains. **Post-closure corrections are independently accepted, 2026-10-10:** core, CLI/DB and tooling/docs @@ -63,51 +63,26 @@ Final reviewed head `3d4491df` passed PR/push CI and SonarCloud before PR #92 me No merge-commit execution or new whole-PR review is inferred. Two nonblocking Sonar complexity suggestions remain visible; no new deferral or decision is added. -**Joint Wave 2 accepted on `development` — 2026-10-13:** all eight implementation -steps are complete under approved ADR-0105–0109. The [closing register](docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-closing-register--2026-10-13) -joins forty individual dispositions, four new security sittings plus W4 revalidation, -two NEW complete clean cumulative whole-wave rounds, broad author/coverage gates -and exact-source published causal/platform evidence. Exit criteria 1–3 and the D -half of M2.5.5-2 are accepted; the I half and full phases remain open. Publication -is separate from merge. No ADR approval, provider capture, key, billing or user -decision is pending for Wave 2. Next engineering work is -[Wave 3 — Clear the ground](docs/roadmap/current.md#wave-3--clear-the-ground), -starting with its serialized multi-file queue and a fresh decision preflight. -Full M/N lifecycle and existing approved deferrals retain their canonical homes. - -**PR #94 maintainer source corrections accepted — 2026-10-13:** runner-level -unhandled-error rejection, legal OpusTags tails and FLAC nonfinal block consistency -each have two NEW independent clean complete cumulative correction rounds. Final -source rounds at `f4ca3c53` cover all 28 correction paths / 72 hunks; these are not a -new review of all original PR paths. The [correction register](docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-94-systematic-review-corrections--2026-10-13) -joins source acceptance; [PR #94](https://github.com/HodeTech/Relavium/pull/94) -records current corrected-head full CI/coverage/platform and complete physical -controls separately. Their actual success is required before merge. Original -`395fbf95` artifacts retain their historical scope. Wave 3 is subsequent work. - -**PR #94 lower-priority corrections — 2026-10-13:** all twelve verified Low -items from the subsequent 305-path review are implemented in three increments. -The [lower-priority correction register](docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-94-lower-priority-review-corrections--2026-10-13) -records every disposition and separately attributes selected controls, broad gates -and historical captures. Each increment requires two NEW complete clean cumulative -source rounds after its final fix; PR #94 owns the actual final reviewed-head -acceptance and refreshed CI/coverage/platform/physical-evidence publication. -No new ADR, deferral, provider request, key, billing action or user decision is -introduced. Wave 3 follows these corrections; publication remains separate from merge. +**Joint Wave 2 merged to `main` — 2026-10-15, PR #94 (`ab08209e`).** +All eight implementation steps and the maintainer corrections are complete on `main` +under Accepted ADR-0105–0109. The [merge checkpoint](docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins forty item dispositions, security sittings, the final reviewed source `4cdfe011`, +two NEW clean complete cumulative correction rounds and successful PR/push CI, +coverage, native-platform and independently inspected physical controls. Earlier +whole-wave and correction reviews retain their exact source scopes; no new whole-PR +review or merge-commit execution is inferred. Detailed review records remain local +and Git-ignored. + +Exit criteria 1–3 and the D half of M2.5.5-2 are satisfied on `main`. The I half, +whole Phase 2.5.5/2.6, full M/N production lifecycle, future live provider headers +and existing approved deferrals remain open in their canonical homes. No Wave 2 +implementation, review, publication, ADR approval, provider capture, credential, +billing or user-decision gate remains. Next is [Wave 3 — Clear the ground](docs/roadmap/current.md#wave-3--clear-the-ground), +starting with a fresh document/decision preflight and its serialized multi-file queue. +Wave 3 implementation has not started in this closure update. See [docs/roadmap/current.md](docs/roadmap/current.md) for live status. -**PR #94 additional corrections implemented — 2026-10-15:** review of `992e6dab` -reproduced generated-result publication, Vorbis framing and host-boundary defects. -All four repair increments, including the subsequent finite pin-error capture, are -implemented. The [additional correction register](docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-94-additional-systematic-review--2026-10-15) -owns their scope and checkpoint receipts; [PR #94](https://github.com/HodeTech/Relavium/pull/94) -owns actual current-source review, execution and ready/merge status. Two NEW clean -complete cumulative rounds after the final material fix and actual corrected-source -CI/coverage/platform/physical controls are required before merge. Earlier acceptance -and green captures retain their historical scope. Wave 3 follows corrected PR acceptance. -No provider, credential, billing or new ADR approval is pending. - ## The non-negotiable rules 1. **TypeScript-first, strict.** No `any`, no unsafe `as`. diff --git a/CLAUDE.md b/CLAUDE.md index eb9b21ca..024a15c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,7 @@ throughout the registers describe their original checkpoints. CR-70 (cross-turn tool-history carrying), CR-93 (tenant-scoped mutable state), CR-95's long continuation and existing exact-capacity/scoped residuals retain their approved limits in [deferred-tasks.md](docs/roadmap/deferred-tasks.md). No new deferral, provider capture, billing, -credential or W8 user decision is pending. The joint [Wave 2 — Shut the doors](docs/roadmap/current.md#next-work--wave-2--shut-the-doors) is now accepted on `development`; +credential or W8 user decision is pending. The joint [Wave 2 — Shut the doors](docs/roadmap/current.md#next-work--wave-2--shut-the-doors) merged to `main` on 2026-10-15 as PR #94; next is [Wave 3 — Clear the ground](docs/roadmap/current.md#wave-3--clear-the-ground). Phase 2.5.5 and Phase 2.6 remain in progress. @@ -152,37 +152,23 @@ billing, key or user decision is pending. The [merge checkpoint](docs/roadmap/ph records W7 closure. W8 subsequently merged in PR #92; the register is 49/51 on `main`. Existing approved limits are unchanged; W8 has no open implementation or landing gate. -**Joint Wave 2 accepted on `development` — 2026-10-13:** all eight implementation -steps are complete under approved ADR-0105–0109. The [closing register](docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-closing-register--2026-10-13) -joins forty individual dispositions, four new security sittings plus W4 revalidation, -two NEW complete clean cumulative whole-wave rounds, broad author/coverage gates -and exact-source published causal/platform evidence. Exit criteria 1–3 and the D -half of M2.5.5-2 are accepted; the I half and full phases remain open. Publication -is separate from merge. No ADR approval, provider capture, key, billing or user -decision is pending for Wave 2. Next engineering work is -[Wave 3 — Clear the ground](docs/roadmap/current.md#wave-3--clear-the-ground), -starting with its serialized multi-file queue and a fresh decision preflight. -Full M/N lifecycle and existing approved deferrals retain their canonical homes. - -**PR #94 maintainer source corrections accepted — 2026-10-13:** runner-level -unhandled-error rejection, legal OpusTags tails and FLAC nonfinal block consistency -each have two NEW independent clean complete cumulative correction rounds. Final -source rounds at `f4ca3c53` cover all 28 correction paths / 72 hunks; these are not a -new review of all original PR paths. The [correction register](docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-94-systematic-review-corrections--2026-10-13) -joins source acceptance; [PR #94](https://github.com/HodeTech/Relavium/pull/94) -records current corrected-head full CI/coverage/platform and complete physical -controls separately. Their actual success is required before merge. Original -`395fbf95` artifacts retain their historical scope. Wave 3 is subsequent work. - -**PR #94 lower-priority corrections — 2026-10-13:** all twelve verified Low -items from the subsequent 305-path review are implemented in three increments. -The [lower-priority correction register](docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-94-lower-priority-review-corrections--2026-10-13) -records every disposition and separately attributes selected controls, broad gates -and historical captures. Each increment requires two NEW complete clean cumulative -source rounds after its final fix; PR #94 owns the actual final reviewed-head -acceptance and refreshed CI/coverage/platform/physical-evidence publication. -No new ADR, deferral, provider request, key, billing action or user decision is -introduced. Wave 3 follows these corrections; publication remains separate from merge. +**Joint Wave 2 merged to `main` — 2026-10-15, PR #94 (`ab08209e`).** +All eight implementation steps and the maintainer corrections are complete on `main` +under Accepted ADR-0105–0109. The [merge checkpoint](docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins forty item dispositions, security sittings, the final reviewed source `4cdfe011`, +two NEW clean complete cumulative correction rounds and successful PR/push CI, +coverage, native-platform and independently inspected physical controls. Earlier +whole-wave and correction reviews retain their exact source scopes; no new whole-PR +review or merge-commit execution is inferred. Detailed review records remain local +and Git-ignored. + +Exit criteria 1–3 and the D half of M2.5.5-2 are satisfied on `main`. The I half, +whole Phase 2.5.5/2.6, full M/N production lifecycle, future live provider headers +and existing approved deferrals remain open in their canonical homes. No Wave 2 +implementation, review, publication, ADR approval, provider capture, credential, +billing or user-decision gate remains. Next is [Wave 3 — Clear the ground](docs/roadmap/current.md#wave-3--clear-the-ground), +starting with a fresh document/decision preflight and its serialized multi-file queue. +Wave 3 implementation has not started in this closure update. For live status, per-PR history, milestone dates, and open obligations, see the canonical home [docs/roadmap/current.md](docs/roadmap/current.md); @@ -190,17 +176,6 @@ home [docs/roadmap/current.md](docs/roadmap/current.md); [docs/roadmap/phases/phase-2.6-conversational-authoring.md](docs/roadmap/phases/phase-2.6-conversational-authoring.md) has the full Phase 2.6 plan. -**PR #94 additional corrections implemented — 2026-10-15:** review of `992e6dab` -reproduced generated-result publication, Vorbis framing and host-boundary defects. -All four repair increments, including the subsequent finite pin-error capture, are -implemented. The [additional correction register](docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-94-additional-systematic-review--2026-10-15) -owns their scope and checkpoint receipts; [PR #94](https://github.com/HodeTech/Relavium/pull/94) -owns actual current-source review, execution and ready/merge status. Two NEW clean -complete cumulative rounds after the final material fix and actual corrected-source -CI/coverage/platform/physical controls are required before merge. Earlier acceptance -and green captures retain their historical scope. Wave 3 follows corrected PR acceptance. -No provider, credential, billing or new ADR approval is pending. - ## Non-negotiable rules for AI agents These apply to every AI agent in this repo, regardless of model, runner, or tool. diff --git a/README.md b/README.md index 73fefc38..c28b08d6 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ Relavium is under active development. The **CLI is published as v0.1.1**; the pu agent-session entry point, workflow runtime, inbound MCP client, and CLI management surface are implemented. **Phase 2.6.5 — Core Reliability Remediation is complete with approved deferrals**, with W8 merged in PR #92. The joint Phase 2.5.5/2.6 security remediation -Wave 2 is accepted on `development`; publication and merge remain separate. The next +Wave 2 merged to `main` on 2026-10-15 as PR #94. The next engineering wave is “Clear the ground”: CLI safety, shared guards and decomposition of the hot files needed by later product work. diff --git a/docs/decisions/0006-os-keychain-for-api-keys.md b/docs/decisions/0006-os-keychain-for-api-keys.md index b3589662..b893f34f 100644 --- a/docs/decisions/0006-os-keychain-for-api-keys.md +++ b/docs/decisions/0006-os-keychain-for-api-keys.md @@ -66,3 +66,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0019-cli-node-keychain-library.md b/docs/decisions/0019-cli-node-keychain-library.md index 270f227b..b1a76d02 100644 --- a/docs/decisions/0019-cli-node-keychain-library.md +++ b/docs/decisions/0019-cli-node-keychain-library.md @@ -56,3 +56,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0026-session-export-to-workflow.md b/docs/decisions/0026-session-export-to-workflow.md index a9989ae4..db70b542 100644 --- a/docs/decisions/0026-session-export-to-workflow.md +++ b/docs/decisions/0026-session-export-to-workflow.md @@ -113,3 +113,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0029-tool-policy-hardening.md b/docs/decisions/0029-tool-policy-hardening.md index 18e85cd6..f9aa2c91 100644 --- a/docs/decisions/0029-tool-policy-hardening.md +++ b/docs/decisions/0029-tool-policy-hardening.md @@ -119,3 +119,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0031-llm-seam-shape-amendment-multimodal-io.md b/docs/decisions/0031-llm-seam-shape-amendment-multimodal-io.md index d88c7166..dbdb6022 100644 --- a/docs/decisions/0031-llm-seam-shape-amendment-multimodal-io.md +++ b/docs/decisions/0031-llm-seam-shape-amendment-multimodal-io.md @@ -357,3 +357,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0037-engine-tool-execution-boundary.md b/docs/decisions/0037-engine-tool-execution-boundary.md index ed85198f..5c7806a5 100644 --- a/docs/decisions/0037-engine-tool-execution-boundary.md +++ b/docs/decisions/0037-engine-tool-execution-boundary.md @@ -81,3 +81,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md b/docs/decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md index 24c68565..5c7b7fa3 100644 --- a/docs/decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md +++ b/docs/decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md @@ -136,3 +136,12 @@ The [canonical carrier contract](../reference/shared-core/llm-provider-seam.md#b owns routing and optional-host compatibility. This corrects integration of ADR-0089/0106 without changing ingress bounds, retention or realized accounting. Source review and corrected-head execution acceptance remain separate. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0043-media-egress-failover-rematerialization-ssrf.md b/docs/decisions/0043-media-egress-failover-rematerialization-ssrf.md index fc79d74d..2b2c0c91 100644 --- a/docs/decisions/0043-media-egress-failover-rematerialization-ssrf.md +++ b/docs/decisions/0043-media-egress-failover-rematerialization-ssrf.md @@ -106,3 +106,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0044-media-access-governance-read-media-save-to-cost.md b/docs/decisions/0044-media-access-governance-read-media-save-to-cost.md index 5e7b5a80..bea8cc9a 100644 --- a/docs/decisions/0044-media-access-governance-read-media-save-to-cost.md +++ b/docs/decisions/0044-media-access-governance-read-media-save-to-cost.md @@ -101,3 +101,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0050-cli-history-db-at-rest-posture.md b/docs/decisions/0050-cli-history-db-at-rest-posture.md index 23ef66aa..a51490a4 100644 --- a/docs/decisions/0050-cli-history-db-at-rest-posture.md +++ b/docs/decisions/0050-cli-history-db-at-rest-posture.md @@ -197,3 +197,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0054-cli-bare-invocation-interactive-home.md b/docs/decisions/0054-cli-bare-invocation-interactive-home.md index 35aa01b7..6b90dfe7 100644 --- a/docs/decisions/0054-cli-bare-invocation-interactive-home.md +++ b/docs/decisions/0054-cli-bare-invocation-interactive-home.md @@ -76,3 +76,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0055-cli-host-capability-seam-tool-environment-factory.md b/docs/decisions/0055-cli-host-capability-seam-tool-environment-factory.md index ad7ffe13..e5fc00ae 100644 --- a/docs/decisions/0055-cli-host-capability-seam-tool-environment-factory.md +++ b/docs/decisions/0055-cli-host-capability-seam-tool-environment-factory.md @@ -272,3 +272,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md b/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md index dfef5f3a..74a7592c 100644 --- a/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md +++ b/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md @@ -216,3 +216,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0061-cli-input-layer-file-injection-and-shell-escape.md b/docs/decisions/0061-cli-input-layer-file-injection-and-shell-escape.md index ceaf7cad..230afaf0 100644 --- a/docs/decisions/0061-cli-input-layer-file-injection-and-shell-escape.md +++ b/docs/decisions/0061-cli-input-layer-file-injection-and-shell-escape.md @@ -361,3 +361,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0063-cli-config-write-contract.md b/docs/decisions/0063-cli-config-write-contract.md index 4e350cb7..bf8753fa 100644 --- a/docs/decisions/0063-cli-config-write-contract.md +++ b/docs/decisions/0063-cli-config-write-contract.md @@ -140,3 +140,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0065-provider-economics-and-extensibility.md b/docs/decisions/0065-provider-economics-and-extensibility.md index f2818ff6..7bfbf29a 100644 --- a/docs/decisions/0065-provider-economics-and-extensibility.md +++ b/docs/decisions/0065-provider-economics-and-extensibility.md @@ -235,3 +235,12 @@ activation, full M/N lifecycle or removal of approved residuals. > capture and the `kind` column. [ADR-0108](0108-provider-headers-stay-gated-until-keychain-custody.md) > remains the governing custody and activation gate. The earlier dated note is retained > as history; this correction changes no decision or implementation boundary. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md b/docs/decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md index 497f1055..8895a6b9 100644 --- a/docs/decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md +++ b/docs/decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md @@ -392,3 +392,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0084-consent-before-a-local-mcp-spawn.md b/docs/decisions/0084-consent-before-a-local-mcp-spawn.md index 43647d5f..00c458c0 100644 --- a/docs/decisions/0084-consent-before-a-local-mcp-spawn.md +++ b/docs/decisions/0084-consent-before-a-local-mcp-spawn.md @@ -541,3 +541,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0089-media-correctness-four-boundaries.md b/docs/decisions/0089-media-correctness-four-boundaries.md index eaeaebb0..531df888 100644 --- a/docs/decisions/0089-media-correctness-four-boundaries.md +++ b/docs/decisions/0089-media-correctness-four-boundaries.md @@ -403,3 +403,12 @@ fixes the exact object bound and port compatibility. URL streaming, direct small put bounds, byte verification and persist-before-deliver are preserved; a storage refusal never repeats already billed generation. This note does not certify the new implementation or execution evidence. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md b/docs/decisions/0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md index 3a1f24bd..24f25b5e 100644 --- a/docs/decisions/0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md +++ b/docs/decisions/0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md @@ -1232,3 +1232,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0105-filesystem-roots-are-invocation-grants-not-project-authority.md b/docs/decisions/0105-filesystem-roots-are-invocation-grants-not-project-authority.md index 8c0e556c..027f5b59 100644 --- a/docs/decisions/0105-filesystem-roots-are-invocation-grants-not-project-authority.md +++ b/docs/decisions/0105-filesystem-roots-are-invocation-grants-not-project-authority.md @@ -310,3 +310,12 @@ native Windows cohort selects the equivalent-separator cases; local source/contr proof is not native Windows execution or a network-share fixture. The material fix requires two NEW clean complete cumulative rounds; prior selected controls at `eee9ca53` remain historical until corrected-head publication. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0106-media-publication-requires-bounded-verified-bytes.md b/docs/decisions/0106-media-publication-requires-bounded-verified-bytes.md index eb3d9f9e..c3d44b2f 100644 --- a/docs/decisions/0106-media-publication-requires-bounded-verified-bytes.md +++ b/docs/decisions/0106-media-publication-requires-bounded-verified-bytes.md @@ -538,3 +538,12 @@ New actual-engine host/executor and internal-helper controls accompany the repai The earlier source round is not clean; two NEW complete cumulative source rounds and corrected-head publication are required after this fix. Their actual verdicts and execution remain owned by PR #94 and its correction register. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0107-first-run-secret-inputs-use-bounded-stdin.md b/docs/decisions/0107-first-run-secret-inputs-use-bounded-stdin.md index ac5674d9..2b26e3b6 100644 --- a/docs/decisions/0107-first-run-secret-inputs-use-bounded-stdin.md +++ b/docs/decisions/0107-first-run-secret-inputs-use-bounded-stdin.md @@ -109,3 +109,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0108-provider-headers-stay-gated-until-keychain-custody.md b/docs/decisions/0108-provider-headers-stay-gated-until-keychain-custody.md index 67a7c20c..456c7276 100644 --- a/docs/decisions/0108-provider-headers-stay-gated-until-keychain-custody.md +++ b/docs/decisions/0108-provider-headers-stay-gated-until-keychain-custody.md @@ -147,3 +147,12 @@ joins the per-item disposition, two NEW clean complete whole-wave review rounds, security sittings, exact-source gates and preserved limits. Earlier dated pending checkpoints remain historical. This landing does not claim a merge, future header activation, full M/N lifecycle or removal of approved residuals. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/decisions/0109-native-media-close-ownership-is-explicit-iterator-provenance.md b/docs/decisions/0109-native-media-close-ownership-is-explicit-iterator-provenance.md index 2924fefc..dee7f47c 100644 --- a/docs/decisions/0109-native-media-close-ownership-is-explicit-iterator-provenance.md +++ b/docs/decisions/0109-native-media-close-ownership-is-explicit-iterator-provenance.md @@ -267,3 +267,12 @@ The [canonical lifecycle contract](../reference/shared-core/llm-provider-seam.md distinguishes these reachable cases. Existing foreign-finite, native-join and cleanup-order removal controls retain their witnesses; this clarification neither weakens the accepted guarantee nor claims current-head publication acceptance. + + +### Wave 2 merge landing — 2026-10-15 + +The applicable Wave 2 implementation and maintainer corrections merged to `main` +as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved +limits. Earlier pending/unmerged notes are historical; this landing adds no decision, +future feature, deferral or claim of merge-commit execution. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 925cf052..7f12a643 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -101,51 +101,43 @@ Earlier dated pending checkpoints in this page and the phase register are histor -## Wave 2 accepted — Shut the doors - -**PR #94 additional corrections implemented — 2026-10-15.** Review of `992e6dab` -reproduced two High valid-media failures and further host-boundary defects. All four -repair increments, including the followup finite pin-error capture, are implemented. -The [additional correction register](phases/phase-2.5.5-hardening-and-remediation.md#pr-94-additional-systematic-review--2026-10-15) -tracks their scope and checkpoint evidence. [PR #94](https://github.com/HodeTech/Relavium/pull/94) -owns actual current-source cumulative review, execution and ready/merge status; -two NEW complete clean rounds after the final fix and actual required gates must -succeed before merge. The acceptance checkpoints below retain their original scopes. -Wave 3 follows corrected PR acceptance; no provider, credential or user decision is pending. - -**Wave 2's eight steps are accepted on `development`, 2026-10-13.** This existing -navigation anchor is retained for incoming decision links; implementation is -complete and publication is separate from merge. The [closing register](phases/phase-2.5.5-hardening-and-remediation.md#wave-2-closing-register--2026-10-13) -joins all forty item dispositions, four new threat sittings plus W4 revalidation, -two NEW complete clean cumulative whole-wave review rounds and exact-source author, -coverage, native Windows/Node-floor and full physical causal evidence. - -**PR #94's three maintainer source corrections are accepted — 2026-10-13.** -Systematic review of `395fbf95` confirmed a runner-level unhandled-error blind spot, -refusal of valid OpusTags padding and missing nonfinal FLAC block-size consistency. -All three repairs have permanent regressions and two NEW independent clean complete -cumulative rounds after each final source correction. The final source rounds at -`f4ca3c53` cover all 28 correction paths / 72 hunks. The -[correction register](phases/phase-2.5.5-hardening-and-remediation.md#pr-94-systematic-review-corrections--2026-10-13) -joins their separate acceptance scopes. Corrected-head full CI, coverage, platform -results and independently inspected complete controls belong to the -[PR #94 publication record](https://github.com/HodeTech/Relavium/pull/94) and must -pass before merge. Earlier green checks retain their original source attribution; -the original `395fbf95` artifacts do not establish the repaired runner-error -guarantee. Publication and maintainer review precede merge; Wave 3 remains -subsequent work. - -Completed work covers invocation-authorized filesystem boundaries, bounded secret -input and bridge diagnostics, faithful-snapshot warnings and structural session -privacy, export completion, verified bounded media/CAS/native lifecycles, and the -interim provider-header gate. Exit criteria 1–3 and sub-stream D are signed off. -M2.5.5-2's I half, full Phase 2.5.5/2.6, M/N production lifecycle, future live headers -and existing approved residuals remain open. There is no pending ADR approval, -provider capture, credential, billing or user decision for this wave. + + +## Wave 2 merged — Shut the doors + +**Wave 2 merged to `main` on 2026-10-15 as [PR #94](https://github.com/HodeTech/Relavium/pull/94), +merge commit `ab08209e`. All eight steps and the maintainer corrections are complete.** +The [merge checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) +joins the forty item dispositions, four new security sittings plus W4 revalidation, +original whole-wave acceptance and all subsequent correction scopes. Existing incoming +navigation anchors are retained; dated implementation checkpoints remain historical. + +Completed work covers invocation-authorized filesystem boundaries and scratch-policy +composition; bounded secret stdin, early admission and private bridge diagnostics; +faithful-snapshot warnings, structural session privacy and export completion; verified +bounded media/CAS/native lifecycles; and the interim provider-header gate. Maintainer +corrections include runner-level error rejection, valid Opus/Vorbis publication, +FLAC structural consistency, generated-result streaming and finite pin-error provenance. + +Final reviewed correction source `4cdfe011` has two NEW independent complete clean +cumulative rounds covering all 67 correction paths / 188 hunks / 24 complete new files. +These scoped rounds do not claim a new review of every original PR path. Full local +CI, ordinary/enforced coverage and all nine jobs in each PR/push CI workflow pass; +SonarCloud and selected native Windows/Node-floor controls pass. Local coverage records +588 files / 12,983 passing tests / 20 explicit skips; CI records 12,982 / 21 with its +established extra CI-only skip. Complete physical controls are independently inspected. +The merge tree matches the reviewed source; no new merge-commit execution is inferred. + +**Satisfied on `main`:** Phase 2.5.5 exit criteria 1–3 and sub-stream D acceptance. +**Still open:** M2.5.5-2's I half, whole Phase 2.5.5/2.6, M/N production lifecycle, +future live headers and existing approved residuals. Wave 2 has no remaining implementation, +review, publication, ADR approval, provider capture, credential, billing or user-decision gate. +The [deferred register](deferred-tasks.md) retains its existing limits; closure adds no deferral. ## Next work — Wave 3 — Clear the ground -The next engineering work is [Wave 3 — Clear the ground](#wave-3--clear-the-ground). +The next engineering work is [Wave 3 — Clear the ground](#wave-3--clear-the-ground); +its implementation has not started in this closure update. Its serialized multi-file queue starts with shared close handling, safe path presentation and exhaustiveness guards, before hot-file decomposition and the remaining CLI/process work. Start with current documents/code and its own decision @@ -524,7 +516,7 @@ flowchart TD W1["Wave 1 — Stop the bleeding ✅
3 CRITICALs · cost cap · ADR-0074"] LEDGER["#W15-1 — realized-cost ledger ✅
ADR-0076 + ADR-0077"] P265["Phase 2.6.5 — Core reliability
51 CR items · 8 P0 blockers behind ADR-0078–0084
W0–W8 complete · W3 blocker closed in W8
W4 merged — the hostile-MCP class, ADR-0088
W5 merged — media correctness, ADR-0089+0090
W6 merged — authoring correctness, ADR-0091–0094
W7 merged — PR #90 · W8 merged — PR #92
49/51 closed · approved deferrals retained"] - W2["Wave 2 — Shut the doors
fs jail · secrets · config trust
certifies 2.5.5 EXIT 1–3"] + W2["Wave 2 — Shut the doors ✅
PR #94 merged · fs jail · secrets · config trust
2.5.5 EXIT 1–3 satisfied"] W3["Wave 3 — Clear the ground
god-file decomposition · CLI net"] W4a["Wave 4a — The spine
2.6.A/D/H/K + 2 ADRs"] W4b["Wave 4b — Money floor & close-out
2.5.5.B/C/F/H drain · v0.2.0"] @@ -1080,7 +1072,7 @@ an ESLint selector rather than a list. ### Wave 2 — Shut the doors -**Accepted on `development`, 2026-10-13; merge remains separate.** The [closing register](phases/phase-2.5.5-hardening-and-remediation.md#wave-2-closing-register--2026-10-13) joins all forty queue IDs and the exit/D sign-off. Hostile-MCP work is reused from W4/PR #87; the remaining filesystem, secrets/config, persistence and media work is complete within its recorded limits. The queue below preserves scope and traceability. +**Complete on `main`, 2026-10-15, merged as PR #94 (`ab08209e`).** The [merge checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) joins all forty queue IDs, the exit/D sign-off and final correction evidence. Hostile-MCP work is reused from W4/PR #87; the remaining filesystem, secrets/config, persistence and media work is complete within its recorded limits. The queue below preserves scope and traceability. 1. ✅ **Completed in W4/PR #87:** 2.5.5.D · project-layer MCP name collision redirecting a global secret (G19) and MCP tool-definition poisoning (#202); see the @@ -1094,19 +1086,19 @@ an ESLint selector rather than a list. certify from there in step 6 below; the *work* happens in 2.6.5 alongside `CR-16` (consent before a stdio spawn) and `CR-41` (the DNS/redirect SSRF hole), because they are the same code and the same security sitting. Splitting them across two phases would book the hostile-MCP reviewer twice. -3. ✅ **The fs jail, ONE reviewed change set — accepted on `development`, 2026-10-12** — 2.5.5.D · sensitive-credential floor for `.kube`/`.azure`/gcloud +3. ✅ **The fs jail, ONE reviewed change set — complete on `main`, PR #94** — 2.5.5.D · sensitive-credential floor for `.kube`/`.azure`/gcloud (#36, #39) → extract `deepestExistingReal` to `packages/shared` (#235) → **2.6.M's `project`-tier `extraRoots`** and **2.6.N's fs-floor home-anchoring**, both *pulled forward*. `~/.relavium/tmp/` (G21) is resolved under the same general predicate (decision D12) — otherwise `fs.ts` takes two security reviews. -4. `discoverCatalog` realpath containment (#1, accepted in Step 2); the secrets-layer test pair — OS-keychain bridge (#27) and +4. ✅ `discoverCatalog` realpath containment (#1, accepted in Step 2); the secrets-layer test pair — OS-keychain bridge (#27) and `readSecretFromStdin` + stdin cap (#31, #293); secret-typed `run --input` rejection (#25); the unredacted workflow-snapshot doc + lint (#30); 2.5.5.B · `scrubSecrets` param-name broadening (#196). -5. Persistence-security: 2.5.5.C · `assertRelativePath`'s Windows drive-relative form (#113), media-store +5. ✅ Persistence-security: 2.5.5.C · `assertRelativePath`'s Windows drive-relative form (#113), media-store hardening (#111, #108, #112), media `mimeType` verification (#107, #109, #110), and certification of W7's structural session/tool-content controls (#115, Wave D15). 2.5.5.A · chat-export continuity/slug completion and existing tool-content exclusion (#80, #81, #83, D14), plus `analyzeSecretTaint`'s context sites (G22). 2.5.5.D · the dormant `default_headers` admission/custody gate (G20, D13). -6. **Certify 2.5.5 exit criteria 1, 2 and 3** + the sub-stream D acceptance sign-off. **Closes M2.5.5-2 (D half).** +6. ✅ **Phase 2.5.5 exit criteria 1, 2 and 3 and sub-stream D acceptance are satisfied on `main` by PR #94.** The D half of M2.5.5-2 is complete; the I half remains open. > Book **four security-review sittings by threat class** (fs/path-jail · secrets-at-rest · secrets-input · > config-trust), not ~30 per-PR passes. The **hostile-MCP** sitting moved to Phase 2.6.5 with the queue-2 work; diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 1b5fcc3c..f8a13c33 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -2,7 +2,8 @@ > Status: Living -> Last updated: 2026-10-11 — W8 merged in PR #92; phase closure preserves approved deferrals. +> Last updated: 2026-10-15 — Wave 2 merged in PR #94; existing approved deferrals remain. +> The [merge checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) records completed prerequisites; full M/N lifecycle and other scheduled work remain with their owners. > > Earlier triage: the Phase-2.6 rewrite triaged every open item, the 2026-07-19 full-project > review added the deliberately-unscheduled block at the end, Phase 2.6.5's `W1` and `W2` residual diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index c93fa45a..3dfaea50 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -1,6 +1,6 @@ # Phase 2.5.5 — Hardening and Remediation -> Status: 🚧 **Open — Wave 2 Steps 1–8 accepted on development; publication and later waves remain separate.** Live progress and execution order are canonical in +> Status: 🚧 **Open — Wave 2 Steps 1–8 and review corrections merged to main as PR #94 (2026-10-15); later waves remain open.** Live progress and execution order are canonical in > [../current.md](../current.md#execution-order--phase-255--phase-26-temporary); this banner does not > restate them. A full-project multi-agent code review completed > 2026-07-19 at HEAD `f88b0e8` — 377 findings, 373 surviving two-lens adversarial @@ -448,7 +448,7 @@ This substream is the development-process substrate itself — `.github/workflow | In-phase | Completed by | Outcome | |----------|--------------|---------| | ✅ M2.5.5-1 Safety-critical propagation fixes *(met 2026-07-30, PR #81 — its two CRITICALs, #91 and #228, are closed. ADR-0074 §1 was open at that date and CLOSED on 2026-08-09 with `/cost --release`; the "Wave 1.5" framing this note used was retracted — that work was PR #81's own closing list, not a later wave. See [current.md](../current.md).)* | 2.5.5.A + 2.5.5.C | Two of the three CRITICAL findings closed (`#91` the unredacted approval-preview secret leak; `#228` the chat-persister unhandled-rejection crash — the third, `#56`, lands with 2.5.5.I under M2.5.5-2); the budget governor's concurrency gap and the two-process migration race are fixed | -| M2.5.5-2 Security-surface hardening — D accepted, I open ([Wave 2 sign-off](#wave-2-closing-register--2026-10-13)) | 2.5.5.D + 2.5.5.I | MCP tool-poisoning/config-secret-hijack gaps closed; the third CRITICAL (`#56`) closed as terminal-control sanitization reaches `relavium run`, the error boundary, and the approval card; `chat.ts`/`home-controller.ts` decomposed ahead of 2.6.E/2.6.G | +| M2.5.5-2 Security-surface hardening — D complete on main (PR #94), I open ([merge checkpoint](#wave-2-merge-checkpoint--2026-10-15)) | 2.5.5.D + 2.5.5.I | MCP tool-poisoning/config-secret-hijack gaps closed; the third CRITICAL (`#56`) closed as terminal-control sanitization reaches `relavium run`, the error boundary, and the approval card; `chat.ts`/`home-controller.ts` decomposed ahead of 2.6.E/2.6.G | | M2.5.5-3 CLI operator-safety net | 2.5.5.E | The outermost crash/signal/error boundary behaves uniformly across every command; `--version`/`--help` startup tax closed | | M2.5.5-4 Money-safety & process substrate | 2.5.5.B + 2.5.5.H | The three LLM adapters share guard/classification parity and `FallbackChain` is the sole retry authority; CI actually executes the compiled binary and the coverage/dependency/logging gates match what they claim to enforce | | M2.5.5-5 Documentation & hygiene close-out | 2.5.5.F + 2.5.5.G | No canonical doc names a retired data source or an unbuilt surface as shipped; the naming/duplication/dead-code sweep is closed | @@ -513,7 +513,7 @@ directly as a 2.6.A work item). **Intra-phase:** the five file overlaps recorded ## Exit criteria -**Wave 2 disposition — 2026-10-13:** criteria 1–3 are accepted on `development`; the [closing register](#wave-2-closing-register--2026-10-13) joins their direct regressions and threat sittings. Whole-phase criteria 4–8 remain open. +**Wave 2 disposition — 2026-10-15:** criteria 1–3 are satisfied on `main` following the merge of PR #94; the [merge checkpoint](#wave-2-merge-checkpoint--2026-10-15) joins their direct regressions, threat sittings and final corrections. Whole-phase criteria 4–8 remain open. 1. All three CRITICAL findings (`#91` the `previewFor()` secret leak; `#228` the chat-persister unhandled rejection; `#56` the unsanitized `relavium run` TUI) are closed and regression-tested. @@ -1041,7 +1041,7 @@ Raised by a reviewer, then disproved by both verifiers reading the code. Recorde ## Wave 2 preflight and proposed execution plan — 2026-10-12 -> Current disposition — 2026-10-13: all eight steps are accepted on `development`; the [closing register](#wave-2-closing-register--2026-10-13) controls current acceptance. The dated preflight/checkpoint states below remain historical. +> Current disposition — 2026-10-15: all eight steps and review corrections merged to `main` as PR #94; the [merge checkpoint](#wave-2-merge-checkpoint--2026-10-15) controls current completion. The dated preflight/checkpoint states below remain historical. **Status: maintainer approved ADR-0105–0108 and the eight-step plan on 2026-10-12. Steps 1–6 are accepted after independent cumulative reviews and required gates; Step 7 proceeds.** @@ -2902,7 +2902,9 @@ Two NEW clean complete cumulative whole-wave rounds remain required. ### Wave 2 closing register — 2026-10-13 -**Steps 1–8 are accepted on `development`; publication is separate from merge.** +> Current disposition — 2026-10-15: Steps 1–8 and maintainer corrections are complete on `main` after PR #94 merged. The [merge checkpoint](#wave-2-merge-checkpoint--2026-10-15) joins final source reviews and execution; the dated evidence below retains its original scope. + +**Original acceptance — 2026-10-13: Steps 1–8 accepted on `development`, before merge.** This is the joint Phase 2.5.5/2.6 “Shut the doors” wave. It does not reopen the merged Phase 2.6.5 W2/W8 interlude or close all of Phase 2.5.5/2.6. @@ -3481,3 +3483,100 @@ together, including the entire increment-4 diff. PR #94 owns their actual verdic and the required final CI/coverage/platform/physical-evidence publication; the old nonclean round contributes no clean acceptance. No new ADR, deferral or user action is introduced, and Wave 3 remains subsequent work. + +### Wave 2 merge checkpoint — 2026-10-15 + +[PR #94](https://github.com/HodeTech/Relavium/pull/94) merged to `main` at +**17:50:23 UTC (20:50:23 Europe/Istanbul)**, merge commit +`ab08209ea0fd4681a9ed760811c6b1acfc6501b9`. Its final reviewed head is +`4cdfe011fc62841e6daba3b39391d0d1e540c4dc`; the merge tree is byte-identical +to that source. **All eight Wave 2 steps and the maintainer corrections are +complete on `main`.** ADR-0105–0109 remain Accepted. The forty-ID +[disposition ledger](#wave-2-individual-dispositions--certification-in-progress), +[original closing register](#wave-2-closing-register--2026-10-13), +[first corrections](#pr-94-systematic-review-corrections--2026-10-13), +[lower-priority corrections](#pr-94-lower-priority-review-corrections--2026-10-13) +and [additional corrections](#pr-94-additional-systematic-review--2026-10-15) +retain their exact source scopes and historical receipts. + +#### Completed scope + +| Area | Complete on `main` | +| --- | --- | +| Filesystem authority | Invocation-only grants and narrowing selectors, credential floors, shared ancestor walk, catalog containment, exact scratch-floor prerequisite and composition, literal glob admission. | +| Secrets and config trust | Bounded owned stdin, first-run secret delivery and argv refusal, native bridge failure privacy, finite scrubbing and faithful-snapshot warnings. | +| Persistence and export | Revalidated structural session/tool-content refusal, secret-taint context sites, export continuity/slug and completion notices. | +| Media and native lifecycle | Verified bounded bytes before publication, CAS/temp ownership and recovery, explicit iterator provenance, generated-result streaming and valid Opus/Vorbis/FLAC handling. | +| Provider-header gate | Nonempty-map refusal, inert legacy rows, explicit logical clear and SDK/public-projection exclusion; live header activation remains gated. | +| Review corrections and evidence | Runner-error rejection, finite immutable node-bound pin diagnostics, safe declared-input reasons, native DB journal validation, all 151 filesystem/secrets anchors and complete refreshed controls. | + +The four implementation increments from the 2026-10-15 review, including the +pin-error followup and final removal-anchor synchronization, have **two NEW +independent complete clean cumulative correction rounds** at `4cdfe011`. Each +round covers all **67 correction paths / 188 hunks / 24 complete new files**, +including the entire increment-4 subset (34 paths / 94 hunks / 11 new files). +These are scoped correction reviews, not a new review of every original PR path. +Original whole-wave acceptance and earlier correction rounds retain their separate +attribution. Root joined the complete reports and file/blob/diff inventories; +detailed review records remain local and Git-ignored. + +#### Final reviewed-source execution + +Full local `pnpm run ci`, ordinary coverage and enforced coverage pass at +`4cdfe011`. Both local coverage commands execute freshly and record **588 files / +12,983 passing tests / 20 explicit skips**. Published CI coverage records +**12,982 passes / 21 skips**, with the established additional CI-only skip; +thresholds are unchanged. Matching workspace/build/lint cache reuse is recorded +separately and is not described as fresh execution. + +All nine jobs in each [push CI](https://github.com/HodeTech/Relavium/actions/runs/38771189079) +and [PR CI](https://github.com/HodeTech/Relavium/actions/runs/38771197426) pass, +including the required aggregate, Node 22.13.0 floor, native Windows and acceptance +jobs. SonarCloud succeeds with quality gate OK; demonstrated false positives have +individual reasons, with no rule/gate weakening. Other nonblocking maintainability +suggestions remain visible. PR checkout `24f66e27` has the same Git tree; literal-head +physical evidence comes from push CI. These results do not claim new execution +of the merge commit or this documentation-only successor. + +Selected native Windows evidence includes DB 58 files / 1,031 passes, filesystem +12 files / 323 passes / 18 platform skips, and shared/CLI media 14/8 files / +399/87 passes, plus CAS, packaging and headless EOF4 controls. This is not whole-repository +Windows coverage. The required native DB journal contains 97 records, ten workers +and nine modes; actual identities, stages, original readiness bounds and joined +outcomes pass independent validation. All 321 acceptance-tool tests pass. + +Complete local and literal-head push exports were independently inspected: + +| Family | Controls | Phase records / case executions | Outcome authority | +| --- | ---: | --- | --- | +| Filesystem | 110 | 330 / 8,148 | 330 completed runner receipts, zero runner errors. | +| Secrets | 41 | 123 / 8,451 | 123 completed runner receipts, zero runner errors. | +| Wire | 4 | 9 / 216 | Same 24-case installed-SDK HTTP inventory. | +| Usage | 23 | 70 / 3,738 | Includes 435 permanent cases and 71 native children. | + +Frozen Git blobs, exact removal bytes, complete inventories, named semantic +witnesses, restoration, process/runtime joins and retained bounds pass. Qualified +secondary removal effects cannot replace causal witnesses. Filesystem/secrets +retain their 2,000-file/64-MiB bounds; usage/wire retain their separate existing +20,000-file/256-MiB bounds and outcome authorities. Remote dependency pins/loader +receipts are joined without claiming local rehashing of remote installed bytes. +Earlier partial, failed, selected or prior-head runs do not count as final acceptance. + +#### Exit status and next work + +**Phase 2.5.5 exit criteria 1–3 and sub-stream D acceptance are satisfied on `main` +by PR #94.** M2.5.5-2's D half is complete; its I half remains open. Whole-phase +exit criteria 4–8, later Waves 3–7 and full Phase 2.5.5/2.6 remain open. +Full M/N production lifecycle, future live headers and existing approved limits +retain their [canonical deferred homes](../deferred-tasks.md); closure creates no +new deferral or architectural decision. Finite structural media verification, +cooperating-host filesystem assumptions, one loaded shared-module iterator domain +and sensitive retained content remain explicit. + +Wave 2 has no remaining implementation, review, publication, ADR approval, provider +capture, key, billing or user-decision gate. Next is [Wave 3 — Clear the ground](../current.md#wave-3--clear-the-ground), +starting with its fresh document/decision preflight and serialized multi-file queue: +shared close handling, safe path presentation and exhaustiveness guards. The +remaining decomposition, viewport, shared-schema and CLI/process queues follow +their existing order; Wave 3's unresolved recommendations are not approvals. +This merge-status update starts no Wave 3 implementation and changes no release version. diff --git a/docs/roadmap/phases/phase-2.6-conversational-authoring.md b/docs/roadmap/phases/phase-2.6-conversational-authoring.md index 1578d2c7..9ec2254d 100644 --- a/docs/roadmap/phases/phase-2.6-conversational-authoring.md +++ b/docs/roadmap/phases/phase-2.6-conversational-authoring.md @@ -2,6 +2,7 @@ > Status: **In progress** — **2.6.F** (platform floor + full-screen TUI, PR #74) and **2.6.C** (reseat > transcript-carry + `/cost`, PR #75) are ✅ **Done**, and **2.6.Q P1–P5** landed with PR #76 (2026-07-26). +> Joint Wave 2 prerequisites and corrections merged in PR #94 (2026-10-15); the [merge checkpoint](phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) records their exact scope. Full M/N lifecycle and remaining workstreams stay open. > Live progress is canonical in [../current.md](../current.md); the rest of the phase follows. Depends on the Phase 2.5 > spine (the wired tool-environment and the per-tool approval / mode system), which is **complete** (M2.5-4, > PR #69, 2026-07-08), so this phase is unblocked. @@ -1257,8 +1258,8 @@ workstreams — each stays checked off **only** in the PR that lands it: | Session budget pause/resume (EA4 ride; 1.V follow-up) | 2.6.K | | i18n CI key-parity + data/code separation standard | 2.6.L | | Live `web_search`/`http_request` egress credential resolver | 2.6.M | -| `project`-tier invocation roots/config selectors — accepted | [Wave 2 Step 2 acceptance](phase-2.5.5-hardening-and-remediation.md#step-2-acceptance--2026-10-12) | -| Exact host-issued scratch floor prerequisite — accepted; production lifecycle remains 2.6.N | [Wave 2 Step 2 acceptance](phase-2.5.5-hardening-and-remediation.md#step-2-acceptance--2026-10-12) / 2.6.N | +| `project`-tier invocation roots/config selectors — complete on main (PR #94) | [Wave 2 Step 2 acceptance](phase-2.5.5-hardening-and-remediation.md#step-2-acceptance--2026-10-12) | +| Exact host-issued scratch floor prerequisite — complete on main (PR #94); production lifecycle remains 2.6.N | [Wave 2 Step 2 acceptance](phase-2.5.5-hardening-and-remediation.md#step-2-acceptance--2026-10-12) / 2.6.N | | Target-scoped approval cache + structured MCP preview | 2.6.M | | Approval-consent-line zero-width hardening · shared `[c]` reducer | 2.6.M (with render v2) | From bd1da978ccb7b3f8337b83fb88b58c5ade6046d6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 15 Oct 2026 21:45:11 +0300 Subject: [PATCH 002/222] docs(docs): propose wave 3 decisions and execution plan Reconcile historical findings against current source before implementation. Record four proposed decisions, concrete compatibility choices, a confirmed synthetic context-taint leak and the serialized seventeen-step review plan. Dependent implementation awaits maintainer approval. Existing accepted ADRs and product code are unchanged; detailed reviews remain Git-ignored. Validation: 2,882 relative links and anchors resolve; diff check passes. Scoped unchanged-source suites pass 319 tests; no full CI is inferred. Refs: ADR-0110, ADR-0111, ADR-0112, ADR-0113, Wave 3 Co-Authored-By: Codex --- ...d-discovery-have-explicit-compatibility.md | 146 ++++++++++++++++++ ...-operations-use-a-lazy-packaged-runtime.md | 115 ++++++++++++++ ...and-onboarding-state-are-operator-owned.md | 137 ++++++++++++++++ ...ontext-keeps-provenance-at-public-sinks.md | 132 ++++++++++++++++ docs/decisions/README.md | 4 + docs/roadmap/current.md | 17 +- .../phase-2.5.5-hardening-and-remediation.md | 121 +++++++++++++++ 7 files changed, 669 insertions(+), 3 deletions(-) create mode 100644 docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md create mode 100644 docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md create mode 100644 docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md create mode 100644 docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md new file mode 100644 index 00000000..c6e93621 --- /dev/null +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -0,0 +1,146 @@ +# ADR-0110: CLI diagnostics and discovery have explicit compatibility + +- **Status**: Proposed +- **Date**: 2026-10-15 +- **Related**: [ADR-0047](0047-cli-framework-commander-ink-clack.md), [ADR-0049](0049-cli-machine-output-contract.md), [ADR-0056](0056-cli-in-app-slash-command-system-and-manifest.md), [ADR-0088](0088-the-mcp-boundary-is-hostile.md), [ADR-0103](0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) + +## Context + +Wave 3 contains maintainer decisions D19/D20/D22 and findings #13/#18/#19/#45, +#264/#265/#268/#309/#310 and G15/G35. They change public CLI behaviour and need +one compatibility decision before implementation. ADR-0049 says help remains +human-readable under `--json`; ADR-0056 describes a machine-readable command +manifest. Both cannot govern the same explicit help invocation. + +At the preflight baseline `702cdc9c`, quiet is largely ineffective, debug and +verbose use different predicates, internal CLI faults share exit 1 with failed +workflows, and human log replay prints individual token events. The existing +background-rejection net already reports and keeps the process alive; its absence +must not be claimed as a new defect. Uncaught exceptions need a different policy. + +This proposal requires maintainer approval. It introduces no engine ErrorCode, +provider seam field, runtime dependency or new tool. Exact option/output shapes +belong in [CLI commands](../reference/cli/commands.md), with the interactive +behaviour in [chat-session.md](../reference/cli/chat-session.md). + +## Decision + +We will preserve machine data and existing lifecycle outcomes while making +diagnostics, discovery and the enumerated remediation aliases explicit. + +### Diagnostic policy and failure ownership + +Keep `--quiet` and `--verbose` mutually exclusive: specifying both remains an +invalid invocation. Either explicit flag overrides `RELAVIUM_DEBUG=1`; only the +exact environment value `1` is a verbose alias. Default verbosity is normal. +`--json` controls representation independently of verbosity. + +Quiet suppresses decoration, banners, progress churn and optional informational +notices. It preserves requested command results, assistant answers, final outcomes +and cost/usage qualifications, approvals, warnings, errors, denials and required +durability/effect disclosures. Implement categories at their owners; never filter +all stdout/stderr writes. JSON preserves every contractual data record and safety +diagnostic and never contains human stack dumps. Verbose diagnostics are bounded, +redacted, terminal-safe and exclude arbitrary cause inspection, raw argv, request +bodies and credential values. Generic failures point to the supported verbose flag. + +Reserve additive exit **9** for an unclassified CLI internal failure. Genuine +workflow failure/cancellation remains 1; invocation faults remain 2; existing +0 and 3–8 and surface-specific signal outcomes retain their meanings. Typed +history/ownership/receipt refusals must not be flattened into 9. A final joined +money/effect/durability/ownership disposition takes precedence over a generic +internal failure. + +Preserve the recoverable background-rejection net's keepalive policy: report a +bounded safe diagnostic, remember the failure and upgrade only an otherwise clean +exit 0 to 9. Preserve an existing nonzero lifecycle outcome. An uncaught exception +or top-level rejected invocation instead stops new work, reports once and requests +bounded cooperative owner cleanup. The fatal shutdown budget is three seconds; +after it, only owned native resources may be synchronously reaped before exit. +Normal/recoverable cleanup still joins ADR-0103 owners before closing their stores. +The process `exit` callback cannot await work and must never be described as an +asynchronous durability acknowledgement. No crash handler fabricates a run terminal +or retries a paid request. Repeated faults and a broken diagnostic stream cannot +recursively enter the handler. + +A closed output pipe is a typed output failure, with bounded cancellation/cleanup, +not a raw Node stack. Write a safe diagnostic only to a still-usable independent +sink; an EPIPE sink cannot acknowledge its own error. Preserve more specific final +safety dispositions; otherwise use exit 9. Do not assert successful terminal delivery +or durable publication from a failed write. + +### Discovery and compatibility + +Explicit root `--help --json` returns a versioned command-manifest JSON record +(`type: command_manifest`, `version: 1`, `commands` containing the canonical list); +command-specific help returns the matching command/subtree in the same format. +Stable existing executor IDs remain available; aliases resolve to the same executor. +Both are exit-0 meta-operations and start no +services. This replaces **only** ADR-0049 §6's human-help exception for explicit +help plus JSON. `--version --json` and bare `--json` retain their existing human +meta-operation behaviour. Run/session JSON streams retain their event shapes and +sequence ordering. Invalid command/option grammar still fails as an invocation. + +Make `chat resume`, `chat list` and `chat export` canonical while retaining working +flat aliases. Make `models pricing --input-price` / `--output-price` canonical, +retaining the old pricing spellings as deprecated aliases. Specifying both spellings +for one quantity refuses even when values match, rather than silently choosing one. +Successful deprecated-alias use has one bounded stderr notice, structured in JSON +mode, never an extra stdout record. Update the one command manifest, +dispatch and help together. Do not rename run inputs, gate payloads or reserved +agent input semantics as collateral work. + +`provider remove-key` requires explicit confirmation on an interactive terminal or +`--force` for automation. A refusal/cancel performs no key deletion; JSON/CI cannot +prompt. Deliberately declining confirmation exits 0; a noninteractive invocation +without force exits 2. Actual process termination retains its signal contract. +Human `logs` coalesces consecutive token/reasoning fragments by run, node +and channel, flushing at boundaries and EOF with bounded buffering. JSON logs +retain the actual stored event stream. Unknown-command diagnostics keep arbitrary +command strings private and add a static help hint rather than echoing raw input. + +D22 means no unrecorded breaking change. This decision explicitly authorizes the +listed aliases, deletion consent, diagnostic behaviour, meta-help format and +internal exit distinction. It does not authorize unrelated command or event +changes. Landing must reconcile the phase's older two-exception wording and its +blanket “no new ADR” claim, preserving historical ADR bodies through dated notes. + +Considered preserving all current behaviour and correcting only documentation: +rejected because quiet, internal-fault distinction and discovery remain misleading. +Considered making verbose win over quiet: rejected because it changes the existing +conflict rule unnecessarily. Considered suppressing safety output in quiet or +coalescing JSON events: rejected because either hides contractual evidence. +Considered making every unhandled rejection fatal: rejected because it reverses +the accepted recoverable persister backstop. Fatal exceptions are distinct. + +### Acceptance and landing + +1. A flags × environment × output-mode matrix proves category-based suppression, + explicit precedence and preserved JSON/safety data; planted secret/control text + never enters diagnostics, including verbose and fatal paths. +2. Native child tests cover closed stdout/stderr, synchronous startup throws, + background rejection, uncaught exception, repeated faults and owned subprocess + teardown. Positive controls preserve genuine terminals and 3–8/signal outcomes. +3. Commander-to-dispatch tests prove help projection, aliases, conflict refusal, + global help and deletion consent with zero unintended service/key effects. +4. Human log tests prove boundary-aware coalescing and bounded EOF flushing; JSON + replay remains byte-equivalent for the same event sequence. +5. Removing each correction breaks its regression. Required CI, package/build + checks and two new independent cumulative review rounds pass before acceptance. + Add dated forward notes to ADR-0049/0056/0088/0103 at implementation landing; + update their canonical CLI references and the Wave 3 register. + +## Consequences + +### Positive + +- Scripts can distinguish a failed workflow from a broken CLI without changing + actual run events, receipt authority or the existing safety exits. +- Quiet and verbose have predictable limits, and discovery derives from one manifest. + +### Negative + +- Explicit JSON help, deletion consent and exit 9 alter public behaviour. Their + narrowly recorded scope, old aliases and permanent compatibility tests mitigate it. +- Fatal shutdown cannot promise durable acknowledgement after its deadline. The + diagnostic and final outcome must retain that uncertainty rather than claim cleanup. diff --git a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md new file mode 100644 index 00000000..a72d5f8e --- /dev/null +++ b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md @@ -0,0 +1,115 @@ +# ADR-0111: CLI meta-operations use a lazy packaged runtime + +- **Status**: Proposed +- **Date**: 2026-10-15 +- **Related**: [ADR-0047](0047-cli-framework-commander-ink-clack.md), [ADR-0051](0051-cli-distribution-thin-bundle-private-engine.md), [ADR-0056](0056-cli-in-app-slash-command-system-and-manifest.md), [ADR-0067](0067-node-supported-floor-22-reaffirm-better-sqlite3.md), [ADR-0071](0071-models-dev-as-the-model-metadata-source.md), [ADR-0110](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) + +## Context + +Wave 3 #43/#44/#271 concerns eager service imports and cached catalog reads on +help/version. At `702cdc9c`, deferring `driveHome` alone is insufficient: +`program` → `specs` → `dispatch` imports command implementations and their +DB/provider/TUI dependencies. The entry also imports MCP, and diagnostic helpers +reach engine dependency barrels. This requires a dependency-graph correction, +not just moving one function call. + +ADR-0051's single ESM bundle and the closure guard's single-output assertion prevent +a genuine lightweight entry with deferred implementation chunks. The engine must +still be inlined and third-party dependencies externalized. Wave 3 #46 also needs +release sourcemaps, while ADR-0051 prohibits their publication. The GitHub repository +was verified public during preflight; an ordinary Actions artifact is not a private +archive merely because it is absent from npm. + +This proposal requires approval before bundler, startup or release-policy changes. +No dependency version bump, new runtime dependency or package publication is authorized. + +## Decision + +We will package one executable ESM entry plus its finite, explicitly packaged ESM +runtime chunks. This replaces ADR-0051's **single-output-file** constraint only; +all proprietary engine code remains inlined into the CLI artifact, every third-party +runtime dependency remains external and declared, and the engine packages remain private. + +The entry, flag grammar, version and command descriptors form a lightweight graph. +Meta-operations resolve descriptors without reading config or cached catalogs, +admitting native filesystem grants, opening history/migrations/keychain, connecting +MCP or constructing providers/TUI. Runtime validation still owns normal command +execution; invalid option grammar is not silently accepted by the meta path. +Defer command executor imports, Home, native services and MCP version wiring to +their actual owners. Seed the cached catalog once before an executing command's +first price/capability read, retaining the shipped offline floor and no-startup-fetch +rule. Never move seeding after admission or budget pricing. + +The bundle guard must inspect **every** emitted JavaScript output and its static +and dynamic imports, verify the complete declared dependency closure, and reject +unbundled `@relavium/*`, missing/escaping chunks and undeclared external packages. +The packed artifact, not a source checkout, is the cross-platform smoke subject. +Chunk-relative migration paths, native iterator/shared-module identity and lazy +initialization must be tested together; splitting must not create independent +copies of authority-bearing module state. + +### Private source-map custody + +Generate private sourcemaps for the **exact final minified JavaScript bytes** that +are packed, with `sourcesContent: false`. Do not rebuild the shipping JavaScript +after its map receipt is recorded. A private manifest binds source commit, package +version, actual toolchain and every JavaScript/map digest. Published JavaScript has +no sourceMappingURL, and npm contains neither maps, metafiles, source trees nor the +private manifest. A pack inspection is required, not just a files-list assumption. + +The proposed CI archive is authenticated encrypted material for a maintainer-owned +public recipient key, using vetted tooling rather than in-house cryptography. +The private decryption key never enters this repository or CI. Public artifact +storage is permitted only for ciphertext; fixed diagnostics never print map +contents or key material. The recipient and retention destination must be explicitly +configured and verified before a release can claim private archival. A local +owner-only archive is available for development and custody tests; it does not +claim that CI release archival happened. + +The maintainer is being asked to choose encrypted CI archival, an existing private +archive, or local-only custody. Until that choice and this ADR are approved, no +archive backend is considered settled. Implementation may use synthetic recipient +keys for offline encryption/decryption controls, never manufacture a production +recipient or upload plaintext to complete a gate. The first actual release belongs +to Wave 4b; it must fail closed if its approved private archival prerequisite is +absent. Wave 3 must implement and test the complete selected archival path and +record its real configuration status without presenting a fixture as production custody. + +Considered retaining a single file and deferring only Home: rejected because +external imports remain eager through the rest of the graph. Considered publishing +engine packages separately: rejected by the proprietary bundle boundary. +Considered publishing source-free maps openly: rejected because symbol names and +source layout remain private even without embedded TypeScript. Considered ordinary +public Actions artifacts or Release attachments: rejected as private custody. + +### Acceptance and landing + +1. Native packed help/version/root-help/JSON-help executions install throwing + service-boundary witnesses and prove no service initialization or cached-config + I/O. Record cold-start measurements against the same current baseline; the + historical approximately 400 ms figure is not a fresh measurement or threshold. +2. Real packed run/chat fixture, DB migrations, MCP version, CAS and native iterator + identity controls pass on supported platforms, including the minimum Node floor. +3. Deliberately missing/escaping chunks, external engine imports and an undeclared + dependency break the complete closure/pack guard. Every required chunk is shipped. +4. Map-to-JavaScript receipts survive packing byte-for-byte; a planted original source, + map, manifest or metafile fails public-artifact inspection. Actual private archive + round-trip controls use test keys, and failure cannot fall back to public plaintext. +5. Update the [release runbook](../runbooks/release-a-surface.md), packaging reference + and canonical CLI docs, and append a dated partial-replacement note to ADR-0051 + plus refinements to ADR-0047/0056/0071. Required CI, packed smokes and two fresh + independent cumulative reviews must pass before implementation acceptance. + +## Consequences + +### Positive + +- Help/version avoid initializing unrelated native/network/UI subsystems by construction. +- Exact release bytes can be symbolicated without publishing proprietary source metadata. + +### Negative + +- Multiple packaged files replace a single-file assumption. Complete closure checks, + shared-state identity controls and exact-tarball platform smokes become mandatory. +- Private archival adds a release custody prerequisite. Its configuration is explicit; + absence blocks the archival claim/release instead of leaking maps or silently skipping it. diff --git a/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md b/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md new file mode 100644 index 00000000..27342bea --- /dev/null +++ b/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md @@ -0,0 +1,137 @@ +# ADR-0112: Chat network grants and onboarding state are operator-owned + +- **Status**: Proposed +- **Date**: 2026-10-15 +- **Related**: [ADR-0006](0006-os-keychain-for-api-keys.md), [ADR-0029](0029-tool-policy-hardening.md), [ADR-0033](0033-strict-config-files-amends-0023.md), [ADR-0037](0037-engine-tool-execution-boundary.md), [ADR-0054](0054-cli-bare-invocation-interactive-home.md), [ADR-0055](0055-cli-host-capability-seam-tool-environment-factory.md), [ADR-0057](0057-cli-chat-modes-and-per-tool-approval.md), [ADR-0059](0059-cli-mid-session-model-reseat.md), [ADR-0063](0063-cli-config-write-contract.md), [ADR-0105](0105-filesystem-roots-are-invocation-grants-not-project-authority.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) + +## Context + +Wave 3 pulls G30 forward: chat can advertise `http_request`, but its policy has no +configuration path for `allowedDomains`, so dispatch always denies. Simply copying +a project-owned allowlist would turn a cloned artifact into network authority. +ADR-0105 makes this distinction explicit for filesystem grants; this proposal makes +a separate decision for chat egress rather than pretending that ADR already governs it. + +Onboarding also treats a missing key and an unavailable keychain as the same reason +to repeat full setup. It can announce success after model-default persistence fails. +The global config writer silently replaces intentional symlinks. These changes share +one trust boundary: operator-owned non-secret configuration, mutated only by explicit +user actions through a typed writer. + +Approval is required before the new shared config fields or keychain/config paths +change. Exact keys, precedence and validation belong in the +[config specification](../reference/contracts/config-spec.md), not a second schema here. + +## Decision + +We will use operator-owned global policy for chat HTTP grants and explicit, +non-authoritative preferences for onboarding acknowledgements. + +### Chat HTTP authority + +Add a global-only `[chat_egress].allowed_domains` setting, read from the normal +global config or the file explicitly selected as global by `--config`. A project, +workspace, agent definition, persisted session, model output or environment variable +cannot add or replace this authority. Loading a project must not promote its file +to global configuration. Selecting an explicit global file is the operator's choice. + +An absent/empty list remains deny-all. Limit it to 128 entries, each at most 253 +characters, canonical ASCII DNS names; IDNs use explicit punycode spelling. +Reject URLs, credentials, paths, query strings, ports, wildcards, literal IPs and +single-label/local host forms. Normalize case and any accepted terminal root-dot +form through the same host comparison used by dispatch; do not add a second URL +parser or silently introduce suffix/subdomain permission. Deduplicate canonical names. +Exact host authority retains the existing HTTPS-port semantics; it does not mean +port 443 only. DNS/range checks remain necessary even for an allowed hostname. + +Resolve an owned policy on every fresh/resumed/reseated/cleared host build, including +Home chat and `agent run`. A prior persisted policy cannot revive a removed grant. +An already-built host owns its immutable snapshot until it is rebuilt; this is not +a live revocation watcher. Original-workspace resume discovery remains ADR-0105's +filesystem rule and cannot replace the current operator global egress setting. + +Forward only the approved domain list to the existing engine tool policy. Preserve +exact-host checking before dispatch, per-tool modes/approval, HTTPS-only SSRF/DNS +validation, pinned connection, no redirects and response size/time bounds. `auto` +does not bypass missing domain permission. Loading policy performs no request and +attaches no provider key. This does not activate search credentials, live provider +headers, workflow-run egress or a new tool. Workflow-authored policy remains its +separate execution contract. Advertise filtering is helpful but dispatch is authoritative. + +### Onboarding and mutation + +Add a typed global-only `keychain_unavailable_acknowledged` preference. Set it only +after a genuine typed unavailable-keychain outcome and an explicit user choice to +use a short reminder next time. Cancel, missing key, auth failure or unrelated DB +failure must not set it. Store only a boolean, never a key, hint, error text or +backend/path identity. Use the existing strict, atomic, secret-incapable writer; +no new generic key-value store or automatic startup write is authorized. + +Every Home startup still checks actual current key resolvability. A working env or +keychain key suppresses setup as today. Otherwise an acknowledged backend problem +shows a short keyless reminder with an explicit retry-setup action instead of +automatically repeating full setup/provider validation. The acknowledgement neither +claims permanent lock nor conceals the current missing-key state. A failed preference +write is visible and recoverable and cannot be reported saved. + +After a key is stored but model/provider defaults fail to persist, preserve the key +and offer Retry or an honest incomplete-setup continuation. The next chat must choose +a compatible model explicitly or use an explicitly chosen ephemeral model/provider; +never announce a persisted default that failed. Higher-precedence project defaults +are not overwritten implicitly. Thread the resolved color choice to actual prompts +without process-global environment mutation. + +Refuse a pre-existing symlink at the global preference write target, preserving its +link and referent. Do not follow it into a different authority or warn only after +replacing it. Regular-file writes retain schema round-trip, owner-only temp file, +atomic rename and existing durability guarantees. A malicious same-user process +swapping a target between checks and rename is outside the existing owner-controlled +filesystem assumption; a preflight lstat must not be described as an atomic no-follow +replace primitive. The fixed diagnostic explains how to edit the intentional link +manually without echoing its path or content. + +Considered project-granted domains: rejected because imported config would confer +machine authority. Considered another invocation flag plus project selectors: +viable, but larger than G30 needs; the operator global setting supports project-less +chat without implementing a durable permission store. Considered allowing every +domain once a user approves a tool: rejected because approval and network policy +are independent gates. Considered treating acknowledgement as credential state or +silently writing through config links: rejected because either hides current truth +or changes write authority. + +### Acceptance and landing + +1. Strict schema/resolver tests cover global/explicit-file authority, project and + workspace attempts to widen it, canonical hostname validation and empty deny-all. + Invalid policy causes no session creation, provider/key access or MCP connection + beyond the explicitly necessary existing-history read for resume. +2. Actual session-host-to-tool dispatch controls cover all chat surfaces, rebuilds, + revoked-on-rebuild grants, allowed requests, no-domain denial, approval refusal + and unchanged workflow/search/MCP separation. Native SSRF controls prove the + existing DNS/connect/redirect floor still applies. No paid/live provider test is needed. +3. First-chat integration proves chosen non-default provider/model binding, failed + config-write recovery and precedence. Current keyless state, explicit acknowledgement, + retry, cancellation and write-failure tests prove no false setup/credential claim. +4. Native symlink tests preserve link/referent bytes on refusal; regular-file + positive controls keep atomicity, permissions and error confidentiality. Real + prompt output respects color-off. Separate config-trust/egress and keychain-path + security review sittings plus two fresh cumulative rounds are required. +5. Update canonical config, chat, Home, CLI and built-in tool references, append + dated refinements to related Accepted ADRs, and register actual acceptance in + Wave 3. This proposal itself establishes no permission, saved setting or completion. + +## Consequences + +### Positive + +- An existing chat tool becomes usable under explicit operator authority without + weakening default deny, approvals or SSRF controls. +- Setup tells the truth about key availability and persisted defaults, while an + intentional config symlink is preserved. + +### Negative + +- New strict global keys require a new enough binary; older binaries reject them + under ADR-0033. Document rollback/removal of these non-secret keys. +- Domain changes take effect at host rebuild, not instantaneously. An operator + needing immediate revocation cancels the active turn and rebuilds the host. diff --git a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md new file mode 100644 index 00000000..1bc21ea6 --- /dev/null +++ b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md @@ -0,0 +1,132 @@ +# ADR-0113: Secret-derived context keeps provenance at public sinks + +- **Status**: Proposed +- **Date**: 2026-10-15 +- **Related**: [ADR-0027](0027-expression-sandbox.md), [ADR-0029](0029-tool-policy-hardening.md), [ADR-0037](0037-engine-tool-execution-boundary.md), [ADR-0038](0038-agentrunner-llm-call-boundary.md), [ADR-0050](0050-cli-history-db-at-rest-posture.md), [ADR-0083](0083-input-admission-and-a-resume-that-verifies-its-own-identity.md), [ADR-0093](0093-an-expression-sees-only-what-it-is-ordered-after.md), [ADR-0095](0095-what-an-agent-session-remembers-across-turns.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) + +## Context + +Wave 3 #92 observes that `ToolDispatchContext.secretArgKeys` has no live producer. +The existing parser already computes transitive declared-source taint, but bare +JavaScript expression scopes are a separate consumer from interpolation templates. +At baseline `702cdc9c`, a synthetic workflow with a secret input, a derived context +entry and a `transform: ctx.derived` is accepted and exposes its canary in two +`node:completed` events and `run:completed`. `run:started` correctly masks the input. +The parent independently reran this source/QuickJS/in-memory-host probe. No real +credential, provider or network was used. + +`resolveContext` is a public trusted resolver and intentionally retains declared +secret-input values for governed credential use. The canonical workflow contract +allows this intermediate propagation while forbidding public model/human text. +Masking all inputs before resolution would close the probe by changing that +contract. Conversely, adding an empty `secretArgKeys` set would leave the leak and +falsely claim a producer. Neither is an adequate repair. + +This proposal refines enforcement of existing ADR-0027/0029 secrecy, not universal +redaction of conversational content. Approval is required before changing the +execution/preparation context or provenance interfaces. No new runtime dependency, +platform import, persisted secret, model request field or general mapping feature +is authorized. + +## Decision + +We will carry trusted declared-source provenance to every consuming boundary, +preserve raw values only inside their existing trusted resolution domain, and keep +secret-derived context out of expression/model/human public sinks. + +Reuse the existing linear `computeTaint` dependency graph for declared secret input +names and transitive `ctx` references. Expose its provenance result through one +pure internal authority, shared by parser checks and execution/preparation. Do not +add a second scanner, scan raw secret values, or guess secrecy from argument names. +Metadata contains source/key relationships only, never credential bytes, and is +owned against mutation by the consumer's execution context. + +For every `condition`, `transform` and `merge_fn`, build the sandbox context with +the existing secret marker for each tainted key, just as declared secret inputs +are masked today. No raw derived string enters QuickJS, including computed access +such as `ctx[name]`, enumeration or JSON serialization of the context. Public +outputs may carry the inert marker but never the raw value. This preserves the +trusted resolver's raw return contract and clean-context expressions. Static +model/human text checks use the same transitive provenance; preparation, resume, +branching and merge paths must not reconstruct an unqualified context. + +The engine supplies provenance on all actual producer paths, computed from the +same admitted workflow used for trusted context resolution. Add a required frozen +list of secret-derived context names to both execution and preparation contexts; +empty is an explicit clean result, never the default for absent metadata. Migrate +all repository context producers and doubles together. A direct low-level caller +missing this field refuses before sandbox entry; a JavaScript caller that lies +about its inputs/provenance violates the trusted-host contract, just as lying about +existing `secretInputNames` does today. Do not claim to detect that lie by inspecting +credential bytes. Define the public embedding migration in +[agent-runner.md](../reference/shared-core/agent-runner.md) and +[expression-sandbox-spec.md](../reference/shared-core/expression-sandbox-spec.md). +The required field is an explicitly approved low-level API change; existing +no-secret doubles migrate by supplying an owned empty list. + +Keep `ToolDispatchContext.secretArgKeys` as the explicit external provenance guard. +There is no live tool-input-mapping producer: current agent/session producers pass +empty config, and JSON chosen by a model cannot retain declared-input provenance. +Record this limitation honestly, as #92's permitted ADR disposition; do not +implement an unrelated mapping feature or invent a nonempty producer from string +matching to close a checkbox. A future mapping producer must derive tainted final +top-level argument names **after** model/mapping/config precedence, with nested +taint marking its parent, before dispatch or approval persistence. It may not +launder a marker by serializing it to a normal string. Ordinary tool text never +receives raw marked values; credential references retain their separate host-only +resolution path. Existing external nonempty-set refusal controls must remain causal. + +This is not a claim that user/model free text, attachments or tool results are +universally free of sensitive content, or that the CLI database is encrypted. +ADR-0050/0095 retain their exact at-rest and structural scopes. No secret-value +search, request capture or provider-key plumbing is added to core. + +Considered masking every input before trusted context resolution: rejected because +it changes legitimate trusted resolution rather than qualifying the sink. +Considered static literal-expression analysis alone: rejected because computed key +access and whole-context serialization bypass it. Considered blanket name/value +heuristics or a fabricated empty metadata producer: rejected because neither proves +provenance. Considered full dynamic output taint propagation: unnecessary for this +repair when raw secret values never enter the sandbox; future sources require their +own explicit boundary, not an unverified general claim. + +### Acceptance and landing + +1. Permanent actual-engine/QuickJS tests reproduce the baseline canary and prove + absence from every event/output, raw sandbox input and surfaced error after the + fix. Cover direct/transitive/reordered context graphs and computed/enumerated + access in condition/transform/custom merge. Clean inputs/context are positive controls. +2. Fresh/resumed/prepared executions carry owned provenance without a parallel + mutable authority. Missing low-level metadata refuses before sandbox entry; + mutation of a source list cannot alter the engine-owned list. Clean and tainted + lists derive from actual admitted workflows; hostile trusted-host forgery is + explicitly outside this boundary, not falsely certified by a shape guard. +3. Actual parser-to-model/human tests preserve transitive refusal and the trusted + resolver's raw intermediate contract. Credential references stay opaque and are + resolved only by the already-governed host; no new credential sink is introduced. +4. Real external tool-dispatch tests prove nonempty top-level taint refusal before + host effects/approval persistence and preserve clean-call behaviour. Nested-parent + projection remains the documented obligation of a future mapping producer; + do not claim that a nonexistent producer was exercised. No test labels arbitrary + model JSON as declared-input provenance. +5. Removing context masking, source propagation or tool refusal breaks its specific + regression, with exact restoration. A dedicated secret-flow security sitting, + required gates and two new independent cumulative clean reviews precede acceptance. + Update the canonical workflow/expression/tool contracts and append dated forward + notes to ADR-0027/0029/0037/0083/0093; register the #92 limitation and actual repaired + leak separately, never as universal secret redaction or a completed mapping feature. + +## Consequences + +### Positive + +- An existing accepted no-secret-sandbox guarantee covers context aliases as well + as direct inputs, including dynamically selected keys. +- Provenance is shared with the existing parser graph and has no credential bytes. + +### Negative + +- Expressions that previously observed leaked secret context now observe a marker. + This deliberate security correction needs clear compatibility tests and a dated note. +- Low-level executor embeddings must honor provenance ownership. The canonical + interface and fail-closed migration rule must be settled before implementation. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index a8eaa0b6..2ee343ba 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -153,6 +153,10 @@ flowchart TD | 0107 | [First-run secret inputs use bounded stdin](0107-first-run-secret-inputs-use-bounded-stdin.md) | Accepted | 2026-10-12 | | 0108 | [Provider headers stay gated until keychain custody](0108-provider-headers-stay-gated-until-keychain-custody.md) | Accepted | 2026-10-12 | | 0109 | [Native media close ownership is explicit iterator provenance](0109-native-media-close-ownership-is-explicit-iterator-provenance.md) | Accepted | 2026-10-13 | +| 0110 | [CLI diagnostics and discovery have explicit compatibility](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md) | Proposed | 2026-10-15 | +| 0111 | [CLI meta-operations use a lazy packaged runtime](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md) | Proposed | 2026-10-15 | +| 0112 | [Chat network grants and onboarding state are operator-owned](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) | Proposed | 2026-10-15 | +| 0113 | [Secret-derived context keeps provenance at public sinks](0113-secret-derived-context-keeps-provenance-at-public-sinks.md) | Proposed | 2026-10-15 | ## Creating a new ADR diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 7f12a643..3f7812c9 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1106,6 +1106,14 @@ an ESLint selector rather than a list. ### Wave 3 — Clear the ground +**Preflight in progress — 2026-10-15.** The original queue below is reconciled against +current source in the [Wave 3 preflight and proposed execution plan](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-preflight-and-proposed-execution-plan--2026-10-15). +It records already-satisfied items, concrete compatibility/security decisions and +seventeen proposed increments. ADR-0110–0113 are Proposed; implementation has not +started and dependent code awaits maintainer approval. Historical finding descriptions +are not new assertions about current source. The existing Wave 5a renderer/word-wrap +ownership is preserved, not newly approved by this preflight. + Decompose, guard and normalize every hot file a 2.6 workstream is about to land on. **The multi-file queue runs first and is serialized against the rest**; the remaining queues are file-disjoint, any order. @@ -1123,9 +1131,11 @@ on `home-controller.ts`/`chat-ink.tsx` (the decomposition moves already-guarded chat gap (G30)**, pulled forward into `session-host.ts`. - **`home-controller.ts` (strict)** — 2.5.5.E · `selectGatePrompter` test (G37) → the swallowed palette rejection (#281) → **2.5.5.I · decompose `createHomeController` + the overlay discriminated union** - (#215, #243) → 2.5.5.G · the `Capability`/`Port` vocabulary split (#217, #222) → 2.5.5.F · Home's - `showCost` no-op (#125, after D26). -- **`chat-ink.tsx`** — 1,795 lines, larger than `home-controller.ts`, decomposed by **neither** phase doc. + (#215, #243) → 2.5.5.G · the `Capability`/`Port` vocabulary split (#217, #222). + Settle #125/D26 before decomposition: the preflight proposes certifying the real + in-Home-chat cost path and retaining chat-only scope. If a new bare-Home cost + surface is selected, wire its explicit session target before decomposition. +- **`chat-ink.tsx`** — 1,906 lines at preflight (the original queue recorded 1,795), decomposed by **neither** phase doc. **Recommendation: decline** a new decomposition (decision D18); take only the exhaustiveness guard and the min-terminal-size guard (#63), and hand restructuring to 2.6.M's render-v2 in Wave 5a. - **Viewport (strict)** — 2.5.5.I · share the ADR-0069 grapheme segmenter (#58, #62, G40, G51) → @@ -1276,6 +1286,7 @@ unanswered**; the remainder sit inline in their own phase-file bullet. | D19 | 3 | `--quiet`/`--verbose`/`RELAVIUM_DEBUG` precedence | `--quiet` < default < `--verbose`; `RELAVIUM_DEBUG=1` an alias, explicit flag wins; `--json` overrides for stream shape | | D20 | 3 | `--input`'s four-way collision: rename or document? | Rename `models pricing` to `--input-price`/`--output-price`, old spellings deprecated aliases | | D22 | 3 | Read EXIT:4 as "no **breaking** change"? | Yes — with the additive exit code and the noun-verb aliases as the two sanctioned, documented exceptions | +| D26 | 3 | Which session would bare Home's `showCost` target? | **Proposed preflight disposition:** keep `/cost` chat-only; certify both live chat surfaces. Bare Home currently cannot invoke the no-op. Past-session selection remains 2.6.G; no implicit cost release. See the [Wave 3 plan](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-preflight-and-proposed-execution-plan--2026-10-15). | | D28/D29 | 4a | Flip ADR-0058 and ADR-0060 to Accepted | Yes; 0060's taint model is the cited precedent for Wave 7's generation-security ADR | | D31 | 4a | Strike 2.6.H's chat-persister atomicity sub-part as a duplicate? | Yes — owned by Wave 1; record the closure | | D33 | 4b | Retention policy (the phase's largest single item) | Default bound 50 + `--limit`/`--tail`; ship `chat delete` writing the dead `deleted_at` columns; never auto-hard-delete | diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 3dfaea50..e2dcfc60 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -3580,3 +3580,124 @@ shared close handling, safe path presentation and exhaustiveness guards. The remaining decomposition, viewport, shared-schema and CLI/process queues follow their existing order; Wave 3's unresolved recommendations are not approvals. This merge-status update starts no Wave 3 implementation and changes no release version. + +### Wave 3 preflight and proposed execution plan — 2026-10-15 + +**Source baseline:** `development` at `702cdc9c59c688a48a240c2961bbf16b491e0ace`. +Wave 2 is merged; this is its documentation successor. Wave 3 is in document/decision +preflight, with no implementation accepted or started by this entry. The current +[Wave 3 queue](../current.md#wave-3--clear-the-ground) owns execution order; A/E/G/I +above retain scope and acceptance. No release, new provider capture, credential or +paid test is required. Detailed working reports stay local and Git-ignored. + +Three independently scoped agents examined CLI/process, shared/core and chat/Home/ +viewport documents and actual sources/tests. The parent read their complete reports +and independently reran the source/QuickJS context-taint probe. Existing focused +checks passed 8 files / 260 tests (shared/core) and 5 files / 59 tests (CLI), with +no full CI, coverage, platform run or implementation acceptance inferred. These +preflight agents are not either of the two fresh implementation review rounds. + +#### Preflight corrections and already-satisfied work + +| Finding(s) | Current evidence and disposition | +|---|---| +| D10/G50, G38/G39/G47/G49, #91 | Existing typed budget guards, reservation/warning and redacted preview controls remain accepted; preserve, do not reimplement. | +| #75 | W2/CR-22 already derives remaining run deadlines. Preserve actual clock/cancel semantics. | +| #77/G48 | W8 closes the original stream-consumption blocker. Preserve ADR-0087's actual contract and approved exact-capacity residuals. | +| #211, G16 | ADR-0083 already validates input defaults/enums; W7 already enforces compaction-role/structural session rows on read and write. No legacy reader tightening. | +| #89/parallel half of #212 | Full WorkflowSchema rejects duplicate parallel members; exported leaf/union schema still needs consistent enforcement. Centralize without duplicate diagnostics. | +| G22, #80/#81/#83 | Wave 2 context namespace refusal and W7/Wave 2 structural export/caveat/slug work are complete. The new context-to-JavaScript leak is distinct. | +| #78, #280 DB half | Checkpoint comments already name the tiered journal; DbOpenError already has fixed path-free messages. Remaining CLI open/error wrapping lands last. | +| #21 MCP half, #42 rejection half | W4 already reaps MCP and #228 already installs the recoverable rejection net. Remaining active-session abort and fatal/EPIPE/process-group work remain. | +| G36/G37/G46, #20 | Direct verbose and selector tests exist. Idempotent gate JSON routing exists but needs direct regressions. Revalidate, do not claim absent code. | +| G34/G44/#56/#57, G0/#50 | Wave 1 terminal sanitization and job-control/onboarding ordering are closed and preserved by Wave 2. | +| #93 | ADR-0105 already decides immutable host filesystem authority. Clarify descriptive dispatch tier; no per-turn grant expansion. | +| #216 | Remaining `success` is canonical event data, not an internal competing result shape. Keep the wire exception; no event rename. | +| #303/#304 | Exact catalog round-trip fixture already exists; revalidate it. Compiler assertion sites moved from MCP to shared in W6. | +| #125/D26 | Bare Home cannot invoke `/cost`; live in-Home chat uses the real shared DB-backed implementation. Proposed disposition is chat-only plus parity regression, not selection of an arbitrary historical session. | +| #274/#239 | Tested exported `validate:false` and the adapters subpath are public surfaces, not proven dead APIs. Proposed compatibility disposition retains/document them. Local dead exports #240/#241 can be removed after consumer audit. | + +The old E paragraph's “Required ADR: none new” does not authorize changed public +CLI contracts, trust boundaries or distribution/storage decisions. The phase's +current exit criterion 8 applies. The old shared count of “seven remaining” and +the old god-file sizes are historical: current chat/Home/chat-ink are 2,822/1,604/ +1,906 lines. The correct existing identifier comparator is shared `byCodeUnit`; +the token heuristic is now in LLM `request-estimator`, not an adapter module. + +#### Decisions presented together before implementation + +All entries below are **proposed**, not maintainer rulings. Approval covers the +concrete public/API/secrets plan as well as the new ADRs. No proposed choice silently +changes a currently Accepted ADR or a canonical reference before approval. + +| Decision | Recommended choice and compatibility boundary | +|---|---| +| ADR-0110: D19/D20/D22, #13/#19/#309, G15/G35 | [Explicit CLI compatibility](../../decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md): retain quiet+verbose conflict; exact DEBUG=1 alias; preserve JSON/safety data; internal exit 9; versioned explicit JSON help; alias-preserving pricing/chat syntax; key-delete consent; bounded human log coalescing. Genuine run outcomes and exits 3–8/signals keep their contracts. D22's old two-exception shorthand must be reconciled to this explicit enumerated scope. | +| ADR-0111: #43/#44/#46/#271 | [Lazy packaged runtime](../../decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md): lightweight descriptor-only meta graph; engine-inlined finite chunks, complete closure guard and packed platform controls; exact-byte private sourcemaps. Archive method is awaiting the maintainer's choice; public plaintext artifacts are prohibited. | +| ADR-0112: G30/#34/#49/#52/#55 | [Operator-owned chat policy and preferences](../../decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md): global-only domain authority, exact DNS/deny-all/approval/SSRF floors; explicit unavailable-keychain acknowledgement with current-state recheck; truthful model-write recovery; refuse intentional config symlink replacement. | +| ADR-0113: #92 and reproduced leak | [Declared-source context provenance](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md): reuse existing linear taint graph, preserve trusted raw resolution, mask tainted sandbox/public sinks and make low-level provenance mandatory. Record unsupported future tool mappings honestly rather than fabricate a producer. | +| D18, D26/#125, #311 | Keep chat-ink restructuring with Wave 5a; keep cost chat-only with both real surfaces tested; preserve unknown-command non-echo and add static help. These are explicit dispositions, not new features or undisclosed deferrals. | +| #89/#90/G18/#210/G17 | Apply missing duplicate/cross-field guards at leaf and full boundaries. Condition equality is typed (`true` differs from `'true'`; 0 equals -0). Duplicate fallback tuples are provider+model within the fallback list; do not forbid equality with the primary. Timeout without action retains default reject; action without timeout refuses. | +| G17 RunSchema detail | Preserve optional legacy timestamps and outputs. Reject completedAt on nonterminal states, startedAt on pending, and error outside failed; retain temporal checks. Terminal records need not invent timestamps/error fields. Do not adopt a stricter logical schema as a DB reader or migrate historical rows. Existing tests accepting contradictory present fields must change deliberately. | +| #213/#219, #217/#222/#239/#274 | Retain named event exports while sharing the actual exactly-one-correlation envelope validation without payload stripping. Add a minimal shared error base, preserving constructor/name/code/kind/reason/context/cause/guards and existing structural boundary checks; inventory current classes, not historical 15. Add ModelFeature with deprecated Capability alias; retain Accepted ToolHost *Capability names, use Port for new orchestration interfaces. Retain documented adapters/validate compatibility surfaces. | +| #95 tool input caps | UTF-8 byte limits: write content 8 MiB, HTTP body 1 MiB, git message 16 KiB; run args ≤256, each ≤16 KiB and aggregate ≤64 KiB. Add cheap length prechecks, real byte refinement and accurate model metadata. These are proposed new limits, not promises of OS command-line capacity. Refuse before approval/effect/host work. | +| #87/#82/#275 | Document system_prompt_append as literal, preserving taint scans and intentional brace text; do not activate Proposed ADR-0060 or interpolate instructions. Decode quoted argument quote/backslash escapes without eval or blanket Windows-path reinterpretation. Use an in-house priority structure preserving Kahn's authored-index order, with equivalence tests. | +| G4/#65/#64/#66/#63 | Bound input history to 500 and width memoization to 1,024 entries; 8-cell tabs before wrapping; latest-size frame coalescing with immediate narrowing safety, not stale-width debounce. Both chat surfaces enforce 80×24 and disable hidden affirmative approvals while retaining reject/abort/resize. No transcript trimming or new Unicode table. | + +The live synthetic security reproduction is a declared secret input → context alias +→ bare JS transform → output. Three events expose the canary while run:started is +correctly masked. It is not covered by an approved deferral. The owning permanent +regressions and a dedicated secret-flow sitting must accompany its correction. +No credential values or request captures belong in tracked evidence. + +#### Proposed implementation increments + +| Step | Scope, finding IDs and principal verification | +|---|---| +| 1 | **Serialized multi-file queue first:** shared close handling #22 (four named siblings plus newer owned journal sites) → safe source labels #4/G11 → exhaustive session/picker/run switches #300/#301/G3 and actual reasoning rendering. Preserve primary outcomes and host-safe-close ownership; native error and mounted output regressions. | +| 2 | **Shared admission and diagnostics:** #89/#90/#212/G18/#210/G17, certify #211/G16; shared parser reason helper #85/#86 and config numeric bounds #32. Approved status/duplicate matrices, direct leaf/full parse tests and no value-bearing diagnostics. | +| 3 | **Secret provenance:** ADR-0113/#92, fresh/resume/preparation/condition/transform/merge paths, source-to-event negative controls and clean trusted-resolution positives. Dedicated security sitting, no future mapping implementation. | +| 4 | **Error/envelope substrate:** #213/#219, actual migrated-class inventory and cross-bundle structural guards. Preserve exported names/discriminants, constructor/cause behaviour and event payloads. Convert only supported error classes, not vendor errors or plain seam unions. | +| 5 | **Tools and engine tails:** #93/#95/#96/#98/#97/#214/#221/#258/#259/#308, #94/#82/#87/#76/G9/#79/#274/#275. Bounds before effects, model-visible semantics, existing ordinal helper, path-local cycle guards, cancellation race and deterministic plan equivalence; certify #75/#78/#216 instead of rewriting them. | +| 6 | **Process head:** #40/#41/#42/#224/#269 under ADR-0110. Fatal/EPIPE/owned process and DB/MCP lifetimes; preserve recoverable #228 keepalive and W4 signal contracts. Native child/grandchild, broken-sink and receipt-join controls. | +| 7 | **Lazy startup and release substrate:** #43/#44/#46/#271 under ADR-0111. Descriptor/runtime graph, early meta routing, catalog timing, finite chunks, exact pack closure, minimum-Node/platform and private archive tests. Actual archive configuration status is separate from fixture proof. | +| 8 | **CLI contracts:** #45/#264/#265/#268/G35/#13/#17/G15/#18/#310/#309/#19 under ADR-0110. Category-aware flags, explicit help JSON, pricing/chat aliases, human coalescing and deletion consent; byte-preserving JSON/old-alias integration. | +| 9 | **Chat normalization and abort:** #236/#237/#242/#307 → #218 → #21/#23. Canonical token/money source with approved additive LLM export, preserved two-decimal cap formatting and real immediate abort while a turn is blocked. Audit Home's imports. | +| 10 | **Chat decomposition:** #220/#244 after preceding chat fixes. Split lifecycle/drivers, line handler, printers and reseat/clear ownership. Preserve compatibility entry points, W7/W8 receipts, unknown-usage, full-screen bounds, disclosure activation and cancellation. | +| 11 | **Chat domain policy:** G30/ADR-0112 after decomposition, all fresh/resume/Home/agent/rebuild paths. Real host-policy dispatch and no-authority/SSRF/approval controls; separate egress/config-trust security sitting. | +| 12 | **Home controller:** certify G37, fix #281, record/test #125/D26 before #215/#243 decomposition; then #217/#222 scoped naming. Overlay discriminated union, subcontrollers, stale async work/focus/approval invariants and both live cost surfaces. | +| 13 | **Viewport/input:** #58/#62/G40/G51 → #65 → #64/#66, then #63/#69/G4/G55. Grapheme-correct editing/truncation, tabs, bounded caches, safe resizing, TERM=dumb and both small-terminal surfaces, input history and mouse bounds. CJK measurement and real emitted rows. | +| 14 | **Onboarding and overlays:** #49/#52/#51/#53/#55/G5/G52/G41/G53 and #34 under ADR-0112. Real next-model binding, recoverable persistence, explicit acknowledgement, current keyless nudge, hidden-approval safety, color-off mounts and native symlink preservation; keychain/config security sitting. | +| 15 | **Remaining CLI/hygiene:** #24/#26/G43/G2/#12/#54/G14/#11/#47/#48/#240/#241/#311/G54/#303/#304; certify #20/G36/G46. One-shot coverage-aware cost/banner, actual-host doctor, typed faults, no silent missing-dist skip, guard misuse, attachment estimate and compiler checks. #239 remains the approved documented compatibility surface. | +| 16 | **openLocalDb LAST:** #278/#280 across every moved caller. Central fixed safe error wrapping, attempt-all secondary cleanup, preserved causes, privacy/checkpoint notices and exact writer lifetimes. Actual command-family failure probes; no duplicate wrapper left behind. | +| 17 | **Whole-wave acceptance and publication:** every item disposition; A/E/G and M2.5.5-3 evidence; ordinary/enforced coverage and required CI/build/fences/smokes/causal/platform checks at attributed source; evaluate/promote core coverage floor as testing.md requires after Wave 3 coverage work. Two new whole-wave cumulative reviews, canonical doc/ADR landings and PR. I/whole phases/later product waves stay open. | + +Steps are dependency-safe increments, not permission to parallel-edit shared hot +files. Within each row, arrows remain strict. Step 1 completes before all other +code. Home cost is disposed or implemented before its decomposition; viewport +geometry work never races editor/cache work. If review exposes a necessary smaller +increment, split it with unchanged ID coverage and record the split before proceeding. + +For **every** increment: implement with meaningful regressions, run required author +checks, commit; perform a new independent complete cumulative review, verify/fix +findings and commit; perform a second new independent complete cumulative review. +A material fix invalidates preceding clean rounds: obtain two new clean complete +rounds after the final fix. Only then advance automatically. Review agent models +and effort follow criticality; neither this preflight nor an old agent round is +implementation acceptance. Final publication does not infer merge or execution at +an untested successor revision. + +#### Existing later-wave ownership and approval gate + +The renderer restructuring decision D18 and character-wrap #61 retain Wave 5a +ownership. #61 needs a formal linked deferral record when this proposed disposition +is approved; it is not permission to silently relax I's final acceptance. Picker +position #60 and full I sign-off remain Wave 5a. Full M/N tools/child lifecycle, +CR-70/93, CR-95 continuation, exact-capacity residuals, Wave 4b retention/release +and the remaining whole-phase criteria retain their current canonical owners. + +**Gate:** maintainer approval of ADR-0110–0113 and this concrete plan, plus archive +method selection. No dependent code is authorized merely by a recommendation, +preflight test pass or a clean proposal review. Approval is requested because the +user explicitly requires it for new ADRs and implement-task step 3 requires it for +public API/Accepted-decision/secrets changes. No product step is complete yet. From 6f47c13bada408b49e5e816b278404037970b605 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 15 Oct 2026 21:53:48 +0300 Subject: [PATCH 003/222] docs(docs): complete wave 3 preflight item coverage Assign the independently verified omitted UTF-8 resolver correction to the first increment with distinguishing multibyte and overflow controls. Clarify existing structural evidence, process exit dependencies and the required core coverage promotion before whole-wave closure. Record both complete finding-bearing proposal reviews without claiming maintainer approval or implementation acceptance. Fresh review follows. Refs: Wave 3, ADR-0110, ADR-0111, ADR-0112, ADR-0113 Co-Authored-By: Codex --- .../phase-2.5.5-hardening-and-remediation.md | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index e2dcfc60..6d4d7d06 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -3606,7 +3606,7 @@ preflight agents are not either of the two fresh implementation review rounds. | #77/G48 | W8 closes the original stream-consumption blocker. Preserve ADR-0087's actual contract and approved exact-capacity residuals. | | #211, G16 | ADR-0083 already validates input defaults/enums; W7 already enforces compaction-role/structural session rows on read and write. No legacy reader tightening. | | #89/parallel half of #212 | Full WorkflowSchema rejects duplicate parallel members; exported leaf/union schema still needs consistent enforcement. Centralize without duplicate diagnostics. | -| G22, #80/#81/#83 | Wave 2 context namespace refusal and W7/Wave 2 structural export/caveat/slug work are complete. The new context-to-JavaScript leak is distinct. | +| G22, #80/#81/#83/#115 | Wave 2 context namespace refusal and W7/Wave 2 structural persistence/export/caveat/slug work are complete. The new context-to-JavaScript leak is distinct. | | #78, #280 DB half | Checkpoint comments already name the tiered journal; DbOpenError already has fixed path-free messages. Remaining CLI open/error wrapping lands last. | | #21 MCP half, #42 rejection half | W4 already reaps MCP and #228 already installs the recoverable rejection net. Remaining active-session abort and fatal/EPIPE/process-group work remain. | | G36/G37/G46, #20 | Direct verbose and selector tests exist. Idempotent gate JSON routing exists but needs direct regressions. Revalidate, do not claim absent code. | @@ -3654,14 +3654,14 @@ No credential values or request captures belong in tracked evidence. | Step | Scope, finding IDs and principal verification | |---|---| -| 1 | **Serialized multi-file queue first:** shared close handling #22 (four named siblings plus newer owned journal sites) → safe source labels #4/G11 → exhaustive session/picker/run switches #300/#301/G3 and actual reasoning rendering. Preserve primary outcomes and host-safe-close ownership; native error and mounted output regressions. | +| 1 | **Serialized multi-file queue first:** shared close handling #22 (four named siblings plus newer owned journal sites) → safe source labels #4/G11 plus #3's UTF-8 source admission parity → exhaustive session/picker/run switches #300/#301/G3 and actual reasoning rendering. Preserve primary outcomes and host-safe-close ownership. For #3, use the sibling 4× byte precheck plus authoritative parser character bound; test a dense multibyte source above the old byte cap but below the character cap, ASCII character overflow and true byte overflow. Add native error and mounted output regressions. | | 2 | **Shared admission and diagnostics:** #89/#90/#212/G18/#210/G17, certify #211/G16; shared parser reason helper #85/#86 and config numeric bounds #32. Approved status/duplicate matrices, direct leaf/full parse tests and no value-bearing diagnostics. | | 3 | **Secret provenance:** ADR-0113/#92, fresh/resume/preparation/condition/transform/merge paths, source-to-event negative controls and clean trusted-resolution positives. Dedicated security sitting, no future mapping implementation. | | 4 | **Error/envelope substrate:** #213/#219, actual migrated-class inventory and cross-bundle structural guards. Preserve exported names/discriminants, constructor/cause behaviour and event payloads. Convert only supported error classes, not vendor errors or plain seam unions. | | 5 | **Tools and engine tails:** #93/#95/#96/#98/#97/#214/#221/#258/#259/#308, #94/#82/#87/#76/G9/#79/#274/#275. Bounds before effects, model-visible semantics, existing ordinal helper, path-local cycle guards, cancellation race and deterministic plan equivalence; certify #75/#78/#216 instead of rewriting them. | -| 6 | **Process head:** #40/#41/#42/#224/#269 under ADR-0110. Fatal/EPIPE/owned process and DB/MCP lifetimes; preserve recoverable #228 keepalive and W4 signal contracts. Native child/grandchild, broken-sink and receipt-join controls. | +| 6 | **Process head:** #40/#41/#42/#224/#269/G35 under ADR-0110. Land internal exit 9 with its fatal/EPIPE consumers; preserve genuine workflow and specific safety outcomes. Fatal/EPIPE/owned process and DB/MCP lifetimes retain recoverable #228 keepalive and W4 signal contracts. Native child/grandchild, broken-sink and receipt-join controls. | | 7 | **Lazy startup and release substrate:** #43/#44/#46/#271 under ADR-0111. Descriptor/runtime graph, early meta routing, catalog timing, finite chunks, exact pack closure, minimum-Node/platform and private archive tests. Actual archive configuration status is separate from fixture proof. | -| 8 | **CLI contracts:** #45/#264/#265/#268/G35/#13/#17/G15/#18/#310/#309/#19 under ADR-0110. Category-aware flags, explicit help JSON, pricing/chat aliases, human coalescing and deletion consent; byte-preserving JSON/old-alias integration. | +| 8 | **CLI contracts:** #45/#264/#265/#268/#13/#17/G15/#18/#310/#309/#19 under ADR-0110, consuming Step 6's G35 exit distinction. Category-aware flags, explicit help JSON, pricing/chat aliases, human coalescing and deletion consent; byte-preserving JSON/old-alias integration. | | 9 | **Chat normalization and abort:** #236/#237/#242/#307 → #218 → #21/#23. Canonical token/money source with approved additive LLM export, preserved two-decimal cap formatting and real immediate abort while a turn is blocked. Audit Home's imports. | | 10 | **Chat decomposition:** #220/#244 after preceding chat fixes. Split lifecycle/drivers, line handler, printers and reseat/clear ownership. Preserve compatibility entry points, W7/W8 receipts, unknown-usage, full-screen bounds, disclosure activation and cancellation. | | 11 | **Chat domain policy:** G30/ADR-0112 after decomposition, all fresh/resume/Home/agent/rebuild paths. Real host-policy dispatch and no-authority/SSRF/approval controls; separate egress/config-trust security sitting. | @@ -3670,7 +3670,7 @@ No credential values or request captures belong in tracked evidence. | 14 | **Onboarding and overlays:** #49/#52/#51/#53/#55/G5/G52/G41/G53 and #34 under ADR-0112. Real next-model binding, recoverable persistence, explicit acknowledgement, current keyless nudge, hidden-approval safety, color-off mounts and native symlink preservation; keychain/config security sitting. | | 15 | **Remaining CLI/hygiene:** #24/#26/G43/G2/#12/#54/G14/#11/#47/#48/#240/#241/#311/G54/#303/#304; certify #20/G36/G46. One-shot coverage-aware cost/banner, actual-host doctor, typed faults, no silent missing-dist skip, guard misuse, attachment estimate and compiler checks. #239 remains the approved documented compatibility surface. | | 16 | **openLocalDb LAST:** #278/#280 across every moved caller. Central fixed safe error wrapping, attempt-all secondary cleanup, preserved causes, privacy/checkpoint notices and exact writer lifetimes. Actual command-family failure probes; no duplicate wrapper left behind. | -| 17 | **Whole-wave acceptance and publication:** every item disposition; A/E/G and M2.5.5-3 evidence; ordinary/enforced coverage and required CI/build/fences/smokes/causal/platform checks at attributed source; evaluate/promote core coverage floor as testing.md requires after Wave 3 coverage work. Two new whole-wave cumulative reviews, canonical doc/ADR landings and PR. I/whole phases/later product waves stay open. | +| 17 | **Whole-wave acceptance and publication:** every item disposition; A/E/G and M2.5.5-3 evidence; ordinary/enforced coverage and required CI/build/fences/smokes/causal/platform checks at attributed source; measure and promote core's ≥90% line/branch floor to required CI as testing.md requires after Wave 3 coverage work, fixing meaningful missing coverage before closure. Two new whole-wave cumulative reviews, canonical doc/ADR landings and PR. I/whole phases/later product waves stay open. | Steps are dependency-safe increments, not permission to parallel-edit shared hot files. Within each row, arrows remain strict. Step 1 completes before all other @@ -3701,3 +3701,16 @@ method selection. No dependent code is authorized merely by a recommendation, preflight test pass or a clean proposal review. Approval is requested because the user explicitly requires it for new ADRs and implement-task step 3 requires it for public API/Accepted-decision/secrets changes. No product step is complete yet. + +#### Proposal review disposition — 2026-10-15 + +Two new independent complete cumulative round-1 reviews of all seven proposal paths +at `bd1da978` independently verify one Medium: the still-open E #3 UTF-8 resolver +byte-cap correction was not assigned. Step 1 now owns it with multibyte, character- +overflow and byte-overflow controls; it is neither deferred nor silently closed. +The parent also makes #115's existing Wave 2 certification explicit, places G35's +exit definition with its first process consumers, and states the required core +coverage promotion without an optional close-out interpretation. +The four ADR proposals have no additional verified round-1 finding. This correction +requires fresh complete cumulative proposal review; it is not maintainer approval, +implementation acceptance or a clean implementation round. From 399670750751bda96a9d2f6148abcdc9e9acf8e7 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 15 Oct 2026 22:00:56 +0300 Subject: [PATCH 004/222] docs(docs): record encrypted wave 3 archive selection Record the maintainer choice of public-recipient encrypted CI archival. Propose vetted isolated GnuPG tooling, pinned recipient identity and per-version ciphertext retention beyond workflow artifact expiry. Keep overall ADR and execution-plan approval pending. No production key configuration, archive upload or product implementation is inferred. Fresh cumulative proposal reviews follow this source update. Refs: ADR-0111, Wave 3 Co-Authored-By: Codex --- ...-operations-use-a-lazy-packaged-runtime.md | 35 +++++++++++++------ .../phase-2.5.5-hardening-and-remediation.md | 15 ++++++-- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md index a72d5f8e..94b9100f 100644 --- a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md +++ b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md @@ -57,8 +57,14 @@ version, actual toolchain and every JavaScript/map digest. Published JavaScript no sourceMappingURL, and npm contains neither maps, metafiles, source trees nor the private manifest. A pack inspection is required, not just a files-list assumption. -The proposed CI archive is authenticated encrypted material for a maintainer-owned -public recipient key, using vetted tooling rather than in-house cryptography. +The maintainer selected public-recipient encrypted CI archival on 2026-10-15; +this method selection does not approve the remaining ADR or implementation plan. +Use vetted [GnuPG recipient encryption](https://gnupg.org/documentation/manuals/gnupg/Operational-GPG-Commands.html) +as release tooling, not a CLI runtime dependency or in-house cryptographic protocol. +Use an isolated public-only keyring and an explicitly pinned maintainer-confirmed +recipient fingerprint, without ambient keyring/config or automatic key discovery. +An exact-source archive receipt establishes build provenance; encryption alone +does not authenticate who created an archive. The private decryption key never enters this repository or CI. Public artifact storage is permitted only for ciphertext; fixed diagnostics never print map contents or key material. The recipient and retention destination must be explicitly @@ -66,14 +72,23 @@ configured and verified before a release can claim private archival. A local owner-only archive is available for development and custody tests; it does not claim that CI release archival happened. -The maintainer is being asked to choose encrypted CI archival, an existing private -archive, or local-only custody. Until that choice and this ADR are approved, no -archive backend is considered settled. Implementation may use synthetic recipient -keys for offline encryption/decryption controls, never manufacture a production -recipient or upload plaintext to complete a gate. The first actual release belongs -to Wave 4b; it must fail closed if its approved private archival prerequisite is -absent. Wave 3 must implement and test the complete selected archival path and -record its real configuration status without presenting a fixture as production custody. +CI stages ciphertext as its workflow artifact. Because [workflow artifacts expire](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/remove-workflow-artifacts), +actual publication also retains that exact ciphertext as a per-version +[Release asset](https://docs.github.com/en/rest/releases/assets), verified by digest +before npm publication. Retain it for every supported release; this wave adds no +automatic deletion. Never replace an existing version's different ciphertext +silently. Public storage remains ciphertext-only; this does not make the repository +or artifact access private. Release uploads use only the existing trusted main/tag +path with least required permissions, never a PR/fork or help invocation. + +Implementation may use synthetic recipient keys for offline encryption/decryption +controls, never manufacture a production recipient or upload plaintext to complete +a gate. Do not publish proprietary maps encrypted to a publicly available fixture +private key. Production recipient configuration and actual storage remain separate +release prerequisites; no key material is requested for this proposal review. +The first actual release belongs to Wave 4b and fails closed if private archival +is absent. Wave 3 implements/tests the complete selected path and records its real +configuration status without presenting a fixture as production custody. Considered retaining a single file and deferring only Home: rejected because external imports remain eager through the rest of the graph. Considered publishing diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 6d4d7d06..f2deffe2 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -3633,7 +3633,7 @@ changes a currently Accepted ADR or a canonical reference before approval. | Decision | Recommended choice and compatibility boundary | |---|---| | ADR-0110: D19/D20/D22, #13/#19/#309, G15/G35 | [Explicit CLI compatibility](../../decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md): retain quiet+verbose conflict; exact DEBUG=1 alias; preserve JSON/safety data; internal exit 9; versioned explicit JSON help; alias-preserving pricing/chat syntax; key-delete consent; bounded human log coalescing. Genuine run outcomes and exits 3–8/signals keep their contracts. D22's old two-exception shorthand must be reconciled to this explicit enumerated scope. | -| ADR-0111: #43/#44/#46/#271 | [Lazy packaged runtime](../../decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md): lightweight descriptor-only meta graph; engine-inlined finite chunks, complete closure guard and packed platform controls; exact-byte private sourcemaps. Archive method is awaiting the maintainer's choice; public plaintext artifacts are prohibited. | +| ADR-0111: #43/#44/#46/#271 | [Lazy packaged runtime](../../decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md): lightweight descriptor-only meta graph; engine-inlined finite chunks, complete closure guard and packed platform controls; exact-byte private sourcemaps. The maintainer selected public-recipient encrypted CI archival; GnuPG, pinned recipient and per-version ciphertext retention are proposed mechanics. Public plaintext artifacts are prohibited. | | ADR-0112: G30/#34/#49/#52/#55 | [Operator-owned chat policy and preferences](../../decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md): global-only domain authority, exact DNS/deny-all/approval/SSRF floors; explicit unavailable-keychain acknowledgement with current-state recheck; truthful model-write recovery; refuse intentional config symlink replacement. | | ADR-0113: #92 and reproduced leak | [Declared-source context provenance](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md): reuse existing linear taint graph, preserve trusted raw resolution, mask tainted sandbox/public sinks and make low-level provenance mandatory. Record unsupported future tool mappings honestly rather than fabricate a producer. | | D18, D26/#125, #311 | Keep chat-ink restructuring with Wave 5a; keep cost chat-only with both real surfaces tested; preserve unknown-command non-echo and add static help. These are explicit dispositions, not new features or undisclosed deferrals. | @@ -3696,8 +3696,10 @@ position #60 and full I sign-off remain Wave 5a. Full M/N tools/child lifecycle, CR-70/93, CR-95 continuation, exact-capacity residuals, Wave 4b retention/release and the remaining whole-phase criteria retain their current canonical owners. -**Gate:** maintainer approval of ADR-0110–0113 and this concrete plan, plus archive -method selection. No dependent code is authorized merely by a recommendation, +**Gate:** maintainer approval of ADR-0110–0113 and this concrete plan. Archive-method +selection is complete; production recipient/storage setup is a separate actual-release +prerequisite, not an approval or credential needed to review these proposals. +No dependent code is authorized merely by a recommendation, preflight test pass or a clean proposal review. Approval is requested because the user explicitly requires it for new ADRs and implement-task step 3 requires it for public API/Accepted-decision/secrets changes. No product step is complete yet. @@ -3714,3 +3716,10 @@ coverage promotion without an optional close-out interpretation. The four ADR proposals have no additional verified round-1 finding. This correction requires fresh complete cumulative proposal review; it is not maintainer approval, implementation acceptance or a clean implementation round. + +The fresh complete cumulative round-2 review at `6f47c13b` is clean across all seven +paths and all four ADRs. The maintainer then selected public-recipient encrypted CI +archival as the method only. ADR-0111 now specifies isolated GnuPG tooling, pinned +recipient identity and exact ciphertext retention beyond expiring workflow artifacts; +no plaintext map, production key setup, upload, ADR acceptance or code implementation +is inferred. Fresh complete cumulative reviews of this final proposal are required. From 20af06170dd792bc6f3582ac0e77c5aae87445d0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 15 Oct 2026 22:06:49 +0300 Subject: [PATCH 005/222] docs(docs): record reviewed wave 3 proposal readiness Record two new complete independent clean cumulative proposal reviews at 39967075, including the selected encrypted CI archive method. The receipt changes no reviewed decision or proposed implementation step. Keep all four ADRs Proposed and dependent code gated on maintainer approval. Existing product acceptance, tests and review scopes are not expanded; detailed working reports remain Git-ignored. Validation: relative links/anchors and diff check pass; four proposed ADR files equal the reviewed bytes; pre-existing ADRs are unchanged. Refs: ADR-0110, ADR-0111, ADR-0112, ADR-0113, Wave 3 Co-Authored-By: Codex --- docs/roadmap/current.md | 5 ++-- .../phase-2.5.5-hardening-and-remediation.md | 28 ++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 3f7812c9..ca6a635c 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1106,10 +1106,11 @@ an ESLint selector rather than a list. ### Wave 3 — Clear the ground -**Preflight in progress — 2026-10-15.** The original queue below is reconciled against +**Preflight reviewed; maintainer approval pending — 2026-10-15.** The original queue below is reconciled against current source in the [Wave 3 preflight and proposed execution plan](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-preflight-and-proposed-execution-plan--2026-10-15). It records already-satisfied items, concrete compatibility/security decisions and -seventeen proposed increments. ADR-0110–0113 are Proposed; implementation has not +seventeen proposed increments. Two new independent complete cumulative final +proposal reviews at `39967075` are clean. ADR-0110–0113 are Proposed; implementation has not started and dependent code awaits maintainer approval. Historical finding descriptions are not new assertions about current source. The existing Wave 5a renderer/word-wrap ownership is preserved, not newly approved by this preflight. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index f2deffe2..286c5445 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -3722,4 +3722,30 @@ paths and all four ADRs. The maintainer then selected public-recipient encrypted archival as the method only. ADR-0111 now specifies isolated GnuPG tooling, pinned recipient identity and exact ciphertext retention beyond expiring workflow artifacts; no plaintext map, production key setup, upload, ADR acceptance or code implementation -is inferred. Fresh complete cumulative reviews of this final proposal are required. +is inferred. At that source checkpoint, fresh complete cumulative reviews of the +final proposal were still required; their outcome follows. + +#### Final proposal readiness — 2026-10-15 + +Two NEW independent complete cumulative final reviews at `39967075` cover all +seven proposal paths and all four ADRs, including the selected encrypted archive +mechanism. Authority/security (`gpt-6.1-sol`, high) and compatibility/execution +(`gpt-6-sol`, high) each report zero verified findings. The parent reads both full +reports and verifies all A/E/G/I IDs have explicit owners/dispositions, all seventeen +steps remain ordered, and the final proposal preserves the corrected #3 controls. +These are proposal-readiness reviews, not product implementation or whole-wave acceptance. + +Document validation resolves 2,882 relative links/anchors across the 56 Markdown +files changed since the Wave 2 base; exact Wave 3 scope is seven documentation paths. +All 109 pre-existing ADR files are byte-identical to baseline `702cdc9c`; four new +ADRs remain Proposed. Diff whitespace passes. The existing 319 focused preflight +test passes and the synthetic security reproduction remain attributed to their +original unchanged-source runs; no new CI/platform/coverage/release execution is inferred. + +This receipt/current-status update changes no reviewed ADR mechanism or proposed +implementation step. **Document preflight is ready for maintainer review. Approval +of ADR-0110–0113 and the concrete plan is the only current implementation gate.** +The archive-method choice is complete; production recipient/retention configuration +belongs to actual release preparation. No provider request, key access, billing, +archive upload or code implementation has started. Detailed reports remain local +and Git-ignored, and no proposal round substitutes for a future implementation review. From 864008114534e0f505f9b12e2733cfacf59f9593 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 15 Oct 2026 23:00:35 +0300 Subject: [PATCH 006/222] docs(docs): revise wave 3 proposals against source review Correct the token replay premise and define executable process, context, config and packaging contracts before maintainer approval. Split the source-dependent plan into nineteen reviewed increments. Refs: ADR-0110, ADR-0111, ADR-0112, ADR-0113 Co-Authored-By: Codex --- ...d-discovery-have-explicit-compatibility.md | 245 +++++++++--------- ...-operations-use-a-lazy-packaged-runtime.md | 245 ++++++++++-------- ...and-onboarding-state-are-operator-owned.md | 230 ++++++++-------- ...ontext-keeps-provenance-at-public-sinks.md | 244 +++++++++-------- docs/reference/cli/README.md | 1 + docs/reference/cli/commands.md | 119 +++++++++ docs/reference/cli/packaging.md | 137 ++++++++++ docs/reference/contracts/config-spec.md | 65 +++++ docs/reference/shared-core/agent-runner.md | 21 ++ .../shared-core/expression-sandbox-spec.md | 56 ++++ docs/reference/shared-core/tool-registry.md | 27 ++ docs/roadmap/current.md | 30 ++- .../phase-2.5.5-hardening-and-remediation.md | 85 ++++-- 13 files changed, 1024 insertions(+), 481 deletions(-) create mode 100644 docs/reference/cli/packaging.md diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index c6e93621..f7cfa59b 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -2,145 +2,150 @@ - **Status**: Proposed - **Date**: 2026-10-15 -- **Related**: [ADR-0047](0047-cli-framework-commander-ink-clack.md), [ADR-0049](0049-cli-machine-output-contract.md), [ADR-0056](0056-cli-in-app-slash-command-system-and-manifest.md), [ADR-0088](0088-the-mcp-boundary-is-hostile.md), [ADR-0103](0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) +- **Supersedes (partial)**: [ADR-0049](0049-cli-machine-output-contract.md) §6, for explicit help with JSON +- **Related**: [ADR-0047](0047-cli-framework-commander-ink-clack.md), [ADR-0056](0056-cli-in-app-slash-command-system-and-manifest.md), [ADR-0088](0088-the-mcp-boundary-is-hostile.md), [ADR-0103](0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md), [ADR-0111](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) ## Context -Wave 3 contains maintainer decisions D19/D20/D22 and findings #13/#18/#19/#45, -#264/#265/#268/#309/#310 and G15/G35. They change public CLI behaviour and need -one compatibility decision before implementation. ADR-0049 says help remains -human-readable under `--json`; ADR-0056 describes a machine-readable command -manifest. Both cannot govern the same explicit help invocation. +Wave 3 D19/D20/D22, #13/#17/#18/#19/#40/#41/#42/#45/#224/#264/#265/#268/#269/ +#309/#310 and G35 require explicit CLI compatibility. Accepted ADR-0056 already +promises machine-readable help; ADR-0049 §6 prescribes human help under JSON. +This proposal realizes the former and partially replaces the latter. -At the preflight baseline `702cdc9c`, quiet is largely ineffective, debug and -verbose use different predicates, internal CLI faults share exit 1 with failed -workflows, and human log replay prints individual token events. The existing -background-rejection net already reports and keeps the process alive; its absence -must not be claimed as a new defect. Uncaught exceptions need a different policy. +At baseline `702cdc9c`, verbosity has no effective general consumer and debug +predicates differ. Unexpected CLI failures share exit 1 with known operational +failures. Crucially, `CliErrorCode.internal` also names typed history refusals and +unacknowledged departures: remapping it wholesale would change accepted semantics. +Commander echoes unknown input although the later dispatcher does not. The +recoverable background-rejection net already exists. -This proposal requires maintainer approval. It introduces no engine ErrorCode, -provider seam field, runtime dependency or new tool. Exact option/output shapes -belong in [CLI commands](../reference/cli/commands.md), with the interactive -behaviour in [chat-session.md](../reference/cli/chat-session.md). +G15's token-firehose premise is refuted: token/reasoning events use only the live +bus, shipping persistence excludes them, and `logs` reads durable history. +Preserve replay and add an actual-engine/store regression. Invented persisted +token fixtures cannot demonstrate a shipping coalescing defect. + +The strict, alias-free model command manifest lacks CLI flag spellings, +positionals, repeats, globals and group paths. Discovery needs an enriched +descriptor authority and separate compatible projections, not that inadequate DTO. + +These choices remain Proposed. Exact shapes, categories and exits belong in +[CLI commands](../reference/cli/commands.md#proposed-wave-3-cli-compatibility). +No engine ErrorCode or runtime dependency is added. ## Decision -We will preserve machine data and existing lifecycle outcomes while making -diagnostics, discovery and the enumerated remediation aliases explicit. - -### Diagnostic policy and failure ownership - -Keep `--quiet` and `--verbose` mutually exclusive: specifying both remains an -invalid invocation. Either explicit flag overrides `RELAVIUM_DEBUG=1`; only the -exact environment value `1` is a verbose alias. Default verbosity is normal. -`--json` controls representation independently of verbosity. - -Quiet suppresses decoration, banners, progress churn and optional informational -notices. It preserves requested command results, assistant answers, final outcomes -and cost/usage qualifications, approvals, warnings, errors, denials and required -durability/effect disclosures. Implement categories at their owners; never filter -all stdout/stderr writes. JSON preserves every contractual data record and safety -diagnostic and never contains human stack dumps. Verbose diagnostics are bounded, -redacted, terminal-safe and exclude arbitrary cause inspection, raw argv, request -bodies and credential values. Generic failures point to the supported verbose flag. - -Reserve additive exit **9** for an unclassified CLI internal failure. Genuine -workflow failure/cancellation remains 1; invocation faults remain 2; existing -0 and 3–8 and surface-specific signal outcomes retain their meanings. Typed -history/ownership/receipt refusals must not be flattened into 9. A final joined -money/effect/durability/ownership disposition takes precedence over a generic -internal failure. - -Preserve the recoverable background-rejection net's keepalive policy: report a -bounded safe diagnostic, remember the failure and upgrade only an otherwise clean -exit 0 to 9. Preserve an existing nonzero lifecycle outcome. An uncaught exception -or top-level rejected invocation instead stops new work, reports once and requests -bounded cooperative owner cleanup. The fatal shutdown budget is three seconds; -after it, only owned native resources may be synchronously reaped before exit. -Normal/recoverable cleanup still joins ADR-0103 owners before closing their stores. -The process `exit` callback cannot await work and must never be described as an -asynchronous durability acknowledgement. No crash handler fabricates a run terminal -or retries a paid request. Repeated faults and a broken diagnostic stream cannot -recursively enter the handler. - -A closed output pipe is a typed output failure, with bounded cancellation/cleanup, -not a raw Node stack. Write a safe diagnostic only to a still-usable independent -sink; an EPIPE sink cannot acknowledge its own error. Preserve more specific final -safety dispositions; otherwise use exit 9. Do not assert successful terminal delivery -or durable publication from a failed write. +### Diagnostics and ownership + +Keep quiet and verbose mutually exclusive; explicit flags override the exact +debug alias defined in the canonical proposal. Narrowing the old defined-variable +debug predicate is deliberate. Quiet preserves requested data and safety output. +Verbose adds bounded diagnostic metadata and validated stack-frame locations, +never arbitrary error messages, cause trees or raw stack headers. Pattern +redaction is defence in depth, not proof that arbitrary text is secret-free. +JSON diagnostics, including #269's last resort, remain structured stderr records. + +Reserve exit 9 for unexpected CLI infrastructure failures on every surface, +independently of the existing broad `internal` code. Known history, departure, +ownership, receipt and operational refusals retain their exit/remedy. The canonical +surface table explicitly covers Home, chat, one-shot agents, workflow/resume and +management/meta commands. Genuine run/turn outcomes and deliberate session end +remain distinct; an unknown CLI failure cannot become Home success or chat end. + +Create one lightweight invocation-owned shutdown registry in the CLI process layer, +with no engine/native/UI service imports. Owners register stop, join, final-health +and owned-native-reap callbacks before admitting work; unregister only after actual +settlement. Normal, fatal and output-failure paths share those registrations. +ADR-0111's chunks retain one instance. Ambient PID scanning is not ownership proof. + +Install early diagnostic flag extraction and the registry before dynamic runtime +imports. Recoverable background rejection keeps the process alive, reports safely, +and upgrades only an otherwise clean success; existing nonzero outcomes remain. +Fatal exception/top-level rejection stops admission and exits without resuming +business logic. Best-effort cooperative joining has a three-second **fatal process +termination** budget; afterward only explicitly owned native resources may be +synchronously reaped. This is not an ADR-0103 receipt deadline, acknowledgement or +safe-close permission. An exit callback cannot await; re-entry and broken sinks +are guarded. Normal/recoverable cleanup still joins actual owners. + +Known final money/effect/durability/ownership health takes precedence on **return +and throw** paths. Unavailable health remains uncertain, with inspection before +retry; no terminal or acknowledgement is fabricated. Native teardown uses owned +POSIX groups and a separately tested Windows child/tree mechanism, never a +negative-PID claim for Windows or foreign processes. + +EPIPE is a classified output-unavailable operational failure, not an unclassified +exception. Stop/join producers, preserve stronger safety exits and use at most one +fixed diagnostic on an independent usable sink. A failed sink cannot acknowledge +itself. No automatic repetition of paid/effectful work follows an output or fatal +failure; the canonical exit-9 remedy explicitly says this. ### Discovery and compatibility -Explicit root `--help --json` returns a versioned command-manifest JSON record -(`type: command_manifest`, `version: 1`, `commands` containing the canonical list); -command-specific help returns the matching command/subtree in the same format. -Stable existing executor IDs remain available; aliases resolve to the same executor. -Both are exit-0 meta-operations and start no -services. This replaces **only** ADR-0049 §6's human-help exception for explicit -help plus JSON. `--version --json` and bare `--json` retain their existing human -meta-operation behaviour. Run/session JSON streams retain their event shapes and -sequence ordering. Invalid command/option grammar still fails as an invocation. - -Make `chat resume`, `chat list` and `chat export` canonical while retaining working -flat aliases. Make `models pricing --input-price` / `--output-price` canonical, -retaining the old pricing spellings as deprecated aliases. Specifying both spellings -for one quantity refuses even when values match, rather than silently choosing one. -Successful deprecated-alias use has one bounded stderr notice, structured in JSON -mode, never an extra stdout record. Update the one command manifest, -dispatch and help together. Do not rename run inputs, gate payloads or reserved -agent input semantics as collateral work. - -`provider remove-key` requires explicit confirmation on an interactive terminal or -`--force` for automation. A refusal/cancel performs no key deletion; JSON/CI cannot -prompt. Deliberately declining confirmation exits 0; a noninteractive invocation -without force exits 2. Actual process termination retains its signal contract. -Human `logs` coalesces consecutive token/reasoning fragments by run, node -and channel, flushing at boundaries and EOF with bounded buffering. JSON logs -retain the actual stored event stream. Unknown-command diagnostics keep arbitrary -command strings private and add a static help hint rather than echoing raw input. - -D22 means no unrecorded breaking change. This decision explicitly authorizes the -listed aliases, deletion consent, diagnostic behaviour, meta-help format and -internal exit distinction. It does not authorize unrelated command or event -changes. Landing must reconcile the phase's older two-exception wording and its -blanket “no new ADR” claim, preserving historical ADR bodies through dated notes. - -Considered preserving all current behaviour and correcting only documentation: -rejected because quiet, internal-fault distinction and discovery remain misleading. -Considered making verbose win over quiet: rejected because it changes the existing -conflict rule unnecessarily. Considered suppressing safety output in quiet or -coalescing JSON events: rejected because either hides contractual evidence. -Considered making every unhandled rejection fatal: rejected because it reverses -the accepted recoverable persister backstop. Fatal exceptions are distinct. +One framework-free descriptor registry owns stable executor IDs, canonical token +paths, explicit aliases, argument bindings and globals. Derive Commander, help and +the existing model manifest from it. Keep the legacy strict model-manifest shape; +new JSON help describes the actual grammar in a separate versioned projection. +Groups need no executor. Explicitly register nested chat paths and flat aliases; +Commander aliases alone cannot span them. Two-way grammar drift tests are required. + +Explicit help plus JSON realizes ADR-0056 and replaces only ADR-0049's corresponding +exception. Root/subtree projection starts no services. Version plus JSON and bare +JSON remain human meta-operations. Invalid grammar refuses. JSON-help integration +follows the descriptor and lazy-graph increments, not an earlier unimplemented step. + +Make noun-verb chat paths and unambiguous pricing flags canonical, retaining old +aliases and one structured stderr deprecation notice in JSON mode. Conflicting +pricing spellings refuse even if equal. #18/#310's remaining command-scoped input +meanings are intentionally preserved/documented: workflow inputs, reserved agent +input and gate payload are not collateral renames. Unknown command/option errors +use static hints without arbitrary tokens or Commander suggestions; this is a +parser-path correction, not merely preservation of dispatcher behaviour. + +Key deletion requires interactive consent or explicit automation force. Canonical +eligibility/decline/interruption rules and every existing deletion example must +agree. G15 closes as a refuted current defect with preservation evidence. + +At **acceptance**, append ADR-0049/0056 forward notes and ADR-0088/0103 refinements +before dependent code. Reconcile D19's old precedence ladder, D22's two-exception +shorthand, E's “Required ADR: none new” and phase exit criterion 4's blanket +machine-contract prohibition to this enumerated scope. Accepted historical bodies +stay intact. Proposed reference sections are explicitly unshipped. + +Considered verbose winning over quiet: rejected to preserve conflict refusal. +Considered remapping every internal error: rejected because known refusals use it. +Considered sanitized raw stacks: rejected because pattern matching cannot prove +confidentiality. Considered coalescing invented token rows: rejected as a false +premise. Considered every rejection fatal: rejected because the persister +backstop's accepted keepalive policy is distinct from process exceptions. ### Acceptance and landing -1. A flags × environment × output-mode matrix proves category-based suppression, - explicit precedence and preserved JSON/safety data; planted secret/control text - never enters diagnostics, including verbose and fatal paths. -2. Native child tests cover closed stdout/stderr, synchronous startup throws, - background rejection, uncaught exception, repeated faults and owned subprocess - teardown. Positive controls preserve genuine terminals and 3–8/signal outcomes. -3. Commander-to-dispatch tests prove help projection, aliases, conflict refusal, - global help and deletion consent with zero unintended service/key effects. -4. Human log tests prove boundary-aware coalescing and bounded EOF flushing; JSON - replay remains byte-equivalent for the same event sequence. -5. Removing each correction breaks its regression. Required CI, package/build - checks and two new independent cumulative review rounds pass before acceptance. - Add dated forward notes to ADR-0049/0056/0088/0103 at implementation landing; - update their canonical CLI references and the Wave 3 register. +1. Inventory production output owners against the canonical category matrix. + Flags × environment × output-mode tests exercise those owners. Preserve data + and warnings; arbitrary canaries in messages/causes/stack headers never render. +2. Native children test early import failure, background rejection, fatal/repeated + fault, both broken sinks, owned children/grandchildren and Windows teardown. + Joined health wins on return and throw; timeout never certifies safe store close. +3. Test every surface's exit classification, typed history/departure refusals and + existing 0–8/signal outcomes. Distinguish Node's pre-bootstrap exits. +4. Commander-to-dispatch and packed tests cover root/subtree discovery, globals, + repeated/positional args, aliases, conflicts, non-echoing refusal and deletion + consent with zero unintended key/service effects. +5. Actual engine/store/log tests pin the G15 disposition and unchanged JSON replay; + label preservation controls honestly. Actual corrections each need a causal + regression and two new independent complete cumulative clean reviews. Land + canonical references and the Wave 3 register. ## Consequences ### Positive -- Scripts can distinguish a failed workflow from a broken CLI without changing - actual run events, receipt authority or the existing safety exits. -- Quiet and verbose have predictable limits, and discovery derives from one manifest. +- Scripts distinguish unexpected CLI faults from outcomes across surfaces. +- Discovery describes real grammar; diagnostics preserve data, consent and safety. ### Negative -- Explicit JSON help, deletion consent and exit 9 alter public behaviour. Their - narrowly recorded scope, old aliases and permanent compatibility tests mitigate it. -- Fatal shutdown cannot promise durable acknowledgement after its deadline. The - diagnostic and final outcome must retain that uncertainty rather than claim cleanup. +- JSON help, consent, debug predicate and failure classification deliberately alter + public behaviour. Explicit compatibility tables and aliases bound the change. +- Fatal cleanup cannot promise crash-safe receipts. Inspect before repeating paid + or effectful work; a failed cleanup must not be reported acknowledged. diff --git a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md index 94b9100f..1a554334 100644 --- a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md +++ b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md @@ -2,129 +2,156 @@ - **Status**: Proposed - **Date**: 2026-10-15 -- **Related**: [ADR-0047](0047-cli-framework-commander-ink-clack.md), [ADR-0051](0051-cli-distribution-thin-bundle-private-engine.md), [ADR-0056](0056-cli-in-app-slash-command-system-and-manifest.md), [ADR-0067](0067-node-supported-floor-22-reaffirm-better-sqlite3.md), [ADR-0071](0071-models-dev-as-the-model-metadata-source.md), [ADR-0110](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) +- **Supersedes (partial)**: [ADR-0047](0047-cli-framework-commander-ink-clack.md) and [ADR-0051](0051-cli-distribution-thin-bundle-private-engine.md), single-output packaging constraint +- **Related**: [ADR-0056](0056-cli-in-app-slash-command-system-and-manifest.md), [ADR-0067](0067-node-supported-floor-22-reaffirm-better-sqlite3.md), [ADR-0071](0071-models-dev-as-the-model-metadata-source.md), [ADR-0072](0072-model-metadata-in-the-db-behind-a-generated-offline-floor.md), [ADR-0103](0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md), [ADR-0109](0109-native-media-close-ownership-is-explicit-iterator-provenance.md), [ADR-0110](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) ## Context -Wave 3 #43/#44/#271 concerns eager service imports and cached catalog reads on -help/version. At `702cdc9c`, deferring `driveHome` alone is insufficient: -`program` → `specs` → `dispatch` imports command implementations and their -DB/provider/TUI dependencies. The entry also imports MCP, and diagnostic helpers -reach engine dependency barrels. This requires a dependency-graph correction, -not just moving one function call. - -ADR-0051's single ESM bundle and the closure guard's single-output assertion prevent -a genuine lightweight entry with deferred implementation chunks. The engine must -still be inlined and third-party dependencies externalized. Wave 3 #46 also needs -release sourcemaps, while ADR-0051 prohibits their publication. The GitHub repository -was verified public during preflight; an ordinary Actions artifact is not a private -archive merely because it is absent from npm. - -This proposal requires approval before bundler, startup or release-policy changes. -No dependency version bump, new runtime dependency or package publication is authorized. +At baseline `702cdc9c`, program → specs → dispatch imports native/DB/provider/TUI +implementations, while errors and sanitization reach engine barrels. Deferring +Home alone cannot make help/version lightweight. #43/#44/#271 require correcting +the actual graph, including early crash handling. The single-output guard blocks +real deferred ESM chunks. + +#46 requires useful release symbolication. All five engine packages currently +compile with sourceMap false and their exports point to compiled JS. A final CLI +map alone cannot map engine frames to TypeScript. Release also builds the CLI +before pnpm pack; prepack rebuilds it with clean output. tsup adds map-link trailers +even when esbuild is told to emit external maps. The prior abstract no-rebuild +rule therefore lacked an executable packing path. + +The repository is public. Its source layout and symbol names are not secret; +ADR-0051 expressly relies on licensing/obfuscation rather than secrecy. The +maintainer selected encrypted CI archival on 2026-10-15 as a **distribution and +custody policy**: release debugging material stays out of npm and public plaintext +artifacts. This neither hides public source nor requires making the repo private. + +All mechanisms remain Proposed. Exact artifact shapes and operational states live +in [CLI packaging](../reference/cli/packaging.md), with release procedures in the +[release runbook](../runbooks/release-a-surface.md). No runtime dependency/version +bump, production recipient, upload or publication is authorized. ## Decision -We will package one executable ESM entry plus its finite, explicitly packaged ESM -runtime chunks. This replaces ADR-0051's **single-output-file** constraint only; -all proprietary engine code remains inlined into the CLI artifact, every third-party -runtime dependency remains external and declared, and the engine packages remain private. - -The entry, flag grammar, version and command descriptors form a lightweight graph. -Meta-operations resolve descriptors without reading config or cached catalogs, -admitting native filesystem grants, opening history/migrations/keychain, connecting -MCP or constructing providers/TUI. Runtime validation still owns normal command -execution; invalid option grammar is not silently accepted by the meta path. -Defer command executor imports, Home, native services and MCP version wiring to -their actual owners. Seed the cached catalog once before an executing command's -first price/capability read, retaining the shipped offline floor and no-startup-fetch -rule. Never move seeding after admission or budget pricing. - -The bundle guard must inspect **every** emitted JavaScript output and its static -and dynamic imports, verify the complete declared dependency closure, and reject -unbundled `@relavium/*`, missing/escaping chunks and undeclared external packages. -The packed artifact, not a source checkout, is the cross-platform smoke subject. -Chunk-relative migration paths, native iterator/shared-module identity and lazy -initialization must be tested together; splitting must not create independent -copies of authority-bearing module state. - -### Private source-map custody - -Generate private sourcemaps for the **exact final minified JavaScript bytes** that -are packed, with `sourcesContent: false`. Do not rebuild the shipping JavaScript -after its map receipt is recorded. A private manifest binds source commit, package -version, actual toolchain and every JavaScript/map digest. Published JavaScript has -no sourceMappingURL, and npm contains neither maps, metafiles, source trees nor the -private manifest. A pack inspection is required, not just a files-list assumption. - -The maintainer selected public-recipient encrypted CI archival on 2026-10-15; -this method selection does not approve the remaining ADR or implementation plan. -Use vetted [GnuPG recipient encryption](https://gnupg.org/documentation/manuals/gnupg/Operational-GPG-Commands.html) -as release tooling, not a CLI runtime dependency or in-house cryptographic protocol. -Use an isolated public-only keyring and an explicitly pinned maintainer-confirmed -recipient fingerprint, without ambient keyring/config or automatic key discovery. -An exact-source archive receipt establishes build provenance; encryption alone -does not authenticate who created an archive. -The private decryption key never enters this repository or CI. Public artifact -storage is permitted only for ciphertext; fixed diagnostics never print map -contents or key material. The recipient and retention destination must be explicitly -configured and verified before a release can claim private archival. A local -owner-only archive is available for development and custody tests; it does not -claim that CI release archival happened. - -CI stages ciphertext as its workflow artifact. Because [workflow artifacts expire](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/remove-workflow-artifacts), -actual publication also retains that exact ciphertext as a per-version -[Release asset](https://docs.github.com/en/rest/releases/assets), verified by digest -before npm publication. Retain it for every supported release; this wave adds no -automatic deletion. Never replace an existing version's different ciphertext -silently. Public storage remains ciphertext-only; this does not make the repository -or artifact access private. Release uploads use only the existing trusted main/tag -path with least required permissions, never a PR/fork or help invocation. - -Implementation may use synthetic recipient keys for offline encryption/decryption -controls, never manufacture a production recipient or upload plaintext to complete -a gate. Do not publish proprietary maps encrypted to a publicly available fixture -private key. Production recipient configuration and actual storage remain separate -release prerequisites; no key material is requested for this proposal review. -The first actual release belongs to Wave 4b and fails closed if private archival -is absent. Wave 3 implements/tests the complete selected path and records its real -configuration status without presenting a fixture as production custody. - -Considered retaining a single file and deferring only Home: rejected because -external imports remain eager through the rest of the graph. Considered publishing -engine packages separately: rejected by the proprietary bundle boundary. -Considered publishing source-free maps openly: rejected because symbol names and -source layout remain private even without embedded TypeScript. Considered ordinary -public Actions artifacts or Release attachments: rejected as private custody. +### Lazy graph and catalog authority + +Package one executable ESM entry plus a finite flat set of ESM runtime chunks. +Only the single-output constraint changes: engine code stays inlined, engine +packages private, third-party runtime dependencies external and declared. + +Use ADR-0110's single pure descriptor/grammar authority. Early flag policy, +local error class/exits and invocation-owned shutdown registry import no runtime +classifier, native service or engine barrel. Extract/share minimal sanitization +through a supported pure module rather than duplicate or weaken redaction. +Runtime classification and command executors load dynamically under the early +failure boundary; MCP version wiring moves before actual client construction. +Splitting must not duplicate the registry, media iterator WeakMap or other +authority-bearing module state. Do not treat tree shaking as source proof. + +Meta help/version/bare noninteractive help reads no config/cache, admits no +filesystem grants and opens no DB/keychain/MCP/provider/TUI. Validate grammar +without a second argv parser. New JSON-help integration lands after the descriptor +and lazy graph; the earlier increment only certifies implemented meta operations. + +For execution distinguish **filesystem grant admission** from model/cost admission. +Preserve current order: filesystem admission, once-only cache fallback seed where +needed, current DB catalog overlay at its owner, then first capability/quote/ +budget/egress read. A late file seed must never replace authoritative DB long-tail +prices. Preserve ADR-0072's offline floor, empty-DB non-erasure and no-startup-fetch +rules and media maintenance's no-config/cache/DB exception. Home/slash/resume +transitions cannot reinstall an older file overlay. + +Inspect every emitted JS output and its static/dynamic imports. Closure rejects +missing/escaping chunks, unbundled internal packages and undeclared externals; +shipped and emitted finite graphs must agree. Flat layout preserves migration +relative paths. Required PR/push CI installs the exact same tarball on supported +OSes and the declared minimum runtime, then release CI reuses that mechanism. + +### One build, exact maps and retryable custody + +Enable external upstream TypeScript maps for inlined packages, without embedded +sources. The final bundler chains them to original TypeScript; do not substitute +source aliases or claim a decrypt test proves engine symbolication. + +Make **prepack the sole final CLI build** after dependency builds. Finalize tsup +output before hashing: remove only its known trailing map links, move maps/build +metadata outside the public package, verify complete closure and retain exact JS. +Then inspect the actual tarball against finalized bytes and seal the private map +manifest. Never build, rewrite or pack again as a retry for that candidate. + +Use vetted GnuPG recipient encryption as release tooling. The selected public key, +primary/subkey fingerprints, validity, isolation and rotation policy are explicit; +private keys never enter CI/repo. Encryption is randomized and does not authenticate +the producer. Encrypt once and retain the original ciphertext, tarball and small +nonsecret binding receipt. The private per-source manifest stays inside ciphertext. +Public source/build identity and hashes are allowed in the binding receipt; +maps, source content and private per-source inventories are not. + +Before retrying, retrieve and verify original staged or Release assets. Reuse +exact matching bytes, fill only missing assets from a recoverable candidate and +refuse conflicts. Expired staging without a complete candidate fails with recovery +instructions; re-encryption cannot manufacture identical custody. Never make +encryption deterministic, overwrite conflicts or silently swap a version's bytes. + +A dedicated archive job retains ciphertext, tarball and binding receipt as +per-version Release assets beyond Actions expiry, verifies downloaded digests, +and gates npm publication of that same tarball. Only real trusted pushed tags +with version/main-ancestry checks can mutate Releases/publish. Manual dispatch, +including an off-main override or dispatch targeting a tag, remains dry-run. +Only the archive job gets Contents write; npm retains its provenance permission. + +A dedicated crypto/recipient/CI-custody security sitting validates this path. +Fixture-key controls use synthetic maps only; do not upload product maps under a +public test private key. Production recipient/backup/retention configuration is +an actual-release prerequisite in Wave 4b, not a credential needed for proposal +review. Future releases fail closed without it. Rotation applies prospectively; +supported old archives require retained offline decryption capability, and public +ciphertext cannot be recalled by revoking a key. No automatic archive deletion +is introduced. + +Considered retaining one file and deferring only Home: rejected because the graph +remains eager. Considered separate published engine packages: rejected by the +distribution boundary. Considered public source-free maps: technically possible +for this public repo, but contrary to the selected archive policy, **not** because +public source names would become newly secret. Considered deterministic encryption +or rebuilding on retry: rejected because cryptographic randomness and exact-byte +release identity must be preserved. ### Acceptance and landing -1. Native packed help/version/root-help/JSON-help executions install throwing - service-boundary witnesses and prove no service initialization or cached-config - I/O. Record cold-start measurements against the same current baseline; the - historical approximately 400 ms figure is not a fresh measurement or threshold. -2. Real packed run/chat fixture, DB migrations, MCP version, CAS and native iterator - identity controls pass on supported platforms, including the minimum Node floor. -3. Deliberately missing/escaping chunks, external engine imports and an undeclared - dependency break the complete closure/pack guard. Every required chunk is shipped. -4. Map-to-JavaScript receipts survive packing byte-for-byte; a planted original source, - map, manifest or metafile fails public-artifact inspection. Actual private archive - round-trip controls use test keys, and failure cannot fall back to public plaintext. -5. Update the [release runbook](../runbooks/release-a-surface.md), packaging reference - and canonical CLI docs, and append a dated partial-replacement note to ADR-0051 - plus refinements to ADR-0047/0056/0071. Required CI, packed smokes and two fresh - independent cumulative reviews must pass before implementation acceptance. +1. Packed existing meta operations have static reachability plus native throwing + service witnesses, including early diagnostics/sanitization. Measure against + the same current baseline; historical ~400 ms is not a fresh threshold. +2. Required packed platform/minimum-runtime controls cover real run/chat fixtures, + native loading, migrations, MCP version, CAS and shared iterator/registry identity. + Catalog order tests include file-only, empty/torn DB, authoritative DB tail, + repeated Home/resume and media exceptions before first capability/budget read. +3. Missing/escaping chunks, external engine imports and undeclared dependencies + break closure. Actual tarball inspection rejects public maps/source/metafiles/ + manifests/map links and compares every packed JS digest with finalized outputs. +4. Exact packed CLI **and engine** frames map to the expected original TS locations + using the matching source revision. Wrong map/JS/source, an upstream missing map + and a post-receipt rebuild fail; sourcesContent is absent throughout. +5. Synthetic GPG and archive/API controls cover recipient isolation/expiry/rotation, + encryption failure, retry/partial upload/conflict/expired staging and archive + before publish. No plaintext fallback or false production-custody claim. +6. Split lazy graph, fatal integration and archival into distinct reviewed + increments. Rerun affected packed gates after later CLI/DB edits and at final + whole-wave head. Two new independent complete cumulative clean rounds follow + each final material fix. At acceptance append partial-replacement notes to + ADR-0047/0051 and refinements to ADR-0056/0071/0072/0103/0109 before code; land + canonical references/runbook and actual configuration status separately. ## Consequences ### Positive -- Help/version avoid initializing unrelated native/network/UI subsystems by construction. -- Exact release bytes can be symbolicated without publishing proprietary source metadata. +- Meta operations avoid unrelated services by construction and retain one grammar. +- Exact published bytes can be symbolicated to original public-source revisions. ### Negative -- Multiple packaged files replace a single-file assumption. Complete closure checks, - shared-state identity controls and exact-tarball platform smokes become mandatory. -- Private archival adds a release custody prerequisite. Its configuration is explicit; - absence blocks the archival claim/release instead of leaking maps or silently skipping it. +- Multiple files require complete graph, identity and exact-tarball platform gates. +- Encrypted archive policy adds a release availability dependency despite public + source. Explicit backup/rotation/retry and recovery rules bound that tradeoff; + missing production custody blocks release, not document review. diff --git a/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md b/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md index 27342bea..e5c14d96 100644 --- a/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md +++ b/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md @@ -6,132 +6,142 @@ ## Context -Wave 3 pulls G30 forward: chat can advertise `http_request`, but its policy has no -configuration path for `allowedDomains`, so dispatch always denies. Simply copying -a project-owned allowlist would turn a cloned artifact into network authority. -ADR-0105 makes this distinction explicit for filesystem grants; this proposal makes -a separate decision for chat egress rather than pretending that ADR already governs it. - -Onboarding also treats a missing key and an unavailable keychain as the same reason -to repeat full setup. It can announce success after model-default persistence fails. -The global config writer silently replaces intentional symlinks. These changes share -one trust boundary: operator-owned non-secret configuration, mutated only by explicit -user actions through a typed writer. - -Approval is required before the new shared config fields or keychain/config paths -change. Exact keys, precedence and validation belong in the -[config specification](../reference/contracts/config-spec.md), not a second schema here. +G30 exposes a missing chat HTTP policy path. Advertising `http_request` does not +supply its domain authority. Default Home agents do not grant that tool; standalone +agent execution is ask-mode without a confirmer. A successful request is therefore +not a valid acceptance expectation on every current surface. + +A global-only policy is an explicit new choice for this capability. It is not a +claim that projects confer no network authority today: approved commands such as +curl, project MCP and workflow tool policy have separate contracts. Nor is any +operator-selected `--config` file necessarily protected from model filesystem +tools; a workspace-local selection may be writable. + +Clear/reseat currently reuse startup chat options; Home refreshes model/provider +and can fall back on read error. Those paths cannot provide policy revocation +without new global-only reads and an invocation ceiling. Dispatch also compares +`allowed.includes(parsed.host)` privately, without normalizing the allowlist. +The proposed shared comparison is a correction, not an existing public helper. + +Onboarding confuses unavailable keychain with missing key and can announce success +after default persistence fails. The config loader follows intentional links; +the atomic writer replaces their final directory entry. Read permission and write +permission must be stated separately, not justified by a fictitious owner change. + +All choices remain Proposed. Exact keys, limits, diagnostics, transition and +rollback rules have one home in +[config-spec.md](../reference/contracts/config-spec.md#proposed-wave-3-chat-policy-and-preferences). ## Decision -We will use operator-owned global policy for chat HTTP grants and explicit, -non-authoritative preferences for onboarding acknowledgements. - ### Chat HTTP authority -Add a global-only `[chat_egress].allowed_domains` setting, read from the normal -global config or the file explicitly selected as global by `--config`. A project, -workspace, agent definition, persisted session, model output or environment variable -cannot add or replace this authority. Loading a project must not promote its file -to global configuration. Selecting an explicit global file is the operator's choice. - -An absent/empty list remains deny-all. Limit it to 128 entries, each at most 253 -characters, canonical ASCII DNS names; IDNs use explicit punycode spelling. -Reject URLs, credentials, paths, query strings, ports, wildcards, literal IPs and -single-label/local host forms. Normalize case and any accepted terminal root-dot -form through the same host comparison used by dispatch; do not add a second URL -parser or silently introduce suffix/subdomain permission. Deduplicate canonical names. -Exact host authority retains the existing HTTPS-port semantics; it does not mean -port 443 only. DNS/range checks remain necessary even for an allowed hostname. - -Resolve an owned policy on every fresh/resumed/reseated/cleared host build, including -Home chat and `agent run`. A prior persisted policy cannot revive a removed grant. -An already-built host owns its immutable snapshot until it is rebuilt; this is not -a live revocation watcher. Original-workspace resume discovery remains ADR-0105's -filesystem rule and cannot replace the current operator global egress setting. - -Forward only the approved domain list to the existing engine tool policy. Preserve -exact-host checking before dispatch, per-tool modes/approval, HTTPS-only SSRF/DNS -validation, pinned connection, no redirects and response size/time bounds. `auto` -does not bypass missing domain permission. Loading policy performs no request and -attaches no provider key. This does not activate search credentials, live provider -headers, workflow-run egress or a new tool. Workflow-authored policy remains its -separate execution contract. Advertise filtering is helpful but dispatch is authoritative. +Use global-only operator configuration for the built-in chat HTTP tool. The +normal global file or an explicitly selected global file is the authority; +projects, agents, sessions and environment overlays cannot supply this field. +Selecting an explicit file is a deliberate trust choice, not proof it is immutable. +The setting's name describes HTTP-tool policy, not all chat egress. + +Absent/empty authority denies all. Use bounded canonical ASCII DNS hostnames, +explicit punycode and one pure hostname normalizer/comparator shared by validation +and dispatch. Preserve exact-host permission with no suffix/subdomain grant. +The current `parsed.host` is a hostname, not URL host-with-port; the host transport +allows arbitrary HTTPS ports. State this authority explicitly in the +[tool contract](../reference/shared-core/tool-registry.md#proposed-wave-3-http-authority-and-secret-provenance); +do not silently make it 443-only. DNS/range validation, pinned connection, redirect +refusal and response bounds remain independent. Auto mode is still an authority +to send permitted tool data to a permitted host/port; provider keys are not attached. + +Capture an owned invocation ceiling before building the first host. Every host +construction reads **only current global HTTP policy**, intersects it with that +ceiling and the last accepted effective policy, and retains the narrowed result +across Home chats, clear, reseat and resume. No rebuild widens authority; a fresh +CLI invocation is required to add or restore a grant. Never reuse captured startup +chat policy or Home's model-read fallback for this read. Read/schema failure +refuses the new build with a fixed actionable diagnostic; it does not restore +old policy or reset the ceiling. Stop the old turn/host before replacing it. +An active host retains its immutable snapshot until cancellation/rebuild; there +is no live watcher. Persisted session policy never revives authority. + +Forward only that list to existing tool dispatch, keeping model tool grants, +approval modes and SSRF as separate gates. Test each surface's actual behaviour: +a custom chat agent granting HTTP can succeed in an allowed approval mode; +default Home and confirmer-less ask paths still deny or do not advertise it. +Do not add a tool grant or confirmer merely to obtain a green positive test. +This does not activate search, provider headers, workflow egress or MCP policy. ### Onboarding and mutation -Add a typed global-only `keychain_unavailable_acknowledged` preference. Set it only -after a genuine typed unavailable-keychain outcome and an explicit user choice to -use a short reminder next time. Cancel, missing key, auth failure or unrelated DB -failure must not set it. Store only a boolean, never a key, hint, error text or -backend/path identity. Use the existing strict, atomic, secret-incapable writer; -no new generic key-value store or automatic startup write is authorized. - -Every Home startup still checks actual current key resolvability. A working env or -keychain key suppresses setup as today. Otherwise an acknowledged backend problem -shows a short keyless reminder with an explicit retry-setup action instead of -automatically repeating full setup/provider validation. The acknowledgement neither -claims permanent lock nor conceals the current missing-key state. A failed preference -write is visible and recoverable and cannot be reported saved. - -After a key is stored but model/provider defaults fail to persist, preserve the key -and offer Retry or an honest incomplete-setup continuation. The next chat must choose -a compatible model explicitly or use an explicitly chosen ephemeral model/provider; -never announce a persisted default that failed. Higher-precedence project defaults -are not overwritten implicitly. Thread the resolved color choice to actual prompts -without process-global environment mutation. - -Refuse a pre-existing symlink at the global preference write target, preserving its -link and referent. Do not follow it into a different authority or warn only after -replacing it. Regular-file writes retain schema round-trip, owner-only temp file, -atomic rename and existing durability guarantees. A malicious same-user process -swapping a target between checks and rename is outside the existing owner-controlled -filesystem assumption; a preflight lstat must not be described as an atomic no-follow -replace primitive. The fixed diagnostic explains how to edit the intentional link -manually without echoing its path or content. - -Considered project-granted domains: rejected because imported config would confer -machine authority. Considered another invocation flag plus project selectors: -viable, but larger than G30 needs; the operator global setting supports project-less -chat without implementing a durable permission store. Considered allowing every -domain once a user approves a tool: rejected because approval and network policy -are independent gates. Considered treating acknowledgement as credential state or -silently writing through config links: rejected because either hides current truth -or changes write authority. +Keep an explicit, typed, global-only acknowledgement preference for a genuine +unavailable-keychain outcome and deliberate user choice. It is only UX state: +every startup rechecks current key availability; a working key suppresses setup, +and a still-keyless acknowledged failure gets a reminder and explicit retry. +Missing key, cancellation, authentication and unrelated DB errors do not set it. +Persist only a boolean through the secret-incapable writer; failures stay visible. + +After storing a key but failing to persist defaults, retain the key and truthfully +offer recoverable retry or explicitly chosen ephemeral defaults. Never say +“Connected”/saved default when persistence failed. Project precedence is not +silently overwritten. Propagate color choice without changing process environment. + +Refuse an existing **final-component symlink** for preference writes, with a +fixed path-free `ConfigWriteRefusedError` and `reason: symlink_target`; mark it +non-retryable in the wizard/picker. Keep reads through intentional links. +Preserve link and referent, explain manual edit/regular-file replacement outside +the wizard, and offer honest ephemeral continuation; no endless Retry loop. +Ordinary write failures can still offer Retry. This is an entry-preservation +policy, not a claim that same-owner link targets have a different authority. +Ancestor links remain under the existing owner-controlled-directory assumption; +atomic rename replaces one hardlink entry rather than editing all aliases. +An lstat precheck is not an atomic no-follow replace against a hostile same-user +race. Regular writes keep verified schema round-trip, permissions and durability. +Existing TOML reserialization loses comments/order; document that tradeoff before +writing a hand-edited policy and preserve all policy values, never broaden them. + +Considered project domains: rejected for this capability because explicit operator +authority is preferable to implicit imported authority. Considered invocation +flags: viable but unnecessary for project-less G30. Considered stateless keychain +status alone: needed for truth but insufficient for #55's explicit remembered +acknowledgement. Considered same-owner symlink referent writes: rejected because +it changes the existing atomic entry-replacement protocol and recovery boundary; +manual editing preserves intentional links without introducing that writer. +These choices do not establish a universal no-project-egress guarantee. ### Acceptance and landing -1. Strict schema/resolver tests cover global/explicit-file authority, project and - workspace attempts to widen it, canonical hostname validation and empty deny-all. - Invalid policy causes no session creation, provider/key access or MCP connection - beyond the explicitly necessary existing-history read for resume. -2. Actual session-host-to-tool dispatch controls cover all chat surfaces, rebuilds, - revoked-on-rebuild grants, allowed requests, no-domain denial, approval refusal - and unchanged workflow/search/MCP separation. Native SSRF controls prove the - existing DNS/connect/redirect floor still applies. No paid/live provider test is needed. -3. First-chat integration proves chosen non-default provider/model binding, failed - config-write recovery and precedence. Current keyless state, explicit acknowledgement, - retry, cancellation and write-failure tests prove no false setup/credential claim. -4. Native symlink tests preserve link/referent bytes on refusal; regular-file - positive controls keep atomicity, permissions and error confidentiality. Real - prompt output respects color-off. Separate config-trust/egress and keychain-path - security review sittings plus two fresh cumulative rounds are required. -5. Update canonical config, chat, Home, CLI and built-in tool references, append - dated refinements to related Accepted ADRs, and register actual acceptance in - Wave 3. This proposal itself establishes no permission, saved setting or completion. +1. Strict global schema/resolver controls verify exact hostname grammar, actual + global/project merge precedence, no project authority and empty deny-all. + Loader failures are fixed, actionable and value/path-free. Test actual merge + bypass attempts, not only unknown-key rejection. +2. Native actual host/dispatch controls exercise permitted custom chat, default + Home non-advertisement, agent ask denial, approval refusal and SSRF/ports. + Clear/reseat/resume/repeated Home builds reread global policy: remove, re-add, + attempted widen, malformed/unreadable file and startup fallback all exercise + the persistent invocation ceiling. No live/paid provider is required. +3. First-chat tests verify model/provider selection, retained key, truthful + write failure, typed acknowledgement, current keyless reminder and retry/cancel. + New strict keys have concrete old-binary removal/exit instructions. +4. Native symlink controls preserve link/referent and prove no Retry/success lie; + ancestor-link/hardlink controls state the actual scope. Regular writes retain + atomicity, mode, policy values and confidentiality; prompts respect color-off. +5. Separate config/egress and keychain-path security sittings and two fresh + cumulative clean rounds precede acceptance. At ADR acceptance add forward + notes to affected Accepted decisions; implementation lands canonical config, + tool, chat/Home references and actual Wave 3 evidence without claiming other + egress capabilities were governed. ## Consequences ### Positive -- An existing chat tool becomes usable under explicit operator authority without - weakening default deny, approvals or SSRF controls. -- Setup tells the truth about key availability and persisted defaults, while an - intentional config symlink is preserved. +- Existing chat HTTP becomes usable under explicit, bounded operator authority. +- Rebuilds revoke grants monotonically; setup and intentional config links stay honest. ### Negative -- New strict global keys require a new enough binary; older binaries reject them - under ADR-0033. Document rollback/removal of these non-secret keys. -- Domain changes take effect at host rebuild, not instantaneously. An operator - needing immediate revocation cancels the active turn and rebuilds the host. +- Strict new keys break old-binary config reads until removed, even on unrelated + commands. The canonical rollback instructions make the recovery explicit. +- Revocation requires cancellation/rebuild and widening requires a new invocation. + An active snapshot is not instantaneous revocation. +- Symlink writes require manual action or ephemeral continuation. Non-retryable + diagnostics avoid a wizard loop; comments/order remain a known writer tradeoff. diff --git a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md index 1bc21ea6..f6873e81 100644 --- a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md +++ b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md @@ -6,127 +6,151 @@ ## Context -Wave 3 #92 observes that `ToolDispatchContext.secretArgKeys` has no live producer. -The existing parser already computes transitive declared-source taint, but bare -JavaScript expression scopes are a separate consumer from interpolation templates. -At baseline `702cdc9c`, a synthetic workflow with a secret input, a derived context -entry and a `transform: ctx.derived` is accepted and exposes its canary in two -`node:completed` events and `run:completed`. `run:started` correctly masks the input. -The parent independently reran this source/QuickJS/in-memory-host probe. No real -credential, provider or network was used. - -`resolveContext` is a public trusted resolver and intentionally retains declared -secret-input values for governed credential use. The canonical workflow contract -allows this intermediate propagation while forbidding public model/human text. -Masking all inputs before resolution would close the probe by changing that -contract. Conversely, adding an empty `secretArgKeys` set would leave the leak and -falsely claim a producer. Neither is an adequate repair. - -This proposal refines enforcement of existing ADR-0027/0029 secrecy, not universal -redaction of conversational content. Approval is required before changing the -execution/preparation context or provenance interfaces. No new runtime dependency, -platform import, persisted secret, model request field or general mapping feature -is authorized. +At `702cdc9c`, actual WorkflowEngine + standard executor + QuickJS accepts secret +input → context alias → JavaScript transform. Direct, computed, serialized, +transitive/nested and custom-merge reads expose the canary in node/run completion. +A condition can select a branch using its raw value. Actual SQLite history stores +the leak in run events, step outputs and run outputs. A downstream human gate can +display the escaped transform output. Direct model/human templates already refuse +tainted context at parse; these are preservation controls, not absent defences. +SandboxError's private detail can also contain the canary although its public +message and JSON do not. + +The public trusted `resolveContext` contract allows raw intermediate secret +resolution. That does not require passing raw derived strings into every executor: +there is no shipping workflow tool-mapping consumer needing them. Node inputs +still have the existing trusted-host raw contract and must remain masked before +sandbox/public output. Qualifying context does not make arbitrary executors +a secret-free trust domain. + +The earlier proposal to inject markers would silently turn a secret comparison +into a false branch or let a marker satisfy an output schema. Its additional +preparation field had no VM consumer. Both mechanisms are replaced below, while +the existing direct-input marker contract stays unchanged. + +All decisions remain Proposed. Exact interfaces and error behaviour belong in +[expression-sandbox-spec.md](../reference/shared-core/expression-sandbox-spec.md#proposed-wave-3-secret-context-boundary) +and its linked runner/tool contracts. No new dependency, engine ErrorCode, +credential sink or tool-mapping feature is authorized. ## Decision -We will carry trusted declared-source provenance to every consuming boundary, -preserve raw values only inside their existing trusted resolution domain, and keep -secret-derived context out of expression/model/human public sinks. - -Reuse the existing linear `computeTaint` dependency graph for declared secret input -names and transitive `ctx` references. Expose its provenance result through one -pure internal authority, shared by parser checks and execution/preparation. Do not -add a second scanner, scan raw secret values, or guess secrecy from argument names. -Metadata contains source/key relationships only, never credential bytes, and is -owned against mutation by the consumer's execution context. - -For every `condition`, `transform` and `merge_fn`, build the sandbox context with -the existing secret marker for each tainted key, just as declared secret inputs -are masked today. No raw derived string enters QuickJS, including computed access -such as `ctx[name]`, enumeration or JSON serialization of the context. Public -outputs may carry the inert marker but never the raw value. This preserves the -trusted resolver's raw return contract and clean-context expressions. Static -model/human text checks use the same transitive provenance; preparation, resume, -branching and merge paths must not reconstruct an unqualified context. - -The engine supplies provenance on all actual producer paths, computed from the -same admitted workflow used for trusted context resolution. Add a required frozen -list of secret-derived context names to both execution and preparation contexts; -empty is an explicit clean result, never the default for absent metadata. Migrate -all repository context producers and doubles together. A direct low-level caller -missing this field refuses before sandbox entry; a JavaScript caller that lies -about its inputs/provenance violates the trusted-host contract, just as lying about -existing `secretInputNames` does today. Do not claim to detect that lie by inspecting -credential bytes. Define the public embedding migration in -[agent-runner.md](../reference/shared-core/agent-runner.md) and -[expression-sandbox-spec.md](../reference/shared-core/expression-sandbox-spec.md). -The required field is an explicitly approved low-level API change; existing -no-secret doubles migrate by supplying an owned empty list. - -Keep `ToolDispatchContext.secretArgKeys` as the explicit external provenance guard. -There is no live tool-input-mapping producer: current agent/session producers pass -empty config, and JSON chosen by a model cannot retain declared-input provenance. -Record this limitation honestly, as #92's permitted ADR disposition; do not -implement an unrelated mapping feature or invent a nonempty producer from string -matching to close a checkbox. A future mapping producer must derive tainted final -top-level argument names **after** model/mapping/config precedence, with nested -taint marking its parent, before dispatch or approval persistence. It may not -launder a marker by serializing it to a normal string. Ordinary tool text never -receives raw marked values; credential references retain their separate host-only -resolution path. Existing external nonempty-set refusal controls must remain causal. - -This is not a claim that user/model free text, attachments or tool results are -universally free of sensitive content, or that the CLI database is encrypted. -ADR-0050/0095 retain their exact at-rest and structural scopes. No secret-value -search, request capture or provider-key plumbing is added to core. - -Considered masking every input before trusted context resolution: rejected because -it changes legitimate trusted resolution rather than qualifying the sink. -Considered static literal-expression analysis alone: rejected because computed key -access and whole-context serialization bypass it. Considered blanket name/value -heuristics or a fabricated empty metadata producer: rejected because neither proves -provenance. Considered full dynamic output taint propagation: unnecessary for this -repair when raw secret values never enter the sandbox; future sources require their -own explicit boundary, not an unverified general claim. +### Qualify once at the engine boundary + +Reuse the existing linear declared-source taint graph for input/default/context +relationships. One pure authority computes provenance; no value scanning, second +expression scanner or argument-name heuristic is introduced. Derive immutable +arrays once from the same admitted workflow, copying before freezing; freezing a +Set would not prevent mutation. + +Keep the exported trusted resolver's raw result contract. Immediately qualify its +result inside the engine, before storing or handing context to execution or +budget preparation. The one owned projection contains only clean context strings +and denied-key metadata, never raw tainted strings. Replace the low-level context +field with this required projection and migrate every actual producer/double. +Missing projection refuses with a fixed typed validation failure; there is no +implicit clean default. Trusted hosts remain responsible for honest admitted +workflow/input provenance; shape validation cannot detect a deliberate lie. + +Execution and preparation consume the **same** projection. Preparation uses a +public-template resolver which rejects denied references before filters/defaults; +this is a real template consumer, not a fictitious preparation sandbox. Clean +templates work unchanged. Fresh, same-process resume and cross-process budget +preparation cannot reconstruct raw unqualified context. Trusted raw resolution +stays local to the resolver/projection cut rather than a second executor authority. + +### Denied access fails loudly + +Expression scopes serialize only clean context and denied-key metadata. Inside +QuickJS, create frozen context accessors for denied keys with an inaccessible +sticky denial latch. Evaluate authored code in a separate lexical environment +receiving only the public bindings; a guessed wrapper-local name or eval cannot +reset the latch. Capture required intrinsic operations before author execution. +After evaluation **and result serialization**, check the latch before accepting a +condition, transform or custom merge. This covers caught access, returned ctx, +nested outputs, getters and toJSON, not just literal property reads. + +Any denied value access produces the fixed non-retryable secret-context sandbox +failure specified in the canonical contract, even if authored code catches the +accessor's exception. It cannot become a false condition, generic TypeError, +successful marker or output-schema success. No raw value enters the VM or its +error detail. Clean context reads and key-only enumeration remain usable. Preserve +existing condition/output serialization rules; prototype/intrinsic mutation cannot +replace the trusted result check. This mechanism requires real VM causal controls, +not only a host Proxy or wrapper mock. + +A synthetic probe on the installed QuickJS supports private lexical latch, +caught-read and serialization feasibility; it is not product implementation. +Permanent actual-engine/store tests below must establish the final mechanism. +Existing direct-input markers remain their Accepted contract; extending this +fail-loud rule to them is not silently included. + +### Tool provenance scope + +Retain the external `secretArgKeys` guard without inventing a tool-mapping producer. +Current agent/session dispatches supply empty config and model JSON has no +declared-input provenance. Absence is a documented **unverified** external +provenance state, not proof of clean arguments; the compatibility interface remains +optional and is not a universal secrecy guarantee. A mapped path must refuse +missing provenance before approval/effect dispatch. This wave neither enables such +a path nor falsely certifies it. The nonempty external guard's current refusal +remains a preservation/causal control. + +Explicitly reconcile both contradictory passages in +[tool-registry.md](../reference/shared-core/tool-registry.md#proposed-wave-3-http-authority-and-secret-provenance): +the dispatch-context secret-args comment and Guardrail enforcement's claimed +1.O/1.V nested-path producer. Record no current producer and the future mandatory +final-argument obligation once in that canonical home. #92 receives an honest +documented limitation; the actual context leak is repaired separately. + +This does not mask arbitrary user/model text or encrypt history. ADR-0050/0095 +retain their precise scopes. Existing historical SandboxError.detail may contain +sensitive authored text; do not call it universally secret-free or render it. +New denied-context errors contain fixed diagnostics without source/value text. + +Considered handler-only marker projection: rejected for silent branching/schema +changes and repeating the second-gate defect. Considered qualifying once at engine +construction: selected for raw-value removal, with actual VM/template consumers +for loud refusal. Considered rejecting all expressions when any context is tainted: +rejected because unrelated clean expressions must work. Static literal scanning +alone cannot close computed/whole-context access. Full dynamic output taint and +fabricated empty tool metadata add unsupported claims without repairing the sink. ### Acceptance and landing -1. Permanent actual-engine/QuickJS tests reproduce the baseline canary and prove - absence from every event/output, raw sandbox input and surfaced error after the - fix. Cover direct/transitive/reordered context graphs and computed/enumerated - access in condition/transform/custom merge. Clean inputs/context are positive controls. -2. Fresh/resumed/prepared executions carry owned provenance without a parallel - mutable authority. Missing low-level metadata refuses before sandbox entry; - mutation of a source list cannot alter the engine-owned list. Clean and tainted - lists derive from actual admitted workflows; hostile trusted-host forgery is - explicitly outside this boundary, not falsely certified by a shape guard. -3. Actual parser-to-model/human tests preserve transitive refusal and the trusted - resolver's raw intermediate contract. Credential references stay opaque and are - resolved only by the already-governed host; no new credential sink is introduced. -4. Real external tool-dispatch tests prove nonempty top-level taint refusal before - host effects/approval persistence and preserve clean-call behaviour. Nested-parent - projection remains the documented obligation of a future mapping producer; - do not claim that a nonexistent producer was exercised. No test labels arbitrary - model JSON as declared-input provenance. -5. Removing context masking, source propagation or tool refusal breaks its specific - regression, with exact restoration. A dedicated secret-flow security sitting, - required gates and two new independent cumulative clean reviews precede acceptance. - Update the canonical workflow/expression/tool contracts and append dated forward - notes to ADR-0027/0029/0037/0083/0093; register the #92 limitation and actual repaired - leak separately, never as universal secret redaction or a completed mapping feature. +1. Actual WorkflowEngine + createStandardNodeExecutor + QuickJS controls cover + direct/computed/serialized/transitive/nested/custom merge, caught access, + condition oracle, returned ctx/getters/toJSON and intrinsic/latch tampering. + Assert the expected typed failed outcome, no branch/output-schema success and + no raw VM input/error detail. Independently successful clean workflows must + complete with exact expected outputs; no vacuous negative-only test. +2. Actual SQLite hosts scan `run_events.payload_json`, + `step_executions.output_json` and `runs.output_json`, plus public events and + downstream human gate messages. Clean positive rows prove persistence occurred; + leak fixtures prove the baseline fails the confidentiality assertion. +3. Every fresh/resume/preparation producer supplies the owned projection. + Missing metadata and mutation controls exercise actual VM and public-template + consumers. Removing engine qualification, template refusal or VM latch breaks + its corresponding regression with exact restoration. +4. Direct/transitive parser model/human refusal, trusted raw resolver, direct-input + markers and external tool guard retain their existing behaviour. Label already + passing controls as preservation; no nonexistent mapping producer is certified. +5. Dedicated secret-flow security sitting, required gates and two new independent + complete cumulative clean rounds precede implementation acceptance. At ADR + acceptance append ADR-0027/0029/0037/0083/0093 forward notes before dependent + code. Land canonical workflow, runner, expression and both tool passages and + register actual #92 limitation separately from repaired exposure. ## Consequences ### Positive -- An existing accepted no-secret-sandbox guarantee covers context aliases as well - as direct inputs, including dynamically selected keys. -- Provenance is shared with the existing parser graph and has no credential bytes. +- Raw derived context no longer reaches executor/preparation or expression scopes. +- Denied expressions fail explicitly, including computed and caught accesses. ### Negative -- Expressions that previously observed leaked secret context now observe a marker. - This deliberate security correction needs clear compatibility tests and a dated note. -- Low-level executor embeddings must honor provenance ownership. The canonical - interface and fail-closed migration rule must be settled before implementation. +- Expressions previously reading leaked context now fail deliberately. This is + an explicit security compatibility change; clean and direct-input behaviour stays pinned. +- Low-level embeddings migrate to a required owned projection and template/VM + consumers. A trusted host's fabricated provenance remains outside the guarantee. diff --git a/docs/reference/cli/README.md b/docs/reference/cli/README.md index ae76a533..9e6bc6aa 100644 --- a/docs/reference/cli/README.md +++ b/docs/reference/cli/README.md @@ -13,4 +13,5 @@ Part of [reference/](../README.md). | [commands.md](commands.md) | The full `relavium` command surface (run, chat, chat-resume/list/export, agent run, list, create, import, export, logs, gate, init, provider) plus interactive TUI vs CI JSON mode. | | [home.md](home.md) | The bare-invocation interactive **Home** — the TTY gate, the read-only management strip over `history.db`, the Home↔chat mode machine + keymap, bracketed paste, the min-terminal degrade, and the signal/exit-code lifecycle (2.5.B / ADR-0054). | | [chat-session.md](chat-session.md) | The `relavium chat` agent-session REPL — entry, agent/model selection, the multi-turn loop, streaming, tool availability, the `--json` session-event stream, and exit code 4. | +| [packaging.md](packaging.md) | Proposed Wave 3 finite runtime package, exact source maps, encrypted custody and release retry contract; unshipped pending approval. | | [agent-run-fixture.md](agent-run-fixture.md) | The `relavium agent run --fixture` cassette format — a `StreamChunk[][]` JSON recording of an agent run's LLM stream, for deterministic offline replay. | diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index fbe711b0..7a616b0b 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -644,3 +644,122 @@ For a complete walkthrough (key handling, gates, artifacts, exit-code checks), s ## Qualified usage and cost totals Numeric cost totals are known realized sums. Human totals qualify unverified history, pending invocations, unknown usage and missing prices; machine records expose usageCoverage and independent price information. Unknown usage at a numeric zero is never free. The /cost panel includes models with uncertainty but no realized-money row; estimate release leaves usage uncertainty intact. Resumed/reseated views seed the existing durable coverage instead of a fresh zero. Compaction notices distinguish known observed tokens from complete summary usage. See the [seam](../shared-core/llm-provider-seam.md#6-usage), [event contract](../contracts/sse-event-schema.md), and [database contract](../shared-core/database-schema.md). + +## Proposed Wave 3 CLI compatibility + +**Proposed, unshipped — 2026-10-15.** [ADR-0110](../../decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md) +requires maintainer approval. This section owns the proposed exact contract; +earlier sections describe current behaviour until implementation landing. + +### Diagnostic policy + +Resolve one policy before runtime imports. `--quiet` plus `--verbose` is exit 2. +Either explicit flag wins over the environment; absent flags use verbose only +for exact `RELAVIUM_DEBUG=1`, otherwise normal. The older any-defined predicate +is intentionally narrowed. JSON selects representation independently; CI/TTY +select interactivity, never implicitly JSON. + +| Output category | Quiet human mode | JSON mode | +|---|---|---| +| Requested result, assistant answer, help/version, log/list/output data | Preserve | Preserve every contractual record | +| Final outcome/cost/usage/estimate/unpriced qualifications; approval, warning, denial, receipt/effect/ownership disclosure and remedy | Preserve | Preserve existing data/structured stderr diagnostic | +| Decorative banner/spinner/progress and optional informational notice | Suppress at owner | Never remove actual events; optional noncontractual notices may be suppressed | +| Deprecated alias | One bounded compatibility warning, preserved | One structured stderr notice, no extra stdout record | +| Verbose detail | Quiet disables it | Bounded safe structured detail, never a human stack dump | + +Every production write/render owner needs an explicit category. Do not globally +filter IO or implicitly approve a prompt. Reasoning follows its existing content +control. Verbose exposes only owned fixed classifications and bounded validated +frame locations, with no stack first-line message, arbitrary cause/detail, argv, +request or credential text. Unknown throws, projection/getter faults and broken +sinks fall back to a fixed message; do not coerce arbitrary objects. The ordinary +generic failure points to `--verbose`. + +### Failure classification and remedies + +| Surface / origin | Proposed disposition | +|---|---| +| Workflow run/gate/budget resume: actual terminal or pause | Existing 0/1/3; actual joined 8, then 7, then terminal uncertainty 5 or fencing 6 take priority | +| Chat/chat resume: actual failed turn or deliberate end | Existing 1 or 4; joined receipt/effect health takes priority | +| Home: clean shell end, nested chat end, external signal | Existing 0; consumes nested 4; preserves 128+signo for actual signal termination | +| Standalone agent: actual turn outcome / external signal | Existing outcome 0/1; actual signal 128+signo | +| Management/meta: successful result or known operational failure | Existing 0 or classified 1; invalid invocation/config remains 2 | +| Typed corrupt/unreadable history, known departure-unacknowledged | Preserve known operational 1 unless actual joined 8/7/5/6 overrides; do not infer acknowledgement | +| Classified output-unavailable/EPIPE/failed write acknowledgement | CLI-only `output_unavailable`, exit 1 unless actual stronger safety health overrides; never raw stack | +| Unexpected CLI throw/invariant/wiring failure on **any** surface | Fixed `internal` diagnostic, exit 9 unless actual stronger safety health overrides; never chat-end 4/Home success | +| Recoverable unhandled background rejection | Keepalive; upgrade only otherwise clean 0 to 9, preserve established nonzero outcomes | +| Fatal exception / rejected invocation | Stop admission, one bounded fatal shutdown; generic 9 with observed stronger safety health retained | + +Do not change the broad `CliErrorCode.internal` table alone: inventory and classify +every existing constructor site independently, retaining known operational codes. +Engine terminal `error.code=internal` still denotes a genuine failed run/turn. + +Exit **9** means the CLI could not complete reliably. After admitted paid/effectful +work, **do not automatically repeat it**: inspect logs/ledger and external targets +before recovery. It certifies neither absence of work nor receipt durability. +Exit 1 due to unavailable output has the same inspect-before-retry limitation. +If both sinks fail, no diagnostic delivery is claimed. Node may itself exit 9 +for invalid runtime arguments **before this entry runs**; the CLI diagnostic +distinguishes the handled case, not the numeric value alone +([Node exit codes](https://nodejs.org/docs/latest-v22.x/api/process.html#exit-codes)). + +The fatal budget is three seconds from the first fault, including attempted +diagnostic flush. It is a responsive-event-loop backstop, not preemption of blocked +JavaScript/native work; native tests need an external watchdog. Normal receipt +joining remains unbounded by this fatal policy. A timer never acknowledges an owner +or allows an unjoined store to close. Register owners before work, stop all even +if one callback throws, release only after actual joins, reap only owned native +resources on fallback and guard handler re-entry. + +### Discovery DTO and one descriptor authority + +The new CLI-only DTO is `{ type: 'command_manifest', version: 1, path: string[], +globalOptions: OptionDescriptor[], commands: CommandDescriptor[] }`. Root uses +an empty path; command help resolves aliases then returns the canonical selected +subtree including its executable/group root and descendants. Authored descriptor +order is stable. No internal executor loader paths or functions are serialized. + +| Descriptor | Required contract | +|---|---| +| Command | Canonical `path: string[]`, description, existing effect/label metadata, `availability: 'implemented' \| 'not_implemented'`, aliases, ordered positionals/options, and optional stable `executorId` | +| Alias | Explicit token `path`, `deprecated: boolean`; canonical and flat paths register separately | +| Positional | Name/binding key, scalar value type, required, variadic, description | +| Option | Binding key, canonical flag spelling(s), deprecated spellings, arity (none/required/optional), placeholder/type where applicable, repetition (single/repeatable/variadic), negation, description, existing default where applicable and conflict bindings | +| Global option | Same grammar plus position-independent scope; include help/version and extraction-owned flags | + +A container can also execute, as chat/gate/models do. Optional executor binding +does not force a leaf/group exclusive union. One enriched descriptor registry +derives Commander grammar, this DTO, help and legacy `COMMAND_MANIFEST`; the +legacy strict alias-free model projection and stable IDs remain compatible. +Finite lazy executor bindings are checked both ways against descriptors. +No second authored list of commands/arguments is permitted. + +Canonical paths are `chat resume`, `chat list`, `chat export`; flat aliases keep +working. Canonical pricing flags are `--input-price` and `--output-price`, with +old `--input`/`--output` deprecated. Preserve separate source-spelling presence +until conflict checks; both spellings for one quantity refuse even when equal. +Only then normalize to existing executor args/decimal parsing. Workflow input, +reserved standalone-agent input and gate payload meanings stay command-scoped. + +Explicit `--help --json` (including `-h`) prints one DTO and exits 0, without +services. Resolve known command/subcommand/alias grammar and `--` terminator; +help may omit required values/positionals but cannot excuse unknown grammar. +`--version --json` and bare `--json` remain human meta output. Unknown +commands/options/excess args/value errors use fixed stderr hints, no arbitrary +token echo or Commander suggestions. JSON last-resort errors are structured too. + +### Key removal and durable log preservation + +`provider remove-key` prompts only when stdin **and** stdout are TTY, JSON is +off and CI is off. Prompt defaults no; `--force` skips it. Headless without force +refuses with exit 2 **before DB/keychain construction**, and direct command-core +calls enforce consent as well. Decline/Esc/prompt cancellation exits 0 without +deletion; actual process signals retain their contract. Check abort/admission +again after awaited consent immediately before synchronous delete. Preserve +keychain-first/reference-clear order and truthful absent-key result, without +claiming a cross-store transaction. Never read the credential for confirmation. + +G15 is refuted as a current shipping defect. Preserve human durable-event replay +and actual JSON stored sequence/gaps. The engine/store/log regression must prove +live fragments are emitted live but absent from durable history; no token +coalescer or new persistence is authorized by that historical finding. diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md new file mode 100644 index 00000000..a5b204a9 --- /dev/null +++ b/docs/reference/cli/packaging.md @@ -0,0 +1,137 @@ +# CLI Packaging Contract + +- **Status**: Proposed — unshipped, maintainer approval required +- **Date**: 2026-10-15 +- **Related**: [ADR-0111](../../decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md), [commands.md](commands.md), [release runbook](../../runbooks/release-a-surface.md) + +This is the proposed canonical artifact/custody contract. Current single-bundle +packaging remains governed by ADR-0047/0051 until approval and implementation. +No release, production recipient setup or asset upload is performed by this document. + +## Finite runtime package + +One executable ESM entry and finite runtime chunks live directly in `dist/`. +Engine workspace code is inlined; declared third-party runtime dependencies stay +external. No unresolved internal package imports or second loaded authority +instance (shutdown registry, media iterator provenance) is allowed. + +The build inventory lists every JS output and static/dynamic import. Compare it +with actual emitted and packed files; reject absent/escaping/extra JS, duplicate +or escaping tar entries, unbundled internal imports, undeclared external packages +and unanalyzable runtime loading unless explicitly reviewed/allowlisted. +Chunk layout must preserve real installed `../drizzle` resolution. Runtime files, +migrations, package metadata and existing documented license/changelog assets +form an explicit public allowlist. No source TS/tree, map, private inventory, +build metafile or sourceMappingURL may enter npm. + +The lightweight entry imports only pure flag/descriptor/diagnostic/owner substrate. +Runtime modules load under the early fault boundary. Native witnesses and static +reachability prove meta operations do not touch config/cache/DB/keychain/MCP/UI. +Descriptor grammar and JSON help belong to [commands.md](commands.md#proposed-wave-3-cli-compatibility). +One registry instance passes through the chunk graph; no global-symbol bridge +silently replaces existing private-module provenance. + +## Build, map and candidate identity + +1. Build inlined dependencies fresh with external TypeScript maps and no inline + source contents; do not accept stale incremental/cache output missing maps. + Keep current package exports, engine purity and SDK seam. +2. Invoke `pnpm pack` once. Prepack owns the sole final CLI build, clean output, + minification, splitting, migration copy, map chaining and closure checks. + Release must build dependencies beforehand, not build CLI then rebuild in prepack. +3. Before any receipt, remove only tsup's expected final map-link trailers and + move maps/metafile to owner-only staging outside the npm package. Assert maps + have repository-relative original TS sources and absent sourcesContent. + No transform/banner/minifier/JS write is permitted after finalization. +4. Inspect the produced tarball. Compare each packed JS byte digest and migration + inventory with finalized files; reject prohibited entries independently of the + package files-list. Only now seal its private map manifest. +5. Symbolication loads the matching source revision separately and maps actual + packed CLI and engine frames to expected TypeScript lines/columns. Verify wrong + JS/map/source and missing upstream map refusal. A decrypt round trip alone + does not certify mappings. + +The **private map manifest** is versioned JSON inside the encrypted archive: +source commit/tree, package version, Node/pnpm/TypeScript/tsup/esbuild versions, +target, lock/config hashes, each final JS/map path+SHA-256 pair, and tarball SHA-256. +No host absolute paths, credentials or embedded source are stored. + +The **public binding receipt** is versioned JSON beside ciphertext: source SHA, +package version, tarball/ciphertext SHA-256, recipient primary and encryption-subkey +fingerprints, toolchain/config/lock identity, and originating trusted workflow/run/ +attempt identity. It contains neither maps nor private per-source inventory. +Use SHA-256 for file identity, not a homemade signature/provenance protocol. +Check originating identity against the trusted CI service/run and real tag/source; +a self-declared JSON field alone does not authenticate its producer. + +A candidate comprises exact tarball, original ciphertext and binding receipt. +Package version plus source SHA identifies the intended candidate; a different +candidate for that version refuses instead of silently superseding it. +The finalized JS/map receipt is sealed locally before encryption; the binding +receipt is sealed after ciphertext exists. These are distinct, named artifacts. + +## Recipient custody + +The maintainer selected encrypted CI archival. GnuPG is release tooling, never a +runtime dependency. Use an isolated public-only home/keyring/config, no ambient +keyring, automatic discovery or unexpected recipients. Pin maintainer-confirmed +primary and encryption-capable subkey fingerprints. Refuse missing, expired, +revoked or unusable recipients using machine-readable status; check subprocess +exit, bounded output and exact produced asset. Private keys never enter repo/CI. + +Encrypt the archive once; OpenPGP session-key randomness is expected +([RFC 9580](https://www.rfc-editor.org/rfc/rfc9580.html#section-2.1)). +Retain original ciphertext before retryable upload. Temporary plaintext is cleaned +on all paths without claiming secure erasure. Diagnostics are fixed and never +print content/key data. Synthetic controls use synthetic maps only; a public +fixture private key may never decrypt a product archive. + +Production setup requires confirmed recipient, offline recovery/decryption check +and backup ownership before an actual release. Rotation is a reviewed public-key +update for future candidates; preserve historical fingerprint/decryption capability +for supported versions. Existing public ciphertext cannot be recalled by revocation. +This wave creates no automatic deletion policy or production key. + +## Archive, retry and publication + +Workflow staging contains only tarball, ciphertext and nonsecret receipt. +A dedicated archive job after pack/smoke creates or reuses a draft Release for the +real pushed version tag, uploads those three assets, downloads/verifies them, then +permits npm publication of **that exact tarball**. A successfully published package +can finalize its matching Release; a failed npm step leaves an identifiable draft +candidate for recovery. Existing conflicting Release/tag/assets refuse. + +Archive and npm both require real pushed `v*` tag, package-version equality and +main ancestry. Manual workflow_dispatch, including a tag ref or off-main override, +is build/smoke only: no Release mutation or npm publish. Archive alone gets +Contents write; npm keeps its separate provenance permission. PR/fork runs never +upload release assets. Configure version concurrency to prevent competing candidate +writers and recheck before upload; do not rely only on asset names. + +| Durable state | Permitted action | +|---|---| +| No prior candidate | Build/pack/encrypt once, stage original candidate | +| Trusted candidate staging exists | Retrieve/verify and reuse exact bytes, rerun smoke if needed | +| Complete matching Release assets exist | Retrieve/verify; no rebuild, re-encryption or overwrite | +| Partial upload, trusted full staged candidate recoverable | Verify present assets against it; add only absent matching assets | +| Conflict or expired staging without complete recoverable candidate | Fail with explicit manual recovery; never invent replacement matching ciphertext | + +Retain exact tarball as well as ciphertext/receipt for every supported release: +Actions retention can expire even when npm publication failed. Release is not +private storage; only ciphertext protects debugging material. Do not automatically +delete or replace per-version assets. Before npm retry, detect existing version +and compare package integrity; different bytes refuse, equal published bytes +count as completed publication rather than republishing. + +Required PR/push gates pack once and all supported OS consumers install that same +digest. Include a packed runtime consumer at **Node 22.0.0**, separate from the +workspace install floor at 22.13, plus current .nvmrc on macOS/Linux/Windows. +If the pinned runtime closure fails the public floor, resolve the compatibility +decision explicitly before acceptance. Reuse the same smoke in release CI. +Controls cover actual run/chat/migrations/native load/MCP version/CAS/iterator +identity and early import failure without keychain/provider calls. + +Wave 3 implements this path with offline/API-fake controls and a dedicated +crypto/CI-custody security sitting. Actual production configuration/publication +belongs to Wave 4b and fails closed if custody is missing. Required final whole-wave +checks repeat exact-artifact evidence after later CLI/DB changes. diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index 1e62f66f..d02ab8a7 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -259,3 +259,68 @@ This is covered in full in [../desktop/keychain-and-secrets.md](../desktop/keych ## Schema versioning The workflow and agent files inside `.relavium/` carry their own `schema_version` (see [workflow-yaml-spec.md](workflow-yaml-spec.md)). Because the entire `.relavium/` directory is committed and shared, both the config files and the workflow/agent files are treated as stable, versioned, public formats — breaking changes require a migration path, never a silent reinterpretation of existing keys. + +## Proposed Wave 3 chat policy and preferences + +**Proposed, unshipped — 2026-10-15.** [ADR-0112](../../decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) +requires maintainer approval. These additions do not change current schema until +implementation landing. + +- Global-only `[chat_http].allowed_domains: string[]`, optional, absent/empty deny-all. + At most 128 entries, each at most 253 characters after an optional terminal + root-dot is removed. Canonical ASCII DNS: labels 1–63 characters with alphanumeric + ends and internal hyphens, at least two labels; case-fold/root-dot normalize and + deduplicate. IDNs require explicit punycode. Reject URLs, ports, paths, credentials, + wildcards, suffix grants, IP literals, single-label names, and names equal to or ending in .localhost or .local. Existing + shared URL/authority extraction supplies request hostname normalization; use one + pure DNS-name validator/comparator for config and dispatch, not another URL parser. +- Global-only `[preferences].keychain_unavailable_acknowledged: boolean`, optional, + absent=false. It is reminder state, not credential state. Only a typed backend + unavailable result plus explicit user choice may set it; no startup auto-write. + +Project/workspace/agent/session/environment layers cannot supply or override either +field. Read normal global config or explicitly selected `--config` only. Invalid +fields/values refuse with `config_error`, exit 2, fixed value/path-free diagnostic +naming the known setting and recovery action. Extend the safe issue formatter for +these rules; do not emit raw Zod/TOML messages or generic “value is invalid” alone. +Standard protected global storage and arbitrary operator-selected paths have +different filesystem guarantees; explicit selection is not proof of model +unwritability. Other network capabilities retain their own contracts. + +The invocation captures initial ceiling I and accepted ceiling A=I. Before **every** +host creation (including Home's later chats, resume, clear and reseat), reread only +global HTTP policy G and set A=A∩G before later build effects. A validated removal +remains effective even if the rest of that build fails. Never reset A or use captured +startup chat/model-read fallback on failure. Read/parse failure refuses creation; +it does not supply previous authority. Widening/restoring a removed grant requires +a fresh CLI invocation. An active host owns its snapshot until cancelled/rebuilt; +cancel/retire it before replacement. No session row persists this authority. + +This permits only built-in `http_request` to exact allowed hostnames at existing +HTTPS ports; it does not enable the tool grant, a missing confirmer, search, MCP, +provider traffic or shell egress. See the +[tool comparison contract](../shared-core/tool-registry.md#proposed-wave-3-http-authority-and-secret-provenance). + +Expose a typed, value-free current key-availability observation before Home's UX +choice: usable key (including env fallback), missing entry, or unavailable backend. +Do not infer backend failure from absence or native error strings. Acknowledgement +is used only with actual current unavailable+keyless state; a usable key always +suppresses setup. Retry setup is explicit; ordinary missing-key/auth/DB failures +are not relabelled keychain unavailable. Preference write failure stays visible. + +For mutation, final-component symlink (including dangling link) refuses with +`ConfigWriteRefusedError`, code `config_write_refused`, reason `symlink_target`, +non-retryable, fixed path-free message and exit 2. Read-side links remain supported. +Offer manual edit/regular-file replacement and honest compatible ephemeral setup, +not Retry or saved-success. Ancestor links remain owner-controlled; hardlinks get +atomic replacement of the chosen entry, not all aliases. No lstat+rename race-free +claim. Regular writes retain schema round-trip/0600/temp/fsync/atomic rename. +TOML comments/order are lost as in ADR-0063; preserve every validated policy value +and tell the operator this before writing a hand-edited policy. + +**Rollback to an older strict binary:** back up the non-secret TOML; remove the +entire `[chat_http]` table and the `keychain_unavailable_acknowledged` entry from +`[preferences]`, leaving other preferences intact. Then run the older binary. +Without removal, executing commands reject unknown keys with config error/exit 2; +meta operations remain config-free under ADR-0111. Provider keys stay in OS +keychain. For an intentional link, edit the operator-chosen referent manually. diff --git a/docs/reference/shared-core/agent-runner.md b/docs/reference/shared-core/agent-runner.md index 10b9f267..45a3bd95 100644 --- a/docs/reference/shared-core/agent-runner.md +++ b/docs/reference/shared-core/agent-runner.md @@ -369,3 +369,24 @@ cannot replace the primary. A cleanup failure with no earlier primary remains a these paths authorizes another provider call, releases an uncertain admission or fabricates a successful settlement. The supported host admission callback is the failure boundary; a distinct second synchronous cleanup error is not claimed for the current built-in governor. + +## Proposed Wave 3 qualified context consumers + +**Proposed, unshipped — 2026-10-15.** [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md) +requires approval. The exact projection, VM and refusal contract belongs to the +[expression specification](expression-sandbox-spec.md#proposed-wave-3-secret-context-boundary). + +WorkflowEngine qualifies raw resolveContext output once at fresh start/resume and +standalone cross-process budget preparation, before storing or invoking callbacks. +NodeExecContext and NodePreparationContext carry the same required projection. +The preparation Pick must actually include it. resolvePrompt/public-template +lowering consumes denied keys before filter/default resolution; preparation has +no expression sandbox and no unused VM-only metadata. Execution scope consumes +the projection for condition/transform/merge, and agent/human text uses the same +public template path. Test each producer and consumer with a witness. + +Embedding migration replaces raw ctx records with the factory-owned projection +from the admitted workflow; clean embeddings explicitly construct an empty-denial +projection. No implicit absent metadata. Existing raw secret NodeExecContext.inputs +remain the trusted-host contract and continue to be masked at public/sandbox +boundaries. This context repair is not a universal custom-executor secrecy claim. diff --git a/docs/reference/shared-core/expression-sandbox-spec.md b/docs/reference/shared-core/expression-sandbox-spec.md index aa2130ed..33171796 100644 --- a/docs/reference/shared-core/expression-sandbox-spec.md +++ b/docs/reference/shared-core/expression-sandbox-spec.md @@ -256,3 +256,59 @@ VM because the `Eval` intrinsic must stay on for `evalCode` to compile, yet reac forbidden capability — `Date`/`Math.random`/`Promise` absent, secret-in-scope yields a secret-free error, cap-trip classification, determinism over shuffled `run.outputs` key order) are a binding part of 1.AB per [testing.md](../../standards/testing.md#security-critical-primitive-tests-direct-negative-case). + +## Proposed Wave 3 secret context boundary + +**Proposed, unshipped — 2026-10-15.** [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md) +requires maintainer approval; existing direct-input marker behaviour is preserved. + +The one `WorkflowContextProjection` owns `values: Readonly>` +(clean keys only) and `deniedKeys: readonly string[]`. Copy/freeze both, detach +record prototypes, and reject duplicate/overlapping or malformed keys. One pure +factory qualifies resolved context against the admitted workflow's existing linear +taint result. It contains names, never raw tainted bytes. The low-level execution/ +preparation `ctx` field becomes this required projection; raw resolver return +remains available only under its trusted public contract. No second raw context +is passed to an executor. Missing/malformed projection refuses with existing +validation ErrorCode and a fixed message, never defaults clean. Trusted host +dishonesty is outside this shape/ownership guarantee. + +Actual consumers: + +- Preparation and agent/human text lowering use `resolvePublicTemplate`, reading + clean values and rejecting denied context references **before filters/defaults**. + Use the existing reference lexer, not a new scanner. A fixed + `InterpolationError` reason `secret_reference` maps to non-retryable validation. +- Expression scope construction keeps masking direct inputs and supplies this + projection. Before raw scope serialization validate metadata; no denied bytes + are serialized. Low-level evaluate calls require explicit projection (owned + empty deniedKeys is the clean migration), not an omitted VM-only field. +- In QuickJS, denied keys are enumerable frozen accessors with a private sticky + attempted-access latch. Raw denied values do not exist in the VM. Author code + runs in a separate strict lexical environment receiving only the public bindings, + outside the latch/serializer closure. Capture required intrinsics first. +- Serialize/marshal the result once inside the trusted wrapper, then test the latch + before accepting the type/result. Author catch, returned ctx/nested getter/toJSON, + eval/name guessing or intrinsic mutation cannot produce successful denial. + Keep current primitive, condition, nonserializable/cycle and lossy JSON rules; + JSON-normalizing first must not conceal a forbidden result type. + +Denied access yields `SandboxError.reason: 'secret_context'`, existing +`code: 'sandbox_error'`, retryable=false, fixed message +“secret-derived context is unavailable to expressions”, with no source/value +detail or cause. The host trusts only its private wrapper outcome envelope, never +an authored throw's name/message. A denial overrides authored success/failure; +actual abort/timeout still has its existing cancellation/resource disposition, +with no false successful output. + +Clean value reads and key-only enumeration are allowed even when unrelated keys +are denied. Reading a denied value fails; it never becomes a false comparison, +marker success or output-schema pass. Existing direct-input marker semantics are +unchanged. `SandboxError.detail` for other arbitrary authored errors is internal, +may carry sensitive text and must not be rendered or called universally secret-free. + +The [runner](agent-runner.md#proposed-wave-3-qualified-context-consumers) owns +producer migration; [tool provenance](tool-registry.md#proposed-wave-3-http-authority-and-secret-provenance) +records the separate unsupported mapping boundary. Actual engine/store tests cover +run_events.payload_json, step_executions.output_json and runs.output_json with +positive completed/persisted controls, not only failure-path absence assertions. diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index 6db10131..27d881b3 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -498,3 +498,30 @@ whole registry unit-testable with zero real side effects (the engine coverage ba 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. + +## Proposed Wave 3 HTTP authority and secret provenance + +**Proposed, unshipped — 2026-10-15.** [ADR-0112](../../decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) +and [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md) +require maintainer approval. + +HTTP policy compares canonical **hostname**, not URL host-with-port. Reuse the +shared HTTPS authority parser and one pure DNS normalizer/comparator for both +request and allowlist. Permission is exact, with no suffix/subdomain match; +existing HTTPS ports remain allowed, not 443-only. Native DNS/range, pinned +connection, redirect/time/size floors still apply. Chat's global policy is defined +only in [config-spec.md](../contracts/config-spec.md#proposed-wave-3-chat-policy-and-preferences); +workflow policy and independent command/MCP/provider egress are not expanded here. + +At implementation landing correct both the dispatch-context secret-argument +comment and Guardrail enforcement's “1.O/1.V producer” claim. No live authored +tool-input mapping producer exists; agent/session config is empty. The optional +external `secretArgKeys` is unverified when absent, not a verified clean result. +Nonempty tainted effective arguments continue to refuse before approvals/effects. + +Any future mapped path must fail closed on absent provenance and project taint +after final model/mapping/config precedence to top-level keys, including nested +parents, before approval persistence or dispatch. Marker serialization cannot +clear provenance. This is a prerequisite for activating that feature, not a claim +that this wave implemented or exercised it. Actual expression-context repair and +#92's documented mapping limitation receive separate dispositions. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index ca6a635c..ac8f7b78 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1106,14 +1106,16 @@ an ESLint selector rather than a list. ### Wave 3 — Clear the ground -**Preflight reviewed; maintainer approval pending — 2026-10-15.** The original queue below is reconciled against -current source in the [Wave 3 preflight and proposed execution plan](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-preflight-and-proposed-execution-plan--2026-10-15). -It records already-satisfied items, concrete compatibility/security decisions and -seventeen proposed increments. Two new independent complete cumulative final -proposal reviews at `39967075` are clean. ADR-0110–0113 are Proposed; implementation has not -started and dependent code awaits maintainer approval. Historical finding descriptions -are not new assertions about current source. The existing Wave 5a renderer/word-wrap -ownership is preserved, not newly approved by this preflight. +**Proposal reopened after maintainer source review — 2026-10-15.** +[Source dispositions and revised plan](phases/phase-2.5.5-hardening-and-remediation.md#maintainer-review-reopened-the-proposal--2026-10-15) +correct G15's false token-persistence premise, fatal/lazy build dependencies, +exact-byte archive mechanics, HTTP rebuild revocation and secret-context consumers. +Nineteen proposed increments retain the serialized first queue. ADR-0110–0113 +and their explicitly unshipped canonical sections remain Proposed; no code has +started. Earlier clean proposal reviews retain historical scope and no longer +establish readiness. Fresh cumulative proposal reviews and maintainer approval +are required. The encrypted archive method is selected; no production key, +provider capture, billing or upload is needed for this review. Decompose, guard and normalize every hot file a 2.6 workstream is about to land on. **The multi-file queue runs first and is serialized against the rest**; the remaining queues are file-disjoint, any order. @@ -1145,14 +1147,14 @@ on `home-controller.ts`/`chat-ink.tsx` (the decomposition moves already-guarded - **`packages/shared` (strict)** — the `.code` ruling (G50, D10) → `findDuplicates`/`superRefine` at all seven remaining schema sites (#89, #90, G16, G17, G18, #210, #211, #212) → `BaseEventSchema` disposition (#213) → the `RelaviumError` base class (#219). *The ruling must precede the base class, or it is baked in permanently.* -- **`registry.ts` / `builtins.ts` / `engine.ts`** — `ctx.secretArgKeys` producer (#92) → `ctx.fsScope` (#93) → +- **`registry.ts` / `builtins.ts` / `engine.ts`** — declared-source context repair and honest unsupported-mapping disposition (#92/Proposed ADR-0113) → `ctx.fsScope` (#93) → `ok`/`success` (#216, #221) → identifier-sort `localeCompare` pinning (#258, #259) → the `git_commit` denial copy (#308); then the four schema length caps (#95), the `llmVisibleParams` rewrite (#96, #98), stale doc comments (#78, #97, #214); then `#armRunTimeout` (#75), `#dispatch` dead branch (#79), `AbortSignal` into `resolvePrompt` + the human-gate recheck (#76, G9). -- **Process/CLI boundary (strict head)** — 2.5.5.E · the five crash/signal net gaps (#40, #41, #42, #224, - #269) → the `--help`/`--version` ~400 ms tax (#43, #44, #271); then the flag-system ruling (#45, #264, - #265, #268), the exit-code and `--help --json` rulings (G35, #13), `logs` coalescing (G15), the `--input` +- **Process/CLI boundary (strict head)** — the revised plan serializes pure flag/descriptor/owner substrate → lazy guarded runtime graph → native crash integration → map/archive custody → command behaviour. It covers 2.5.5.E · the five crash/signal net gaps (#40, #41, #42, #224, + #269), the `--help`/`--version` historical ~400 ms tax (#43, #44, #271), and the flag-system ruling (#45, #264, + #265, #268), the exit-code and `--help --json` rulings (G35, #13), durable-only logs preservation (G15 refuted), the `--input` namespace ruling (#18, #310). **`openLocalDb` wrap-and-redact (#278, #280) lands LAST** — it touches 8+ call sites the other queues are still moving. - Then the tails of 2.5.5.A, E, G and I in any order; sub-stream **A, E and G acceptance sign-offs**; @@ -1284,9 +1286,9 @@ unanswered**; the remainder sit inline in their own phase-file bullet. | D15 | 2 | What masking guarantee does the session fixture (#115) certify? | **Reconciled 2026-10-12:** ADR-0095/0098 supply structural refusal, not universal masking. Certify the actual producer/SQLite/export fixtures and retained-content limits. This repairs the missing Wave 2 decision row; it is unrelated to historical media D15. | | D17 | 2 | Book security reviews **by threat class**, not per PR? | **Accepted 2026-10-13:** four new sittings (filesystem, secret input, secrets at rest, config trust), plus W4 hostile-MCP sitting/current regression revalidation. [Closing evidence](phases/phase-2.5.5-hardening-and-remediation.md#wave-2-closing-register--2026-10-13). | | D18 | 3 | `chat-ink.tsx` (1,795 lines, in neither doc): decompose or decline? | **Decline** — hand it to 2.6.M's render-v2 in Wave 5a, where the palette sweep then runs once | -| D19 | 3 | `--quiet`/`--verbose`/`RELAVIUM_DEBUG` precedence | `--quiet` < default < `--verbose`; `RELAVIUM_DEBUG=1` an alias, explicit flag wins; `--json` overrides for stream shape | +| D19 | 3 | `--quiet`/`--verbose`/`RELAVIUM_DEBUG` precedence | **Proposed ADR-0110:** retain quiet+verbose conflict; either explicit flag overrides exact DEBUG=1. This replaces the earlier recommended precedence ladder; approval pending. JSON remains independent. | | D20 | 3 | `--input`'s four-way collision: rename or document? | Rename `models pricing` to `--input-price`/`--output-price`, old spellings deprecated aliases | -| D22 | 3 | Read EXIT:4 as "no **breaking** change"? | Yes — with the additive exit code and the noun-verb aliases as the two sanctioned, documented exceptions | +| D22 | 3 | Read EXIT:4 as "no **breaking** change"? | **Proposed ADR-0110:** no unrecorded break; its enumerated compatibility scope replaces the old two-exception recommendation. Reconcile phase exit criterion 4 and E no-new-ADR wording at acceptance, with ADR-0049/0056 forward notes before code. Approval pending. | | D26 | 3 | Which session would bare Home's `showCost` target? | **Proposed preflight disposition:** keep `/cost` chat-only; certify both live chat surfaces. Bare Home currently cannot invoke the no-op. Past-session selection remains 2.6.G; no implicit cost release. See the [Wave 3 plan](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-preflight-and-proposed-execution-plan--2026-10-15). | | D28/D29 | 4a | Flip ADR-0058 and ADR-0060 to Accepted | Yes; 0060's taint model is the cited precedent for Wave 7's generation-security ADR | | D31 | 4a | Strike 2.6.H's chat-persister atomicity sub-part as a duplicate? | Yes — owned by Wave 1; record the closure | diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 286c5445..32984a24 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -3632,11 +3632,11 @@ changes a currently Accepted ADR or a canonical reference before approval. | Decision | Recommended choice and compatibility boundary | |---|---| -| ADR-0110: D19/D20/D22, #13/#19/#309, G15/G35 | [Explicit CLI compatibility](../../decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md): retain quiet+verbose conflict; exact DEBUG=1 alias; preserve JSON/safety data; internal exit 9; versioned explicit JSON help; alias-preserving pricing/chat syntax; key-delete consent; bounded human log coalescing. Genuine run outcomes and exits 3–8/signals keep their contracts. D22's old two-exception shorthand must be reconciled to this explicit enumerated scope. | +| ADR-0110: D19/D20/D22, #13/#19/#309, G15/G35 | [Explicit CLI compatibility](../../decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md): retain quiet+verbose conflict; exact DEBUG=1 alias; preserve JSON/safety data; internal exit 9; versioned explicit JSON help; alias-preserving pricing/chat syntax; key-delete consent; G15 refuted with durable-only replay preservation. Genuine run outcomes and exits 3–8/signals keep their contracts. D22's old two-exception shorthand must be reconciled to this explicit enumerated scope. | | ADR-0111: #43/#44/#46/#271 | [Lazy packaged runtime](../../decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md): lightweight descriptor-only meta graph; engine-inlined finite chunks, complete closure guard and packed platform controls; exact-byte private sourcemaps. The maintainer selected public-recipient encrypted CI archival; GnuPG, pinned recipient and per-version ciphertext retention are proposed mechanics. Public plaintext artifacts are prohibited. | -| ADR-0112: G30/#34/#49/#52/#55 | [Operator-owned chat policy and preferences](../../decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md): global-only domain authority, exact DNS/deny-all/approval/SSRF floors; explicit unavailable-keychain acknowledgement with current-state recheck; truthful model-write recovery; refuse intentional config symlink replacement. | -| ADR-0113: #92 and reproduced leak | [Declared-source context provenance](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md): reuse existing linear taint graph, preserve trusted raw resolution, mask tainted sandbox/public sinks and make low-level provenance mandatory. Record unsupported future tool mappings honestly rather than fabricate a producer. | -| D18, D26/#125, #311 | Keep chat-ink restructuring with Wave 5a; keep cost chat-only with both real surfaces tested; preserve unknown-command non-echo and add static help. These are explicit dispositions, not new features or undisclosed deferrals. | +| ADR-0112: G30/#34/#49/#52/#55 | [Operator-owned chat policy and preferences](../../decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md): global-only domain authority, exact DNS/deny-all/approval/SSRF floors; explicit unavailable-keychain acknowledgement with current-state recheck; truthful model-write recovery; non-retryable final-component link refusal and honest recovery; monotonically narrowing global HTTP grants. | +| ADR-0113: #92 and reproduced leak | [Declared-source context provenance](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md): reuse existing linear taint graph, preserve trusted raw resolution, qualify context before executor/preparation and fail loudly on denied VM/template reads; required owned projection. Record unsupported future tool mappings honestly rather than fabricate a producer. | +| D18, D26/#125, #311 | Keep chat-ink restructuring with Wave 5a; keep cost chat-only with both real surfaces tested; correct Commander parser-path token echo and add static help; preserve dispatcher privacy. These are explicit dispositions, not new features or undisclosed deferrals. | | #89/#90/G18/#210/G17 | Apply missing duplicate/cross-field guards at leaf and full boundaries. Condition equality is typed (`true` differs from `'true'`; 0 equals -0). Duplicate fallback tuples are provider+model within the fallback list; do not forbid equality with the primary. Timeout without action retains default reject; action without timeout refuses. | | G17 RunSchema detail | Preserve optional legacy timestamps and outputs. Reject completedAt on nonterminal states, startedAt on pending, and error outside failed; retain temporal checks. Terminal records need not invent timestamps/error fields. Do not adopt a stricter logical schema as a DB reader or migrate historical rows. Existing tests accepting contradictory present fields must change deliberately. | | #213/#219, #217/#222/#239/#274 | Retain named event exports while sharing the actual exactly-one-correlation envelope validation without payload stripping. Add a minimal shared error base, preserving constructor/name/code/kind/reason/context/cause/guards and existing structural boundary checks; inventory current classes, not historical 15. Add ModelFeature with deprecated Capability alias; retain Accepted ToolHost *Capability names, use Port for new orchestration interfaces. Retain documented adapters/validate compatibility surfaces. | @@ -3656,24 +3656,26 @@ No credential values or request captures belong in tracked evidence. |---|---| | 1 | **Serialized multi-file queue first:** shared close handling #22 (four named siblings plus newer owned journal sites) → safe source labels #4/G11 plus #3's UTF-8 source admission parity → exhaustive session/picker/run switches #300/#301/G3 and actual reasoning rendering. Preserve primary outcomes and host-safe-close ownership. For #3, use the sibling 4× byte precheck plus authoritative parser character bound; test a dense multibyte source above the old byte cap but below the character cap, ASCII character overflow and true byte overflow. Add native error and mounted output regressions. | | 2 | **Shared admission and diagnostics:** #89/#90/#212/G18/#210/G17, certify #211/G16; shared parser reason helper #85/#86 and config numeric bounds #32. Approved status/duplicate matrices, direct leaf/full parse tests and no value-bearing diagnostics. | -| 3 | **Secret provenance:** ADR-0113/#92, fresh/resume/preparation/condition/transform/merge paths, source-to-event negative controls and clean trusted-resolution positives. Dedicated security sitting, no future mapping implementation. | +| 3 | **Secret provenance:** ADR-0113/#92, fresh/resume/preparation/condition/transform/merge paths, engine-owned projection and actual VM/template consumers, fail-loud denial, real SQLite columns and positive completed/persisted controls. Dedicated security sitting, no future mapping implementation. | | 4 | **Error/envelope substrate:** #213/#219, actual migrated-class inventory and cross-bundle structural guards. Preserve exported names/discriminants, constructor/cause behaviour and event payloads. Convert only supported error classes, not vendor errors or plain seam unions. | | 5 | **Tools and engine tails:** #93/#95/#96/#98/#97/#214/#221/#258/#259/#308, #94/#82/#87/#76/G9/#79/#274/#275. Bounds before effects, model-visible semantics, existing ordinal helper, path-local cycle guards, cancellation race and deterministic plan equivalence; certify #75/#78/#216 instead of rewriting them. | -| 6 | **Process head:** #40/#41/#42/#224/#269/G35 under ADR-0110. Land internal exit 9 with its fatal/EPIPE consumers; preserve genuine workflow and specific safety outcomes. Fatal/EPIPE/owned process and DB/MCP lifetimes retain recoverable #228 keepalive and W4 signal contracts. Native child/grandchild, broken-sink and receipt-join controls. | -| 7 | **Lazy startup and release substrate:** #43/#44/#46/#271 under ADR-0111. Descriptor/runtime graph, early meta routing, catalog timing, finite chunks, exact pack closure, minimum-Node/platform and private archive tests. Actual archive configuration status is separate from fixture proof. | -| 8 | **CLI contracts:** #45/#264/#265/#268/#13/#17/G15/#18/#310/#309/#19 under ADR-0110, consuming Step 6's G35 exit distinction. Category-aware flags, explicit help JSON, pricing/chat aliases, human coalescing and deletion consent; byte-preserving JSON/old-alias integration. | -| 9 | **Chat normalization and abort:** #236/#237/#242/#307 → #218 → #21/#23. Canonical token/money source with approved additive LLM export, preserved two-decimal cap formatting and real immediate abort while a turn is blocked. Audit Home's imports. | -| 10 | **Chat decomposition:** #220/#244 after preceding chat fixes. Split lifecycle/drivers, line handler, printers and reseat/clear ownership. Preserve compatibility entry points, W7/W8 receipts, unknown-usage, full-screen bounds, disclosure activation and cancellation. | -| 11 | **Chat domain policy:** G30/ADR-0112 after decomposition, all fresh/resume/Home/agent/rebuild paths. Real host-policy dispatch and no-authority/SSRF/approval controls; separate egress/config-trust security sitting. | -| 12 | **Home controller:** certify G37, fix #281, record/test #125/D26 before #215/#243 decomposition; then #217/#222 scoped naming. Overlay discriminated union, subcontrollers, stale async work/focus/approval invariants and both live cost surfaces. | -| 13 | **Viewport/input:** #58/#62/G40/G51 → #65 → #64/#66, then #63/#69/G4/G55. Grapheme-correct editing/truncation, tabs, bounded caches, safe resizing, TERM=dumb and both small-terminal surfaces, input history and mouse bounds. CJK measurement and real emitted rows. | -| 14 | **Onboarding and overlays:** #49/#52/#51/#53/#55/G5/G52/G41/G53 and #34 under ADR-0112. Real next-model binding, recoverable persistence, explicit acknowledgement, current keyless nudge, hidden-approval safety, color-off mounts and native symlink preservation; keychain/config security sitting. | -| 15 | **Remaining CLI/hygiene:** #24/#26/G43/G2/#12/#54/G14/#11/#47/#48/#240/#241/#311/G54/#303/#304; certify #20/G36/G46. One-shot coverage-aware cost/banner, actual-host doctor, typed faults, no silent missing-dist skip, guard misuse, attachment estimate and compiler checks. #239 remains the approved documented compatibility surface. | -| 16 | **openLocalDb LAST:** #278/#280 across every moved caller. Central fixed safe error wrapping, attempt-all secondary cleanup, preserved causes, privacy/checkpoint notices and exact writer lifetimes. Actual command-family failure probes; no duplicate wrapper left behind. | -| 17 | **Whole-wave acceptance and publication:** every item disposition; A/E/G and M2.5.5-3 evidence; ordinary/enforced coverage and required CI/build/fences/smokes/causal/platform checks at attributed source; measure and promote core's ≥90% line/branch floor to required CI as testing.md requires after Wave 3 coverage work, fixing meaningful missing coverage before closure. Two new whole-wave cumulative reviews, canonical doc/ADR landings and PR. I/whole phases/later product waves stay open. | +| 6 | **Pure process/discovery substrate:** ADR-0110 categories, shared early flag grammar, explicit exit/site inventory, enriched descriptor registry with legacy/model and new CLI projections, lightweight local errors and invocation owner registry. Contracts precede consumers. Unit/source checks do not claim fatal/native or JSON-help integration complete. | +| 7 | **Lazy runtime and package graph:** #43/#44/#271/ADR-0111, consuming Step 6. Guard dynamic imports from a lightweight entry; defer classifier/sanitizer/service barrels and MCP wiring; preserve filesystem → fallback cache → DB catalog → model/cost ordering and media exception. Finite flat chunks, all-output closure, real packed existing meta/run/chat/migration/identity and minimum-Node/platform controls. New JSON help waits for Step 10. | +| 8 | **Native process integration:** #40/#41/#42/#224/#269/G35 under ADR-0110, after the early graph can catch import failures. Wire registered owners, classified EPIPE, generic exit 9 and throw-path final-health precedence. Preserve known history/departure refusals, recoverable #228 and signals; native child/grandchild/Windows, both sinks and externally watched fatal-budget controls. No false host-safe-close ACK. | +| 9 | **Release map/custody substrate:** #46/ADR-0111, independently reviewed after the finite graph. Upstream TS maps, sole prepack final build, pre-hash trailer removal/staging, exact tarball/CLI+engine symbolication, encrypt-once candidate and retry/partial-upload/conflict controls, archive-before-npm DAG and required artifact/platform gates. Dedicated archive/crypto security sitting. Fixture proof never claims production recipient/custody configuration. | +| 10 | **CLI behaviour/discovery integration:** #45/#264/#265/#268/#13/#17/#18/#310/#309/#19 under ADR-0110, consuming Steps 6–9. Production owner category matrix, real root/subtree JSON help, pricing/chat aliases, non-echoing Commander refusal and signal-safe deletion consent. G15 is refuted and receives real engine/store/log preservation evidence, with no invented token coalescing. Repeat affected packed gates. | +| 11 | **Chat normalization and abort:** #236/#237/#242/#307 → #218 → #21/#23. Canonical token/money source with approved additive LLM export, preserved two-decimal cap formatting and real immediate abort while a turn is blocked. Audit Home's imports. | +| 12 | **Chat decomposition:** #220/#244 after preceding chat fixes. Split lifecycle/drivers, line handler, printers and reseat/clear ownership. Preserve compatibility entry points, W7/W8 receipts, unknown-usage, full-screen bounds, disclosure activation and cancellation. | +| 13 | **Chat domain policy:** G30/ADR-0112 after decomposition, all fresh/resume/Home/agent/rebuild paths. Real host-policy dispatch and no-authority/SSRF/approval controls; separate egress/config-trust security sitting. | +| 14 | **Home controller:** certify G37, fix #281, record/test #125/D26 before #215/#243 decomposition; then #217/#222 scoped naming. Overlay discriminated union, subcontrollers, stale async work/focus/approval invariants and both live cost surfaces. | +| 15 | **Viewport/input:** #58/#62/G40/G51 → #65 → #64/#66, then #63/#69/G4/G55. Grapheme-correct editing/truncation, tabs, bounded caches, safe resizing, TERM=dumb and both small-terminal surfaces, input history and mouse bounds. CJK measurement and real emitted rows. | +| 16 | **Onboarding and overlays:** #49/#52/#51/#53/#55/G5/G52/G41/G53 and #34 under ADR-0112. Real next-model binding, recoverable persistence, explicit acknowledgement, current keyless nudge, hidden-approval safety, color-off mounts and native symlink preservation; keychain/config security sitting. | +| 17 | **Remaining CLI/hygiene:** #24/#26/G43/G2/#12/#54/G14/#11/#47/#48/#240/#241/#311/G54/#303/#304; certify #20/G36/G46. One-shot coverage-aware cost/banner, actual-host doctor, typed faults, no silent missing-dist skip, guard misuse, attachment estimate and compiler checks. #239 remains the approved documented compatibility surface. | +| 18 | **openLocalDb LAST:** #278/#280 across every moved caller. Central fixed safe error wrapping, attempt-all secondary cleanup, preserved causes, privacy/checkpoint notices and exact writer lifetimes. Actual command-family failure probes; no duplicate wrapper left behind. | +| 19 | **Whole-wave acceptance and publication:** every item disposition; A/E/G and M2.5.5-3 evidence; ordinary/enforced coverage and required CI/build/fences/smokes/causal/platform checks at attributed source; measure and promote core's ≥90% line/branch floor to required CI as testing.md requires after Wave 3 coverage work, fixing meaningful missing coverage before closure. Two new whole-wave cumulative reviews, canonical doc/ADR landings and PR. I/whole phases/later product waves stay open. | Steps are dependency-safe increments, not permission to parallel-edit shared hot -files. Within each row, arrows remain strict. Step 1 completes before all other +files. Within each row, arrows remain strict. The process/packaging sequence replaces the old fatal-before-lazy assumption: pure contracts → guarded lazy graph → native fatal integration → map/archive custody → remaining CLI behaviour. It is nineteen increments, with no parallel edits of shared hot files. Step 1 completes before all other code. Home cost is disposed or implemented before its decomposition; viewport geometry work never races editor/cache work. If review exposes a necessary smaller increment, split it with unchanged ID coverage and record the split before proceeding. @@ -3749,3 +3751,50 @@ The archive-method choice is complete; production recipient/retention configurat belongs to actual release preparation. No provider request, key access, billing, archive upload or code implementation has started. Detailed reports remain local and Git-ignored, and no proposal round substitutes for a future implementation review. + +#### Maintainer review reopened the proposal — 2026-10-15 + +**Current status: revised proposal, not approved; no implementation started.** +The clean proposal reports at 39967075/20af0617 are historical and were insufficient +source verification. Both submitted reviews reopen readiness. Three source audits +(CLI/process, packaging, chat/taint) and offline feasibility probes below replace +unsupported premises; they are not implementation acceptance or final proposal rounds. + +| Reviewed concern | Verified disposition / revised mechanism | +|---|---| +| Public repo versus source-map secrecy | Remove symbol/layout secrecy premise; retain the maintainer-selected encrypted archive as distribution/custody policy, consistent with ADR-0051. | +| G15 shipping token replay | Refuted by live-bus emit and durable-store/log code. Preserve actual replay and add engine/store/log regression; no invented token persistence/coalescing. Historical inventory remains provenance. | +| Exit 9 / broad internal / surfaces / EPIPE | Inventory each site; known history/departure/operational failure remains classified 1, real outcomes/signals retain meanings, generic CLI/wiring failure is 9. EPIPE is operational output_unavailable, not unknown. Canonical surface table and inspect-before-retry remedy are proposed. | +| Fatal ownership, health, early flags and lazy entry | One lightweight invocation-owned registry, actual retirement promises, return/throw health precedence, responsive fatal backstop without false store ACK; pure contracts precede graph, native integration follows graph. Windows is an explicit native acceptance obligation. | +| Quiet/verbose/debug/non-echo | Real output-owner taxonomy and safe bounded projection; no raw arbitrary stack/cause guarantee. Exact DEBUG=1 and Commander parser non-echo are explicit compatibility changes. | +| Help manifest / aliases / #18/#310 | One enriched descriptor authority, compatible legacy model projection and complete new CLI DTO; groups may execute, flags retain source presence until conflicts checked. Remaining command-scoped input meanings deliberately stay. | +| D19/D22 / ADR-0049 versus ADR-0056 / exit criterion 4 | Proposed decision rows explicitly replace the old ladder/two-exception shorthand. At acceptance append both ADR forward notes and reconcile phase criterion 4/E's no-new-ADR claim before code; event rails remain unchanged. | +| Pack/prepack/maps/symbolication | Sole final CLI prepack build, upstream maps chained to original TS, known tsup trailer removed before receipt, maps staged outside package; exact packed CLI and engine source-location controls. | +| Randomized encryption / archive permissions / retries | Encrypt once, retain original tgz/ciphertext/binding receipt, verify/reuse on retry, refuse conflicts/unrecoverable expiry. Separate trusted-tag archive job and dry-run dispatch; production setup remains Wave 4b. | +| Catalog / ADR-0072 / media | Preserve filesystem admission before cache fallback, DB overlay before first model/cost read; never late-replace DB prices. Add actual catalog-order/long-tail/empty-DB/repeated-host controls and media maintenance exception. | +| Source-map/platform/crypto gates | New proposed packaging reference owns receipts/retention/retry; required packed OS plus public minimum Node controls and dedicated crypto/CI-custody sitting. Existing workspace floor is not packed-floor proof. | +| HTTP policy freshness / surface claims / threat scope | Global-only chat_http policy, immutable initial and monotonically narrowing accepted invocation ceilings, real rereads on every rebuild, no startup fallback on read error. Default Home/ask denials stay; custom granted chat supplies the positive. Other shell/MCP/provider authority remains separate. | +| Config links / acknowledgement / rollback | Final-component link write refusal is path-free and non-retryable, read remains allowed; manual/ephemeral recovery, no false Connected or Retry loop. Ancestor/hardlink/owner-race limitations explicit. Typed current key availability precedes remembered UX state; exact old-binary key removal/exit 2 is canonical. | +| Secret context / engine boundary / preparation / silent marker | Actual leak reproduced. Qualify once before executor/preparation, retain trusted raw resolver locally/publicly, require one projection; real public-template consumer replaces dead preparation plumbing. VM denied access fails loudly with private sticky latch and checked serialization; direct-input marker contract stays. | +| Optional tool provenance / canonical producer contradiction | Absence remains explicitly unverified compatibility state; no live mapping producer or fabricated empty one. Name both tool-registry passages for correction and retain future fail-closed mapping prerequisite. | +| Taint acceptance / details / mutation | Actual engine+standard executor+QuickJS+SQLite columns, downstream gate and condition oracle with exact successful positive controls; fixed secret_context reason. Copy/freeze arrays, not mutable Sets; error detail is not universally safe. Existing parser/tool controls labelled preservation. | +| Canonical homes / traceability / acceptance timing | Proposed exact contracts live in commands/config/expression/runner/tool/packaging references, with rationale in ADRs. All listed CLI IDs have owners. New ADR acceptance notes precede dependent code; old Accepted bodies are not rewritten. | + +Source audits at 20af0617 include 7 focused CLI suites / 127 passing tests. +Actual engine/standard-executor/QuickJS probes reproduce direct/computed/serialized/ +nested/custom-merge leaks and the condition oracle. Native SQLite positive and +negative runs both complete: the canary reaches three run-event rows, two step +output rows and one run output row; downstream human-gate exposure is confirmed. +Direct/transitive model/human context templates already refuse and remain controls. +The VM private-latch alternative passes feasibility checks for catch, serialization, +returned ctx/getters/toJSON and latch/intrinsic tampering, with clean-read/key-only +positives. An in-memory installed TypeScript→esbuild map-chain probe maps a synthetic +engine frame to its original TS location without embedded sources. These probes +are design evidence, not integrated fixes, full CI, native Windows or release proof. + +**Approval scope:** the revised four Proposed ADRs, linked unshipped canonical +contracts and nineteen-increment plan, including fail-loud context semantics, +monotonic HTTP rebuild grants, non-retryable link writes, operational EPIPE versus +generic 9, and exact-byte encrypted custody. Archive method selection persists. +Fresh complete cumulative proposal reviews and maintainer approval remain pending; +no product task, production recipient, provider/key/billing or upload is claimed. From 359352731c3531301e4486b678f2c590566bde41 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 15 Oct 2026 23:36:05 +0300 Subject: [PATCH 007/222] docs(docs): close wave 3 proposal boundary gaps Specify primitive VM outcome transport and uncached private map builds. Define owned diagnostic frames and finite native teardown scope, and reconcile the active roadmap tasks with verified source facts. Refs: ADR-0110, ADR-0111, ADR-0113 Co-Authored-By: Codex --- ...d-discovery-have-explicit-compatibility.md | 12 ++++-- ...-operations-use-a-lazy-packaged-runtime.md | 6 ++- ...ontext-keeps-provenance-at-public-sinks.md | 19 +++++--- docs/reference/cli/commands.md | 42 ++++++++++++++++++ docs/reference/cli/packaging.md | 16 ++++++- .../shared-core/expression-sandbox-spec.md | 15 +++++-- .../phase-2.5.5-hardening-and-remediation.md | 43 ++++++++++++++++--- 7 files changed, 130 insertions(+), 23 deletions(-) diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index f7cfa59b..3c58b9d9 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -39,7 +39,7 @@ No engine ErrorCode or runtime dependency is added. Keep quiet and verbose mutually exclusive; explicit flags override the exact debug alias defined in the canonical proposal. Narrowing the old defined-variable debug predicate is deliberate. Quiet preserves requested data and safety output. -Verbose adds bounded diagnostic metadata and validated stack-frame locations, +Verbose adds bounded diagnostic metadata and validated stack-frame locations from a closed build-owned relative-file allowlist, never arbitrary error messages, cause trees or raw stack headers. Pattern redaction is defence in depth, not proof that arbitrary text is secret-free. JSON diagnostics, including #269's last resort, remain structured stderr records. @@ -70,8 +70,12 @@ are guarded. Normal/recoverable cleanup still joins actual owners. Known final money/effect/durability/ownership health takes precedence on **return and throw** paths. Unavailable health remains uncertain, with inspection before retry; no terminal or acknowledgement is fabricated. Native teardown uses owned -POSIX groups and a separately tested Windows child/tree mechanism, never a -negative-PID claim for Windows or foreign processes. +POSIX groups and the trusted absolute Windows taskkill facility with fixed +owned-root tree arguments, never PATH lookup or foreign PID scanning. The +canonical contract limits this to live registered roots/current descendants; +root-exit, escape/reparenting and same-user PID reuse are not race-free contained. +A request is not close ACK. Full job-object/native containment is not claimed or +silently deferred as a completed Wave 3 guarantee; existing M/N ownership remains. EPIPE is a classified output-unavailable operational failure, not an unclassified exception. Stop/join producers, preserve stronger safety exits and use at most one @@ -147,5 +151,5 @@ backstop's accepted keepalive policy is distinct from process exceptions. - JSON help, consent, debug predicate and failure classification deliberately alter public behaviour. Explicit compatibility tables and aliases bound the change. -- Fatal cleanup cannot promise crash-safe receipts. Inspect before repeating paid +- Fatal cleanup cannot promise crash-safe receipts or universal descendant containment. Inspect before repeating paid or effectful work; a failed cleanup must not be reported acknowledged. diff --git a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md index 1a554334..55c0b48f 100644 --- a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md +++ b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md @@ -69,8 +69,10 @@ OSes and the declared minimum runtime, then release CI reuses that mechanism. ### One build, exact maps and retryable custody -Enable external upstream TypeScript maps for inlined packages, without embedded -sources. The final bundler chains them to original TypeScript; do not substitute +Emit external upstream TypeScript maps only in a fresh isolated, uncached +candidate build, without embedded sources. Ordinary workspace builds remain +map-free. No Actions/Turbo/remote build-cache read/write or later broad cache +capture may include upstream/final maps, private manifests or staging. The final bundler chains them to original TypeScript; do not substitute source aliases or claim a decrypt test proves engine symbolication. Make **prepack the sole final CLI build** after dependency builds. Finalize tsup diff --git a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md index f6873e81..edcaba6c 100644 --- a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md +++ b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md @@ -64,10 +64,14 @@ stays local to the resolver/projection cut rather than a second executor authori Expression scopes serialize only clean context and denied-key metadata. Inside QuickJS, create frozen context accessors for denied keys with an inaccessible sticky denial latch. Evaluate authored code in a separate lexical environment -receiving only the public bindings; a guessed wrapper-local name or eval cannot -reset the latch. Capture required intrinsic operations before author execution. +receiving a frozen public pack and binding its public names as const; a guessed +wrapper-local name or eval cannot reset the latch. Capture required intrinsic +operations before author execution. After evaluation **and result serialization**, check the latch before accepting a -condition, transform or custom merge. This covers caught access, returned ctx, +condition, transform or custom merge. Return the final null-prototype control +envelope as a primitive string through captured serialization; host typeof/getString, +parse and strict arm validation replace prototype-sensitive object dump. Author +results cannot select an outer control arm. This covers caught access, returned ctx, nested outputs, getters and toJSON, not just literal property reads. Any denied value access produces the fixed non-retryable secret-context sandbox @@ -79,8 +83,10 @@ existing condition/output serialization rules; prototype/intrinsic mutation cann replace the trusted result check. This mechanism requires real VM causal controls, not only a host Proxy or wrapper mock. -A synthetic probe on the installed QuickJS supports private lexical latch, -caught-read and serialization feasibility; it is not product implementation. +The initial ordinary-object feasibility wrapper was forgeable through inherited +toJSON at host dump, despite its private latch. A revised primitive-transport +probe closes that schedule and preserves const binding refusal. Both are design +evidence, not product implementation. Permanent actual-engine/store tests below must establish the final mechanism. Existing direct-input markers remain their Accepted contract; extending this fail-loud rule to them is not silently included. @@ -120,7 +126,8 @@ fabricated empty tool metadata add unsupported claims without repairing the sink 1. Actual WorkflowEngine + createStandardNodeExecutor + QuickJS controls cover direct/computed/serialized/transitive/nested/custom merge, caught access, - condition oracle, returned ctx/getters/toJSON and intrinsic/latch tampering. + condition oracle, returned ctx/getters/toJSON, final-envelope prototype poisoning, + const reassignment and intrinsic/latch tampering. Assert the expected typed failed outcome, no branch/output-schema success and no raw VM input/error detail. Independently successful clean workflows must complete with exact expected outputs; no vacuous negative-only test. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 7a616b0b..763381fc 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -675,6 +675,16 @@ request or credential text. Unknown throws, projection/getter faults and broken sinks fall back to a fixed message; do not coerce arbitrary objects. The ordinary generic failure points to `--verbose`. +Frame syntax alone is not provenance: Error.stack is writable. Emit only an exact +owned shipped-artifact/source-file identity from a pure static/build-generated +allowlist, as its canonical relative ID plus bounded positive line/column. +Drop function labels, absolute install/user paths, URL queries/fragments, +eval/anonymous/sourceURL and unknown/external frames. Do not open maps/config or +inspect causes to validate frames. Early failures with no owned match get fixed +classification only. Bound descriptor-safe stack acquisition and parsing; hostile +accessor/proxy/coercion faults produce no detail. Include arbitrary canaries in +syntactically valid frame paths as negative controls. + ### Failure classification and remedies | Surface / origin | Proposed disposition | @@ -711,6 +721,38 @@ or allows an unjoined store to close. Register owners before work, stop all even if one callback throws, release only after actual joins, reap only owned native resources on fallback and guard handler re-entry. +### Native crash teardown scope + +On POSIX, terminate only registered owned process groups/children. On Windows, +use the OS [taskkill](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/taskkill) +facility with fixed `/PID /T /F` arguments, shell=false and no +image-name/remote/wildcard selectors. Resolve its absolute System32 location from +the invocation's trusted OS installation root, canonicalize and verify that fixed +regular executable there before any tool/MCP spawn; never search workspace/PATH, +or accept a workflow/model-supplied executable/root. A missing/invalid facility +refuses subprocess admission; it cannot silently degrade to claimed tree cleanup. +The OS installation root is trusted host environment, not a security boundary +against an already hostile launching user/process. + +Register exact ChildProcess ownership before exposing work; retain it until close. +Do not kill a stale PID after the owned root has closed. Bound taskkill completion +within the responsive fatal budget, attempt all registered owners, and join actual +native close separately; termination-request success is not acknowledgement. + +**Finite guarantee:** best-effort cleanup of the live registered root/current +descendants on Windows and owned process group on POSIX. Already exited roots, +escaped/reparented/detached descendants and same-user PID-reuse races are not +race-free contained by these primitives. Do not scan ambient processes or claim +no orphan can ever remain. Retain uncertainty/remedy when membership/closure is +unknown. This explicitly bounded crash mechanism is the proposed Wave 3 scope; +full M/N child isolation/containment remains with its existing owner, not silently +certified here. Full Windows job-object containment would require a separately +approved native-helper/dependency decision; no such dependency is authorized. + +Required native Windows controls cover hostile PATH, invalid OS-root facility, +live descendants, root-exit/stale registration, refusal, timeout and actual close. +POSIX tests cover owned groups and the same uncertainty distinctions. + ### Discovery DTO and one descriptor authority The new CLI-only DTO is `{ type: 'command_manifest', version: 1, path: string[], diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index a5b204a9..bcc9c35d 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -33,8 +33,11 @@ silently replaces existing private-module provenance. ## Build, map and candidate identity -1. Build inlined dependencies fresh with external TypeScript maps and no inline - source contents; do not accept stale incremental/cache output missing maps. +1. Build inlined dependencies fresh in an isolated candidate job with external + TypeScript maps and no inline source contents. Ordinary workspace builds stay + map-free. Disable build-cache read **and write**, including Turbo local/remote, + Actions build caches and later broad capture; force alone is insufficient. + Build via existing dependency-ordered scripts in that private stage; do not accept stale incremental/cache output missing maps. Keep current package exports, engine purity and SDK seam. 2. Invoke `pnpm pack` once. Prepack owns the sole final CLI build, clean output, minification, splitting, migration copy, map chaining and closure checks. @@ -51,6 +54,15 @@ silently replaces existing private-module provenance. JS/map/source and missing upstream map refusal. A decrypt round trip alone does not certify mappings. +Upstream/final maps, private manifests, metafiles and staging may leave the +candidate job only inside ciphertext; exclude them from every artifact, cache, +diagnostic and failure upload. Package/dependency-download caching may include +only already-public dependencies, never this build output. A synthetic planted +map/cache marker must fail the public-output inventory; changing the exclusion +or enabling a candidate build-cache write must break that causal control. +[GitHub cache access](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching) +is not a private archive boundary. + The **private map manifest** is versioned JSON inside the encrypted archive: source commit/tree, package version, Node/pnpm/TypeScript/tsup/esbuild versions, target, lock/config hashes, each final JS/map path+SHA-256 pair, and tarball SHA-256. diff --git a/docs/reference/shared-core/expression-sandbox-spec.md b/docs/reference/shared-core/expression-sandbox-spec.md index 33171796..de7d94f5 100644 --- a/docs/reference/shared-core/expression-sandbox-spec.md +++ b/docs/reference/shared-core/expression-sandbox-spec.md @@ -285,13 +285,22 @@ Actual consumers: empty deniedKeys is the clean migration), not an omitted VM-only field. - In QuickJS, denied keys are enumerable frozen accessors with a private sticky attempted-access latch. Raw denied values do not exist in the VM. Author code - runs in a separate strict lexical environment receiving only the public bindings, - outside the latch/serializer closure. Capture required intrinsics first. + runs in a separate strict lexical environment receiving only a frozen public scope pack, outside the latch/serializer closure. + Bind inputs/ctx/run/branches as local const names inside that function (ordinary + mutable parameters would change existing semantics). Capture required intrinsics first. - Serialize/marshal the result once inside the trusted wrapper, then test the latch before accepting the type/result. Author catch, returned ctx/nested getter/toJSON, - eval/name guessing or intrinsic mutation cannot produce successful denial. + eval/name guessing, Object.prototype.toJSON envelope poisoning or intrinsic mutation cannot produce successful denial. Keep current primitive, condition, nonserializable/cycle and lossy JSON rules; JSON-normalizing first must not conceal a forbidden result type. +- The final private control envelope has a null prototype and is serialized with + captured intrinsics **inside** that closure, returned as a primitive string. + The host checks VM typeof, extracts with getString, host-parses and strictly + validates closed success/failure/denial arms. Never dump a prototype-bearing VM + object after checking denial: inherited toJSON can forge that final observation. + Author results remain nested payload, never the control arm. Bound the transport. + Preserve primitive/condition/boxed/cycle semantics explicitly rather than + JSON-normalizing all values indiscriminately. Denied access yields `SandboxError.reason: 'secret_context'`, existing `code: 'sandbox_error'`, retryable=false, fixed message diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 32984a24..d928a9b4 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -309,7 +309,7 @@ This workstream is `apps/cli`'s outermost layer — the process/runtime host eve **Tasks:** -- **Close the five remaining gaps in the CLI's outermost crash/signal safety net.** `processIo()` (`apps/cli/src/process/io.ts`) has no EPIPE handling, so a `--json` consumer that closes its reader early dumps a raw stack trace instead of a structured envelope. `buildProgram()` in `apps/cli/src/run.ts` is the one synchronous step inside `run()` not wrapped in the standard error path, despite `run()`'s own JSDoc promising it never rejects. There is zero `process.on('unhandledRejection'|'uncaughtException', ...)` registration anywhere in production `apps/cli/src` — an async error escaping `run()`'s own promise chain falls straight to Node's default handler, and `process.on('exit')` never reaps detached `run_command` child process groups or closes DB/MCP connections on a crash. The last-resort crash message in `apps/cli/src/index.ts` unconditionally writes plain text to stderr with no `--json` check, the sole exception to the CLI's otherwise-universal structured-envelope convention. Fix all five in `index.ts`/`run.ts`/`process/io.ts` together: add EPIPE handling, wrap `buildProgram()`, register the missing global handlers with child-process reaping and DB/MCP close-on-exit, and make the last-resort message honor `--json`. *(L · apps/cli/src/index.ts, apps/cli/src/run.ts, apps/cli/src/process/io.ts · #40, #41, #42, #224, #269)* +- **Close the five remaining gaps in the CLI's outermost crash/signal safety net.** `processIo()` (`apps/cli/src/process/io.ts`) has no EPIPE handling, so a `--json` consumer that closes its reader early dumps a raw stack trace instead of a structured envelope. `buildProgram()` in `apps/cli/src/run.ts` is the one synchronous step inside `run()` not wrapped in the standard error path, despite `run()`'s own JSDoc promising it never rejects. There is zero `process.on('unhandledRejection'|'uncaughtException', ...)` registration anywhere in production `apps/cli/src` — an async error escaping `run()`'s own promise chain falls straight to Node's default handler, and `process.on('exit')` never reaps detached `run_command` child process groups or closes DB/MCP connections on a crash. The last-resort crash message in `apps/cli/src/index.ts` unconditionally writes plain text to stderr with no `--json` check, the sole exception to the CLI's otherwise-universal structured-envelope convention. Fix all five in `index.ts`/`run.ts`/`process/io.ts` together: add EPIPE handling, wrap `buildProgram()`, register the missing global handlers with child-process reaping and DB/MCP close-on-exit, and make the last-resort message honor `--json`. *(L · apps/cli/src/index.ts, apps/cli/src/run.ts, apps/cli/src/process/io.ts · #40, #41, #42, #224, #269)* **Source correction — 2026-10-15:** the recoverable rejection net already exists and keeps alive; preserve it. Proposed ADR-0110 adds fatal/early-import/output handling through registered owners. Do not close DB/MCP from exit without actual owner ACK or claim universal descendant containment; see the revised preflight and canonical proposed teardown scope. - **Carry `const exhaustive: never` from `engine.ts` to the three sibling switches it was never propagated to, two of which drop live data.** `apps/cli/src/chat/persister.ts`'s event switch — the sole writer of `agent_sessions`/`session_costs` — silently drops any unhandled event kind via a bare `default`, a real money/data risk. Four picker key-routing switches in `apps/cli/src/render/tui/chat-ink.tsx` and `apps/cli/src/render/tui/home-controller.ts` lack the same guard on the reseat surface that already caused a data-loss-class bug. `apps/cli/src/render/tui/run-view-model.ts`'s `reduceRunEvent` silently drops `agent:reasoning` with no state field to hold it, so `relavium run` never shows reasoning that `chat` already renders — the exact bug class the guard in `apps/cli/src/chat/session-host.ts`'s `engine.ts`-derived pattern was invented to prevent, recurring on a different driver. Apply `const exhaustive: never` uniformly across all six sites. *(M · apps/cli/src/chat/persister.ts · #300, #301, G3)* - **Centralize `openLocalDb` wrap-and-redact instead of leaving it well-wrapped at only 3 of 8+ call sites.** On the `run`/`chat`/`gate` paths the well-wrapped open happens AFTER an earlier unwrapped one, making the clean `CliError` message largely unreachable in practice. Separately, `packages/db/src/client.ts` interpolates a raw absolute path into its error with no redaction backstop, unlike `packages/llm`'s `scrubSecrets`. Move the wrap-and-redact logic inside `openLocalDb` itself so every caller gets it for free instead of re-implementing it. *(M · packages/db/src/client.ts · #278, #280)* - **Wire `agent-run.ts`'s missing SIGINT handler and make chat's existing one actually abort the session.** `apps/cli/src/commands/agent-run.ts` registers zero SIGINT handler, so Ctrl-C orphans spawned MCP child processes. `drivePlain`/`driveJson`'s SIGINT handlers (chat driver) only close readline and never call `session.abort()`, letting the billed generation run to completion. Register a handler in `agent-run.ts` mirroring `driveRun`'s teardown, and make the chat handlers call `abort()`. *(M · apps/cli/src/commands/agent-run.ts · #21, #23)* @@ -323,7 +323,7 @@ This workstream is `apps/cli`'s outermost layer — the process/runtime host eve - **Close `agent run`'s two UX-parity gaps against its sibling drivers in one pass.** `apps/cli/src/commands/agent-run.ts` blocks silently on a TTY with no piped stdin and prints no banner, unlike `chat`/`create` which both print one. `makePlainPrinter` (shared with `apps/cli/src/commands/chat.ts`'s plain-printer path) has no `cost:updated` case or final cost line for `agent run`'s non-JSON output, unlike `run`'s TUI/plain renderer. Both are the same shape of gap — `agent run` is the newest, least-mature driver — so add the stdin-TTY banner `create` already has and the cost line `run` already prints in the same change. *(S · apps/cli/src/commands/agent-run.ts · #24, #26)* - **Add a dedicated exit code for unclassified internal faults, or amend the docs that call exit code 1 "workflow failed."** Exit code 1 is documented as meaning a failed workflow but is silently reused for any unclassified internal fault from any command, so a script branching on exit code cannot distinguish "your workflow failed" from "the CLI itself broke." Add a dedicated code or correct the documented contract. *(S · apps/cli/src/index.ts · G35)* - **Resolve `--quiet`/`--verbose`/`RELAVIUM_DEBUG` into one coherent diagnostic-flag system and point the CLI's generic error fallbacks at it.** `--quiet` has exactly one consumer, and that consumer hardcodes the value away — a complete no-op. `--verbose` only gates `err.stack` on a top-level crash and has zero effect during a live session. `RELAVIUM_DEBUG` is undocumented and is a second, inconsistently-named escape hatch alongside `--verbose`. Both of the CLI's generic "internal error" fallback messages never mention `--verbose` or `RELAVIUM_DEBUG` as a way to get more detail — the same incoherence, seen from the user's side of the same broken flag system. Wire `--quiet` to actually suppress output, extend `--verbose` to gate live-session diagnostics, fold `RELAVIUM_DEBUG` into `--verbose`'s precedence chain, and append the resulting hint to both generic fallback messages in the same change; the fallback-message fix cannot be written correctly until the flag relationship is settled. **Blocked: maintainer decision needed on the final `--quiet`/`--verbose`/`RELAVIUM_DEBUG` precedence and behavior before this can be implemented.** *(M · apps/cli/src/index.ts · #45, #264, #265, #268)* -- **Rule on `logs`' human-mode token replay, then implement it.** `relavium logs`' human-mode output replays raw per-event `agent:token`/`agent:reasoning` events instead of coalescing them into a single stream, unlike every other renderer in the CLI (chat/run TUI already coalesce). **Blocked: maintainer decision needed on whether `relavium logs` human-mode should coalesce streamed token/reasoning events or intentionally stay raw as a deliberate design choice for this command.** Implement whichever the maintainer picks. *(M · apps/cli/src/commands/logs.ts · G15)* +- **Certify durable-only logs (G15; source correction — 2026-10-15).** The earlier token-firehose premise is refuted: shipping token/reasoning emission is live-only and logs reads durable rows. No coalescing decision or new persistence is required. Preserve actual replay with a real engine/store/log regression and JSON sequence-gap controls; the [reopened preflight](#maintainer-review-reopened-the-proposal--2026-10-15) records the evidence. *(M · apps/cli/src/commands/logs.ts · G15)* - **Attach `GLOBAL_OPTIONS_HELP` to every subcommand's `--help`, not just the root program's.** `apps/cli/src/program.ts` defines `GLOBAL_OPTIONS_HELP` (documenting the position-independent flags `commander` itself doesn't parse, like `--json`/`--cwd`/`--verbose`) and attaches it only to the root program; `commander` does not propagate it to subcommand help output, so `relavium run --help` never shows it. Attach the same help text to each subcommand, or add an explicit pointer to `relavium --help`. *(S · apps/cli/src/program.ts · #17)* - **Add a `--force` gate or interactive confirmation to `provider remove-key`, the manifest's sole `effect: 'destructive'` command.** `apps/cli/src/commands/provider.ts`'s `remove-key` action has no confirmation gate at all, unlike the create/import/export family, which all require explicit confirmation for destructive effects. Add the same pattern here. *(S · apps/cli/src/commands/provider.ts · #19)* - **Make `formatZodError` show numeric bounds for `too_small`/`too_big` the same way it already does for `invalid_enum_value`.** `apps/cli/src/config/load.ts`'s `formatZodError` includes the schema-derived bound value in enum-mismatch messages but withholds it for out-of-range numeric errors, so a `config.toml` value outside its valid range gets a less actionable message than a bad enum choice. Include the bound consistently across both error kinds. *(S · apps/cli/src/config/load.ts · #32)* @@ -339,7 +339,7 @@ This workstream is `apps/cli`'s outermost layer — the process/runtime host eve - **Convert the three remaining bare `throw new Error()` sites to the project's typed `CliError`.** `apps/cli/src/chat/fixture.ts`'s cassette provider and `apps/cli/src/engine/host.ts`'s checkpointer wiring guard both throw a bare `Error` instead of `CliError`. `apps/cli/src/onboarding/wizard.ts`'s internal choice-validation guard does the same, contradicting its own docstring's claimed propagation contract, even though `CliError` is already imported in that file for a different branch. Convert all three in one mechanical pass. *(S · apps/cli/src/onboarding/wizard.ts · G2, #12, #54)* - **Generate and privately archive a per-release sourcemap so a `RELAVIUM_DEBUG` crash stack can be symbolicated.** `apps/cli/tsup.config.ts` correctly ships `sourcemap: false` per ADR-0051 (no original source in the published `dist/`), but no separate, privately-retained sourcemap exists anywhere in the release pipeline, so a `RELAVIUM_DEBUG` stack trace prints mangled minified names with no way to map them back. Generate a sourcemap at release-build time and archive it outside the published artifact. *(M · apps/cli/tsup.config.ts · #46)* -**Acceptance:** Ctrl-C during `agent run`/`chat` actually cancels the in-flight generation via `session.abort()` and tears down spawned MCP child processes the same way `run`'s driver already does; `gate.ts`'s two idempotent-resume success paths honor `--json`; `persister.ts`, the four picker switches, and `run-view-model.ts`'s `reduceRunEvent` all carry `const exhaustive: never` and `relavium run` renders `agent:reasoning` events; `openLocalDb` wraps and redacts at every call site, not just 3 of 8+; `/doctor` probes with the session's real profile; `--help --json` either emits a real `COMMAND_MANIFEST` or the docs no longer claim it does; `--version`/`--help` no longer pay the ~400ms `driveHome`/`seedCatalog()` tax; `resolve.ts` uses the same `* 4` UTF-8 byte multiplier as `authoring.ts`/`catalog.ts`; no command leaks an absolute filesystem path into an error message; the CLI has a process-level `unhandledRejection`/`uncaughtException` net that reaps orphaned children and closes DB/MCP on crash, `buildProgram()` is wrapped, EPIPE no longer dumps a raw stack, and the last-resort crash message honors `--json`; `provider remove-key` requires confirmation like its sibling destructive commands; and every closed test-coverage gap (`selectGatePrompter`, the verbose-stack branch, `seedRun`'s guard-rails, the cross-process skip) has a red-on-regression test. **Required ADR: none new — the `--quiet`/`--verbose`/`RELAVIUM_DEBUG` precedence decision and the `--input` namespace decision are maintainer calls recorded as amendments to existing CLI diagnostic/flag conventions, not new architectural decisions.** +**Acceptance:** Ctrl-C during `agent run`/`chat` actually cancels the in-flight generation via `session.abort()` and tears down spawned MCP child processes the same way `run`'s driver already does; `gate.ts`'s two idempotent-resume success paths honor `--json`; `persister.ts`, the four picker switches, and `run-view-model.ts`'s `reduceRunEvent` all carry `const exhaustive: never` and `relavium run` renders `agent:reasoning` events; `openLocalDb` wraps and redacts at every call site, not just 3 of 8+; `/doctor` probes with the session's real profile; `--help --json` either emits a real `COMMAND_MANIFEST` or the docs no longer claim it does; `--version`/`--help` no longer pay the ~400ms `driveHome`/`seedCatalog()` tax; `resolve.ts` uses the same `* 4` UTF-8 byte multiplier as `authoring.ts`/`catalog.ts`; no command leaks an absolute filesystem path into an error message; the existing recoverable rejection net is preserved and the approved process-owner/fatal policy attempts bounded native cleanup without false DB/MCP close ACK or universal descendant-containment claims, `buildProgram()` is wrapped, EPIPE no longer dumps a raw stack, and the last-resort crash message honors `--json`; `provider remove-key` requires confirmation like its sibling destructive commands; and every closed test-coverage gap (`selectGatePrompter`, the verbose-stack branch, `seedRun`'s guard-rails, the cross-process skip) has a red-on-regression test. **Required ADR: none new — the `--quiet`/`--verbose`/`RELAVIUM_DEBUG` precedence decision and the `--input` namespace decision are maintainer calls recorded as amendments to existing CLI diagnostic/flag conventions, not new architectural decisions.** ### 2.5.5.F — Documentation & roadmap accuracy @@ -3786,9 +3786,10 @@ nested/custom-merge leaks and the condition oracle. Native SQLite positive and negative runs both complete: the canary reaches three run-event rows, two step output rows and one run output row; downstream human-gate exposure is confirmed. Direct/transitive model/human context templates already refuse and remain controls. -The VM private-latch alternative passes feasibility checks for catch, serialization, -returned ctx/getters/toJSON and latch/intrinsic tampering, with clean-read/key-only -positives. An in-memory installed TypeScript→esbuild map-chain probe maps a synthetic +The initial VM private-latch alternative passes the originally enumerated +feasibility checks but its ordinary final envelope is later shown forgeable through +inherited toJSON at host dump. The correction record below supersedes any broader +feasibility inference; clean-read/key-only positives remain historical evidence. An in-memory installed TypeScript→esbuild map-chain probe maps a synthetic engine frame to its original TS location without embedded sources. These probes are design evidence, not integrated fixes, full CI, native Windows or release proof. @@ -3798,3 +3799,33 @@ monotonic HTTP rebuild grants, non-retryable link writes, operational EPIPE vers generic 9, and exact-byte encrypted custody. Archive method selection persists. Fresh complete cumulative proposal reviews and maintainer approval remain pending; no product task, production recipient, provider/key/billing or upload is claimed. + +#### Revised proposal round 1 corrections — 2026-10-15 + +Two NEW complete cumulative source-based reviews of all fourteen proposal paths +at 86400811 independently request changes. Both verify final-envelope host-dump +forgery, public build-cache map exposure and living G15 task inconsistency. +Authority review additionally verifies missing owned-frame provenance and positive +Windows facility/scope. All five are corrected in the proposal before approval: + +- The VM control envelope uses null-prototype captured serialization and primitive + string transport, then host typeof/getString/parse/strict arm validation. Author + bindings stay const. The parent's six revised feasibility controls pass, including + prototype-poisoned envelope, caught reads, latch reset, returned getter, exact clean + result and reassignment refusal; the earlier wrapper is not certification. +- Map-enabled dependency builds belong only to isolated uncached candidate stages, + with ordinary workspace builds map-free and all cache/artifact/failure paths + covered by planted-marker controls. Public source does not change custody policy. +- Verbose frames emit only closed build-owned canonical relative file identities + plus numeric locations; arbitrary valid-looking stack paths/functions are dropped. +- Windows uses verified trusted absolute taskkill/fixed owned-root argv, with + actual close joining, bounded completion and admission refusal if unavailable. + Live tree/group cleanup is best effort; detached/root-exited/PID-race containment + is explicitly unclaimed. Existing M/N ownership is preserved. This scope requires + maintainer approval with ADR-0110, not an inferred acceptance of a universal claim. +- Living E G15 and crash acceptance now point to current source/policy; the frozen + historical inventory is not rewritten. + +Reviews remain nonclean history. These corrections require two NEW complete +cumulative clean proposal rounds after their final material fix. No implementation, +Windows native gate, public archive, full CI/coverage or ADR approval is inferred. From 49ba03eecf9856e3d6f0bda6528f4a48d94a34e8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 15 Oct 2026 23:44:23 +0300 Subject: [PATCH 008/222] docs(docs): separate wave 3 package tests from release custody Keep required package gates independent of production recipients while requiring encryption before any trusted release candidate handoff. Prevent promotion of test artifacts after private maps are discarded. Refs: ADR-0111 Co-Authored-By: Codex --- ...-operations-use-a-lazy-packaged-runtime.md | 10 +++++- docs/reference/cli/packaging.md | 34 +++++++++++++++++-- .../phase-2.5.5-hardening-and-remediation.md | 17 ++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md index 55c0b48f..7fc46077 100644 --- a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md +++ b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md @@ -89,7 +89,15 @@ nonsecret binding receipt. The private per-source manifest stays inside cipherte Public source/build identity and hashes are allowed in the binding receipt; maps, source content and private per-source inventories are not. -Before retrying, retrieve and verify original staged or Release assets. Reuse +Separate required package-test and actual release modes. PR/push/manual dry-runs +verify the exact pack and maps locally, discard private outputs and share only +tarball plus public package-test identity; no production recipient is needed. +Synthetic GPG controls use synthetic maps only. A package-test artifact is not +promoted after its maps are discarded. A trusted tag creates its own original +release candidate once, requiring production encryption in that originating pack +job before handoff; missing custody cannot downgrade to test mode. + +Before retrying a release, retrieve and verify original staged or Release assets. Reuse exact matching bytes, fill only missing assets from a recoverable candidate and refuse conflicts. Expired staging without a complete candidate fails with recovery instructions; re-encryption cannot manufacture identical custody. Never make diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index bcc9c35d..cef7e007 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -76,7 +76,7 @@ Use SHA-256 for file identity, not a homemade signature/provenance protocol. Check originating identity against the trusted CI service/run and real tag/source; a self-declared JSON field alone does not authenticate its producer. -A candidate comprises exact tarball, original ciphertext and binding receipt. +A **release candidate** comprises exact tarball, original ciphertext and release binding receipt. Package version plus source SHA identifies the intended candidate; a different candidate for that version refuses instead of silently superseding it. The finalized JS/map receipt is sealed locally before encryption; the binding @@ -104,9 +104,37 @@ update for future candidates; preserve historical fingerprint/decryption capabil for supported versions. Existing public ciphertext cannot be recalled by revocation. This wave creates no automatic deletion policy or production key. +## Package-test and release modes + +**Package-test mode** is required on PR/push and manual dry-run. In its isolated +uncached originating pack job, build once, inspect exact tarball, verify maps and +CLI/engine symbolication locally, then discard all private maps/manifests/metafiles. +Share only the tarball and a nonsecret package-test receipt (version, mode +package_test, source SHA, package version, tarball digest and originating workflow +identity) with platform consumers. It needs no production recipient. GPG fixture +controls remain separate and use synthetic map payloads only. + +A package-test artifact is never promoted to a release candidate after private +maps are discarded. A trusted-tag release makes its own fresh candidate once, +then obeys no-rebuild/no-re-encryption retry rules. This is a distinct release +build identity, not a replacement for an existing same-version release candidate. + +**Release mode** runs only for the real trusted pushed tag. First retrieve any +matching original release candidate for retry. If none exists, require confirmed +production recipient in the originating isolated pack job, build/pack/maps once, +and encrypt there **before any job handoff**. Private bytes never enter shared +staging. Its public binding receipt has mode release plus the fields above in +the release-receipt contract. If required custody is unavailable, fail the release; +do not downgrade to package-test mode or use a fixture key for product maps. + +Both modes run the same exact-tarball smoke implementation and local map checks. +Only release mode can enter the archive/publish DAG. Mode, trusted trigger/source +and receipt digest are validated by consumers; a flag or self-declared receipt +does not confer trusted release authority. + ## Archive, retry and publication -Workflow staging contains only tarball, ciphertext and nonsecret receipt. +Release workflow staging contains only tarball, ciphertext and release receipt. A dedicated archive job after pack/smoke creates or reuses a draft Release for the real pushed version tag, uploads those three assets, downloads/verifies them, then permits npm publication of **that exact tarball**. A successfully published package @@ -122,7 +150,7 @@ writers and recheck before upload; do not rely only on asset names. | Durable state | Permitted action | |---|---| -| No prior candidate | Build/pack/encrypt once, stage original candidate | +| No prior release candidate | Build/pack/encrypt once, stage original candidate | | Trusted candidate staging exists | Retrieve/verify and reuse exact bytes, rerun smoke if needed | | Complete matching Release assets exist | Retrieve/verify; no rebuild, re-encryption or overwrite | | Partial upload, trusted full staged candidate recoverable | Verify present assets against it; add only absent matching assets | diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index d928a9b4..513859a1 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -3829,3 +3829,20 @@ Windows facility/scope. All five are corrected in the proposal before approval: Reviews remain nonclean history. These corrections require two NEW complete cumulative clean proposal rounds after their final material fix. No implementation, Windows native gate, public archive, full CI/coverage or ADR approval is inferred. + +#### Revised proposal round 2 clarification — 2026-10-15 + +The NEW complete cumulative review at 35935273 verifies the five round-1 +corrections and reports one Medium: package-test and release-candidate modes +were implicit. The canonical packaging contract now separates them. Required +PR/push/manual tests keep map verification local, discard private outputs and share +only the exact tarball/package-test hash receipt without production keys. Actual +trusted-tag release requires production encryption in its originating pack job +before any handoff, retains the original three-asset candidate and never promotes +a test artifact whose maps were discarded. No recipient-less or fixture-key +product release is authorized. Existing retry/no-overwrite rules still apply. + +The focused 127-test pass, actual engine/SQLite probe and six primitive-envelope +controls retain that review's exact 35935273 attribution; no product fix/CI/Windows +execution is claimed. This material clarification requires two NEW complete +cumulative clean proposal rounds at its corrected head before approval is requested. From a8708a65b8428c044dcf7e4502fc95f1917fe42c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Thu, 15 Oct 2026 23:54:29 +0300 Subject: [PATCH 009/222] docs(docs): record source-corrected wave 3 proposal readiness Attribute both fresh complete clean reviews to the frozen proposal head. Keep all four ADRs Proposed and implementation gated on maintainer approval. Preserve separate feasibility, product and production-release claims. Refs: ADR-0110, ADR-0111, ADR-0112, ADR-0113 Co-Authored-By: Codex --- docs/roadmap/current.md | 22 ++++++----- .../phase-2.5.5-hardening-and-remediation.md | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index ac8f7b78..f66e65d0 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1106,16 +1106,18 @@ an ESLint selector rather than a list. ### Wave 3 — Clear the ground -**Proposal reopened after maintainer source review — 2026-10-15.** -[Source dispositions and revised plan](phases/phase-2.5.5-hardening-and-remediation.md#maintainer-review-reopened-the-proposal--2026-10-15) -correct G15's false token-persistence premise, fatal/lazy build dependencies, -exact-byte archive mechanics, HTTP rebuild revocation and secret-context consumers. -Nineteen proposed increments retain the serialized first queue. ADR-0110–0113 -and their explicitly unshipped canonical sections remain Proposed; no code has -started. Earlier clean proposal reviews retain historical scope and no longer -establish readiness. Fresh cumulative proposal reviews and maintainer approval -are required. The encrypted archive method is selected; no production key, -provider capture, billing or upload is needed for this review. +**Source-corrected proposal reviewed; maintainer approval pending — 2026-10-15.** +[Revised readiness and approval scope](phases/phase-2.5.5-hardening-and-remediation.md#revised-proposal-readiness-after-source-corrections--2026-10-15) +records two NEW independent complete cumulative clean reviews at 49ba03ee across +all fourteen proposal paths/four ADRs and the nineteen-increment plan. The +[reopened source dispositions](phases/phase-2.5.5-hardening-and-remediation.md#maintainer-review-reopened-the-proposal--2026-10-15) +correct G15, process/lazy dependencies, exact-byte archive/cache/retry modes, +HTTP revocation and secret-context consumers. Earlier nonclean/insufficient +proposal rounds remain historical and are not acceptance. ADR-0110–0113 and +their canonical proposal sections remain unshipped/Proposed. No product code +has started; explicit approval is the current gate. Archive method is selected; +production recipient/recovery setup belongs to actual Wave 4b release. +No provider capture, key access, billing or upload is required for this review. Decompose, guard and normalize every hot file a 2.6 workstream is about to land on. **The multi-file queue runs first and is serialized against the rest**; the remaining queues are file-disjoint, any order. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 513859a1..0ff6f499 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -3846,3 +3846,41 @@ The focused 127-test pass, actual engine/SQLite probe and six primitive-envelope controls retain that review's exact 35935273 attribution; no product fix/CI/Windows execution is claimed. This material clarification requires two NEW complete cumulative clean proposal rounds at its corrected head before approval is requested. + +#### Revised proposal readiness after source corrections — 2026-10-15 + +Two NEW independent COMPLETE cumulative reviews are clean at exact reviewed source +49ba03eecf9856e3d6f0bda6528f4a48d94a34e8: round 3 (gpt-6.1-sol, high) and round 4 +(gpt-6-sol, high). Each covers all fourteen proposal paths, all four ADRs, the +nineteen-increment plan and related actual source/Accepted contracts, rather than +only the most recent correction. Each records zero verified material findings. +The parent reads both complete reports. Nonclean rounds at 86400811 and 35935273 +remain historical and are not counted as clean; the old 39967075 readiness claim +is superseded by this source-corrected proposal. + +Exact-head six-control installed-QuickJS primitive-envelope feasibility passes +in both final rounds; round 3 also reruns actual engine/standard-executor/SQLite +positive and negative intake. These reproduce the **existing unfixed** exposure, +not a product correction. The prior 7-suite/127-test and synthetic TS-map-chain +results keep their actual execution attribution. Source inventory proves only +documentation changed, all 109 prior numbered ADRs remain byte-identical to +702cdc9c, and ADR-0110–0113 remain Proposed. Parent doc validation resolves 3,033 +relative links/anchors across the 61 Markdown files changed since the older +Wave 2 base; final reviewer scope remains fourteen proposal paths. Diff whitespace +passes. No full CI, coverage, native Windows, exact pack/release or provider/key/ +billing execution is inferred. + +This final receipt and current-status update add no mechanism or step to the +reviewed proposal. **Ready for maintainer approval of the four Proposed ADRs, +their explicitly unshipped canonical contracts and nineteen-increment plan.** +Approval includes the fail-loud qualified context boundary, monotonically narrowing +HTTP rebuild authority, non-retryable intentional-link write refusal, explicit +CLI compatibility and finite native teardown scope, and separated package-test/ +encrypted-release custody modes. The selected archive method remains unchanged. + +No implementation is authorized by a clean proposal review. After explicit +approval, update ADR statuses/index and append the specified Accepted-ADR forward +notes before dependent code, then start serialized Step 1 and its required review +loop. Production recipient/recovery configuration remains an actual Wave 4b release +prerequisite; no production key, provider capture, billing or upload is needed for +the current approval. Detailed reports remain local and Git-ignored. From 86ff88a85fe26836c5d14ebd6276fb21aeb40a8f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 00:49:26 +0300 Subject: [PATCH 010/222] docs(docs): correct wave 3 ownership and recovery proposals Reopen prior proposal readiness after the second maintainer source review. Specify per-owner CLI health and teardown, distinct output-failure exits, permissioned release discovery/recovery, live HTTP narrowing across failed Home builds, and finite secret-access semantics with exact result parity. Keep all four ADRs Proposed and product source unchanged. Record explicit compatibility and canonical landing obligations before requesting approval. Refs: ADR-0110, ADR-0111, ADR-0112, ADR-0113 Co-Authored-By: Codex --- ...d-discovery-have-explicit-compatibility.md | 110 +++++++---- ...-operations-use-a-lazy-packaged-runtime.md | 67 ++++--- ...and-onboarding-state-are-operator-owned.md | 66 +++++-- ...ontext-keeps-provenance-at-public-sinks.md | 134 ++++++++----- docs/reference/cli/commands.md | 179 ++++++++++++------ docs/reference/cli/packaging.md | 175 ++++++++++++----- docs/reference/contracts/config-spec.md | 76 ++++++-- docs/reference/shared-core/agent-runner.md | 41 ++-- .../shared-core/expression-sandbox-spec.md | 164 ++++++++++------ docs/reference/shared-core/tool-registry.md | 17 ++ docs/roadmap/current.md | 22 +-- .../phase-2.5.5-hardening-and-remediation.md | 112 +++++++++-- 12 files changed, 814 insertions(+), 349 deletions(-) diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index 3c58b9d9..4d7ad707 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -2,8 +2,7 @@ - **Status**: Proposed - **Date**: 2026-10-15 -- **Supersedes (partial)**: [ADR-0049](0049-cli-machine-output-contract.md) §6, for explicit help with JSON -- **Related**: [ADR-0047](0047-cli-framework-commander-ink-clack.md), [ADR-0056](0056-cli-in-app-slash-command-system-and-manifest.md), [ADR-0088](0088-the-mcp-boundary-is-hostile.md), [ADR-0103](0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md), [ADR-0111](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) +- **Related**: [ADR-0049](0049-cli-machine-output-contract.md) (partial replacement of explicit JSON-help exception), [ADR-0047](0047-cli-framework-commander-ink-clack.md), [ADR-0056](0056-cli-in-app-slash-command-system-and-manifest.md), [ADR-0088](0088-the-mcp-boundary-is-hostile.md), [ADR-0103](0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md), [ADR-0111](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) ## Context @@ -44,44 +43,59 @@ never arbitrary error messages, cause trees or raw stack headers. Pattern redaction is defence in depth, not proof that arbitrary text is secret-free. JSON diagnostics, including #269's last resort, remain structured stderr records. -Reserve exit 9 for unexpected CLI infrastructure failures on every surface, -independently of the existing broad `internal` code. Known history, departure, -ownership, receipt and operational refusals retain their exit/remedy. The canonical -surface table explicitly covers Home, chat, one-shot agents, workflow/resume and -management/meta commands. Genuine run/turn outcomes and deliberate session end -remain distinct; an unknown CLI failure cannot become Home success or chat end. +Reserve exit 9 for CLI infrastructure failure on every surface: unexpected faults +use the distinct CLI diagnostic `cli_internal`; classified failed-output/EPIPE +uses `output_unavailable`. Output failure deliberately moves from the earlier +proposal's exit 1 to 9: it does not mean the workflow failed, even if its result +could not be delivered. Known history/departure and operational failures retain +their stated disposition; do not remap the existing broad `internal` code. +Classify actual storage-boundary failures before native integration, rather than +waiting for the final DB refactor. The canonical surface/site table owns details. +An ordinary failed chat turn leaves the REPL alive; one-shot agent outcomes are +0/1. Unknown catches inside slash/rebuild/one-shot paths cannot silently turn an +infrastructure fault into a successful Home/REPL end. Create one lightweight invocation-owned shutdown registry in the CLI process layer, -with no engine/native/UI service imports. Owners register stop, join, final-health -and owned-native-reap callbacks before admitting work; unregister only after actual -settlement. Normal, fatal and output-failure paths share those registrations. -ADR-0111's chunks retain one instance. Ambient PID scanning is not ownership proof. +with no engine/native/UI service imports. Owners register kind-specific stop/join, +terminal-restore and native-reap callbacks before admitting work; only workflow +owners expose existing RunHandle health. AgentSession has no run-departure or 5–8 +channel: join its existing host lifetime, retain publication failure and give +inspect-before-repeat advice without inventing a health DTO or acknowledgement. +Unregister only after actual settlement. Normal, fatal and output-failure paths +share those registrations. ADR-0111's chunks retain one instance. Ambient PID +scanning is not ownership proof. Install early diagnostic flag extraction and the registry before dynamic runtime imports. Recoverable background rejection keeps the process alive, reports safely, and upgrades only an otherwise clean success; existing nonzero outcomes remain. Fatal exception/top-level rejection stops admission and exits without resuming -business logic. Best-effort cooperative joining has a three-second **fatal process -termination** budget; afterward only explicitly owned native resources may be -synchronously reaped. This is not an ADR-0103 receipt deadline, acknowledgement or -safe-close permission. An exit callback cannot await; re-entry and broken sinks -are guarded. Normal/recoverable cleanup still joins actual owners. - -Known final money/effect/durability/ownership health takes precedence on **return -and throw** paths. Unavailable health remains uncertain, with inspection before -retry; no terminal or acknowledgement is fabricated. Native teardown uses owned -POSIX groups and the trusted absolute Windows taskkill facility with fixed -owned-root tree arguments, never PATH lookup or foreign PID scanning. The -canonical contract limits this to live registered roots/current descendants; -root-exit, escape/reparenting and same-user PID reuse are not race-free contained. -A request is not close ACK. Full job-object/native containment is not claimed or -silently deferred as a completed Wave 3 guarantee; existing M/N ownership remains. - -EPIPE is a classified output-unavailable operational failure, not an unclassified -exception. Stop/join producers, preserve stronger safety exits and use at most one -fixed diagnostic on an independent usable sink. A failed sink cannot acknowledge -itself. No automatic repetition of paid/effectful work follows an output or fatal -failure; the canonical exit-9 remedy explicitly says this. +business logic. One absolute three-second **fatal process termination** budget +includes stop/join, native reap, terminal restoration and diagnostic flush; no +owner/helper receives a fresh budget or starts unbounded work after expiry. This +is not an ADR-0103 receipt deadline, acknowledgement or safe-close permission. +Restore raw/alternate-screen/render custody before final stderr diagnosis so it +is not erased. Diagnostic text never falls back to stdout. An exit callback cannot +await; re-entry and broken sinks are guarded. Normal/recoverable cleanup still +joins actual owners. The first termination request owns cleanup; later observed +faults can upgrade disposition, never restart cleanup or extend its deadline. + +Actual workflow health takes precedence on **return and throw** paths, followed +by fatal/output infrastructure failure, actual terminating signal, then ordinary +outcome; the canonical contract defines the total order and distinct recoverable +background-rejection rule. Unavailable/session health remains uncertain, with +inspection before retry; no terminal or acknowledgement is fabricated. Native +teardown follows creation ownership: run_command owns a POSIX process group, +SDK-backed MCP owns a PID, not a detached group. Windows uses the verified absolute +taskkill facility for a still-owned live root. Never infer PGID from an MCP PID, +search PATH or scan foreign processes. Root-exit, escape/reparenting and same-user +PID/PGID reuse are not race-free contained. A request is not close ACK. Full native +containment is not claimed; existing M/N lifecycle ownership remains. + +EPIPE is a classified infrastructure output failure, not an unknown exception. +Stop/join producers, preserve actual stronger workflow safety exits and use at +most one fixed stderr diagnostic if that sink is usable. A failed sink cannot +acknowledge itself. The exit-9 remedy covers both output and unexpected failures: +no automatic repetition of paid/effectful work; inspect existing work first. ### Discovery and compatibility @@ -109,18 +123,27 @@ Key deletion requires interactive consent or explicit automation force. Canonica eligibility/decline/interruption rules and every existing deletion example must agree. G15 closes as a refuted current defect with preservation evidence. -At **acceptance**, append ADR-0049/0056 forward notes and ADR-0088/0103 refinements -before dependent code. Reconcile D19's old precedence ladder, D22's two-exception -shorthand, E's “Required ADR: none new” and phase exit criterion 4's blanket -machine-contract prohibition to this enumerated scope. Accepted historical bodies -stay intact. Proposed reference sections are explicitly unshipped. +At **acceptance**, record ADR-0049's clause-specific partial replacement in its +Status/index and dated forward note, append ADR-0056's realization note and +ADR-0088/0103 refinements before dependent code. Reconcile D19's old precedence +ladder, D22's two-exception shorthand, E's “Required ADR: none new” and phase exit +criterion 4's blanket machine-contract prohibition to this enumerated scope. +Accepted historical bodies stay intact. Use the existing Status/Date/Related +metadata convention. Proposed reference sections stay unshipped until their +implementing increment lands; the plan owns promotion/removal and overlapping +current-text reconciliation. Considered verbose winning over quiet: rejected to preserve conflict refusal. Considered remapping every internal error: rejected because known refusals use it. Considered sanitized raw stacks: rejected because pattern matching cannot prove confidentiality. Considered coalescing invented token rows: rejected as a false premise. Considered every rejection fatal: rejected because the persister -backstop's accepted keepalive policy is distinct from process exceptions. +backstop's keepalive policy is distinct. Considered output failure remaining +exit 1: rejected because it confuses output delivery with workflow outcome. +Considered uniform process-group/session-health abstractions: rejected because +shipping owners do not supply them. Windows facility admission protects finite +best-effort cleanup; reconsider its availability cost when platform/owner support +changes, rather than claiming universal containment. ### Acceptance and landing @@ -129,7 +152,9 @@ backstop's accepted keepalive policy is distinct from process exceptions. and warnings; arbitrary canaries in messages/causes/stack headers never render. 2. Native children test early import failure, background rejection, fatal/repeated fault, both broken sinks, owned children/grandchildren and Windows teardown. - Joined health wins on return and throw; timeout never certifies safe store close. + Actual workflow health wins on return and throw; session joins never fabricate + it. Include competing signal/faults, caught command failures and terminal-restore + ordering; timeout never certifies safe store close. 3. Test every surface's exit classification, typed history/departure refusals and existing 0–8/signal outcomes. Distinguish Node's pre-bootstrap exits. 4. Commander-to-dispatch and packed tests cover root/subtree discovery, globals, @@ -144,7 +169,8 @@ backstop's accepted keepalive policy is distinct from process exceptions. ### Positive -- Scripts distinguish unexpected CLI faults from outcomes across surfaces. +- Scripts distinguish unexpected/output infrastructure faults from outcomes + across surfaces; known operational failures retain their explicit table. - Discovery describes real grammar; diagnostics preserve data, consent and safety. ### Negative @@ -153,3 +179,5 @@ backstop's accepted keepalive policy is distinct from process exceptions. public behaviour. Explicit compatibility tables and aliases bound the change. - Fatal cleanup cannot promise crash-safe receipts or universal descendant containment. Inspect before repeating paid or effectful work; a failed cleanup must not be reported acknowledged. +- Windows subprocess admission refuses if its verified teardown facility is + unavailable; POSIX MCP cleanup cannot promise reaping its descendants. diff --git a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md index 7fc46077..8769a50d 100644 --- a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md +++ b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md @@ -2,8 +2,7 @@ - **Status**: Proposed - **Date**: 2026-10-15 -- **Supersedes (partial)**: [ADR-0047](0047-cli-framework-commander-ink-clack.md) and [ADR-0051](0051-cli-distribution-thin-bundle-private-engine.md), single-output packaging constraint -- **Related**: [ADR-0056](0056-cli-in-app-slash-command-system-and-manifest.md), [ADR-0067](0067-node-supported-floor-22-reaffirm-better-sqlite3.md), [ADR-0071](0071-models-dev-as-the-model-metadata-source.md), [ADR-0072](0072-model-metadata-in-the-db-behind-a-generated-offline-floor.md), [ADR-0103](0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md), [ADR-0109](0109-native-media-close-ownership-is-explicit-iterator-provenance.md), [ADR-0110](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) +- **Related**: [ADR-0047](0047-cli-framework-commander-ink-clack.md), [ADR-0051](0051-cli-distribution-thin-bundle-private-engine.md) (single-output constraint partially replaced), [ADR-0105](0105-filesystem-roots-are-invocation-grants-not-project-authority.md), [ADR-0056](0056-cli-in-app-slash-command-system-and-manifest.md), [ADR-0067](0067-node-supported-floor-22-reaffirm-better-sqlite3.md), [ADR-0071](0071-models-dev-as-the-model-metadata-source.md), [ADR-0072](0072-model-metadata-in-the-db-behind-a-generated-offline-floor.md), [ADR-0103](0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md), [ADR-0109](0109-native-media-close-ownership-is-explicit-iterator-provenance.md), [ADR-0110](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) ## Context @@ -50,16 +49,21 @@ authority-bearing module state. Do not treat tree shaking as source proof. Meta help/version/bare noninteractive help reads no config/cache, admits no filesystem grants and opens no DB/keychain/MCP/provider/TUI. Validate grammar -without a second argv parser. New JSON-help integration lands after the descriptor +without a second argv parser. Skipping directory existence/link/permission admission +for meta operations is a deliberate ADR-0105 refinement: an absent root no longer +blocks help, while pure syntax/count/size checks remain. No grant is minted. +New JSON-help integration lands after the descriptor and lazy graph; the earlier increment only certifies implemented meta operations. For execution distinguish **filesystem grant admission** from model/cost admission. -Preserve current order: filesystem admission, once-only cache fallback seed where -needed, current DB catalog overlay at its owner, then first capability/quote/ +Preserve current order: filesystem admission, one invocation-owned +`seedFileCatalogOnce()` gate for commands needing pricing, current DB catalog +overlay at its owner, then first capability/quote/ budget/egress read. A late file seed must never replace authoritative DB long-tail prices. Preserve ADR-0072's offline floor, empty-DB non-erasure and no-startup-fetch rules and media maintenance's no-config/cache/DB exception. Home/slash/resume -transitions cannot reinstall an older file overlay. +transitions share that owner and cannot reinstall an older file overlay. The +canonical owner inventory includes quote-only opens and offline fixture exceptions. Inspect every emitted JS output and its static/dynamic imports. Closure rejects missing/escaping chunks, unbundled internal packages and undeclared externals; @@ -71,7 +75,9 @@ OSes and the declared minimum runtime, then release CI reuses that mechanism. Emit external upstream TypeScript maps only in a fresh isolated, uncached candidate build, without embedded sources. Ordinary workspace builds remain -map-free. No Actions/Turbo/remote build-cache read/write or later broad cache +map-free. The canonical contract gives executable dependency-build overrides, +prepack map selection/finalization and an entry-only shebang/mode inventory. +No Actions/Turbo/remote build-cache read/write or later broad cache capture may include upstream/final maps, private manifests or staging. The final bundler chains them to original TypeScript; do not substitute source aliases or claim a decrypt test proves engine symbolication. @@ -97,18 +103,25 @@ promoted after its maps are discarded. A trusted tag creates its own original release candidate once, requiring production encryption in that originating pack job before handoff; missing custody cannot downgrade to test mode. -Before retrying a release, retrieve and verify original staged or Release assets. Reuse -exact matching bytes, fill only missing assets from a recoverable candidate and -refuse conflicts. Expired staging without a complete candidate fails with recovery -instructions; re-encryption cannot manufacture identical custody. Never make -encryption deterministic, overwrite conflicts or silently swap a version's bytes. - -A dedicated archive job retains ciphertext, tarball and binding receipt as -per-version Release assets beyond Actions expiry, verifies downloaded digests, -and gates npm publication of that same tarball. Only real trusted pushed tags -with version/main-ancestry checks can mutate Releases/publish. Manual dispatch, -including an off-main override or dispatch targeting a tag, remains dry-run. -Only the archive job gets Contents write; npm retains its provenance permission. +Use one explicit trusted-tag custody graph: admission → permissioned candidate +discovery/reservation → fresh pack or exact reuse → smoke → archive → npm publish +→ Release finalization. Discovery precedes **every** product build, including an +all-jobs rerun. It can see drafts and originating run/attempt artifacts; permission, +pagination or missing-history uncertainty is not “fresh”. Reserve a draft candidate +identity before its first build; interrupted/lost staging cannot become permission +to create different same-version bytes. Recover original tarball/ciphertext/receipt, +fill only absent matching assets and refuse conflicts or unrecoverable expiry. +Never make encryption deterministic or re-encrypt to imitate prior custody. + +The canonical contract assigns each job, token capability, durable state and rerun +mode. Only discovery, archive and finalization get narrowly scoped Contents write; +cross-run retrieval gets Actions read in discovery. Builds do not receive Release +write or npm credentials. Publish retains only read plus existing provenance +permission and consumes verified staged tarball/archive receipt; finalization is +a separate post-publish writer. Workflow defaults deny unspecified permissions. +Only real trusted pushed tags with version/main-ancestry checks can mutate Releases +or publish. Manual dispatch, even against a tag, remains dry-run with separate +concurrency. Existing repository Release immutability is not assumed. A dedicated crypto/recipient/CI-custody security sitting validates this path. Fixture-key controls use synthetic maps only; do not upload product maps under a @@ -125,7 +138,10 @@ distribution boundary. Considered public source-free maps: technically possible for this public repo, but contrary to the selected archive policy, **not** because public source names would become newly secret. Considered deterministic encryption or rebuilding on retry: rejected because cryptographic randomness and exact-byte -release identity must be preserved. +release identity must be preserved. The selected archive protects distribution/ +custody, not secret source. Its release-availability cost is explicit; reconsider +proportionality with the maintainer if this policy or release/debugging needs change, +not by silently weakening a failed release gate. ### Acceptance and landing @@ -134,6 +150,9 @@ release identity must be preserved. the same current baseline; historical ~400 ms is not a fresh threshold. 2. Required packed platform/minimum-runtime controls cover real run/chat fixtures, native loading, migrations, MCP version, CAS and shared iterator/registry identity. + The required aggregate rejects missing/skipped/cancelled platform legs. Actual + installed chat uses an external test transport, not a fictitious chat fixture flag + or replaced inlined session implementation. Catalog order tests include file-only, empty/torn DB, authoritative DB tail, repeated Home/resume and media exceptions before first capability/budget read. 3. Missing/escaping chunks, external engine imports and undeclared dependencies @@ -144,12 +163,16 @@ release identity must be preserved. and a post-receipt rebuild fail; sourcesContent is absent throughout. 5. Synthetic GPG and archive/API controls cover recipient isolation/expiry/rotation, encryption failure, retry/partial upload/conflict/expired staging and archive - before publish. No plaintext fallback or false production-custody claim. + before publish, finalization, all-jobs/failed-jobs/cross-run reruns and exact + npm-already-published bytes. The same state controller uses permission-enforcing + fake API controls; no live release/tag is needed for Wave 3. No plaintext fallback + or false production-custody claim. 6. Split lazy graph, fatal integration and archival into distinct reviewed increments. Rerun affected packed gates after later CLI/DB edits and at final whole-wave head. Two new independent complete cumulative clean rounds follow each final material fix. At acceptance append partial-replacement notes to - ADR-0047/0051 and refinements to ADR-0056/0071/0072/0103/0109 before code; land + ADR-0047/0051, their Status/index partial annotations, and refinements to + ADR-0056/0071/0072/0103/0105/0109 before code; land canonical references/runbook and actual configuration status separately. ## Consequences diff --git a/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md b/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md index e5c14d96..4e2803e8 100644 --- a/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md +++ b/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md @@ -2,7 +2,7 @@ - **Status**: Proposed - **Date**: 2026-10-15 -- **Related**: [ADR-0006](0006-os-keychain-for-api-keys.md), [ADR-0029](0029-tool-policy-hardening.md), [ADR-0033](0033-strict-config-files-amends-0023.md), [ADR-0037](0037-engine-tool-execution-boundary.md), [ADR-0054](0054-cli-bare-invocation-interactive-home.md), [ADR-0055](0055-cli-host-capability-seam-tool-environment-factory.md), [ADR-0057](0057-cli-chat-modes-and-per-tool-approval.md), [ADR-0059](0059-cli-mid-session-model-reseat.md), [ADR-0063](0063-cli-config-write-contract.md), [ADR-0105](0105-filesystem-roots-are-invocation-grants-not-project-authority.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) +- **Related**: [ADR-0006](0006-os-keychain-for-api-keys.md), [ADR-0029](0029-tool-policy-hardening.md), [ADR-0033](0033-strict-config-files-amends-0023.md), [ADR-0037](0037-engine-tool-execution-boundary.md), [ADR-0054](0054-cli-bare-invocation-interactive-home.md), [ADR-0055](0055-cli-host-capability-seam-tool-environment-factory.md), [ADR-0057](0057-cli-chat-modes-and-per-tool-approval.md), [ADR-0059](0059-cli-mid-session-model-reseat.md), [ADR-0063](0063-cli-config-write-contract.md), [ADR-0105](0105-filesystem-roots-are-invocation-grants-not-project-authority.md), [ADR-0110](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) ## Context @@ -19,7 +19,9 @@ tools; a workspace-local selection may be writable. Clear/reseat currently reuse startup chat options; Home refreshes model/provider and can fall back on read error. Those paths cannot provide policy revocation -without new global-only reads and an invocation ceiling. Dispatch also compares +without new global-only reads and an invocation ceiling. Home is build-first: a +failed replacement preserves the old host. Merely narrowing a new snapshot or +promising stop-before-build therefore cannot revoke its old authority. Dispatch also compares `allowed.includes(parsed.host)` privately, without normalizing the allowlist. The proposed shared comparison is a correction, not an existing public helper. @@ -52,16 +54,27 @@ do not silently make it 443-only. DNS/range validation, pinned connection, redir refusal and response bounds remain independent. Auto mode is still an authority to send permitted tool data to a permitted host/port; provider keys are not attached. -Capture an owned invocation ceiling before building the first host. Every host -construction reads **only current global HTTP policy**, intersects it with that -ceiling and the last accepted effective policy, and retains the narrowed result -across Home chats, clear, reseat and resume. No rebuild widens authority; a fresh -CLI invocation is required to add or restore a grant. Never reuse captured startup -chat policy or Home's model-read fallback for this read. Read/schema failure -refuses the new build with a fixed actionable diagnostic; it does not restore -old policy or reset the ceiling. Stop the old turn/host before replacing it. -An active host retains its immutable snapshot until cancellation/rebuild; there -is no live watcher. Persisted session policy never revives authority. +Pin the selected global file identity lexically once per invocation, then capture +an owned initial ceiling before the first host/key/MCP build. One invocation-owned +**live narrowing authority** is shared by old, pending and replacement hosts. +Every fresh/resume/clear/reseat/Home build rereads only global HTTP policy and +synchronously intersects the accepted authority before later asynchronous work. +A validated removal remains effective even when that build fails; build-first +recovery may keep the old host, but cannot keep its broader HTTP authority. +Use the existing post-approval/post-effect-prepare beforeDispatch seam with an +owned immutable admitted PolicyTarget to check the live authority immediately +before HTTP dispatch. Do not decorate all egress or introduce a new model field. + +Build generation/selection publication is latest-only; dispose stale hosts and +never restore a previous authority snapshot. A read/schema failure suspends HTTP +admission and refuses the new build with a fixed actionable diagnostic. It does +not silently fall back, reset the ceiling or permanently interpret transient I/O +failure as a validated removal; a subsequent successful refresh still intersects +the last accepted authority. Additions/restoration require a new invocation and +produce a fixed ignored-widening notice. There is no file watcher: changes take +effect at explicit refresh/rebuild. Already admitted requests may complete; the +post-prepare check is the admission linearization point, not network recall. +Persisted session policy never revives authority. Forward only that list to existing tool dispatch, keeping model tool grants, approval modes and SSRF as separate gates. Test each surface's actual behaviour: @@ -78,11 +91,17 @@ every startup rechecks current key availability; a working key suppresses setup, and a still-keyless acknowledged failure gets a reminder and explicit retry. Missing key, cancellation, authentication and unrelated DB errors do not set it. Persist only a boolean through the secret-incapable writer; failures stay visible. +Its portability cannot confer access because current key/backend state always +wins. A DB-owned acknowledgement was considered but adds a second mutable store +for UX-only state without replacing that check. After storing a key but failing to persist defaults, retain the key and truthfully offer recoverable retry or explicitly chosen ephemeral defaults. Never say “Connected”/saved default when persistence failed. Project precedence is not -silently overwritten. Propagate color choice without changing process environment. +silently overwritten. Keep explicitly chosen ephemeral provider/model selection +in an invocation-owned binding reused by later rebuilds, including clear. Publish +that binding only with the latest successful build; never confuse it with a saved +project/global default. Propagate color choice without changing process environment. Refuse an existing **final-component symlink** for preference writes, with a fixed path-free `ConfigWriteRefusedError` and `reason: symlink_target`; mark it @@ -94,9 +113,15 @@ policy, not a claim that same-owner link targets have a different authority. Ancestor links remain under the existing owner-controlled-directory assumption; atomic rename replaces one hardlink entry rather than editing all aliases. An lstat precheck is not an atomic no-follow replace against a hostile same-user -race. Regular writes keep verified schema round-trip, permissions and durability. +race. The shared preference-writer primitive performs this check before loader/read/ +chmod side effects; current and future settings callers use it, not picker-only +checks. Regular writes keep verified schema round-trip, permissions and durability. Existing TOML reserialization loses comments/order; document that tradeoff before -writing a hand-edited policy and preserve all policy values, never broaden them. +every existing-file preference rewrite, rather than guessing whether the operator +hand-edited it. Preserve all policy values, never broaden them. New-file creation +needs no comment-loss notice. Shared error identities belong to ADR-0110’s one +closed CLI classification authority; Home/wizard arms must actually surface known +actionable refusals rather than discard their classification. Considered project domains: rejected for this capability because explicit operator authority is preferable to implicit imported authority. Considered invocation @@ -117,9 +142,12 @@ These choices do not establish a universal no-project-egress guarantee. Home non-advertisement, agent ask denial, approval refusal and SSRF/ports. Clear/reseat/resume/repeated Home builds reread global policy: remove, re-add, attempted widen, malformed/unreadable file and startup fallback all exercise - the persistent invocation ceiling. No live/paid provider is required. + the live invocation ceiling. Include failed Home replacement, overlapping build + completion, approval/effect-prepare races and transient read recovery; an old + host must not admit a revoked request. No live/paid provider is required. 3. First-chat tests verify model/provider selection, retained key, truthful write failure, typed acknowledgement, current keyless reminder and retry/cancel. + Ephemeral provider/model survives clear/reseat; stale builds cannot publish it. New strict keys have concrete old-binary removal/exit instructions. 4. Native symlink controls preserve link/referent and prove no Retry/success lie; ancestor-link/hardlink controls state the actual scope. Regular writes retain @@ -141,7 +169,9 @@ These choices do not establish a universal no-project-egress guarantee. - Strict new keys break old-binary config reads until removed, even on unrelated commands. The canonical rollback instructions make the recovery explicit. -- Revocation requires cancellation/rebuild and widening requires a new invocation. - An active snapshot is not instantaneous revocation. +- Revocation takes effect at explicit refresh/rebuild, including failed replacement; + in-flight requests are not recalled. Widening requires a new invocation. +- Policy read failure temporarily suspends HTTP even on an otherwise retained host. + Final-link refusal applies to every preference writer, including future settings. - Symlink writes require manual action or ephemeral continuation. Non-retryable diagnostics avoid a wizard loop; comments/order remain a known writer tradeoff. diff --git a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md index edcaba6c..1775c763 100644 --- a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md +++ b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md @@ -25,8 +25,9 @@ a secret-free trust domain. The earlier proposal to inject markers would silently turn a secret comparison into a false branch or let a marker satisfy an output schema. Its additional -preparation field had no VM consumer. Both mechanisms are replaced below, while -the existing direct-input marker contract stays unchanged. +preparation field had no VM consumer. Both mechanisms are replaced below, and the same silent comparison exists for direct secret-input markers. The revised +proposal applies denied-value access uniformly to both expression namespaces; +display/input-node masking remains unchanged. All decisions remain Proposed. Exact interfaces and error behaviour belong in [expression-sandbox-spec.md](../reference/shared-core/expression-sandbox-spec.md#proposed-wave-3-secret-context-boundary) @@ -40,8 +41,10 @@ credential sink or tool-mapping feature is authorized. Reuse the existing linear declared-source taint graph for input/default/context relationships. One pure authority computes provenance; no value scanning, second expression scanner or argument-name heuristic is introduced. Derive immutable -arrays once from the same admitted workflow, copying before freezing; freezing a -Set would not prevent mutation. +arrays and a real immutable secret-input membership view once from the same +admitted workflow. Copy the backing data; freezing a Set does not prevent mutation. +Share this one authority through fresh/resume/preparation paths, not caller-owned +live Sets or separately derived lists. Keep the exported trusted resolver's raw result contract. Immediately qualify its result inside the engine, before storing or handing context to execution or @@ -54,42 +57,57 @@ workflow/input provenance; shape validation cannot detect a deliberate lie. Execution and preparation consume the **same** projection. Preparation uses a public-template resolver which rejects denied references before filters/defaults; -this is a real template consumer, not a fictitious preparation sandbox. Clean +this is a real template consumer, not a fictitious preparation sandbox. It also +refuses direct secret-input references using that owned membership view, including +hand-constructed workflows that bypass parser checks. Clean templates work unchanged. Fresh, same-process resume and cross-process budget preparation cannot reconstruct raw unqualified context. Trusted raw resolution stays local to the resolver/projection cut rather than a second executor authority. -### Denied access fails loudly - -Expression scopes serialize only clean context and denied-key metadata. Inside -QuickJS, create frozen context accessors for denied keys with an inaccessible -sticky denial latch. Evaluate authored code in a separate lexical environment -receiving a frozen public pack and binding its public names as const; a guessed -wrapper-local name or eval cannot reset the latch. Capture required intrinsic -operations before author execution. -After evaluation **and result serialization**, check the latch before accepting a -condition, transform or custom merge. Return the final null-prototype control -envelope as a primitive string through captured serialization; host typeof/getString, -parse and strict arm validation replace prototype-sensitive object dump. Author -results cannot select an outer control arm. This covers caught access, returned ctx, -nested outputs, getters and toJSON, not just literal property reads. - -Any denied value access produces the fixed non-retryable secret-context sandbox -failure specified in the canonical contract, even if authored code catches the -accessor's exception. It cannot become a false condition, generic TypeError, -successful marker or output-schema success. No raw value enters the VM or its -error detail. Clean context reads and key-only enumeration remain usable. Preserve -existing condition/output serialization rules; prototype/intrinsic mutation cannot -replace the trusted result check. This mechanism requires real VM causal controls, -not only a host Proxy or wrapper mock. - -The initial ordinary-object feasibility wrapper was forgeable through inherited -toJSON at host dump, despite its private latch. A revised primitive-transport -probe closes that schedule and preserves const binding refusal. Both are design -evidence, not product implementation. -Permanent actual-engine/store tests below must establish the final mechanism. -Existing direct-input markers remain their Accepted contract; extending this -fail-loud rule to them is not silently included. +### Remove denied values; report observed denied access + +Expression scopes contain clean context/input values and explicit denied-name +metadata, never raw secret-input or tainted-context values. Direct secret inputs +now use the same denied accessors as context. This deliberately replaces the +expression-input marker rule in ADR-0027/0083; run-start/input-node/display markers +remain data under their existing contract. No universal rejection of marker-shaped +objects or arbitrary secret-looking user/model text is claimed. + +Inside QuickJS create frozen denied accessors with a private sticky latch. Evaluate +authored code in a separate strict lexical environment receiving only the frozen +public pack with const bindings; capture trusted intrinsics first. Serialize the +authored result once to a primitive payload string **before** the final latch +check. The outer null-prototype control envelope contains only trusted primitive +metadata and that string; no authored object/getter/toJSON executes during its +later serialization. Host typeof/getString/parse/strict validation replaces +prototype-sensitive object dump. Preserve current result semantics through the +canonical tagged transport rather than indiscriminate JSON normalization. + +When a denied accessor body executes, denial wins even if author code catches its +exception or triggers it during result serialization. Use the existing +sandbox_error code with fixed non-retryable secret_context reason, not a new +engine ErrorCode or an authored error-name test. No raw denied value/detail enters +the VM. Clean reads and key-only enumeration remain available. + +**Finite semantic guarantee:** resource failure before the accessor body can be +caught without setting the latch; the installed QuickJS intake reproduces this. +Do not promise detection of every attempted access or universal fail-loud behavior. +Raw-value exclusion still protects confidentiality in that case. An uncaught +resource failure keeps its existing classification. Latch purpose is preventing +silent false branching after an actually observed denied read, not supplying the +privacy boundary. Required tests pin the documented limitation and ordinary +caught-read denial; neither is disguised as full attempted-access detection. + +ADR-0093 considered VM accessors for out-of-closure reads but preferred parse-time +refusal to retain before-money-spent ordering. This decision addresses runtime +secret-value exclusion and semantic diagnosis, not that scheduling guarantee; its +existing literal dependency scan remains. No host function is introduced. + +The original ordinary-object wrapper allowed inherited-toJSON envelope forgery. +Primitive transport closes that path, but is not itself proof of complete latch +coverage or integrated result compatibility. Product engine/store acceptance +below remains required. Existing ordinary sandbox error classification is retained; +only the private secret-denial arm is independent of authored name/message. ### Tool provenance scope @@ -112,15 +130,26 @@ documented limitation; the actual context leak is repaired separately. This does not mask arbitrary user/model text or encrypt history. ADR-0050/0095 retain their precise scopes. Existing historical SandboxError.detail may contain sensitive authored text; do not call it universally secret-free or render it. -New denied-context errors contain fixed diagnostics without source/value text. +New denied-value errors contain fixed diagnostics without source/value text. +Previously persisted event/step/run output or error detail can still contain +sensitive content: this change does not rewrite history, value-scan replayed +outputs or certify legacy resume data secret-free. Document that at-rest/recovery +limit with ADR-0050; never auto-render private error detail. Considered handler-only marker projection: rejected for silent branching/schema changes and repeating the second-gate defect. Considered qualifying once at engine construction: selected for raw-value removal, with actual VM/template consumers -for loud refusal. Considered rejecting all expressions when any context is tainted: +for loud refusal. Considered rejecting all expressions when any input/context is tainted: rejected because unrelated clean expressions must work. Static literal scanning alone cannot close computed/whole-context access. Full dynamic output taint and fabricated empty tool metadata add unsupported claims without repairing the sink. +Considered keeping direct-input markers: rejected because they reproduce silent +false branching. Considered a host-function latch: rejected to preserve ADR-0027’s +zero-host-function boundary. Considered static literals plus throwing accessors: +insufficient for computed/caught reads. Considered no latch: simpler privacy, but +loses ordinary caught-read diagnosis. The private latch is chosen for that bounded +semantic benefit; revisit if its implementation cost exceeds the demonstrated +benefit, without weakening raw-value exclusion. ### Acceptance and landing @@ -128,36 +157,45 @@ fabricated empty tool metadata add unsupported claims without repairing the sink direct/computed/serialized/transitive/nested/custom merge, caught access, condition oracle, returned ctx/getters/toJSON, final-envelope prototype poisoning, const reassignment and intrinsic/latch tampering. - Assert the expected typed failed outcome, no branch/output-schema success and - no raw VM input/error detail. Independently successful clean workflows must + For executed accessors assert typed denial and no branch/output-schema success; + separately pin caught pre-accessor resource failure without claiming a denial. + Verify no raw newly projected value in VM input/error detail. Pin single + result serialization, stateful toJSON and exact current primitive/result parity. Independently successful clean workflows must complete with exact expected outputs; no vacuous negative-only test. 2. Actual SQLite hosts scan `run_events.payload_json`, `step_executions.output_json` and `runs.output_json`, plus public events and downstream human gate messages. Clean positive rows prove persistence occurred; leak fixtures prove the baseline fails the confidentiality assertion. 3. Every fresh/resume/preparation producer supplies the owned projection. - Missing metadata and mutation controls exercise actual VM and public-template + Missing metadata and mutation controls (including attempted live-Set clear) + exercise actual VM and public-template consumers. Removing engine qualification, template refusal or VM latch breaks its corresponding regression with exact restoration. -4. Direct/transitive parser model/human refusal, trusted raw resolver, direct-input - markers and external tool guard retain their existing behaviour. Label already - passing controls as preservation; no nonexistent mapping producer is certified. +4. Direct/transitive parser model/human refusal, trusted raw resolver, display/ + input-node markers and external tool guard remain preservation controls. New + direct-input VM/template refusals include hand-constructed workflows with + successful clean positives. No nonexistent mapping producer is certified. 5. Dedicated secret-flow security sitting, required gates and two new independent complete cumulative clean rounds precede implementation acceptance. At ADR acceptance append ADR-0027/0029/0037/0083/0093 forward notes before dependent - code. Land canonical workflow, runner, expression and both tool passages and - register actual #92 limitation separately from repaired exposure. + code. Reconcile expression-input marker compatibility and ADR-0093 rationale. + Land canonical workflow, runner, expression, both tool passages, stale scope/ + error comments and at-rest/recovery notices; register actual #92 tool-mapping + limitation separately from repaired exposure and finite latch semantics. ## Consequences ### Positive - Raw derived context no longer reaches executor/preparation or expression scopes. -- Denied expressions fail explicitly, including computed and caught accesses. +- Executed denied reads fail explicitly, including ordinary computed/caught reads; + privacy does not depend on a universal attempted-access detector. ### Negative - Expressions previously reading leaked context now fail deliberately. This is - an explicit security compatibility change; clean and direct-input behaviour stays pinned. + an explicit compatibility change, including direct secret-input expression reads. + Clean behaviour and display masking stay pinned; resource-before-accessor + detection and legacy persisted data are explicitly outside the new guarantee. - Low-level embeddings migrate to a required owned projection and template/VM consumers. A trusted host's fabricated provenance remains outside the guarantee. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 763381fc..88a27cb9 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -687,71 +687,127 @@ syntactically valid frame paths as negative controls. ### Failure classification and remedies +These are CLI dispositions, separate from engine ErrorCode. One closed +`CliErrorCode`/classification authority in the pure process layer owns the new +`cli_internal`, `output_unavailable`, `storage_unavailable` and +`config_write_refused` codes; ADR-0112 does not create a parallel error union. + | Surface / origin | Proposed disposition | |---|---| -| Workflow run/gate/budget resume: actual terminal or pause | Existing 0/1/3; actual joined 8, then 7, then terminal uncertainty 5 or fencing 6 take priority | -| Chat/chat resume: actual failed turn or deliberate end | Existing 1 or 4; joined receipt/effect health takes priority | -| Home: clean shell end, nested chat end, external signal | Existing 0; consumes nested 4; preserves 128+signo for actual signal termination | -| Standalone agent: actual turn outcome / external signal | Existing outcome 0/1; actual signal 128+signo | -| Management/meta: successful result or known operational failure | Existing 0 or classified 1; invalid invocation/config remains 2 | -| Typed corrupt/unreadable history, known departure-unacknowledged | Preserve known operational 1 unless actual joined 8/7/5/6 overrides; do not infer acknowledgement | -| Classified output-unavailable/EPIPE/failed write acknowledgement | CLI-only `output_unavailable`, exit 1 unless actual stronger safety health overrides; never raw stack | -| Unexpected CLI throw/invariant/wiring failure on **any** surface | Fixed `internal` diagnostic, exit 9 unless actual stronger safety health overrides; never chat-end 4/Home success | +| Workflow run/gate/budget resume: actual terminal or pause | Existing 0/1/3; actual RunHandle health has the precedence below | +| Chat/chat resume: classified failed turn / deliberate end | Failed turn stays in the REPL; deliberate end is 4. No invented session 5–8 health channel | +| Home: clean shell end / nested chat end / terminating external signal | Existing 0; consumes nested 4; actual signal 128+signo absent stronger observed disposition | +| Standalone agent: actual turn outcome / terminating external signal | Existing 0/1; actual signal 128+signo absent stronger observed disposition | +| Management/meta success / known operational failure | Existing 0 or classified 1; invalid invocation/schema remains 2 | +| Typed corrupt/unreadable history or known departure-unacknowledged | Existing known operational 1/remedy, unless actual workflow health overrides | +| Owned storage boundary: typed DbOpenError or filesystem EACCES/EPERM/ENOSPC/EROFS | Fixed `storage_unavailable`, operational 1; correct permissions/capacity before retry. No arbitrary cause text | +| Failed-output/EPIPE/write acknowledgement on any surface | Fixed `output_unavailable`, exit 9 unless actual stronger workflow health overrides | +| Unexpected CLI throw, invariant, native import/ABI or wiring failure on any surface | Fixed `cli_internal`, exit 9 unless actual stronger workflow health overrides; never chat-end 4/Home success | | Recoverable unhandled background rejection | Keepalive; upgrade only otherwise clean 0 to 9, preserve established nonzero outcomes | -| Fatal exception / rejected invocation | Stop admission, one bounded fatal shutdown; generic 9 with observed stronger safety health retained | - -Do not change the broad `CliErrorCode.internal` table alone: inventory and classify -every existing constructor site independently, retaining known operational codes. -Engine terminal `error.code=internal` still denotes a genuine failed run/turn. - -Exit **9** means the CLI could not complete reliably. After admitted paid/effectful -work, **do not automatically repeat it**: inspect logs/ledger and external targets -before recovery. It certifies neither absence of work nor receipt durability. -Exit 1 due to unavailable output has the same inspect-before-retry limitation. -If both sinks fail, no diagnostic delivery is claimed. Node may itself exit 9 -for invalid runtime arguments **before this entry runs**; the CLI diagnostic -distinguishes the handled case, not the numeric value alone -([Node exit codes](https://nodejs.org/docs/latest-v22.x/api/process.html#exit-codes)). - -The fatal budget is three seconds from the first fault, including attempted -diagnostic flush. It is a responsive-event-loop backstop, not preemption of blocked -JavaScript/native work; native tests need an external watchdog. Normal receipt -joining remains unbounded by this fatal policy. A timer never acknowledges an owner -or allows an unjoined store to close. Register owners before work, stop all even -if one callback throws, release only after actual joins, reap only owned native -resources on fallback and guard handler re-entry. +| Fatal exception / rejected invocation | Stop admission and enter one bounded fatal shutdown; compute disposition from actual observations | + +Do not remap broad `CliErrorCode.internal` wholesale. Inventory each constructor, +catch and direct forced-exit site before integration: workflow/gate departure, +agent-run catch, chat slash/export/workflows/cost/doctor/compact and rebuild catches, +Home initial/submit/clear/reseat arms, persister backstop and signal/force-quit +paths. Known operational refusal stays inline where supported; unexpected faults +must reach the invocation coordinator even if a balanced internal turn event was +emitted first. That engine event alone cannot convert an escaping CLI fault to +an ordinary turn outcome. Programmer-invalid DB URI is not a normal CLI +storage-open failure; preserve its API validation boundary. + +Classify native storage errno only at the actual owned I/O boundary, not by +examining arbitrary provider/plugin objects or private cause trees. Recognize +actual typed DbOpenError without importing DB through the lightweight entry. +The lazy runtime adapter supplies its classification. Existing CLI open wrappers +that label runtime permission/capacity failures invalid invocation/2 deliberately +move to operational 1. Step 6 defines this mapping and Step 8 installs minimal +boundary adapters; Step 18 centralizes them after all callers have moved. + +A sticky observation record computes this total priority on return **and throw**: +actual workflow health **8 > 7 > 5 > 6**, then fatal/output infrastructure **9**, +then an actually terminating external signal **128+signo**, then ordinary outcome. +5 and 6 remain the existing terminal-uncertainty versus fencing dispositions, not +new session statuses. First workflow SIGINT remains cooperative run cancellation +(normally 1); raw-mode chat Ctrl-C remains deliberate end 4. The recoverable +background net is the separate keepalive rule in the table. + +The first termination request latches admission-stop and one cleanup operation. +Later observed faults may upgrade the final disposition, never duplicate cleanup, +renew a deadline or downgrade 9 to a signal/session-end result. A fault first +observed after synchronous process exit cannot be included retrospectively. + +Exit **9** means CLI infrastructure could not complete reliably, including output +delivery. It does not say the workflow failed. `cli_internal` and +`output_unavailable` distinguish unexpected failure from classified broken output. +After admitted paid/effectful work, **do not automatically repeat it**: inspect +logs/ledger and external targets before recovery. It certifies neither absence of +work nor receipt durability. Session surfaces expose stop/join and retained +publication failures, not RunDeparture health; their 9 remedy carries the same +uncertainty. If stderr fails, no diagnostic delivery is claimed; stdout is never a +fallback diagnostic sink. Node may itself exit 9 for invalid runtime arguments +before this entry runs; a CLI diagnostic distinguishes the handled case, not its +numeric value alone ([Node exit codes](https://nodejs.org/docs/latest-v22.x/api/process.html#exit-codes)). + +### Invocation owners and fatal ordering + +One invocation-owned lightweight registry is installed before dynamic imports. +Owners register before admitting work and unregister only after actual settlement. +Callbacks expose facts/capabilities specific to their owner, not a generic false +health object: + +| Owner | Available observations / actions | +|---|---| +| Workflow | RunHandle departure/durability/terminal error; actual host joins and owned producer teardown | +| Chat/Home/standalone AgentSession | Cancel/stop and existing whenHostSafeToClose join; retain rejection/publication failure. No invented RunDeparture, 5–8 or healthy receipt inference | +| Terminal renderer/input | Stop redraw/input and SIGCONT reclaim; restore raw/paste/mouse/cursor/alternate-screen custody | +| Native tool/MCP | Exact creation-specific registration, stop/join and the finite reap authority below | + +Stop every owner even if one callback fails, before awaiting one of them. Release +terminal custody before the final fixed stderr diagnostic and its acknowledgement; +otherwise alternate-screen restoration can erase it. Restoration control bytes use +the existing terminal control sink, not diagnostic stdout. Keep host/DB close +behind actual joins; fatal process termination does not grant safe store close. + +The fatal deadline is three seconds from the first infrastructure fault, including +join, native helper/reap, restore and flush. Every callback/helper receives only the +remaining common monotonic budget; no serial per-owner renewal. An already earlier +bounded signal teardown can shorten it, never extend it. It is a responsive-event- +loop backstop, not preemption of blocked JS/native work; native tests require an +external watchdog. Normal receipt joins retain their actual protocol. A timer +never acknowledges an owner. After expiry no unbounded async helper starts; +finite nonawaited exit cleanup cannot certify native closure. ### Native crash teardown scope -On POSIX, terminate only registered owned process groups/children. On Windows, -use the OS [taskkill](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/taskkill) -facility with fixed `/PID /T /F` arguments, shell=false and no -image-name/remote/wildcard selectors. Resolve its absolute System32 location from -the invocation's trusted OS installation root, canonicalize and verify that fixed -regular executable there before any tool/MCP spawn; never search workspace/PATH, -or accept a workflow/model-supplied executable/root. A missing/invalid facility -refuses subprocess admission; it cannot silently degrade to claimed tree cleanup. -The OS installation root is trusted host environment, not a security boundary -against an already hostile launching user/process. - -Register exact ChildProcess ownership before exposing work; retain it until close. -Do not kill a stale PID after the owned root has closed. Bound taskkill completion -within the responsive fatal budget, attempt all registered owners, and join actual -native close separately; termination-request success is not acknowledgement. - -**Finite guarantee:** best-effort cleanup of the live registered root/current -descendants on Windows and owned process group on POSIX. Already exited roots, -escaped/reparented/detached descendants and same-user PID-reuse races are not -race-free contained by these primitives. Do not scan ambient processes or claim -no orphan can ever remain. Retain uncertainty/remedy when membership/closure is -unknown. This explicitly bounded crash mechanism is the proposed Wave 3 scope; -full M/N child isolation/containment remains with its existing owner, not silently -certified here. Full Windows job-object containment would require a separately -approved native-helper/dependency decision; no such dependency is authorized. - -Required native Windows controls cover hostile PATH, invalid OS-root facility, -live descendants, root-exit/stale registration, refusal, timeout and actual close. -POSIX tests cover owned groups and the same uncertainty distinctions. +| Creation owner / platform | Permitted finite teardown | +|---|---| +| POSIX run_command | Its explicitly detached owned process group; retain the group token through bounded final reap even if the leader closes | +| POSIX SDK-backed MCP | Its owned live PID only; SDK spawn is not detached. Never cast PID to negative PGID or kill the CLI's group; descendants are unclaimed | +| Other POSIX child | Only the creation-specific PID/group authority actually registered; no ambient scan | +| Windows supported child/MCP | Verified absolute taskkill with fixed `/PID /T /F`, shell=false; no cached closed root or image/remote/wildcard selectors | + +Resolve [taskkill](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/taskkill) +from the trusted OS installation root, canonicalize/verify that fixed regular +executable before tool/MCP spawn; never use workspace/PATH or model-supplied roots. +Missing/invalid facility refuses Windows subprocess admission, with a fixed +remedy. The OS root is trusted launching environment, not protection against an +already hostile launching user/process. The helper is itself owned and its runtime +fits the same remaining deadline; a successful request is not target close ACK. + +Use actual owner close observations. MCP's latched live PID list alone is not a +ChildProcess handle or race-free liveness proof: a narrow owner adapter must expose +its known-close/lifetime state, gate fallback on current ownership and perform a +best-effort immediate liveness check. Preserve PID-reuse uncertainty. A closed +Windows root cannot be used for `/T`; an explicitly created POSIX group may still +need final reap after leader close, but bounded PGID retention is not reuse-proof. +Escaped/reparented/detached descendants and PID/PGID races are uncontained; do not +claim universal no-orphan behavior. Existing M/N isolation remains its own work. + +Required native controls distinguish POSIX group versus MCP PID, leader/root exit, +actual close versus request, competing faults/signals, hostile Windows PATH, +invalid facility, timeout and both failed sinks. Full Windows job-object/native +containment would require a separate approved decision; none is added here. ### Discovery DTO and one descriptor authority @@ -780,8 +836,11 @@ Canonical paths are `chat resume`, `chat list`, `chat export`; flat aliases keep working. Canonical pricing flags are `--input-price` and `--output-price`, with old `--input`/`--output` deprecated. Preserve separate source-spelling presence until conflict checks; both spellings for one quantity refuse even when equal. -Only then normalize to existing executor args/decimal parsing. Workflow input, +Only then normalize to stable executor/model binding keys `input` and `output` +and existing decimal parsing. Model DTO calls are not shell alias usage. Workflow input, reserved standalone-agent input and gate payload meanings stay command-scoped. +All product-generated remedies/examples use canonical pricing/chat spellings; +retained aliases do not cause the product to recommend its own deprecated syntax. Explicit `--help --json` (including `-h`) prints one DTO and exits 0, without services. Resolve known command/subcommand/alias grammar and `--` terminator; diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index cef7e007..1ea022e7 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -21,13 +21,27 @@ or escaping tar entries, unbundled internal imports, undeclared external package and unanalyzable runtime loading unless explicitly reviewed/allowlisted. Chunk layout must preserve real installed `../drizzle` resolution. Runtime files, migrations, package metadata and existing documented license/changelog assets -form an explicit public allowlist. No source TS/tree, map, private inventory, +form an explicit public allowlist. Only the entry has its source hashbang and executable mode 0755; runtime chunks +have no hashbang and mode 0644. Remove the global tsup banner before emission, +not a first-line rewrite after mapping. Validate tar modes and every output. +No source TS/tree, map, private inventory, build metafile or sourceMappingURL may enter npm. The lightweight entry imports only pure flag/descriptor/diagnostic/owner substrate. Runtime modules load under the early fault boundary. Native witnesses and static reachability prove meta operations do not touch config/cache/DB/keychain/MCP/UI. +Meta parsing retains pure grammar/count/size/absolute-root checks, but does not +stat/admit root directories: `--help --allow-fs-root ` now +succeeds without granting authority. Real execution still admits first. Descriptor grammar and JSON help belong to [commands.md](commands.md#proposed-wave-3-cli-compatibility). +The lazy dispatcher owns one invocation-local `seedFileCatalogOnce()` gate after +filesystem admission and before first pricing/model access. Its callers share the +same guard across Home/new/resume/clear/reseat. Preserve current DB overlays in +run, gate quote/execution, chat fresh/resume/clear/reseat, Home fresh/resume, +standalone agent and models; these eleven current call sites are an audit +inventory, not eleven new seeds. Empty/torn DB cannot erase fallback, current DB +tail wins, offline agent fixture and media no-cache exceptions remain. + One registry instance passes through the chunk graph; no global-symbol bridge silently replaces existing private-module provenance. @@ -37,15 +51,28 @@ silently replaces existing private-module provenance. TypeScript maps and no inline source contents. Ordinary workspace builds stay map-free. Disable build-cache read **and write**, including Turbo local/remote, Actions build caches and later broad capture; force alone is insufficient. - Build via existing dependency-ordered scripts in that private stage; do not accept stale incremental/cache output missing maps. + In the owned fresh checkout, clean dependency dist/incremental files, then run + installed tsc with `--sourceMap true --inlineSources false --incremental false` + against each existing tsconfig.build.json in order shared → llm → core → db → + mcp. For example `pnpm --dir packages/shared exec tsc -p tsconfig.build.json + --sourceMap true --inlineSources false --incremental false`, then the remaining + packages in that order. This bypasses Turbo cache entirely; do not alter ordinary + map-free scripts/config defaults or install a different compiler/bundler. Keep current package exports, engine purity and SDK seam. 2. Invoke `pnpm pack` once. Prepack owns the sole final CLI build, clean output, minification, splitting, migration copy, map chaining and closure checks. - Release must build dependencies beforehand, not build CLI then rebuild in prepack. -3. Before any receipt, remove only tsup's expected final map-link trailers and - move maps/metafile to owner-only staging outside the npm package. Assert maps - have repository-relative original TS sources and absent sourcesContent. - No transform/banner/minifier/JS write is permitted after finalization. + Candidate-mode prepack explicitly enables tsup sourcemap/splitting with external + maps and sourcesContent=false; force-running a map-free config is insufficient. + Release builds dependencies beforehand, not CLI then CLI again in prepack. +3. One finalizer inside prepack inventories consumed upstream JS/maps and every + emitted JS/map. Refuse missing/malformed upstream maps: bundler success may + otherwise silently map engine frames to compiled JS. Validate and rewrite map + source paths to repository-relative original TS identities; reject unsupported + sourceRoot/absolute/URL/escaping paths and preserve mappings/names. Assert absent + sourcesContent. Remove only expected final tsup map-link trailers without moving + executable code lines. Stage maps/metafile owner-only outside npm, verify closure + and public file/mode inventory, then hash. No transform/banner/minifier/JS write + is permitted afterward. 4. Inspect the produced tarball. Compare each packed JS byte digest and migration inventory with finalized files; reject prohibited entries independently of the package files-list. Only now seal its private map manifest. @@ -119,59 +146,117 @@ maps are discarded. A trusted-tag release makes its own fresh candidate once, then obeys no-rebuild/no-re-encryption retry rules. This is a distinct release build identity, not a replacement for an existing same-version release candidate. -**Release mode** runs only for the real trusted pushed tag. First retrieve any -matching original release candidate for retry. If none exists, require confirmed -production recipient in the originating isolated pack job, build/pack/maps once, -and encrypt there **before any job handoff**. Private bytes never enter shared -staging. Its public binding receipt has mode release plus the fields above in -the release-receipt contract. If required custody is unavailable, fail the release; -do not downgrade to package-test mode or use a fixture key for product maps. - -Both modes run the same exact-tarball smoke implementation and local map checks. -Only release mode can enter the archive/publish DAG. Mode, trusted trigger/source -and receipt digest are validated by consumers; a flag or self-declared receipt -does not confer trusted release authority. +**Release mode** runs only for the real trusted pushed tag. A permissioned discovery +job first retrieves original candidates and reserves a fresh identity as described +below. Only a verified `fresh` route enters build/pack/encryption with the confirmed +production recipient in the isolated originating job **before any handoff**. +`reuse` downloads exact prior bytes and invokes no build, pack, finalizer or GPG. +Required custody failure never downgrades to package-test mode or fixture keys. +Both modes run the same tarball smoke and local map checks. Self-declared mode or +receipt strings do not confer release authority. ## Archive, retry and publication -Release workflow staging contains only tarball, ciphertext and release receipt. -A dedicated archive job after pack/smoke creates or reuses a draft Release for the -real pushed version tag, uploads those three assets, downloads/verifies them, then -permits npm publication of **that exact tarball**. A successfully published package -can finalize its matching Release; a failed npm step leaves an identifiable draft -candidate for recovery. Existing conflicting Release/tag/assets refuse. +Top-level workflow permissions are `{}`; each job lists its minimum capabilities. +Release jobs require real pushed `v*` tag, package-version equality and main ancestry. +PR/fork/manual workflow_dispatch, even against a tag or off-main override, cannot +mutate Releases or publish. Use separate dry-run concurrency; release-candidate +writers serialize by version, never run/attempt ID, and recheck remote identity +immediately before mutation. Repository defaults or immutable Release settings are +not relied on; the workflow enforces no overwrite even when immutability is off. + +| Job in strict dependency order | Permission / responsibility | +|---|---| +| Admission | Contents read; validate actual event/tag/source/version/main ancestry | +| Discovery/reservation | Contents write for draft visibility/reservation, Actions read for cross-run artifact retrieval; no install/build/npm credentials | +| Pack or reuse | Contents read; fresh alone builds/packs/encrypts once; reuse consumes discovery's verified staged original bytes | +| Exact-tarball smoke | Contents read; every required OS/minimum-runtime leg uses the chosen digest | +| Archive | Contents write after smoke; verify candidate, upload absent matching assets only, download/verify all three | +| npm publish | Contents read plus existing id-token provenance permission; needs smoke **and archive**, consumes verified tarball/public archive receipt; never edits Releases | +| Release finalization | Contents write after successful or verified already-complete npm publication; recheck assets/npm identity, then publish matching draft | + +Discovery paginates drafts/published Releases and originating trusted release +workflow runs/attempts/artifacts. Draft lookup must use its authorized API/listing; +a read-only tag lookup's 404 is not proof of absence. Verify source/run/attempt +against GitHub metadata and the trusted tag-only workflow source. Artifact names +and receipt fields alone are insufficient. API permission/transient/pagination or +history uncertainty returns failure/recovery_required, never fresh. Cross-run +retrieval occurs in this Actions-read job; pass only verified public candidate +assets onward. Retain original origin identity on retry. -Archive and npm both require real pushed `v*` tag, package-version equality and -main ancestry. Manual workflow_dispatch, including a tag ref or off-main override, -is build/smoke only: no Release mutation or npm publish. Archive alone gets -Contents write; npm keeps its separate provenance permission. PR/fork runs never -upload release assets. Configure version concurrency to prevent competing candidate -writers and recheck before upload; do not rely only on asset names. +The [Releases API](https://docs.github.com/en/rest/releases/releases#list-releases) +limits draft listings to push-capable callers; +[cross-run artifact API access](https://docs.github.com/en/rest/actions/artifacts) +requires Actions read. These API capabilities are explicit job requirements, +not authority inferred from public repository visibility. + +Before first build, discovery creates a draft reservation for version/source and +originating candidate attempt. No prior Release/reservation or candidate-producing +attempt may exist for the fresh route. This small public durable record makes an +interrupted build or expired staging distinguishable from a fresh version. An +incomplete reservation with no recoverable complete candidate requires explicit +manual recovery; the same job rerun does not silently build again. Collision, +inaccessible/ambiguous/deleted history or an unrelated existing Release refuses. +No production reservation/mutation occurs in Wave 3's offline controls. + +After smoke, archive stores exact tarball, original ciphertext and release binding +receipt as per-version Release assets, downloading to verify all digests. It passes +tarball plus public archive receipt (version/source/three digests/Release identity) +to publish, so the read-only publish job need not retrieve a hidden draft. Publish +rechecks that binding and npm version integrity: equal existing bytes mean +publication complete, different bytes refuse. Finalization owns the later draft +transition; it cannot run on publication failure. Failed npm leaves the draft +candidate recoverable; failed finalization retries without publication/build/GPG. | Durable state | Permitted action | |---|---| -| No prior release candidate | Build/pack/encrypt once, stage original candidate | -| Trusted candidate staging exists | Retrieve/verify and reuse exact bytes, rerun smoke if needed | -| Complete matching Release assets exist | Retrieve/verify; no rebuild, re-encryption or overwrite | -| Partial upload, trusted full staged candidate recoverable | Verify present assets against it; add only absent matching assets | -| Conflict or expired staging without complete recoverable candidate | Fail with explicit manual recovery; never invent replacement matching ciphertext | - -Retain exact tarball as well as ciphertext/receipt for every supported release: -Actions retention can expire even when npm publication failed. Release is not -private storage; only ciphertext protects debugging material. Do not automatically -delete or replace per-version assets. Before npm retry, detect existing version -and compare package integrity; different bytes refuse, equal published bytes -count as completed publication rather than republishing. +| Proven absent history/candidate, new reservation recorded | One fresh build/pack/encryption for its original attempt | +| Complete trusted original staging | Retrieve/verify and reuse; rerun smoke, no rebuild or encryption | +| Complete matching draft/published assets | Retrieve/verify; no build/encryption/overwrite | +| Partial assets plus trusted complete original staging | Verify present assets; add only absent matching bytes | +| npm already contains equal candidate | Verified publication completion; finalize matching Release if needed | +| npm publication failed or finalizer failed | Resume the applicable downstream jobs using the same complete candidate | +| Incomplete reservation, missing asset/ciphertext, expired/inaccessible staging, conflicting bytes or ambiguous origin | Fail with manual recovery; never manufacture a replacement candidate | + +Support failed-jobs, all-jobs and cross-run retries through that same discovery +route **before any build step**. A missing source artifact cannot be compensated by +re-encryption. Retain tarball/ciphertext/receipt for every supported release beyond +Actions expiry, including npm failures. No automatic asset deletion/replacement. +Public Release is not private storage; only ciphertext restricts debugging payload. + +Test the same state controller behind injected GitHub/npm API ports with synthetic +identities and a permission-enforcing fake service. Cover read-only hidden draft, +Actions-read denial, pagination, 403 versus 404, reservation/interruption, all durable +states, every rerun mode and npm timeout-after-success. Assert build/pack/GPG/npm +call counts and unchanged conflicting bytes. Static workflow controls verify +predicates, permission minima, concurrency, dependency graph and required CI +aggregation. A dispatch dry-run cannot substitute for these release-mode controls. +No live tag/upload/publication or production key is required for Wave 3; real +production token/custody configuration remains separately attributed Wave 4b work. Required PR/push gates pack once and all supported OS consumers install that same -digest. Include a packed runtime consumer at **Node 22.0.0**, separate from the +digest. Add the packed aggregate to required ci.needs **and** its success checks +under always(); missing/skipped/cancelled pack or platform legs fail the gate. +Include a packed runtime consumer at **Node 22.0.0**, separate from the workspace install floor at 22.13, plus current .nvmrc on macOS/Linux/Windows. If the pinned runtime closure fails the public floor, resolve the compatibility decision explicitly before acceptance. Reuse the same smoke in release CI. Controls cover actual run/chat/migrations/native load/MCP version/CAS/iterator -identity and early import failure without keychain/provider calls. +identity and early import failure without real keychain/provider calls. A real +installed chat control drives prompt → AgentSession turn → assistant output → +SQLite persistence → clean end/resume using an external test transport hook and +throw witnesses for network/keychain. Assert actual dispatch count and persisted/ +output markers. Do not invent a shipped chat --fixture flag, replace the inlined +implementation under test or call an empty EOF run chat acceptance. Hook feasibility +and cross-platform execution must be proven during implementation. Wave 3 implements this path with offline/API-fake controls and a dedicated crypto/CI-custody security sitting. Actual production configuration/publication belongs to Wave 4b and fails closed if custody is missing. Required final whole-wave checks repeat exact-artifact evidence after later CLI/DB changes. + +At Step 9 landing update the release runbook's currently accurate single-bundle +procedure to this graph, modes/recovery and symbolication; do not relabel historical +single-bundle evidence as testing this design. Before whole-wave acceptance merge +this Proposed contract into implemented reference status and remove stale proposal +banners only for actually accepted mechanisms. diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index d02ab8a7..cd23040b 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -269,11 +269,15 @@ implementation landing. - Global-only `[chat_http].allowed_domains: string[]`, optional, absent/empty deny-all. At most 128 entries, each at most 253 characters after an optional terminal root-dot is removed. Canonical ASCII DNS: labels 1–63 characters with alphanumeric - ends and internal hyphens, at least two labels; case-fold/root-dot normalize and - deduplicate. IDNs require explicit punycode. Reject URLs, ports, paths, credentials, + ends and internal hyphens, at least two labels. Check ASCII **before** any case + folding; then normalize case/root-dot and deduplicate. Reject numeric final + labels/WHATWG IPv4 forms (including 127.1 and 0x7f.1), not only dotted quads. IDNs require explicit punycode. Reject URLs, ports, paths, credentials, wildcards, suffix grants, IP literals, single-label names, and names equal to or ending in .localhost or .local. Existing - shared URL/authority extraction supplies request hostname normalization; use one - pure DNS-name validator/comparator for config and dispatch, not another URL parser. + shared URL/authority extraction supplies request hostname normalization; one pure + DNS validator/comparator rejects alternate IP interpretation and agrees with + actual connection authority. Do not introduce another ad-hoc URL parser. Workflow + allowedDomains retains its separate existing policy; this grammar does not + silently change stored workflow grants. - Global-only `[preferences].keychain_unavailable_acknowledged: boolean`, optional, absent=false. It is reminder state, not credential state. Only a typed backend unavailable result plus explicit user choice may set it; no startup auto-write. @@ -287,14 +291,37 @@ Standard protected global storage and arbitrary operator-selected paths have different filesystem guarantees; explicit selection is not proof of model unwritability. Other network capabilities retain their own contracts. -The invocation captures initial ceiling I and accepted ceiling A=I. Before **every** -host creation (including Home's later chats, resume, clear and reseat), reread only -global HTTP policy G and set A=A∩G before later build effects. A validated removal -remains effective even if the rest of that build fails. Never reset A or use captured -startup chat/model-read fallback on failure. Read/parse failure refuses creation; -it does not supply previous authority. Widening/restoring a removed grant requires -a fresh CLI invocation. An active host owns its snapshot until cancelled/rebuilt; -cancel/retire it before replacement. No session row persists this authority. +Pin the global selection once: resolve relative --config against invocation +startup cwd, capture the default home/config path once, and never rebase either on +later --cwd, environment changes or model input. This pins lexical selection, not +an intentional read-side symlink's referent. Follow the selected link under the +existing read contract, and state that changing that referent changes operator +policy. A genuinely absent initial regular-file selection yields I=A=[]; dangling +links, ENOTDIR, permission failure or disappearance during a read are errors. + +Before first host/key/MCP construction, capture owned ceiling I and accepted A=I. +One live invocation owner supplies effective HTTP authority to **all** old, pending +and replacement hosts. Every new/resume/Home/clear/reseat build rereads current +global G and synchronously sets A=A∩G before later build effects. A validated +removal is sticky even if build fails; additions to I or A never widen it and +produce one fixed compatibility notice per refresh. Build-first Home recovery +may retain its old session, but its HTTP guard reads current A, not a host snapshot. + +Read/parse failure (including disappearance after a previously present policy) +suspends HTTP admission and refuses the build with the known fixed diagnostic. +It preserves A for recovery, never treats transient failure as accepted empty G. +Successful later refresh clears suspension and still intersects previous A. +Validated explicit empty policy is a real removal; re-adding then requires a fresh +invocation. No startup/model fallback or persisted session can reset this owner. + +At dispatch, check the current non-suspended view after awaited approval/effect +prepare and immediately before host work, using the owned admitted HTTP target +specified in the [tool contract](../shared-core/tool-registry.md#proposed-wave-3-http-authority-and-secret-provenance). +Already admitted requests may complete. There is no file watcher or recall promise. +Use latest-only build generations for host and selected model publication; stale +completion disposes its host and cannot restore policy/selection. This contract +works with Home's existing build-first lifecycle rather than pretending it stops +its old host before build. This permits only built-in `http_request` to exact allowed hostnames at existing HTTPS ports; it does not enable the tool grant, a missing confirmer, search, MCP, @@ -306,21 +333,34 @@ choice: usable key (including env fallback), missing entry, or unavailable backe Do not infer backend failure from absence or native error strings. Acknowledgement is used only with actual current unavailable+keyless state; a usable key always suppresses setup. Retry setup is explicit; ordinary missing-key/auth/DB failures -are not relabelled keychain unavailable. Preference write failure stays visible. +are not relabelled keychain unavailable. Preference write failure stays visible. Keeping this UX-only preference in global +TOML is deliberate despite portability; a DB record would add another store and +would still require the same current-key check. -For mutation, final-component symlink (including dangling link) refuses with +An explicitly chosen ephemeral provider/model pair belongs to one invocation +selection binding used by all later clear/reseat/Home builds. Reuse the existing +resolved chat/reseat target shape. Commit selection only with the latest successful +build; failed/stale builds never overwrite it. Disk/project defaults stay distinct. + +For mutation, the shared writer checks final-component symlink (including dangling link) refuses with `ConfigWriteRefusedError`, code `config_write_refused`, reason `symlink_target`, non-retryable, fixed path-free message and exit 2. Read-side links remain supported. -Offer manual edit/regular-file replacement and honest compatible ephemeral setup, +Check before loader/read/chmod side effects; all present/future preference writers +use this same primitive. Offer manual edit/regular-file replacement and honest compatible ephemeral setup, not Retry or saved-success. Ancestor links remain owner-controlled; hardlinks get atomic replacement of the chosen entry, not all aliases. No lstat+rename race-free claim. Regular writes retain schema round-trip/0600/temp/fsync/atomic rename. TOML comments/order are lost as in ADR-0063; preserve every validated policy value -and tell the operator this before writing a hand-edited policy. +and emit the fixed compatibility notice before **every existing-file** preference +rewrite (new-file creation has no lost comments). Do not infer hand-editing. Home, +picker and wizard render the known refusal/action, not a generic swallowed error; +unexpected infrastructure faults follow ADR-0110. CLI error codes share its one +closed authority. **Rollback to an older strict binary:** back up the non-secret TOML; remove the entire `[chat_http]` table and the `keychain_unavailable_acknowledged` entry from `[preferences]`, leaving other preferences intact. Then run the older binary. -Without removal, executing commands reject unknown keys with config error/exit 2; -meta operations remain config-free under ADR-0111. Provider keys stay in OS +Without removal, an older strict binary can reject unknown keys with config +error/exit 2 on **any** command, including help/version when it loads config. +ADR-0111’s new meta behavior cannot be promised for older binaries. Provider keys stay in OS keychain. For an intentional link, edit the operator-chosen referent manually. diff --git a/docs/reference/shared-core/agent-runner.md b/docs/reference/shared-core/agent-runner.md index 45a3bd95..3283d53d 100644 --- a/docs/reference/shared-core/agent-runner.md +++ b/docs/reference/shared-core/agent-runner.md @@ -372,21 +372,30 @@ second synchronous cleanup error is not claimed for the current built-in governo ## Proposed Wave 3 qualified context consumers -**Proposed, unshipped — 2026-10-15.** [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md) -requires approval. The exact projection, VM and refusal contract belongs to the +**Proposed, unshipped — 2026-10-16.** [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md) +requires approval. Exact projection, VM/input compatibility, result transport and +finite refusal scope belong to the [expression specification](expression-sandbox-spec.md#proposed-wave-3-secret-context-boundary). -WorkflowEngine qualifies raw resolveContext output once at fresh start/resume and -standalone cross-process budget preparation, before storing or invoking callbacks. -NodeExecContext and NodePreparationContext carry the same required projection. -The preparation Pick must actually include it. resolvePrompt/public-template -lowering consumes denied keys before filter/default resolution; preparation has -no expression sandbox and no unused VM-only metadata. Execution scope consumes -the projection for condition/transform/merge, and agent/human text uses the same -public template path. Test each producer and consumer with a witness. - -Embedding migration replaces raw ctx records with the factory-owned projection -from the admitted workflow; clean embeddings explicitly construct an empty-denial -projection. No implicit absent metadata. Existing raw secret NodeExecContext.inputs -remain the trusted-host contract and continue to be masked at public/sandbox -boundaries. This context repair is not a universal custom-executor secrecy claim. +WorkflowEngine qualifies raw resolveContext output at fresh start/resume and +standalone cross-process budget preparation before storing or invoking callbacks. +NodeExecContext and NodePreparationContext carry the same required projection and +owned immutable secret-input membership. The preparation Pick must actually include +it. Public prompt/agent/human lowering rejects denied context and direct secret +inputs before filters/defaults, including hand-constructed workflows. Preparation +has no sandbox or unused VM-only field. Condition/transform/merge scope omits raw +secret inputs and supplies required deniedInputNames; direct expression-input +markers deliberately change, while input-node/display masking stays. + +Migrate all actual engine producers (fresh execution, budgetPreparationContext and +cross-process resumePreparation.context), the exported scope interface and every +co-located double. Derive/copy secret names once per admitted workflow; no exposed +live mutable Set or resume-time second derivation. Witness each producer and actual +VM/template consumer. Reject absent metadata; clean embeddings explicitly create +empty-denial projections. Trusted raw NodeExecContext.inputs and the raw resolver +remain host APIs, not a universal custom-executor confidentiality boundary. + +Update the stale buildExpressionScope/maskSecretInputs and sandbox/error-detail +comments with their actual split duties. Existing historical persisted outputs +remain outside the new projection guarantee and receive at-rest/recovery guidance, +not silent rewriting. Optional tool provenance is its separate canonical boundary. diff --git a/docs/reference/shared-core/expression-sandbox-spec.md b/docs/reference/shared-core/expression-sandbox-spec.md index de7d94f5..6e2a7f4e 100644 --- a/docs/reference/shared-core/expression-sandbox-spec.md +++ b/docs/reference/shared-core/expression-sandbox-spec.md @@ -259,65 +259,113 @@ error, cap-trip classification, determinism over shuffled `run.outputs` key orde ## Proposed Wave 3 secret context boundary -**Proposed, unshipped — 2026-10-15.** [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md) -requires maintainer approval; existing direct-input marker behaviour is preserved. - -The one `WorkflowContextProjection` owns `values: Readonly>` -(clean keys only) and `deniedKeys: readonly string[]`. Copy/freeze both, detach -record prototypes, and reject duplicate/overlapping or malformed keys. One pure -factory qualifies resolved context against the admitted workflow's existing linear -taint result. It contains names, never raw tainted bytes. The low-level execution/ -preparation `ctx` field becomes this required projection; raw resolver return -remains available only under its trusted public contract. No second raw context -is passed to an executor. Missing/malformed projection refuses with existing -validation ErrorCode and a fixed message, never defaults clean. Trusted host -dishonesty is outside this shape/ownership guarantee. - -Actual consumers: - -- Preparation and agent/human text lowering use `resolvePublicTemplate`, reading - clean values and rejecting denied context references **before filters/defaults**. - Use the existing reference lexer, not a new scanner. A fixed - `InterpolationError` reason `secret_reference` maps to non-retryable validation. -- Expression scope construction keeps masking direct inputs and supplies this - projection. Before raw scope serialization validate metadata; no denied bytes - are serialized. Low-level evaluate calls require explicit projection (owned - empty deniedKeys is the clean migration), not an omitted VM-only field. -- In QuickJS, denied keys are enumerable frozen accessors with a private sticky - attempted-access latch. Raw denied values do not exist in the VM. Author code - runs in a separate strict lexical environment receiving only a frozen public scope pack, outside the latch/serializer closure. - Bind inputs/ctx/run/branches as local const names inside that function (ordinary - mutable parameters would change existing semantics). Capture required intrinsics first. -- Serialize/marshal the result once inside the trusted wrapper, then test the latch - before accepting the type/result. Author catch, returned ctx/nested getter/toJSON, - eval/name guessing, Object.prototype.toJSON envelope poisoning or intrinsic mutation cannot produce successful denial. - Keep current primitive, condition, nonserializable/cycle and lossy JSON rules; - JSON-normalizing first must not conceal a forbidden result type. -- The final private control envelope has a null prototype and is serialized with - captured intrinsics **inside** that closure, returned as a primitive string. - The host checks VM typeof, extracts with getString, host-parses and strictly - validates closed success/failure/denial arms. Never dump a prototype-bearing VM - object after checking denial: inherited toJSON can forge that final observation. - Author results remain nested payload, never the control arm. Bound the transport. - Preserve primitive/condition/boxed/cycle semantics explicitly rather than - JSON-normalizing all values indiscriminately. - -Denied access yields `SandboxError.reason: 'secret_context'`, existing -`code: 'sandbox_error'`, retryable=false, fixed message -“secret-derived context is unavailable to expressions”, with no source/value -detail or cause. The host trusts only its private wrapper outcome envelope, never -an authored throw's name/message. A denial overrides authored success/failure; -actual abort/timeout still has its existing cancellation/resource disposition, -with no false successful output. - -Clean value reads and key-only enumeration are allowed even when unrelated keys -are denied. Reading a denied value fails; it never becomes a false comparison, -marker success or output-schema pass. Existing direct-input marker semantics are -unchanged. `SandboxError.detail` for other arbitrary authored errors is internal, -may carry sensitive text and must not be rendered or called universally secret-free. +**Proposed, unshipped — 2026-10-16.** [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md) +requires maintainer approval, including deliberate replacement of direct secret-input +expression markers. Display/run-start/input-node markers remain unchanged. + +### Owned provenance and consumers + +One WorkflowContextProjection owns values: Readonly> (clean +keys only) and deniedKeys: readonly string[]. Copy/freeze both, detach record +prototypes, reject duplicate/overlapping/malformed keys, and order clean/denied keys +by shared byCodeUnit. One pure factory qualifies resolved context using the existing +linear taint graph. Required execution/preparation ctx becomes this projection; +the exported trusted raw resolver contract stays unchanged. Missing/malformed +projection uses existing validation ErrorCode with fixed content-free message, +never an implicit clean default. Shape checks do not detect a dishonest embedding. + +Derive secret-input membership once from that same admitted workflow. Own its +backing data; expose a ReadonlySet-compatible membership/iteration facade without +Set mutation methods and an immutable ordered denied-name array from the same +factory. Copy caller-provided names at embedding entry: Object.freeze(new Set()) +or a live caller Set is insufficient. Every fresh/resume/preparation producer uses +that same authority, rather than recomputing a mutable set at resume. + +- resolvePublicTemplate uses the existing reference lexer, rejecting denied ctx + **and direct secret input** references before filters/defaults. Existing + InterpolationError reason secret_reference maps to non-retryable validation. + This is required for manually constructed workflow objects as well as parsed + YAML; parser refusal alone is insufficient. Agent/human text and budget prompt + preparation use this same consumer; preparation has no VM-only metadata. +- ExpressionScope requires the context projection and explicit immutable + deniedInputNames; clean embeddings provide explicit empty metadata. Scope + construction omits raw secret inputs and supplies only clean values plus denied + names. No marker substitution in expression inputs. Trusted NodeExecContext.inputs + remains raw; input-node/public display continues using maskSecretInputs. +- QuickJS rebuilds frozen null-prototype scope records and enumerable denied + accessors for both namespaces. Clean values and key-only enumeration remain + usable. Scope metadata validation precedes serialization; no newly projected + raw denied input/context bytes enter the VM. + +### Private evaluation and finite denial + +Author code runs in a separate strict lexical function receiving only the frozen +public pack. Bind inputs/ctx/run/branches as const, outside the private latch and +captured serializer closure. Capture needed intrinsics before authored execution. +When a denied accessor body runs it sets the private sticky latch before throwing. +Perform all authored result serialization before the final latch check; ordinary +caught access, getters and toJSON cannot clear an already-set latch. + +This is **not** universal attempted-access detection. A stack/resource exception +before the accessor body may be caught without setting the latch; the installed +QuickJS intake reproduces it. Successful clean fallback then carries no raw denied +value because none was supplied. Pin that finite semantic limitation separately +from ordinary caught-read denial. Uncaught resource/abort/timeout retains its +existing disposition; do not mislabel it successful denial. Confidentiality rests +on raw-value exclusion, not on the latch. + +Observed denial yields SandboxError.reason secret_context under existing +code sandbox_error, retryable=false, fixed message “secret-derived values are +unavailable to expressions”, with no source/value detail or cause. Only this +private denial arm is independent of authored error name/message; existing ordinary +error classification remains. Historical/arbitrary authored SandboxError.detail +is private potentially sensitive text, never a universally safe diagnostic. + +ADR-0093's alternative (b) concerned before-money-spent dependency ordering and +keeps its literal parse scan. These accessors address runtime privacy/semantic +diagnosis; they do not reverse that ordering choice or add a host function. + +### Primitive transport and result parity + +Versioned private control arms are success, failure or denied. The success arm +contains trusted original VM typeof, a primitive payloadJson string, and a closed +number tag where needed; no authored object is embedded in the outer envelope. +Serialize the author result at most once, finish any author-controlled access, +then check the latch. Serialize the null-prototype control envelope using captured +intrinsics and return it as a primitive string. Later envelope serialization must +not call another authored toJSON/getter. Host checks VM typeof, extracts getString, +parses/strictly validates the private arm and decodes the payload. Never dump a +prototype-bearing VM object after latch validation. Author values cannot select an +outer arm. Malformed transport is fixed sandbox failure, not success or secret proof. + +Bound the full transport's UTF-8 bytes by the evaluation's active memoryBytes, +with cheap length precheck before the bounded byte count; default resource limits +remain those above. No independent unbounded host parse/dump is permitted. An +oversized transport is a fixed non-retryable resource failure, not truncated data. + +Preserve actual evaluate/validateResult semantics, separately from eventual JSON +persistence. Installed baseline controls confirm: + +| Result | condition | transform / merge_fn | +|---|---|---| +| Boolean/string/finite number | Preserve exact value | Preserve exact value | +| Top-level NaN, ±Infinity, -0 | Preserve numeric value/sign with private nan/positive_infinity/negative_infinity/negative_zero tags | Same; later JSON persistence keeps its existing normalization | +| Object/array/null, including Map/Set | result_type failure | Existing JSON object semantics; Map/Set becomes {}; cycles fail | +| Boxed primitive or object whose toJSON becomes non-null scalar | result_type failure | Existing non_serializable failure; do not silently accept JSON-normalized scalar | +| Undefined/function/symbol/bigint | result_type failure | non_serializable failure | + +For object results, preserve nested omission/null/boxed behavior and the existing +null-result exception; successful JSON serialization alone does not override the +original VM type checks. Pin stateful toJSON call count and no second author +serialization, numeric tags and every result arm in real installed QuickJS tests. +Changes needed beyond this parity require an explicit decision, not an accidental +transport side effect. The [runner](agent-runner.md#proposed-wave-3-qualified-context-consumers) owns producer migration; [tool provenance](tool-registry.md#proposed-wave-3-http-authority-and-secret-provenance) -records the separate unsupported mapping boundary. Actual engine/store tests cover +records unsupported mapping separately. Actual engine/SQLite controls scan run_events.payload_json, step_executions.output_json and runs.output_json with -positive completed/persisted controls, not only failure-path absence assertions. +successful clean persisted positives. Previously stored outputs/detail can contain +sensitive data and may be replayed; this change neither scrubs history nor certifies +legacy run.outputs secret-free. Update current at-rest/recovery guidance and three +stale scope/error comments during landing, without rewriting Accepted ADR history. diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index 27d881b3..0e8b789c 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -513,6 +513,23 @@ connection, redirect/time/size floors still apply. Chat's global policy is defin only in [config-spec.md](../contracts/config-spec.md#proposed-wave-3-chat-policy-and-preferences); workflow policy and independent command/MCP/provider egress are not expanded here. +Chat HTTP authority uses one invocation-owned live narrowing view. Extend the +existing beforeDispatch callback additively to receive tool ID and the existing +PolicyTarget. Immediately after admitArgs, copy/freeze its known command/url/path +fields once, before any await, and use that same target for approval/policy and +post-prepare dispatch. The CLI session-host wrapper checks HTTP against current +non-suspended authority at this post-approval/post-effect-prepare cut, then invokes +the existing journal callback. Refusal discards any prepared effect under the +existing protocol. A static host allowlist remains an earlier ceiling, never a +replacement for this current check. Do not decorate shared fetch/search/MCP or +invent a policyTarget function in model arguments. Existing callbacks ignoring +new parameters remain compatible; migrate every invocation/forwarding site. + +Live authority changes only at explicit rebuild/refresh, not file watching. Old +and pending hosts share it, so failed replacement cannot restore a grant. A +request admitted before narrowing may finish. Workflow domains remain separately +owned and are not switched to global chat policy. + At implementation landing correct both the dispatch-context secret-argument comment and Guardrail enforcement's “1.O/1.V producer” claim. No live authored tool-input mapping producer exists; agent/session config is empty. The optional diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index f66e65d0..25f9451e 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1106,18 +1106,16 @@ an ESLint selector rather than a list. ### Wave 3 — Clear the ground -**Source-corrected proposal reviewed; maintainer approval pending — 2026-10-15.** -[Revised readiness and approval scope](phases/phase-2.5.5-hardening-and-remediation.md#revised-proposal-readiness-after-source-corrections--2026-10-15) -records two NEW independent complete cumulative clean reviews at 49ba03ee across -all fourteen proposal paths/four ADRs and the nineteen-increment plan. The -[reopened source dispositions](phases/phase-2.5.5-hardening-and-remediation.md#maintainer-review-reopened-the-proposal--2026-10-15) -correct G15, process/lazy dependencies, exact-byte archive/cache/retry modes, -HTTP revocation and secret-context consumers. Earlier nonclean/insufficient -proposal rounds remain historical and are not acceptance. ADR-0110–0113 and -their canonical proposal sections remain unshipped/Proposed. No product code -has started; explicit approval is the current gate. Archive method is selected; -production recipient/recovery setup belongs to actual Wave 4b release. -No provider capture, key access, billing or upload is required for this review. +**Proposal reopened by maintainer rereview — 2026-10-16.** +[Current corrections and limits](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-and-proposal-corrections--2026-10-16) +supersede the a8708a65/49ba03ee readiness claim. Source audits confirm missing +session-health authority, MCP PID/group distinction, release discovery/finalization +permissions, Home build-first revocation and finite VM denial. Revised ADR-0110–0113 +and their explicitly unshipped canonical contracts remain **Proposed**. New complete +cumulative proposal reviews and maintainer approval are required; no product code +has started. The nineteen-increment plan now assigns these mechanisms explicitly. +Archive method is already selected; production recipient/recovery belongs to +actual Wave 4b release. No provider capture, key access, billing or upload is needed. Decompose, guard and normalize every hot file a 2.6 workstream is about to land on. **The multi-file queue runs first and is serialized against the rest**; the remaining queues are file-disjoint, any order. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 0ff6f499..fbb895a6 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -3632,10 +3632,10 @@ changes a currently Accepted ADR or a canonical reference before approval. | Decision | Recommended choice and compatibility boundary | |---|---| -| ADR-0110: D19/D20/D22, #13/#19/#309, G15/G35 | [Explicit CLI compatibility](../../decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md): retain quiet+verbose conflict; exact DEBUG=1 alias; preserve JSON/safety data; internal exit 9; versioned explicit JSON help; alias-preserving pricing/chat syntax; key-delete consent; G15 refuted with durable-only replay preservation. Genuine run outcomes and exits 3–8/signals keep their contracts. D22's old two-exception shorthand must be reconciled to this explicit enumerated scope. | +| ADR-0110: D19/D20/D22, #13/#19/#309, G15/G35 | [Explicit CLI compatibility](../../decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md): retain quiet+verbose conflict; exact DEBUG=1 alias; preserve JSON/safety data; unexpected/output infrastructure exit 9 with distinct diagnostics; versioned explicit JSON help; alias-preserving pricing/chat syntax; key-delete consent; G15 refuted with durable-only replay preservation. Genuine run outcomes and exits 3–8/signals keep their contracts. D22's old two-exception shorthand must be reconciled to this explicit enumerated scope. | | ADR-0111: #43/#44/#46/#271 | [Lazy packaged runtime](../../decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md): lightweight descriptor-only meta graph; engine-inlined finite chunks, complete closure guard and packed platform controls; exact-byte private sourcemaps. The maintainer selected public-recipient encrypted CI archival; GnuPG, pinned recipient and per-version ciphertext retention are proposed mechanics. Public plaintext artifacts are prohibited. | -| ADR-0112: G30/#34/#49/#52/#55 | [Operator-owned chat policy and preferences](../../decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md): global-only domain authority, exact DNS/deny-all/approval/SSRF floors; explicit unavailable-keychain acknowledgement with current-state recheck; truthful model-write recovery; non-retryable final-component link refusal and honest recovery; monotonically narrowing global HTTP grants. | -| ADR-0113: #92 and reproduced leak | [Declared-source context provenance](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md): reuse existing linear taint graph, preserve trusted raw resolution, qualify context before executor/preparation and fail loudly on denied VM/template reads; required owned projection. Record unsupported future tool mappings honestly rather than fabricate a producer. | +| ADR-0112: G30/#34/#49/#52/#55 | [Operator-owned chat policy and preferences](../../decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md): global-only domain authority, exact DNS/deny-all/approval/SSRF floors; explicit unavailable-keychain acknowledgement with current-state recheck; truthful model-write recovery; non-retryable final-component link refusal and honest recovery; live monotonically narrowing invocation HTTP authority even after failed Home replacement. | +| ADR-0113: #92 and reproduced leak | [Declared-source context provenance](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md): reuse existing linear taint graph, preserve trusted raw resolution, qualify context/input provenance before executor/preparation; remove raw denied values, refuse templates and diagnose executed VM denied access with an explicitly finite latch; required owned projection. Record unsupported future tool mappings honestly rather than fabricate a producer. | | D18, D26/#125, #311 | Keep chat-ink restructuring with Wave 5a; keep cost chat-only with both real surfaces tested; correct Commander parser-path token echo and add static help; preserve dispatcher privacy. These are explicit dispositions, not new features or undisclosed deferrals. | | #89/#90/G18/#210/G17 | Apply missing duplicate/cross-field guards at leaf and full boundaries. Condition equality is typed (`true` differs from `'true'`; 0 equals -0). Duplicate fallback tuples are provider+model within the fallback list; do not forbid equality with the primary. Timeout without action retains default reject; action without timeout refuses. | | G17 RunSchema detail | Preserve optional legacy timestamps and outputs. Reject completedAt on nonterminal states, startedAt on pending, and error outside failed; retain temporal checks. Terminal records need not invent timestamps/error fields. Do not adopt a stricter logical schema as a DB reader or migrate historical rows. Existing tests accepting contradictory present fields must change deliberately. | @@ -3656,22 +3656,22 @@ No credential values or request captures belong in tracked evidence. |---|---| | 1 | **Serialized multi-file queue first:** shared close handling #22 (four named siblings plus newer owned journal sites) → safe source labels #4/G11 plus #3's UTF-8 source admission parity → exhaustive session/picker/run switches #300/#301/G3 and actual reasoning rendering. Preserve primary outcomes and host-safe-close ownership. For #3, use the sibling 4× byte precheck plus authoritative parser character bound; test a dense multibyte source above the old byte cap but below the character cap, ASCII character overflow and true byte overflow. Add native error and mounted output regressions. | | 2 | **Shared admission and diagnostics:** #89/#90/#212/G18/#210/G17, certify #211/G16; shared parser reason helper #85/#86 and config numeric bounds #32. Approved status/duplicate matrices, direct leaf/full parse tests and no value-bearing diagnostics. | -| 3 | **Secret provenance:** ADR-0113/#92, fresh/resume/preparation/condition/transform/merge paths, engine-owned projection and actual VM/template consumers, fail-loud denial, real SQLite columns and positive completed/persisted controls. Dedicated security sitting, no future mapping implementation. | +| 3 | **Secret provenance:** ADR-0113/#92, fresh/resume/preparation/condition/transform/merge paths, engine-owned context/input provenance and actual VM/template consumers; direct secret-input marker compatibility, finite denial latch and single serialized payload with exact result parity. Real SQLite columns, clean positives and historical-output limitation. Dedicated security sitting, no future mapping implementation. | | 4 | **Error/envelope substrate:** #213/#219, actual migrated-class inventory and cross-bundle structural guards. Preserve exported names/discriminants, constructor/cause behaviour and event payloads. Convert only supported error classes, not vendor errors or plain seam unions. | | 5 | **Tools and engine tails:** #93/#95/#96/#98/#97/#214/#221/#258/#259/#308, #94/#82/#87/#76/G9/#79/#274/#275. Bounds before effects, model-visible semantics, existing ordinal helper, path-local cycle guards, cancellation race and deterministic plan equivalence; certify #75/#78/#216 instead of rewriting them. | -| 6 | **Pure process/discovery substrate:** ADR-0110 categories, shared early flag grammar, explicit exit/site inventory, enriched descriptor registry with legacy/model and new CLI projections, lightweight local errors and invocation owner registry. Contracts precede consumers. Unit/source checks do not claim fatal/native or JSON-help integration complete. | -| 7 | **Lazy runtime and package graph:** #43/#44/#271/ADR-0111, consuming Step 6. Guard dynamic imports from a lightweight entry; defer classifier/sanitizer/service barrels and MCP wiring; preserve filesystem → fallback cache → DB catalog → model/cost ordering and media exception. Finite flat chunks, all-output closure, real packed existing meta/run/chat/migration/identity and minimum-Node/platform controls. New JSON help waits for Step 10. | -| 8 | **Native process integration:** #40/#41/#42/#224/#269/G35 under ADR-0110, after the early graph can catch import failures. Wire registered owners, classified EPIPE, generic exit 9 and throw-path final-health precedence. Preserve known history/departure refusals, recoverable #228 and signals; native child/grandchild/Windows, both sinks and externally watched fatal-budget controls. No false host-safe-close ACK. | -| 9 | **Release map/custody substrate:** #46/ADR-0111, independently reviewed after the finite graph. Upstream TS maps, sole prepack final build, pre-hash trailer removal/staging, exact tarball/CLI+engine symbolication, encrypt-once candidate and retry/partial-upload/conflict controls, archive-before-npm DAG and required artifact/platform gates. Dedicated archive/crypto security sitting. Fixture proof never claims production recipient/custody configuration. | +| 6 | **Pure process/discovery substrate:** ADR-0110 categories, shared early flag grammar, explicit exit/site inventory, enriched descriptor registry with legacy/model and new CLI projections, one closed CLI code/origin authority, storage-boundary classification inventory and kind-specific invocation owner registry. Include caught/forced-exit owners and total observation priority. Contracts precede consumers. Unit/source checks do not claim fatal/native or JSON-help integration complete. | +| 7 | **Lazy runtime and package graph:** #43/#44/#271/ADR-0111, consuming Step 6. Guard dynamic imports from a lightweight entry; defer classifier/sanitizer/service barrels and MCP wiring; preserve filesystem → one seedFileCatalogOnce owner → DB catalog → model/cost ordering and media exception; explicitly test meta-only admission refinement. Finite flat chunks, all-output closure, real packed existing meta/run/chat/migration/identity and minimum-Node/platform controls. New JSON help waits for Step 10. | +| 8 | **Native process integration:** #40/#41/#42/#224/#269/G35 under ADR-0110, after the early graph can catch import failures. Wire kind-specific owners, output_unavailable/cli_internal 9, known storage/history/departure classification, run-only health precedence and session join uncertainty. Preserve recoverable #228/signals; MCP PID versus tool PGID/Windows, terminal restore before stderr, total remaining fatal budget, competing faults and both sinks. Minimal I/O adapters land here; central DB refactor stays last. No false host-safe-close ACK. | +| 9 | **Release map/custody substrate:** #46/ADR-0111, independently reviewed after the finite graph. Upstream TS maps, sole prepack final build, pre-hash trailer removal/staging, exact tarball/CLI+engine symbolication, encrypt-once candidate with permissioned prebuild discovery/reservation → pack/reuse → smoke → archive → npm → finalization. Required artifact/platform aggregation, all rerun modes, lost-staging refusal and permission-enforcing offline API controls. Dedicated archive/crypto security sitting. Fixture proof never claims production recipient/custody configuration. | | 10 | **CLI behaviour/discovery integration:** #45/#264/#265/#268/#13/#17/#18/#310/#309/#19 under ADR-0110, consuming Steps 6–9. Production owner category matrix, real root/subtree JSON help, pricing/chat aliases, non-echoing Commander refusal and signal-safe deletion consent. G15 is refuted and receives real engine/store/log preservation evidence, with no invented token coalescing. Repeat affected packed gates. | | 11 | **Chat normalization and abort:** #236/#237/#242/#307 → #218 → #21/#23. Canonical token/money source with approved additive LLM export, preserved two-decimal cap formatting and real immediate abort while a turn is blocked. Audit Home's imports. | | 12 | **Chat decomposition:** #220/#244 after preceding chat fixes. Split lifecycle/drivers, line handler, printers and reseat/clear ownership. Preserve compatibility entry points, W7/W8 receipts, unknown-usage, full-screen bounds, disclosure activation and cancellation. | -| 13 | **Chat domain policy:** G30/ADR-0112 after decomposition, all fresh/resume/Home/agent/rebuild paths. Real host-policy dispatch and no-authority/SSRF/approval controls; separate egress/config-trust security sitting. | +| 13 | **Chat domain policy:** G30/ADR-0112 after decomposition, all fresh/resume/Home/agent/rebuild paths. Invocation-live policy owner shared by old/pending hosts, post-approval/post-prepare target guard, pinned global selection, suspension/recovery and latest-only build publication. Actual failed/overlapping Home rebuild and approval-race controls, alongside no-authority/SSRF/ports; separate egress/config-trust security sitting. | | 14 | **Home controller:** certify G37, fix #281, record/test #125/D26 before #215/#243 decomposition; then #217/#222 scoped naming. Overlay discriminated union, subcontrollers, stale async work/focus/approval invariants and both live cost surfaces. | | 15 | **Viewport/input:** #58/#62/G40/G51 → #65 → #64/#66, then #63/#69/G4/G55. Grapheme-correct editing/truncation, tabs, bounded caches, safe resizing, TERM=dumb and both small-terminal surfaces, input history and mouse bounds. CJK measurement and real emitted rows. | -| 16 | **Onboarding and overlays:** #49/#52/#51/#53/#55/G5/G52/G41/G53 and #34 under ADR-0112. Real next-model binding, recoverable persistence, explicit acknowledgement, current keyless nudge, hidden-approval safety, color-off mounts and native symlink preservation; keychain/config security sitting. | +| 16 | **Onboarding and overlays:** #49/#52/#51/#53/#55/G5/G52/G41/G53 and #34 under ADR-0112. Real next-model/ephemeral invocation binding across clear/reseat, recoverable persistence, explicit acknowledgement/current keyless nudge, shared pre-read writer symlink refusal, surfaced typed recovery and deterministic existing-file comment-loss notices. Hidden-approval safety/color-off; keychain/config security sitting. | | 17 | **Remaining CLI/hygiene:** #24/#26/G43/G2/#12/#54/G14/#11/#47/#48/#240/#241/#311/G54/#303/#304; certify #20/G36/G46. One-shot coverage-aware cost/banner, actual-host doctor, typed faults, no silent missing-dist skip, guard misuse, attachment estimate and compiler checks. #239 remains the approved documented compatibility surface. | -| 18 | **openLocalDb LAST:** #278/#280 across every moved caller. Central fixed safe error wrapping, attempt-all secondary cleanup, preserved causes, privacy/checkpoint notices and exact writer lifetimes. Actual command-family failure probes; no duplicate wrapper left behind. | +| 18 | **openLocalDb LAST:** #278/#280 across every moved caller. Centralize the already-integrated classification/wrapping with fixed safe errors, attempt-all secondary cleanup, preserved causes, privacy/checkpoint notices and exact writer lifetimes. Actual command-family failure probes; no duplicate wrapper left behind. | | 19 | **Whole-wave acceptance and publication:** every item disposition; A/E/G and M2.5.5-3 evidence; ordinary/enforced coverage and required CI/build/fences/smokes/causal/platform checks at attributed source; measure and promote core's ≥90% line/branch floor to required CI as testing.md requires after Wave 3 coverage work, fixing meaningful missing coverage before closure. Two new whole-wave cumulative reviews, canonical doc/ADR landings and PR. I/whole phases/later product waves stay open. | Steps are dependency-safe increments, not permission to parallel-edit shared hot @@ -3849,6 +3849,10 @@ cumulative clean proposal rounds at its corrected head before approval is reques #### Revised proposal readiness after source corrections — 2026-10-15 +> **Historical readiness superseded — 2026-10-16.** The maintainer rereview below +> demonstrates source/contract gaps present at this checkpoint. Its reported clean +> rounds are retained as history, not current readiness or implementation approval. + Two NEW independent COMPLETE cumulative reviews are clean at exact reviewed source 49ba03eecf9856e3d6f0bda6528f4a48d94a34e8: round 3 (gpt-6.1-sol, high) and round 4 (gpt-6-sol, high). Each covers all fourteen proposal paths, all four ADRs, the @@ -3884,3 +3888,89 @@ notes before dependent code, then start serialized Step 1 and its required revie loop. Production recipient/recovery configuration remains an actual Wave 4b release prerequisite; no production key, provider capture, billing or upload is needed for the current approval. Detailed reports remain local and Git-ignored. + +#### Second maintainer rereview and proposal corrections — 2026-10-16 + +**Current status: revised Proposed decisions; source preflight reopened, no product +implementation.** The two submitted rereviews show that the a8708a65/49ba03ee clean +proposal records did not establish adequate source coverage. Those readiness claims +are superseded, not counted toward the corrected proposal. No assertion that all +old partial findings became implemented fixes is made. The four decisions and +nineteen-step plan still require new complete cumulative reviews and maintainer +approval before code. + +Three scoped source intakes (CLI, packaging, chat/context) and parent verification +support the dispositions below. They are not full clean proposal rounds. The +chat/context agent supplied source observations and retained synthetic evidence, +then its turn was stopped by automatic cybersecurity review; it produced no final +clean review. It is not counted as completed or retried to evade that restriction. +The parent can continue normal documentation work using inspected source/evidence. +No release/API mutation, paid call, key access, provider capture or product edit +occurred. The chosen encrypted archive method is retained. + +| Submitted concern | Revised proposal or verified disposition; implementation owner | +|---|---| +| A: EPIPE 1 versus workflow failure | Classified output infrastructure now uses output_unavailable/9; unexpected uses cli_internal/9. Known operational 1 remains explicitly scoped. Commands owns the table/remedy; Steps 6/8/10. | +| Chat/Home/agent fictitious joined 5–8 | Run-only RunHandle health; session cancel/join/publication failure, no new session-health DTO. Failed chat turns keep REPL live, unknown caught faults reach coordinator. Steps 6/8/11/12/14. | +| POSIX MCP group / root-exit semantics | Creation-specific tool PGID versus SDK MCP PID, narrow actual lifetime adapter and explicit PID/PGID uncertainty. Windows verified taskkill only for still-owned live root. Steps 6/8; no SDK transport rewrite or universal containment claim. | +| Fatal race, budget, stderr and catches | Sticky total observation order, one cleanup/deadline including taskkill/restore/flush, terminal restore before stderr, never diagnostic stdout. Existing total budget text was already present; synchronous-after-expiry ambiguity is corrected. Inventory slash/rebuild/one-shot/forced-exit catches and unknown origin. Steps 6/8. | +| DB failure classification before final refactor | Native typed DbOpenError/owned errno operational mapping and minimal adapters precede native integration; invalid schema remains 2, import/ABI fault 9. Existing wrappers' runtime-IO 2→1 change is explicit. Step 18 only centralizes the already-required contract. | +| Pricing binding and own remedies | Legacy model/executor input/output bindings remain; canonical shell spellings track source presence before conflicts. Update every generated hint/example so it does not recommend deprecated pricing/chat aliases. Step 10. | +| Release retry permissions / final writer | Concrete admission→draft-visible Actions-read discovery/reservation→fresh pack or reuse→smoke→archive→npm→finalize graph; only custody jobs get Contents write, top-level deny default, read-only publish consumes verified tarball/receipt. All reruns discover before build. Step 9. | +| Lost candidate / randomized encryption | Reserve original version/source/attempt before first build; incomplete/lost candidate requires recovery, never new encryption/replacement. Retain all three assets beyond Actions expiry. Permission-enforcing fake service tests actual state controller and call counts; no live tag needed. Step 9. | +| Maps / finalizer / chunks / public floor | Installed compiler overrides and concrete dependency order, prepack map enable, missing-upstream refusal and repository-relative finalization, entry-only hashbang/modes, exact-tarball required aggregate on supported OSes and Node 22.0.0. Steps 7/9 and final repeat. | +| Help admission / catalog | Missing directory no longer blocks meta once pure grammar passes: explicit ADR-0105 refinement. One invocation seed owner and eleven current DB-overlay sites, including quote/fixture/media exceptions. Step 7. | +| Public-source encryption proportionality / settings | Preserve selected distribution/custody policy, acknowledge availability cost and revisit only with maintainer policy change. Current repository defaults/immutability are not protection; explicit workflow permissions/concurrency/no-overwrite are required. Step 9; production setup remains Wave 4b. | +| Home build-first / failed and overlapping rebuilds | One live narrowing owner shared by all hosts; post-approval/post-prepare check uses owned PolicyTarget. A validated removal survives failed replacement; stale build cannot restore policy/selection. Step 13 before later Home decomposition acceptance. | +| Policy source / errors / widening / IP aliases | Pin lexical global selection/home once, initial missing versus transient error distinctions, suspend rather than falsely revoke on read fault, no startup fallback, fixed ignored-widening notice. ASCII validation before fold and alternate IP rejection; workflow policy stays separate. Step 13. | +| Ephemeral selection / UX acknowledgement / shared writer | Invocation binding survives clear/reseat and latest-only publication; current key state outranks portable UX preference. Shared lstat refusal before read/chmod for all writers, actionable Home/wizard diagnostics, existing-file comment-loss notice, old-binary rollback cannot promise new meta behavior. Steps 13/16. | +| B: direct secret marker asymmetry | Deliberately replace direct expression-input markers with the same denied-value contract as ctx; public templates also refuse direct secret inputs even for hand-constructed workflows. Display/input-node markers remain. Step 3, requiring approval of this explicit compatibility change. | +| C/F: accessor rationale / error identity | ADR-0093 before-money ordering stays; runtime privacy excludes raw values, latch diagnoses executed denied reads. Existing sandbox_error gains fixed secret_context reason, not a new engine ErrorCode. Step 3. | +| Latch resource bypass / serialization | Document caught pre-accessor stack failure as finite semantic limit, not confidentiality failure. One authored payload serialization before latch check; outer primitive-only envelope cannot run author serialization again. Actual numeric/boxed/object parity and bounded transport are required. Step 3. | +| Mutable secret Set / actual producer inventory | One owned mutation-incapable membership facade plus immutable array; fresh, budget preparation and cross-process resume plus all low-level scope/test producers migrate. Correct stale scope/error comments and private-detail claims. Step 3. | +| Old durable outputs / tool provenance | No automatic historical scrubbing or claim legacy run.outputs is secret-free. Land at-rest/recovery guidance. Optional external tool provenance remains explicitly unverified; nonexistent mapping producer not fabricated. Actual repaired exposure and #92 limitation stay distinct. Step 3. | +| D/E/G: metadata / partial index / Proposed references | Standard Status/Date/Related only. At approval add clause-specific partial Status/index/forward notes; promote each exact reference only at implementing increment acceptance, not ADR acceptance. Explicit landing checklist below. | +| Claimed broken Wave 2 anchors | Refuted: explicit HTML alias wave-2-individual-dispositions--certification-in-progress exists and preserves both links. Do not manufacture a repair/deferral. | + +**Evidence scope.** Source remains product-identical to 702cdc9c. Installed synthetic +TypeScript/tsup/esbuild controls verify map chaining, silent missing-upstream fallback +and entry/chunk mode behavior; these are not actual packed product/release controls. +Existing actual-engine intake confirms direct-input marker comparison completes +false and hand-built human-template input can expose the synthetic canary. Installed +QuickJS emulation reproduces caught pre-accessor stack failure in eight of eight +intake cases without raw values in the VM. Parent additionally executes 36 actual +baseline sandbox result cases across condition/transform/merge: top-level NaN, +±Infinity and -0 stay numbers, boxed primitive is rejected, Map/Set is {}, and +cycle/nonserializable results refuse. Those observations constrain the proposal; +no new implementation or final VM mechanism is certified. No full CI/coverage, +Windows, actual product tarball, GPG/production custody or published API validation +is inferred. Working reports/probes remain local and ignored. + +**Canonical landing checklist (required, not a future unowned debt):** + +1. At explicit approval: change only new ADR statuses/index, append old clause-specific + notes and partial Accepted/index annotations for ADR-0047/0049/0051. Append the + listed refinements, including ADR-0105 meta admission and ADR-0027/0083 direct-input + compatibility/ADR-0093 rationale. Reconcile D19/D22, E no-new-ADR statement and + phase criterion 4 before dependent code. Keep historical bodies intact. +2. Step 3: merge actual secret-boundary/producer behavior into expression-sandbox, + agent-runner, workflow/tool contracts; replace both contradictory tool provenance + passages and stale source comments. Update at-rest/recovery guidance. Preserve + unimplemented remainder as explicitly Proposed. +3. Steps 6–10: promote implemented commands, groups/flags/pricing remedies, errors/ + exits and packaging sections; reconcile phase CLI acceptance plus chat-session.md, + home.md and current command text. Step 9 updates release-a-surface.md for exact + graph/map/modes/recovery. No shipped claim for a merely approved mechanism. +4. Steps 13/16: promote exact config/tool/chat/Home policy, selection and writer + sections as their code is accepted, including rollback and surfaced remedies. +5. Step 19: inventory all Proposed sections in commands.md, packaging.md, + config-spec.md, tool-registry.md, expression-sandbox-spec.md and agent-runner.md. + Merge/remove every completed proposal section/banner and reconcile overlapping + old current text; any genuine unimplemented scope stays visibly open. Repeat + link/anchor, append-only and exact-head source/evidence checks. No blanket + “all references describe shipping code” claim before this inventory passes. + +These edits need fresh complete cumulative source-based proposal reviews. No +implementation is authorized by prior clean reports or source feasibility probes. +The maintainer must approve the revised four ADRs/contracts/plan before Step 1; +archive method selection is already complete, and no production key is requested. From 741d0db4317cf8b782ee5511d8e77e916f67c785 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 01:00:19 +0300 Subject: [PATCH 011/222] docs(docs): preserve wave 3 retry and enumeration invariants Resolve the three verified Medium findings from the complete proposal rereview. Bind fresh release builds to same-attempt discovery, attribute map checks to the original candidate, and preserve complete context/input key ordering while excluding denied values. Keep operational recovery advice and readonly membership back-references explicit. All four ADRs remain Proposed; no product implementation changes. Refs: ADR-0110, ADR-0111, ADR-0113 Co-Authored-By: Codex --- ...-operations-use-a-lazy-packaged-runtime.md | 4 ++- ...ontext-keeps-provenance-at-public-sinks.md | 19 ++++++----- docs/reference/cli/commands.md | 5 ++- docs/reference/cli/packaging.md | 34 +++++++++++++++---- .../shared-core/expression-sandbox-spec.md | 21 ++++++++---- .../phase-2.5.5-hardening-and-remediation.md | 24 +++++++++++++ 6 files changed, 84 insertions(+), 23 deletions(-) diff --git a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md index 8769a50d..c0792df4 100644 --- a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md +++ b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md @@ -106,7 +106,9 @@ job before handoff; missing custody cannot downgrade to test mode. Use one explicit trusted-tag custody graph: admission → permissioned candidate discovery/reservation → fresh pack or exact reuse → smoke → archive → npm publish → Release finalization. Discovery precedes **every** product build, including an -all-jobs rerun. It can see drafts and originating run/attempt artifacts; permission, +all-jobs rerun. A native failed-jobs rerun cannot reuse stale fresh discovery +output to build: same-attempt admission refuses it. Reuse/downstream retries verify +the complete original candidate, not new map checks. It can see drafts and originating run/attempt artifacts; permission, pagination or missing-history uncertainty is not “fresh”. Reserve a draft candidate identity before its first build; interrupted/lost staging cannot become permission to create different same-version bytes. Recover original tarball/ciphertext/receipt, diff --git a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md index 1775c763..b1ac30f1 100644 --- a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md +++ b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md @@ -25,7 +25,8 @@ a secret-free trust domain. The earlier proposal to inject markers would silently turn a secret comparison into a false branch or let a marker satisfy an output schema. Its additional -preparation field had no VM consumer. Both mechanisms are replaced below, and the same silent comparison exists for direct secret-input markers. The revised +preparation field had no VM consumer. Both mechanisms are replaced below; the +same silent comparison exists for direct secret-input markers. The revised proposal applies denied-value access uniformly to both expression namespaces; display/input-node masking remains unchanged. @@ -49,7 +50,9 @@ live Sets or separately derived lists. Keep the exported trusted resolver's raw result contract. Immediately qualify its result inside the engine, before storing or handing context to execution or budget preparation. The one owned projection contains only clean context strings -and denied-key metadata, never raw tainted strings. Replace the low-level context +and denied-key metadata plus the original full key order, never raw tainted +strings. Preserve existing context/input key enumeration rather than introducing +a lexical reorder or changing absent-input presence. Replace the low-level context field with this required projection and migrate every actual producer/double. Missing projection refuses with a fixed typed validation failure; there is no implicit clean default. Trusted hosts remain responsible for honest admitted @@ -59,8 +62,8 @@ Execution and preparation consume the **same** projection. Preparation uses a public-template resolver which rejects denied references before filters/defaults; this is a real template consumer, not a fictitious preparation sandbox. It also refuses direct secret-input references using that owned membership view, including -hand-constructed workflows that bypass parser checks. Clean -templates work unchanged. Fresh, same-process resume and cross-process budget +hand-constructed workflows that bypass parser checks. Clean templates work +unchanged. Fresh, same-process resume and cross-process budget preparation cannot reconstruct raw unqualified context. Trusted raw resolution stays local to the resolver/projection cut rather than a second executor authority. @@ -91,7 +94,7 @@ the VM. Clean reads and key-only enumeration remain available. **Finite semantic guarantee:** resource failure before the accessor body can be caught without setting the latch; the installed QuickJS intake reproduces this. -Do not promise detection of every attempted access or universal fail-loud behavior. +Do not promise detection of every attempted access or universal fail-loud behaviour. Raw-value exclusion still protects confidentiality in that case. An uncaught resource failure keeps its existing classification. Latch purpose is preventing silent false branching after an actually observed denied read, not supplying the @@ -160,7 +163,8 @@ benefit, without weakening raw-value exclusion. For executed accessors assert typed denial and no branch/output-schema success; separately pin caught pre-accessor resource failure without claiming a denial. Verify no raw newly projected value in VM input/error detail. Pin single - result serialization, stateful toJSON and exact current primitive/result parity. Independently successful clean workflows must + result serialization, stateful toJSON, exact current primitive/result parity and + context/input key order/presence. Independently successful clean workflows must complete with exact expected outputs; no vacuous negative-only test. 2. Actual SQLite hosts scan `run_events.payload_json`, `step_executions.output_json` and `runs.output_json`, plus public events and @@ -168,8 +172,7 @@ benefit, without weakening raw-value exclusion. leak fixtures prove the baseline fails the confidentiality assertion. 3. Every fresh/resume/preparation producer supplies the owned projection. Missing metadata and mutation controls (including attempted live-Set clear) - exercise actual VM and public-template - consumers. Removing engine qualification, template refusal or VM latch breaks + exercise actual VM and public-template consumers. Removing engine qualification, template refusal or VM latch breaks its corresponding regression with exact restoration. 4. Direct/transitive parser model/human refusal, trusted raw resolver, display/ input-node markers and external tool guard remain preservation controls. New diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 88a27cb9..4b537b83 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -714,7 +714,10 @@ paths. Known operational refusal stays inline where supported; unexpected faults must reach the invocation coordinator even if a balanced internal turn event was emitted first. That engine event alone cannot convert an escaping CLI fault to an ordinary turn outcome. Programmer-invalid DB URI is not a normal CLI -storage-open failure; preserve its API validation boundary. +storage-open failure; preserve its API validation boundary. Operational 1 after +admitted work (including session publication/join failure) does not prove absence +of paid/effectful work: retain inspect-before-repeat advice before correcting the +storage issue. No healthy session receipt is inferred from its numeric exit. Classify native storage errno only at the actual owned I/O boundary, not by examining arbitrary provider/plugin objects or private cause trees. Recognize diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index 1ea022e7..87bbefc5 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -97,8 +97,8 @@ No host absolute paths, credentials or embedded source are stored. The **public binding receipt** is versioned JSON beside ciphertext: source SHA, package version, tarball/ciphertext SHA-256, recipient primary and encryption-subkey -fingerprints, toolchain/config/lock identity, and originating trusted workflow/run/ -attempt identity. It contains neither maps nor private per-source inventory. +fingerprints, toolchain/config/lock identity, nonsecret originating map-check completion status, +and originating trusted workflow/run/attempt identity. It contains neither maps nor private per-source inventory. Use SHA-256 for file identity, not a homemade signature/provenance protocol. Check originating identity against the trusted CI service/run and real tag/source; a self-declared JSON field alone does not authenticate its producer. @@ -152,8 +152,12 @@ below. Only a verified `fresh` route enters build/pack/encryption with the confi production recipient in the isolated originating job **before any handoff**. `reuse` downloads exact prior bytes and invokes no build, pack, finalizer or GPG. Required custody failure never downgrades to package-test mode or fixture keys. -Both modes run the same tarball smoke and local map checks. Self-declared mode or -receipt strings do not confer release authority. +Fresh originating builds in both modes run local map/symbolication checks before +sealing their receipt. Release reuse has no plaintext maps or decryption key: it +validates the original trusted successful pack identity, map-check completion +evidence and exact candidate digests, and reruns tarball smoke. It does not claim +fresh symbolication. Missing original map-check evidence refuses; self-declared +receipt fields alone do not prove it. ## Archive, retry and publication @@ -218,9 +222,25 @@ candidate recoverable; failed finalization retries without publication/build/GPG | npm publication failed or finalizer failed | Resume the applicable downstream jobs using the same complete candidate | | Incomplete reservation, missing asset/ciphertext, expired/inaccessible staging, conflicting bytes or ambiguous origin | Fail with manual recovery; never manufacture a replacement candidate | -Support failed-jobs, all-jobs and cross-run retries through that same discovery -route **before any build step**. A missing source artifact cannot be compensated by -re-encryption. Retain tarball/ciphertext/receipt for every supported release beyond +Every fresh build requires successful permissioned discovery/reservation in the +**same current run attempt**. A native failed-jobs rerun skips successful upstream +jobs: therefore pack must compare the discovery attempt with its current attempt +before any fresh build. Stale fresh output refuses without building, packing or +encrypting, with rerun-all/manual-recovery instructions. Do not assume GitHub +automatically reruns discovery or give pack Release-write authority. + +| Retry mode | Supported route | +|---|---| +| Failed jobs downstream of a successful pack | Revalidate original complete candidate/binding and resume smoke/archive/publish/finalize; no fresh build or local map-check claim | +| Failed pack with a prior reuse route | Retrieve/verify the same original candidate; no build/GPG; missing staging refuses | +| Failed pack with stale fresh output | Refuse before build; rerun-all reaches discovery, and incomplete reservation still requires manual recovery | +| All jobs or new trusted release run | Re-execute permissioned discovery before pack; reuse complete original bytes or refuse lost/incomplete reservation | + +Same-run transfer may use Actions' existing artifact runtime capability; cross-run +retrieval remains in the Actions-read discovery owner. Archive/finalize recheck +remote state with their own custody permissions on every retry. No downstream job +can interpret stale fresh output as authority to rebuild. A missing source artifact +cannot be compensated by re-encryption. Retain tarball/ciphertext/receipt for every supported release beyond Actions expiry, including npm failures. No automatic asset deletion/replacement. Public Release is not private storage; only ciphertext restricts debugging payload. diff --git a/docs/reference/shared-core/expression-sandbox-spec.md b/docs/reference/shared-core/expression-sandbox-spec.md index 6e2a7f4e..9827e5f9 100644 --- a/docs/reference/shared-core/expression-sandbox-spec.md +++ b/docs/reference/shared-core/expression-sandbox-spec.md @@ -266,9 +266,13 @@ expression markers. Display/run-start/input-node markers remain unchanged. ### Owned provenance and consumers One WorkflowContextProjection owns values: Readonly> (clean -keys only) and deniedKeys: readonly string[]. Copy/freeze both, detach record -prototypes, reject duplicate/overlapping/malformed keys, and order clean/denied keys -by shared byCodeUnit. One pure factory qualifies resolved context using the existing +keys only), deniedKeys: readonly string[] and orderedKeys: readonly string[]. +Copy/freeze all three and detach record prototypes. orderedKeys is the original +Object.keys(resolved) order, not a new lexical sort. Validate unique well-formed +keys and that orderedKeys is exactly the disjoint union of clean and denied keys. +Reconstruct scope data/accessors in this full order, preserving existing context +declaration order and ECMAScript integer-key enumeration. Separate clean/denied +lists alone cannot recover their original interleaving. One pure factory qualifies resolved context using the existing linear taint graph. Required execution/preparation ctx becomes this projection; the exported trusted raw resolver contract stays unchanged. Missing/malformed projection uses existing validation ErrorCode with fixed content-free message, @@ -277,7 +281,8 @@ never an implicit clean default. Shape checks do not detect a dishonest embeddin Derive secret-input membership once from that same admitted workflow. Own its backing data; expose a ReadonlySet-compatible membership/iteration facade without Set mutation methods and an immutable ordered denied-name array from the same -factory. Copy caller-provided names at embedding entry: Object.freeze(new Set()) +factory. Facade callbacks/iterators must never expose the mutable backing Set +(including forEach’s third argument). Copy caller-provided names at embedding entry: Object.freeze(new Set()) or a live caller Set is insufficient. Every fresh/resume/preparation producer uses that same authority, rather than recomputing a mutable set at resume. @@ -289,8 +294,12 @@ that same authority, rather than recomputing a mutable set at resume. preparation use this same consumer; preparation has no VM-only metadata. - ExpressionScope requires the context projection and explicit immutable deniedInputNames; clean embeddings provide explicit empty metadata. Scope - construction omits raw secret inputs and supplies only clean values plus denied - names. No marker substitution in expression inputs. Trusted NodeExecContext.inputs + construction omits raw secret inputs and supplies clean values plus denied + names. Preserve actual input own-key order/presence: present denied keys use + private null placeholders which become accessors before author code; absent + declared-secret keys get non-enumerable denied accessors. Never create an + enumerable missing input or reorder unrelated clean keys. Placeholders are + transport data, never readable expression values or display markers. Trusted NodeExecContext.inputs remains raw; input-node/public display continues using maskSecretInputs. - QuickJS rebuilds frozen null-prototype scope records and enumerable denied accessors for both namespaces. Clean values and key-only enumeration remain diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index fbb895a6..8009e630 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -3974,3 +3974,27 @@ These edits need fresh complete cumulative source-based proposal reviews. No implementation is authorized by prior clean reports or source feasibility probes. The maintainer must approve the revised four ADRs/contracts/plan before Step 1; archive method selection is already complete, and no production key is requested. + +#### Second rereview proposal round 1 corrections — 2026-10-16 + +The NEW complete cumulative source review at 86ff88a8 covers all fourteen proposal +paths and verifies three Medium findings. They are corrected in the proposal: + +- Native failed-jobs rerun skips successful discovery. Fresh pack now requires a + same-current-attempt discovery/reservation; stale fresh output refuses before + build. Explicit retry matrix separates exact reuse/downstream retries from + rerun-all discovery and unrecoverable incomplete reservations. +- Reuse has encrypted maps only and cannot rerun plaintext map/symbolication + checks. Those checks belong to the fresh originating pack; retry verifies its + trusted successful identity/completion evidence and exact digests, then reruns + tarball smoke. No CI private key or fictitious fresh symbolication is added. +- Lexically sorting context changes clean expression results and loses clean/ + denied interleaving. Owned orderedKeys preserves actual resolved own-key order; + input presence/enumeration also stays intact. Mutation controls include facade + callback back-references, not only missing Set.clear on the wrapper. + +Canonical operational-1 recovery also carries inspection advice after admitted +work, consistent with the existing ADR uncertainty rule. These are documentation +corrections, not fixes to running product code. ADRs remain Proposed. The nonclean +round is retained; two NEW complete cumulative clean proposal rounds are required +after this final material correction before asking for approval. From c6166331214def5053a68cc0c032d65b43afe1cf Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 01:12:07 +0300 Subject: [PATCH 012/222] docs(docs): clarify wave 3 partial replacements and scope Close the complete proposal review's two Low wording cautions. Identify ADR-0049 clauses 5 and 6, preserve absent-input non-enumerability, and record exact review provenance before the final independent full rounds. Refs: ADR-0110, ADR-0113 Co-Authored-By: Codex --- ...d-discovery-have-explicit-compatibility.md | 9 ++++++--- docs/reference/cli/packaging.md | 8 +++++--- .../shared-core/expression-sandbox-spec.md | 13 ++++++++----- .../phase-2.5.5-hardening-and-remediation.md | 19 ++++++++++++++++++- 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index 4d7ad707..037d4af4 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -2,14 +2,16 @@ - **Status**: Proposed - **Date**: 2026-10-15 -- **Related**: [ADR-0049](0049-cli-machine-output-contract.md) (partial replacement of explicit JSON-help exception), [ADR-0047](0047-cli-framework-commander-ink-clack.md), [ADR-0056](0056-cli-in-app-slash-command-system-and-manifest.md), [ADR-0088](0088-the-mcp-boundary-is-hostile.md), [ADR-0103](0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md), [ADR-0111](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) +- **Related**: [ADR-0049](0049-cli-machine-output-contract.md) (§5 infrastructure/pre-run exceptions and §6 explicit JSON-help partially replaced), [ADR-0047](0047-cli-framework-commander-ink-clack.md), [ADR-0056](0056-cli-in-app-slash-command-system-and-manifest.md), [ADR-0088](0088-the-mcp-boundary-is-hostile.md), [ADR-0103](0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md), [ADR-0111](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) ## Context Wave 3 D19/D20/D22, #13/#17/#18/#19/#40/#41/#42/#45/#224/#264/#265/#268/#269/ #309/#310 and G35 require explicit CLI compatibility. Accepted ADR-0056 already promises machine-readable help; ADR-0049 §6 prescribes human help under JSON. -This proposal realizes the former and partially replaces the latter. +This proposal realizes the former and partially replaces ADR-0049 §6 for explicit +JSON help, and §5 for classified storage/infrastructure exceptions. Actual run +terminal outcomes and the pure stdout event stream retain their contracts. At baseline `702cdc9c`, verbosity has no effective general consumer and debug predicates differ. Unexpected CLI failures share exit 1 with known operational @@ -124,7 +126,8 @@ eligibility/decline/interruption rules and every existing deletion example must agree. G15 closes as a refuted current defect with preservation evidence. At **acceptance**, record ADR-0049's clause-specific partial replacement in its -Status/index and dated forward note, append ADR-0056's realization note and +Status/index and dated forward note: §5 pre-run/infrastructure exceptions and §6 +explicit JSON help. Append ADR-0056's realization note and ADR-0088/0103 refinements before dependent code. Reconcile D19's old precedence ladder, D22's two-exception shorthand, E's “Required ADR: none new” and phase exit criterion 4's blanket machine-contract prohibition to this enumerated scope. diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index 87bbefc5..251a6a21 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -97,8 +97,9 @@ No host absolute paths, credentials or embedded source are stored. The **public binding receipt** is versioned JSON beside ciphertext: source SHA, package version, tarball/ciphertext SHA-256, recipient primary and encryption-subkey -fingerprints, toolchain/config/lock identity, nonsecret originating map-check completion status, -and originating trusted workflow/run/attempt identity. It contains neither maps nor private per-source inventory. +fingerprints, toolchain/config/lock identity, nonsecret originating map-check +completion status, and originating trusted workflow/run/attempt identity. It +contains neither maps nor private per-source inventory. Use SHA-256 for file identity, not a homemade signature/provenance protocol. Check originating identity against the trusted CI service/run and real tag/source; a self-declared JSON field alone does not authenticate its producer. @@ -240,7 +241,8 @@ Same-run transfer may use Actions' existing artifact runtime capability; cross-r retrieval remains in the Actions-read discovery owner. Archive/finalize recheck remote state with their own custody permissions on every retry. No downstream job can interpret stale fresh output as authority to rebuild. A missing source artifact -cannot be compensated by re-encryption. Retain tarball/ciphertext/receipt for every supported release beyond +cannot be compensated by re-encryption. Retain tarball/ciphertext/receipt for every +supported release beyond Actions expiry, including npm failures. No automatic asset deletion/replacement. Public Release is not private storage; only ciphertext restricts debugging payload. diff --git a/docs/reference/shared-core/expression-sandbox-spec.md b/docs/reference/shared-core/expression-sandbox-spec.md index 9827e5f9..9fe8fa9c 100644 --- a/docs/reference/shared-core/expression-sandbox-spec.md +++ b/docs/reference/shared-core/expression-sandbox-spec.md @@ -272,7 +272,8 @@ Object.keys(resolved) order, not a new lexical sort. Validate unique well-formed keys and that orderedKeys is exactly the disjoint union of clean and denied keys. Reconstruct scope data/accessors in this full order, preserving existing context declaration order and ECMAScript integer-key enumeration. Separate clean/denied -lists alone cannot recover their original interleaving. One pure factory qualifies resolved context using the existing +lists alone cannot recover their original interleaving. One pure factory qualifies +resolved context using the existing linear taint graph. Required execution/preparation ctx becomes this projection; the exported trusted raw resolver contract stays unchanged. Missing/malformed projection uses existing validation ErrorCode with fixed content-free message, @@ -282,7 +283,8 @@ Derive secret-input membership once from that same admitted workflow. Own its backing data; expose a ReadonlySet-compatible membership/iteration facade without Set mutation methods and an immutable ordered denied-name array from the same factory. Facade callbacks/iterators must never expose the mutable backing Set -(including forEach’s third argument). Copy caller-provided names at embedding entry: Object.freeze(new Set()) +(including forEach’s third argument). Copy caller-provided names at embedding +entry: Object.freeze(new Set()) or a live caller Set is insufficient. Every fresh/resume/preparation producer uses that same authority, rather than recomputing a mutable set at resume. @@ -299,10 +301,11 @@ that same authority, rather than recomputing a mutable set at resume. private null placeholders which become accessors before author code; absent declared-secret keys get non-enumerable denied accessors. Never create an enumerable missing input or reorder unrelated clean keys. Placeholders are - transport data, never readable expression values or display markers. Trusted NodeExecContext.inputs + transport data, never readable expression values or display markers. Trusted + NodeExecContext.inputs remains raw; input-node/public display continues using maskSecretInputs. -- QuickJS rebuilds frozen null-prototype scope records and enumerable denied - accessors for both namespaces. Clean values and key-only enumeration remain +- QuickJS rebuilds frozen null-prototype scope records and denied accessors with + the present/absent enumerability specified above. Clean values and key-only enumeration remain usable. Scope metadata validation precedes serialization; no newly projected raw denied input/context bytes enter the VM. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 8009e630..d2f48524 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -3949,7 +3949,8 @@ is inferred. Working reports/probes remain local and ignored. **Canonical landing checklist (required, not a future unowned debt):** 1. At explicit approval: change only new ADR statuses/index, append old clause-specific - notes and partial Accepted/index annotations for ADR-0047/0049/0051. Append the + notes and partial Accepted/index annotations for ADR-0047/0049/0051; ADR-0049 + names §5 infrastructure/pre-run exceptions as well as §6 explicit JSON help. Append the listed refinements, including ADR-0105 meta admission and ADR-0027/0083 direct-input compatibility/ADR-0093 rationale. Reconcile D19/D22, E no-new-ADR statement and phase criterion 4 before dependent code. Keep historical bodies intact. @@ -3998,3 +3999,19 @@ work, consistent with the existing ADR uncertainty rule. These are documentation corrections, not fixes to running product code. ADRs remain Proposed. The nonclean round is retained; two NEW complete cumulative clean proposal rounds are required after this final material correction before asking for approval. + +#### Second rereview proposal round 2 wording closure — 2026-10-16 + +The NEW complete cumulative source review at 741d0db4 (gpt-6.1-sol, xhigh) +verifies all three prior Mediums resolved and records no new material finding. +Two optional Low cautions are also closed: absent declared-secret input accessors +retain non-enumerability, and ADR-0049’s partial-replacement instructions name +§5 infrastructure/pre-run exceptions as well as §6 explicit JSON help. These +clarify the already explicit behavior matrix rather than introduce another rule. +The earlier complete round at 86ff88a8 used gpt-6-astra, xhigh. + +For an unambiguous final checkpoint, obtain two NEW independent complete cumulative +reviews of all fourteen paths at the wording-corrected source. Neither round 1’s +three Mediums nor round 2’s optional cautions are hidden or relabeled zero issues. +All four ADRs remain Proposed; code, platform, production custody and whole-wave +acceptance are still future implementation gates. From effab2ac08120e40be05ec56081cee13fa03cbfd Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 01:19:07 +0300 Subject: [PATCH 013/222] docs(docs): preserve absent inputs in wave 3 secret scopes Resolve the Medium independently verified by both complete final reviewers: non-enumerable accessors still create properties. Keep omitted optional secret inputs truly absent while denying present secret values. Preserve declaration-based template refusal and existing required/default admission. Record the superseded review verdicts and require two new complete rounds. No product implementation or ADR status changes. Refs: ADR-0113 Co-Authored-By: Codex --- ...ontext-keeps-provenance-at-public-sinks.md | 10 ++++++-- .../shared-core/expression-sandbox-spec.md | 16 +++++++++--- .../phase-2.5.5-hardening-and-remediation.md | 25 +++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md index b1ac30f1..c9e1755e 100644 --- a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md +++ b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md @@ -71,7 +71,11 @@ stays local to the resolver/projection cut rather than a second executor authori Expression scopes contain clean context/input values and explicit denied-name metadata, never raw secret-input or tainted-context values. Direct secret inputs -now use the same denied accessors as context. This deliberately replaces the +when present use the same denied accessors as context. Omitted optional secret +inputs stay absent/undefined under all ordinary own-property observations: no +value exists to withhold. Public-template references remain declaration-based +refusals; secret defaults stay forbidden and required admission is unchanged. +This deliberately replaces the expression-input marker rule in ADR-0027/0083; run-start/input-node/display markers remain data under their existing contract. No universal rejection of marker-shaped objects or arbitrary secret-looking user/model text is claimed. @@ -164,7 +168,9 @@ benefit, without weakening raw-value exclusion. separately pin caught pre-accessor resource failure without claiming a denial. Verify no raw newly projected value in VM input/error detail. Pin single result serialization, stateful toJSON, exact current primitive/result parity and - context/input key order/presence. Independently successful clean workflows must + context/input key order/presence, including omitted optional inputs under + reflection and direct undefined reads. Pair with supplied-secret denial and + unchanged required/default admission. Independently successful clean workflows must complete with exact expected outputs; no vacuous negative-only test. 2. Actual SQLite hosts scan `run_events.payload_json`, `step_executions.output_json` and `runs.output_json`, plus public events and diff --git a/docs/reference/shared-core/expression-sandbox-spec.md b/docs/reference/shared-core/expression-sandbox-spec.md index 9fe8fa9c..d03a5856 100644 --- a/docs/reference/shared-core/expression-sandbox-spec.md +++ b/docs/reference/shared-core/expression-sandbox-spec.md @@ -298,14 +298,18 @@ that same authority, rather than recomputing a mutable set at resume. deniedInputNames; clean embeddings provide explicit empty metadata. Scope construction omits raw secret inputs and supplies clean values plus denied names. Preserve actual input own-key order/presence: present denied keys use - private null placeholders which become accessors before author code; absent - declared-secret keys get non-enumerable denied accessors. Never create an - enumerable missing input or reorder unrelated clean keys. Placeholders are + private null placeholders which become accessors before author code. For an + omitted optional secret input, create **no property/accessor**: ordinary + undefined, hasOwn/in and all own-key/descriptor observations retain baseline + absence. Compute VM denied keys as present admitted keys intersected with owned + secret membership; public-template refusal still uses declaration membership. + Never introduce secret defaults or relax required-input admission. Placeholders are transport data, never readable expression values or display markers. Trusted NodeExecContext.inputs remains raw; input-node/public display continues using maskSecretInputs. - QuickJS rebuilds frozen null-prototype scope records and denied accessors with - the present/absent enumerability specified above. Clean values and key-only enumeration remain + only the present denied input keys specified above; context uses its full + ordered projection. Clean values and key-only enumeration remain usable. Scope metadata validation precedes serialization; no newly projected raw denied input/context bytes enter the VM. @@ -370,6 +374,10 @@ For object results, preserve nested omission/null/boxed behavior and the existin null-result exception; successful JSON serialization alone does not override the original VM type checks. Pin stateful toJSON call count and no second author serialization, numeric tags and every result arm in real installed QuickJS tests. +For omitted optional secret inputs also pin Object.keys, own-property names and +descriptors, hasOwn/in, Reflect.ownKeys where supported, and direct undefined +comparison. A supplied secret must actually trigger denial; declared-secret text +references still refuse. Do not certify absence by enumerable keys alone. Changes needed beyond this parity require an explicit decision, not an accidental transport side effect. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index d2f48524..2b4b6394 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4015,3 +4015,28 @@ reviews of all fourteen paths at the wording-corrected source. Neither round 1 three Mediums nor round 2’s optional cautions are hidden or relabeled zero issues. All four ADRs remain Proposed; code, platform, production custody and whole-wave acceptance are still future implementation gates. + +#### Final proposal rounds reopened omitted-input presence — 2026-10-16 + +Two NEW independent complete cumulative reviews at c6166331 (gpt-6-astra/high and +gpt-6.1-sol/high) independently verify one shared Medium. Non-enumerable denied +accessors for omitted optional secret inputs still change hasOwn/in, own-property +names/descriptors and direct reads. They contradict the promised own-presence +parity. Both rounds are nonclean; the authority report's initial clean assessment +was superseded by its independently verified addendum and is not counted. + +The corrected proposal creates **no property/accessor for an omitted optional +secret input**. VM denial applies to present admitted secret values; absence +retains undefined and all ordinary reflective observations. Public-template +secret references still use declaration-based refusal before filters/defaults. +Secret defaults remain forbidden, and missing-required/nonsecret-default admission +is not changed. Actual implementation controls must pair supplied-secret denial +with omitted-input hasOwn/in/keys/descriptors/Reflect (where supported)/undefined +parity. The earlier non-enumerable proposal notes above are historical, superseded +by this correction rather than used as current authority. + +No product code or new confidentiality guarantee is introduced. Both complete +reports were read in full. After this material correction two NEW independent +complete cumulative clean proposal rounds are required before requesting approval. +All four ADRs remain Proposed and production/release/provider/key/billing work +remains outside this preflight. From 76065d2d12f53b728fc40954eceb2d58f2efd7ff Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 01:24:39 +0300 Subject: [PATCH 014/222] docs(docs): align wave 3 roadmap with its execution plan Resolve the scheduling conflict independently verified by both complete reviewers. Keep original queue groupings as scope inventory and make the nineteen reviewed increments the sole proposed execution-order authority. Clarify preservation and later-I scope without closing implementation work. Refs: Wave 3, ADR-0110, ADR-0111, ADR-0112, ADR-0113 Co-Authored-By: Codex --- docs/roadmap/current.md | 25 +++++++++++++------ .../phase-2.5.5-hardening-and-remediation.md | 25 +++++++++++++++++-- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 25f9451e..5b333095 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1117,8 +1117,16 @@ has started. The nineteen-increment plan now assigns these mechanisms explicitly Archive method is already selected; production recipient/recovery belongs to actual Wave 4b release. No provider capture, key access, billing or upload is needed. -Decompose, guard and normalize every hot file a 2.6 workstream is about to land on. **The multi-file queue -runs first and is serialized against the rest**; the remaining queues are file-disjoint, any order. +Decompose, guard and normalize every hot file a 2.6 workstream is about to land on. +**The multi-file queue is Step 1 and runs first.** The proposed +[nineteen-increment execution plan](phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) +owns the full order and review gates: finish each increment and its fresh reviews +before advancing; no parallel edits of shared hot files. + +The groups below retain the original scope inventory and within-file dependency +arrows, not independent scheduling permission or current completion claims. The +[source-corrected preflight dispositions](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-preflight-and-proposed-execution-plan--2026-10-15) +own whether/how each original finding needs implementation or preservation. Two orderings here are not this plan's invention — they are the intra-phase serializations [2.5.5 records under Positioning](phases/phase-2.5.5-hardening-and-remediation.md#positioning): **E before I** @@ -1143,9 +1151,10 @@ on `home-controller.ts`/`chat-ink.tsx` (the decomposition moves already-guarded min-terminal-size guard (#63), and hand restructuring to 2.6.M's render-v2 in Wave 5a. - **Viewport (strict)** — 2.5.5.I · share the ADR-0069 grapheme segmenter (#58, #62, G40, G51) → 2.5.5.G · tab expansion before width measurement (#65) → per-cluster width cache + resize debounce - (#64, #66). Alt-screen word-wrap (#61) is **formally re-deferred**. -- **`packages/shared` (strict)** — the `.code` ruling (G50, D10) → `findDuplicates`/`superRefine` at all seven - remaining schema sites (#89, #90, G16, G17, G18, #210, #211, #212) → `BaseEventSchema` disposition (#213) → + (#64, #66), refined to frame coalescing in the proposed plan. Alt-screen word-wrap + (#61) retains Wave 5a ownership; its formal disposition follows plan approval. +- **`packages/shared` (strict)** — the `.code` ruling (G50, D10) → `findDuplicates`/`superRefine` implementation or preservation at the + schema dispositions (#89, #90, G16, G17, G18, #210, #211, #212) → `BaseEventSchema` disposition (#213) → the `RelaviumError` base class (#219). *The ruling must precede the base class, or it is baked in permanently.* - **`registry.ts` / `builtins.ts` / `engine.ts`** — declared-source context repair and honest unsupported-mapping disposition (#92/Proposed ADR-0113) → `ctx.fsScope` (#93) → `ok`/`success` (#216, #221) → identifier-sort `localeCompare` pinning (#258, #259) → the `git_commit` @@ -1157,8 +1166,10 @@ on `home-controller.ts`/`chat-ink.tsx` (the decomposition moves already-guarded #265, #268), the exit-code and `--help --json` rulings (G35, #13), durable-only logs preservation (G15 refuted), the `--input` namespace ruling (#18, #310). **`openLocalDb` wrap-and-redact (#278, #280) lands LAST** — it touches 8+ call sites the other queues are still moving. -- Then the tails of 2.5.5.A, E, G and I in any order; sub-stream **A, E and G acceptance sign-offs**; - and record the **EXIT:4 reading** (decision D22). **Closes M2.5.5-3.** +- The numbered plan assigns the remaining 2.5.5.A/E/G/I tails and keeps the central + DB refactor last before whole-wave acceptance. Step 19 owns sub-stream **A, E + and G sign-offs**, the reconciled **EXIT:4 reading** (D22), and **M2.5.5-3** + closure. Full I acceptance remains in Wave 5a. ### Wave 4a — The spine diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 2b4b6394..0e2379a3 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -3586,8 +3586,9 @@ This merge-status update starts no Wave 3 implementation and changes no release **Source baseline:** `development` at `702cdc9c59c688a48a240c2961bbf16b491e0ace`. Wave 2 is merged; this is its documentation successor. Wave 3 is in document/decision preflight, with no implementation accepted or started by this entry. The current -[Wave 3 queue](../current.md#wave-3--clear-the-ground) owns execution order; A/E/G/I -above retain scope and acceptance. No release, new provider capture, credential or +[Wave 3 queue](../current.md#wave-3--clear-the-ground) owns scope groupings and +later-wave boundaries; the numbered increments below own proposed execution order +and review gates. A/E/G/I above retain scope and acceptance. No release, new provider capture, credential or paid test is required. Detailed working reports stay local and Git-ignored. Three independently scoped agents examined CLI/process, shared/core and chat/Home/ @@ -4040,3 +4041,23 @@ reports were read in full. After this material correction two NEW independent complete cumulative clean proposal rounds are required before requesting approval. All four ADRs remain Proposed and production/release/provider/key/billing work remains outside this preflight. + +#### Live roadmap scheduling correction — 2026-10-16 + +Two NEW independent complete cumulative reviews at effab2ac (gpt-6-astra/high and +gpt-6-sol/high) confirm omitted-input absence parity and independently verify one +shared Medium: current.md still permits the post-first queues and tails in “any +order”, while the proposed nineteen-increment plan requires serial gated advance. +Both reports are nonclean and have been read in full; neither counts toward final +readiness. + +The live roadmap now labels its original groups as scope/dependency inventory and +links the numbered increments as the sole proposed execution/review authority. +The phase introduction no longer delegates execution back to those groups. Source- +corrected preservation dispositions, viewport coalescing, pending formal #61 +record and later full-I ownership remain explicit; no work is silently closed or +newly deferred. This is a planning consistency correction, not a change to the +already proposed nineteen-step sequence or any product source. + +Two NEW independent complete cumulative clean rounds at the corrected source are +required. ADR-0110–0113 remain Proposed, and maintainer approval precedes code. From 5a065244e3d185e0b2c8e8e23a9a7f4338236699 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 01:31:37 +0300 Subject: [PATCH 015/222] docs(docs): record final wave 3 proposal rereview readiness Record two new independent clean complete cumulative reviews at 76065d2d across all fourteen proposal paths and nineteen steps. Keep superseded and nonclean verdicts distinct, preserve exact evidence scope, and request the required approval with all four ADRs still Proposed and code unchanged. Refs: ADR-0110, ADR-0111, ADR-0112, ADR-0113 Co-Authored-By: Codex --- docs/roadmap/current.md | 22 +++++---- .../phase-2.5.5-hardening-and-remediation.md | 49 +++++++++++++++++++ 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 5b333095..453e33fb 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1106,16 +1106,18 @@ an ESLint selector rather than a list. ### Wave 3 — Clear the ground -**Proposal reopened by maintainer rereview — 2026-10-16.** -[Current corrections and limits](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-and-proposal-corrections--2026-10-16) -supersede the a8708a65/49ba03ee readiness claim. Source audits confirm missing -session-health authority, MCP PID/group distinction, release discovery/finalization -permissions, Home build-first revocation and finite VM denial. Revised ADR-0110–0113 -and their explicitly unshipped canonical contracts remain **Proposed**. New complete -cumulative proposal reviews and maintainer approval are required; no product code -has started. The nineteen-increment plan now assigns these mechanisms explicitly. -Archive method is already selected; production recipient/recovery belongs to -actual Wave 4b release. No provider capture, key access, billing or upload is needed. +**Revised proposal reviewed; maintainer approval pending — 2026-10-16.** +[Final rereview readiness and approval scope](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) +records two NEW independent complete cumulative clean reviews at 76065d2d across +all fourteen proposal paths, four full ADRs and nineteen increments, after every +material correction. Earlier nonclean/superseded verdicts remain history. The +source-corrected contracts cover real CLI/native ownership, exact encrypted +candidate recovery, live Home HTTP narrowing and finite secret-access semantics +with value/key/absence parity. ADR-0110–0113 remain **Proposed and unshipped**; +no product code has started. Approval of those ADRs/contracts/plan precedes Step 1. +Archive method is already selected; actual production recipient/recovery belongs +to Wave 4b release preparation. No provider capture, key, billing or upload is +required for this proposal review. Decompose, guard and normalize every hot file a 2.6 workstream is about to land on. **The multi-file queue is Step 1 and runs first.** The proposed diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 0e2379a3..8175a8ae 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4061,3 +4061,52 @@ already proposed nineteen-step sequence or any product source. Two NEW independent complete cumulative clean rounds at the corrected source are required. ADR-0110–0113 remain Proposed, and maintainer approval precedes code. + +#### Second maintainer rereview final proposal readiness — 2026-10-16 + +Two NEW independent COMPLETE cumulative reviews are clean at exact source +76065d2d12f53b728fc40954eceb2d58f2efd7ff: authority (gpt-6-astra/high) and +compatibility (gpt-6.1-sol/high). Each independently covers all fourteen proposal +paths, all four full ADRs, all nineteen increments and the relevant actual source/ +Accepted contracts. Both report zero verified findings; the parent reads both +full reports. They follow the last material scheduling correction, not merely its +two changed files. No earlier nonclean or superseded verdict counts toward them. + +The complete rounds at 86ff88a8, c6166331 and effab2ac remain nonclean history; +741d0db4’s two optional Low cautions were corrected before these final rounds. +The c6166331 authority report’s initial clean verdict was explicitly superseded +and is not counted. Earlier a8708a65/49ba03ee readiness remains superseded. + +Current proposal decisions now consistently specify distinct CLI output/internal +9 with actual run-only health precedence and owner-kind teardown; exact originating +map evidence and permissioned reservation/retry/finalization; invocation-live HTTP +narrowing across failed Home builds; and owned context/input exclusion with one +result serialization, finite observed-access diagnosis and exact value/key/absence +parity. Declared-secret template refusal, forbidden secret defaults, legacy-output +limits and unsupported tool-mapping scope stay explicit. The numbered plan owns +execution, while original roadmap groups are scope inventory. + +Actual final-round validation includes diff whitespace, independent relative-file +checks and unchanged product/old ADR inventories. Compatibility additionally runs +nine existing schema/manifest/options/source-label/viewport suites: 255 tests pass +at 76065d2d, with no skips. These are unchanged-source preservation/preflight tests, +not tests of future implementation. Prettier skips ignored Markdown and is not +formatting evidence. Parent link/anchor validation covers 61 changed Markdown +files and 3,037 relative links/anchors with zero errors; all 109 earlier numbered +ADRs remain byte-identical to baseline 702cdc9c and four new ADRs remain Proposed. +No full CI/coverage, native teardown/Windows, actual product pack/map/GPG release, +security-sitting, provider/key/billing or production-custody acceptance is inferred. +Detailed review/probe records remain local and Git-ignored. + +**Ready for maintainer review and explicit approval of ADR-0110–0113, their +unshipped canonical contracts and the nineteen-increment plan.** No product code +has started. The selected encrypted archive method persists; actual production +recipient/recovery configuration belongs to Wave 4b, not this approval. Approval +is still required by the user’s new-ADR instruction and implement-task plan gate. + +This receipt/current-status update changes no reviewed decision, mechanism or +implementation step. After approval, update new ADR statuses/index and append +clause-specific Accepted-ADR notes before dependent code, then start Step 1 and +its implementation/commit/two-new-clean-review loop. Later increments advance +automatically after their gates. Whole-wave acceptance and PR remain Step 19; +full I/whole phases and existing approved later-wave scope are not closed here. From 9e4b925f68f1c4bddb787c45cab5b17d393989a9 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 01:44:04 +0300 Subject: [PATCH 016/222] docs(docs): accept wave 3 decisions and implementation plan Record maintainer approval of the final four ADRs and nineteen increments. Annotate only replaced historical clauses, append forward notes and retain unshipped reference status until each implementation passes its gates. Reconcile compatibility criteria and record the approved Wave 5a wrap owner. Refs: ADR-0110, ADR-0111, ADR-0112, ADR-0113 Co-Authored-By: Codex --- AGENTS.md | 5 +-- CLAUDE.md | 5 +-- .../0006-os-keychain-for-api-keys.md | 4 +++ docs/decisions/0027-expression-sandbox.md | 4 +++ docs/decisions/0029-tool-policy-hardening.md | 6 ++++ .../0033-strict-config-files-amends-0023.md | 4 +++ .../0037-engine-tool-execution-boundary.md | 6 ++++ .../0047-cli-framework-commander-ink-clack.md | 6 +++- .../0049-cli-machine-output-contract.md | 6 +++- .../0050-cli-history-db-at-rest-posture.md | 4 +++ ...distribution-thin-bundle-private-engine.md | 6 +++- ...54-cli-bare-invocation-interactive-home.md | 4 +++ ...apability-seam-tool-environment-factory.md | 4 +++ ...n-app-slash-command-system-and-manifest.md | 4 +++ ...57-cli-chat-modes-and-per-tool-approval.md | 4 +++ .../0059-cli-mid-session-model-reseat.md | 4 +++ .../0063-cli-config-write-contract.md | 4 +++ ...models-dev-as-the-model-metadata-source.md | 4 +++ ...the-db-behind-a-generated-offline-floor.md | 4 +++ ...a-resume-that-verifies-its-own-identity.md | 4 +++ .../0088-the-mcp-boundary-is-hostile.md | 4 +++ ...sion-sees-only-what-it-is-ordered-after.md | 4 +++ ...-local-producers-before-its-host-closes.md | 4 +++ ...invocation-grants-not-project-authority.md | 4 +++ ...nership-is-explicit-iterator-provenance.md | 4 +++ ...d-discovery-have-explicit-compatibility.md | 10 +++++- ...-operations-use-a-lazy-packaged-runtime.md | 10 +++++- ...and-onboarding-state-are-operator-owned.md | 10 +++++- ...ontext-keeps-provenance-at-public-sinks.md | 10 +++++- docs/decisions/README.md | 14 ++++----- docs/reference/cli/README.md | 2 +- docs/reference/cli/commands.md | 4 +-- docs/reference/cli/packaging.md | 8 ++--- docs/reference/contracts/config-spec.md | 4 +-- docs/reference/shared-core/agent-runner.md | 4 +-- .../shared-core/expression-sandbox-spec.md | 4 +-- docs/reference/shared-core/tool-registry.md | 4 +-- docs/roadmap/current.md | 31 +++++++++---------- docs/roadmap/deferred-tasks.md | 11 +++++++ .../phase-2.5.5-hardening-and-remediation.md | 30 ++++++++++++++++-- 40 files changed, 215 insertions(+), 53 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dd8aa736..70bd67af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,8 +78,9 @@ whole Phase 2.5.5/2.6, full M/N production lifecycle, future live provider heade and existing approved deferrals remain open in their canonical homes. No Wave 2 implementation, review, publication, ADR approval, provider capture, credential, billing or user-decision gate remains. Next is [Wave 3 — Clear the ground](docs/roadmap/current.md#wave-3--clear-the-ground), -starting with a fresh document/decision preflight and its serialized multi-file queue. -Wave 3 implementation has not started in this closure update. +whose ADR-0110–0113/contracts/nineteen-increment plan were approved on 2026-10-16. +Its serialized multi-file Step 1 is active on `development`; implementation and +independent review acceptance remain staged in the canonical roadmap. See [docs/roadmap/current.md](docs/roadmap/current.md) for live status. diff --git a/CLAUDE.md b/CLAUDE.md index 024a15c7..b5bffb4f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,8 +167,9 @@ whole Phase 2.5.5/2.6, full M/N production lifecycle, future live provider heade and existing approved deferrals remain open in their canonical homes. No Wave 2 implementation, review, publication, ADR approval, provider capture, credential, billing or user-decision gate remains. Next is [Wave 3 — Clear the ground](docs/roadmap/current.md#wave-3--clear-the-ground), -starting with a fresh document/decision preflight and its serialized multi-file queue. -Wave 3 implementation has not started in this closure update. +whose ADR-0110–0113/contracts/nineteen-increment plan were approved on 2026-10-16. +Its serialized multi-file Step 1 is active on `development`; implementation and +independent review acceptance remain staged in the canonical roadmap. For live status, per-PR history, milestone dates, and open obligations, see the canonical home [docs/roadmap/current.md](docs/roadmap/current.md); diff --git a/docs/decisions/0006-os-keychain-for-api-keys.md b/docs/decisions/0006-os-keychain-for-api-keys.md index b893f34f..f1913d3e 100644 --- a/docs/decisions/0006-os-keychain-for-api-keys.md +++ b/docs/decisions/0006-os-keychain-for-api-keys.md @@ -75,3 +75,7 @@ as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-har joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved limits. Earlier pending/unmerged notes are historical; this landing adds no decision, future feature, deferral or claim of merge-commit execution. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0112](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) is accepted by the maintainer. Keychain custody remains unchanged. The operator acknowledgement is UX-only and must recheck the current backend/key state; ephemeral model selection never claims saved defaults. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0027-expression-sandbox.md b/docs/decisions/0027-expression-sandbox.md index daddd6fc..6f554264 100644 --- a/docs/decisions/0027-expression-sandbox.md +++ b/docs/decisions/0027-expression-sandbox.md @@ -195,3 +195,7 @@ choice is settled and is **not** conditional on perf data. The 1.AB spike measur latency, per-eval RSS, and **per-surface bundle-size impact** (the embedded wasm adds to the Tauri / CLI / VS Code bundles) against recorded thresholds, and runs the dependency-provenance / Leakwatch pass on the pinned variant. It cannot reopen the engine choice. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0113](0113-secret-derived-context-keeps-provenance-at-public-sinks.md) is accepted by the maintainer. Expression scopes exclude present declared-secret inputs and secret-derived context rather than passing expression markers. Display/run-start/input-node masking remains unchanged. Executed denied access has a finite private-latch diagnosis; omitted optional inputs retain absence and no host function is introduced. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0029-tool-policy-hardening.md b/docs/decisions/0029-tool-policy-hardening.md index f9aa2c91..50993e09 100644 --- a/docs/decisions/0029-tool-policy-hardening.md +++ b/docs/decisions/0029-tool-policy-hardening.md @@ -128,3 +128,9 @@ as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-har joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved limits. Earlier pending/unmerged notes are historical; this landing adds no decision, future feature, deferral or claim of merge-commit execution. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0112](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) is accepted by the maintainer. The built-in chat HTTP tool obtains global-only, invocation-live monotonically narrowing authority, independent of tool grants, approval and SSRF. Workflow, shell, provider and MCP egress retain their separate contracts. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. + +[ADR-0113](0113-secret-derived-context-keeps-provenance-at-public-sinks.md) additionally refines this boundary: Declared-source provenance removes raw derived values before standard VM/template sinks; external secretArgKeys remains an unverified optional compatibility field with no current mapping producer. diff --git a/docs/decisions/0033-strict-config-files-amends-0023.md b/docs/decisions/0033-strict-config-files-amends-0023.md index 3fd46ec4..4b27a7bf 100644 --- a/docs/decisions/0033-strict-config-files-amends-0023.md +++ b/docs/decisions/0033-strict-config-files-amends-0023.md @@ -62,3 +62,7 @@ rejected for the same reasons ADR-0023 rejected it for authored YAML (no diagnos (`config-spec.md`), and a loud failure on a machine-local file the user controls is preferable to a silently-dropped setting. (This is the exact forward-compat tolerance ADR-0023's carve-out had chosen to keep; this ADR consciously trades it for fail-loud, reconciling with `config-spec.md`.) + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0112](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) is accepted by the maintainer. Approved new global-only HTTP policy and keychain acknowledgement keys remain strict. The canonical config specification owns old-binary key-removal recovery; acceptance does not make these unimplemented keys shipping. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0037-engine-tool-execution-boundary.md b/docs/decisions/0037-engine-tool-execution-boundary.md index 5c7806a5..ffdc912d 100644 --- a/docs/decisions/0037-engine-tool-execution-boundary.md +++ b/docs/decisions/0037-engine-tool-execution-boundary.md @@ -90,3 +90,9 @@ as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-har joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved limits. Earlier pending/unmerged notes are historical; this landing adds no decision, future feature, deferral or claim of merge-commit execution. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0113](0113-secret-derived-context-keeps-provenance-at-public-sinks.md) is accepted by the maintainer. The engine owns qualified context/input provenance before standard execution and budget preparation. Trusted raw resolver/host inputs are not a public secret-free domain. The optional external tool provenance field has no current authored mapping producer and is not proof of cleanliness. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. + +[ADR-0112](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) additionally refines this boundary: The immutable admitted PolicyTarget and existing beforeDispatch seam supply the final live chat HTTP check after approval/effect preparation; tool grants and approval remain independent. diff --git a/docs/decisions/0047-cli-framework-commander-ink-clack.md b/docs/decisions/0047-cli-framework-commander-ink-clack.md index 5aff644f..a7d9569b 100644 --- a/docs/decisions/0047-cli-framework-commander-ink-clack.md +++ b/docs/decisions/0047-cli-framework-commander-ink-clack.md @@ -1,6 +1,6 @@ # ADR-0047: CLI framework — `commander` + `ink` + `@clack/prompts`, confined to `apps/cli` -- **Status**: Accepted +- **Status**: Accepted (single-output constraint partially replaced by ADR-0111) - **Date**: 2026-06-22 - **Related**: [ADR-0003](0003-pure-ts-engine-not-langgraph-python.md), [ADR-0011](0011-internal-llm-abstraction.md), [ADR-0019](0019-cli-node-keychain-library.md), [ADR-0035](0035-yaml-parser-dependency.md), [phase-2-cli.md](../roadmap/phases/phase-2-cli.md), [cli/commands.md](../reference/cli/commands.md), [architectural-principles.md](../standards/architectural-principles.md), [tech-stack.md](../tech-stack.md), [ADR-0068](0068-full-screen-tui-renderer-ink7-harness.md) (refines: `ink` 6→7) @@ -125,3 +125,7 @@ Considered alternatives: - `ink` couples the CLI to React in the terminal — accepted: it is the chosen TUI model, its footprint is the CLI binary's alone, and the framework-free cores mean a future TUI swap would not touch command logic. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0111](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md) is accepted by the maintainer. Only the single-output packaging constraint is partially replaced by a finite flat ESM entry/chunk graph. CLI framework confinement, inlined private engine packages and declared external third-party dependencies remain accepted. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0049-cli-machine-output-contract.md b/docs/decisions/0049-cli-machine-output-contract.md index 5c365860..2489fce7 100644 --- a/docs/decisions/0049-cli-machine-output-contract.md +++ b/docs/decisions/0049-cli-machine-output-contract.md @@ -1,6 +1,6 @@ # ADR-0049: CLI machine-output contract (`--json` NDJSON stream + stderr diagnostics) -- **Status**: Accepted +- **Status**: Accepted (§5 infrastructure/storage and §6 explicit JSON help partially replaced by ADR-0110) - **Date**: 2026-06-22 - **Related**: [ADR-0036](0036-run-loop-substrate-event-bus-and-execution-host.md), [ADR-0047](0047-cli-framework-commander-ink-clack.md), [sse-event-schema.md](../reference/contracts/sse-event-schema.md), [commands.md](../reference/cli/commands.md), [error-handling.md](../standards/error-handling.md) @@ -134,3 +134,7 @@ Machine-readable events and durable discovery records now qualify numeric known See [ADR-0104](0104-unknown-provider-usage-is-durable-cost-uncertainty.md) and the [canonical usage contract](../reference/shared-core/llm-provider-seam.md#6-usage). + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0110](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md) is accepted by the maintainer. Sections 5 and 6 are partially replaced for explicit infrastructure/storage classifications and explicit JSON help. The approved CLI commands contract owns surface-specific exits, structured stderr and the separate versioned discovery projection. Actual workflow outcomes and the pure stdout event stream remain accepted. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0050-cli-history-db-at-rest-posture.md b/docs/decisions/0050-cli-history-db-at-rest-posture.md index a51490a4..9598ab20 100644 --- a/docs/decisions/0050-cli-history-db-at-rest-posture.md +++ b/docs/decisions/0050-cli-history-db-at-rest-posture.md @@ -206,3 +206,7 @@ as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-har joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved limits. Earlier pending/unmerged notes are historical; this landing adds no decision, future feature, deferral or claim of merge-commit execution. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0113](0113-secret-derived-context-keeps-provenance-at-public-sinks.md) is accepted by the maintainer. New qualified expression scopes prevent newly projected secret values from reaching standard execution outputs. Existing sensitive event/step/run outputs and private error detail are not scrubbed or certified secret-free on replay; the unencrypted-at-rest posture remains unchanged. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0051-cli-distribution-thin-bundle-private-engine.md b/docs/decisions/0051-cli-distribution-thin-bundle-private-engine.md index c694aa6e..1df8188f 100644 --- a/docs/decisions/0051-cli-distribution-thin-bundle-private-engine.md +++ b/docs/decisions/0051-cli-distribution-thin-bundle-private-engine.md @@ -1,6 +1,6 @@ # ADR-0051: CLI distribution — an engine-inlined ESM bundle that externalizes every third-party dependency -- **Status**: Accepted +- **Status**: Accepted (single-output constraint partially replaced by ADR-0111) - **Date**: 2026-06-24 - **Related**: [ADR-0047](0047-cli-framework-commander-ink-clack.md) (commander/ink/`@clack/prompts` + `tsup` single-bundle — this ADR finalizes the bundle boundary 0047 deferred to 2.L), [ADR-0019](0019-cli-node-keychain-library.md) (`@napi-rs/keyring`), [ADR-0021](0021-node-sqlite-driver-better-sqlite3.md) (`better-sqlite3`), [ADR-0027](0027-expression-sandbox.md) (the quickjs JS sandbox), [ADR-0011](0011-internal-llm-abstraction.md) (the in-house `@relavium/llm` this protects), [ADR-0067](0067-node-supported-floor-22-reaffirm-better-sqlite3.md) (the `>=22` floor — see the amendment note) @@ -147,3 +147,7 @@ intended precedent for the desktop (`.dmg`) and VS Code (`.vsix`) surfaces. [tech-stack.md](../tech-stack.md), the [commands.md Install section](../reference/cli/commands.md), and references this ADR from [phase-2-cli.md §2.L](../roadmap/phases/phase-2-cli.md) so the model is not ADR-only. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0111](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md) is accepted by the maintainer. Only the single-output constraint is partially replaced by finite engine-inlined runtime chunks with complete closure and packed-platform checks. Licensing rather than source secrecy, private engine packages and no public plaintext source maps remain accepted. Exact release-map encrypted custody is a distribution policy, not secrecy for this public source repository. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0054-cli-bare-invocation-interactive-home.md b/docs/decisions/0054-cli-bare-invocation-interactive-home.md index 6b90dfe7..47a43185 100644 --- a/docs/decisions/0054-cli-bare-invocation-interactive-home.md +++ b/docs/decisions/0054-cli-bare-invocation-interactive-home.md @@ -85,3 +85,7 @@ as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-har joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved limits. Earlier pending/unmerged notes are historical; this landing adds no decision, future feature, deferral or claim of merge-commit execution. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0112](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) is accepted by the maintainer. Home replacement hosts share invocation-live HTTP authority with retained hosts; failed/overlapping builds cannot restore revoked grants. Explicit ephemeral selection is invocation-owned. ADR-0110 separately owns fatal/output classification without inventing run health for sessions. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0055-cli-host-capability-seam-tool-environment-factory.md b/docs/decisions/0055-cli-host-capability-seam-tool-environment-factory.md index e5fc00ae..7886a0db 100644 --- a/docs/decisions/0055-cli-host-capability-seam-tool-environment-factory.md +++ b/docs/decisions/0055-cli-host-capability-seam-tool-environment-factory.md @@ -281,3 +281,7 @@ as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-har joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved limits. Earlier pending/unmerged notes are historical; this landing adds no decision, future feature, deferral or claim of merge-commit execution. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0112](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) is accepted by the maintainer. Existing tool-host/dispatch separation remains. Chat HTTP uses an owned immutable admitted target and the existing post-approval/post-effect-prepare beforeDispatch seam for the latest live authority check; no broad egress decoration is authorized. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0056-cli-in-app-slash-command-system-and-manifest.md b/docs/decisions/0056-cli-in-app-slash-command-system-and-manifest.md index 80e69d7e..494c343f 100644 --- a/docs/decisions/0056-cli-in-app-slash-command-system-and-manifest.md +++ b/docs/decisions/0056-cli-in-app-slash-command-system-and-manifest.md @@ -110,3 +110,7 @@ core decision — the slash registry, the palette, the manifest, and the `--help The context-aware footer hint-bar (surfacing `/ for commands` at an empty prompt) plus the palette's own nav hints (`↑/↓ · Enter · Esc`) make the keymap discoverable in context, so a separate static-reference command is redundant. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0110](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md) is accepted by the maintainer. The approved framework-free descriptor authority realizes explicit machine-readable help through a separate versioned CLI projection, preserving the strict existing model manifest. Explicit nested command paths and retained flat aliases share stable executor identities; real integration remains staged in Wave 3. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md b/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md index 74a7592c..9095dded 100644 --- a/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md +++ b/docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md @@ -225,3 +225,7 @@ as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-har joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved limits. Earlier pending/unmerged notes are historical; this landing adds no decision, future feature, deferral or claim of merge-commit execution. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0112](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) is accepted by the maintainer. Interactive approval remains an independent floor. The invocation-live HTTP authority is rechecked after awaited approval/effect preparation; a permission change cannot be bypassed by an earlier approval snapshot. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0059-cli-mid-session-model-reseat.md b/docs/decisions/0059-cli-mid-session-model-reseat.md index 2ec2ea48..938aeb1b 100644 --- a/docs/decisions/0059-cli-mid-session-model-reseat.md +++ b/docs/decisions/0059-cli-mid-session-model-reseat.md @@ -127,3 +127,7 @@ session that drops history (rejected: poor UX — the user expects to continue). > does not exist in this design"*), reseat or not. The notice now says exactly that. **This narrows the disclosure to > what is true; it does not weaken the text-only bound, which is unchanged.** Whether the engine SHOULD carry tool > pairs is a separate, still-open question — it is a Phase-3 scope cut of the engine, not a property of the reseat. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0112](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) is accepted by the maintainer. Clear/reseat/Home builds share live narrowing HTTP authority and latest-only selection publication. Explicit ephemeral provider/model choices survive invocation rebuilds without being represented as persisted defaults. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0063-cli-config-write-contract.md b/docs/decisions/0063-cli-config-write-contract.md index bf8753fa..19059b58 100644 --- a/docs/decisions/0063-cli-config-write-contract.md +++ b/docs/decisions/0063-cli-config-write-contract.md @@ -149,3 +149,7 @@ as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-har joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved limits. Earlier pending/unmerged notes are historical; this landing adds no decision, future feature, deferral or claim of merge-commit execution. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0112](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) is accepted by the maintainer. The shared preference writer refuses an existing final-component symlink before loader/read/chmod side effects, using fixed non-retryable recovery. Reads through intentional links remain supported. Existing-file rewrites disclose comment/order loss and retain schema, permissions and durability guarantees. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0071-models-dev-as-the-model-metadata-source.md b/docs/decisions/0071-models-dev-as-the-model-metadata-source.md index 0fdf3882..1be40bd8 100644 --- a/docs/decisions/0071-models-dev-as-the-model-metadata-source.md +++ b/docs/decisions/0071-models-dev-as-the-model-metadata-source.md @@ -524,3 +524,7 @@ Missing usage and missing model/modality prices remain independent. A known zero See [ADR-0104](0104-unknown-provider-usage-is-durable-cost-uncertainty.md) and the [canonical usage contract](../reference/shared-core/llm-provider-seam.md#6-usage). + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0111](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md) is accepted by the maintainer. Lazy execution preserves filesystem admission, one invocation-owned file-catalog seed, current DB overlay, then capability/quote/budget reads. Late file seeding cannot replace authoritative DB long-tail prices; meta operations perform no catalog/cache work. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0072-model-metadata-in-the-db-behind-a-generated-offline-floor.md b/docs/decisions/0072-model-metadata-in-the-db-behind-a-generated-offline-floor.md index 55da06ab..33f130f4 100644 --- a/docs/decisions/0072-model-metadata-in-the-db-behind-a-generated-offline-floor.md +++ b/docs/decisions/0072-model-metadata-in-the-db-behind-a-generated-offline-floor.md @@ -67,3 +67,7 @@ Two further constraints frame the design. `packages/core` (the engine) has **zer - **Frozen normalizer in stored rows** — normalized rows freeze today's normalizer. *Mitigation: `catalog_schema_version` in `catalog_meta`, checked before admission; older-version rows are treated as absent until rewritten, and the file cache is kept until the version gate is proven.* - **`description` may be `NULL` on arrival** — models.dev publishes no per-model description today. *The column is carried at the maintainer's explicit request; it is NULLABLE and populated only when a Relavium-owned overlay (like `deprecations.ts`) or a second source exists — pure enrichment, never on the pricing/wire path.* - **Boot latency** — a DB open on every invocation would slow `--help`/`--version` (today `seedCatalog()` runs unconditionally but opens no DB). *Mitigation: the DB seed/read is gated so it runs only after the resolved command is one that prices; cheap paths stay on the no-DB path. The exact command-classification mechanism is an implementation detail deferred to the read-cutover phase.* + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0111](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md) is accepted by the maintainer. One invocation-owned seedFileCatalogOnce owner spans command, quote, Home and resume transitions. Preserve the shipped offline floor, empty/torn DB fallback and no-startup-fetch contract; current DB catalog authority wins over an older file overlay. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md b/docs/decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md index 8895a6b9..46fa9ef2 100644 --- a/docs/decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md +++ b/docs/decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md @@ -401,3 +401,7 @@ as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-har joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved limits. Earlier pending/unmerged notes are historical; this landing adds no decision, future feature, deferral or claim of merge-commit execution. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0113](0113-secret-derived-context-keeps-provenance-at-public-sinks.md) is accepted by the maintainer. Only expression-input marker compatibility is replaced: present admitted secret values are excluded and use denied accessors. Omitted optional secrets remain absent, secret defaults remain forbidden and required/default admission stays unchanged. Run-start/display/input-node marker contracts remain accepted. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0088-the-mcp-boundary-is-hostile.md b/docs/decisions/0088-the-mcp-boundary-is-hostile.md index 00d135f3..3b649266 100644 --- a/docs/decisions/0088-the-mcp-boundary-is-hostile.md +++ b/docs/decisions/0088-the-mcp-boundary-is-hostile.md @@ -447,3 +447,7 @@ removed from current reap views, even while another connection is still closing. snapshot authorises killing a later process. Cancellation continues to preserve sibling calls; cleanup does not replay tool execution. The canonical contract and remaining host-wide obligations are in [MCP integration](../reference/shared-core/mcp-integration.md#invocation-and-transport-lifetimes). + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0110](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md) is accepted by the maintainer. Fatal cleanup follows actual native ownership: SDK-backed MCP owns its live PID, not a detached POSIX process group. Windows uses the verified absolute teardown facility. Bounded process termination never certifies an MCP close acknowledgement or universal descendant containment. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0093-an-expression-sees-only-what-it-is-ordered-after.md b/docs/decisions/0093-an-expression-sees-only-what-it-is-ordered-after.md index 054f90d1..9fb50b2d 100644 --- a/docs/decisions/0093-an-expression-sees-only-what-it-is-ordered-after.md +++ b/docs/decisions/0093-an-expression-sees-only-what-it-is-ordered-after.md @@ -184,3 +184,7 @@ with that name and a docblock saying why. The residue is recorded in [deferred-tasks.md](../roadmap/deferred-tasks.md) as *"a read the scan cannot confidently resolve"*, which is the phrasing that survives the next shape being found. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0113](0113-secret-derived-context-keeps-provenance-at-public-sinks.md) is accepted by the maintainer. The literal dependency scan and before-money-spent scheduling rule remain accepted. Runtime secret exclusion uses finite VM accessors for a different purpose: diagnosis of executed denied reads, not replacement of parse-time out-of-closure admission. No universal attempted-access detection is claimed. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md b/docs/decisions/0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md index 24f25b5e..84467a22 100644 --- a/docs/decisions/0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md +++ b/docs/decisions/0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md @@ -1241,3 +1241,7 @@ as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-har joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved limits. Earlier pending/unmerged notes are historical; this landing adds no decision, future feature, deferral or claim of merge-commit execution. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0110](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md) is accepted by the maintainer. The invocation shutdown registry preserves actual owner stop/join and run-only health on return and throw paths. The total fatal-process budget is not a receipt deadline or host-safe-close acknowledgement. Sessions keep their existing lifetime/publication failures without fabricated RunDeparture health. ADR-0111 requires a single registry instance in the finite package graph. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0105-filesystem-roots-are-invocation-grants-not-project-authority.md b/docs/decisions/0105-filesystem-roots-are-invocation-grants-not-project-authority.md index 027f5b59..4e6d623c 100644 --- a/docs/decisions/0105-filesystem-roots-are-invocation-grants-not-project-authority.md +++ b/docs/decisions/0105-filesystem-roots-are-invocation-grants-not-project-authority.md @@ -319,3 +319,7 @@ as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-har joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved limits. Earlier pending/unmerged notes are historical; this landing adds no decision, future feature, deferral or claim of merge-commit execution. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0111](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md) is accepted by the maintainer. Meta operations deliberately skip directory existence/link/permission grant admission while retaining pure grammar/count/size checks; no filesystem authority is minted. Execution preserves filesystem admission before the one catalog seed and later model/cost admission. ADR-0112 separately narrows built-in chat HTTP authority without changing filesystem grants. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0109-native-media-close-ownership-is-explicit-iterator-provenance.md b/docs/decisions/0109-native-media-close-ownership-is-explicit-iterator-provenance.md index dee7f47c..a6ed2bab 100644 --- a/docs/decisions/0109-native-media-close-ownership-is-explicit-iterator-provenance.md +++ b/docs/decisions/0109-native-media-close-ownership-is-explicit-iterator-provenance.md @@ -276,3 +276,7 @@ as PR #94 (`ab08209e`). The [merge checkpoint](../roadmap/phases/phase-2.5.5-har joins final reviewed source `4cdfe011`, review scopes, execution evidence and preserved limits. Earlier pending/unmerged notes are historical; this landing adds no decision, future feature, deferral or claim of merge-commit execution. + +## Wave 3 decision acceptance — 2026-10-16 + +> Amended 2026-10-16: [ADR-0111](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md) is accepted by the maintainer. Finite bundled runtime chunks must retain one loaded authority-bearing shared module instance, including the private native-media iterator provenance WeakMap. Packed controls verify identity; splitting is not permission to duplicate ownership domains. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index 037d4af4..e6748767 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -1,6 +1,6 @@ # ADR-0110: CLI diagnostics and discovery have explicit compatibility -- **Status**: Proposed +- **Status**: Accepted - **Date**: 2026-10-15 - **Related**: [ADR-0049](0049-cli-machine-output-contract.md) (§5 infrastructure/pre-run exceptions and §6 explicit JSON-help partially replaced), [ADR-0047](0047-cli-framework-commander-ink-clack.md), [ADR-0056](0056-cli-in-app-slash-command-system-and-manifest.md), [ADR-0088](0088-the-mcp-boundary-is-hostile.md), [ADR-0103](0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md), [ADR-0111](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) @@ -184,3 +184,11 @@ changes, rather than claiming universal containment. or effectful work; a failed cleanup must not be reported acknowledged. - Windows subprocess admission refuses if its verified teardown facility is unavailable; POSIX MCP cleanup cannot promise reaping its descendants. + +## Maintainer acceptance — 2026-10-16 + +The maintainer approved this final decision, its linked unshipped canonical +contracts and the nineteen-increment Wave 3 plan. Earlier proposal wording above +records the review stage; approval is complete. Implementation, independent +review gates and canonical promotion remain staged in that plan. Acceptance +does not claim shipping behaviour, an actual release or production custody setup. diff --git a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md index c0792df4..eae7b282 100644 --- a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md +++ b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md @@ -1,6 +1,6 @@ # ADR-0111: CLI meta-operations use a lazy packaged runtime -- **Status**: Proposed +- **Status**: Accepted - **Date**: 2026-10-15 - **Related**: [ADR-0047](0047-cli-framework-commander-ink-clack.md), [ADR-0051](0051-cli-distribution-thin-bundle-private-engine.md) (single-output constraint partially replaced), [ADR-0105](0105-filesystem-roots-are-invocation-grants-not-project-authority.md), [ADR-0056](0056-cli-in-app-slash-command-system-and-manifest.md), [ADR-0067](0067-node-supported-floor-22-reaffirm-better-sqlite3.md), [ADR-0071](0071-models-dev-as-the-model-metadata-source.md), [ADR-0072](0072-model-metadata-in-the-db-behind-a-generated-offline-floor.md), [ADR-0103](0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md), [ADR-0109](0109-native-media-close-ownership-is-explicit-iterator-provenance.md), [ADR-0110](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) @@ -190,3 +190,11 @@ not by silently weakening a failed release gate. - Encrypted archive policy adds a release availability dependency despite public source. Explicit backup/rotation/retry and recovery rules bound that tradeoff; missing production custody blocks release, not document review. + +## Maintainer acceptance — 2026-10-16 + +The maintainer approved this final decision, its linked unshipped canonical +contracts and the nineteen-increment Wave 3 plan. Earlier proposal wording above +records the review stage; approval is complete. Implementation, independent +review gates and canonical promotion remain staged in that plan. Acceptance +does not claim shipping behaviour, an actual release or production custody setup. diff --git a/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md b/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md index 4e2803e8..e7922241 100644 --- a/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md +++ b/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md @@ -1,6 +1,6 @@ # ADR-0112: Chat network grants and onboarding state are operator-owned -- **Status**: Proposed +- **Status**: Accepted - **Date**: 2026-10-15 - **Related**: [ADR-0006](0006-os-keychain-for-api-keys.md), [ADR-0029](0029-tool-policy-hardening.md), [ADR-0033](0033-strict-config-files-amends-0023.md), [ADR-0037](0037-engine-tool-execution-boundary.md), [ADR-0054](0054-cli-bare-invocation-interactive-home.md), [ADR-0055](0055-cli-host-capability-seam-tool-environment-factory.md), [ADR-0057](0057-cli-chat-modes-and-per-tool-approval.md), [ADR-0059](0059-cli-mid-session-model-reseat.md), [ADR-0063](0063-cli-config-write-contract.md), [ADR-0105](0105-filesystem-roots-are-invocation-grants-not-project-authority.md), [ADR-0110](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) @@ -175,3 +175,11 @@ These choices do not establish a universal no-project-egress guarantee. Final-link refusal applies to every preference writer, including future settings. - Symlink writes require manual action or ephemeral continuation. Non-retryable diagnostics avoid a wizard loop; comments/order remain a known writer tradeoff. + +## Maintainer acceptance — 2026-10-16 + +The maintainer approved this final decision, its linked unshipped canonical +contracts and the nineteen-increment Wave 3 plan. Earlier proposal wording above +records the review stage; approval is complete. Implementation, independent +review gates and canonical promotion remain staged in that plan. Acceptance +does not claim shipping behaviour, an actual release or production custody setup. diff --git a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md index c9e1755e..52abb94f 100644 --- a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md +++ b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md @@ -1,6 +1,6 @@ # ADR-0113: Secret-derived context keeps provenance at public sinks -- **Status**: Proposed +- **Status**: Accepted - **Date**: 2026-10-15 - **Related**: [ADR-0027](0027-expression-sandbox.md), [ADR-0029](0029-tool-policy-hardening.md), [ADR-0037](0037-engine-tool-execution-boundary.md), [ADR-0038](0038-agentrunner-llm-call-boundary.md), [ADR-0050](0050-cli-history-db-at-rest-posture.md), [ADR-0083](0083-input-admission-and-a-resume-that-verifies-its-own-identity.md), [ADR-0093](0093-an-expression-sees-only-what-it-is-ordered-after.md), [ADR-0095](0095-what-an-agent-session-remembers-across-turns.md), [Wave 3](../roadmap/current.md#wave-3--clear-the-ground) @@ -208,3 +208,11 @@ benefit, without weakening raw-value exclusion. detection and legacy persisted data are explicitly outside the new guarantee. - Low-level embeddings migrate to a required owned projection and template/VM consumers. A trusted host's fabricated provenance remains outside the guarantee. + +## Maintainer acceptance — 2026-10-16 + +The maintainer approved this final decision, its linked unshipped canonical +contracts and the nineteen-increment Wave 3 plan. Earlier proposal wording above +records the review stage; approval is complete. Implementation, independent +review gates and canonical promotion remain staged in that plan. Acceptance +does not claim shipping behaviour, an actual release or production custody setup. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 2ee343ba..8b9136a2 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -90,11 +90,11 @@ flowchart TD | 0044 | [Media access & spend governance — `read_media` scope-set authz, the byte-delivery gate, the `save_to` write port, and the per-modality media cost (amends 0028 and 0029)](0044-media-access-governance-read-media-save-to-cost.md) | Accepted | 2026-06-18 | | 0045 | [Engine-owned async media-job loop (1.AG/A5) — the `generateMedia`/`pollMediaJob` poll/checkpoint/resume/cancel LRO, the `media_job:submitted` derived state, inline-vs-generative routing, and media-cost realization (amends 0031, 0036, 0040)](0045-async-media-job-loop-poll-checkpoint-resume-cancel.md) | Accepted | 2026-06-20 | | 0046 | [Inline media-out routes through `generate()`; the streaming media triad is host-deferred (amends 0031, 0038)](0046-inline-media-out-via-generate-streaming-triad-deferred.md) | Accepted | 2026-06-20 | -| 0047 | [CLI framework — `commander` + `ink` + `@clack/prompts`, confined to `apps/cli`](0047-cli-framework-commander-ink-clack.md) | Accepted | 2026-06-22 | +| 0047 | [CLI framework — `commander` + `ink` + `@clack/prompts`, confined to `apps/cli`](0047-cli-framework-commander-ink-clack.md) | Accepted (single-output constraint partially replaced by ADR-0111) | 2026-06-22 | | 0048 | [TOML parser for config files — `smol-toml`, confined to the `apps/cli` config loader](0048-toml-config-parser.md) | Accepted | 2026-06-22 | -| 0049 | [CLI machine-output contract (`--json` NDJSON stream + stderr diagnostics)](0049-cli-machine-output-contract.md) | Accepted | 2026-06-22 | +| 0049 | [CLI machine-output contract (`--json` NDJSON stream + stderr diagnostics)](0049-cli-machine-output-contract.md) | Accepted (§5 infrastructure/storage and §6 explicit JSON help partially replaced by ADR-0110) | 2026-06-22 | | 0050 | [CLI run-history `history.db` is unencrypted at rest, guarded by OS file permissions (refines 0005/0008 for the Node/CLI surface)](0050-cli-history-db-at-rest-posture.md) | Accepted | 2026-06-23 | -| 0051 | [CLI distribution — an engine-inlined ESM bundle that externalizes every third-party dependency (finalizes 0047's bundle boundary)](0051-cli-distribution-thin-bundle-private-engine.md) | Accepted | 2026-06-24 | +| 0051 | [CLI distribution — an engine-inlined ESM bundle that externalizes every third-party dependency (finalizes 0047's bundle boundary)](0051-cli-distribution-thin-bundle-private-engine.md) | Accepted (single-output constraint partially replaced by ADR-0111) | 2026-06-24 | | 0052 | [Inbound MCP client — the `@relavium/mcp` package boundary, host-injected connection lifecycle, host-side tool registration, a dependency-free schema→validator compiler, and the agent↔config reference linkage (implements 0034)](0052-inbound-mcp-client-package-lifecycle-registration.md) | Accepted | 2026-06-26 | | 0053 | [MCP network-transport (`sse`/`websocket`) egress security — SSRF enforcement on the one shared primitive, and the explicit per-server local-endpoint opt-in](0053-mcp-network-transport-egress-security.md) | Accepted | 2026-06-26 | | 0054 | [Bare `relavium` invocation opens an interactive Home (TTY only), preserving the meta-op contract](0054-cli-bare-invocation-interactive-home.md) | Accepted | 2026-06-29 | @@ -153,10 +153,10 @@ flowchart TD | 0107 | [First-run secret inputs use bounded stdin](0107-first-run-secret-inputs-use-bounded-stdin.md) | Accepted | 2026-10-12 | | 0108 | [Provider headers stay gated until keychain custody](0108-provider-headers-stay-gated-until-keychain-custody.md) | Accepted | 2026-10-12 | | 0109 | [Native media close ownership is explicit iterator provenance](0109-native-media-close-ownership-is-explicit-iterator-provenance.md) | Accepted | 2026-10-13 | -| 0110 | [CLI diagnostics and discovery have explicit compatibility](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md) | Proposed | 2026-10-15 | -| 0111 | [CLI meta-operations use a lazy packaged runtime](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md) | Proposed | 2026-10-15 | -| 0112 | [Chat network grants and onboarding state are operator-owned](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) | Proposed | 2026-10-15 | -| 0113 | [Secret-derived context keeps provenance at public sinks](0113-secret-derived-context-keeps-provenance-at-public-sinks.md) | Proposed | 2026-10-15 | +| 0110 | [CLI diagnostics and discovery have explicit compatibility](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md) | Accepted | 2026-10-15 | +| 0111 | [CLI meta-operations use a lazy packaged runtime](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md) | Accepted | 2026-10-15 | +| 0112 | [Chat network grants and onboarding state are operator-owned](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) | Accepted | 2026-10-15 | +| 0113 | [Secret-derived context keeps provenance at public sinks](0113-secret-derived-context-keeps-provenance-at-public-sinks.md) | Accepted | 2026-10-15 | ## Creating a new ADR diff --git a/docs/reference/cli/README.md b/docs/reference/cli/README.md index 9e6bc6aa..b663b04b 100644 --- a/docs/reference/cli/README.md +++ b/docs/reference/cli/README.md @@ -13,5 +13,5 @@ Part of [reference/](../README.md). | [commands.md](commands.md) | The full `relavium` command surface (run, chat, chat-resume/list/export, agent run, list, create, import, export, logs, gate, init, provider) plus interactive TUI vs CI JSON mode. | | [home.md](home.md) | The bare-invocation interactive **Home** — the TTY gate, the read-only management strip over `history.db`, the Home↔chat mode machine + keymap, bracketed paste, the min-terminal degrade, and the signal/exit-code lifecycle (2.5.B / ADR-0054). | | [chat-session.md](chat-session.md) | The `relavium chat` agent-session REPL — entry, agent/model selection, the multi-turn loop, streaming, tool availability, the `--json` session-event stream, and exit code 4. | -| [packaging.md](packaging.md) | Proposed Wave 3 finite runtime package, exact source maps, encrypted custody and release retry contract; unshipped pending approval. | +| [packaging.md](packaging.md) | Approved Wave 3 finite runtime package, exact source maps, encrypted custody and release retry design; unshipped pending implementation. | | [agent-run-fixture.md](agent-run-fixture.md) | The `relavium agent run --fixture` cassette format — a `StreamChunk[][]` JSON recording of an agent run's LLM stream, for deterministic offline replay. | diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 4b537b83..48a19895 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -647,8 +647,8 @@ Numeric cost totals are known realized sums. Human totals qualify unverified his ## Proposed Wave 3 CLI compatibility -**Proposed, unshipped — 2026-10-15.** [ADR-0110](../../decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md) -requires maintainer approval. This section owns the proposed exact contract; +**Approved design, unshipped — 2026-10-16.** [ADR-0110](../../decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md) +was approved by the maintainer. This section owns the approved exact contract; earlier sections describe current behaviour until implementation landing. ### Diagnostic policy diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index 251a6a21..c2db93e5 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -1,11 +1,11 @@ # CLI Packaging Contract -- **Status**: Proposed — unshipped, maintainer approval required +- **Status**: Approved design — unshipped; implementation staged in Wave 3 - **Date**: 2026-10-15 - **Related**: [ADR-0111](../../decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md), [commands.md](commands.md), [release runbook](../../runbooks/release-a-surface.md) -This is the proposed canonical artifact/custody contract. Current single-bundle -packaging remains governed by ADR-0047/0051 until approval and implementation. +This is the maintainer-approved canonical design contract (2026-10-16). Current +single-bundle packaging remains shipping until the implementing increments land. No release, production recipient setup or asset upload is performed by this document. ## Finite runtime package @@ -280,5 +280,5 @@ checks repeat exact-artifact evidence after later CLI/DB changes. At Step 9 landing update the release runbook's currently accurate single-bundle procedure to this graph, modes/recovery and symbolication; do not relabel historical single-bundle evidence as testing this design. Before whole-wave acceptance merge -this Proposed contract into implemented reference status and remove stale proposal +this approved design contract into implemented reference status and remove stale proposal banners only for actually accepted mechanisms. diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index cd23040b..8b324564 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -262,8 +262,8 @@ The workflow and agent files inside `.relavium/` carry their own `schema_version ## Proposed Wave 3 chat policy and preferences -**Proposed, unshipped — 2026-10-15.** [ADR-0112](../../decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) -requires maintainer approval. These additions do not change current schema until +**Approved design, unshipped — 2026-10-16.** [ADR-0112](../../decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) +was approved by the maintainer. These additions do not change current schema until implementation landing. - Global-only `[chat_http].allowed_domains: string[]`, optional, absent/empty deny-all. diff --git a/docs/reference/shared-core/agent-runner.md b/docs/reference/shared-core/agent-runner.md index 3283d53d..b50b9c8a 100644 --- a/docs/reference/shared-core/agent-runner.md +++ b/docs/reference/shared-core/agent-runner.md @@ -372,8 +372,8 @@ second synchronous cleanup error is not claimed for the current built-in governo ## Proposed Wave 3 qualified context consumers -**Proposed, unshipped — 2026-10-16.** [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md) -requires approval. Exact projection, VM/input compatibility, result transport and +**Approved design, unshipped — 2026-10-16.** [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md) +was approved by the maintainer. Exact projection, VM/input compatibility, result transport and finite refusal scope belong to the [expression specification](expression-sandbox-spec.md#proposed-wave-3-secret-context-boundary). diff --git a/docs/reference/shared-core/expression-sandbox-spec.md b/docs/reference/shared-core/expression-sandbox-spec.md index d03a5856..9948cb3e 100644 --- a/docs/reference/shared-core/expression-sandbox-spec.md +++ b/docs/reference/shared-core/expression-sandbox-spec.md @@ -259,8 +259,8 @@ error, cap-trip classification, determinism over shuffled `run.outputs` key orde ## Proposed Wave 3 secret context boundary -**Proposed, unshipped — 2026-10-16.** [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md) -requires maintainer approval, including deliberate replacement of direct secret-input +**Approved design, unshipped — 2026-10-16.** [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md) +was approved by the maintainer, including deliberate replacement of direct secret-input expression markers. Display/run-start/input-node markers remain unchanged. ### Owned provenance and consumers diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index 0e8b789c..5a57ec3d 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -501,9 +501,9 @@ tool to the catalog never widens what a given node may call. An MCP or media too ## Proposed Wave 3 HTTP authority and secret provenance -**Proposed, unshipped — 2026-10-15.** [ADR-0112](../../decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) +**Approved design, unshipped — 2026-10-16.** [ADR-0112](../../decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) and [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md) -require maintainer approval. +were approved by the maintainer. Implementation remains staged in the Wave 3 plan. HTTP policy compares canonical **hostname**, not URL host-with-port. Reuse the shared HTTPS authority parser and one pure DNS normalizer/comparator for both diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 453e33fb..327e92ef 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1106,21 +1106,18 @@ an ESLint selector rather than a list. ### Wave 3 — Clear the ground -**Revised proposal reviewed; maintainer approval pending — 2026-10-16.** -[Final rereview readiness and approval scope](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) -records two NEW independent complete cumulative clean reviews at 76065d2d across -all fourteen proposal paths, four full ADRs and nineteen increments, after every -material correction. Earlier nonclean/superseded verdicts remain history. The -source-corrected contracts cover real CLI/native ownership, exact encrypted -candidate recovery, live Home HTTP narrowing and finite secret-access semantics -with value/key/absence parity. ADR-0110–0113 remain **Proposed and unshipped**; -no product code has started. Approval of those ADRs/contracts/plan precedes Step 1. -Archive method is already selected; actual production recipient/recovery belongs -to Wave 4b release preparation. No provider capture, key, billing or upload is -required for this proposal review. +**Approved for implementation — 2026-10-16.** The maintainer approved +ADR-0110–0113, their linked unshipped contracts and the nineteen-increment plan. +The [approval checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-maintainer-approval-and-implementation-checkpoint--2026-10-16) +records completed decision gates and staged implementation. Step 1 is active; +no implementation acceptance is claimed. The +[final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) +remain proposal evidence, not implementation review. The selected encrypted archive +method persists; production recipient/recovery belongs to Wave 4b release setup. +No provider capture, credential, billing or upload is needed for this implementation. Decompose, guard and normalize every hot file a 2.6 workstream is about to land on. -**The multi-file queue is Step 1 and runs first.** The proposed +**The multi-file queue is Step 1 and runs first.** The approved [nineteen-increment execution plan](phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns the full order and review gates: finish each increment and its fresh reviews before advancing; no parallel edits of shared hot files. @@ -1145,7 +1142,7 @@ on `home-controller.ts`/`chat-ink.tsx` (the decomposition moves already-guarded - **`home-controller.ts` (strict)** — 2.5.5.E · `selectGatePrompter` test (G37) → the swallowed palette rejection (#281) → **2.5.5.I · decompose `createHomeController` + the overlay discriminated union** (#215, #243) → 2.5.5.G · the `Capability`/`Port` vocabulary split (#217, #222). - Settle #125/D26 before decomposition: the preflight proposes certifying the real + Settle #125/D26 before decomposition: the approved preflight certifies the planned scope of the real in-Home-chat cost path and retaining chat-only scope. If a new bare-Home cost surface is selected, wire its explicit session target before decomposition. - **`chat-ink.tsx`** — 1,906 lines at preflight (the original queue recorded 1,795), decomposed by **neither** phase doc. @@ -1299,10 +1296,10 @@ unanswered**; the remainder sit inline in their own phase-file bullet. | D15 | 2 | What masking guarantee does the session fixture (#115) certify? | **Reconciled 2026-10-12:** ADR-0095/0098 supply structural refusal, not universal masking. Certify the actual producer/SQLite/export fixtures and retained-content limits. This repairs the missing Wave 2 decision row; it is unrelated to historical media D15. | | D17 | 2 | Book security reviews **by threat class**, not per PR? | **Accepted 2026-10-13:** four new sittings (filesystem, secret input, secrets at rest, config trust), plus W4 hostile-MCP sitting/current regression revalidation. [Closing evidence](phases/phase-2.5.5-hardening-and-remediation.md#wave-2-closing-register--2026-10-13). | | D18 | 3 | `chat-ink.tsx` (1,795 lines, in neither doc): decompose or decline? | **Decline** — hand it to 2.6.M's render-v2 in Wave 5a, where the palette sweep then runs once | -| D19 | 3 | `--quiet`/`--verbose`/`RELAVIUM_DEBUG` precedence | **Proposed ADR-0110:** retain quiet+verbose conflict; either explicit flag overrides exact DEBUG=1. This replaces the earlier recommended precedence ladder; approval pending. JSON remains independent. | +| D19 | 3 | `--quiet`/`--verbose`/`RELAVIUM_DEBUG` precedence | **Approved ADR-0110, 2026-10-16:** quiet+verbose remains a conflict; either explicit flag overrides exact DEBUG=1. This replaces the old precedence ladder. JSON remains independent; implementation is staged. | | D20 | 3 | `--input`'s four-way collision: rename or document? | Rename `models pricing` to `--input-price`/`--output-price`, old spellings deprecated aliases | -| D22 | 3 | Read EXIT:4 as "no **breaking** change"? | **Proposed ADR-0110:** no unrecorded break; its enumerated compatibility scope replaces the old two-exception recommendation. Reconcile phase exit criterion 4 and E no-new-ADR wording at acceptance, with ADR-0049/0056 forward notes before code. Approval pending. | -| D26 | 3 | Which session would bare Home's `showCost` target? | **Proposed preflight disposition:** keep `/cost` chat-only; certify both live chat surfaces. Bare Home currently cannot invoke the no-op. Past-session selection remains 2.6.G; no implicit cost release. See the [Wave 3 plan](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-preflight-and-proposed-execution-plan--2026-10-15). | +| D22 | 3 | Read EXIT:4 as "no **breaking** change"? | **Approved ADR-0110, 2026-10-16:** its enumerated compatibility scope replaces the old two-exception recommendation. Phase exit criterion 4/E wording and ADR-0049/0056 forward notes are reconciled before dependent code. | +| D26 | 3 | Which session would bare Home's `showCost` target? | **Approved disposition, 2026-10-16:** keep `/cost` chat-only; certify both live chat surfaces. Bare Home currently cannot invoke the no-op. Past-session selection remains 2.6.G; no implicit cost release. See the [Wave 3 plan](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-preflight-and-proposed-execution-plan--2026-10-15). | | D28/D29 | 4a | Flip ADR-0058 and ADR-0060 to Accepted | Yes; 0060's taint model is the cited precedent for Wave 7's generation-security ADR | | D31 | 4a | Strike 2.6.H's chat-persister atomicity sub-part as a duplicate? | Yes — owned by Wave 1; record the closure | | D33 | 4b | Retention policy (the phase's largest single item) | Default bound 50 + `--limit`/`--tail`; ship `chat delete` writing the dead `deleted_at` columns; never auto-hard-delete | diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index f8a13c33..95963ecb 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -2168,3 +2168,14 @@ future test cannot silently re-acquire it. beyond the two genuine hits it already spun off (`isDispatchableId`, the `@relavium/llm/adapters` subpath export) — already tracked under the quality dead-code cleanup work; the remaining ~150 hits are confirmed false positives, not deferred work. *(polish · packages/db/src/media-write.ts; #245)* + +## Wave 3 approved later-wave ownership — 2026-10-16 + +- [ ] **Scheduled → Wave 5a: alt-screen word wrapping (#61).** The maintainer + approved retaining character-wrap replacement with render-v2/D18 and picker + positioning #60, so that renderer restructuring and the palette sweep occur + once. Wave 3 still owns grapheme/tab/resize/cache/editor bounds; this disposition + does not waive Phase 2.5.5.I's eventual word-wrap/full acceptance. The + [approved execution plan](phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) + and [Wave 5a owner](current.md#wave-5a--paint-before-you-build) retain the split; close only + after actual word-wrap implementation and regression evidence land. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 8175a8ae..d7a5b1e7 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -339,7 +339,7 @@ This workstream is `apps/cli`'s outermost layer — the process/runtime host eve - **Convert the three remaining bare `throw new Error()` sites to the project's typed `CliError`.** `apps/cli/src/chat/fixture.ts`'s cassette provider and `apps/cli/src/engine/host.ts`'s checkpointer wiring guard both throw a bare `Error` instead of `CliError`. `apps/cli/src/onboarding/wizard.ts`'s internal choice-validation guard does the same, contradicting its own docstring's claimed propagation contract, even though `CliError` is already imported in that file for a different branch. Convert all three in one mechanical pass. *(S · apps/cli/src/onboarding/wizard.ts · G2, #12, #54)* - **Generate and privately archive a per-release sourcemap so a `RELAVIUM_DEBUG` crash stack can be symbolicated.** `apps/cli/tsup.config.ts` correctly ships `sourcemap: false` per ADR-0051 (no original source in the published `dist/`), but no separate, privately-retained sourcemap exists anywhere in the release pipeline, so a `RELAVIUM_DEBUG` stack trace prints mangled minified names with no way to map them back. Generate a sourcemap at release-build time and archive it outside the published artifact. *(M · apps/cli/tsup.config.ts · #46)* -**Acceptance:** Ctrl-C during `agent run`/`chat` actually cancels the in-flight generation via `session.abort()` and tears down spawned MCP child processes the same way `run`'s driver already does; `gate.ts`'s two idempotent-resume success paths honor `--json`; `persister.ts`, the four picker switches, and `run-view-model.ts`'s `reduceRunEvent` all carry `const exhaustive: never` and `relavium run` renders `agent:reasoning` events; `openLocalDb` wraps and redacts at every call site, not just 3 of 8+; `/doctor` probes with the session's real profile; `--help --json` either emits a real `COMMAND_MANIFEST` or the docs no longer claim it does; `--version`/`--help` no longer pay the ~400ms `driveHome`/`seedCatalog()` tax; `resolve.ts` uses the same `* 4` UTF-8 byte multiplier as `authoring.ts`/`catalog.ts`; no command leaks an absolute filesystem path into an error message; the existing recoverable rejection net is preserved and the approved process-owner/fatal policy attempts bounded native cleanup without false DB/MCP close ACK or universal descendant-containment claims, `buildProgram()` is wrapped, EPIPE no longer dumps a raw stack, and the last-resort crash message honors `--json`; `provider remove-key` requires confirmation like its sibling destructive commands; and every closed test-coverage gap (`selectGatePrompter`, the verbose-stack branch, `seedRun`'s guard-rails, the cross-process skip) has a red-on-regression test. **Required ADR: none new — the `--quiet`/`--verbose`/`RELAVIUM_DEBUG` precedence decision and the `--input` namespace decision are maintainer calls recorded as amendments to existing CLI diagnostic/flag conventions, not new architectural decisions.** +**Acceptance:** Ctrl-C during `agent run`/`chat` actually cancels the in-flight generation via `session.abort()` and tears down spawned MCP child processes the same way `run`'s driver already does; `gate.ts`'s two idempotent-resume success paths honor `--json`; `persister.ts`, the four picker switches, and `run-view-model.ts`'s `reduceRunEvent` all carry `const exhaustive: never` and `relavium run` renders `agent:reasoning` events; `openLocalDb` wraps and redacts at every call site, not just 3 of 8+; `/doctor` probes with the session's real profile; `--help --json` either emits a real `COMMAND_MANIFEST` or the docs no longer claim it does; `--version`/`--help` no longer pay the ~400ms `driveHome`/`seedCatalog()` tax; `resolve.ts` uses the same `* 4` UTF-8 byte multiplier as `authoring.ts`/`catalog.ts`; no command leaks an absolute filesystem path into an error message; the existing recoverable rejection net is preserved and the approved process-owner/fatal policy attempts bounded native cleanup without false DB/MCP close ACK or universal descendant-containment claims, `buildProgram()` is wrapped, EPIPE no longer dumps a raw stack, and the last-resort crash message honors `--json`; `provider remove-key` requires confirmation like its sibling destructive commands; and every closed test-coverage gap (`selectGatePrompter`, the verbose-stack branch, `seedRun`'s guard-rails, the cross-process skip) has a red-on-regression test. **Required ADRs: ADR-0110–0113, accepted 2026-10-16.** Their explicit compatibility, packaging/custody, operator policy and provenance decisions replace the old no-new-ADR assumption; implementation remains staged in the approved Wave 3 plan. ### 2.5.5.F — Documentation & roadmap accuracy @@ -523,8 +523,10 @@ directly as a 2.6.A work item). **Intra-phase:** the five file overlaps recorded config-layer secret-hijack gap are closed or explicitly, permanently documented as accepted residuals. 4. `pnpm turbo run lint typecheck test` stays green throughout, with no widening of the - `LLMProvider` seam, no new platform import into `packages/core`, and no change to the - `--json`/CI machine-output contract. + `LLMProvider` seam or new platform import into `packages/core`. Machine-output + compatibility changes are limited to the explicitly approved ADR-0110 scope + (diagnostics/exits, JSON discovery, aliases and consent); existing run-event + stdout and actual workflow-outcome contracts remain accepted. 5. All items in a given sub-stream's `**Tasks:**` list are either shipped or explicitly re-deferred with a recorded reason (routed to Phase 2.6, `deferred-tasks.md`, or declined with rationale in the traceability appendix). @@ -4110,3 +4112,25 @@ clause-specific Accepted-ADR notes before dependent code, then start Step 1 and its implementation/commit/two-new-clean-review loop. Later increments advance automatically after their gates. Whole-wave acceptance and PR remain Step 19; full I/whole phases and existing approved later-wave scope are not closed here. + +### Wave 3 maintainer approval and implementation checkpoint — 2026-10-16 + +The maintainer explicitly approved final ADR-0110–0113, their linked unshipped +canonical contracts and all nineteen increments. Their statuses/index are Accepted; +clause-specific partial replacements and forward notes were recorded before +dependent code. ADR-0047/0051 retain framework, licensing/private-engine and +no-public-map rules outside the replaced single-output constraint; ADR-0049 +retains actual outcomes/stdout outside the enumerated §5/§6 changes. D19/D22, +E's old no-new-ADR assertion and exit criterion 4 are reconciled. + +Step 1 is active on `development`; no product implementation or implementation +review acceptance is claimed by this checkpoint. Every increment still requires +author gates, commits and two NEW independent complete cumulative clean reviews +after its last material fix. Later increments advance automatically after those +gates. The previously recorded proposal reviews remain proposal-only evidence. + +The approved #61 character-wrap disposition is now linked in +[deferred-tasks.md](../deferred-tasks.md#wave-3-approved-later-wave-ownership--2026-10-16): +Wave 5a owns it alongside D18/#60; full I acceptance is not waived. Production +recipient/recovery setup remains a Wave 4b actual-release prerequisite. No ADR +approval, provider capture, key, billing or upload is pending for Wave 3. From c610a4ad4947aca122cd4f0df807166735df67b3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 01:57:06 +0300 Subject: [PATCH 017/222] fix(cli): complete the wave 3 serialized reliability queue Share safe owned cleanup without masking primary outcomes, reuse guarded source labels and align UTF-8 prechecks with the parser character ceiling. Make session and picker event handling exhaustive and render bounded live reasoning separately from durable answer text. Preserve host lifetime joins. Full workspace lint/typecheck/test and build pass. Native close regressions exercise real SQLite; four removal controls break the intended regressions and exact restoration is followed by the green complete author suite. Independent complete cumulative review remains pending. Refs: Phase 2.5.5 Wave 3 Step 1, ADR-0103 Co-Authored-By: Codex --- apps/cli/src/chat/agent-source.test.ts | 17 +- apps/cli/src/chat/export.test.ts | 27 +- apps/cli/src/chat/export.ts | 17 +- apps/cli/src/chat/persister.test.ts | 36 ++ apps/cli/src/chat/persister.ts | 19 +- apps/cli/src/commands/agent-run.ts | 5 +- apps/cli/src/commands/chat-export.ts | 9 +- apps/cli/src/commands/chat-list.ts | 3 +- apps/cli/src/commands/chat.ts | 25 +- apps/cli/src/commands/gate.ts | 7 +- apps/cli/src/commands/run.ts | 5 +- apps/cli/src/commands/safe-close.test.ts | 368 ++++++++++++++++++ apps/cli/src/engine/pricing-overlay.test.ts | 5 +- apps/cli/src/engine/pricing-overlay.ts | 9 +- apps/cli/src/home/drive-home.test.ts | 51 ++- apps/cli/src/home/drive-home.tsx | 11 +- apps/cli/src/process/close-quietly.test.ts | 84 ++++ apps/cli/src/process/close-quietly.ts | 26 ++ apps/cli/src/render/tui/RunApp.test.tsx | 30 +- apps/cli/src/render/tui/RunApp.tsx | 17 + apps/cli/src/render/tui/chat-ink.tsx | 8 + apps/cli/src/render/tui/home-controller.ts | 8 + .../cli/src/render/tui/run-view-model.test.ts | 100 +++++ apps/cli/src/render/tui/run-view-model.ts | 40 +- apps/cli/src/workflows/catalog.ts | 5 +- apps/cli/src/workflows/resolve.test.ts | 97 ++++- apps/cli/src/workflows/resolve.ts | 32 +- docs/reference/cli/commands.md | 2 +- docs/roadmap/current.md | 2 +- .../phase-2.5.5-hardening-and-remediation.md | 38 +- packages/shared/src/run-event.ts | 4 +- 31 files changed, 1014 insertions(+), 93 deletions(-) create mode 100644 apps/cli/src/commands/safe-close.test.ts create mode 100644 apps/cli/src/process/close-quietly.test.ts create mode 100644 apps/cli/src/process/close-quietly.ts diff --git a/apps/cli/src/chat/agent-source.test.ts b/apps/cli/src/chat/agent-source.test.ts index 52866c23..b19a89dd 100644 --- a/apps/cli/src/chat/agent-source.test.ts +++ b/apps/cli/src/chat/agent-source.test.ts @@ -163,9 +163,24 @@ describe('resolveChatAgent', () => { } }); + it('accepts dense multibyte source through the actual chat-agent resolver', () => { + const path = join(dir, 'dense.agent.yaml'); + const yaml = `${AGENT_YAML}\n#${'文'.repeat(Math.floor(MAX_SOURCE_CHARS / 2))}\n`; + expect(Buffer.byteLength(yaml)).toBeGreaterThan(MAX_SOURCE_CHARS); + expect(yaml.length).toBeLessThan(MAX_SOURCE_CHARS); + writeFileSync(path, yaml); + const agent = resolveChatAgent(path, { + cwd: dir, + projectConfigDir: undefined, + defaultModel: undefined, + }); + expect(agent.id).toBe('coder'); + expect(agent.system_prompt).toBe('You are a focused coding assistant.'); + }); + it('rejects an oversized .agent.yaml before reading it (the agent kind of the shared size cap)', () => { const path = join(dir, 'huge.agent.yaml'); - writeFileSync(path, 'a'.repeat(MAX_SOURCE_CHARS + 1)); // one byte over the shared cap + writeFileSync(path, 'a'.repeat(MAX_SOURCE_CHARS * 4 + 1)); // one byte over the UTF-8 precheck let caught: unknown; try { resolveChatAgent(path, { cwd: dir, projectConfigDir: undefined, defaultModel: undefined }); diff --git a/apps/cli/src/chat/export.test.ts b/apps/cli/src/chat/export.test.ts index a78da30b..b301159a 100644 --- a/apps/cli/src/chat/export.test.ts +++ b/apps/cli/src/chat/export.test.ts @@ -5,11 +5,16 @@ import { join } from 'node:path'; import { parseWorkflow } from '@relavium/core'; import { createClient, createSessionStore, runMigrations, type DbClient } from '@relavium/db'; import { AgentSchema, type AgentSessionRecord, type SessionMessage } from '@relavium/shared'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { isCliError } from '../process/errors.js'; import { exportSession } from './export.js'; +vi.mock('node:fs', async (original) => { + const native = await original(); + return { ...native, writeFileSync: vi.fn(native.writeFileSync) }; +}); + const ISO = '2026-06-25T00:00:00.000Z'; const CTX = { workingDir: '/workspace', fsScopeTier: 'sandboxed' as const }; const AGENT = AgentSchema.parse({ @@ -52,6 +57,7 @@ describe('exportSession (2.P)', () => { let cwd: string; beforeEach(() => { + vi.clearAllMocks(); client = createClient(':memory:'); runMigrations(client.db); store = createSessionStore(client.db); @@ -209,4 +215,23 @@ describe('exportSession (2.P)', () => { expect(yaml).toContain('type: output'); expect(yaml).not.toContain('type: agent'); // no completed turn ⇒ no agent node }); + it('wraps native write failure behind a safe destination label and preserves cause', () => { + store.createSession(record()); + const cause = new Error(`private native destination ${cwd}`); + vi.mocked(writeFileSync).mockImplementationOnce(() => { + throw cause; + }); + try { + exportSession({ store, sessionId: 's1', cwd, outPath: 'exports/fault.yaml', force: false }); + expect.fail('native write failure must refuse'); + } catch (error) { + expect(isCliError(error)).toBe(true); + if (!isCliError(error)) throw error; + expect(error.code).toBe('internal'); + expect(error.cause).toBe(cause); + expect(error.message).toContain(join('exports', 'fault.yaml')); + expect(error.message).not.toContain(cwd); + expect(error.message).not.toContain('private native'); + } + }); }); diff --git a/apps/cli/src/chat/export.ts b/apps/cli/src/chat/export.ts index 199739bc..be41ed27 100644 --- a/apps/cli/src/chat/export.ts +++ b/apps/cli/src/chat/export.ts @@ -6,6 +6,7 @@ import type { SessionStore } from '@relavium/db'; import type { AgentSessionRecord } from '@relavium/shared'; import { CliError } from '../process/errors.js'; +import { sourceLabel } from '../process/source-label.js'; /** * Session → workflow export (2.P / [ADR-0026](../../../../docs/decisions/0026-session-export-to-workflow.md)) — @@ -72,6 +73,7 @@ export function exportSession(opts: ExportSessionOptions): ExportResult { opts.outPath === undefined ? join(opts.cwd, `${opts.sessionId}.relavium.yaml`) : resolve(opts.cwd, opts.outPath); + const label = sourceLabel(opts.cwd, path); try { // Create the parent directory (so `--out exports/wf.yaml` just works) before writing the scaffold. mkdirSync(dirname(path), { recursive: true }); @@ -79,26 +81,29 @@ export function exportSession(opts: ExportSessionOptions): ExportResult { // separate existence check and the write; `w` truncates/overwrites under `--force`. writeFileSync(path, yaml, { encoding: 'utf8', flag: opts.force ? 'w' : 'wx' }); } catch (err) { - const code = - err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : undefined; + const code = typeof err === 'object' && err !== null && 'code' in err ? err.code : undefined; // An existing target without `--force` (EEXIST) is the no-overwrite fault; a structurally-invalid // `--out` (a directory → EISDIR; a file used as a dir → ENOTDIR) is also an INVOCATION fault (exit 2). - // Other write faults (permissions, disk) propagate. + // Other native write faults retain their cause behind a fixed safe diagnostic. if (code === 'EEXIST') { throw new CliError( 'invalid_invocation', - `${path} already exists — pass --force to overwrite`, + `${label} already exists — pass --force to overwrite`, { cause: err }, ); } if (code === 'EISDIR' || code === 'ENOTDIR') { throw new CliError( 'invalid_invocation', - `cannot write ${path}: the target path is not a file`, + `cannot write ${label}: the target path is not a file`, { cause: err }, ); } - throw err; + throw new CliError( + 'internal', + `could not write ${label}: check destination permissions and available disk space`, + { cause: err }, + ); } // The session is append-only; the next event/seq is one past the durable MAX (a fold, not a spread). diff --git a/apps/cli/src/chat/persister.test.ts b/apps/cli/src/chat/persister.test.ts index e4686c2f..e7e064f0 100644 --- a/apps/cli/src/chat/persister.test.ts +++ b/apps/cli/src/chat/persister.test.ts @@ -114,6 +114,42 @@ describe('createSessionPersister', () => { return { built, persister, store, usageStore }; } + it('keeps reasoning live-only while durably recording the completed answer', async () => { + const privateThinking = 'live reasoning sentinel'; + const { built, persister } = await setup( + scriptedResolver([ + [ + { type: 'reasoning_delta', id: 'thinking-1', text: privateThinking }, + { type: 'text_delta', text: 'durable answer' }, + stop(), + ], + ]), + ); + const observed: SessionStreamHandleEvent[] = []; + const off = built.handle.subscribe((event) => observed.push(event)); + try { + persister.start(); + built.session.start(); + persister.beginUserTurn('hello'); + await built.session.sendMessage('hello'); + expect( + observed.some( + (event) => event.type === 'agent:reasoning' && event.text === privateThinking, + ), + ).toBe(true); + const loaded = store.loadMessages(built.sessionId); + expect(store.loadSession(built.sessionId)).toBeDefined(); + expect(loaded.map((message) => textOf(message.content))).toEqual(['hello', 'durable answer']); + expect(JSON.stringify(loaded)).not.toContain(privateThinking); + expect(listenerNotes).toEqual([]); + } finally { + built.session.cancel(); + await built.whenHostSafeToClose(); + persister.close(); + off(); + } + }); + it('publishes typed session usage events only after their native SQLite coverage ACK', async () => { const { built, persister } = await setup( scriptedResolver([ diff --git a/apps/cli/src/chat/persister.ts b/apps/cli/src/chat/persister.ts index 5332c33a..765d37ee 100644 --- a/apps/cli/src/chat/persister.ts +++ b/apps/cli/src/chat/persister.ts @@ -478,8 +478,25 @@ export function createSessionPersister(deps: SessionPersisterDeps): SessionPersi unsubscribe = undefined; } return; - default: + // Lifecycle/display-only events have no transcript or money write here. Coverage has its + // acknowledged writer; reasoning remains live-only, never persisted as assistant text. + case 'session:started': + case 'session:turn_started': + case 'session:exported': + case 'session:compacting': + case 'session:compaction_failed': + case 'session:compaction_budget_refused': + case 'agent:reasoning': + case 'agent:tool_result': + case 'agent:approval_requested': + case 'cost:usage_pending': + case 'cost:usage_disposition': + case 'budget:estimate_committed': return; + default: { + const exhaustive: never = event; + return exhaustive; + } } }; diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index 05966cce..ed2e4e86 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -1,3 +1,4 @@ +import { closeQuietly } from '../process/close-quietly.js'; import { randomUUID } from 'node:crypto'; import { StringDecoder } from 'node:string_decoder'; @@ -110,7 +111,7 @@ export async function agentRunCommand( // The ADR-0065 §2 user-pricing overlay (2.5.G S10) — so a one-shot live turn enforces + tracks a user-priced // model. SKIPPED under `--fixture`: cassette pricing stays deterministic and fully offline. Usage coverage // and realized attribution still use the local database, without consulting the user pricing overlay. - const resolvePrice = offline ? undefined : loadUserPricingOverlay(homeDir); + const resolvePrice = offline ? undefined : loadUserPricingOverlay(homeDir, deps.io); // An unknown `` (path or id) throws a typed CliError here (exit 2), before any turn. The build is // async (2.R): it connects the agent's inline stdio `mcp_servers` (a connect failure is a fail-loud exit-2 @@ -355,7 +356,7 @@ export async function agentRunCommand( for (const receipt of receipts.values()) usageStore.retire(receipt); receipts.clear(); } finally { - journalStore.close(); + closeQuietly(deps.io, 'effect journal', () => journalStore.close()); } } } diff --git a/apps/cli/src/commands/chat-export.ts b/apps/cli/src/commands/chat-export.ts index f3ef52e3..5a2f94af 100644 --- a/apps/cli/src/commands/chat-export.ts +++ b/apps/cli/src/commands/chat-export.ts @@ -1,3 +1,4 @@ +import { closeQuietly } from '../process/close-quietly.js'; import { SessionExportedEventSchema } from '@relavium/shared'; import { exportSession, SESSION_EXPORT_REVIEW_NOTICE } from '../chat/export.js'; @@ -75,10 +76,8 @@ export function chatExportCommand( exportedWorkflowPath: result.path, updatedAt: new Date(now()).toISOString(), }); - } catch (err) { - deps.io.writeErr( - `note: scaffold written but could not mark the session exported: ${err instanceof Error ? err.message : String(err)}\n`, - ); + } catch { + deps.io.writeErr('note: scaffold written but could not mark the session exported\n'); } if (deps.global.json) { @@ -96,6 +95,6 @@ export function chatExportCommand( } return EXIT_CODES.success; } finally { - opened.close(); + closeQuietly(deps.io, 'session store', () => opened.close()); } } diff --git a/apps/cli/src/commands/chat-list.ts b/apps/cli/src/commands/chat-list.ts index 7e24b1e2..ade3c912 100644 --- a/apps/cli/src/commands/chat-list.ts +++ b/apps/cli/src/commands/chat-list.ts @@ -1,3 +1,4 @@ +import { closeQuietly } from '../process/close-quietly.js'; import type { AgentSessionRecord } from '@relavium/shared'; import { loadResolvedConfig } from '../config/load.js'; @@ -54,7 +55,7 @@ export function chatListCommand(deps: ChatListCommandDeps): ExitCode { } return EXIT_CODES.success; } finally { - opened.close(); + closeQuietly(deps.io, 'session store', () => opened.close()); } } diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 7a6bf108..2171d623 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -83,6 +83,7 @@ import { createProviderResolver, type ProviderResolver } from '../engine/provide import { openSessionStore, type OpenedSessionStore } from '../history/session-open.js'; import { CliError } from '../process/errors.js'; import type { CliIo } from '../process/io.js'; +import { closeQuietly, warnTeardown } from '../process/close-quietly.js'; import type { GlobalOptions } from '../process/options.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import { detectOutputMode, isCiEnv } from '../process/output-mode.js'; @@ -144,24 +145,6 @@ export interface ChatCommandArgs { readonly agent: string | undefined; } -/** Surface a teardown failure as a stderr warning (never silently swallowed, never thrown — the primary - * command outcome must survive a cleanup fault). Shared by the chat command + resume teardown paths. */ -function warnTeardown(io: CliIo, label: string, err: unknown): void { - io.writeErr( - `warning: ${label} teardown failed: ${err instanceof Error ? err.message : String(err)}\n`, - ); -} - -/** Best-effort SYNC close: run it, but a reject/throw only warns — so one resource's failure never skips the - * next teardown step nor masks the primary outcome (attempt-all, preserve-primary). */ -function closeQuietly(io: CliIo, label: string, close: () => void): void { - try { - close(); - } catch (err) { - warnTeardown(io, label, err); - } -} - /** * A mid-session model-switch target ([ADR-0059](../../../../docs/decisions/0059-cli-mid-session-model-reseat.md)): * the picked model + its provider. The reseat rebuilds the session bound to this model (dropping the original @@ -581,7 +564,7 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): // The ADR-0065 §2 user-pricing overlay (2.5.G S10) — a transient read of the `model_catalog` `source='user'` // rows, so a user-priced model is enforced by `[chat].max_cost_microcents` + tracked in realized cost. NON-FATAL: // an unopenable db yields `undefined` here and surfaces cleanly through the session store open below. - const resolvePrice = loadUserPricingOverlay(homeDir); + const resolvePrice = loadUserPricingOverlay(homeDir, deps.io); // An unknown --agent / un-inferrable default model throws a typed CliError here (exit 2), before any session. // The build is async (2.R): it connects the agent's inline stdio `mcp_servers` (a connect failure is a @@ -898,7 +881,7 @@ export async function chatResumeCommand( // A pre-loop fault (not-found, no snapshot, build failure, or a post-build setup throw) must not strand the // open db handle NOR the spawned MCP children — tear BOTH down (a reject in one must not skip the other), and // never let a cleanup fault mask the primary error (best-effort; closeMcp is idempotent + a no-op when unset). - await closeMcp?.().catch((e: unknown) => warnTeardown(deps.io, 'MCP', e)); + await closeMcp?.().catch(() => warnTeardown(deps.io, 'MCP')); closeQuietly(deps.io, 'session store', () => opened.close()); throw err; } @@ -2266,7 +2249,7 @@ async function driveOneSession(wiring: ReplWiring, deps: ChatReplDeps): Promise< // The lifetime join finishes before its durability verdict can reject. A failed final write // must still release the now-quiescent persister and MCP clients. closeQuietly(deps.io, 'persister', () => persister.close()); - await built.closeMcp?.().catch((e: unknown) => warnTeardown(deps.io, 'MCP', e)); + await built.closeMcp?.().catch(() => warnTeardown(deps.io, 'MCP')); } } } diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index 33da0b3d..93fa2d05 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -1,3 +1,4 @@ +import { closeQuietly, warnTeardown } from '../process/close-quietly.js'; import { EgressWorkScope } from '../engine/egress-work.js'; import { randomUUID } from 'node:crypto'; import { statSync } from 'node:fs'; @@ -611,13 +612,11 @@ async function resumeGateCommand( try { try { // Cleanup must not replace a primary refusal or a durably completed command result. - await mcpRuntime?.client.close().catch(() => { - deps.io.writeErr('warning: the MCP client could not close cleanly\n'); - }); + await mcpRuntime?.client.close().catch(() => warnTeardown(deps.io, 'MCP')); } finally { mcpWork.seal(); await mcpWork.done; - opened.close(); + closeQuietly(deps.io, 'history database', () => opened.close()); } } finally { unguardMcp(); diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 62fa3b25..4aaf98c1 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -1,3 +1,4 @@ +import { closeQuietly, warnTeardown } from '../process/close-quietly.js'; import { EgressWorkScope } from '../engine/egress-work.js'; import { randomUUID } from 'node:crypto'; @@ -511,11 +512,11 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr // outcome (closeAll swallows per-connection; the db close is best-effort here too). try { try { - await mcpRuntime?.client.close(); + await mcpRuntime?.client.close().catch(() => warnTeardown(deps.io, 'MCP')); } finally { mcpWork.seal(); await mcpWork.done; - opened?.close(); + closeQuietly(deps.io, 'history database', () => opened?.close()); } } finally { // **Released LAST, after the MCP close has finished** — and it was released first, which left the one diff --git a/apps/cli/src/commands/safe-close.test.ts b/apps/cli/src/commands/safe-close.test.ts new file mode 100644 index 00000000..65d20f15 --- /dev/null +++ b/apps/cli/src/commands/safe-close.test.ts @@ -0,0 +1,368 @@ +import { randomUUID } from 'node:crypto'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; + +import { + createClient, + createModelCatalogStore, + createProviderStore, + createRunHistoryStore, + createSessionStore, + runMigrations, + type DbClient, +} from '@relavium/db'; +import { clearCatalogRefresh } from '@relavium/llm'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { OpenedSessionStore } from '../history/session-open.js'; +import * as sessionOpen from '../history/session-open.js'; +import * as dbOpen from '../db/open.js'; +import { loadUserPricingOverlay } from '../engine/pricing-overlay.js'; +import { buildChatSession } from '../chat/session-host.js'; +import { scriptedResolver, textTurn } from '../chat/test-support.js'; +import { EXIT_CODES } from '../process/exit-codes.js'; +import type { GlobalOptions } from '../process/options.js'; +import { captureIo, parseNdjson } from '../test-support.js'; +import { chatExportCommand } from './chat-export.js'; +import { agentRunCommand } from './agent-run.js'; +import { chatListCommand } from './chat-list.js'; +import { gateCommand } from './gate.js'; +import { runCommand, type RunCommandDeps } from './run.js'; + +const YAML = `schema_version: '1.0' +workflow: + id: safe-close + nodes: + - { id: start, type: input } + - { id: out, type: output } + edges: + - { from: start, to: out } +`; +const CLOSE_FAILURE = new Error( + 'SQLITE_IOERR_CLOSE /private/operator/history.db secret-token\x1b[2J', +); + +describe('command teardown preserves primary outcomes (#22)', () => { + let client: DbClient; + let cwd: string; + let opened: OpenedSessionStore; + let close: ReturnType; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'relavium-safe-close-')); + const home = join(cwd, 'home'); + mkdirSync(home); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); + client = createClient(':memory:'); + runMigrations(client.db); + close = vi.fn(() => { + client.sqlite.close(); + throw CLOSE_FAILURE; + }); + opened = { store: createSessionStore(client.db), db: client.db, close }; + writeFileSync(join(cwd, 'flow.yaml'), YAML); + }); + + afterEach(() => { + clearCatalogRefresh(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + if (client.sqlite.open) client.sqlite.close(); + rmSync(cwd, { recursive: true, force: true }); + }); + + function global(json = false): GlobalOptions { + return { json, color: false, cwd, configPath: undefined, verbosity: 'normal' }; + } + + function assertClosed(warning: string): void { + expect(close).toHaveBeenCalledTimes(1); + expect(client.sqlite.open).toBe(false); + expect(warning).not.toContain('secret-token'); + expect(warning).not.toContain('/private/operator'); + expect(warning).not.toContain('\x1b'); + } + + function seedSession(): void { + opened.store.createSession({ + id: 'session-safe-close', + agentSlug: 'chatter', + context: { workingDir: cwd, fsScopeTier: 'sandboxed' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: '2026-10-16T00:00:00.000Z', + updatedAt: '2026-10-16T00:00:00.000Z', + }); + } + + function historyOpener(): NonNullable { + return (definition) => ({ + db: client.db, + close, + terminalOutboxPath: join(cwd, 'outbox.ndjson'), + store: createRunHistoryStore(client.db, { + uuid: randomUUID, + now: Date.now, + workflow: { + slug: definition.workflow.id, + name: definition.workflow.id, + definitionJson: JSON.stringify(definition), + }, + }), + }); + } + + it('retains a real pricing overlay after its transient native database close throws', () => { + const storeDeps = { uuid: randomUUID, now: Date.now }; + const provider = createProviderStore(client.db, storeDeps).upsert({ + name: 'openai', + displayName: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + }); + createModelCatalogStore(client.db, storeDeps).upsert({ + providerId: provider.id, + modelId: 'safe-close-custom', + displayName: 'Safe close custom', + contextWindowTokens: 32_000, + maxOutputTokens: 4_000, + source: 'user', + inputCostPerMtokMicrocents: 300_000_000, + outputCostPerMtokMicrocents: 900_000_000, + }); + vi.spyOn(dbOpen, 'openLocalDb').mockReturnValue({ db: client.db, close }); + const { io, out, err } = captureIo(); + const overlay = loadUserPricingOverlay(join(cwd, 'home'), io); + expect(overlay?.get('safe-close-custom')).toMatchObject({ + provider: 'openai', + inputPerMtokMicrocents: 300_000_000, + outputPerMtokMicrocents: 900_000_000, + }); + expect(out()).toBe(''); + expect(err()).toBe('warning: history database teardown failed\n'); + assertClosed(err()); + }); + + it.each([false, true])('chat-list retains success and clean stdout (json=%s)', (json) => { + const { io, out, err } = captureIo(); + expect(chatListCommand({ io, global: global(json), openSessionStore: () => opened })).toBe( + EXIT_CODES.success, + ); + expect(out()).toBe(json ? '' : 'No agent sessions yet.\n'); + expect(err()).toBe('warning: session store teardown failed\n'); + assertClosed(err()); + }); + + it.each([false, true])( + 'chat-list preserves a store failure identity even with a failed warning sink (%s)', + (brokenSink) => { + const primary = new Error('store read failed'); + vi.spyOn(opened.store, 'listSessions').mockImplementation(() => { + throw primary; + }); + const captured = captureIo(); + const io = brokenSink + ? { + ...captured.io, + writeErr: () => { + throw new Error('stderr closed'); + }, + } + : captured.io; + let caught: unknown; + try { + chatListCommand({ io, global: global(), openSessionStore: () => opened }); + } catch (failure) { + caught = failure; + } + expect(caught).toBe(primary); + expect(captured.out()).toBe(''); + assertClosed(captured.err()); + }, + ); + + it.each([false, true])( + 'chat-export retains the written artifact and success (json=%s)', + (json) => { + seedSession(); + const { io, out, err } = captureIo(); + expect( + chatExportCommand( + { sessionId: 'session-safe-close', force: false }, + { io, global: global(json), openSessionStore: () => opened }, + ), + ).toBe(EXIT_CODES.success); + expect(readFileSync(join(cwd, 'session-safe-close.relavium.yaml'), 'utf8')).toContain( + 'schema_version:', + ); + if (json) + expect(parseNdjson(out())).toEqual([expect.objectContaining({ type: 'session:exported' })]); + expect(out()).not.toContain('teardown'); + expect(err()).toContain('warning: session store teardown failed\n'); + assertClosed(err()); + }, + ); + + it('chat-export preserves the original load failure identity', () => { + const primary = new Error('session load failed'); + vi.spyOn(opened.store, 'loadFull').mockImplementation(() => { + throw primary; + }); + const { io, out, err } = captureIo(); + let caught: unknown; + try { + chatExportCommand( + { sessionId: 'session-safe-close', force: false }, + { io, global: global(true), openSessionStore: () => opened }, + ); + } catch (failure) { + caught = failure; + } + expect(caught).toBe(primary); + expect(out()).toBe(''); + assertClosed(err()); + }); + + it('run retains a real durable successful result after native close throws', async () => { + const { io, out, err } = captureIo(); + expect( + await runCommand( + { workflow: 'flow.yaml', input: [], allowMcpStdio: [] }, + { + io, + global: global(true), + openRunStore: historyOpener(), + sweepMedia: () => Promise.resolve(undefined), + }, + ), + ).toBe(EXIT_CODES.success); + expect(parseNdjson(out())).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'run:completed' })]), + ); + expect(out()).not.toContain('teardown'); + expect(err()).toContain('warning: history database teardown failed\n'); + assertClosed(err()); + }); + + it('run preserves an engine-build failure identity even when close and its warning sink fail', async () => { + const primary = new Error('engine build failed'); + const captured = captureIo(); + const io = { + ...captured.io, + writeErr: () => { + throw new Error('stderr closed'); + }, + }; + await expect( + runCommand( + { workflow: 'flow.yaml', input: [], allowMcpStdio: [] }, + { + io, + global: global(true), + openRunStore: historyOpener(), + buildEngine: () => Promise.reject(primary), + }, + ), + ).rejects.toBe(primary); + assertClosed(captured.err()); + }); + + it('gate preserves a missing-run invocation refusal after native close throws', async () => { + const { io, out, err } = captureIo(); + await expect( + gateCommand( + { runId: 'absent', approve: true }, + { io, global: global(true), openDb: () => ({ db: client.db, close }) }, + ), + ).rejects.toMatchObject({ code: 'invalid_invocation', message: 'no run found with id absent' }); + expect(out()).toBe(''); + expect(err()).toBe('warning: history database teardown failed\n'); + assertClosed(err()); + }); + + it('gate retains idempotent success for a genuinely completed run after native close throws', async () => { + const first = captureIo(); + expect( + await runCommand( + { workflow: 'flow.yaml', input: [], allowMcpStdio: [] }, + { + io: first.io, + global: global(true), + sweepMedia: () => Promise.resolve(undefined), + openRunStore: (definition, home, root) => ({ + ...historyOpener()(definition, home, root), + close: () => {}, + }), + }, + ), + ).toBe(EXIT_CODES.success); + const started = parseNdjson<{ type: string; runId: string }>(first.out()).find( + (event) => event.type === 'run:started', + ); + if (started === undefined) throw new Error('expected durable run identity'); + const { io, out, err } = captureIo(); + expect( + await gateCommand( + { runId: started.runId, approve: true }, + { io, global: global(true), openDb: () => ({ db: client.db, close }) }, + ), + ).toBe(EXIT_CODES.success); + expect(out()).toBe(''); + expect(err()).toContain('warning: history database teardown failed\n'); + assertClosed(err()); + }); + + it.each([false, true])( + 'agent-run joins host work before closing its journal and preserves its outcome (join verdict fails=%s)', + async (verdictFails) => { + const primary = new Error('final money publication failed'); + let joined = false; + const nativeClose = close; + close = vi.fn(() => { + expect(joined).toBe(true); + nativeClose(); + }); + opened = { ...opened, close }; + vi.spyOn(sessionOpen, 'openSessionStore').mockReturnValue(opened); + writeFileSync( + join(cwd, 'agent.yaml'), + 'id: chatter\nprovider: anthropic\nmodel: claude-sonnet-4-6\nsystem_prompt: Be concise.\n', + ); + const { io, out, err } = captureIo(); + const command = agentRunCommand( + { agent: 'agent.yaml', input: [], allowMcpStdio: [] }, + { + io: { ...io, stdin: Readable.from(['hello']) }, + global: global(true), + providers: scriptedResolver([textTurn('done')]), + buildSession: async (options) => { + const built = await buildChatSession(options); + const joinHost = built.whenHostSafeToClose; + return { + ...built, + whenHostSafeToClose: async () => { + await joinHost(); + joined = true; + if (verdictFails) throw primary; + }, + }; + }, + }, + ); + if (verdictFails) await expect(command).rejects.toBe(primary); + else { + expect(await command).toBe(EXIT_CODES.success); + expect(parseNdjson(out())).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'session:turn_completed' })]), + ); + } + expect(err()).toContain('warning: effect journal teardown failed\n'); + assertClosed(err()); + }, + ); +}); diff --git a/apps/cli/src/engine/pricing-overlay.test.ts b/apps/cli/src/engine/pricing-overlay.test.ts index 0279df31..57f9f475 100644 --- a/apps/cli/src/engine/pricing-overlay.test.ts +++ b/apps/cli/src/engine/pricing-overlay.test.ts @@ -14,6 +14,7 @@ import { import { clearCatalogRefresh, priceModel } from '@relavium/llm'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { captureIo } from '../test-support.js'; import { openLocalDb } from '../db/open.js'; import { catalogModelToRow } from './catalog-metadata.js'; import { @@ -153,7 +154,7 @@ describe('loadUserPricingOverlay (self-contained transient open)', () => { } finally { opened.close(); } - const overlay = loadUserPricingOverlay(home); + const overlay = loadUserPricingOverlay(home, captureIo().io); expect(overlay?.get('acme-custom-1')?.inputPerMtokMicrocents).toBe(300_000_000); }); @@ -163,6 +164,6 @@ describe('loadUserPricingOverlay (self-contained transient open)', () => { // than propagating (the surface's own session/run store open is the authoritative fault report, a clean exit 2). const blocker = join(home, 'blocker'); writeFileSync(blocker, 'not a directory'); - expect(loadUserPricingOverlay(blocker)).toBeUndefined(); + expect(loadUserPricingOverlay(blocker, captureIo().io)).toBeUndefined(); }); }); diff --git a/apps/cli/src/engine/pricing-overlay.ts b/apps/cli/src/engine/pricing-overlay.ts index 0a9eab70..c20d5960 100644 --- a/apps/cli/src/engine/pricing-overlay.ts +++ b/apps/cli/src/engine/pricing-overlay.ts @@ -1,3 +1,5 @@ +import { closeQuietly } from '../process/close-quietly.js'; +import type { CliIo } from '../process/io.js'; import { randomUUID } from 'node:crypto'; import { @@ -91,7 +93,10 @@ function readPricingRows(db: Db): PricingOverlay { * fault surfaces through the surface's OWN store-open error path (a clean exit 2), not an opaque throw here. The * transient handle closes before the surface opens its real store — sequential opens of the one SQLite file. */ -export function loadUserPricingOverlay(homeDir: string): PricingOverlay | undefined { +export function loadUserPricingOverlay( + homeDir: string, + io: Pick, +): PricingOverlay | undefined { let opened: { db: Db; close: () => void }; try { opened = openLocalDb(homeDir); @@ -108,6 +113,6 @@ export function loadUserPricingOverlay(homeDir: string): PricingOverlay | undefi // surface; the surface's own store open is the authoritative fault report. return undefined; } finally { - opened.close(); + closeQuietly(io, 'history database', () => opened.close()); } } diff --git a/apps/cli/src/home/drive-home.test.ts b/apps/cli/src/home/drive-home.test.ts index aad0fb9c..eabd32a0 100644 --- a/apps/cli/src/home/drive-home.test.ts +++ b/apps/cli/src/home/drive-home.test.ts @@ -129,7 +129,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { cwd = mkdtempSync(join(tmpdir(), 'relavium-home-')); }); afterEach(() => { - client.sqlite.close(); + if (client.sqlite.open) client.sqlite.close(); }); const global: GlobalOptions = { @@ -260,6 +260,55 @@ describe('driveHome (2.5.B / ADR-0054)', () => { expect(controls).toContain(DISABLE_MOUSE); }); + it('a native database close failure preserves a clean Home exit and restored terminal controls (#22)', async () => { + closeSpy.mockImplementation(() => { + client.sqlite.close(); + throw new Error('SQLITE_IOERR_CLOSE /private/operator/history.db secret-token\x1b[2J'); + }); + let captured: RootAppProps | undefined; + const writeErr = vi.fn(); + const { deps, unmount, writeControl, exitHandlers } = makeDeps((props) => (captured = props), { + io: { ...io, writeErr }, + }); + const drivePromise = driveHome(deps); + if (captured === undefined) throw new Error('the injected render was never invoked'); + captured.controller.handleKey('c', CTRL_C); + + expect(await drivePromise).toBe(EXIT_CODES.success); + expect(client.sqlite.open).toBe(false); + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(unmount).toHaveBeenCalledTimes(1); + expect(writeControl).toHaveBeenCalledWith(DISABLE_BRACKETED_PASTE); + expect(writeControl).toHaveBeenCalledWith(DISABLE_MOUSE); + expect(writeErr).toHaveBeenCalledExactlyOnceWith('warning: session store teardown failed\n'); + // The process-exit fallback shares the same close owner; a late callback must not repeat close or warning. + for (const exit of exitHandlers) exit(); + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(writeErr).toHaveBeenCalledTimes(1); + }); + + it('a native database close failure preserves the original mount error and restored controls (#22)', async () => { + const primary = new Error('ink mount primary failure'); + closeSpy.mockImplementation(() => { + client.sqlite.close(); + throw new Error('SQLITE_IOERR_CLOSE /private/operator/history.db secret-token\x1b[2J'); + }); + const writeErr = vi.fn(); + const { deps, writeControl } = makeDeps(() => undefined, { + io: { ...io, writeErr }, + render: () => { + throw primary; + }, + }); + + await expect(driveHome(deps)).rejects.toBe(primary); + expect(client.sqlite.open).toBe(false); + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(writeControl).toHaveBeenCalledWith(DISABLE_BRACKETED_PASTE); + expect(writeControl).toHaveBeenCalledWith(DISABLE_MOUSE); + expect(writeErr).toHaveBeenCalledExactlyOnceWith('warning: session store teardown failed\n'); + }); + it('MOUSE: the capture PORT exists by default, and `--no-mouse` withholds it (Step 5e/6g, ADR-0068 §e)', async () => { // The opt-out IS the safety mechanism this feature exists for. The unit tests pin `resolveMouseMode` in isolation; // this pins the ASSEMBLY — `deps.global.noMouse` → `resolveMouseMode` → whether `RootApp` gets a capture port at diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index c5cb4b15..0a024f89 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -1,3 +1,4 @@ +import { closeQuietly, warnTeardown } from '../process/close-quietly.js'; import { COMPACTION_UNKNOWN_WINDOW_NOTICE } from '../chat/repl-info.js'; import { randomUUID } from 'node:crypto'; import { Buffer } from 'node:buffer'; @@ -230,7 +231,7 @@ export async function driveHome(deps: HomeDeps): Promise { const closeDb = (): void => { if (dbClosed) return; dbClosed = true; - opened.close(); + closeQuietly(deps.io, 'session store', () => opened.close()); }; const writeControl = deps.writeControl ?? @@ -593,9 +594,9 @@ export async function driveHome(deps: HomeDeps): Promise { await built.whenHostSafeToClose(); } finally { try { - persister?.close(); + closeQuietly(deps.io, 'persister', () => persister?.close()); } finally { - await built.closeMcp?.().catch(() => undefined); // best-effort; never orphan a spawned stdio child + await built.closeMcp?.().catch(() => warnTeardown(deps.io, 'MCP')); // best-effort; never orphan a spawned stdio child } } }; @@ -635,9 +636,9 @@ export async function driveHome(deps: HomeDeps): Promise { clearInterval(frame); // reclaim whatever the wiring managed to acquire before the throw unsubscribe?.(); try { - persister?.close(); + closeQuietly(deps.io, 'persister', () => persister?.close()); } finally { - await built.closeMcp?.().catch(() => undefined); + await built.closeMcp?.().catch(() => warnTeardown(deps.io, 'MCP')); } throw err; } diff --git a/apps/cli/src/process/close-quietly.test.ts b/apps/cli/src/process/close-quietly.test.ts new file mode 100644 index 00000000..45fdb9bc --- /dev/null +++ b/apps/cli/src/process/close-quietly.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { closeQuietly, warnTeardown } from './close-quietly.js'; + +describe('owned synchronous teardown (#22)', () => { + it('closes once without a diagnostic when cleanup succeeds', () => { + const close = vi.fn(); + const writeErr = vi.fn(); + closeQuietly({ writeErr }, 'session store', close); + expect(close).toHaveBeenCalledTimes(1); + expect(writeErr).not.toHaveBeenCalled(); + }); + + it.each([ + new Error('EIO /private/operator/history.db secret-token\x1b[2J'), + 'secret-token /private/operator/history.db', + { + get message(): never { + throw new Error('must not inspect thrown diagnostics'); + }, + }, + ])('emits only the fixed owned-resource warning for a thrown value (%#)', (failure) => { + const writeErr = vi.fn(); + closeQuietly({ writeErr }, 'effect journal', () => { + // eslint-disable-next-line @typescript-eslint/only-throw-error -- Exercise an untrusted native boundary's unknown thrown value. + throw failure; + }); + expect(writeErr).toHaveBeenCalledExactlyOnceWith('warning: effect journal teardown failed\n'); + }); + + it('preserves a returned exit when both close and the diagnostic sink fail', () => { + const writeErr = vi.fn(() => { + throw new Error('closed stderr'); + }); + const primary = (): number => { + try { + return 3; + } finally { + closeQuietly({ writeErr }, 'history database', () => { + throw new Error('native close'); + }); + } + }; + expect(primary()).toBe(3); + expect(writeErr).toHaveBeenCalledTimes(1); + }); + + it('preserves the primary thrown identity and attempts the next cleanup after a failed warning', () => { + const primary = new Error('primary failure'); + const next = vi.fn(); + const writeErr = vi.fn(() => { + throw new Error('closed stderr'); + }); + let caught: unknown; + try { + try { + throw primary; + } finally { + closeQuietly({ writeErr }, 'persister', () => { + throw new Error('native close'); + }); + closeQuietly({ writeErr }, 'session store', next); + } + } catch (failure) { + caught = failure; + } + expect(caught).toBe(primary); + expect(next).toHaveBeenCalledTimes(1); + expect(writeErr).toHaveBeenCalledTimes(1); + }); + + it('contains a failed warning sink independently of a close callback', () => { + expect(() => + warnTeardown( + { + writeErr: () => { + throw new Error('closed stderr'); + }, + }, + 'MCP', + ), + ).not.toThrow(); + }); +}); diff --git a/apps/cli/src/process/close-quietly.ts b/apps/cli/src/process/close-quietly.ts new file mode 100644 index 00000000..cbcd6f69 --- /dev/null +++ b/apps/cli/src/process/close-quietly.ts @@ -0,0 +1,26 @@ +import type { CliIo } from './io.js'; + +/** Owned resource labels only: native failures may contain paths, credentials or hostile text. */ +type ResourceLabel = 'session store' | 'history database' | 'effect journal' | 'persister' | 'MCP'; + +/** A secondary cleanup failure cannot replace the command result or prevent remaining teardown. */ +export function warnTeardown(io: Pick, label: ResourceLabel): void { + try { + io.writeErr(`warning: ${label} teardown failed\n`); + } catch { + // The diagnostic sink has failed too. Preserve the primary outcome; never retry on stdout. + } +} + +/** Synchronous best-effort close, called only after the resource owner's real lifetime joins. */ +export function closeQuietly( + io: Pick, + label: ResourceLabel, + close: () => void, +): void { + try { + close(); + } catch { + warnTeardown(io, label); + } +} diff --git a/apps/cli/src/render/tui/RunApp.test.tsx b/apps/cli/src/render/tui/RunApp.test.tsx index 2b71010d..1bde57e8 100644 --- a/apps/cli/src/render/tui/RunApp.test.tsx +++ b/apps/cli/src/render/tui/RunApp.test.tsx @@ -4,7 +4,12 @@ import { afterEach, describe, expect, it } from 'vitest'; import { waitFor } from './harness-util.js'; import { RunApp } from './RunApp.js'; import type { RunStore, RunStoreSnapshot } from './run-store.js'; -import type { RunViewState } from './run-view-model.js'; +import { + initialRunViewState, + reduceRunEvent, + MAX_ACTIVE_TOKEN_LINES, + type RunViewState, +} from './run-view-model.js'; afterEach(cleanup); @@ -24,6 +29,26 @@ function staticStore(state: RunViewState): RunStore { } describe('RunApp terminal projection', () => { + it('renders reduced reasoning with a bounded tail in a real color-disabled Ink frame', async () => { + const text = Array.from({ length: 12 }, (_, i) => `reasoning row ${i}`).join('\n'); + const state = reduceRunEvent(initialRunViewState(), { + type: 'agent:reasoning', + runId: 'run-1', + nodeId: 'thinker', + model: 'reasoner', + timestamp: '2026-10-16T00:00:00.000Z', + sequenceNumber: 1, + text, + }); + const { lastFrame } = render(); + await waitFor(() => (lastFrame() ?? '').includes('reasoning row 11')); + const frame = lastFrame() ?? ''; + expect(frame).toContain('thinking · thinker'); + expect(frame).not.toContain('reasoning row 0'); + expect(frame.match(/reasoning row/g)).toHaveLength(MAX_ACTIVE_TOKEN_LINES); + expect(frame).not.toContain('\x1b'); + }); + it('removes terminal and bidi controls from every untrusted display field in a real Ink frame (#56)', async () => { const nodeId = `node visible${ATTACK}\nforged row`; const state: RunViewState = { @@ -37,6 +62,7 @@ describe('RunApp terminal projection', () => { }, activeNodeId: nodeId, activeModel: `model visible${ATTACK}\nforged model`, + activeReasoning: `reasoning visible${ATTACK}\nsecond reasoning visible`, activeTokens: `token visible${ATTACK}\nsecond token visible`, toolLines: [`tool visible${ATTACK}\nforged tool row`], cumulativeCostMicrocents: 0, @@ -65,6 +91,8 @@ describe('RunApp terminal projection', () => { expect(frame).toContain('error visible'); expect(frame).toContain('model visible'); expect(frame).toContain('token visible'); + expect(frame).toContain('reasoning visible'); + expect(frame).toContain('thinking'); expect(frame).toContain('tool visible'); expect(frame).toContain('warning visible'); expect(frame).toContain('image/png visible'); diff --git a/apps/cli/src/render/tui/RunApp.tsx b/apps/cli/src/render/tui/RunApp.tsx index d41676ae..29b6c149 100644 --- a/apps/cli/src/render/tui/RunApp.tsx +++ b/apps/cli/src/render/tui/RunApp.tsx @@ -45,6 +45,11 @@ export function RunApp(props: Readonly<{ store: RunStore }>): ReactElement { const activeLines = state.activeTokens === '' ? [] : state.activeTokens.split('\n').slice(-MAX_ACTIVE_TOKEN_LINES); + const reasoningLines = + state.activeReasoning === '' + ? [] + : state.activeReasoning.split('\n').slice(-MAX_ACTIVE_TOKEN_LINES); + return ( {/* Per-node status list */} @@ -57,6 +62,18 @@ export function RunApp(props: Readonly<{ store: RunStore }>): ReactElement { })} + {/* Live-only reasoning has the same bounded rows/control sanitization as answer text. */} + {activeNode !== undefined && reasoningLines.length > 0 ? ( + + thinking · {sanitizeInline(activeNode.nodeId)} + {reasoningLines.map((line, i) => ( + + {stripTerminalControls(line)} + + ))} + + ) : null} + {/* The active node's live token stream (trailing lines) */} {activeNode !== undefined && activeLines.length > 0 ? ( diff --git a/apps/cli/src/render/tui/chat-ink.tsx b/apps/cli/src/render/tui/chat-ink.tsx index 59f29f03..60ab17d2 100644 --- a/apps/cli/src/render/tui/chat-ink.tsx +++ b/apps/cli/src/render/tui/chat-ink.tsx @@ -1006,6 +1006,10 @@ export function ChatApp(props: Readonly): ReactElement { // Clear the transient hint only on a REAL interaction — the fold returns the SAME state ref for an inert key. applyModelPicker(step.state === open ? open : { ...step.state, hint: undefined }); return; + default: { + const exhaustive: never = step; + return exhaustive; + } } }; @@ -1041,6 +1045,10 @@ export function ChatApp(props: Readonly): ReactElement { case 'state': applyEffortPicker(step.state); return; + default: { + const exhaustive: never = step; + return exhaustive; + } } }; diff --git a/apps/cli/src/render/tui/home-controller.ts b/apps/cli/src/render/tui/home-controller.ts index 8bb99b45..53a4a86f 100644 --- a/apps/cli/src/render/tui/home-controller.ts +++ b/apps/cli/src/render/tui/home-controller.ts @@ -942,6 +942,10 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { // (an unhandled key / backspace on an empty filter), which must not wipe a just-shown hint. set({ modelPicker: step.state === open ? open : { ...step.state, hint: undefined } }); break; + default: { + const exhaustive: never = step; + return exhaustive; + } } return true; }; @@ -985,6 +989,10 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { case 'state': set({ effortPicker: step.state }); break; + default: { + const exhaustive: never = step; + return exhaustive; + } } return true; }; diff --git a/apps/cli/src/render/tui/run-view-model.test.ts b/apps/cli/src/render/tui/run-view-model.test.ts index f0330f8b..112c5ce9 100644 --- a/apps/cli/src/render/tui/run-view-model.test.ts +++ b/apps/cli/src/render/tui/run-view-model.test.ts @@ -28,6 +28,106 @@ describe('reduceRunEvent', () => { expect(s.summary).toBeUndefined(); }); + it('shows early reasoning, appends deltas, and preserves independent answer text', () => { + const state = reduceAll([ + { + type: 'agent:reasoning', + runId: RUN, + timestamp: TS, + sequenceNumber: 1, + nodeId: 'a', + model: 'reasoner', + text: 'consider ', + }, + { + type: 'agent:reasoning', + runId: RUN, + timestamp: TS, + sequenceNumber: 2, + nodeId: 'a', + model: 'reasoner', + text: 'this', + }, + { + type: 'agent:token', + runId: RUN, + timestamp: TS, + sequenceNumber: 3, + nodeId: 'a', + model: 'reasoner', + token: 'answer', + }, + ]); + expect(state.nodes['a']?.status).toBe('running'); + expect(state.activeNodeId).toBe('a'); + expect(state.activeModel).toBe('reasoner'); + expect(state.activeReasoning).toBe('consider this'); + expect(state.activeTokens).toBe('answer'); + }); + + it('bounds reasoning and ignores duplicate or out-of-order deltas without losing the cost snapshot', () => { + const first: RunEvent = { + type: 'agent:reasoning', + runId: RUN, + timestamp: TS, + sequenceNumber: 3, + nodeId: 'a', + model: 'reasoner', + text: `${'x'.repeat(MAX_TOKEN_CHARS)}tail`, + cumulativeCostMicrocents: 25, + }; + const state = reduceAll([ + first, + { ...first, text: 'duplicate' }, + { ...first, sequenceNumber: 2, text: 'old' }, + ]); + expect(state.activeReasoning).toHaveLength(MAX_TOKEN_CHARS); + expect(state.activeReasoning.endsWith('tail')).toBe(true); + expect(state.activeReasoning).not.toContain('duplicate'); + expect(state.activeReasoning).not.toContain('old'); + expect(state.cumulativeCostMicrocents).toBe(25); + expect(state.lastSequenceNumber).toBe(3); + }); + + it('resets both live buffers on node switch and on an explicit node start', () => { + const reason: RunEvent = { + type: 'agent:reasoning', + runId: RUN, + timestamp: TS, + sequenceNumber: 1, + nodeId: 'a', + model: 'reasoner', + text: 'first reasoning', + }; + let state = reduceAll([ + reason, + { + type: 'agent:token', + runId: RUN, + timestamp: TS, + sequenceNumber: 2, + nodeId: 'b', + model: 'other', + token: 'other answer', + }, + ]); + expect(state.activeReasoning).toBe(''); + state = reduceRunEvent(state, { ...reason, sequenceNumber: 3 }); + expect(state.activeTokens).toBe(''); + expect(state.activeReasoning).toBe('first reasoning'); + state = reduceRunEvent(state, { + type: 'node:started', + runId: RUN, + timestamp: TS, + sequenceNumber: 4, + nodeId: 'c', + nodeType: 'agent', + }); + expect(state.activeTokens).toBe(''); + expect(state.activeReasoning).toBe(''); + expect(state.activeModel).toBeUndefined(); + }); + it('records the runId from run:started', () => { const s = reduceAll([ { diff --git a/apps/cli/src/render/tui/run-view-model.ts b/apps/cli/src/render/tui/run-view-model.ts index a6bd7d49..6c768e90 100644 --- a/apps/cli/src/render/tui/run-view-model.ts +++ b/apps/cli/src/render/tui/run-view-model.ts @@ -2,6 +2,7 @@ import { collectDurableMediaHandles, type BudgetAllowanceState, type AgentTokenEvent, + type AgentReasoningEvent, type NodeCompletedEvent, type RunEvent, type UsageCoverage, @@ -78,6 +79,8 @@ export interface RunViewState { readonly activeModel: string | undefined; /** The active node's streamed token text — bounded to the trailing {@link MAX_TOKEN_CHARS}. */ readonly activeTokens: string; + /** Live reasoning tail, separate from answer text and never a durable transcript. */ + readonly activeReasoning: string; /** Recent compact, secret-free tool-call / tool-result lines — bounded to {@link MAX_TOOL_LINES}. */ readonly toolLines: readonly string[]; /** The run-wide cost running total (integer micro-cents — 1e-8 USD; database-schema.md). */ @@ -121,6 +124,7 @@ export function initialRunViewState(): RunViewState { nodes: {}, activeModel: undefined, activeTokens: '', + activeReasoning: '', toolLines: [], cumulativeCostMicrocents: 0, gapDetected: false, @@ -247,7 +251,10 @@ function clip(text: string, max = 80): string { * token somehow precedes its `node:started` — otherwise RunApp's `nodes[activeNodeId]` is undefined and the * live region hides the tokens. Only upserts when the node is absent, so a real status is never clobbered. */ -function reduceAgentToken(base: RunViewState, event: AgentTokenEvent): RunViewState { +function reduceAgentDelta( + base: RunViewState, + event: AgentTokenEvent | AgentReasoningEvent, +): RunViewState { const switching = event.nodeId !== base.activeNodeId; const ensureNode = base.nodes[event.nodeId] === undefined @@ -258,7 +265,18 @@ function reduceAgentToken(base: RunViewState, event: AgentTokenEvent): RunViewSt ...ensureNode, activeNodeId: event.nodeId, activeModel: event.model, - activeTokens: appendTokens(switching ? '' : base.activeTokens, event.token), + activeTokens: + event.type === 'agent:token' + ? appendTokens(switching ? '' : base.activeTokens, event.token) + : switching + ? '' + : base.activeTokens, + activeReasoning: + event.type === 'agent:reasoning' + ? appendTokens(switching ? '' : base.activeReasoning, event.text) + : switching + ? '' + : base.activeReasoning, }; } @@ -288,7 +306,8 @@ function reduceNodeCompleted(base: RunViewState, event: NodeCompletedEvent): Run /** * Reduce one canonical {@link RunEvent} into the next immutable {@link RunViewState}. Pure: no I/O, no * mutation of `state`. A token reduce is shallow (only the active buffer changes) so a high token rate - * stays cheap. Unknown/forward event types fall through (the run-event union is intentionally lenient). + * stays cheap. Every member of the validated closed RunEvent union has an explicit disposition; + * a newly added member must be handled or deliberately ignored here. */ export function reduceRunEvent(state: RunViewState, event: RunEvent): RunViewState { const seq = trackSeq(state, event); @@ -326,10 +345,12 @@ export function reduceRunEvent(state: RunViewState, event: RunEvent): RunViewSta activeNodeId: event.nodeId, activeModel: undefined, activeTokens: '', // a new node's output region starts clean + activeReasoning: '', }; case 'agent:token': - return reduceAgentToken(base, event); + case 'agent:reasoning': + return reduceAgentDelta(base, event); case 'agent:tool_call': return { @@ -504,9 +525,16 @@ export function reduceRunEvent(state: RunViewState, event: RunEvent): RunViewSta warnings: pushBounded(base.warnings, `run timed out (${event.elapsedMs}ms)`, MAX_WARNINGS), }; - default: - // Forward-compatible: a future RunEvent variant is reflected only in the seq/gap tracking above. + // Coverage is folded by the run store; approval is owned by the active gate prompter. + case 'cost:coverage_started': + case 'cost:usage_pending': + case 'cost:usage_disposition': + case 'agent:approval_requested': return base; + default: { + const exhaustive: never = event; + return exhaustive; + } } } diff --git a/apps/cli/src/workflows/catalog.ts b/apps/cli/src/workflows/catalog.ts index b77f8f26..146d71dc 100644 --- a/apps/cli/src/workflows/catalog.ts +++ b/apps/cli/src/workflows/catalog.ts @@ -8,7 +8,7 @@ import { realpathSync, statSync, } from 'node:fs'; -import { basename, join, relative } from 'node:path'; +import { basename, join } from 'node:path'; import { AgentParseError, @@ -19,6 +19,7 @@ import { } from '@relavium/core'; import { CliError } from '../process/errors.js'; +import { sourceLabel } from '../process/source-label.js'; import { isWithinFsRoot } from '../engine/tool-host/fs-policy.js'; /** @@ -118,7 +119,7 @@ function invalidEntry(path: string, rel: string, error: string): CatalogEntry { /** Read + parse one catalog file into an entry; any read/parse fault becomes a `valid: false` entry. */ function readEntry(path: string, cwd: string, kind: CatalogKind, root: string): CatalogEntry { - const rel = relative(cwd, path); + const rel = sourceLabel(cwd, path); let yaml: string; try { const canonical = realpathSync.native(path); diff --git a/apps/cli/src/workflows/resolve.test.ts b/apps/cli/src/workflows/resolve.test.ts index 858afc91..194709ac 100644 --- a/apps/cli/src/workflows/resolve.test.ts +++ b/apps/cli/src/workflows/resolve.test.ts @@ -1,17 +1,23 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync, statSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { MAX_SOURCE_CHARS } from '@relavium/core'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { MAX_SOURCE_CHARS, parseWorkflow, parseAgent } from '@relavium/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { isCliError } from '../process/errors.js'; -import { resolveWorkflowSource } from './resolve.js'; +import { resolveYamlSource, resolveWorkflowSource } from './resolve.js'; + +vi.mock('node:fs', async (original) => { + const native = await original(); + return { ...native, statSync: vi.fn(native.statSync), readFileSync: vi.fn(native.readFileSync) }; +}); const YAML = 'schema_version: "1.0"\n'; let root: string; beforeEach(() => { + vi.clearAllMocks(); root = mkdtempSync(join(tmpdir(), 'relavium-resolve-')); }); afterEach(() => { @@ -100,7 +106,7 @@ describe('resolveWorkflowSource', () => { it('rejects a workflow file over the size cap before reading it', () => { const path = join(root, 'huge.relavium.yaml'); - writeFileSync(path, 'a'.repeat(MAX_SOURCE_CHARS + 1)); // one byte over the shared cap + writeFileSync(path, 'a'.repeat(MAX_SOURCE_CHARS * 4 + 1)); // above the actual byte ceiling let caught: unknown; try { resolveWorkflowSource(path, { cwd: root, projectConfigDir: undefined }); @@ -114,4 +120,85 @@ describe('resolveWorkflowSource', () => { expect(caught.message).toContain('size limit'); } }); + it.each(['workflow', 'agent'] as const)( + 'accepts dense UTF-8 %s source above the old byte ceiling, then parses it', + (kind) => { + const header = + kind === 'workflow' + ? 'schema_version: "1.0"\nworkflow:\n id: dense\n nodes: [{id: a, type: input}, {id: b, type: output}]\n edges: [{from: a, to: b}]\n' + : 'id: dense\nmodel: gpt-4o\nprovider: openai\nsystem_prompt: hello\n'; + const yaml = `${header}#${'文'.repeat(Math.floor(MAX_SOURCE_CHARS / 2))}\n`; + expect(yaml.length).toBeLessThan(MAX_SOURCE_CHARS); + expect(Buffer.byteLength(yaml)).toBeGreaterThan(MAX_SOURCE_CHARS); + const path = join(root, `${kind}.yaml`); + writeFileSync(path, yaml); + const source = resolveYamlSource(path, { + cwd: root, + kind, + subdir: kind, + projectConfigDir: undefined, + idSuffixes: ['.yaml'], + }); + expect( + kind === 'workflow' ? parseWorkflow(source.yaml).workflow.id : parseAgent(source.yaml).id, + ).toBe('dense'); + }, + ); + + it('leaves the authoritative ASCII character overflow to the parser', () => { + const path = join(root, 'long.yaml'); + writeFileSync(path, `#${'a'.repeat(MAX_SOURCE_CHARS)}`); + const source = resolveWorkflowSource(path, { cwd: root, projectConfigDir: undefined }); + expect(() => parseWorkflow(source.yaml)).toThrow(/limit/i); + }); + + it('refuses true byte overflow before any file read', () => { + const path = join(root, 'huge.yaml'); + writeFileSync(path, 'a'.repeat(MAX_SOURCE_CHARS * 4 + 1)); + expect(() => resolveWorkflowSource(path, { cwd: root, projectConfigDir: undefined })).toThrow( + /size limit/, + ); + expect(readFileSync).not.toHaveBeenCalled(); + }); + + it.each(['stat', 'read'] as const)( + 'keeps a native %s fault as cause without disclosing its absolute path', + (stage) => { + const path = join(root, 'fault.yaml'); + writeFileSync(path, YAML); + const cause = Object.assign(new Error(`private native path: ${path}`), { code: 'EACCES' }); + if (stage === 'stat') + vi.mocked(statSync).mockImplementationOnce(() => { + throw cause; + }); + else + vi.mocked(readFileSync).mockImplementationOnce(() => { + throw cause; + }); + try { + resolveWorkflowSource(path, { cwd: root, projectConfigDir: undefined }); + expect.fail('native fault must refuse'); + } catch (error) { + expect(isCliError(error)).toBe(true); + if (!isCliError(error)) throw error; + expect(error.cause).toBe(cause); + expect(error.message).toContain('fault.yaml'); + expect(error.message).not.toContain(root); + expect(error.message).not.toContain('private native'); + } + }, + ); + + it('labels both the absolute argument and searched candidates in a miss', () => { + const path = join(root, 'missing.yaml'); + try { + resolveWorkflowSource(path, { cwd: root, projectConfigDir: undefined }); + expect.fail('missing source must refuse'); + } catch (error) { + expect(isCliError(error)).toBe(true); + if (!isCliError(error)) throw error; + expect(error.message).toContain('missing.yaml'); + expect(error.message).not.toContain(root); + } + }); }); diff --git a/apps/cli/src/workflows/resolve.ts b/apps/cli/src/workflows/resolve.ts index 99002cfb..c9282267 100644 --- a/apps/cli/src/workflows/resolve.ts +++ b/apps/cli/src/workflows/resolve.ts @@ -4,15 +4,11 @@ import { isAbsolute, join, resolve, sep } from 'node:path'; import { MAX_SOURCE_CHARS } from '@relavium/core'; import { CliError } from '../process/errors.js'; +import { sourceLabel } from '../process/source-label.js'; -/** - * Pre-read byte ceiling for a YAML source file (workflow or agent) — the core parser's authoritative char - * cap ({@link MAX_SOURCE_CHARS}), reused (not re-declared) so this guard never desyncs from it. UTF-8 - * bytes ≥ chars, so rejecting a file whose `stat` size exceeds the char cap is conservative and lets - * us bail BEFORE slurping it into memory; the parser then re-applies the exact char cap (a multibyte - * file slightly under this byte ceiling may still trip there — the authoritative check). - */ -const MAX_YAML_SOURCE_BYTES = MAX_SOURCE_CHARS; +/** UTF-8 uses at most four bytes per source character. Match the sibling catalog precheck; + * the pure parser remains authoritative for the character ceiling after decoding. */ +const MAX_YAML_SOURCE_BYTES = MAX_SOURCE_CHARS * 4; export interface WorkflowSource { /** The absolute path the YAML was read from (also the parse-error label, made cwd-relative). */ @@ -59,15 +55,18 @@ export function resolveYamlSource( ): WorkflowSource { const candidates = candidatePaths(arg, opts); for (const candidate of candidates) { - const yaml = tryRead(candidate, opts.kind); + const yaml = tryRead(candidate, opts.kind, opts.cwd); if (yaml !== undefined) { return { path: candidate, yaml }; } } - const where = candidates.length > 0 ? candidates.join(', ') : '(no project .relavium/ found)'; + const where = + candidates.length > 0 + ? candidates.map((path) => sourceLabel(opts.cwd, path)).join(', ') + : '(no project .relavium/ found)'; throw new CliError( 'invalid_invocation', - `${opts.kind} '${arg}' not found — looked for: ${where}.`, + `${opts.kind} '${sourceLabel(opts.cwd, resolve(opts.cwd, arg))}' not found — looked for: ${where}.`, ); } @@ -109,7 +108,8 @@ function candidatePaths( * found". The size cap is enforced from `stat` before the file is read into memory. `kind` names the * artifact in the diagnostic ('workflow' / 'agent'). */ -function tryRead(path: string, kind: string): string | undefined { +function tryRead(path: string, kind: string, cwd: string): string | undefined { + const label = sourceLabel(cwd, path); let stats: Stats; try { stats = statSync(path); @@ -117,23 +117,23 @@ function tryRead(path: string, kind: string): string | undefined { if (errnoCode(err) === 'ENOENT') { return undefined; // this candidate does not exist — try the next } - throw new CliError('invalid_invocation', `${kind} file '${path}' could not be read.`, { + throw new CliError('invalid_invocation', `${kind} file '${label}' could not be read.`, { cause: err, }); } if (!stats.isFile()) { - throw new CliError('invalid_invocation', `${kind} path '${path}' is not a regular file.`); + throw new CliError('invalid_invocation', `${kind} path '${label}' is not a regular file.`); } if (stats.size > MAX_YAML_SOURCE_BYTES) { throw new CliError( 'invalid_invocation', - `${kind} file '${path}' exceeds the ${MAX_YAML_SOURCE_BYTES}-byte size limit.`, + `${kind} file '${label}' exceeds the ${MAX_YAML_SOURCE_BYTES}-byte size limit.`, ); } try { return readFileSync(path, 'utf8'); } catch (err) { - throw new CliError('invalid_invocation', `${kind} file '${path}' could not be read.`, { + throw new CliError('invalid_invocation', `${kind} file '${label}' could not be read.`, { cause: err, }); } diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 48a19895..b4e46099 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -28,7 +28,7 @@ The CLI auto-detects its environment and switches presentation accordingly: | Mode | When | Behavior | |------|------|----------| -| **Interactive TUI** | TTY attached, no `--json` | `ink`-rendered live view: animated per-node status, streaming token output for the active node, final cost/duration summary | +| **Interactive TUI** | TTY attached, no `--json` | `ink`-rendered live view: animated per-node status, separate bounded live reasoning and answer output for the active node, final cost/duration summary | | **Plain** | No TTY or `CI=true` (and no `--json`) | The TUI is disabled; a terse line-per-lifecycle-event human renderer writes to stdout | | **NDJSON** | `--json` (anywhere on the command line) | The machine contract: stdout is a pure NDJSON stream — [RunEvent](../contracts/sse-event-schema.md)s for `run` / `gate` / `budget resume`, or [SessionEvent](../contracts/sse-event-schema.md#session-event-namespace)s for `chat` / `agent run` — and all diagnostics go to stderr. See [The `--json` machine-output contract](#the---json-machine-output-contract) | diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 327e92ef..3de34d0e 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1297,7 +1297,7 @@ unanswered**; the remainder sit inline in their own phase-file bullet. | D17 | 2 | Book security reviews **by threat class**, not per PR? | **Accepted 2026-10-13:** four new sittings (filesystem, secret input, secrets at rest, config trust), plus W4 hostile-MCP sitting/current regression revalidation. [Closing evidence](phases/phase-2.5.5-hardening-and-remediation.md#wave-2-closing-register--2026-10-13). | | D18 | 3 | `chat-ink.tsx` (1,795 lines, in neither doc): decompose or decline? | **Decline** — hand it to 2.6.M's render-v2 in Wave 5a, where the palette sweep then runs once | | D19 | 3 | `--quiet`/`--verbose`/`RELAVIUM_DEBUG` precedence | **Approved ADR-0110, 2026-10-16:** quiet+verbose remains a conflict; either explicit flag overrides exact DEBUG=1. This replaces the old precedence ladder. JSON remains independent; implementation is staged. | -| D20 | 3 | `--input`'s four-way collision: rename or document? | Rename `models pricing` to `--input-price`/`--output-price`, old spellings deprecated aliases | +| D20 | 3 | `--input`'s four-way collision: rename or document? | **Approved ADR-0110, 2026-10-16:** rename pricing flags to `--input-price`/`--output-price`, retaining deprecated aliases and refusing conflicting spellings before normalization. Other command-scoped input meanings stay documented. | | D22 | 3 | Read EXIT:4 as "no **breaking** change"? | **Approved ADR-0110, 2026-10-16:** its enumerated compatibility scope replaces the old two-exception recommendation. Phase exit criterion 4/E wording and ADR-0049/0056 forward notes are reconciled before dependent code. | | D26 | 3 | Which session would bare Home's `showCost` target? | **Approved disposition, 2026-10-16:** keep `/cost` chat-only; certify both live chat surfaces. Bare Home currently cannot invoke the no-op. Past-session selection remains 2.6.G; no implicit cost release. See the [Wave 3 plan](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-preflight-and-proposed-execution-plan--2026-10-15). | | D28/D29 | 4a | Flip ADR-0058 and ADR-0060 to Accepted | Yes; 0060's taint model is the cited precedent for Wave 7's generation-security ADR | diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index d7a5b1e7..fa7b7fda 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -179,9 +179,9 @@ reached. something that already ships. It does change a small, enumerated set of **public CLI contracts** as remediation: noun-verb aliases for `chat-resume`/`chat-list`/`chat-export` (`#309`), a confirmation / `--force` gate on `provider remove-key` (`#19`), a dedicated exit code for an unclassified internal - fault (`G35`), `--help --json` (`#13`), and the `--quiet`/`--verbose` precedence (`#45`). Each is - additive or alias-preserving, and each lands with its `reference/cli/commands.md` update — see the - EXIT:4 reading recorded in [../current.md](../current.md). + fault (`G35`), `--help --json` (`#13`), and the `--quiet`/`--verbose` precedence (`#45`). The exact compatibility changes, including deliberate exit/consent/debug changes, + are approved in [ADR-0110](../../decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md); + they are not all merely additive. Each lands with its canonical CLI reference update. ## Work breakdown @@ -322,7 +322,7 @@ This workstream is `apps/cli`'s outermost layer — the process/runtime host eve - **Extend `chat.ts`'s `closeQuietly()` helper to the four sibling commands still using an unguarded `.close()`.** `apps/cli/src/commands/chat.ts` wraps every store-close in `closeQuietly()` so a close-time throw can never overwrite a successful exit code; `apps/cli/src/commands/run.ts`, `apps/cli/src/commands/gate.ts`, `apps/cli/src/commands/chat-export.ts`, and `apps/cli/src/commands/chat-list.ts` all still call `.close()`/`opened.close()` unguarded. Extract the helper to a shared location and import it into all four. *(S · apps/cli/src/commands/chat.ts · #22)* - **Close `agent run`'s two UX-parity gaps against its sibling drivers in one pass.** `apps/cli/src/commands/agent-run.ts` blocks silently on a TTY with no piped stdin and prints no banner, unlike `chat`/`create` which both print one. `makePlainPrinter` (shared with `apps/cli/src/commands/chat.ts`'s plain-printer path) has no `cost:updated` case or final cost line for `agent run`'s non-JSON output, unlike `run`'s TUI/plain renderer. Both are the same shape of gap — `agent run` is the newest, least-mature driver — so add the stdin-TTY banner `create` already has and the cost line `run` already prints in the same change. *(S · apps/cli/src/commands/agent-run.ts · #24, #26)* - **Add a dedicated exit code for unclassified internal faults, or amend the docs that call exit code 1 "workflow failed."** Exit code 1 is documented as meaning a failed workflow but is silently reused for any unclassified internal fault from any command, so a script branching on exit code cannot distinguish "your workflow failed" from "the CLI itself broke." Add a dedicated code or correct the documented contract. *(S · apps/cli/src/index.ts · G35)* -- **Resolve `--quiet`/`--verbose`/`RELAVIUM_DEBUG` into one coherent diagnostic-flag system and point the CLI's generic error fallbacks at it.** `--quiet` has exactly one consumer, and that consumer hardcodes the value away — a complete no-op. `--verbose` only gates `err.stack` on a top-level crash and has zero effect during a live session. `RELAVIUM_DEBUG` is undocumented and is a second, inconsistently-named escape hatch alongside `--verbose`. Both of the CLI's generic "internal error" fallback messages never mention `--verbose` or `RELAVIUM_DEBUG` as a way to get more detail — the same incoherence, seen from the user's side of the same broken flag system. Wire `--quiet` to actually suppress output, extend `--verbose` to gate live-session diagnostics, fold `RELAVIUM_DEBUG` into `--verbose`'s precedence chain, and append the resulting hint to both generic fallback messages in the same change; the fallback-message fix cannot be written correctly until the flag relationship is settled. **Blocked: maintainer decision needed on the final `--quiet`/`--verbose`/`RELAVIUM_DEBUG` precedence and behavior before this can be implemented.** *(M · apps/cli/src/index.ts · #45, #264, #265, #268)* +- **Resolve `--quiet`/`--verbose`/`RELAVIUM_DEBUG` into one coherent diagnostic-flag system and point the CLI's generic error fallbacks at it.** `--quiet` has exactly one consumer, and that consumer hardcodes the value away — a complete no-op. `--verbose` only gates `err.stack` on a top-level crash and has zero effect during a live session. `RELAVIUM_DEBUG` is undocumented and is a second, inconsistently-named escape hatch alongside `--verbose`. Both of the CLI's generic "internal error" fallback messages never mention `--verbose` or `RELAVIUM_DEBUG` as a way to get more detail — the same incoherence, seen from the user's side of the same broken flag system. Wire `--quiet` to actually suppress output, extend `--verbose` to gate live-session diagnostics, fold `RELAVIUM_DEBUG` into `--verbose`'s precedence chain, and append the resulting hint to both generic fallback messages in the same change; the fallback-message fix cannot be written correctly until the flag relationship is settled. **Approved 2026-10-16 in ADR-0110:** quiet+verbose conflict, explicit flags over exact DEBUG=1, independent JSON representation and preserved requested data/safety categories. Implementation remains staged. *(M · apps/cli/src/index.ts · #45, #264, #265, #268)* - **Certify durable-only logs (G15; source correction — 2026-10-15).** The earlier token-firehose premise is refuted: shipping token/reasoning emission is live-only and logs reads durable rows. No coalescing decision or new persistence is required. Preserve actual replay with a real engine/store/log regression and JSON sequence-gap controls; the [reopened preflight](#maintainer-review-reopened-the-proposal--2026-10-15) records the evidence. *(M · apps/cli/src/commands/logs.ts · G15)* - **Attach `GLOBAL_OPTIONS_HELP` to every subcommand's `--help`, not just the root program's.** `apps/cli/src/program.ts` defines `GLOBAL_OPTIONS_HELP` (documenting the position-independent flags `commander` itself doesn't parse, like `--json`/`--cwd`/`--verbose`) and attaches it only to the root program; `commander` does not propagate it to subcommand help output, so `relavium run --help` never shows it. Attach the same help text to each subcommand, or add an explicit pointer to `relavium --help`. *(S · apps/cli/src/program.ts · #17)* - **Add a `--force` gate or interactive confirmation to `provider remove-key`, the manifest's sole `effect: 'destructive'` command.** `apps/cli/src/commands/provider.ts`'s `remove-key` action has no confirmation gate at all, unlike the create/import/export family, which all require explicit confirmation for destructive effects. Add the same pattern here. *(S · apps/cli/src/commands/provider.ts · #19)* @@ -335,7 +335,7 @@ This workstream is `apps/cli`'s outermost layer — the process/runtime host eve - **Make the two-process lock-contention test fail loudly instead of silently skipping under a bare `pnpm test`.** The test silently skips (not fails) inside `apps/cli` when `@relavium/db`'s dist build is missing, dropping WAL-mode coverage with no red CI signal. Make the missing-dist condition a hard failure. *(S · apps/cli/src (cross-process lock test) · #47)* - **Add direct tests for `seedRun`'s own misuse guard-rails.** `seedRun`'s guard-rails against an empty gates array and a duplicate `gateId` have zero test coverage of their own, so a regression in either guard would silently seed the wrong fixture with no signal. Add tests asserting both throw as documented. *(S · apps/cli/src (seedRun fixture helper) · #48)* - **Give the palette step's swallowed rejection a comment or a user-visible trace.** A command-palette step's `run()` rejection is discarded with a bare `.catch(() => undefined)`, unlike sibling `.catch` sites elsewhere in the CLI that document why the rejection is safe to drop. Add the same justification, or surface the failure. *(S · apps/cli/src/render/tui/home-controller.ts · #281)* -- **Settle `--input`'s four-way flag-namespace collision with one maintainer naming decision.** `--input` currently means a workflow input `k=v` pair on `run`, a reserved/rejected `{{ctx.*}}` session variable on `agent run`, an arbitrary JSON/string gate payload on `gate`, and a USD-per-million-token price on `models pricing` — all sharing one flat `CommandInput.options` key across `apps/cli/src/commands/dispatch.ts` and `apps/cli/src/commands/specs.ts`, found independently in both files. **Blocked: maintainer decision needed — rename the pricing flags (e.g. `--input-price`/`--output-price`) versus explicitly documenting the per-command collision in each manifest entry** — one decision resolves both files at once; do not patch them independently. *(S · apps/cli/src/commands/dispatch.ts · #18, #310)* +- **Settle `--input`'s four-way flag-namespace collision with one maintainer naming decision.** `--input` currently means a workflow input `k=v` pair on `run`, a reserved/rejected `{{ctx.*}}` session variable on `agent run`, an arbitrary JSON/string gate payload on `gate`, and a USD-per-million-token price on `models pricing` — all sharing one flat `CommandInput.options` key across `apps/cli/src/commands/dispatch.ts` and `apps/cli/src/commands/specs.ts`, found independently in both files. **Approved 2026-10-16 in ADR-0110:** canonical `--input-price`/`--output-price` with deprecated old aliases and pre-normalization conflict refusal; intentionally retain/document the other command-scoped input meanings — one decision resolves both files at once; do not patch them independently. *(S · apps/cli/src/commands/dispatch.ts · #18, #310)* - **Convert the three remaining bare `throw new Error()` sites to the project's typed `CliError`.** `apps/cli/src/chat/fixture.ts`'s cassette provider and `apps/cli/src/engine/host.ts`'s checkpointer wiring guard both throw a bare `Error` instead of `CliError`. `apps/cli/src/onboarding/wizard.ts`'s internal choice-validation guard does the same, contradicting its own docstring's claimed propagation contract, even though `CliError` is already imported in that file for a different branch. Convert all three in one mechanical pass. *(S · apps/cli/src/onboarding/wizard.ts · G2, #12, #54)* - **Generate and privately archive a per-release sourcemap so a `RELAVIUM_DEBUG` crash stack can be symbolicated.** `apps/cli/tsup.config.ts` correctly ships `sourcemap: false` per ADR-0051 (no original source in the published `dist/`), but no separate, privately-retained sourcemap exists anywhere in the release pipeline, so a `RELAVIUM_DEBUG` stack trace prints mangled minified names with no way to map them back. Generate a sourcemap at release-build time and archive it outside the published artifact. *(M · apps/cli/tsup.config.ts · #46)* @@ -4134,3 +4134,31 @@ The approved #61 character-wrap disposition is now linked in Wave 5a owns it alongside D18/#60; full I acceptance is not waived. Production recipient/recovery setup remains a Wave 4b actual-release prerequisite. No ADR approval, provider capture, key, billing or upload is pending for Wave 3. + +### Wave 3 Step 1 author candidate — 2026-10-16 + +The serialized queue is implemented in its approved order: shared safe synchronous +close handling (#22), source labels/UTF-8 admission (#4/G11/#3), then six exhaustive +session/picker/run switches (#300/#301/G3) and actual live reasoning projection. +The four named command siblings, existing chat owners, Home and the newer one-shot +journal retain their lifetime joins. The transient pricing-reader owner also uses +the same helper rather than allowing close to override its non-fatal read contract. +Fixed owned-label warnings neither inspect native error text nor retry on stdout; +a failed warning sink cannot replace the primary outcome. Native error causes stay +private behind safe source labels. Reasoning has an independent bounded display +buffer and never becomes persisted assistant text. No event/exit/usage contract is +renamed; the later process/discovery changes remain unshipped. + +Author gates pass: all workspace lint/typecheck and 690 test files, 13,020 passing +tests plus 20 established skips; all six build tasks pass. Seam-fence and changed- +file strict-TypeScript screens, diff whitespace and 3,329 relative links/anchors +also pass. Four local removal controls break 21/7/1/3 designated regressions for +close handling, source admission/labels, export diagnostics and reasoning; +each source is restored byte-identically, followed by the passing complete author +suite. Filtered removal runs' unselected tests are not platform skips or coverage. +These are local author checks, not full CI/coverage/platform or whole-wave evidence. + +**Review pending:** commit the author candidate, then obtain two NEW independent +complete cumulative clean Step 1 rounds after the last material fix. No Step 1 +acceptance or permission to advance to Step 2 is inferred from author checks alone. +Detailed working logs remain local and Git-ignored. diff --git a/packages/shared/src/run-event.ts b/packages/shared/src/run-event.ts index 8e5092be..2b7ad626 100644 --- a/packages/shared/src/run-event.ts +++ b/packages/shared/src/run-event.ts @@ -201,8 +201,8 @@ export const AgentTokenEventSchema = z.object({ * counterpart of `agent:token`: a **dual-envelope** event (`runId` on a run, `sessionId` on a session) * the correlation-agnostic turn core emits per `reasoning_delta` chunk. Carries the delta `text` + the * emitting `model` — never the ephemeral same-provider `signature` (a same-turn continuity token that is - * never written to an event or log, ADR-0030). A surface renders it as a collapsible "thinking" panel; a - * consumer that does not care ignores it forward-compatibly (an additive `type`, no `assertNever`). + * never written to an event or log, ADR-0030). Surfaces render a bounded thinking region; consumers of the closed event union + * explicitly handle it or name their deliberate ignore disposition. No signature becomes display data. */ export const AgentReasoningEventSchema = z.object({ type: z.literal('agent:reasoning'), From e1e9d69c09ab50d18ac610d0b97a2b2c0d5dcdd5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 02:10:08 +0300 Subject: [PATCH 018/222] docs(docs): record independent wave 3 step 1 acceptance Join two complete clean cumulative reviews and actual author gates. Correct the author file-count sum to 590 without changing product bytes, close the serialized queue findings and advance the live plan to step 2. Refs: Phase 2.5.5 Wave 3 Step 1 Co-Authored-By: Codex --- AGENTS.md | 5 +-- CLAUDE.md | 5 +-- docs/reference/cli/commands.md | 2 +- docs/roadmap/current.md | 7 ++-- .../phase-2.5.5-hardening-and-remediation.md | 35 +++++++++++++++---- 5 files changed, 40 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 70bd67af..ebdce2ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,8 +79,9 @@ and existing approved deferrals remain open in their canonical homes. No Wave 2 implementation, review, publication, ADR approval, provider capture, credential, billing or user-decision gate remains. Next is [Wave 3 — Clear the ground](docs/roadmap/current.md#wave-3--clear-the-ground), whose ADR-0110–0113/contracts/nineteen-increment plan were approved on 2026-10-16. -Its serialized multi-file Step 1 is active on `development`; implementation and -independent review acceptance remain staged in the canonical roadmap. +Its serialized multi-file Step 1 is independently accepted on `development` +(2026-10-16); Step 2 is active. Later implementation and whole-wave acceptance +remain staged in the canonical roadmap. See [docs/roadmap/current.md](docs/roadmap/current.md) for live status. diff --git a/CLAUDE.md b/CLAUDE.md index b5bffb4f..40de3957 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,8 +168,9 @@ and existing approved deferrals remain open in their canonical homes. No Wave 2 implementation, review, publication, ADR approval, provider capture, credential, billing or user-decision gate remains. Next is [Wave 3 — Clear the ground](docs/roadmap/current.md#wave-3--clear-the-ground), whose ADR-0110–0113/contracts/nineteen-increment plan were approved on 2026-10-16. -Its serialized multi-file Step 1 is active on `development`; implementation and -independent review acceptance remain staged in the canonical roadmap. +Its serialized multi-file Step 1 is independently accepted on `development` +(2026-10-16); Step 2 is active. Later implementation and whole-wave acceptance +remain staged in the canonical roadmap. For live status, per-PR history, milestone dates, and open obligations, see the canonical home [docs/roadmap/current.md](docs/roadmap/current.md); diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index b4e46099..15c38df0 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1,6 +1,6 @@ # CLI Command Reference (`relavium`) -> Last updated: 2026-10-15 +> Last updated: 2026-10-16 - **Status**: Reference (partial — surface defined, exact flags to be finalized as the CLI is built) - **Surface**: CLI (`relavium`) diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 3de34d0e..383a7b9b 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1109,8 +1109,9 @@ an ESLint selector rather than a list. **Approved for implementation — 2026-10-16.** The maintainer approved ADR-0110–0113, their linked unshipped contracts and the nineteen-increment plan. The [approval checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-maintainer-approval-and-implementation-checkpoint--2026-10-16) -records completed decision gates and staged implementation. Step 1 is active; -no implementation acceptance is claimed. The +records completed decision gates and staged implementation. +[Step 1 is accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-1-acceptance--2026-10-16) +after two NEW independent complete clean reviews; Step 2 is active. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. @@ -1132,7 +1133,7 @@ Two orderings here are not this plan's invention — they are the intra-phase se on `home-controller.ts`/`chat-ink.tsx` (the decomposition moves already-guarded code), and **C before E** on `packages/db/src/client.ts` (C types the error path in Wave 1; E's `openLocalDb` rewrite lands last, here). -- **Multi-file (strict, first)** — 2.5.5.E · `closeQuietly()` to its four siblings (#22) → `displayPath` +- ✅ **Multi-file Step 1 accepted — 2026-10-16** ([evidence](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-1-acceptance--2026-10-16)) — 2.5.5.E · `closeQuietly()` to its four siblings (#22) → `displayPath` discipline to `resolve.ts`/`catalog.ts`/`chat-export.ts` (#4, G11) → `const exhaustive: never` across the six sites (#300, #301, G3, which also makes `relavium run` render `agent:reasoning`). - **`chat.ts` (strict)** — 2.5.5.G · the four money/token reimplementations (#236, #237, #242, #307) → diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index fa7b7fda..4972fafe 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -310,16 +310,16 @@ This workstream is `apps/cli`'s outermost layer — the process/runtime host eve **Tasks:** - **Close the five remaining gaps in the CLI's outermost crash/signal safety net.** `processIo()` (`apps/cli/src/process/io.ts`) has no EPIPE handling, so a `--json` consumer that closes its reader early dumps a raw stack trace instead of a structured envelope. `buildProgram()` in `apps/cli/src/run.ts` is the one synchronous step inside `run()` not wrapped in the standard error path, despite `run()`'s own JSDoc promising it never rejects. There is zero `process.on('unhandledRejection'|'uncaughtException', ...)` registration anywhere in production `apps/cli/src` — an async error escaping `run()`'s own promise chain falls straight to Node's default handler, and `process.on('exit')` never reaps detached `run_command` child process groups or closes DB/MCP connections on a crash. The last-resort crash message in `apps/cli/src/index.ts` unconditionally writes plain text to stderr with no `--json` check, the sole exception to the CLI's otherwise-universal structured-envelope convention. Fix all five in `index.ts`/`run.ts`/`process/io.ts` together: add EPIPE handling, wrap `buildProgram()`, register the missing global handlers with child-process reaping and DB/MCP close-on-exit, and make the last-resort message honor `--json`. *(L · apps/cli/src/index.ts, apps/cli/src/run.ts, apps/cli/src/process/io.ts · #40, #41, #42, #224, #269)* **Source correction — 2026-10-15:** the recoverable rejection net already exists and keeps alive; preserve it. Proposed ADR-0110 adds fatal/early-import/output handling through registered owners. Do not close DB/MCP from exit without actual owner ACK or claim universal descendant containment; see the revised preflight and canonical proposed teardown scope. -- **Carry `const exhaustive: never` from `engine.ts` to the three sibling switches it was never propagated to, two of which drop live data.** `apps/cli/src/chat/persister.ts`'s event switch — the sole writer of `agent_sessions`/`session_costs` — silently drops any unhandled event kind via a bare `default`, a real money/data risk. Four picker key-routing switches in `apps/cli/src/render/tui/chat-ink.tsx` and `apps/cli/src/render/tui/home-controller.ts` lack the same guard on the reseat surface that already caused a data-loss-class bug. `apps/cli/src/render/tui/run-view-model.ts`'s `reduceRunEvent` silently drops `agent:reasoning` with no state field to hold it, so `relavium run` never shows reasoning that `chat` already renders — the exact bug class the guard in `apps/cli/src/chat/session-host.ts`'s `engine.ts`-derived pattern was invented to prevent, recurring on a different driver. Apply `const exhaustive: never` uniformly across all six sites. *(M · apps/cli/src/chat/persister.ts · #300, #301, G3)* +- ✅ **DONE — Wave 3 Step 1, 2026-10-16** ([acceptance](#wave-3-step-1-acceptance--2026-10-16)). **Carry `const exhaustive: never` from `engine.ts` to the three sibling switches it was never propagated to, two of which drop live data.** `apps/cli/src/chat/persister.ts`'s event switch — the sole writer of `agent_sessions`/`session_costs` — silently drops any unhandled event kind via a bare `default`, a real money/data risk. Four picker key-routing switches in `apps/cli/src/render/tui/chat-ink.tsx` and `apps/cli/src/render/tui/home-controller.ts` lack the same guard on the reseat surface that already caused a data-loss-class bug. `apps/cli/src/render/tui/run-view-model.ts`'s `reduceRunEvent` silently drops `agent:reasoning` with no state field to hold it, so `relavium run` never shows reasoning that `chat` already renders — the exact bug class the guard in `apps/cli/src/chat/session-host.ts`'s `engine.ts`-derived pattern was invented to prevent, recurring on a different driver. Apply `const exhaustive: never` uniformly across all six sites. *(M · apps/cli/src/chat/persister.ts · #300, #301, G3)* - **Centralize `openLocalDb` wrap-and-redact instead of leaving it well-wrapped at only 3 of 8+ call sites.** On the `run`/`chat`/`gate` paths the well-wrapped open happens AFTER an earlier unwrapped one, making the clean `CliError` message largely unreachable in practice. Separately, `packages/db/src/client.ts` interpolates a raw absolute path into its error with no redaction backstop, unlike `packages/llm`'s `scrubSecrets`. Move the wrap-and-redact logic inside `openLocalDb` itself so every caller gets it for free instead of re-implementing it. *(M · packages/db/src/client.ts · #278, #280)* - **Wire `agent-run.ts`'s missing SIGINT handler and make chat's existing one actually abort the session.** `apps/cli/src/commands/agent-run.ts` registers zero SIGINT handler, so Ctrl-C orphans spawned MCP child processes. `drivePlain`/`driveJson`'s SIGINT handlers (chat driver) only close readline and never call `session.abort()`, letting the billed generation run to completion. Register a handler in `agent-run.ts` mirroring `driveRun`'s teardown, and make the chat handlers call `abort()`. *(M · apps/cli/src/commands/agent-run.ts · #21, #23)* - **Wrap `gate.ts`'s two idempotent-resume success paths in the same `--json` branch every other command uses.** Both write plain human text to stdout unconditionally, breaking the NDJSON contract for scripted callers, and neither path is tested. No dependency on the 2.6.K resume-core extraction — fix in place. *(S · apps/cli/src/commands/gate.ts · #20)* - **Fix `/doctor`'s hardcoded probe profile, which misreports the exact thing it exists to diagnose.** `apps/cli/src/chat/doctor.ts` probes tool-host wiring with a hardcoded profile instead of the session's actual one, so it always misreports `egress`/`os` capability as unwired regardless of the real config. Probe with the session's actual profile. *(S · apps/cli/src/chat/doctor.ts · G43)* - **Implement `--help --json`'s promised `COMMAND_MANIFEST` serialization, or correct the docs that claim it exists.** Doc comments in `apps/cli/src/commands/manifest.ts` and user-facing docs both claim `--help --json` emits `COMMAND_MANIFEST` as JSON; no code path serializes it, confirmed by a passing test that asserts help stays human text. Either implement the missing serialization or amend the docs to match reality. *(M · apps/cli/src/commands/manifest.ts · #13)* - **Defer the CLI's eager `driveHome` import and unconditional `seedCatalog()` call behind command-need, closing the ~400ms `--help`/`--version` tax.** `apps/cli/src/run.ts`'s static import of `driveHome` pulls in its full transitive graph — ink, react, the home controller, the DB layer, the native keychain binding — on every process start, even for `--help`/`--version`. `seedCatalog()` (`apps/cli/src/engine/catalog-refresh.ts`) does an unconditional sync read/parse/validate of the cached model catalog before checking whether pricing data is even needed. Measured ~390-410ms wall time for `--version` against ~30ms bare Node startup; a separate measurement centered on `dispatch.ts` confirmed the same root cause independently. Gate both behind a dynamic import keyed on actual command need; likely requires enabling `splitting: true` in `apps/cli/tsup.config.ts`, which currently emits a single bundle file. *(L · apps/cli/src/run.ts · #43, #44, #271)* -- **Extend `authoring.ts`'s cwd-relative `displayPath` error-path discipline to `resolve.ts`, `catalog.ts`, and `chat/export.ts`.** `apps/cli/src/authoring/authoring.ts`'s `readAuthoredFile` already renders filesystem errors against a cwd-relative `displayPath` instead of the raw absolute path; `apps/cli/src/workflows/resolve.ts` and `apps/cli/src/workflows/catalog.ts` never adopted it, and neither did the filesystem `CliError`s in `apps/cli/src/commands/chat-export.ts`. Apply `displayPath` at all three remaining sites so no command leaks the user's absolute home-directory path into an error message. *(M · apps/cli/src/workflows/resolve.ts · #4, G11)* -- **Fix `resolve.ts`'s 1x UTF-8 byte multiplier, which undersizes the load cap for non-ASCII workflow files.** `apps/cli/src/workflows/resolve.ts` compares `stats.size` directly against `MAX_SOURCE_CHARS` (a 1x multiplier); `authoring.ts` and `catalog.ts` both correctly use `MAX_SOURCE_CHARS * 4` to account for UTF-8's up-to-4-bytes-per-char worst case. On the `relavium run` load path this means a dense multibyte YAML file well under the real char cap can be wrongly rejected as oversized. Apply the same `* 4` multiplier used by its two siblings. *(S · apps/cli/src/workflows/resolve.ts · #3)* -- **Extend `chat.ts`'s `closeQuietly()` helper to the four sibling commands still using an unguarded `.close()`.** `apps/cli/src/commands/chat.ts` wraps every store-close in `closeQuietly()` so a close-time throw can never overwrite a successful exit code; `apps/cli/src/commands/run.ts`, `apps/cli/src/commands/gate.ts`, `apps/cli/src/commands/chat-export.ts`, and `apps/cli/src/commands/chat-list.ts` all still call `.close()`/`opened.close()` unguarded. Extract the helper to a shared location and import it into all four. *(S · apps/cli/src/commands/chat.ts · #22)* +- ✅ **DONE — Wave 3 Step 1, 2026-10-16** ([acceptance](#wave-3-step-1-acceptance--2026-10-16)). **Extend `authoring.ts`'s cwd-relative `displayPath` error-path discipline to `resolve.ts`, `catalog.ts`, and `chat/export.ts`.** `apps/cli/src/authoring/authoring.ts`'s `readAuthoredFile` already renders filesystem errors against a cwd-relative `displayPath` instead of the raw absolute path; `apps/cli/src/workflows/resolve.ts` and `apps/cli/src/workflows/catalog.ts` never adopted it, and neither did the filesystem `CliError`s in `apps/cli/src/commands/chat-export.ts`. Apply `displayPath` at all three remaining sites so no command leaks the user's absolute home-directory path into an error message. *(M · apps/cli/src/workflows/resolve.ts · #4, G11)* +- ✅ **DONE — Wave 3 Step 1, 2026-10-16** ([acceptance](#wave-3-step-1-acceptance--2026-10-16)). **Fix `resolve.ts`'s 1x UTF-8 byte multiplier, which undersizes the load cap for non-ASCII workflow files.** `apps/cli/src/workflows/resolve.ts` compares `stats.size` directly against `MAX_SOURCE_CHARS` (a 1x multiplier); `authoring.ts` and `catalog.ts` both correctly use `MAX_SOURCE_CHARS * 4` to account for UTF-8's up-to-4-bytes-per-char worst case. On the `relavium run` load path this means a dense multibyte YAML file well under the real char cap can be wrongly rejected as oversized. Apply the same `* 4` multiplier used by its two siblings. *(S · apps/cli/src/workflows/resolve.ts · #3)* +- ✅ **DONE — Wave 3 Step 1, 2026-10-16** ([acceptance](#wave-3-step-1-acceptance--2026-10-16)). **Extend `chat.ts`'s `closeQuietly()` helper to the four sibling commands still using an unguarded `.close()`.** `apps/cli/src/commands/chat.ts` wraps every store-close in `closeQuietly()` so a close-time throw can never overwrite a successful exit code; `apps/cli/src/commands/run.ts`, `apps/cli/src/commands/gate.ts`, `apps/cli/src/commands/chat-export.ts`, and `apps/cli/src/commands/chat-list.ts` all still call `.close()`/`opened.close()` unguarded. Extract the helper to a shared location and import it into all four. *(S · apps/cli/src/commands/chat.ts · #22)* - **Close `agent run`'s two UX-parity gaps against its sibling drivers in one pass.** `apps/cli/src/commands/agent-run.ts` blocks silently on a TTY with no piped stdin and prints no banner, unlike `chat`/`create` which both print one. `makePlainPrinter` (shared with `apps/cli/src/commands/chat.ts`'s plain-printer path) has no `cost:updated` case or final cost line for `agent run`'s non-JSON output, unlike `run`'s TUI/plain renderer. Both are the same shape of gap — `agent run` is the newest, least-mature driver — so add the stdin-TTY banner `create` already has and the cost line `run` already prints in the same change. *(S · apps/cli/src/commands/agent-run.ts · #24, #26)* - **Add a dedicated exit code for unclassified internal faults, or amend the docs that call exit code 1 "workflow failed."** Exit code 1 is documented as meaning a failed workflow but is silently reused for any unclassified internal fault from any command, so a script branching on exit code cannot distinguish "your workflow failed" from "the CLI itself broke." Add a dedicated code or correct the documented contract. *(S · apps/cli/src/index.ts · G35)* - **Resolve `--quiet`/`--verbose`/`RELAVIUM_DEBUG` into one coherent diagnostic-flag system and point the CLI's generic error fallbacks at it.** `--quiet` has exactly one consumer, and that consumer hardcodes the value away — a complete no-op. `--verbose` only gates `err.stack` on a top-level crash and has zero effect during a live session. `RELAVIUM_DEBUG` is undocumented and is a second, inconsistently-named escape hatch alongside `--verbose`. Both of the CLI's generic "internal error" fallback messages never mention `--verbose` or `RELAVIUM_DEBUG` as a way to get more detail — the same incoherence, seen from the user's side of the same broken flag system. Wire `--quiet` to actually suppress output, extend `--verbose` to gate live-session diagnostics, fold `RELAVIUM_DEBUG` into `--verbose`'s precedence chain, and append the resulting hint to both generic fallback messages in the same change; the fallback-message fix cannot be written correctly until the flag relationship is settled. **Approved 2026-10-16 in ADR-0110:** quiet+verbose conflict, explicit flags over exact DEBUG=1, independent JSON representation and preserved requested data/safety categories. Implementation remains staged. *(M · apps/cli/src/index.ts · #45, #264, #265, #268)* @@ -4149,7 +4149,7 @@ private behind safe source labels. Reasoning has an independent bounded display buffer and never becomes persisted assistant text. No event/exit/usage contract is renamed; the later process/discovery changes remain unshipped. -Author gates pass: all workspace lint/typecheck and 690 test files, 13,020 passing +Author gates pass: all workspace lint/typecheck and 590 test files, 13,020 passing tests plus 20 established skips; all six build tasks pass. Seam-fence and changed- file strict-TypeScript screens, diff whitespace and 3,329 relative links/anchors also pass. Four local removal controls break 21/7/1/3 designated regressions for @@ -4158,7 +4158,30 @@ each source is restored byte-identically, followed by the passing complete autho suite. Filtered removal runs' unselected tests are not platform skips or coverage. These are local author checks, not full CI/coverage/platform or whole-wave evidence. -**Review pending:** commit the author candidate, then obtain two NEW independent +**Historical author-stage review gate (now satisfied below):** commit the author candidate, then obtain two NEW independent complete cumulative clean Step 1 rounds after the last material fix. No Step 1 acceptance or permission to advance to Step 2 is inferred from author checks alone. Detailed working logs remain local and Git-ignored. + +### Wave 3 Step 1 acceptance — 2026-10-16 + +Step 1 is independently accepted at `c610a4ad4947aca122cd4f0df807166735df67b3`. +Two NEW complete cumulative clean rounds cover all 31 changed paths and actual +consumers: gpt-6-astra/high (13 files / 250 independent passing tests) followed by +gpt-6.1-sol/high (27 files / 866 independent passing tests plus four Windows-path +semantic controls). Both report zero findings; the parent read both full reports. +They are new implementation reviews, separate from proposal acceptance. + +All scoped #22/#4/G11/#3/#300/#301/G3 work is complete. The author gates retain +their actual attribution: 590 files / 13,020 passing tests / 20 established skips, +all workspace lint/typecheck and six build tasks. Test-isolation additionally +confirms all 590 collected files belong to the six intended workspaces; source +formatting, seam fence, whitespace and relative links pass. The earlier author +file-count sum was mistakenly recorded as 690 and is corrected to 590 here and +in that receipt; component execution, test counts and reviewed product bytes +are unchanged. This metadata/landing update is not a new product revision or a +claim of whole-wave CI, coverage, platform or successor execution. + +Next is Step 2: shared admission/diagnostics and preserved existing guards. +No user decision or review fix is pending for Step 1. Detailed reports and local +removal logs remain Git-ignored; Step 19 retains whole-wave acceptance/publication. From 8ef7b3b619b6166c279200d31208a392680ee1a3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 02:28:19 +0300 Subject: [PATCH 019/222] fix(shared): enforce consistent authored admission invariants Validate duplicate parallel branches, typed condition matches and fallback identities at leaf and full boundaries. Share human-gate timeout validation and preserve legacy-compatible logical run fields while refusing present status contradictions. Keep one duplicate diagnostic and safe MCP reasons. Refs: ADR-0023, Phase 2.5.5 Wave 3 Step 2 Co-Authored-By: Codex --- docs/reference/contracts/agent-yaml-spec.md | 4 +- .../reference/contracts/workflow-yaml-spec.md | 7 +- docs/reference/shared-core/database-schema.md | 2 +- packages/shared/src/agent.test.ts | 49 ++++++ packages/shared/src/agent.ts | 15 +- packages/shared/src/node.test.ts | 129 +++++++++++++- packages/shared/src/node.ts | 57 +++++-- packages/shared/src/run.test.ts | 73 +++++++- packages/shared/src/run.ts | 26 +++ packages/shared/src/workflow.test.ts | 157 ++++++++++++++++++ packages/shared/src/workflow.ts | 18 +- 11 files changed, 495 insertions(+), 42 deletions(-) diff --git a/docs/reference/contracts/agent-yaml-spec.md b/docs/reference/contracts/agent-yaml-spec.md index 0a17a9ca..2eafad6b 100644 --- a/docs/reference/contracts/agent-yaml-spec.md +++ b/docs/reference/contracts/agent-yaml-spec.md @@ -9,7 +9,7 @@ An **agent** is a named, reusable LLM configuration: which model, which provider Agents are **git-committable** and shareable. An agent may live as its own `.agent.yaml` file (so it can be reused across workflows) or be declared inline inside a workflow's `agents:` array. Both forms validate against the same schema. -> **Strict validation.** `AgentSchema` rejects unknown/mistyped keys (e.g. `temprature`) rather than silently dropping them ([ADR-0023](../../decisions/0023-strict-authored-yaml-validation.md)). `temperature` is bounded to a finite `[0, 2]`, and a declared MCP `url` must use an `http(s)`/`ws(s)` scheme. +> **Strict validation.** `AgentSchema` rejects unknown/mistyped keys (e.g. `temprature`) rather than silently dropping them ([ADR-0023](../../decisions/0023-strict-authored-yaml-validation.md)). `temperature` is bounded to a finite `[0, 2]`, and a declared MCP `url` must use an `http(s)`/`ws(s)` scheme. The standalone parser retains field paths and uses the same [authored diagnostic reasons](workflow-yaml-spec.md#validation-and-versioning-rules) as workflow parsing. ## Schema @@ -64,7 +64,7 @@ fallback_chain: # ordered alternates tried after the primary is exha | `mcp_servers` | no | See [../shared-core/mcp-integration.md](../shared-core/mcp-integration.md). | | `memory` | no | `none`, `window` (with positive integer `window_size`) or `summary`; omitted preserves the full working transcript. Enforced by `AgentSession`; see [Conversational memory](#conversational-memory). **No effect on a workflow `agent` node**, including a node produced by session export: the node executes one turn. | | `retry` | no | Node-retry budget **above** the fallback chain — re-runs the whole node on a retryable failure, up to `max` total attempts ([ADR-0040](../../decisions/0040-node-retry-budget-above-the-chain.md)). `max` may not exceed **10** ([ADR-0086](../../decisions/0086-absolute-admission-ceilings-on-authored-values.md), which supersedes ADR-0040's "intentionally unbounded" clause). | -| `fallback_chain` | no | Switch to a *different* model/provider **within an attempt** (the within-chain failover); the node `retry` budget then re-runs the whole chain above it. At most **5** entries, each with `max_attempts` at most **10** ([ADR-0086](../../decisions/0086-absolute-admission-ceilings-on-authored-values.md)) — the two multiply, so both are bounded. | +| `fallback_chain` | no | The `(provider, model)` pair must be unique within the fallback list, irrespective of `max_attempts`; equality with the primary is permitted. Switch to a *different* model/provider **within an attempt** (the within-chain failover); the node `retry` budget then re-runs the whole chain above it. At most **5** entries, each with `max_attempts` at most **10** ([ADR-0086](../../decisions/0086-absolute-admission-ceilings-on-authored-values.md)) — the two multiply, so both are bounded. | | `tools` | no | The tools this agent may call. A single model RESPONSE may request at most **16** tool calls ([ADR-0086](../../decisions/0086-absolute-admission-ceilings-on-authored-values.md) §2) — a turn over that is refused before any of them is dispatched, so no effect fires. This is the model's width, not the author's; there is no field to raise it. | ## Conversational memory diff --git a/docs/reference/contracts/workflow-yaml-spec.md b/docs/reference/contracts/workflow-yaml-spec.md index 48242a79..f69788ce 100644 --- a/docs/reference/contracts/workflow-yaml-spec.md +++ b/docs/reference/contracts/workflow-yaml-spec.md @@ -306,6 +306,8 @@ Each node has an `id` (kebab-case, unique within the workflow) and a `type`. The timeout_action: reject # reject | approve (escalate is reserved — see note) ``` +`timeout_action` requires `timeout_ms` at both standalone node and complete workflow admission. A timeout without an action keeps the safe default `reject`; omitting both sets no timeout. + `timeout_action: approve` auto-approves on timeout — dangerous; use sparingly. **`escalate` is reserved in v1.0**: real escalation needs a Phase-2 notification system, so the validator rejects it for now (use `reject` or `approve`); a timeout then resolves with `decidedBy: 'timeout'`. Because parallel branches may each reach a gate, **multiple gates can be pending at once** — each carries an independent timeout and the surfaces show a pending-gate queue (`relavium gate list`). The gate lifecycle (suspend → notify → resume) is described in [sse-event-schema.md](sse-event-schema.md) and [../../architecture/execution-model.md](../../architecture/execution-model.md). ### `condition` node @@ -322,7 +324,7 @@ Each node has an `id` (kebab-case, unique within the workflow) and a `type`. The default: synthesize-report # taken when no `when` matches the evaluated result ``` -A condition node evaluates `expression` **once** and selects the branch whose `when` value **strictly equals** (`===`, no type coercion) the result; `when` values must be a boolean, string, or number. If no `when` matches and no `default` is set, the run fails with a typed "no branch matched" error rather than stalling. The `when` values are also the named output handles, referenceable from edges as `nodeId:when` (see [Edges](#edges)). +A condition node evaluates `expression` **once** and selects the branch whose `when` value **strictly equals** (`===`, no type coercion) the result; `when` values must be a boolean, string, or number and unique within the branch list under that typed equality. For example, `true` and `"true"` differ, while numeric `0` and `-0` are duplicates. If no `when` matches and no `default` is set, the run fails with a typed "no branch matched" error rather than stalling. The `when` values are also the named output handles, referenceable from edges as `nodeId:when` (see [Edges](#edges)). The `expression:` string is a **sandboxed JavaScript expression** — in v1.0 the only `expression_type` is **`js`**, evaluated in a deterministic, resource-capped sandbox (no I/O, no ambient globals, no wall-clock/RNG; [ADR-0027](../../decisions/0027-expression-sandbox.md)). `jmespath` and `jsonlogic` are **reserved** (each would add an undeclared dependency) and deferred to a future ADR. **There is no Python evaluator** — the engine is pure TypeScript ([ADR-0003](../../decisions/0003-pure-ts-engine-not-langgraph-python.md)). The `expression_type` set is owned by [node-types.md](../shared-core/node-types.md#per-type-engine-config); the full sandbox contract — scope, allow-list, caps, determinism, and the `sandbox_error` taxonomy — is owned by [expression-sandbox-spec.md](../shared-core/expression-sandbox-spec.md). Note an expression's **JS syntax is checked at evaluation, not at parse** — a typo'd `expression` parses fine and fails the first time its node runs, so test workflows before production. (An `expression_type` other than `js`, by contrast, **is** rejected at parse.) @@ -354,6 +356,8 @@ A sandboxed JavaScript expression (`expression_type: js`) whose result becomes t > **Indices into `branches` are relative, not absolute.** A branch a `condition` routed away from is **omitted** from the array (the survivors keep their relative order), so `branches[1]` is not pinned to any particular authored node. Write a `merge_fn` that tolerates a shorter array — or reach for `object_merge`, which is keyed rather than positional — whenever a branch can be skipped. +`parallel_of` members must be unique at standalone node and complete workflow admission; a duplicate cannot represent a second execution of the same branch. + > **`parallel_of` is authoritative for branch membership.** On a `parallel` node, `parallel_of: [...]` alone defines which nodes are the concurrent branches; the parser materializes a fan-out edge to each listed node, so explicit `from: fan-out` edges are **not required**. The complete example below writes them out for readability, but they are redundant with `parallel_of` and must not contradict it — if both are present they must agree (the validator rejects an explicit fan-out edge to a node not in `parallel_of`). ## Tool policy (`spec.tools`) @@ -656,6 +660,7 @@ workflow: ## Validation and versioning rules - The file is parsed and validated against `WorkflowSchema` (Zod, in `@relavium/shared`) at load time; invalid files throw a `WorkflowValidationError` and never start a run. +- Authored validation diagnostics name field paths and schema-derived reasons (expected types/literals, enum choices and structural constraints). Field locators may name validated declared identifiers; reasons do not echo rejected scalar/enum/literal payloads. Standalone agent parsing uses the same reason authority while preserving `AgentParseError.fields`. Duplicate MCP-registration diagnostics do not repeat the registration identities. - `schema_version` is the migration anchor. The format is a public API: breaking changes require a new `schema_version` and a migration tool. Add new optional fields freely; never repurpose or remove an existing one within `'1.0'`. - Node `id`s must be unique within a workflow and are referenced by edges, conditions, templates, and `run.outputs`. - Relavium resolves configured provider credentials from the OS keychain at call time and does not insert those values into YAML. Arbitrary authored strings can nevertheless contain credentials: schema validation is not content redaction. `secret` inputs are caller-supplied values admitted under [ADR-0083](../../decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md), not automatic keychain lookups. MCP-server `env` uses its separate named-secret resolver; author `{{secrets.*}}` placeholders rather than literals. The CLI's [bounded authoring/snapshot review](../cli/commands.md#relavium-import--relavium-export) emits value-free notices at create/import/run and preserves the parsed document and admitted augmented snapshot faithfully. A clean review is not proof that the content is safe to share. See [key custody and content retention](../desktop/keychain-and-secrets.md). diff --git a/docs/reference/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index 68b86fc8..fe2511ff 100644 --- a/docs/reference/shared-core/database-schema.md +++ b/docs/reference/shared-core/database-schema.md @@ -486,7 +486,7 @@ One row per workflow execution. `workflow_definition_snapshot` freezes the exact > **"The exact graph that ran" includes MCP-discovered tool grants.** A surface that augments the parsed workflow before starting the engine — `relavium run` unions each inline agent's `tools` grant with the tools its declared `mcp_servers` discovered — must freeze the **augmented** definition, not the authored one. It did not, until [ADR-0083](../../decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md) §5: on every workflow with `mcp_servers`, the column recorded a graph that never ran, and a cross-process `relavium gate` rebuilt the run from it. The grants are part of workflow identity, so an MCP server returning a different tool set on resume is a divergence — and one that can only be detected if what was frozen is what was executed. -> **Logical `Run` vs persisted `RunRow`.** `@relavium/shared` exports `RunSchema` — the **narrow, engine-/surface-facing** view of a run (status, trigger, inputs/outputs, token + cost totals, timestamps). This `runs` table is the **persistence** shape and carries additional columns that are a database concern, modeled by `@relavium/db` as a distinct `RunRow` mirroring the DDL below: `workflow_definition_snapshot` (the frozen graph for replay/resume), `trigger_metadata`, `workflow_path`/`project_root`, and the `deleted_at` soft-delete cursor. Those are intentionally absent from the logical `RunSchema`; a consumer that needs them reads the `RunRow`. The split keeps the engine view free of storage details while `@relavium/db` owns the row ↔ column mapping. +> **Logical `Run` vs persisted `RunRow`.** `@relavium/shared` exports `RunSchema` — the **narrow, engine-/surface-facing** view of a run (status, trigger, inputs/outputs, token + cost totals, timestamps). This `runs` table is the **persistence** shape and carries additional columns that are a database concern, modeled by `@relavium/db` as a distinct `RunRow` mirroring the DDL below: `workflow_definition_snapshot` (the frozen graph for replay/resume), `trigger_metadata`, `workflow_path`/`project_root`, and the `deleted_at` soft-delete cursor. Those are intentionally absent from the logical `RunSchema`; a consumer that needs them reads the `RunRow`. The split keeps the engine view free of storage details while `@relavium/db` owns the row ↔ column mapping. The logical validator permits missing legacy `startedAt`/`completedAt`, error and outputs; when present, `completedAt` requires completed/failed/cancelled status, `startedAt` is forbidden while pending, and `error` requires failed status. Existing temporal checks still apply. These are logical-view admission rules, not new SQL constraints, migrations or stricter persisted-row readers. | Column | Type | Constraints | |--------|------|-------------| diff --git a/packages/shared/src/agent.test.ts b/packages/shared/src/agent.test.ts index 95232517..47dcf30a 100644 --- a/packages/shared/src/agent.test.ts +++ b/packages/shared/src/agent.test.ts @@ -44,10 +44,59 @@ describe('AgentSchema', () => { ).toBe(false); }); + it('rejects a repeated fallback model/provider even when attempt budgets differ, without echoing model values', () => { + const model = 'private-model-sentinel'; + const result = AgentSchema.safeParse({ + ...summarizer, + fallback_chain: [ + { model, provider: 'openai', max_attempts: 1 }, + { model: 'other', provider: 'gemini', max_attempts: 1 }, + { model, provider: 'openai', max_attempts: 3 }, + ], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual([expect.objectContaining({ path: ['fallback_chain'] })]); + expect(result.error.issues[0]?.message).toContain('duplicate'); + expect(result.error.message).not.toContain(model); + } + }); + + it('preserves ordered fallback tuples sharing a model or provider and allows the primary once in the list', () => { + const agent = { + ...summarizer, + fallback_chain: [ + { model: summarizer.model, provider: summarizer.provider, max_attempts: 2 }, + { model: summarizer.model, provider: 'openai', max_attempts: 1 }, + { model: 'different-model', provider: summarizer.provider, max_attempts: 1 }, + ], + }; + expect(AgentSchema.parse(agent)).toEqual(agent); + expect(AgentSchema.safeParse({ ...summarizer, fallback_chain: [] }).success).toBe(true); + }); + it('rejects an empty system_prompt', () => { expect(AgentSchema.safeParse({ ...summarizer, system_prompt: '' }).success).toBe(false); }); + it('reports duplicate MCP registrations without reflecting their identity values', () => { + const identity = 'private-registration-sentinel'; + const result = AgentSchema.safeParse({ + ...summarizer, + mcp_servers: [{ ref: identity }, { ref: identity }], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual([ + expect.objectContaining({ + path: ['mcp_servers'], + message: 'duplicate mcp_servers identity', + }), + ]); + expect(result.error.message).not.toContain(identity); + } + }); + it('rejects duplicate mcp_servers ids within an agent', () => { const server = { id: 'gh', transport: 'stdio', command: 'npx' }; expect( diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index 07aa99f7..76ca42ff 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -311,6 +311,19 @@ export const AgentSchema = z }) .strict() .superRefine((agent, ctx) => { + // Identity is the model/provider pair within the fallback list, not the attempt count. + // Equality with the primary is permitted; tuple encoding avoids delimiter collisions. + const fallbacks = (agent.fallback_chain ?? []).map(({ model, provider }) => + JSON.stringify([model, provider]), + ); + if (findDuplicates(fallbacks).length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['fallback_chain'], + message: 'duplicate fallback_chain model/provider pair(s)', + }); + } + // MCP server identities must be unique within an agent (they namespace the registered tools). The identity // here is the EXACT inline `id` or by-name `ref` registration name. This catches the common exact-duplicate // case at parse; a host-side *sanitization* collision (two distinct free-form registration names that map to @@ -324,7 +337,7 @@ export const AgentSchema = z if (duplicates.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `duplicate mcp_servers id(s): ${duplicates.join(', ')}`, + message: 'duplicate mcp_servers identity', path: ['mcp_servers'], }); } diff --git a/packages/shared/src/node.test.ts b/packages/shared/src/node.test.ts index ef890b63..51b49f34 100644 --- a/packages/shared/src/node.test.ts +++ b/packages/shared/src/node.test.ts @@ -1,14 +1,133 @@ import { describe, expect, it } from 'vitest'; import { WORKFLOW_NODE_TYPES } from './constants.js'; -import { NodeSchema } from './node.js'; +import { + ConditionNodeSchema, + HumanGateNodeSchema, + NodeSchema, + ParallelNodeSchema, +} from './node.js'; + +describe.each([ + ['parallel leaf', ParallelNodeSchema], + ['node union', NodeSchema], +] as const)('%s membership admission', (_label, schema) => { + it('refuses a repeated branch without echoing the branch identity', () => { + const member = 'private-branch-sentinel'; + const result = schema.safeParse({ + id: 'fan', + type: 'parallel', + parallel_of: [member, 'other', member], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toHaveLength(1); + expect(result.error.issues[0]?.path).toEqual(['parallel_of']); + expect(result.error.issues[0]?.message).toContain('duplicate'); + expect(result.error.message).not.toContain(member); + } + }); + + it('retains declared order for distinct branches', () => { + const node = { id: 'fan', type: 'parallel', parallel_of: ['z-last', 'a-first'] }; + expect(schema.parse(node)).toEqual(node); + }); +}); + +describe.each([ + ['condition leaf', ConditionNodeSchema], + ['node union', NodeSchema], +] as const)('%s branch admission', (_label, schema) => { + const condition = { id: 'choose', type: 'condition', expression: 'inputs.choice' }; + + it.each([true, false, 0, 7, 'private-when-sentinel'])( + 'refuses duplicate typed when value (%s)', + (when) => { + const result = schema.safeParse({ + ...condition, + branches: [ + { when, target_node: 'first' }, + { when, target_node: 'second' }, + ], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toHaveLength(1); + expect(result.error.issues[0]?.path).toEqual(['branches']); + expect(result.error.issues[0]?.message).toContain('duplicate'); + expect(result.error.message).not.toContain('private-when-sentinel'); + } + }, + ); + + it('refuses -0 and 0 as the same numeric branch match', () => { + const result = schema.safeParse({ + ...condition, + branches: [ + { when: -0, target_node: 'first' }, + { when: 0, target_node: 'second' }, + ], + }); + expect(result.success).toBe(false); + if (!result.success) + expect(result.error.issues.map((issue) => issue.path)).toEqual([['branches']]); + }); + + it('preserves boolean/string and number/string distinctions and permits a shared target', () => { + const node = { + ...condition, + branches: [ + { when: true, target_node: 'same' }, + { when: 'true', target_node: 'same' }, + { when: 0, target_node: 'same' }, + { when: '0', target_node: 'same' }, + ], + }; + expect(schema.parse(node)).toEqual(node); + }); +}); + +describe.each([ + ['human gate leaf', HumanGateNodeSchema], + ['node union', NodeSchema], +] as const)('%s timeout admission', (_label, schema) => { + const gate = { id: 'approval', type: 'human_gate', gate_type: 'approval' }; + + it.each(['approve', 'reject'])( + 'refuses orphan timeout_action=%s at the action field', + (timeout_action) => { + const result = schema.safeParse({ ...gate, timeout_action }); + expect(result.success).toBe(false); + if (!result.success) + expect(result.error.issues).toEqual([ + expect.objectContaining({ + path: ['timeout_action'], + message: 'timeout_action requires timeout_ms', + }), + ]); + }, + ); + + it('permits an untimed gate and timeout without an explicit action', () => { + expect(schema.parse(gate)).toEqual(gate); + expect(schema.parse({ ...gate, timeout_ms: 1 })).toEqual({ ...gate, timeout_ms: 1 }); + }); + + it.each(['approve', 'reject'])( + 'permits timeout_action=%s with a valid timeout', + (timeout_action) => { + const node = { ...gate, timeout_action, timeout_ms: 1 }; + expect(schema.parse(node)).toEqual(node); + }, + ); +}); describe('NodeSchema', () => { it('the union has exactly the eight authored node types', () => { // Each type's acceptance is proven by "accepts a minimal valid node of each type" // below (so a rename fails there); the count catches an extra/missing variant without // reading Zod internals. - expect(NodeSchema.options).toHaveLength(WORKFLOW_NODE_TYPES.length); + expect(NodeSchema.innerType().options).toHaveLength(WORKFLOW_NODE_TYPES.length); }); it('accepts a minimal valid node of each authored type', () => { @@ -247,8 +366,8 @@ describe('NodeSchema', () => { }); it('accepts a custom merge with no merge_fn at the node level (the cross-field rule lives at WorkflowSchema)', () => { - // A discriminated-union member can't carry a cross-field refinement, so `merge_strategy:custom` - // without `merge_fn` is intentionally accepted here and only rejected at WorkflowSchema level. + // This existing exception is deliberate: `merge_strategy:custom` without `merge_fn` + // remains accepted here and is rejected at the complete WorkflowSchema boundary. expect(NodeSchema.safeParse({ id: 'm', type: 'merge', merge_strategy: 'custom' }).success).toBe( true, ); @@ -320,7 +439,7 @@ describe('NodeSchema', () => { }); it('rejects the reserved timeout_action escalate (v1.0 allows only reject / approve)', () => { - const gate = { id: 'g', type: 'human_gate', gate_type: 'approval' }; + const gate = { id: 'g', type: 'human_gate', gate_type: 'approval', timeout_ms: 1 }; expect(NodeSchema.safeParse({ ...gate, timeout_action: 'escalate' }).success).toBe(false); expect(NodeSchema.safeParse({ ...gate, timeout_action: 'reject' }).success).toBe(true); expect(NodeSchema.safeParse({ ...gate, timeout_action: 'approve' }).success).toBe(true); diff --git a/packages/shared/src/node.ts b/packages/shared/src/node.ts index dd8f20b6..4d1b9a2b 100644 --- a/packages/shared/src/node.ts +++ b/packages/shared/src/node.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; import { jsonSchemaMetadataSchema, + findDuplicates, kebabIdSchema, nonEmptyString, positiveInt, @@ -142,7 +143,7 @@ export const AgentNodeSchema = z }) .strict(); -export const HumanGateNodeSchema = z +const HumanGateNodeFields = z .object({ id: kebabIdSchema, type: z.literal('human_gate'), @@ -154,6 +155,22 @@ export const HumanGateNodeSchema = z }) .strict(); +/** Keep the cross-field rule identical on the exported leaf and the discriminated union. */ +function validateHumanGateTimeout( + node: z.infer, + ctx: z.RefinementCtx, +): void { + if (node.timeout_action !== undefined && node.timeout_ms === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['timeout_action'], + message: 'timeout_action requires timeout_ms', + }); + } +} + +export const HumanGateNodeSchema = HumanGateNodeFields.superRefine(validateHumanGateTimeout); + /** A branch of a `condition` node: `when` value → `target_node`. */ export const ConditionBranchSchema = z .object({ @@ -170,7 +187,12 @@ export const ConditionNodeSchema = z expression_type: ExpressionTypeSchema.optional(), branches: z .array(ConditionBranchSchema) - .min(1, 'a condition node must declare at least one branch'), + .min(1, 'a condition node must declare at least one branch') + .refine( + (branches) => + findDuplicates(branches.map(({ when }) => `${typeof when}:${String(when)}`)).length === 0, + { message: 'duplicate branch when value(s)' }, + ), default: kebabIdSchema.optional(), retry: RetrySchema.optional(), // above-chain node-retry budget (ADR-0040) — the expression runs in the sandbox }) @@ -192,7 +214,12 @@ export const ParallelNodeSchema = z id: kebabIdSchema, type: z.literal('parallel'), // Authoritative for branch membership; the parser materializes a fan-out edge per entry. - parallel_of: z.array(kebabIdSchema).min(1), + parallel_of: z + .array(kebabIdSchema) + .min(1) + .refine((members) => findDuplicates(members).length === 0, { + message: 'duplicate parallel_of member(s)', + }), }) .strict(); @@ -218,14 +245,18 @@ export const OutputNodeSchema = z .strict(); /** The authored node discriminated union. */ -export const NodeSchema = z.discriminatedUnion('type', [ - InputNodeSchema, - AgentNodeSchema, - HumanGateNodeSchema, - ConditionNodeSchema, - TransformNodeSchema, - ParallelNodeSchema, - MergeNodeSchema, - OutputNodeSchema, -]); +export const NodeSchema = z + .discriminatedUnion('type', [ + InputNodeSchema, + AgentNodeSchema, + HumanGateNodeFields, + ConditionNodeSchema, + TransformNodeSchema, + ParallelNodeSchema, + MergeNodeSchema, + OutputNodeSchema, + ]) + .superRefine((node, ctx) => { + if (node.type === 'human_gate') validateHumanGateTimeout(node, ctx); + }); export type WorkflowNode = z.infer; diff --git a/packages/shared/src/run.test.ts b/packages/shared/src/run.test.ts index cf0ec6f9..aa56f1d9 100644 --- a/packages/shared/src/run.test.ts +++ b/packages/shared/src/run.test.ts @@ -70,9 +70,76 @@ describe('RunSchema', () => { }); it('enforces temporal invariants (completedAt >= startedAt, updatedAt >= createdAt)', () => { - expect(RunSchema.safeParse({ ...run, startedAt: 2000, completedAt: 1000 }).success).toBe(false); - expect(RunSchema.safeParse({ ...run, createdAt: 2000, updatedAt: 1000 }).success).toBe(false); - expect(RunSchema.safeParse({ ...run, startedAt: 1000, completedAt: 2000 }).success).toBe(true); + const backwardsFinish = RunSchema.safeParse({ + ...run, + status: 'completed', + startedAt: 2000, + completedAt: 1000, + }); + expect(backwardsFinish.success).toBe(false); + if (!backwardsFinish.success) + expect(backwardsFinish.error.issues).toEqual([ + expect.objectContaining({ + path: ['completedAt'], + message: 'completedAt must be >= startedAt', + }), + ]); + const backwardsUpdate = RunSchema.safeParse({ ...run, createdAt: 2000, updatedAt: 1000 }); + expect(backwardsUpdate.success).toBe(false); + if (!backwardsUpdate.success) + expect(backwardsUpdate.error.issues).toEqual([ + expect.objectContaining({ + path: ['updatedAt'], + message: 'updatedAt must be >= createdAt', + }), + ]); + expect( + RunSchema.safeParse({ ...run, status: 'completed', startedAt: 1000, completedAt: 2000 }) + .success, + ).toBe(true); + expect( + RunSchema.safeParse({ ...run, status: 'completed', startedAt: 1000, completedAt: 1000 }) + .success, + ).toBe(true); + }); + + // Explicit contract matrix, independent of the implementation's status predicates. + const lifecycle = [ + { status: 'pending', startedAt: false, completedAt: false, error: false }, + { status: 'running', startedAt: true, completedAt: false, error: false }, + { status: 'paused', startedAt: true, completedAt: false, error: false }, + { status: 'completed', startedAt: true, completedAt: true, error: false }, + { status: 'failed', startedAt: true, completedAt: true, error: true }, + { status: 'cancelled', startedAt: true, completedAt: true, error: false }, + ] as const; + const completionFields = { + startedAt: 1000, + completedAt: 2000, + error: { code: 'internal', message: 'PRIVATE_ERROR_SENTINEL', retryable: false }, + }; + + describe.each(lifecycle)('$status logical record compatibility', (row) => { + it('allows missing legacy lifecycle fields and retains optional outputs', () => { + const absent = { ...run, status: row.status }; + expect(RunSchema.parse(absent)).toEqual(absent); + const withOutputs = { ...absent, outputs: { partial: 'legacy-result' } }; + expect(RunSchema.parse(withOutputs)).toEqual(withOutputs); + }); + + it.each(['startedAt', 'completedAt', 'error'] as const)( + 'enforces the present %s contract independently', + (field) => { + const value = { ...run, status: row.status, [field]: completionFields[field] }; + const result = RunSchema.safeParse(value); + expect(result.success).toBe(row[field]); + if (result.success) expect(result.data).toEqual(value); + else { + expect(result.error.issues).toHaveLength(1); + expect(result.error.issues[0]?.path).toEqual([field]); + expect(result.error.message).not.toContain('PRIVATE_ERROR_SENTINEL'); + } + }, + ); }); it('accepts the optional completion fields (error, startedAt, completedAt) when present', () => { diff --git a/packages/shared/src/run.ts b/packages/shared/src/run.ts index 5a496250..2f9122fd 100644 --- a/packages/shared/src/run.ts +++ b/packages/shared/src/run.ts @@ -70,6 +70,32 @@ export const RunSchema = z updatedAt: nonNegativeInt, }) .superRefine((run, ctx) => { + // The logical view permits missing legacy timestamps/error/output fields. Contradictory + // present fields are refused; this schema is not the persisted RunRow reader. + const terminal = + run.status === 'completed' || run.status === 'failed' || run.status === 'cancelled'; + if (run.completedAt !== undefined && !terminal) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['completedAt'], + message: 'completedAt requires a terminal status', + }); + } + if (run.startedAt !== undefined && run.status === 'pending') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['startedAt'], + message: 'startedAt is not permitted while pending', + }); + } + if (run.error !== undefined && run.status !== 'failed') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['error'], + message: 'error requires failed status', + }); + } + // Temporal invariants: a run cannot finish before it starts, or be updated before it was created. if ( run.startedAt !== undefined && diff --git a/packages/shared/src/workflow.test.ts b/packages/shared/src/workflow.test.ts index ac8582c8..3a5d5325 100644 --- a/packages/shared/src/workflow.test.ts +++ b/packages/shared/src/workflow.test.ts @@ -271,6 +271,163 @@ describe('WorkflowSchema', () => { ).toBe(false); }); + it('reports exactly one value-free full-document duplicate parallel diagnostic', () => { + const result = WorkflowSchema.safeParse( + withWorkflow({ + nodes: base.workflow.nodes.map((node) => + node.id === 'fan-out' + ? { + ...node, + parallel_of: ['security-scan-node', 'style-review-node', 'style-review-node'], + } + : node, + ), + }), + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual([ + expect.objectContaining({ path: ['workflow', 'nodes', 1, 'parallel_of'] }), + ]); + expect(result.error.issues[0]?.message).toContain('duplicate'); + expect(result.error.message).not.toContain('style-review-node'); + expect(result.error.message).not.toContain('fan-out'); + } + }); + + it.each([true, 'private-branch-value', 0, -0])( + 'rejects repeated condition matches in a complete workflow (%s)', + (when) => { + const result = WorkflowSchema.safeParse( + withWorkflow({ + nodes: base.workflow.nodes.map((node) => + node.id === 'severity-gate' + ? { + ...node, + branches: [ + { when, target_node: 'human-approval' }, + { when, target_node: 'synthesize-report' }, + ], + } + : node, + ), + }), + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual([ + expect.objectContaining({ path: ['workflow', 'nodes', 5, 'branches'] }), + ]); + expect(result.error.issues[0]?.message).toContain('duplicate'); + expect(result.error.message).not.toContain('private-branch-value'); + } + }, + ); + + it('preserves typed distinct condition matches at the full-document boundary', () => { + const node = { + id: 'choice', + type: 'condition', + expression: 'inputs.choice', + branches: [ + { when: true, target_node: 'done' }, + { when: 'true', target_node: 'done' }, + { when: 0, target_node: 'done' }, + { when: '0', target_node: 'done' }, + ], + }; + const doc = { + schema_version: '1.0', + workflow: { id: 'typed-choice', nodes: [node, { id: 'done', type: 'output' }], edges: [] }, + }; + expect(WorkflowSchema.parse(doc)).toEqual(doc); + }); + + it.each(['approve', 'reject'])( + 'rejects an orphan gate action %s in a full document', + (timeout_action) => { + const result = WorkflowSchema.safeParse({ + schema_version: '1.0', + workflow: { + id: 'untimed', + nodes: [{ id: 'approval', type: 'human_gate', gate_type: 'approval', timeout_action }], + edges: [], + }, + }); + expect(result.success).toBe(false); + if (!result.success) + expect(result.error.issues).toEqual([ + expect.objectContaining({ + path: ['workflow', 'nodes', 0, 'timeout_action'], + message: 'timeout_action requires timeout_ms', + }), + ]); + }, + ); + + it.each([ + { label: 'untimed', fields: {} }, + { label: 'default timeout action', fields: { timeout_ms: 1 } }, + { label: 'timed approval', fields: { timeout_ms: 1, timeout_action: 'approve' } }, + { label: 'timed rejection', fields: { timeout_ms: 1, timeout_action: 'reject' } }, + ])('preserves a $label gate in a complete document', ({ fields }) => { + const doc = { + schema_version: '1.0', + workflow: { + id: 'gate-contract', + nodes: [{ id: 'approval', type: 'human_gate', gate_type: 'approval', ...fields }], + edges: [], + }, + }; + expect(WorkflowSchema.parse(doc)).toEqual(doc); + }); + + it('rejects duplicate inline-agent fallback tuples without echoing their model', () => { + const model = 'PRIVATE_FALLBACK_SENTINEL'; + const result = WorkflowSchema.safeParse( + withWorkflow({ + agents: [ + { + ...base.workflow.agents[0], + fallback_chain: [ + { model, provider: 'openai', max_attempts: 1 }, + { model, provider: 'openai', max_attempts: 2 }, + ], + }, + ...base.workflow.agents.slice(1), + ], + }), + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual([ + expect.objectContaining({ path: ['workflow', 'agents', 0, 'fallback_chain'] }), + ]); + expect(result.error.issues[0]?.message).toContain('duplicate'); + expect(result.error.message).not.toContain(model); + } + }); + + it('accepts an inline agent fallback equal to its primary alongside distinct model/provider tuples', () => { + expect( + accepts( + withWorkflow({ + agents: [ + { + ...base.workflow.agents[0], + fallback_chain: [ + { model: 'claude-sonnet-4-6', provider: 'anthropic', max_attempts: 1 }, + { model: 'claude-sonnet-4-6', provider: 'openai', max_attempts: 1 }, + { model: 'other', provider: 'anthropic', max_attempts: 1 }, + ], + }, + ...base.workflow.agents.slice(1), + ], + }), + ), + ).toBe(true); + }); + it('rejects a trigger type whose required payload is absent', () => { expect(accepts(withWorkflow({ trigger: { type: 'webhook' } }))).toBe(false); expect(accepts(withWorkflow({ trigger: { type: 'file_change' } }))).toBe(false); diff --git a/packages/shared/src/workflow.ts b/packages/shared/src/workflow.ts index ec1682f8..735834a5 100644 --- a/packages/shared/src/workflow.ts +++ b/packages/shared/src/workflow.ts @@ -688,22 +688,8 @@ export const WorkflowSchema = z ['workflow', 'agents'], ); - // A duplicate `parallel_of` member is a silent wrong answer, not an ergonomics nit: the DAG builder - // copies `parallel_of` verbatim into the merge's `branchNodeIds`, so the branch's output appears TWICE - // in a `concat` array and twice in a `merge_fn`'s `branches`, shifting every index after it. The - // dependency graph de-duplicates (a Set), so the node runs once and nothing else disagrees — which is - // exactly why it reaches the result unnoticed. There is no reading of `parallel_of: [a, a]` that means - // anything, so it is refused here with the other authored-duplicate checks. - nodes.forEach((node, i) => { - if (node.type === 'parallel') { - reportDuplicates(node.parallel_of, `parallel_of member(s) on node \`${node.id}\``, [ - 'workflow', - 'nodes', - i, - 'parallel_of', - ]); - } - }); + // NodeSchema owns parallel member uniqueness at both leaf and full-document boundaries. + // Do not add a second duplicate diagnostic at the workflow level. // `parallel_of` is authoritative for branch membership: an explicit edge out of a // `parallel` node must target a node listed in that node's `parallel_of` From 26cd4c00e0ff99cd42faab8985400f77e320dab0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 02:28:19 +0300 Subject: [PATCH 020/222] fix(core): share value-free authored parser reasons Report schema literal expectations and standalone agent constraint reasons through one internal helper, preserving typed field identities and excluding received payloads. Keep strict unknown-key diagnostics linear. Update the resource-ceiling fixture to isolate ceilings from duplicate admission. Refs: ADR-0023, Phase 2.5.5 Wave 3 Step 2 Co-Authored-By: Codex --- packages/core/src/agent-parser.test.ts | 48 ++++++++++++++++++++++++++ packages/core/src/agent-parser.ts | 36 ++++++++++--------- packages/core/src/limits.test.ts | 5 +-- packages/core/src/parser.test.ts | 4 +++ packages/core/src/parser.ts | 46 ++---------------------- packages/core/src/schema-issue.test.ts | 29 ++++++++++++++++ packages/core/src/schema-issue.ts | 46 ++++++++++++++++++++++++ 7 files changed, 151 insertions(+), 63 deletions(-) create mode 100644 packages/core/src/schema-issue.test.ts create mode 100644 packages/core/src/schema-issue.ts diff --git a/packages/core/src/agent-parser.test.ts b/packages/core/src/agent-parser.test.ts index 82a62126..65224dd9 100644 --- a/packages/core/src/agent-parser.test.ts +++ b/packages/core/src/agent-parser.test.ts @@ -105,6 +105,54 @@ describe('parseAgent', () => { } }); + it('does not expose rejected MCP identities when explaining a duplicate registration', () => { + const identity = 'private-identity-sentinel'; + try { + parseAgent( + `${VALID_MINIMAL}\nmcp_servers:\n - {ref: ${identity}}\n - {ref: ${identity}}\n`, + ); + expect.unreachable('duplicate registration must refuse'); + } catch (error) { + if (!(error instanceof AgentParseError)) throw error; + expect(error.fields).toEqual(['mcp_servers']); + expect(error.message).toContain('duplicate mcp_servers identity'); + expect(error.message).not.toContain(identity); + expect(error.cause).toBeUndefined(); + } + }); + + it('explains missing and invalid fields without echoing the received value', () => { + const secret = 'private-provider-sentinel'; + try { + parseAgent(`id: tiny\nmodel: m\nprovider: ${secret}\n`); + expect.unreachable('must reject'); + } catch (error) { + if (!(error instanceof AgentParseError)) throw error; + expect(error.fields).toContain('provider'); + expect(error.fields).toContain('system_prompt'); + expect(error.message).toContain('expected one of:'); + expect(error.message).toContain('system_prompt: missing — expected string'); + expect(error.message).not.toContain(secret); + expect(error.cause).toBeUndefined(); + } + }); + + it('keeps strict unknown-key diagnostics linear in the key inventory', () => { + const keys = Array.from({ length: 128 }, (_, i) => `extra_${i}`); + try { + parseAgent( + `${VALID_MINIMAL}\n${keys.map((key) => `${key}: private-value-sentinel`).join('\n')}`, + ); + expect.unreachable('must reject unknown fields'); + } catch (error) { + if (!(error instanceof AgentParseError)) throw error; + expect(error.fields).toEqual(keys); + expect(error.message).toContain('unknown keys:'); + expect(error.message.length).toBeLessThan(10_000); + expect(error.message).not.toContain('private-value-sentinel'); + } + }); + it('rejects a schema failure as agent_validation, naming the failing field path (no value)', () => { // Missing required `system_prompt` + a kebab-invalid id. try { diff --git a/packages/core/src/agent-parser.ts b/packages/core/src/agent-parser.ts index 180f21c3..6085f391 100644 --- a/packages/core/src/agent-parser.ts +++ b/packages/core/src/agent-parser.ts @@ -1,3 +1,4 @@ +import { schemaIssueReason } from './schema-issue.js'; /** * `parseAgent` — the standalone `.agent.yaml` parser, the agent sibling of {@link ./parser.ts}'s * `parseWorkflow`. It loads an agent-document **string**, decodes it through the one hardened YAML profile @@ -145,26 +146,27 @@ export function parseAgent(yamlText: string, opts?: ParseAgentOptions): AgentDef const result = AgentSchema.safeParse(raw); if (!result.success) { - // Field PATHS only (key names / indices) — never an authored value, so the message stays secret-free. For + // Field paths and schema-derived reasons only — never a rejected authored value. For // an `unrecognized_keys` issue Zod puts the unknown key NAMES in `issue.keys` (not the path), so name those // — prefixed with the parent path for a NESTED `.strict()` object (`retry`, `mcp_servers[i]`, memory) so the // context isn't lost, and bare at the root (empty path) — mirroring parser.ts's field locator. - const fields = [ - ...new Set( - result.error.issues.flatMap((issue: ZodIssue) => { - if (issue.code === 'unrecognized_keys') { - const prefix = issue.path.length > 0 ? `${issue.path.map(String).join('.')}.` : ''; - return issue.keys.map((key) => `${prefix}${key}`); - } - return [issue.path.map(String).join('.') || 'agent']; - }), - ), - ]; - throw new AgentParseError( - 'agent_validation', - `invalid agent${label}: ${fields.join(', ')}`, - fields, - ); + const diagnostics = result.error.issues.map((issue: ZodIssue) => { + const paths = + issue.code === 'unrecognized_keys' + ? issue.keys.map( + (key) => + `${issue.path.length > 0 ? `${issue.path.map(String).join('.')}.` : ''}${key}`, + ) + : [issue.path.map(String).join('.') || 'agent']; + return { paths, reason: schemaIssueReason(issue) }; + }); + const fields = [...new Set(diagnostics.flatMap(({ paths }) => paths))]; + // One reason per issue, not per unknown key: a strict-object refusal stays linear in its key count. + const detail = diagnostics + .map(({ paths, reason }) => `${paths.join(', ')}: ${reason}`) + .join('; '); + + throw new AgentParseError('agent_validation', `invalid agent${label}: ${detail}`, fields); } // **`output_schema` is compiled HERE too** (ADR-0092 §3). A standalone agent never reaches a DAG build — // `AgentSession` (`relavium chat`, `agent run`) has no plan — so plan-build was the one place this could diff --git a/packages/core/src/limits.test.ts b/packages/core/src/limits.test.ts index b2912f1b..692cfb12 100644 --- a/packages/core/src/limits.test.ts +++ b/packages/core/src/limits.test.ts @@ -339,8 +339,9 @@ workflow: it('rejects an over-long fallback chain and every over-budget entry in it, not just the first', () => { const entries = Array.from( { length: ADMISSION_CEILINGS.fallbackChainEntries + 1 }, - () => - `{ model: m, provider: anthropic, max_attempts: ${ADMISSION_CEILINGS.chainEntryMaxAttempts + 1} }`, + // Distinct identities isolate resource ceilings from the earlier duplicate-tuple admission guard. + (_, index) => + `{ model: m-${index}, provider: anthropic, max_attempts: ${ADMISSION_CEILINGS.chainEntryMaxAttempts + 1} }`, ).join(', '); const yaml = `schema_version: '1.0' workflow: diff --git a/packages/core/src/parser.test.ts b/packages/core/src/parser.test.ts index aa10a72f..1701ef3c 100644 --- a/packages/core/src/parser.test.ts +++ b/packages/core/src/parser.test.ts @@ -414,6 +414,10 @@ describe('parseWorkflow — diagnostic field naming (issue-mapper coverage)', () `schema_version: '2.0'\nworkflow:\n id: w\n nodes:\n - id: n\n type: input\n edges: []`, ); expect(err.issues.some((i) => i.field === 'schema_version')).toBe(true); + expect(err.issues.find((i) => i.field === 'schema_version')?.message).toBe( + 'expected literal "1.0"', + ); + expect(err.message).not.toContain('2.0'); }); it('handles a non-object workflow body without crashing', () => { diff --git a/packages/core/src/parser.ts b/packages/core/src/parser.ts index e55efed8..f76ba565 100644 --- a/packages/core/src/parser.ts +++ b/packages/core/src/parser.ts @@ -1,3 +1,4 @@ +import { schemaIssueReason } from './schema-issue.js'; /** * `WorkflowYAMLParser` (1.L / 1.L2) — the engine's entry point. Loads a `.relavium.yaml` **string**, * validates it against the strict `@relavium/shared` `WorkflowSchema` (ADR-0023), and runs the static @@ -176,7 +177,7 @@ function syntaxErrorFrom( // --- Zod issue → field-named, secret-free WorkflowIssue -------------------------------------- function describeIssue(issue: ZodIssue, root: unknown): WorkflowIssue { - return { field: locate(issue.path, root), message: messageFor(issue) }; + return { field: locate(issue.path, root), message: schemaIssueReason(issue) }; } /** @@ -244,49 +245,6 @@ function itemLabel( } } -/** - * A user-facing message for a Zod issue. Deliberately code-derived (type names, key names, enum - * options) — it never echoes an authored *value*, so a secret in a credential field can never leak. - * - * `custom` issues come from `@relavium/shared`'s `superRefine` calls. Today every one of them emits - * a structural message (schema-defined key names, kebab-id-validated identifiers, type-enum labels) - * — never an authored *value*. The explicit `case 'custom':` here makes that invariant visible in - * the call site: if a future refine ever embeds an authored value, this switch is the first place a - * reviewer should add a safe, value-free override. The `default` branch is the last-resort fallback - * for Zod codes not yet seen in practice; it returns a generic message so an unknown code can never - * accidentally surface a payload. - */ -function messageFor(issue: ZodIssue): string { - switch (issue.code) { - case 'invalid_type': - return issue.received === 'undefined' - ? `missing — expected ${issue.expected}` - : `expected ${issue.expected}, received ${issue.received}`; - case 'unrecognized_keys': - return `unknown key${issue.keys.length === 1 ? '' : 's'}: ${issue.keys - .map((key) => `\`${key}\``) - .join(', ')}`; - case 'invalid_enum_value': - return `invalid value — expected one of: ${issue.options.map(String).join(', ')}`; - case 'invalid_union_discriminator': - return `expected one of: ${issue.options.map(String).join(', ')}`; - case 'invalid_string': - case 'too_small': - case 'too_big': - // Zod's built-in messages for these codes are purely structural (constraint metadata, not the - // authored value), so they are safe to surface directly. - return issue.message; - case 'custom': - // All @relavium/shared superRefines emit structural-only messages — see the comment above. - return issue.message; - default: - // A Zod code not explicitly handled — return a generic message rather than risking an echo of - // an authored value via issue.message (which is code-derived for the built-in codes, but could - // change in future Zod versions). - return 'invalid value'; - } -} - function asRecord(value: unknown): Record | undefined { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) diff --git a/packages/core/src/schema-issue.test.ts b/packages/core/src/schema-issue.test.ts new file mode 100644 index 00000000..87b95baf --- /dev/null +++ b/packages/core/src/schema-issue.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { schemaIssueReason } from './schema-issue.js'; + +describe('shared parser issue reasons', () => { + it('describes a schema literal, never the received value', () => { + const result = z.literal('1.0').safeParse('private-received-sentinel'); + if (result.success) throw new Error('literal mismatch must reject'); + expect(result.error.issues.map(schemaIssueReason)).toEqual(['expected literal "1.0"']); + }); + + it('handles non-string literals without JSON serialization exceptions', () => { + const result = z.literal(42n).safeParse(43n); + if (result.success) throw new Error('literal mismatch must reject'); + expect(result.error.issues.map(schemaIssueReason)).toEqual(['expected literal 42']); + }); + + it('retains field-useful reasons while excluding received enum values', () => { + const result = z + .object({ mode: z.enum(['one', 'two']), count: z.number() }) + .safeParse({ mode: 'private-received-sentinel' }); + if (result.success) throw new Error('missing count and wrong mode must reject'); + expect(result.error.issues.map(schemaIssueReason)).toEqual([ + 'invalid value — expected one of: one, two', + 'missing — expected number', + ]); + }); +}); diff --git a/packages/core/src/schema-issue.ts b/packages/core/src/schema-issue.ts new file mode 100644 index 00000000..c54e492a --- /dev/null +++ b/packages/core/src/schema-issue.ts @@ -0,0 +1,46 @@ +import type { ZodIssue } from 'zod'; + +/** + * A user-facing message for a Zod issue. Deliberately code-derived (type names, key names, enum + * options) — it never echoes an authored *value*, so a secret in a credential field can never leak. + * + * `custom` issues come from `@relavium/shared`'s `superRefine` calls. Today every one of them emits + * a structural message (schema-defined key names, kebab-id-validated identifiers, type-enum labels) + * — never an authored *value*. The explicit `case 'custom':` here makes that invariant visible in + * the call site: if a future refine ever embeds an authored value, this switch is the first place a + * reviewer should add a safe, value-free override. The `default` branch is the last-resort fallback + * for Zod codes not yet seen in practice; it returns a generic message so an unknown code can never + * accidentally surface a payload. + */ +export function schemaIssueReason(issue: ZodIssue): string { + switch (issue.code) { + case 'invalid_literal': + return `expected literal ${typeof issue.expected === 'string' ? JSON.stringify(issue.expected) : String(issue.expected)}`; + case 'invalid_type': + return issue.received === 'undefined' + ? `missing — expected ${issue.expected}` + : `expected ${issue.expected}, received ${issue.received}`; + case 'unrecognized_keys': + return `unknown key${issue.keys.length === 1 ? '' : 's'}: ${issue.keys + .map((key) => `\`${key}\``) + .join(', ')}`; + case 'invalid_enum_value': + return `invalid value — expected one of: ${issue.options.map(String).join(', ')}`; + case 'invalid_union_discriminator': + return `expected one of: ${issue.options.map(String).join(', ')}`; + case 'invalid_string': + case 'too_small': + case 'too_big': + // Zod's built-in messages for these codes are purely structural (constraint metadata, not the + // authored value), so they are safe to surface directly. + return issue.message; + case 'custom': + // All @relavium/shared superRefines emit structural-only messages — see the comment above. + return issue.message; + default: + // A Zod code not explicitly handled — return a generic message rather than risking an echo of + // an authored value via issue.message (which is code-derived for the built-in codes, but could + // change in future Zod versions). + return 'invalid value'; + } +} From 5df71a426b4278374eed42e97eebba82b7a31fc8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 02:28:19 +0300 Subject: [PATCH 021/222] fix(cli): show schema bounds in numeric config diagnostics Use the actual inclusive or exclusive schema bound on loader and writer validation failures without echoing the rejected number. Exercise the real config-file boundary as well as the shared formatter. Refs: ADR-0033, Phase 2.5.5 Wave 3 Step 2 Co-Authored-By: Codex --- apps/cli/src/config/load.test.ts | 45 +++++++++++++++++++++++-- apps/cli/src/config/load.ts | 8 +++-- docs/reference/contracts/config-spec.md | 2 +- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/config/load.test.ts b/apps/cli/src/config/load.test.ts index 65b44b17..2df0eb5c 100644 --- a/apps/cli/src/config/load.test.ts +++ b/apps/cli/src/config/load.test.ts @@ -10,11 +10,12 @@ import { import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { GlobalConfigSchema } from '@relavium/shared'; +import { GlobalConfigSchema, ProjectConfigSchema } from '@relavium/shared'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; import { ConfigError } from './errors.js'; -import { loadConfigFile, loadResolvedConfig } from './load.js'; +import { formatZodError, loadConfigFile, loadResolvedConfig } from './load.js'; describe('loadConfigFile', () => { let dir: string; @@ -25,6 +26,20 @@ describe('loadConfigFile', () => { rmSync(dir, { recursive: true, force: true }); }); + it('reports the numeric bound through an actual config-file rejection', () => { + const file = join(dir, 'project.toml'); + writeFileSync(file, '[chat]\ncompact_threshold = 1234567\n'); + try { + loadConfigFile(file, ProjectConfigSchema); + expect.fail('out-of-range config must refuse'); + } catch (error) { + if (!(error instanceof ConfigError)) throw error; + expect(error.code).toBe('config_error'); + expect(error.message).toContain('chat.compact_threshold: must be <= 1'); + expect(error.message).not.toContain('1234567'); + } + }); + it('parses and validates a global config', () => { const file = join(dir, 'config.toml'); writeFileSync(file, 'update_channel = "beta"\n[preferences]\ndefault_model = "m"\n'); @@ -257,3 +272,29 @@ describe('loadResolvedConfig', () => { } }); }); + +describe('schema-derived numeric config reasons (#32)', () => { + it.each([ + [z.number().min(0), -1234567, '>= 0'], + [z.number().gt(0), 0, '> 0'], + [z.number().max(1), 1234567, '<= 1'], + [z.number().lt(1), 1, '< 1'], + ] as const)( + 'shows the actual numeric bound with its inclusion rule (%#)', + (schema, value, expected) => { + const result = z.object({ threshold: schema }).safeParse({ threshold: value }); + expect(result.success).toBe(false); + if (result.success) throw new Error('the out-of-range fixture must reject'); + expect(formatZodError(result.error)).toBe(`threshold: must be ${expected}`); + expect(formatZodError(result.error)).not.toContain('1234567'); + }, + ); + + it('shows a genuine config bound without the rejected value', () => { + const result = ProjectConfigSchema.safeParse({ chat: { compact_threshold: 1234567 } }); + expect(result.success).toBe(false); + if (result.success) throw new Error('invalid chat threshold must reject'); + expect(formatZodError(result.error)).toBe('chat.compact_threshold: must be <= 1'); + expect(formatZodError(result.error)).not.toContain('1234567'); + }); +}); diff --git a/apps/cli/src/config/load.ts b/apps/cli/src/config/load.ts index 9a422481..6fedf6be 100644 --- a/apps/cli/src/config/load.ts +++ b/apps/cli/src/config/load.ts @@ -216,9 +216,13 @@ function safeIssueReason(issue: ZodIssue): string { case 'invalid_enum_value': return `must be one of: ${issue.options.map(String).join(', ')}`; case 'too_small': - return 'value is too small'; + return issue.type === 'number' + ? `must be ${issue.inclusive ? '>=' : '>'} ${String(issue.minimum)}` + : 'value is too small'; case 'too_big': - return 'value is too large'; + return issue.type === 'number' + ? `must be ${issue.inclusive ? '<=' : '<'} ${String(issue.maximum)}` + : 'value is too large'; case 'invalid_string': return 'value is not a valid string'; default: diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index 8b324564..336e28c5 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -6,7 +6,7 @@ Relavium uses a two-level configuration model that mirrors VS Code's **user vs. workspace** split: a **global** config in the user's home directory, and a **per-project** config committed alongside the code. The per-project layer overrides the global layer. A directory the user opens *is* the workspace — there is no separate "project" concept; the filesystem directory is the unit of organization, which makes git integration trivial. -Config files are **TOML 1.0**. They are decoded to plain objects by the surface that reads them (the CLI in build phase 2 — [ADR-0048](../../decisions/0048-toml-config-parser.md)), then validated against the strict `@relavium/shared` config schemas ([ADR-0033](../../decisions/0033-strict-config-files-amends-0023.md)); the engine never reads config files itself. +Config files are **TOML 1.0**. They are decoded to plain objects by the surface that reads them (the CLI in build phase 2 — [ADR-0048](../../decisions/0048-toml-config-parser.md)), then validated against the strict `@relavium/shared` config schemas ([ADR-0033](../../decisions/0033-strict-config-files-amends-0023.md)); the engine never reads config files itself. Numeric range diagnostics identify the field, schema-side bound and inclusive/exclusive operator without displaying the rejected value; loader and preference-writer validation share that formatter. ## Locations From 7912c5e7fa0c046413c9ce906b4a1899acccc6e2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 02:28:19 +0300 Subject: [PATCH 022/222] docs(docs): record wave 3 step 2 author gates Attribute shared admission and diagnostic changes, preserved guards and six causal removals to their actual author evidence. Independent complete cumulative review remains required before the next increment. Refs: Phase 2.5.5 Wave 3 Step 2 Co-Authored-By: Codex --- .../phase-2.5.5-hardening-and-remediation.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 4972fafe..ec4f1ac6 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4185,3 +4185,37 @@ claim of whole-wave CI, coverage, platform or successor execution. Next is Step 2: shared admission/diagnostics and preserved existing guards. No user decision or review fix is pending for Step 1. Detailed reports and local removal logs remain Git-ignored; Step 19 retains whole-wave acceptance/publication. + +### Wave 3 Step 2 author candidate — 2026-10-16 + +Shared authored admission now rejects duplicate parallel members, typed condition +matches and fallback model/provider tuples (#89/#90/#212/G18). One human-gate +cross-field authority validates both the exported leaf and NodeSchema union +(#210); timeout without action retains default rejection. Full workflow admission +no longer reports parallel duplicates twice. Primary-equal fallback entries and +different provider/model tuples remain permitted. Logical RunSchema refuses only +contradictory present lifecycle fields (G17), retaining missing legacy fields, +optional outputs and the existing temporal checks. No persisted-row reader or +migration is tightened. Existing input-default/enum and compaction-role guards +(#211/G16) are preserved and revalidated by their actual shared/DB suites. + +The two core parsers share schema-derived reasons (#85/#86), including expected +schema literals and useful standalone agent reasons with unchanged fields/code. +Duplicate MCP reasons no longer reflect registration identities; strict unknown- +key diagnostics remain linear. Config range diagnostics report their real bound +and inclusion rule (#32), through the existing loader/writer formatter. Canonical +authored/config/logical-run references are updated; no dependency or event code is +added. The old ceiling fixture now uses distinct fallback identities so it still +isolates chain length and every entry's attempt ceiling rather than failing the +new earlier duplicate guard. + +Required author gates pass: 591 files / 13,101 passing tests / 20 established +skips, all workspace lint/typecheck and six builds. Formatting, seam fence, +test-isolation (591 files / six workspaces), whitespace and relative links pass. +Six local removal controls break 25/3/9/3/3/6 intended regressions for node, agent, +run, literal, standalone reasons and config-bound mechanisms; source bytes are +restored exactly before the complete passing author suite. Filtered unselected +cases are not platform skips. This is not full CI/coverage/platform or whole-wave +acceptance. **Independent complete cumulative review is pending**; obtain two NEW +clean rounds after the final material fix before Step 3. Working logs remain local +and Git-ignored. From b3560e68c949affa272f2ba06b57d029d0fa5f03 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 02:38:39 +0300 Subject: [PATCH 023/222] fix(shared): preserve historical session agent snapshots Separate historical structural validation from fresh fallback uniqueness. Persisted session snapshots retain their ordered entries and attempt budgets while authored standalone and inline agents still reject duplicate tuples. Shared regressions retain existing structural and MCP identity checks. Refs: Wave 3 Step 2, ADR-0024, ADR-0086 Co-Authored-By: Codex --- packages/shared/src/agent.ts | 36 +++++++++++++++++------------ packages/shared/src/session.test.ts | 32 +++++++++++++++++++++++++ packages/shared/src/session.ts | 4 ++-- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/packages/shared/src/agent.ts b/packages/shared/src/agent.ts index 76ca42ff..f70a0e40 100644 --- a/packages/shared/src/agent.ts +++ b/packages/shared/src/agent.ts @@ -284,8 +284,12 @@ export const FallbackChainEntrySchema = z .strict(); export type FallbackChainEntry = z.infer; -/** A reusable agent definition (`.agent.yaml` or an inline `agents:` entry). */ -export const AgentSchema = z +/** + * Frozen session snapshots retain the structural admission contract they were written under. + * New authored fallback uniqueness is intentionally applied only by AgentSchema below; history + * must preserve old ordered entries and their attempt budgets, not deduplicate or reject them. + */ +export const AgentSnapshotSchema = z .object({ id: kebabIdSchema, name: z.string().optional(), @@ -311,19 +315,6 @@ export const AgentSchema = z }) .strict() .superRefine((agent, ctx) => { - // Identity is the model/provider pair within the fallback list, not the attempt count. - // Equality with the primary is permitted; tuple encoding avoids delimiter collisions. - const fallbacks = (agent.fallback_chain ?? []).map(({ model, provider }) => - JSON.stringify([model, provider]), - ); - if (findDuplicates(fallbacks).length > 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ['fallback_chain'], - message: 'duplicate fallback_chain model/provider pair(s)', - }); - } - // MCP server identities must be unique within an agent (they namespace the registered tools). The identity // here is the EXACT inline `id` or by-name `ref` registration name. This catches the common exact-duplicate // case at parse; a host-side *sanitization* collision (two distinct free-form registration names that map to @@ -342,4 +333,19 @@ export const AgentSchema = z }); } }); +/** A reusable agent definition (`.agent.yaml` or an inline `agents:` entry). */ +export const AgentSchema = AgentSnapshotSchema.superRefine((agent, ctx) => { + // Identity is the model/provider pair within the fallback list, not the attempt count. + // Equality with the primary is permitted; tuple encoding avoids delimiter collisions. + const fallbacks = (agent.fallback_chain ?? []).map(({ model, provider }) => + JSON.stringify([model, provider]), + ); + if (findDuplicates(fallbacks).length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['fallback_chain'], + message: 'duplicate fallback_chain model/provider pair(s)', + }); + } +}); export type Agent = z.infer; diff --git a/packages/shared/src/session.test.ts b/packages/shared/src/session.test.ts index f902d44e..2897b5b8 100644 --- a/packages/shared/src/session.test.ts +++ b/packages/shared/src/session.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { AgentSchema } from './agent.js'; import { SessionContextSchema } from './run-event.js'; import { AgentSessionSchema, @@ -131,6 +132,37 @@ describe('AgentSessionSchema', () => { expect(AgentSessionSchema.safeParse(full).success).toBe(true); }); + it('preserves historical fallback entries while fresh authored admission rejects duplicates', () => { + const agentSnapshot = { + id: 'chatter', + model: 'primary', + provider: 'openai', + system_prompt: 'hello', + fallback_chain: [ + { model: 'alternate', provider: 'openai', max_attempts: 1 }, + { model: 'alternate', provider: 'openai', max_attempts: 2 }, + ], + }; + expect(AgentSchema.safeParse(agentSnapshot).success).toBe(false); + const parsed = AgentSessionSchema.parse(baseSession({ agentSnapshot })); + expect(parsed.agentSnapshot).toEqual(agentSnapshot); + // Historical compatibility does not bypass pre-existing shape or MCP identity validation. + expect( + AgentSessionSchema.safeParse( + baseSession({ + agentSnapshot: { ...agentSnapshot, provider: 'unknown' }, + }), + ).success, + ).toBe(false); + expect( + AgentSessionSchema.safeParse( + baseSession({ + agentSnapshot: { ...agentSnapshot, mcp_servers: [{ ref: 'same' }, { ref: 'same' }] }, + }), + ).success, + ).toBe(false); + }); + it('rejects a status outside the closed set', () => { expect(AgentSessionSchema.safeParse(baseSession({ status: 'paused' })).success).toBe(false); }); diff --git a/packages/shared/src/session.ts b/packages/shared/src/session.ts index 2687a757..59fb7e14 100644 --- a/packages/shared/src/session.ts +++ b/packages/shared/src/session.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import { AgentSchema } from './agent.js'; +import { AgentSnapshotSchema } from './agent.js'; import { kebabIdSchema, nonEmptyString, nonNegativeInt } from './common.js'; import { SessionContextSchema, StopReasonSchema } from './run-event.js'; import { @@ -127,7 +127,7 @@ export const AgentSessionSchema = z.object({ /** The catalog `agents.id` this session resolved to, when the agent was a stored catalog entry. */ agentId: nonEmptyString.optional(), /** Frozen agent config for reproducibility (`agent_sessions.agent_snapshot`). */ - agentSnapshot: AgentSchema.optional(), + agentSnapshot: AgentSnapshotSchema.optional(), title: nonEmptyString.optional(), /** The session's configured **primary** model (resolved at start) — a `model_catalog` id reference (not a * raw model string); a per-turn model may differ under fallback, recorded on each `SessionMessage.modelId`. */ From a265e4ff49fc21ada5b798c22a0f258f4fde8352 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 02:38:40 +0300 Subject: [PATCH 024/222] test(db): preserve legacy fallback snapshots through session reads Plant a historical JSON snapshot in real SQLite, then verify history load, listing, full transcript access and session updates preserve it. Fresh agent admission remains a rejecting control; no reader rewrite or deduplication. Refs: Wave 3 Step 2, ADR-0024 Co-Authored-By: Codex --- packages/db/src/session-store.test.ts | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/db/src/session-store.test.ts b/packages/db/src/session-store.test.ts index 7a39dd41..4816ccdd 100644 --- a/packages/db/src/session-store.test.ts +++ b/packages/db/src/session-store.test.ts @@ -366,6 +366,42 @@ describe('SessionStore (1.X) — persist + resume', () => { expect(rawRow && fromAgentSessionRow(rawRow).deletedAt).toBe(TS_ISO); }); + it('reads and preserves legacy duplicate fallback snapshots across all history entry points', () => { + const agentSnapshot = { + id: 'legacy-agent', + model: 'primary', + provider: 'openai', + system_prompt: 'hello', + fallback_chain: [ + { model: 'alternate', provider: 'openai', max_attempts: 1 }, + { model: 'alternate', provider: 'openai', max_attempts: 2 }, + ], + }; + expect(AgentSchema.safeParse(agentSnapshot).success).toBe(false); + store.createSession(makeSession()); + // Plant the exact historical JSON column instead of passing through today's author admission. + const snapshotBytes = JSON.stringify(agentSnapshot); + client.db + .update(agentSessions) + .set({ agentSnapshot: snapshotBytes }) + .where(eq(agentSessions.id, 'sess-1')) + .run(); + const loaded = store.loadSession('sess-1'); + expect(loaded?.agentSnapshot).toEqual(agentSnapshot); + expect(store.listSessions().map((session) => session.agentSnapshot)).toEqual([agentSnapshot]); + expect(store.loadFull('sess-1')?.session.agentSnapshot).toEqual(agentSnapshot); + expect(loaded).toBeDefined(); + if (loaded === undefined) throw new Error('legacy session was not loaded'); + store.updateSession({ ...loaded, title: 'Resumed legacy session' }); + expect( + client.db + .select({ snapshot: agentSessions.agentSnapshot }) + .from(agentSessions) + .where(eq(agentSessions.id, 'sess-1')) + .get()?.snapshot, + ).toBe(snapshotBytes); + }); + it('updateSession preserves the immutable created_at while advancing updated_at', () => { const t1 = '2026-06-17T08:00:00.000Z'; const t2 = '2026-06-17T09:30:00.000Z'; From 09e5927d03ca5eda09b030493be72f41d50d2e0d Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 02:38:40 +0300 Subject: [PATCH 025/222] docs(docs): record the step 2 historical snapshot correction Name the authored versus historical boundary and record the verified first review finding, real SQLite regression, causal removal and corrected author gates. Two new cumulative clean rounds remain required before Step 3. Refs: Wave 3 Step 2 Co-Authored-By: Codex --- docs/reference/shared-core/database-schema.md | 5 ++++ .../phase-2.5.5-hardening-and-remediation.md | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/docs/reference/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index fe2511ff..fb0f7e3f 100644 --- a/docs/reference/shared-core/database-schema.md +++ b/docs/reference/shared-core/database-schema.md @@ -723,6 +723,11 @@ share a shape family but must not be merged, because a session and a run have di One row per chat session. `context_json` freezes the `SessionContext` (active file, selection, session variables); `agent_snapshot` freezes the agent config the session ran against. +Its `AgentSnapshotSchema` retains the existing structural and MCP-identity checks. +Fresh [agent authoring](../contracts/agent-yaml-spec.md#retry-vs-fallback) additionally +rejects duplicate fallback model/provider tuples through `AgentSchema`; that new +rule does not invalidate historical snapshots. History loading and session updates +preserve their ordered fallback entries and attempt budgets without deduplication. | Column | Type | Constraints | |--------|------|-------------| diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index ec4f1ac6..94d55a62 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4219,3 +4219,32 @@ cases are not platform skips. This is not full CI/coverage/platform or whole-wav acceptance. **Independent complete cumulative review is pending**; obtain two NEW clean rounds after the final material fix before Step 3. Working logs remain local and Git-ignored. + + +### Wave 3 Step 2 review correction — 2026-10-16 + +The first complete cumulative review at `7912c5e7` reports one verified High: +AgentSessionSchema embedded the newly tightened AgentSchema, so a historically +valid duplicate fallback snapshot failed loadSession, listSessions and loadFull. +The parent reads the full report and confirms the real SQLite reproduction and +baseline-positive control. The author candidate's reader-preservation claim was +therefore premature; this round is not clean and does not permit Step 3. + +AgentSnapshotSchema now owns the historical structural/MCP checks, while +AgentSchema composes the fresh-authoring fallback uniqueness refinement. +AgentSessionSchema uses the snapshot contract: history reads and session updates +retain ordered entries and their different attempt budgets, without deduplicating +or rewriting them. Fresh standalone/inline authoring still refuses duplicate +model/provider tuples. The canonical database reference names that boundary. + +Permanent shared and real SQLite regressions cover fresh rejection, legacy +load/list/full reads, unchanged snapshot bytes after a session update, and retained +structural/MCP rejection. Removing the snapshot separation breaks both designated +regressions; exact byte restoration passes both. Seven focused files / 294 tests +also pass. Final author gates pass at the corrected source: all workspace +lint/typecheck, 591 files / 13,103 passing tests / 20 established skips and six +build tasks. Source formatting, fences, whitespace and 3,337 relative links pass. +These remain local author gates, not full CI/coverage/native-platform certification. +Two NEW complete cumulative clean rounds after this material fix are required; +no earlier round is counted as acceptance. Detailed reports/logs remain local and +Git-ignored. From 06f9c8f564b791d1d0e91def7a0f4e2fe6bb3691 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 02:53:30 +0300 Subject: [PATCH 026/222] fix(shared): separate frozen workflow validation from authoring Share structural fields and document invariants across authored and frozen workflows. Keep new branch/gate/fallback checks at author admission while retaining all former historical checks, including parallel uniqueness. Refs: Wave 3 Step 2, ADR-0026, ADR-0083, ADR-0086 Co-Authored-By: Codex --- packages/shared/src/node.ts | 61 ++++++---- packages/shared/src/workflow.test.ts | 91 ++++++++++++++ packages/shared/src/workflow.ts | 172 +++++++++++++++------------ 3 files changed, 224 insertions(+), 100 deletions(-) diff --git a/packages/shared/src/node.ts b/packages/shared/src/node.ts index 4d1b9a2b..827feb8b 100644 --- a/packages/shared/src/node.ts +++ b/packages/shared/src/node.ts @@ -179,7 +179,7 @@ export const ConditionBranchSchema = z }) .strict(); -export const ConditionNodeSchema = z +const ConditionNodeFields = z .object({ id: kebabIdSchema, type: z.literal('condition'), @@ -187,17 +187,29 @@ export const ConditionNodeSchema = z expression_type: ExpressionTypeSchema.optional(), branches: z .array(ConditionBranchSchema) - .min(1, 'a condition node must declare at least one branch') - .refine( - (branches) => - findDuplicates(branches.map(({ when }) => `${typeof when}:${String(when)}`)).length === 0, - { message: 'duplicate branch when value(s)' }, - ), + .min(1, 'a condition node must declare at least one branch'), default: kebabIdSchema.optional(), retry: RetrySchema.optional(), // above-chain node-retry budget (ADR-0040) — the expression runs in the sandbox }) .strict(); +/** One author-only condition rule, shared by direct leaf and full-document admission. */ +function validateConditionMatches( + node: z.infer, + ctx: z.RefinementCtx, +): void { + const matches = node.branches.map(({ when }) => `${typeof when}:${String(when)}`); + if (findDuplicates(matches).length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['branches'], + message: 'duplicate branch when value(s)', + }); + } +} + +export const ConditionNodeSchema = ConditionNodeFields.superRefine(validateConditionMatches); + export const TransformNodeSchema = z .object({ id: kebabIdSchema, @@ -244,19 +256,24 @@ export const OutputNodeSchema = z }) .strict(); -/** The authored node discriminated union. */ -export const NodeSchema = z - .discriminatedUnion('type', [ - InputNodeSchema, - AgentNodeSchema, - HumanGateNodeFields, - ConditionNodeSchema, - TransformNodeSchema, - ParallelNodeSchema, - MergeNodeSchema, - OutputNodeSchema, - ]) - .superRefine((node, ctx) => { - if (node.type === 'human_gate') validateHumanGateTimeout(node, ctx); - }); +/** + * Node fields for frozen workflow snapshots. Retains the prior full-workflow parallel + * uniqueness rule; newly authored condition/gate checks are composed separately below. + */ +export const NodeSnapshotSchema = z.discriminatedUnion('type', [ + InputNodeSchema, + AgentNodeSchema, + HumanGateNodeFields, + ConditionNodeFields, + TransformNodeSchema, + ParallelNodeSchema, + MergeNodeSchema, + OutputNodeSchema, +]); + +/** The authored node union, with the same cross-field authorities as its exported leaves. */ +export const NodeSchema = NodeSnapshotSchema.superRefine((node, ctx) => { + if (node.type === 'human_gate') validateHumanGateTimeout(node, ctx); + if (node.type === 'condition') validateConditionMatches(node, ctx); +}); export type WorkflowNode = z.infer; diff --git a/packages/shared/src/workflow.test.ts b/packages/shared/src/workflow.test.ts index 3a5d5325..d5d73686 100644 --- a/packages/shared/src/workflow.test.ts +++ b/packages/shared/src/workflow.test.ts @@ -7,6 +7,7 @@ import { violatesInputContract, WorkflowInputSchema, WorkflowSchema, + WorkflowSnapshotSchema, } from './workflow.js'; /** @@ -997,3 +998,93 @@ describe('WorkflowInputSchema — the ADR-0083 tightenings', () => { ).toBe(false); }); }); + +describe('frozen workflow structural compatibility', () => { + const historical = { + schema_version: '1.0', + workflow: { + id: 'historical', + agents: [ + { + id: 'a', + model: 'primary', + provider: 'openai', + system_prompt: 'hello', + fallback_chain: [ + { model: 'alternate', provider: 'openai', max_attempts: 1 }, + { model: 'alternate', provider: 'openai', max_attempts: 2 }, + ], + }, + ], + nodes: [ + { id: 'gate', type: 'human_gate', gate_type: 'approval', timeout_action: 'reject' }, + { + id: 'choose', + type: 'condition', + expression: 'true', + branches: [ + { when: true, target_node: 'out' }, + { when: true, target_node: 'out' }, + ], + }, + { id: 'out', type: 'output' }, + ], + edges: [ + { from: 'gate', to: 'choose' }, + { from: 'choose', to: 'out' }, + ], + }, + }; + + it('preserves all newly author-only fields in a historical document', () => { + expect(WorkflowSchema.safeParse(historical).success).toBe(false); + expect(WorkflowSnapshotSchema.parse(historical)).toEqual(historical); + }); + + it.each([ + ['strict fields', { ...historical.workflow, unknown: true }], + [ + 'node identity', + { + ...historical.workflow, + nodes: [historical.workflow.nodes[0], historical.workflow.nodes[0]], + }, + ], + [ + 'input defaults', + { ...historical.workflow, inputs: [{ name: 'n', type: 'number', default: 'wrong' }] }, + ], + [ + 'parallel members', + { + ...historical.workflow, + nodes: [{ id: 'fan', type: 'parallel', parallel_of: ['out', 'out'] }], + }, + ], + [ + 'custom merge', + { ...historical.workflow, nodes: [{ id: 'join', type: 'merge', merge_strategy: 'custom' }] }, + ], + [ + 'parallel edge membership', + { + ...historical.workflow, + nodes: [{ id: 'fan', type: 'parallel', parallel_of: ['out'] }], + edges: [{ from: 'fan', to: 'other' }], + }, + ], + [ + 'MCP identity', + { + ...historical.workflow, + agents: [ + { ...historical.workflow.agents[0], mcp_servers: [{ ref: 'same' }, { ref: 'same' }] }, + ], + }, + ], + ])('retains the previous %s rejection', (_label, workflow) => { + expect(WorkflowSnapshotSchema.safeParse({ schema_version: '1.0', workflow }).success).toBe( + false, + ); + }); +}); diff --git a/packages/shared/src/workflow.ts b/packages/shared/src/workflow.ts index 735834a5..1e7f3892 100644 --- a/packages/shared/src/workflow.ts +++ b/packages/shared/src/workflow.ts @@ -9,8 +9,8 @@ import { positiveInt, } from './common.js'; import { ON_EXCEED_ACTIONS, SCHEMA_VERSION } from './constants.js'; -import { AgentSchema } from './agent.js'; -import { NodeSchema } from './node.js'; +import { AgentSchema, AgentSnapshotSchema } from './agent.js'; +import { NodeSchema, NodeSnapshotSchema } from './node.js'; import { EdgeSchema } from './edge.js'; /** @@ -633,89 +633,105 @@ export const WorkflowSpecSchema = z export type WorkflowSpec = z.infer; /** The complete workflow document: `schema_version` + `workflow`. */ -export const WorkflowSchema = z +const WorkflowDocumentFields = z .object({ schema_version: z.literal(SCHEMA_VERSION), workflow: WorkflowSpecSchema, }) - .strict() - .superRefine((doc, ctx) => { - const { nodes } = doc.workflow; - - // `merge_fn` is required when `merge_strategy` is `custom`. - nodes.forEach((node, i) => { - if (node.type === 'merge' && node.merge_strategy === 'custom' && !node.merge_fn) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'merge_fn is required when merge_strategy is "custom"', - path: ['workflow', 'nodes', i, 'merge_fn'], - }); - } - }); - - // Referenced identifiers must be unique within a workflow — node ids, input names, - // context keys, and agent ids are each addressed by reference (edges, `{{inputs.*}}`, - // `{{ctx.*}}`, `agent_ref`), so a duplicate is an ambiguity, not a forward-compat field. - const reportDuplicates = (values: string[], label: string, path: (string | number)[]) => { - const duplicates = findDuplicates(values); - if (duplicates.length > 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `duplicate ${label}: ${duplicates.join(', ')}`, - path, - }); - } - }; - reportDuplicates( - nodes.map((n) => n.id), - 'node id(s)', - ['workflow', 'nodes'], - ); - reportDuplicates( - (doc.workflow.inputs ?? []).map((i) => i.name), - 'input name(s)', - ['workflow', 'inputs'], - ); - reportDuplicates( - (doc.workflow.context ?? []).map((c) => c.key), - 'context key(s)', - ['workflow', 'context'], - ); - reportDuplicates( - // Only inline agents carry an `id`; a `{ $ref }` entry is resolved (and id-checked) by the engine. - (doc.workflow.agents ?? []).flatMap((a) => ('id' in a ? [a.id] : [])), - 'agent id(s)', - ['workflow', 'agents'], - ); + .strict(); - // NodeSchema owns parallel member uniqueness at both leaf and full-document boundaries. - // Do not add a second duplicate diagnostic at the workflow level. +/** Shared document invariants apply equally to authored and historical workflows. */ +function validateWorkflow(doc: z.infer, ctx: z.RefinementCtx): void { + const { nodes } = doc.workflow; - // `parallel_of` is authoritative for branch membership: an explicit edge out of a - // `parallel` node must target a node listed in that node's `parallel_of` - // (workflow-yaml-spec.md). Explicit fan-out edges are redundant with `parallel_of`, - // but if present they must not contradict it. - const branchesByParallelId = new Map(); - for (const node of nodes) { - if (node.type === 'parallel') branchesByParallelId.set(node.id, node.parallel_of); + // `merge_fn` is required when `merge_strategy` is `custom`. + nodes.forEach((node, i) => { + if (node.type === 'merge' && node.merge_strategy === 'custom' && !node.merge_fn) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'merge_fn is required when merge_strategy is "custom"', + path: ['workflow', 'nodes', i, 'merge_fn'], + }); } - doc.workflow.edges.forEach((edge, i) => { - // Strip an optional `:handle` to the base node id. `split` always yields >= 1 - // element at runtime; the `?? edge.from` satisfies `noUncheckedIndexedAccess` - // (which types `[0]` as `string | undefined`) so `fromId` is a plain `string`. - const fromId = edge.from.split(':')[0] ?? edge.from; - const branches = branchesByParallelId.get(fromId); - if (branches && !branches.includes(edge.to)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `edge from parallel node '${fromId}' targets '${edge.to}', which is not in its parallel_of`, - path: ['workflow', 'edges', i, 'to'], - }); - } - }); + }); - // Note: `agent_ref` → agent resolution and `agents` presence are NOT validated here. - // An agent node's agent may be declared inline, in a sibling `.agent.yaml`, or in the - // workspace agent registry — only the engine, with the full registry, can resolve it. + // Referenced identifiers must be unique within a workflow — node ids, input names, + // context keys, and agent ids are each addressed by reference (edges, `{{inputs.*}}`, + // `{{ctx.*}}`, `agent_ref`), so a duplicate is an ambiguity, not a forward-compat field. + const reportDuplicates = (values: string[], label: string, path: (string | number)[]) => { + const duplicates = findDuplicates(values); + if (duplicates.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `duplicate ${label}: ${duplicates.join(', ')}`, + path, + }); + } + }; + reportDuplicates( + nodes.map((n) => n.id), + 'node id(s)', + ['workflow', 'nodes'], + ); + reportDuplicates( + (doc.workflow.inputs ?? []).map((i) => i.name), + 'input name(s)', + ['workflow', 'inputs'], + ); + reportDuplicates( + (doc.workflow.context ?? []).map((c) => c.key), + 'context key(s)', + ['workflow', 'context'], + ); + reportDuplicates( + // Only inline agents carry an `id`; a `{ $ref }` entry is resolved (and id-checked) by the engine. + (doc.workflow.agents ?? []).flatMap((a) => ('id' in a ? [a.id] : [])), + 'agent id(s)', + ['workflow', 'agents'], + ); + + // Both authored and historical node schemas own the prior parallel member uniqueness rule. + // Do not add a second duplicate diagnostic at the workflow level. + + // `parallel_of` is authoritative for branch membership: an explicit edge out of a + // `parallel` node must target a node listed in that node's `parallel_of` + // (workflow-yaml-spec.md). Explicit fan-out edges are redundant with `parallel_of`, + // but if present they must not contradict it. + const branchesByParallelId = new Map(); + for (const node of nodes) { + if (node.type === 'parallel') branchesByParallelId.set(node.id, node.parallel_of); + } + doc.workflow.edges.forEach((edge, i) => { + // Strip an optional `:handle` to the base node id. `split` always yields >= 1 + // element at runtime; the `?? edge.from` satisfies `noUncheckedIndexedAccess` + // (which types `[0]` as `string | undefined`) so `fromId` is a plain `string`. + const fromId = edge.from.split(':')[0] ?? edge.from; + const branches = branchesByParallelId.get(fromId); + if (branches && !branches.includes(edge.to)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `edge from parallel node '${fromId}' targets '${edge.to}', which is not in its parallel_of`, + path: ['workflow', 'edges', i, 'to'], + }); + } }); + + // Note: `agent_ref` → agent resolution and `agents` presence are NOT validated here. + // An agent node's agent may be declared inline, in a sibling `.agent.yaml`, or in the + // workspace agent registry — only the engine, with the full registry, can resolve it. +} + +export const WorkflowSchema = WorkflowDocumentFields.superRefine(validateWorkflow); + +/** + * Frozen workflow reader: preserve prior structural and whole-document invariants without + * applying new author-only fallback, branch-match or orphan gate-action refusals to old runs. + * Never use this historical contract to admit a newly authored document. + */ +export const WorkflowSnapshotSchema = WorkflowDocumentFields.extend({ + workflow: WorkflowSpecSchema.extend({ + agents: z.array(z.union([AgentSnapshotSchema, AgentRefSchema])).optional(), + nodes: z.array(NodeSnapshotSchema).min(1, 'a workflow must declare at least one node'), + }), +}).superRefine(validateWorkflow); export type Workflow = z.infer; From 816b2b9a02938a55248b6843557137091fc47dd1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 02:53:30 +0300 Subject: [PATCH 027/222] fix(core): preserve frozen workflow resume and export semantics Use the historical workflow contract for exact frozen-content identity. Pin old-valid/current-author-invalid snapshots and content mismatch refusal. Preserve legacy export entries and qualify current-author round-trip claims. Refs: Wave 3 Step 2, ADR-0026, ADR-0083, ADR-0086 Co-Authored-By: Codex --- .../core/src/engine/resume-identity.test.ts | 53 ++++++++++++++++++- packages/core/src/engine/resume-identity.ts | 8 +-- packages/core/src/export/serializer.test.ts | 27 ++++++++++ packages/core/src/export/serializer.ts | 14 ++--- 4 files changed, 91 insertions(+), 11 deletions(-) diff --git a/packages/core/src/engine/resume-identity.test.ts b/packages/core/src/engine/resume-identity.test.ts index daceefba..986218a1 100644 --- a/packages/core/src/engine/resume-identity.test.ts +++ b/packages/core/src/engine/resume-identity.test.ts @@ -8,7 +8,7 @@ * the lease** it acquired before it could know it would refuse (ADR-0079 §4). */ -import type { RunEvent } from '@relavium/shared'; +import { WorkflowSchema, WorkflowSnapshotSchema, type RunEvent } from '@relavium/shared'; import { describe, expect, it } from 'vitest'; import { parseWorkflow, type WorkflowDefinition } from '../parser.js'; @@ -430,6 +430,57 @@ describe('verifyFrozenWorkflowContent — the same slug is not the same graph (A expect(verifyFrozenWorkflowContent(JSON.stringify(WF, null, 2), WF)).toBeUndefined(); }); + it.each(['fallback', 'condition', 'gate'] as const)( + 'accepts an unchanged historical %s snapshot while fresh authoring refuses it', + (kind) => { + const historical = { + ...WF, + workflow: { + ...WF.workflow, + ...(kind === 'fallback' + ? { + agents: [ + { + id: 'legacy-agent', + model: 'primary', + provider: 'openai', + system_prompt: 'hello', + fallback_chain: [ + { model: 'alternate', provider: 'openai', max_attempts: 1 }, + { model: 'alternate', provider: 'openai', max_attempts: 2 }, + ], + }, + ], + } + : {}), + nodes: WF.workflow.nodes.map((node) => { + if (node.id !== 'g') return node; + if (kind === 'condition') + return { + id: 'g', + type: 'condition', + expression: 'true', + branches: [ + { when: true, target_node: 'b' }, + { when: true, target_node: 'b' }, + ], + }; + return kind === 'gate' ? { ...node, timeout_action: 'reject' } : node; + }), + }, + }; + expect(WorkflowSchema.safeParse(historical).success).toBe(false); + const supplied = WorkflowSnapshotSchema.parse(historical); + expect(verifyFrozenWorkflowContent(JSON.stringify(historical), supplied)).toBeUndefined(); + expect( + verifyFrozenWorkflowContent(JSON.stringify(historical), { + ...supplied, + workflow: { ...supplied.workflow, name: 'changed' }, + })?.code, + ).toBe('workflow_content_mismatch'); + }, + ); + it('refuses the SAME slug with edited content — the drift the id guard cannot see', () => { // `resolveWorkflowId` maps a slug to a surrogate UUID, so an edited-but-same-slug workflow passes the // identity guard untouched. That is the gap ADR-0079 §4 deferred to a content hash and this closes. diff --git a/packages/core/src/engine/resume-identity.ts b/packages/core/src/engine/resume-identity.ts index ed281f8f..0385366a 100644 --- a/packages/core/src/engine/resume-identity.ts +++ b/packages/core/src/engine/resume-identity.ts @@ -22,7 +22,7 @@ import { isReferenceableInputName, MaskedSecretSchema, - WorkflowSchema, + WorkflowSnapshotSchema, type ExecutionMode, type Workflow, } from '@relavium/shared'; @@ -190,7 +190,7 @@ function inputMismatchMessage(first: { name?: string; message: string } | undefi * being asked. * * **Each side is normalized according to how much it is trusted.** The frozen JSON is durable data of unknown - * provenance, so it goes through `WorkflowSchema` — a value that will not parse as a workflow is + * provenance, so it goes through `WorkflowSnapshotSchema` — a value that will not parse as a workflow is * `admission_record_unreadable`, which is a different fact from "it differs" and has a different remedy. The * supplied side is already a parsed `Workflow` by type, so it only takes the JSON round trip the column * imposed on the other side: `JSON.stringify` drops `undefined`-valued keys, and comparing a live object @@ -212,7 +212,7 @@ export function verifyFrozenWorkflowContent( } catch { return unreadable('the frozen workflow definition for this run is not valid JSON'); } - const normalizedFrozen = WorkflowSchema.safeParse(frozen); + const normalizedFrozen = WorkflowSnapshotSchema.safeParse(frozen); if (!normalizedFrozen.success) { // Value-free: the reason list would carry authored content from a workflow this process did not write. return unreadable( @@ -234,7 +234,7 @@ export function verifyFrozenWorkflowContent( }; } // The FROZEN side takes the same guarded round trip as the supplied one — and it is the side that needed - // it more. `WorkflowSchema` accepts a `metadata` record of `z.unknown()` without recursing into it, so a + // it more. `WorkflowSnapshotSchema` accepts a `metadata` record of `z.unknown()` without recursing into it, so a // snapshot carrying a deeply nested value passes validation and then blows the stack inside // `JSON.stringify`. A review measured that: a ~60,000-deep array under `workflow.metadata` threw a raw // `RangeError` out of `resumeFromCheckpoint`, past the typed seam (the CLI reported "an unexpected internal diff --git a/packages/core/src/export/serializer.test.ts b/packages/core/src/export/serializer.test.ts index b380c4dd..4c0d5e5d 100644 --- a/packages/core/src/export/serializer.test.ts +++ b/packages/core/src/export/serializer.test.ts @@ -49,6 +49,33 @@ const msg = ( timestamp: TS, }); +it('faithfully exports a historical agent which needs repair before current authored admission', () => { + const agentSnapshot = { + ...AGENT, + fallback_chain: [ + { model: 'alternate', provider: 'openai' as const, max_attempts: 1 }, + { model: 'alternate', provider: 'openai' as const, max_attempts: 2 }, + ], + }; + const record = AgentSessionSchema.parse(session({ agentSnapshot })); + const workflow = sessionToWorkflow(record, []); + expect(workflow.workflow.agents).toEqual([agentSnapshot]); + const yaml = serializeWorkflow(workflow); + expect(() => parseWorkflow(yaml)).toThrowError(/fallback_chain/); + // Repair is an explicit author decision, never an exporter rewrite of frozen attempt budgets. + const repaired = { + ...workflow, + workflow: { + ...workflow.workflow, + agents: [{ ...agentSnapshot, fallback_chain: agentSnapshot.fallback_chain.slice(0, 1) }], + }, + }; + expect(parseWorkflow(serializeWorkflow(repaired)).workflow.agents).toEqual( + repaired.workflow.agents, + ); + expect(record.agentSnapshot?.fallback_chain).toEqual(agentSnapshot.fallback_chain); +}); + describe('sessionToWorkflow (1.Z) — linear-chain scaffold', () => { it('preserves legacy bare-user prefixes in the next completed export prompt', () => { const messages = [ diff --git a/packages/core/src/export/serializer.ts b/packages/core/src/export/serializer.ts index dd17d412..c1b639f7 100644 --- a/packages/core/src/export/serializer.ts +++ b/packages/core/src/export/serializer.ts @@ -1,8 +1,9 @@ /** * Session **export-to-workflow** (1.Z) — the inverse of the parser (1.L is parse-only). Two pure functions: * - * - {@link serializeWorkflow} emits a {@link WorkflowDefinition} as **deterministic, round-trippable** YAML - * (map keys sorted alphabetically, array order preserved) so `parse → serialize` is byte-stable. + * - {@link serializeWorkflow} emits a {@link WorkflowDefinition} as **deterministic** YAML + * (map keys sorted alphabetically, array order preserved). Currently admitted definitions round-trip; + * frozen historical agents may require deliberate author repair before current parsing. * - {@link sessionToWorkflow} maps a persisted session (`AgentSessionRecord` + its ordered * `SessionMessage[]`, 1.X) into a **linear-chain scaffold** `WorkflowDefinition` * ([ADR-0026](../../../../docs/decisions/0026-session-export-to-workflow.md); the mapping contract lives @@ -36,8 +37,8 @@ type AgentNode = Extract; /** * Emit a {@link WorkflowDefinition} as YAML. Map keys are sorted alphabetically and array element order is - * preserved, so the output is deterministic and `parse(serialize(def))` followed by another serialize is - * byte-stable. Faithful: it serializes exactly what it is given. Durable session types exclude structural + * preserved. For a currently admitted definition, `parse(serialize(def))` followed by another serialize + * is byte-stable; a frozen historical snapshot may require deliberate author repair before parsing. Faithful: it serializes exactly what it is given. Durable session types exclude structural * tool values/signatures (see {@link sessionToWorkflow}); authored text/config may still be sensitive. */ export function serializeWorkflow(workflow: WorkflowDefinition): string { @@ -46,7 +47,7 @@ export function serializeWorkflow(workflow: WorkflowDefinition): string { /** * Emit an {@link AgentDefinition} as deterministic YAML (map keys sorted alphabetically, array order preserved), - * so `parse(serialize(agent))` is byte-stable — the `.agent.yaml` counterpart of {@link serializeWorkflow}. + * so currently admitted agents have byte-stable `parse(serialize(agent))` — the `.agent.yaml` counterpart of {@link serializeWorkflow}. * Re-serializing the validated AST drops authored comments, but faithfully retains its values: prompts, * descriptions and other authored config can contain sensitive literals. Keychain custody and MCP secret * placeholders do not make the whole document safe to share. Review before sharing; no scrubbing occurs @@ -136,7 +137,8 @@ function workflowIdFor(record: AgentSessionRecord): string { /** * Map a persisted session + its ordered transcript into a linear-chain scaffold `WorkflowDefinition` * (ADR-0026). Deterministic — the same `record` + `messages` always produce the same definition (no - * wall-clock / randomness), so the emitted YAML round-trips. Assumes a well-formed, user-initiated + * wall-clock / randomness). Frozen agent entries are preserved; historical entries which no longer pass + * current author admission require review/repair before the emitted YAML can round-trip. Assumes a well-formed, user-initiated * transcript (each turn opens with a `user` message, as `AgentSession` emits). Orphaned assistant rows * remain in metadata and never become completed-turn nodes. */ From 86370b82fa5c316ff1fc07b04036bb8ba80f7a62 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 02:53:30 +0300 Subject: [PATCH 028/222] fix(cli): resume historical workflows without authoring rewrites Read frozen workflow snapshots through their historical contract. Real SQLite/engine gate controls retain branch order, timeout semantics, outputs and snapshot identity. Export notices name historical validation repair. Refs: Wave 3 Step 2, ADR-0026, ADR-0083, ADR-0086 Co-Authored-By: Codex --- apps/cli/src/chat/export.ts | 2 +- apps/cli/src/commands/chat-export.test.ts | 3 + apps/cli/src/commands/gate.test.ts | 154 +++++++++++++++++++++- apps/cli/src/commands/gate.ts | 6 +- 4 files changed, 160 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/chat/export.ts b/apps/cli/src/chat/export.ts index be41ed27..feb83085 100644 --- a/apps/cli/src/chat/export.ts +++ b/apps/cli/src/chat/export.ts @@ -25,7 +25,7 @@ import { sourceLabel } from '../process/source-label.js'; /** Fixed, value-free successful-export notice: interactive transcript or plain/JSON stderr. */ export const SESSION_EXPORT_REVIEW_NOTICE = - 'note: exported workflow is a scaffold; conversation context is not reproduced. Compaction summaries, trimming and omitted context may not transfer. Review retained user/assistant text, @-attached files, !-shell output and authored agent literals before committing.\n'; + 'note: exported workflow is a scaffold; conversation context is not reproduced. Compaction summaries, trimming and omitted context may not transfer. Review retained user/assistant text, @-attached files, !-shell output and authored agent literals before committing. Historical agent snapshots may require edits to pass current validation before running.\n'; export interface ExportSessionOptions { readonly store: SessionStore; diff --git a/apps/cli/src/commands/chat-export.test.ts b/apps/cli/src/commands/chat-export.test.ts index b5e526b0..734449f8 100644 --- a/apps/cli/src/commands/chat-export.test.ts +++ b/apps/cli/src/commands/chat-export.test.ts @@ -103,6 +103,9 @@ describe('chatExportCommand (2.P)', () => { ); expect(notice.trim().split('\n')).toHaveLength(1); expect(`${out()}${notice}`).not.toContain('PRIVATE_'); + expect(err()).toContain( + 'Historical agent snapshots may require edits to pass current validation before running.', + ); expect(out()).not.toContain('scaffold;'); expect(readFileSync(join(cwd, 's1.relavium.yaml'), 'utf8')).toContain( 'PRIVATE_USER_SENTINEL', diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index 7a63d363..09d35fd1 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -9,6 +9,7 @@ import { parseWorkflow, type CheckpointState, type RunHandle, + type WorkflowDefinition, type WorkflowEngine, } from '@relavium/core'; import { @@ -25,7 +26,7 @@ import { type DbClient, type RunHistoryStore, } from '@relavium/db'; -import type { RunEvent } from '@relavium/shared'; +import { WorkflowSchema, WorkflowSnapshotSchema, type RunEvent } from '@relavium/shared'; import { startMcpClient as realStartMcpClient } from '@relavium/mcp'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -254,6 +255,15 @@ describe('gateCommand', () => { projectRoot?: string, ): Promise<{ runId: string; gateIds: string[] }> { const def = parseWorkflow(yaml, { source: 'gate.test' }); + return setupPausedWorkflow(def, inputs, projectRoot); + } + + /** Historical runs enter with their already-admitted definition, never through fresh YAML admission. */ + async function setupPausedWorkflow( + def: WorkflowDefinition, + inputs: Record, + projectRoot?: string, + ): Promise<{ runId: string; gateIds: string[] }> { const store = createRunHistoryStore(db, { uuid: () => randomUUID(), now: () => Date.now(), @@ -278,6 +288,129 @@ describe('gateCommand', () => { return { runId, gateIds }; } + it.each([ + { + label: 'an inline agent with repeated fallback model/provider entries', + freshError: /duplicate fallback_chain/, + skippedNodeId: undefined, + historical: (base: WorkflowDefinition) => ({ + ...base, + workflow: { + ...base.workflow, + // Unused by this deterministic workflow: retaining its ordered attempt budgets needs no key. + agents: [ + { + id: 'unused', + model: 'gpt-4o', + provider: 'openai', + system_prompt: 'Keep the historical fallback order.', + fallback_chain: [ + { model: 'gpt-4o-mini', provider: 'openai', max_attempts: 1 }, + { model: 'gpt-4o-mini', provider: 'openai', max_attempts: 2 }, + ], + }, + ], + }, + }), + }, + { + label: 'a condition with repeated matching values and first-declared routing', + freshError: /duplicate branch when/, + skippedNodeId: 'shadow', + historical: (base: WorkflowDefinition) => ({ + ...base, + workflow: { + ...base.workflow, + nodes: [ + ...base.workflow.nodes, + { + id: 'route', + type: 'condition', + expression: 'inputs.n', + branches: [ + { when: 7, target_node: 'double' }, + { when: 7, target_node: 'shadow' }, + ], + }, + { id: 'shadow', type: 'transform', transform: '({ d: 999 })' }, + ], + edges: [ + { from: 'start', to: 'g' }, + { from: 'g', to: 'route' }, + { from: 'double', to: 'out' }, + { from: 'shadow', to: 'out' }, + ], + }, + }), + }, + { + label: 'a gate with an inactive timeout action and no timeout duration', + freshError: /timeout_action requires timeout_ms/, + skippedNodeId: undefined, + historical: (base: WorkflowDefinition) => ({ + ...base, + workflow: { + ...base.workflow, + nodes: base.workflow.nodes.map((node) => + node.id === 'g' ? { ...node, timeout_action: 'reject' } : node, + ), + }, + }), + }, + ])('resumes historical $label without rewriting its frozen definition', async (historical) => { + const original = historical.historical(parseWorkflow(GATED, { source: 'gate.test' })); + // New authoring rejects each shape, while an already-admitted run retains its original semantics. + expect(() => WorkflowSchema.parse(original)).toThrow(historical.freshError); + expect(() => parseWorkflow(JSON.stringify(original), { source: 'gate.test' })).toThrow( + historical.freshError, + ); + const definition = WorkflowSnapshotSchema.parse(original); + expect(definition).toEqual(original); + + const { runId, gateIds } = await setupPausedWorkflow(definition, { n: 7 }); + expect(runId).not.toBe(''); + expect(gateIds).toHaveLength(1); + expect(reader().loadRun(runId)?.status).toBe('paused'); + const before = reader().loadRunEvents(runId); + const paused = before.find((event) => event.type === 'human_gate:paused'); + expect(paused).toMatchObject({ nodeId: 'g', gateId: gateIds[0] }); + // Even the historical timeout_action alone never armed a timer or applied a timeout decision. + expect(paused).not.toHaveProperty('timeoutMs'); + expect(paused).not.toHaveProperty('timeoutAction'); + expect(paused).not.toHaveProperty('expiresAt'); + expect(before.some((event) => event.type === 'run:completed')).toBe(false); + const snapshot = loadRunSnapshot(db, runId)?.workflowDefinitionSnapshot; + expect(snapshot).toBe(JSON.stringify(definition)); + + const { io } = captureIo(); + expect(await gateCommand({ runId, approve: true }, deps(io))).toBe(EXIT_CODES.success); + const events = reader().loadRunEvents(runId); + expect(reader().loadRun(runId)?.status).toBe('completed'); + expect(events.filter((event) => event.type === 'run:started')).toHaveLength(1); + expect(events.filter((event) => event.type === 'run:completed')).toEqual([ + expect.objectContaining({ runId, outputs: { out: { d: 14 } } }), + ]); + expect(events.filter((event) => event.type === 'human_gate:resumed')).toEqual([ + expect.objectContaining({ runId, nodeId: 'g', decision: 'approved', decidedBy: 'cli' }), + ]); + expect(events.slice(0, before.length)).toEqual(before); + expect(loadRunSnapshot(db, runId)?.workflowDefinitionSnapshot).toBe(snapshot); + if (historical.skippedNodeId !== undefined) { + expect(events).toContainEqual( + expect.objectContaining({ + type: 'node:skipped', + nodeId: historical.skippedNodeId, + reason: 'branch_not_taken', + }), + ); + expect( + events.some( + (event) => event.type === 'node:started' && event.nodeId === historical.skippedNodeId, + ), + ).toBe(false); + } + }); + describe('secret re-supply (ADR-0083 §6)', () => { const SECRET = 'sk-live-not-in-the-log'; const seedSecretRun = (): Promise<{ runId: string; gateIds: string[] }> => @@ -1026,6 +1159,25 @@ workflow: .prepare('UPDATE runs SET workflow_definition_snapshot = ? WHERE id = ?') .run('{"workflow":{}}', runId), }, + { + label: 'a historical snapshot with already-invalid duplicate parallel members', + corrupt: (runId: string) => { + const definition = parseWorkflow(GATED, { source: 'gate.test' }); + const invalid = { + ...definition, + workflow: { + ...definition.workflow, + nodes: [ + ...definition.workflow.nodes, + { id: 'fan', type: 'parallel', parallel_of: ['double', 'double'] }, + ], + }, + }; + client.sqlite + .prepare('UPDATE runs SET workflow_definition_snapshot = ? WHERE id = ?') + .run(JSON.stringify(invalid), runId); + }, + }, { label: 'a snapshot but no event log (no resumable state)', corrupt: (runId: string) => diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index 93fa2d05..8d8c56f7 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -25,7 +25,7 @@ import { } from '@relavium/db'; import { MaskedSecretSchema, - WorkflowSchema, + WorkflowSnapshotSchema, type GateDecision, type RunStatus, } from '@relavium/shared'; @@ -681,10 +681,10 @@ function pendingList(pending: readonly { readonly gateId: string }[]): string { return pending.length === 0 ? '' : ` (pending: ${pending.map((gate) => gate.gateId).join(', ')})`; } -/** Re-validate the frozen snapshot JSON against the shared schema — a corrupt snapshot is an exit-2 fault. */ +/** Validate frozen JSON against the historical contract; new author-only rules never rewrite a parked run. */ function parseSnapshot(snapshotJson: string, runId: string): WorkflowDefinition { try { - return WorkflowSchema.parse(JSON.parse(snapshotJson)); + return WorkflowSnapshotSchema.parse(JSON.parse(snapshotJson)); } catch (err) { throw new CliError( 'invalid_invocation', From 4048526fbf2585560cd12dbdf11fc3412d4be9d5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 02:53:30 +0300 Subject: [PATCH 029/222] docs(docs): record complete step 2 historical compatibility gates Reconcile the export round-trip scope and frozen workflow reader boundary. Record the second verified review, complete regression/causal evidence and final author gates without claiming acceptance before two new clean rounds. Refs: Wave 3 Step 2, ADR-0026, ADR-0083, ADR-0086 Co-Authored-By: Codex --- .../reference/contracts/agent-session-spec.md | 11 +++-- docs/reference/shared-core/database-schema.md | 9 ++++ .../phase-2.5.5-hardening-and-remediation.md | 42 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/docs/reference/contracts/agent-session-spec.md b/docs/reference/contracts/agent-session-spec.md index f9de93bb..efefa219 100644 --- a/docs/reference/contracts/agent-session-spec.md +++ b/docs/reference/contracts/agent-session-spec.md @@ -405,7 +405,8 @@ Per [ADR-0026](../../decisions/0026-session-export-to-workflow.md), a session ex - parallel / conditional / loop structure is **not** auto-inferred — the author adds it on the canvas. Successful CLI `chat-export` writes a fixed continuity/review notice to stderr, including -in JSON mode. Live `/export` renders the same notice in the interactive chat/Home transcript +in JSON mode. It also warns that historical agent snapshots may need edits to pass current +authored validation before running. Live `/export` renders the same notice in the interactive chat/Home transcript and uses stderr on plain/JSON drivers; the machine event stream is unchanged. A scaffold does not recreate the session's working context: summarised, trimmed or omitted context may not transfer, and no raw model-issued tool arguments/results are available to restore. Historical persisted turns and @@ -419,7 +420,7 @@ desktop "Export to Canvas" affordance and the CLI `relavium chat-export` both dr **Precise mapping (1.Z).** Given a loaded `AgentSessionRecord` + its ordered `SessionMessage[]`, the exporter builds a `WorkflowDefinition` deterministically (no wall-clock / randomness, so the artifact is -reproducible and round-trips): +reproducible; snapshots satisfying current authored validation round-trip): - **Nodes** — a single `input` node (`id: input`), then **one `agent` node per COMPLETED logical turn** in `sequenceNumber` order (`id: turn-1`, `turn-2`, … — 1-based), then one `output` node (`id: output`). A @@ -444,7 +445,11 @@ reproducible and round-trips): and renameable, not guaranteed unique: normalisation and truncation can collide. Full original identity stays in metadata; the default export filename still uses the session id, not the generated workflow id. - **`agents`** — the session's frozen `agentSnapshot` (an inline `Agent`) is emitted as the sole `agents[]` - entry so `agent_ref` resolves; when no snapshot was captured, `agents` is omitted and `agent_ref` resolves + entry so `agent_ref` resolves. Historical snapshots retain their ordered fallback entries and attempt + budgets even when current [authored admission](agent-yaml-spec.md#retry-vs-fallback) rejects them + (for example, repeated model/provider tuples). Such a scaffold requires deliberate author repair + before it parses/runs; export never silently deduplicates the frozen agent or rewrites history. + When no snapshot was captured, `agents` is omitted and `agent_ref` resolves against the workspace agent registry at author time (the file still parses — `agent_ref` resolution is the engine's job, not the schema's). - **`metadata`** — the full persisted transcript, including content-free tool structure, under a single reserved key: `metadata.relaviumExport = { source: diff --git a/docs/reference/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index fb0f7e3f..1910e978 100644 --- a/docs/reference/shared-core/database-schema.md +++ b/docs/reference/shared-core/database-schema.md @@ -484,6 +484,15 @@ CREATE INDEX idx_workflows_active ON workflows (is_active, updated_at DESC) One row per workflow execution. `workflow_definition_snapshot` freezes the exact graph that ran, so a run can be replayed or inspected even after the YAML file changes. Cost is stored as integer micro-cents. +Frozen run definitions use `WorkflowSnapshotSchema` at the CLI gate reader and engine +resume-identity boundary. It shares current structural fields and the prior whole-document +invariants, including parallel member uniqueness, strict keys, input defaults/enums, +merge requirements and edge consistency. Newly introduced author-only fallback tuple, +condition-match and orphan gate-action refusals do not make an unchanged historical run +unreadable. Fresh parsing still uses WorkflowSchema; frozen content is neither repaired +nor normalized to evade its exact identity comparison. + + > **"The exact graph that ran" includes MCP-discovered tool grants.** A surface that augments the parsed workflow before starting the engine — `relavium run` unions each inline agent's `tools` grant with the tools its declared `mcp_servers` discovered — must freeze the **augmented** definition, not the authored one. It did not, until [ADR-0083](../../decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md) §5: on every workflow with `mcp_servers`, the column recorded a graph that never ran, and a cross-process `relavium gate` rebuilt the run from it. The grants are part of workflow identity, so an MCP server returning a different tool set on resume is a divergence — and one that can only be detected if what was frozen is what was executed. > **Logical `Run` vs persisted `RunRow`.** `@relavium/shared` exports `RunSchema` — the **narrow, engine-/surface-facing** view of a run (status, trigger, inputs/outputs, token + cost totals, timestamps). This `runs` table is the **persistence** shape and carries additional columns that are a database concern, modeled by `@relavium/db` as a distinct `RunRow` mirroring the DDL below: `workflow_definition_snapshot` (the frozen graph for replay/resume), `trigger_metadata`, `workflow_path`/`project_root`, and the `deleted_at` soft-delete cursor. Those are intentionally absent from the logical `RunSchema`; a consumer that needs them reads the `RunRow`. The split keeps the engine view free of storage details while `@relavium/db` owns the row ↔ column mapping. The logical validator permits missing legacy `startedAt`/`completedAt`, error and outputs; when present, `completedAt` requires completed/failed/cancelled status, `startedAt` is forbidden while pending, and `error` requires failed status. Existing temporal checks still apply. These are logical-view admission rules, not new SQL constraints, migrations or stricter persisted-row readers. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 94d55a62..5d76698d 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4248,3 +4248,45 @@ These remain local author gates, not full CI/coverage/native-platform certificat Two NEW complete cumulative clean rounds after this material fix are required; no earlier round is counted as acceptance. Detailed reports/logs remain local and Git-ignored. + + +### Wave 3 Step 2 frozen-workflow correction — 2026-10-16 + +The NEW complete cumulative review at `09e5927d` confirms one High and one Medium; +the parent reads the full report and verifies both actual consumer paths. The +session-reader correction stands, but the engine's frozen-content identity check +and CLI gate reader still applied fresh WorkflowSchema rules to historical runs. +Unchanged inline fallback duplicates, repeated condition matches and inactive +orphan timeout actions were valid at the Step 2 base. This round is not clean; +no earlier review is counted toward acceptance. + +WorkflowSnapshotSchema now composes the same structural fields and shared +whole-document invariants with historical agent/node contracts. It omits only the +new author-only fallback/condition/gate checks. Prior strict fields, duplicate +identities, input default/enum checks, parallel member uniqueness, custom merge +requirements and parallel-edge consistency remain enforced. Actual CLI gate and +engine frozen-identity readers use this contract; fresh parsers retain strict +WorkflowSchema/AgentSchema/leaf admission. Historical content remains the identity +authority, without automatic graph repair or fallback deduplication. + +Permanent controls cover the real frozen-content verifier and actual CLI → engine +→ SQLite pause/resume for all three shapes, exact completed output, first-declared +condition selection, inactive timeout action, preserved original events/snapshot +and continued corrupt parallel-member refusal. Shared snapshot controls retain +seven prior rejection classes. Session export faithfully preserves old fallback +order/budgets; canonical mapping and serializer comments now qualify the current- +authoring round-trip guarantee, a permanent test pins explicit repair/current +refusal, and the shared successful-export notice names required historical review. +The exporter neither rewrites history nor silently deduplicates the snapshot. + +Final author gates pass: all workspace lint/typecheck, 591 files / 13,119 passing +tests / 20 established skips, six build tasks (five cached), seam/dependency fences, +test-isolation across all six workspaces, source formatting, whitespace and 3,338 +relative links. Nine fresh mechanism removals break 25/3/9/3/3/6/1/3/3 designated +regressions for node, agent, run, literal, standalone reasons, config bounds, +historical session, engine frozen identity and CLI gate readers; every source is +restored byte-identically before the final passing author suite. Filtered cases +are not platform skips. This is local author evidence, not full CI, coverage, +native-platform or whole-wave certification. Two NEW independent complete clean +cumulative Step 2 rounds after this final material fix remain required before +Step 3. Detailed reports/controls remain local and Git-ignored. From 873da1c344c9fc91212f4362acf60dc032d4912e Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 03:06:03 +0300 Subject: [PATCH 030/222] docs(docs): record independent wave 3 step 2 acceptance Join two new complete cumulative clean rounds at the final corrected source. Close the scoped admission/diagnostic items and certified existing guards; retain exact author/reviewer attribution and historical-reader dispositions. Advance automatically to approved ADR-0113 Step 3 and its security sitting. Refs: Wave 3 Step 2, Wave 3 Step 3, ADR-0113 Co-Authored-By: Codex --- AGENTS.md | 4 ++- CLAUDE.md | 4 ++- docs/roadmap/current.md | 5 ++- .../phase-2.5.5-hardening-and-remediation.md | 34 +++++++++++++++++-- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ebdce2ff..016f2304 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,9 @@ implementation, review, publication, ADR approval, provider capture, credential, billing or user-decision gate remains. Next is [Wave 3 — Clear the ground](docs/roadmap/current.md#wave-3--clear-the-ground), whose ADR-0110–0113/contracts/nineteen-increment plan were approved on 2026-10-16. Its serialized multi-file Step 1 is independently accepted on `development` -(2026-10-16); Step 2 is active. Later implementation and whole-wave acceptance +(2026-10-16); Step 2 is also accepted after two NEW clean complete cumulative rounds +following its historical-reader corrections. Step 3 (ADR-0113 secret provenance) +is active. Later implementation and whole-wave acceptance remain staged in the canonical roadmap. See [docs/roadmap/current.md](docs/roadmap/current.md) for live status. diff --git a/CLAUDE.md b/CLAUDE.md index 40de3957..4b1cc1a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,7 +169,9 @@ implementation, review, publication, ADR approval, provider capture, credential, billing or user-decision gate remains. Next is [Wave 3 — Clear the ground](docs/roadmap/current.md#wave-3--clear-the-ground), whose ADR-0110–0113/contracts/nineteen-increment plan were approved on 2026-10-16. Its serialized multi-file Step 1 is independently accepted on `development` -(2026-10-16); Step 2 is active. Later implementation and whole-wave acceptance +(2026-10-16); Step 2 is also accepted after two NEW clean complete cumulative rounds +following its historical-reader corrections. Step 3 (ADR-0113 secret provenance) +is active. Later implementation and whole-wave acceptance remain staged in the canonical roadmap. For live status, per-PR history, milestone dates, and open obligations, see the canonical diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 383a7b9b..1ffaf4ef 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1111,7 +1111,10 @@ ADR-0110–0113, their linked unshipped contracts and the nineteen-increment pla The [approval checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-maintainer-approval-and-implementation-checkpoint--2026-10-16) records completed decision gates and staged implementation. [Step 1 is accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-1-acceptance--2026-10-16) -after two NEW independent complete clean reviews; Step 2 is active. The +after two NEW independent complete clean reviews. +[Step 2 is also accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-2-acceptance--2026-10-16) +with two NEW complete clean cumulative rounds after its historical-reader corrections; +Step 3 (ADR-0113 secret provenance) is active. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 5d76698d..4577f0c8 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -195,7 +195,7 @@ reached. - ✅ **DONE (Wave 1, PR #81, 2026-07-30 — G38 · `reservedAdmissions` ledger in `budget-governor.ts`).** **Add a reservation/admission step to `BudgetGovernor`'s pre-egress check.** `checkPreEgress` (`packages/core/src/engine/budget-governor.ts`) is a pure read against a snapshot that is only updated post-hoc after `cost:updated` lands, with no reservation/admission-control step — two or more parallel agent-node branches under `max_parallel` can each pass `checkPreEgress` against the same stale snapshot, letting the sum exceed `max_cost_microcents` even with `on_exceed:'fail'`. Add an in-flight reserved-cost ledger that is checked and updated atomically at dispatch time, and add a test combining budget + `max_parallel` (none exists today). *(L · `packages/core/src/engine/budget-governor.ts` · G38)* - ✅ **DONE (Phase 2.6.5 `CR-22`, 2026-08-27)** — closed early, out of wave order, because `W2`'s liveness item owns the same function. **Compute the remaining timeout budget on every resume instead of re-arming a fresh timer.** `#armRunTimeout` (`packages/core/src/engine/engine.ts`) re-arms a full-duration timer on every checkpoint/gate/media-job resume instead of computing remaining budget from `#elapsedMs()`, unlike the checkpoint's own `startEpochMs` and the media-job absolute-deadline pattern it should mirror — a workflow with `timeout_ms` can run far longer than authored if it pauses and resumes repeatedly. Fix `#armRunTimeout` to compute the remaining budget from elapsed wall-clock time on every re-arm. *(M · `packages/core/src/engine/engine.ts` · #75)* - **Thread the missing `AbortSignal` into `resolvePrompt` and recheck cancellation in `human-gate.ts`.** `resolvePrompt` (`packages/core/src/engine/agent-runner.ts:868`) omits the `AbortSignal` that every other call site passes into `resolveTemplate`, so cancellation during prompt-template resolution is not observed. Separately, `human-gate.ts` (`packages/core/src/engine/node-handlers/human-gate.ts`) is the one node handler doing real async work yet has no post-resolution `ctx.signal.aborted` recheck before returning `'paused'` — a cancelled run can emit `human_gate:paused` right before `run:cancelled`. Add the signal to `resolvePrompt` and the recheck to `human-gate.ts` in one pass. *(M · `packages/core/src/engine/agent-runner.ts`, `packages/core/src/engine/node-handlers/human-gate.ts` · #76, G9)* -- **Extract a shared `messageFor` diagnostic helper for `parser.ts` and `agent-parser.ts`.** `parser.ts`'s `messageFor` has no case for Zod's `invalid_literal` code, so a `schema_version` mismatch reports only "invalid value" with no expected-value indication. `agent-parser.ts`'s `AgentParseError` carries only field paths, never reason text, so distinct error kinds report identically on the `.agent.yaml` authoring surface — a gap distinct from 2.6.B's visibility-wiring task. Extract `messageFor` into a schema-agnostic helper consumed by both `parser.ts` and `agent-parser.ts`. *(M · `packages/core/src/parser.ts`, `packages/core/src/agent-parser.ts` · #85, #86)* +- ✅ **DONE ([Wave 3 Step 2](#wave-3-step-2-acceptance--2026-10-16)).** **Extract a shared `messageFor` diagnostic helper for `parser.ts` and `agent-parser.ts`.** `parser.ts`'s `messageFor` has no case for Zod's `invalid_literal` code, so a `schema_version` mismatch reports only "invalid value" with no expected-value indication. `agent-parser.ts`'s `AgentParseError` carries only field paths, never reason text, so distinct error kinds report identically on the `.agent.yaml` authoring surface — a gap distinct from 2.6.B's visibility-wiring task. Extract `messageFor` into a schema-agnostic helper consumed by both `parser.ts` and `agent-parser.ts`. *(M · `packages/core/src/parser.ts`, `packages/core/src/agent-parser.ts` · #85, #86)* - **Reconcile `system_prompt_append`'s documented interpolation with its verbatim-append implementation.** `workflow-yaml-spec.md` documents `system_prompt_append` as an interpolation target, but `agent-runner.ts` appends it verbatim by design (ADR-0038 anti-injection), while `packages/core/src/dag.ts` still wires it into real scheduling/taint-scan sites — an author using `{{ }}` there gets literal unresolved text sent to the model with zero error. Fix by either correcting the spec or adding a parse-time rejection of `{{ }}` syntax in that field. *(M · `packages/core/src/engine/agent-runner.ts`, `docs/reference/*/workflow-yaml-spec.md` · #87)* - ✅ **DONE (Wave 1, PR #81, 2026-07-30 — G39/G47/G49 · `warningArmed` re-arm + projected `thresholdPct`).** **Re-arm `budget:warning` on continued overage and compute `thresholdPct` from the projected post-call total.** `#warningEmitted` (`packages/core/src/engine/budget-governor.ts:112`) is a one-shot lifetime boolean, so a long-lived `AgentSession`/chat process gets exactly one `budget:warning` ever and then silently proceeds no matter how far over cap spend climbs. Separately, `thresholdPct` is computed from pre-call cumulative spend rather than the projected post-call amount, so a single large call that jumps past the cap reports a near-0% warning. Fix both in the same pass: re-arm the warning on continued overage, and derive `thresholdPct` from the projected post-call total. *(M · `packages/core/src/engine/budget-governor.ts` · G39, G47, G49)* - **Give `assertNoTaintedArgs()` an actual producer for `ctx.secretArgKeys`.** `ctx.secretArgKeys` — the runtime ADR-0029(c) taint-check input that `assertNoTaintedArgs()` (`packages/core/src/tools/registry.ts`, `packages/core/src/tools/types.ts`) correctly enforces when present — has no producer anywhere in the engine, so the check is inert everywhere, including on the `AgentSession`/chat path with no static-analysis backstop. Either wire a producer that actually populates `ctx.secretArgKeys`, or explicitly record this as a tracked, known gap in the ADR. *(M · `packages/core/src/tools/registry.ts`, `packages/core/src/engine/agent-session.ts` · #92)* @@ -204,7 +204,7 @@ reached. - **Remove cycle-guard `WeakSet` entries on recursion unwind.** `redactInlineMediaForText`/`redactSecretShapedWalk` (`packages/core/src/tools/bounding.ts`) add to the shared cycle-guard `WeakSet` but never remove on the way back out of recursion, so any object referenced twice non-cyclically renders as a bogus `[cyclic]` in observability output. Remove the object from the `WeakSet` when recursion unwinds and add a regression test. *(S · `packages/core/src/tools/bounding.ts` · #94)* - **Decide the real backpressure granularity for `RunHandle` and `SessionHandle` in one pass.** `RunHandle.whenConsumersReady()` (`packages/core/src/engine/run-handle.ts`) is only awaited once per node dispatch, never within a single node's own streaming duration, so a long token/reasoning stream against a stalled consumer buffers unboundedly. `SessionHandle.whenConsumersReady`/`whenDrained` (`packages/core/src/engine/session-handle.ts`) is implemented identically but has zero callers anywhere. Decide and implement the actual granularity needed for both handles together. *(M · `packages/core/src/engine/run-handle.ts`, `packages/core/src/engine/session-handle.ts` · #77, G48)* - **Unescape the quoted filter-argument text `parseArg` returns.** `parseArg` (`packages/core/src/interpolation/references.ts`) returns the raw backslash-escaped text of a quoted filter argument instead of unescaping it, contradicted by its own test's comment. Fix `parseArg` to unescape the quoted argument text per its documented contract, and correct the misleading test comment. *(S · `packages/core/src/interpolation/references.ts` · #82)* -- **Apply `findDuplicates` and `superRefine` to the seven remaining schemas that skipped them.** The repo's own `findDuplicates` helper (`packages/shared/src/common.ts`) is applied to node ids/input names/context keys/`mcp_servers` ids, but not to `ParallelNodeSchema.parallel_of` (`packages/shared/src/node.ts`), `ConditionBranchSchema`'s `when` values across `branches[]` (`packages/shared/src/node.ts`), or `AgentSchema.fallback_chain`'s `(model,provider)` pairs (`packages/shared/src/agent.ts`) despite the same schema's `mcp_servers` dedup a few lines away. Separately, `superRefine` cross-field invariant enforcement exists in `packages/shared` but was never applied consistently: `HumanGateNodeSchema` (`packages/shared/src/node.ts`) has no `superRefine` linking `timeout_action` to `timeout_ms` even though the analogous emitted-event schema in `run-event.ts` already rejects exactly this combination; `WorkflowInputSchema` (`packages/shared/src/workflow.ts`) never checks default/enum values are type-consistent with the declared type; `SessionMessageSchema` (`packages/shared/src/session.ts`) has no `superRefine` correlating `compaction` with `role:'system'`; `RunSchema`'s `superRefine` (`packages/shared/src/run.ts:72`) checks temporal ordering but not status/field consistency. One PR applying both existing dispatch-table patterns to all seven remaining schemas. *(M · `packages/shared/src/{node,agent,workflow,session,run,common}.ts` · #89, #90, G18, #212, G16, G17, #210, #211)* +- ✅ **DONE ([Wave 3 Step 2](#wave-3-step-2-acceptance--2026-10-16)).** **Apply consistent duplicate/cross-field admission; certify existing input and session guards.** Shared leaf/full admission now enforces the approved duplicate, human-gate timeout and logical Run status matrices. Existing input defaults/enums and session compaction-role rules are certified. Historical session/workflow readers retain their former contracts; no rows are migrated or silently repaired. *(M · `packages/shared/src/{node,agent,workflow,session,run,common}.ts` · #89, #90, G18, #212, G16, G17, #210, #211)* - **Extend `analyzeSecretTaint`'s leak-reporting loop to cover context-value sites.** `analyzeSecretTaint` (`packages/core/src/interpolation/analyze.ts`) deliberately skips context-value sites in its leak-reporting loop, so a direct `{{secrets.*}}` reference never read elsewhere is not reported at parse time — the workflow only fails later, differently-shaped, at run start. Not an actual secret leak, but a real completeness gap in the "reject before a run starts, uniformly" contract. *(S · `packages/core/src/interpolation/analyze.ts` · G22)* - **Delete the unused `BaseEventSchema`/`HumanGateEvent` exports or wire `BaseEventSchema` to validate the envelope it claims to model.** Both are public exports from `packages/shared/src/run-event.ts`, pinned by `index.test.ts`'s public-surface list, with zero consumers anywhere — and `BaseEventSchema` isn't even used to validate the envelope it claims to model. Delete the unused exports and pinning entries, or wire `BaseEventSchema` to actually validate the run-event envelope. *(S · `packages/shared/src/run-event.ts` · #213)* - **Correct `ctx.fsScope`'s wiring, blocked on a maintainer call.** `ctx.fsScope` is threaded through every dispatch context in `packages/core/src/tools/{types,registry,builtins}.ts` and `packages/core/src/engine/agent-session.ts`, but `enforcePolicy()` (`packages/core/src/tools/registry.ts`) has no `fsScoped` branch and no `FsCapability` method signature accepts it. **Blocked on a maintainer decision**: is this a deliberate "one host instance = one immutable scope" design, in which case only the doc comment needs correcting, or should it be threaded into `FsCapability`'s method signatures to support future per-turn scope escalation? *(S · `packages/core/src/tools/registry.ts` · #93)* @@ -326,7 +326,7 @@ This workstream is `apps/cli`'s outermost layer — the process/runtime host eve - **Certify durable-only logs (G15; source correction — 2026-10-15).** The earlier token-firehose premise is refuted: shipping token/reasoning emission is live-only and logs reads durable rows. No coalescing decision or new persistence is required. Preserve actual replay with a real engine/store/log regression and JSON sequence-gap controls; the [reopened preflight](#maintainer-review-reopened-the-proposal--2026-10-15) records the evidence. *(M · apps/cli/src/commands/logs.ts · G15)* - **Attach `GLOBAL_OPTIONS_HELP` to every subcommand's `--help`, not just the root program's.** `apps/cli/src/program.ts` defines `GLOBAL_OPTIONS_HELP` (documenting the position-independent flags `commander` itself doesn't parse, like `--json`/`--cwd`/`--verbose`) and attaches it only to the root program; `commander` does not propagate it to subcommand help output, so `relavium run --help` never shows it. Attach the same help text to each subcommand, or add an explicit pointer to `relavium --help`. *(S · apps/cli/src/program.ts · #17)* - **Add a `--force` gate or interactive confirmation to `provider remove-key`, the manifest's sole `effect: 'destructive'` command.** `apps/cli/src/commands/provider.ts`'s `remove-key` action has no confirmation gate at all, unlike the create/import/export family, which all require explicit confirmation for destructive effects. Add the same pattern here. *(S · apps/cli/src/commands/provider.ts · #19)* -- **Make `formatZodError` show numeric bounds for `too_small`/`too_big` the same way it already does for `invalid_enum_value`.** `apps/cli/src/config/load.ts`'s `formatZodError` includes the schema-derived bound value in enum-mismatch messages but withholds it for out-of-range numeric errors, so a `config.toml` value outside its valid range gets a less actionable message than a bad enum choice. Include the bound consistently across both error kinds. *(S · apps/cli/src/config/load.ts · #32)* +- ✅ **DONE ([Wave 3 Step 2](#wave-3-step-2-acceptance--2026-10-16)).** **Make `formatZodError` show schema-derived numeric bounds.** `apps/cli/src/config/load.ts`'s `formatZodError` includes the schema-derived bound value in enum-mismatch messages but withholds it for out-of-range numeric errors, so a `config.toml` value outside its valid range gets a less actionable message than a bad enum choice. Include the bound consistently across both error kinds. *(S · apps/cli/src/config/load.ts · #32)* - **Add a direct unit test for `selectGatePrompter`'s TTY/json/CI routing decision.** `apps/cli/src/render/tui/home-controller.ts`'s `selectGatePrompter` has no direct test of its own, unlike its structurally identical sibling `apps/cli/src/home/should-open-home.ts`, which is tested. Add a test mirroring the sibling's coverage. *(S · apps/cli/src/render/tui/home-controller.ts · G37)* - **Add a test for the positive `--verbose`-without-`--json` stack-dump branch, found untested independently twice.** `apps/cli/src/index.ts`'s render-error path has a branch that prints the stack trace when `--verbose` is set without `--json`; it has zero direct test coverage, flagged by two independent review passes. Add one test asserting the branch actually prints the stack. *(S · apps/cli/src/index.ts · G36, G46)* - **Delete the byte-identical duplicate interface declaration in `models-pricing.ts`.** `apps/cli/src/commands/models-pricing.ts` declares the same interface twice with no functional difference. One-line deletion, no behavior change. *(S · apps/cli/src/commands/models-pricing.ts · G14)* @@ -4290,3 +4290,31 @@ are not platform skips. This is local author evidence, not full CI, coverage, native-platform or whole-wave certification. Two NEW independent complete clean cumulative Step 2 rounds after this final material fix remain required before Step 3. Detailed reports/controls remain local and Git-ignored. + + +### Wave 3 Step 2 acceptance — 2026-10-16 + +Step 2 is independently accepted at `4048526fbf2585560cd12dbdf11fc3412d4be9d5`. +Two NEW complete cumulative clean rounds after the final material fix cover all +34 changed paths and actual consumers: gpt-6.1-sol/high (16 files / 545 independent +passing tests, baseline compatibility and wrapper controls) followed by +gpt-6-astra/high (17 files / 585 independent passing tests, 21 workflow + five +agent baseline comparisons, 40 wrapper assertions, actual SQLite preservation and +10 runtime/source correspondence checks). Both report zero findings. The parent +reads both full reports and verifies the original session-reader, frozen-workflow +reader and export-contract findings are resolved; the two earlier nonclean rounds +are not counted toward acceptance. + +All scoped #89/#90/#212/G18/#210/G17/#85/#86/#32 work is complete; #211/G16 are +certified existing guards. The author evidence retains its actual scope: 591 files +/ 13,119 passing tests / 20 established skips, workspace lint/typecheck, six builds +(five cached), fences, 591-file/six-workspace isolation, formatting/whitespace and +relative links. Nine causal removals and exact restoration precede the final +passing author suite. Reviewer executions are separate from those author gates; +no full CI/coverage/native-platform, successor execution or whole-wave acceptance +is inferred from this metadata landing. + +Next is Step 3, ADR-0113 secret provenance and actual VM/public-template consumers, +with a dedicated security sitting. No user decision or review finding remains +pending for Step 2. Detailed reports and working controls remain Git-ignored; +Step 19 retains whole-wave acceptance/publication. From 8813391b898b843041f6f26b97ddddb90c822bd5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 03:32:38 +0300 Subject: [PATCH 031/222] fix(core)!: exclude declared secrets at public context boundaries Own context provenance and secret-input membership before executor and budget preparation callbacks. Public templates preflight declared secret references; QuickJS excludes their raw values and diagnoses ordinary observed reads through private accessors and bounded primitive transport. Preserve trusted raw resolver APIs, display markers, optional input presence, result parity and the finite pre-accessor resource limitation. Add actual VM and fresh/resume/preparation regressions with load-bearing removal witnesses. BREAKING CHANGE: NodeExecContext and ExpressionScope require explicit context provenance; supplied declared secret-input expression reads now fail explicitly instead of returning display markers. See the canonical expression contract. Refs: ADR-0113 Co-Authored-By: Codex --- .../agent-runner-allowance-settlement.test.ts | 8 +- packages/core/src/engine/agent-runner.test.ts | 12 +- packages/core/src/engine/agent-runner.ts | 11 +- packages/core/src/engine/engine.test.ts | 7 +- packages/core/src/engine/engine.ts | 52 +- packages/core/src/engine/node-executor.ts | 19 +- .../engine/node-handlers/human-gate.test.ts | 12 +- .../src/engine/node-handlers/human-gate.ts | 26 +- .../node-handlers/node-handlers.test.ts | 50 +- .../core/src/engine/node-handlers/scope.ts | 45 +- .../secret-provenance-producers.test.ts | 481 ++++++++++++++++++ packages/core/src/errors.ts | 29 +- packages/core/src/expression/program.ts | 113 ++++ .../core/src/expression/sandbox.perf.test.ts | 11 +- packages/core/src/expression/sandbox.test.ts | 31 +- packages/core/src/expression/sandbox.ts | 206 +++----- .../src/expression/scan-soundness.test.ts | 7 +- .../src/expression/secret-context.test.ts | 462 +++++++++++++++++ .../core/src/expression/transport.test.ts | 229 +++++++++ packages/core/src/expression/transport.ts | 113 ++++ packages/core/src/index.ts | 14 +- packages/core/src/interpolation/analyze.ts | 2 +- .../core/src/interpolation/provenance.test.ts | 306 +++++++++++ packages/core/src/interpolation/provenance.ts | 132 +++++ .../src/interpolation/public-template.test.ts | 266 ++++++++++ packages/core/src/interpolation/resolve.ts | 52 +- packages/core/src/tools/registry.ts | 3 +- packages/core/src/tools/types.ts | 6 +- 28 files changed, 2457 insertions(+), 248 deletions(-) create mode 100644 packages/core/src/engine/secret-provenance-producers.test.ts create mode 100644 packages/core/src/expression/program.ts create mode 100644 packages/core/src/expression/secret-context.test.ts create mode 100644 packages/core/src/expression/transport.test.ts create mode 100644 packages/core/src/expression/transport.ts create mode 100644 packages/core/src/interpolation/provenance.test.ts create mode 100644 packages/core/src/interpolation/provenance.ts create mode 100644 packages/core/src/interpolation/public-template.test.ts diff --git a/packages/core/src/engine/agent-runner-allowance-settlement.test.ts b/packages/core/src/engine/agent-runner-allowance-settlement.test.ts index c2f633fd..85f9094e 100644 --- a/packages/core/src/engine/agent-runner-allowance-settlement.test.ts +++ b/packages/core/src/engine/agent-runner-allowance-settlement.test.ts @@ -11,6 +11,10 @@ import { BudgetGovernor, type GovernorEventDraft } from './budget-governor.js'; import type { PreEgressInfo } from './agent-turn.js'; import type { NodeExecContext, NodeStreamEvent } from './node-executor.js'; import type { SettledAttemptDraft } from './money-durability.js'; +import { + createWorkflowContextProjection, + ownSecretInputNames, +} from '../interpolation/provenance.js'; import type { AgentPlanConfig } from '../run-plan.js'; const MODEL = 'w7-runner-settlement'; @@ -109,8 +113,8 @@ function setup(overlay: PricingOverlay, media: boolean, amount = 16, maxTokens = }, runOutputs: new Map(), inputs: {}, - ctx: {}, - secretInputNames: new Set(), + ctx: createWorkflowContextProjection({ values: {}, deniedKeys: [], orderedKeys: [] }), + secretInputNames: ownSecretInputNames([]).secretInputNames, toolPolicy: {}, emit: (event) => { events.push(event); diff --git a/packages/core/src/engine/agent-runner.test.ts b/packages/core/src/engine/agent-runner.test.ts index 3fa90905..8915d260 100644 --- a/packages/core/src/engine/agent-runner.test.ts +++ b/packages/core/src/engine/agent-runner.test.ts @@ -14,6 +14,10 @@ import type { } from '@relavium/llm'; import { describe, expect, it, vi } from 'vitest'; +import { + createWorkflowContextProjection, + ownSecretInputNames, +} from '../interpolation/provenance.js'; import type { AgentPlanConfig, PlanVertex } from '../run-plan.js'; import { BUILTIN_TOOLS } from '../tools/builtins.js'; import { delegateAvailable } from '../tools/delegates.js'; @@ -251,8 +255,12 @@ function ctxFor( vertex, runOutputs, inputs, - ctx: ctxValues, - secretInputNames: new Set(), + ctx: createWorkflowContextProjection({ + values: ctxValues, + deniedKeys: [], + orderedKeys: Object.keys(ctxValues), + }), + secretInputNames: ownSecretInputNames([]).secretInputNames, toolPolicy: {}, emit: (e) => events.push(e), signal: { diff --git a/packages/core/src/engine/agent-runner.ts b/packages/core/src/engine/agent-runner.ts index b29b2938..d601a9d4 100644 --- a/packages/core/src/engine/agent-runner.ts +++ b/packages/core/src/engine/agent-runner.ts @@ -61,8 +61,8 @@ import { import { MAX_REPORTED_WIDENINGS } from '../dag.js'; import { outputSchemaMiss } from '../output-schema.js'; -import { resolveTemplate } from '../interpolation/resolve.js'; -import type { ResolverCapabilities, RunScope } from '../interpolation/scope.js'; +import { resolvePublicTemplate, type PublicTemplateScope } from '../interpolation/resolve.js'; +import type { ResolverCapabilities } from '../interpolation/scope.js'; import type { AgentPlanConfig } from '../run-plan.js'; import { authoredSystemPrompt, type AuthoredSystemPrompt } from './authored-system-prompt.js'; import { modelVisibleDescription } from '../tools/types.js'; @@ -1577,20 +1577,21 @@ function chainCapabilities(deps: AgentRunnerDeps): ChainCapabilities { async function resolvePrompt( template: string, - ctx: Pick, + ctx: Pick, deps: AgentRunnerDeps, ): Promise<{ ok: true; text: string } | { ok: false; message: string }> { // The resolved prompt may draw on untrusted run.outputs / read_file — it lands in a USER message // (assembleMessages), never `system`. `ctx` is the resolved workflow-context namespace // (`NodeExecContext.ctx`), folded once at run start by the engine; a prompt resolves against // inputs + ctx + run.outputs. - const scope: RunScope = { + const scope: PublicTemplateScope = { inputs: ctx.inputs, ctx: ctx.ctx, + secretInputNames: ctx.secretInputNames, outputs: Object.fromEntries(ctx.runOutputs), }; try { - const text = await resolveTemplate(template, scope, deps.resolverCapabilities ?? {}); + const text = await resolvePublicTemplate(template, scope, deps.resolverCapabilities ?? {}); return { ok: true, text }; } catch (err) { return { diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts index a9dcf876..999513e8 100644 --- a/packages/core/src/engine/engine.test.ts +++ b/packages/core/src/engine/engine.test.ts @@ -4360,7 +4360,10 @@ describe('WorkflowEngine — workflow context (ctx.*) resolution', () => { edges: - { from: start, to: work } - { from: work, to: out }`; - const echoCtx = (c: NodeExecContext): NodeOutcome => ({ kind: 'completed', output: c.ctx }); + const echoCtx = (c: NodeExecContext): NodeOutcome => ({ + kind: 'completed', + output: c.ctx.values, + }); it('resolves the workflow context once at start and threads ctx.* to every node', async () => { const events = await drain( @@ -4401,7 +4404,7 @@ describe('WorkflowEngine — workflow context (ctx.*) resolution', () => { } }); - it('threads ctx: {} when the workflow declares no context: block', async () => { + it('threads empty clean context values when the workflow declares no context: block', async () => { const events = await drain( engineWith({ work: echoCtx }).start({ workflow: workflow(` id: no-ctx diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index 83dddd2b..292f0d3b 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -74,7 +74,12 @@ import { import type { EndpointKind, MediaJobStatus, PricingOverlay, ProviderId } from '@relavium/llm'; import { buildRunPlan, type BuildRunPlanOptions } from '../dag.js'; -import { InterpolationError } from '../errors.js'; +import { InterpolationError, WorkflowContextError } from '../errors.js'; +import { + createWorkflowProvenance, + type WorkflowProvenance, + type WorkflowContextProjection, +} from '../interpolation/provenance.js'; import { resolveContext, resolveTemplate } from '../interpolation/resolve.js'; import type { ResolverCapabilities, RunScope } from '../interpolation/scope.js'; import { ADMISSION_CEILINGS, DEFAULT_MAX_PARALLEL } from '../limits.js'; @@ -552,6 +557,7 @@ class RunExecution { readonly #maskedInputs: Record; /** The names of `secret`-typed inputs — threaded to handlers so they keep raw secrets out of outputs. */ readonly #secretInputNames: ReadonlySet; + readonly #provenance: WorkflowProvenance; readonly #executionMode: ExecutionMode; readonly #eventConsumption: EventConsumption; readonly #host: ExecutionHost; @@ -568,7 +574,7 @@ class RunExecution { | undefined; #resumeAdmitted = false; /** The resolved workflow `context:` (`ctx.*`), folded once at run start (or re-resolved on resume). */ - #resolvedContext: Readonly> = {}; + #resolvedContext: WorkflowContextProjection | undefined; readonly #abort: AbortControllerLike; readonly #states = new Map(); @@ -776,7 +782,8 @@ class RunExecution { resumePreparation?: { readonly gateId: string; readonly preparation: BudgetDispatchPreparation; - readonly context: Readonly>; + readonly context: WorkflowContextProjection; + readonly provenance: WorkflowProvenance; }; }) { this.runId = params.runId; @@ -797,13 +804,10 @@ class RunExecution { this.#effectResume = params.effectResume; this.#abort = params.host.newAbortController(); - const secretNames = new Set( - (params.workflow.workflow.inputs ?? []) - .filter((input) => input.type === 'secret') - .map((input) => input.name), - ); - this.#secretInputNames = secretNames; - this.#maskedInputs = maskInputs(params.inputs, secretNames); + this.#provenance = + params.resumePreparation?.provenance ?? createWorkflowProvenance(params.workflow); + this.#secretInputNames = this.#provenance.secretInputNames; + this.#maskedInputs = maskInputs(params.inputs, this.#secretInputNames); this.#maxTokensEstimate = params.maxTokensEstimate ?? DEFAULT_MAX_TOKENS_ESTIMATE; this.#resolvePrice = params.resolvePrice; if (params.resumePreparation !== undefined) { @@ -1361,6 +1365,12 @@ class RunExecution { } } + /** Required engine-owned projection; an absent value is never implicit clean context. */ + #contextProjection(): WorkflowContextProjection { + if (this.#resolvedContext === undefined) throw new WorkflowContextError(); + return this.#resolvedContext; + } + /** * Resolve the workflow `context:` map into the frozen `ctx.*` namespace (the spec's eager-once context), * threaded to every node via {@link NodeExecContext.ctx}. Returns `false` on failure: a cancel mid-resolve @@ -1370,12 +1380,13 @@ class RunExecution { */ async #resolveContextOrFail(): Promise { try { - this.#resolvedContext = await resolveContext( + const resolved = await resolveContext( this.#workflow, this.#inputs, this.#resolverCapabilities, this.#abort.signal, ); + this.#resolvedContext = this.#provenance.qualifyContext(resolved); return true; } catch (error) { if (this.#cancelling || this.#abort.signal.aborted) { @@ -2208,7 +2219,7 @@ class RunExecution { vertex, runOutputs: this.#completedOutputs(), inputs: this.#inputs, - ctx: this.#resolvedContext, + ctx: this.#contextProjection(), secretInputNames: this.#secretInputNames, toolPolicy: this.#workflow.workflow.tools ?? {}, signal: this.#abort.signal, @@ -3036,7 +3047,7 @@ class RunExecution { vertex, runOutputs: this.#completedOutputs(), inputs: this.#inputs, - ctx: this.#resolvedContext, + ctx: this.#contextProjection(), secretInputNames: this.#secretInputNames, toolPolicy: this.#workflow.workflow.tools ?? {}, emit: (event) => { @@ -6199,7 +6210,8 @@ export class WorkflowEngine { | { readonly gateId: string; readonly preparation: BudgetDispatchPreparation; - readonly context: Readonly>; + readonly context: WorkflowContextProjection; + readonly provenance: WorkflowProvenance; } | undefined > { @@ -6244,12 +6256,14 @@ export class WorkflowEngine { { runId: input.runId, gateId: input.gateId }, ); try { - const context = await resolveContext( + const provenance = createWorkflowProvenance(input.workflow); + const resolved = await resolveContext( input.workflow, inputs, this.#resolverCapabilities, signal, ); + const context = provenance.qualifyContext(resolved); const outputs = new Map(); for (const [nodeId, state] of checkpoint.nodeStates) if (state.status === 'completed') outputs.set(nodeId, state.output); @@ -6258,11 +6272,7 @@ export class WorkflowEngine { runOutputs: outputs, inputs, ctx: context, - secretInputNames: new Set( - (input.workflow.workflow.inputs ?? []) - .filter((value) => value.type === 'secret') - .map((value) => value.name), - ), + secretInputNames: provenance.secretInputNames, toolPolicy: input.workflow.workflow.tools ?? {}, signal, maxTokensEstimate: this.#maxTokensEstimate, @@ -6276,7 +6286,7 @@ export class WorkflowEngine { plan.budget.strict_cost_cap === true, this.#resolvePrice, ); - return { gateId: input.gateId, preparation: prepared.preparation, context }; + return { gateId: input.gateId, preparation: prepared.preparation, context, provenance }; } catch { if (signal.aborted) return undefined; // cancel wins; passive admission will settle without egress throw new EngineStateError( diff --git a/packages/core/src/engine/node-executor.ts b/packages/core/src/engine/node-executor.ts index 1df6775d..d8121c6e 100644 --- a/packages/core/src/engine/node-executor.ts +++ b/packages/core/src/engine/node-executor.ts @@ -1,3 +1,4 @@ +import type { WorkflowContextProjection } from '../interpolation/provenance.js'; /** * 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 @@ -175,20 +176,12 @@ export interface NodeExecContext { readonly runOutputs: ReadonlyMap; /** The run-wide inputs (the `input` namespace); a `secret`-typed input is carried per the taint rules. */ readonly inputs: Readonly>; + /** Engine-owned clean context strings and explicit denied-key/order metadata (ADR-0113). */ + readonly ctx: WorkflowContextProjection; /** - * The resolved workflow `context:` namespace (the `ctx.*` reads) — the engine resolves it **once** at run - * start (the spec's eager-once context; `resolveContext`) and threads the frozen result here, so a bare - * `ctx.key` JS read in a `condition`/`transform`/`merge_fn` expression (and an agent prompt) sees the real - * value, not `undefined`. `{}` when the workflow declares no `context:`. Values are strings (resolved - * templates); a `secret`-derived context value is gated at parse by the taint analyzer, never here. - */ - readonly ctx: Readonly>; - /** - * The names of `secret`-typed inputs (`inputs.` declared `type: secret`). A handler that emits - * inputs into a node output (the `input` node) or into the expression sandbox (`condition`/`transform`/ - * `merge_fn`) MUST mask/omit these so a raw secret never reaches an event payload or an expression — the - * engine masks `inputs` only for `run:started`, so the handler is the second gate (CLAUDE.md rule 6; - * sse-event-schema.md; the sandbox's "secrets are never injected — the caller filters" contract). + * Engine-owned immutable declared secret-input membership. Public templates preflight references; + * expression scope construction excludes raw values and supplies denied keys. Input-node/display + * output retains markers. Raw inputs remain trusted host data, not safe custom-executor output. */ readonly secretInputNames: ReadonlySet; /** diff --git a/packages/core/src/engine/node-handlers/human-gate.test.ts b/packages/core/src/engine/node-handlers/human-gate.test.ts index 200c52b8..ccbd718c 100644 --- a/packages/core/src/engine/node-handlers/human-gate.test.ts +++ b/packages/core/src/engine/node-handlers/human-gate.test.ts @@ -1,6 +1,10 @@ import type { AbortSignalLike } from '@relavium/shared'; import { describe, expect, it } from 'vitest'; +import { + createWorkflowContextProjection, + ownSecretInputNames, +} from '../../interpolation/provenance.js'; import type { NodeExecContext, NodeOutcome } from '../node-executor.js'; import type { HumanGatePlanConfig, PlanVertex } from '../../run-plan.js'; import { createHumanGateNodeExecutor } from './human-gate.js'; @@ -53,8 +57,12 @@ function ctxFor( vertex, runOutputs: opts.runOutputs ?? new Map(), inputs: opts.inputs ?? {}, - ctx: opts.ctx ?? {}, - secretInputNames: new Set(), + ctx: createWorkflowContextProjection({ + values: opts.ctx ?? {}, + deniedKeys: [], + orderedKeys: Object.keys(opts.ctx ?? {}), + }), + secretInputNames: ownSecretInputNames([]).secretInputNames, toolPolicy: {}, emit: () => undefined, signal: opts.signal ?? LIVE, diff --git a/packages/core/src/engine/node-handlers/human-gate.ts b/packages/core/src/engine/node-handlers/human-gate.ts index be7a93d4..6776c0a7 100644 --- a/packages/core/src/engine/node-handlers/human-gate.ts +++ b/packages/core/src/engine/node-handlers/human-gate.ts @@ -7,18 +7,13 @@ * `resume`). The handler is intentionally thin and clock-free: deadlines (`expiresAt`) are the engine's * job (only it holds the host clock). * - * **Secrets — two layers.** A `secret`-typed `inputs.*` / `ctx.*` reference in `message_template` / - * `assignee` is rejected at PARSE time by the secret-taint analyzer (`node-text` category; analyze.ts). - * A `{{ run.outputs[…] }}` reference is *not* parse-gated (its content is runtime data), but is protected - * at RUNTIME: the `input` node masks every `secret`-typed input before it enters `run.outputs` (io.ts - * `maskSecretInputs`) and an agent prompt can't interpolate a secret (the same parse gate), so a raw - * secret never reaches `ctx.runOutputs` for this handler to surface. Together that lets the gate text - * resolve against raw inputs without a secret reaching the `human_gate:paused` payload — mirroring the - * agent's `prompt_template` (agent-runner.ts). + * Parser taint checks and resolvePublicTemplate protect declared secret inputs/context in both gate + * fields. Runtime preflight also covers hand-constructed workflows. Upstream outputs are runtime data: + * this consumer does not scan arbitrary sensitive literals or scrub historical persisted outputs. */ -import { resolveTemplate } from '../../interpolation/resolve.js'; -import type { ResolverCapabilities, RunScope } from '../../interpolation/scope.js'; +import { resolvePublicTemplate, type PublicTemplateScope } from '../../interpolation/resolve.js'; +import type { ResolverCapabilities } from '../../interpolation/scope.js'; import type { GateRequest, NodeExecContext, NodeExecutor, NodeOutcome } from '../node-executor.js'; import { cancelled, failed } from './scope.js'; @@ -39,12 +34,11 @@ async function runHumanGate( return cancelled(); } const { node } = config; - // Resolve the human-facing text against inputs + the resolved ctx.* + run.outputs (secrets are - // parse-gated; see file header). `ctx` is the engine-resolved workflow context (NodeExecContext.ctx), - // so a gate's `{{ctx.key}}` in message_template / assignee resolves (workflow-yaml-spec.md §context). - const scope: RunScope = { + // Public text resolves through the same required provenance boundary as agent preparation. + const scope: PublicTemplateScope = { inputs: ctx.inputs, ctx: ctx.ctx, + secretInputNames: ctx.secretInputNames, outputs: Object.fromEntries(ctx.runOutputs), }; const caps = deps.resolverCapabilities ?? {}; @@ -54,11 +48,11 @@ async function runHumanGate( message = node.message_template === undefined ? '' - : await resolveTemplate(node.message_template, scope, caps, ctx.signal); + : await resolvePublicTemplate(node.message_template, scope, caps, ctx.signal); assignee = node.assignee === undefined ? undefined - : await resolveTemplate(node.assignee, scope, caps, ctx.signal); + : await resolvePublicTemplate(node.assignee, scope, caps, ctx.signal); } catch (err) { // A run cancelled mid-resolution surfaces as the throw from resolveTemplate's abort check — classify // it as a deliberate `cancelled` (a distinct fatal reason node retry never re-runs), not a data fault. diff --git a/packages/core/src/engine/node-handlers/node-handlers.test.ts b/packages/core/src/engine/node-handlers/node-handlers.test.ts index efb131bd..de129aa9 100644 --- a/packages/core/src/engine/node-handlers/node-handlers.test.ts +++ b/packages/core/src/engine/node-handlers/node-handlers.test.ts @@ -2,6 +2,10 @@ import type { AbortSignalLike } from '@relavium/shared'; import { beforeAll, describe, expect, it } from 'vitest'; import { type ExpressionSandbox, createExpressionSandbox } from '../../expression/sandbox.js'; +import { + createWorkflowContextProjection, + ownSecretInputNames, +} from '../../interpolation/provenance.js'; import type { NodeExecContext, NodeExecutor, NodeOutcome } from '../node-executor.js'; import type { PlanConfig, PlanVertex } from '../../run-plan.js'; import { createConditionNodeExecutor } from './condition.js'; @@ -92,8 +96,12 @@ function makeCtx( vertex, runOutputs: opts.runOutputs ?? new Map(), inputs: opts.inputs ?? {}, - ctx: opts.ctx ?? {}, - secretInputNames: opts.secretInputNames ?? new Set(), + ctx: createWorkflowContextProjection({ + values: opts.ctx ?? {}, + deniedKeys: [], + orderedKeys: Object.keys(opts.ctx ?? {}), + }), + secretInputNames: ownSecretInputNames(opts.secretInputNames ?? []).secretInputNames, toolPolicy: {}, emit: () => undefined, signal: opts.signal ?? LIVE, @@ -701,7 +709,7 @@ describe('fan_out / input / output handlers (1.P)', () => { }); }); -describe('secret-input masking (1.P security — BLOCKER fix)', () => { +describe('secret-input display masking and expression denial (ADR-0113)', () => { const secretInputNames = new Set(['api_key']); it('the input handler masks a secret-typed input in its emitted output (never the raw value)', async () => { @@ -718,20 +726,27 @@ describe('secret-input masking (1.P security — BLOCKER fix)', () => { expect(JSON.stringify(out)).not.toContain('sk-RAW'); }); - it('a transform sees the masked marker, not the raw secret — it cannot launder it into run.outputs', async () => { + it('a transform reading a supplied secret input fails with typed denial', async () => { const exec = createTransformNodeExecutor({ sandbox }); const v = makeVertex({ kind: 'transform', node: { id: 't', type: 'transform', transform: 'inputs.api_key' }, }); const out = await exec.execute(makeCtx(v, { inputs: { api_key: 'sk-RAW' }, secretInputNames })); - expect(out).toEqual({ kind: 'completed', output: { secret: true, ref: 'inputs.api_key' } }); + expect(out).toEqual({ + kind: 'failed', + error: { + code: 'sandbox_error', + message: 'secret-derived values are unavailable to expressions', + retryable: false, + }, + }); expect(JSON.stringify(out)).not.toContain('sk-RAW'); }); - it('a condition cannot branch on the raw secret value (it sees only the masked marker)', async () => { + it('a condition reading a supplied secret input fails without selecting a branch', async () => { const exec = createConditionNodeExecutor({ sandbox }); - // `=== "sk-RAW"` would be true on the raw value; on the masked marker object it is false. + // A denied read must fail, rather than silently turning this comparison into a false branch. const v = makeVertex( conditionConfig('inputs.api_key === "sk-RAW"', [ { when: true, target_node: 'leaked' }, @@ -740,10 +755,18 @@ describe('secret-input masking (1.P security — BLOCKER fix)', () => { { dependents: ['leaked', 'safe'] }, ); const out = await exec.execute(makeCtx(v, { inputs: { api_key: 'sk-RAW' }, secretInputNames })); - expect(out).toEqual({ kind: 'branch', selected: ['safe'] }); + expect(out).toEqual({ + kind: 'failed', + error: { + code: 'sandbox_error', + message: 'secret-derived values are unavailable to expressions', + retryable: false, + }, + }); + expect(JSON.stringify(out)).not.toContain('sk-RAW'); }); - it('a custom merge_fn sees the masked marker, not the raw secret (the merge arm masks too)', async () => { + it('a custom merge_fn reading a supplied secret input fails with typed denial', async () => { const exec = createFanInNodeExecutor({ sandbox }); const v = makeVertex(fanInConfig('custom', ['a'], 'inputs.api_key')); const out = await exec.execute( @@ -753,7 +776,14 @@ describe('secret-input masking (1.P security — BLOCKER fix)', () => { secretInputNames, }), ); - expect(out).toEqual({ kind: 'completed', output: { secret: true, ref: 'inputs.api_key' } }); + expect(out).toEqual({ + kind: 'failed', + error: { + code: 'sandbox_error', + message: 'secret-derived values are unavailable to expressions', + retryable: false, + }, + }); expect(JSON.stringify(out)).not.toContain('sk-RAW'); }); diff --git a/packages/core/src/engine/node-handlers/scope.ts b/packages/core/src/engine/node-handlers/scope.ts index 01e157b6..c053a80e 100644 --- a/packages/core/src/engine/node-handlers/scope.ts +++ b/packages/core/src/engine/node-handlers/scope.ts @@ -11,7 +11,11 @@ import { byCodeUnit, type ErrorCode, type MaskedSecret } from '@relavium/shared'; -import { SandboxError } from '../../errors.js'; +import { SandboxError, WorkflowContextError } from '../../errors.js'; +import { + createWorkflowContextProjection, + ownSecretInputNames, +} from '../../interpolation/provenance.js'; import type { ExpressionScope } from '../../expression/sandbox.js'; import type { NodeExecContext, NodeOutcome } from '../node-executor.js'; @@ -47,12 +51,10 @@ export function cancelled(): NodeOutcome { * silently false. It draws the boundary that dag.ts's parse-time scan then enforces, and it makes the * scope a static, plan-derived property that is identical on a replay by construction. * - * **Secret invariant (load-bearing):** this does NOT re-mask — it passes node outputs through verbatim. - * The guarantee that `run.outputs` never carries a raw `secret`-typed value rests entirely on the - * **`input` handler masking at the ingress** (io.ts `maskSecretInputs`) and on every expression handler - * reading inputs through {@link buildExpressionScope} (also masked). Any future node-type handler that - * writes `ctx.inputs`-derived data into its returned output MUST mask it (`maskSecretInputs`) first, or a - * raw secret would enter `run.outputs` here unmasked and then a `node:completed` event payload. + * This projection preserves upstream outputs verbatim; it does not scrub historical or arbitrary + * sensitive content. Newly declared secret inputs/context are excluded by buildExpressionScope and + * the VM provenance boundary. The input handler separately preserves display markers. Trusted custom + * executors and previously persisted outputs remain outside that exclusion guarantee (ADR-0113). */ export function outputsRecord( runOutputs: ReadonlyMap, @@ -83,11 +85,8 @@ export function outputsRecord( export { byCodeUnit }; /** - * Replace every `secret`-typed input with its {@link MaskedSecret} marker (`{ secret: true, ref }`), - * leaving non-secret inputs untouched. The single masking point shared by the `input` node's output and - * the expression sandbox's `inputs` namespace, so a raw secret value never leaves a handler — neither - * into an event payload nor into a `condition`/`transform`/`merge_fn` expression (CLAUDE.md rule 6; the - * sandbox's "secrets are never injected — the caller filters" contract). + * Replace every declared secret input with its display/input-node MaskedSecret marker. Expressions + * use buildExpressionScope's separate denied-key protocol; these markers are not VM values. */ export function maskSecretInputs( inputs: Readonly>, @@ -105,20 +104,25 @@ export function maskSecretInputs( } /** - * The one canonical scope a 1.P expression sees. `branches` is supplied only for a `merge_fn`. The - * `inputs` namespace is **secret-masked** ({@link maskSecretInputs}) so a `secret`-typed input is the - * marker object, never the raw value — an expression that reads it cannot launder a secret into an - * output (ADR-0027; the sandbox's "secrets are never injected" contract). `ctx` is the **resolved** - * workflow-context namespace (`NodeExecContext.ctx`), folded once at run start by the engine and threaded - * here, so a bare `ctx.key` read resolves — mirroring the AgentRunner's `RunScope` (agent-runner.ts). + * The canonical expression scope owns the clean context projection and secret-input membership. + * Present denied inputs become private null placeholders without reading their raw values; the VM + * replaces those slots with denied accessors. Omitted optional inputs remain absent. Branch outputs + * and transitive upstream outputs retain their existing semantics. */ export function buildExpressionScope( ctx: NodeExecContext, branches?: readonly unknown[], ): ExpressionScope { + const names = ownSecretInputNames(ctx.secretInputNames); + const inputs: Record = { __proto__: null }; + for (const key of Object.keys(ctx.inputs)) { + // Do not read a denied value, including a caller accessor. Null is private transport only. + inputs[key] = names.secretInputNames.has(key) ? null : ctx.inputs[key]; + } return { - inputs: maskSecretInputs(ctx.inputs, ctx.secretInputNames), - ctx: ctx.ctx, + inputs: Object.freeze(inputs), + deniedInputNames: names.deniedInputNames, + ctx: createWorkflowContextProjection(ctx.ctx), outputs: outputsRecord(ctx.runOutputs, ctx.vertex.ancestors), ...(branches === undefined ? {} : { branches }), }; @@ -131,6 +135,7 @@ export function buildExpressionScope( * handler, surfaced as a non-retryable `internal` failure rather than swallowed. */ export function mapThrownToFailure(err: unknown): NodeOutcome { + if (err instanceof WorkflowContextError) return failed(err.code, err.message, err.retryable); if (err instanceof SandboxError) { return failed(err.code, err.message, err.retryable); } diff --git a/packages/core/src/engine/secret-provenance-producers.test.ts b/packages/core/src/engine/secret-provenance-producers.test.ts new file mode 100644 index 00000000..e24701a6 --- /dev/null +++ b/packages/core/src/engine/secret-provenance-producers.test.ts @@ -0,0 +1,481 @@ +/** ADR-0113: actual execution and passive approval preparation share one owned authority. */ +import type { LlmProvider, LlmRequest, ModelPricing, StreamChunk } from '@relavium/llm'; +import type { RunEvent } from '@relavium/shared'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { createExpressionSandbox, type ExpressionSandbox } from '../expression/sandbox.js'; +import { ownSecretInputNames } from '../interpolation/provenance.js'; +import { resolveContext } from '../interpolation/resolve.js'; +import { parseWorkflow } from '../parser.js'; +import { WorkflowEngine } from './engine.js'; +import { createAbortController, createInMemoryHost, InMemoryRunStore } from './execution-host.js'; +import type { NodeExecContext, NodeExecutor, NodePreparationContext } from './node-executor.js'; +import { createStandardNodeExecutor } from './node-handlers/dispatcher.js'; +import type { RunHandle } from './run-handle.js'; + +const CANARY = 'ADR0113-PRODUCER-PRIVATE-CANARY'; +const INPUTS = { token: CANARY, topic: 'public-topic' }; +const MODEL = 'offline-provenance'; +const PRICES = new Map([ + [ + MODEL, + { + provider: 'openai', + nativeId: MODEL, + displayName: 'offline', + contextWindowTokens: 128_000, + maxOutputTokens: 1000, + inputPerMtokMicrocents: 1_000_000, + outputPerMtokMicrocents: 1_000_000, + cachedInputPerMtokMicrocents: 0, + }, + ], +]); +let sandbox: ExpressionSandbox; +beforeAll(async () => { + sandbox = await createExpressionSandbox(); +}); + +function workflow(budgeted: boolean, transform = 'ctx.clean + ":" + inputs.topic') { + return parseWorkflow( + JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'secret-provenance-producers', + inputs: [ + { name: 'token', type: 'secret' }, + { name: 'topic', type: 'string' }, + ], + context: [ + { key: 'clean', value: 'public-context' }, + { key: 'alias', value: '{{ inputs.token }}' }, + { key: 'nested', value: '{{ ctx.alias }}' }, + ], + ...(budgeted + ? { budget: { max_cost_microcents: 1, on_exceed: 'pause_for_approval' } } + : {}), + agents: [ + { id: 'a', provider: 'openai', model: MODEL, system_prompt: 'System public instruction' }, + ], + nodes: [ + ...(budgeted + ? [ + { + id: 'agent', + type: 'agent', + agent_ref: 'a', + prompt_template: 'Topic {{ inputs.topic }} / {{ ctx.clean }}', + max_tokens: 16, + }, + ] + : []), + { + id: 'human', + type: 'human_gate', + gate_type: 'approval', + message_template: 'Review {{ ctx.clean }} / {{ inputs.topic }}', + }, + { id: 'result', type: 'transform', transform }, + ], + edges: [ + ...(budgeted ? [{ from: 'agent', to: 'human' }] : []), + { from: 'human', to: 'result' }, + ], + }, + }), + ); +} + +function assertOwned(ctx: NodePreparationContext): void { + expect(ctx.ctx).toEqual({ + values: { clean: 'public-context' }, + deniedKeys: ['alias', 'nested'], + orderedKeys: ['clean', 'alias', 'nested'], + }); + expect(JSON.stringify(ctx.ctx)).not.toContain(CANARY); + expect(Object.isFrozen(ctx.ctx)).toBe(true); + expect(Object.isFrozen(ctx.ctx.values)).toBe(true); + expect(Object.isFrozen(ctx.ctx.deniedKeys)).toBe(true); + expect(Object.isFrozen(ctx.ctx.orderedKeys)).toBe(true); + expect([...ctx.secretInputNames]).toEqual(['token']); + expect(Object.isFrozen(ctx.secretInputNames)).toBe(true); + expect(Reflect.get(ctx.secretInputNames, 'clear')).toBeUndefined(); + expect(() => Set.prototype.clear.call(ctx.secretInputNames)).toThrow(TypeError); + ctx.secretInputNames.forEach((name, key, receiver) => { + expect([name, key]).toEqual(['token', 'token']); + expect(receiver).toBe(ctx.secretInputNames); + expect(Reflect.get(receiver, 'clear')).toBeUndefined(); + }); +} + +function fixture() { + const requests: LlmRequest[] = []; + let keyReads = 0; + const provider: LlmProvider = { + id: 'openai', + supports: { + tools: false, + streaming: true, + parallelToolCalls: false, + vision: false, + promptCache: false, + reasoning: false, + media: { + input: { image: false, audio: false, video: false, document: false }, + outputCombinations: [], + }, + }, + generate: () => { + throw new Error('text fixture must stream'); + }, + async *stream(request): AsyncGenerator { + requests.push(request); + await Promise.resolve(); + yield { type: 'text_delta', text: 'public-answer' }; + yield { + type: 'stop', + stopReason: 'stop', + usage: { kind: 'complete', usage: { inputTokens: 1, outputTokens: 1 } }, + }; + }, + }; + const standard = createStandardNodeExecutor({ + sandbox, + agent: { + resolveProvider: () => provider, + keyFor: () => { + keyReads += 1; + return 'synthetic-offline-key'; + }, + sleep: () => Promise.resolve(), + tools: [], + resolvePrice: PRICES, + registry: { + has: () => false, + list: () => [], + dispatch: () => Promise.reject(new Error('no tools advertised')), + }, + }, + }); + const executions: NodeExecContext[] = []; + const preparations: NodePreparationContext[] = []; + const preparedExecutions: NodeExecContext[] = []; + const executor: NodeExecutor = { + execute: (ctx) => { + assertOwned(ctx); + executions.push(ctx); + return standard.execute(ctx); + }, + prepareBudgetDispatch: async (ctx) => { + assertOwned(ctx); + preparations.push(ctx); + const prepare = standard.prepareBudgetDispatch?.bind(standard); + if (prepare === undefined) throw new Error('standard preparation missing'); + const result = await prepare(ctx); + if (result.kind !== 'prepared') return result; + return { + kind: 'prepared', + preparation: { + quote: (options) => result.preparation.quote(options), + execute: (execution) => { + assertOwned(execution); + expect(execution.ctx).toBe(ctx.ctx); + expect(execution.secretInputNames).toBe(ctx.secretInputNames); + preparedExecutions.push(execution); + return result.preparation.execute(execution); + }, + }, + }; + }, + }; + return { + standard, + executor, + requests, + keyReads: () => keyReads, + executions, + preparations, + preparedExecutions, + }; +} + +async function drainAndApproveHumans(handle: RunHandle, engine: WorkflowEngine) { + const events: RunEvent[] = []; + for await (const event of handle.events) { + events.push(event); + if (event.type === 'human_gate:paused' && event.nodeId === 'human') { + expect(event.message).toBe('Review public-context / public-topic'); + await engine.resume(handle.runId, event.gateId, { + decision: 'approved', + decidedBy: 'offline-provenance-test', + }); + } + } + return events; +} + +function assertShared(contexts: readonly NodePreparationContext[]): void { + expect(contexts.length).toBeGreaterThan(1); + for (const ctx of contexts) { + expect(ctx.ctx).toBe(contexts[0]?.ctx); + expect(ctx.secretInputNames).toBe(contexts[0]?.secretInputNames); + } +} + +describe('owned secret provenance at actual engine producers (ADR-0113)', () => { + it('fresh execution and same-process human resume preserve clean VM/template behavior and identity', async () => { + const run = fixture(); + const wf = workflow(false); + // The trusted exported resolver retains raw intermediate values; the engine must qualify them. + expect(await resolveContext(wf, INPUTS)).toEqual({ + clean: 'public-context', + alias: CANARY, + nested: CANARY, + }); + const engine = new WorkflowEngine({ host: createInMemoryHost(), executor: run.executor }); + const events = await drainAndApproveHumans( + engine.start({ workflow: wf, inputs: INPUTS }), + engine, + ); + expect(events.at(-1)?.type).toBe('run:completed'); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'node:completed', + nodeId: 'result', + output: 'public-context:public-topic', + }), + ); + assertShared(run.executions); + expect(JSON.stringify(events)).not.toContain(CANARY); + expect(run.requests).toEqual([]); + }); + + it.each(['ctx.nested', 'inputs.token'])( + 'a same-process human resume still denies a caught %s read in the actual VM', + async (read) => { + const run = fixture(); + const engine = new WorkflowEngine({ host: createInMemoryHost(), executor: run.executor }); + const wf = workflow( + false, + `(() => { try { return ${read}; } catch { return 'escaped'; } })()`, + ); + const events = await drainAndApproveHumans( + engine.start({ workflow: wf, inputs: INPUTS }), + engine, + ); + expect( + events.find((event) => event.type === 'node:failed' && event.nodeId === 'result'), + ).toMatchObject({ + type: 'node:failed', + nodeId: 'result', + error: { code: 'sandbox_error', retryable: false }, + }); + expect( + events.some((event) => event.type === 'node:completed' && event.nodeId === 'result'), + ).toBe(false); + expect(events.at(-1)?.type).toBe('run:failed'); + assertShared(run.executions); + expect(JSON.stringify(events)).not.toContain(CANARY); + }, + ); + + it.each(['same-process', 'cross-process'])( + '%s quoted budget preparation passes its exact owned authority into execution and later consumers', + async (mode) => { + const wf = workflow(true); + const run = fixture(); + const store = new InMemoryRunStore(JSON.stringify(wf)); + const engine = new WorkflowEngine({ + host: createInMemoryHost({ store }), + executor: run.executor, + resolvePrice: PRICES, + }); + const handle = engine.start({ workflow: wf, inputs: INPUTS }); + const prefix: RunEvent[] = []; + const iterator = handle.events[Symbol.asyncIterator](); + let pause: Extract | undefined; + while (true) { + const next = await iterator.next(); + if (next.done) throw new Error('budget fixture terminated before pausing'); + prefix.push(next.value); + if (next.value.type === 'budget:paused') pause = next.value; + if (next.value.type === 'run:paused') break; + } + if ( + pause?.allowanceQuote?.kind !== 'quoted' || + pause.allowanceQuote.quote.amount.kind !== 'representable' + ) { + handle.cancel(); + throw new Error('expected actual representable budget quote'); + } + expect(run.requests).toEqual([]); + expect(run.keyReads()).toBe(0); + const decision = { + decision: 'approved' as const, + decidedBy: 'offline-provenance-test', + approvedAmountMicrocents: pause.allowanceQuote.quote.amount.microcents, + }; + let active = run; + let resumed: RunHandle; + let activeEngine: WorkflowEngine; + if (mode === 'same-process') { + await engine.resume(handle.runId, pause.gateId, decision); + resumed = handle; + activeEngine = engine; + } else { + // Copy the real durable parked prefix; a separate engine/store models process replacement. + const restored = new InMemoryRunStore(JSON.stringify(wf)); + for (const event of store.eventsFor(handle.runId)) await restored.persistEvent(event); + expect(await restored.readWorkflowSnapshot(handle.runId)).toBe(JSON.stringify(wf)); + handle.cancel(); + while (!(await iterator.next()).done) { + /* join original engine retirement */ + } + active = fixture(); + activeEngine = new WorkflowEngine({ + host: createInMemoryHost({ store: restored }), + executor: active.executor, + resolvePrice: PRICES, + }); + resumed = await activeEngine.resumeFromCheckpoint({ + runId: handle.runId, + workflow: wf, + inputs: INPUTS, + gateId: pause.gateId, + decision, + }); + } + const events = await drainAndApproveHumans(resumed, activeEngine); + expect(active.preparations).toHaveLength(1); + expect(active.preparedExecutions).toHaveLength(1); + assertShared([...active.preparations, ...active.preparedExecutions, ...active.executions]); + expect(active.requests).toHaveLength(1); + expect(active.keyReads()).toBeGreaterThan(0); + const publicRequest = JSON.stringify(active.requests); + expect(publicRequest).toContain('Topic public-topic / public-context'); + expect(publicRequest).toContain('System public instruction'); + expect(publicRequest).not.toContain(CANARY); + expect(events.at(-1)?.type).toBe('run:completed'); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'node:completed', + nodeId: 'result', + output: 'public-context:public-topic', + }), + ); + expect(JSON.stringify([...prefix, ...events])).not.toContain(CANARY); + }, + ); + + it('missing metadata and caller-Set mutation refuse at actual VM and public-template consumers', async () => { + const run = fixture(); + const original = workflow(true); + const wf = parseWorkflow( + JSON.stringify({ + ...original, + workflow: { + ...original.workflow, + budget: { max_cost_microcents: 100_000, on_exceed: 'pause_for_approval' }, + }, + }), + ); + const engine = new WorkflowEngine({ host: createInMemoryHost(), executor: run.executor }); + const events = await drainAndApproveHumans( + engine.start({ workflow: wf, inputs: INPUTS }), + engine, + ); + expect(events.at(-1)?.type).toBe('run:completed'); + const freshSignal = createAbortController().signal; + const result = run.executions.find((ctx) => ctx.vertex.id === 'result'); + const human = run.executions.find((ctx) => ctx.vertex.id === 'human'); + const agent = run.executions.find((ctx) => ctx.vertex.id === 'agent'); + const prepare = run.standard.prepareBudgetDispatch?.bind(run.standard); + if (result === undefined || human === undefined || agent === undefined || prepare === undefined) + throw new Error('actual standard contexts were not captured'); + const expectedMissing = { + kind: 'failed', + error: { + code: 'validation', + message: 'workflow context provenance is missing or invalid', + retryable: false, + }, + }; + for (const captured of [result, human, agent]) { + const missing = { ...captured, signal: freshSignal }; + // Deliberately model an unmigrated JavaScript embedding without weakening the TypeScript seam. + Reflect.deleteProperty(missing, 'ctx'); + const outcome = await run.standard.execute(missing); + expect(outcome).toEqual(expectedMissing); + expect(JSON.stringify(outcome)).not.toContain(CANARY); + if (captured === agent) expect(await prepare(missing)).toEqual(expectedMissing); + } + + const callerSet = new Set(['token']); + const names = ownSecretInputNames(callerSet).secretInputNames; + callerSet.clear(); + names.forEach((_name, _key, receiver) => { + expect(receiver).toBe(names); + expect(() => Set.prototype.clear.call(receiver)).toThrow(TypeError); + }); + expect([...names]).toEqual(['token']); + if ( + result.vertex.config.kind !== 'transform' || + human.vertex.config.kind !== 'human_in_the_loop' || + agent.vertex.config.kind !== 'agent' + ) + throw new Error('unexpected actual vertex configuration'); + const deniedVm = await run.standard.execute({ + ...result, + signal: freshSignal, + secretInputNames: names, + vertex: { + ...result.vertex, + config: { + ...result.vertex.config, + node: { ...result.vertex.config.node, transform: 'inputs.token' }, + }, + }, + }); + expect(deniedVm).toMatchObject({ + kind: 'failed', + error: { code: 'sandbox_error', retryable: false }, + }); + for (const template of ['{{ inputs.token }}', '{{ ctx.nested }}']) { + const deniedHuman = await run.standard.execute({ + ...human, + signal: freshSignal, + secretInputNames: names, + vertex: { + ...human.vertex, + config: { + ...human.vertex.config, + node: { ...human.vertex.config.node, message_template: template }, + }, + }, + }); + const deniedPreparation = await prepare({ + ...agent, + signal: freshSignal, + secretInputNames: names, + vertex: { + ...agent.vertex, + config: { + ...agent.vertex.config, + node: { ...agent.vertex.config.node, prompt_template: template }, + }, + }, + }); + expect(deniedHuman).toMatchObject({ + kind: 'failed', + error: { code: 'validation', retryable: false }, + }); + expect(deniedPreparation).toMatchObject({ + kind: 'failed', + error: { code: 'validation', retryable: false }, + }); + expect(JSON.stringify([deniedHuman, deniedPreparation])).not.toContain(CANARY); + } + // Only the clean positive run reached credentials/provider; denied low-level calls add no egress. + expect(run.requests).toHaveLength(1); + }); +}); diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 26b37b40..7b50c61e 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -5,9 +5,9 @@ * and never on `message` (docs/standards/error-handling.md). The user-facing fields — `message`, * `issues`, `field`, `leaks`, the `source` label, and the line/column — name the offending * field/node/symbol and never carry an authored value, a stack trace, or an absolute path. An - * internal `cause`, where attached, is a non-secret diagnostic (a YAML rule, or a host `readFile` - * error) kept for logs per error-handling.md; the raw ZodError is deliberately NOT attached, as it - * can carry an authored `received` value. + * internal cause/detail, where attached, can contain sensitive authored or historical text and must + * not be rendered or assumed safe to log. The raw ZodError is deliberately NOT attached, as it can + * carry an authored `received` value. * * Two families: the parse-time {@link WorkflowParseError} (syntax / schema / secret-leak), thrown by * `parseWorkflow`; and the runtime {@link InterpolationError}, thrown while resolving a template @@ -201,6 +201,7 @@ function summarizeLeaks(leaks: readonly SecretLeak[]): string { /** Stable discriminant for a runtime interpolation failure — callers narrow on `code`, not `message`. */ export type InterpolationErrorCode = + | 'secret_reference' // a public template names a declared denied input/context key | 'unresolved_reference' // a `{{ … }}` head/path resolved to no value (and no `| default(…)` rescued it) | 'unknown_namespace' // a reference reads from a namespace the resolver does not serve (e.g. `secrets`) | 'unknown_filter' // a pipe filter name is not in the registry @@ -237,6 +238,16 @@ export class InterpolationError extends Error { } } +/** Missing/malformed explicit provenance at a public execution boundary (ADR-0113). */ +export class WorkflowContextError extends Error { + readonly code = 'validation'; + readonly retryable = false; + constructor() { + super('workflow context provenance is missing or invalid'); + this.name = 'WorkflowContextError'; + } +} + /** * Why an expression-sandbox evaluation failed (1.AB) — callers narrow on `reason`, never on * `message`. The retryable/fatal split is a pure function of this discriminant @@ -252,6 +263,7 @@ export type SandboxErrorReason = | 'stack' // the stack cap tripped (fatal) | 'result_type' // a `condition` result was not a boolean/string/number (fatal) | 'non_serializable' // a `transform`/`merge_fn` result was not JSON-serializable (fatal) + | 'secret_context' // an executed denied accessor; fixed content-free refusal (fatal) | 'scope'; // the injected scope was itself not serializable — an engine/caller fault (fatal) /** @@ -260,7 +272,8 @@ export type SandboxErrorReason = * member `sandbox_error`. The user-facing `message` is a fixed, generic, secret-free string per * `reason` — it never echoes the expression source, a variable name, a scope value, or a host stack. * Any raw diagnostic (the quickjs `dump()` of the thrown value) is kept on the internal-only - * {@link detail} for logs, and a host `cause` on the standard `cause` — neither is the user message. + * {@link detail}, and a host `cause` on the standard `cause` — neither is the user message or a + * universally safe logging field. The secret_context arm attaches neither. * Both are **non-enumerable** (like a thrown error's `cause`), so a naive `JSON.stringify(err)` / * spread cannot leak them. Callers narrow on {@link reason} / {@link retryable}, never on `message`. */ @@ -272,9 +285,9 @@ export class SandboxError extends Error { /** A pure function of {@link reason}: only a wall-clock `timeout` trip is retryable. */ readonly retryable: boolean; /** - * Internal-only diagnostic (the scrubbed quickjs error text) for logs keyed by a correlation id — - * never surfaced as the user message. May carry expression-derived text, never a secret (secrets - * are filtered out of the scope before evaluation). + * Private diagnostic which may contain sensitive authored or historical text. Never render it or + * assume it is safe to log. Newly projected denied values are excluded from the VM; this does not + * scrub arbitrary authored literals or previously persisted outputs. */ readonly detail?: string; @@ -289,7 +302,7 @@ export class SandboxError extends Error { this.retryable = reason === 'timeout'; if (opts?.detail !== undefined) { // Non-enumerable so it does not ride JSON.stringify(err)/spread (matching `cause`'s precedent) — - // a structural guard for the documented internal-only, secret-free contract, not just a comment. + // a structural guard for the private diagnostic contract, not proof that its content is harmless. Object.defineProperty(this, 'detail', { value: opts.detail, enumerable: false, diff --git a/packages/core/src/expression/program.ts b/packages/core/src/expression/program.ts new file mode 100644 index 00000000..41f3f596 --- /dev/null +++ b/packages/core/src/expression/program.ts @@ -0,0 +1,113 @@ +/** Private QuickJS program construction. No host function enters the VM (ADR-0113). */ +import { SandboxError } from '../errors.js'; +import { + createWorkflowContextProjection, + ownSecretInputNames, +} from '../interpolation/provenance.js'; +import type { ExpressionScope } from './sandbox.js'; + +const MAX_SCOPE_DEPTH = 256; +const MAX_EXPRESSION_CHARS = 100_000; + +export function buildExpressionProgram(expression: string, scope: ExpressionScope): string { + if (expression.length > MAX_EXPRESSION_CHARS) + throw new SandboxError('syntax', 'the expression is too large to evaluate'); + const ctx = createWorkflowContextProjection(scope.ctx); + const names = ownSecretInputNames(scope.deniedInputNames); + const inputs: Record = { __proto__: null }; + const deniedInputs: string[] = []; + for (const key of Object.keys(scope.inputs)) { + if (names.secretInputNames.has(key)) { + inputs[key] = null; + deniedInputs.push(key); + } else inputs[key] = scope.inputs[key]; + } + const envelope = { + inputs, + ctxValues: ctx.values, + ctxDenied: ctx.deniedKeys, + ctxOrder: ctx.orderedKeys, + deniedInputs, + run: { outputs: scope.outputs }, + branches: scope.branches ?? null, + }; + assertBoundedDepth(envelope); + let scopeJson: string; + try { + scopeJson = JSON.stringify(envelope); + } catch (cause) { + throw new SandboxError('scope', 'the expression scope could not be serialized for evaluation', { + cause, + }); + } + // Compiled in a separate lexical function: authored code cannot name the private latch/serializer. + const body = + '"use strict"; const inputs=__public.inputs, ctx=__public.ctx, run=__public.run, branches=__public.branches; return (\n' + + expression + + '\n);'; + return [ + '"use strict";', + 'try { delete Math.random; } catch (e) {}', + 'if (typeof Math.random === "function") { Math.random=function(){throw new RangeError("Math.random is disabled in the expression sandbox");}; }', + 'Object.freeze(Math);', + '(function () {', + ' const stringify=JSON.stringify, parse=JSON.parse, make=Object.create, freeze=Object.freeze, asString=String;', + ' const keys=Object.keys, define=Object.defineProperty, isArray=Array.isArray, isNaN=Number.isNaN, is=Object.is;', + ` const invoke=Function("__public", ${JSON.stringify(body)});`, + ` const data=parse(${JSON.stringify(scopeJson)});`, + ' let denied=false;', + ' const deny=function(){denied=true;throw "secret-derived values are unavailable to expressions";};', + ' const reproto=function(v){', + ' if(v===null || typeof v!=="object") return v;', + ' if(isArray(v)){for(let i=0;i = [{ node: root, depth: 0 }]; + for (;;) { + const item = stack.pop(); + if (item === undefined) return; + const { node, depth } = item; + if (node === null || typeof node !== 'object') continue; + if (depth > MAX_SCOPE_DEPTH) + throw new SandboxError('scope', 'the expression scope is nested too deeply to evaluate'); + for (const value of Object.values(node as Record)) + stack.push({ node: value, depth: depth + 1 }); + } +} diff --git a/packages/core/src/expression/sandbox.perf.test.ts b/packages/core/src/expression/sandbox.perf.test.ts index eefbcdc7..f1cbb356 100644 --- a/packages/core/src/expression/sandbox.perf.test.ts +++ b/packages/core/src/expression/sandbox.perf.test.ts @@ -9,6 +9,10 @@ */ import { describe, expect, it } from 'vitest'; +import { + createWorkflowContextProjection, + ownSecretInputNames, +} from '../interpolation/provenance.js'; import { createExpressionSandbox, DEFAULT_SANDBOX_LIMITS, type EvaluateInput } from './sandbox.js'; const EVALS = 500; @@ -29,7 +33,12 @@ describe('expression sandbox — perf spike', () => { expression: 'run.outputs["scan"].score < inputs.threshold && ctx.mode === "strict"', scope: { inputs: { threshold: 7 }, - ctx: { mode: 'strict' }, + ctx: createWorkflowContextProjection({ + values: { mode: 'strict' }, + deniedKeys: [], + orderedKeys: ['mode'], + }), + deniedInputNames: ownSecretInputNames([]).deniedInputNames, outputs: { scan: { score: 3 } }, }, }; diff --git a/packages/core/src/expression/sandbox.test.ts b/packages/core/src/expression/sandbox.test.ts index e9a93653..e50a9b8c 100644 --- a/packages/core/src/expression/sandbox.test.ts +++ b/packages/core/src/expression/sandbox.test.ts @@ -6,6 +6,10 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { SandboxError } from '../errors.js'; +import { + createWorkflowContextProjection, + ownSecretInputNames, +} from '../interpolation/provenance.js'; import { createExpressionSandbox, hostErrorToSandbox, @@ -20,8 +24,24 @@ beforeAll(async () => { sandbox = await createExpressionSandbox(); }); -function mkScope(partial?: Partial): ExpressionScope { - return { inputs: {}, ctx: {}, outputs: {}, ...partial }; +function mkScope( + partial?: Partial> & { + ctx?: Readonly>; + deniedInputNames?: readonly string[]; + }, +): ExpressionScope { + const values = partial?.ctx ?? {}; + return { + inputs: {}, + outputs: {}, + ...partial, + ctx: createWorkflowContextProjection({ + values, + deniedKeys: [], + orderedKeys: Object.keys(values), + }), + deniedInputNames: ownSecretInputNames(partial?.deniedInputNames ?? []).deniedInputNames, + }; } /** Run an evaluation expected to fail, returning the thrown SandboxError. */ @@ -639,7 +659,12 @@ describe('the scope namespaces have no prototype inside the VM', () => { // allows and cannot resolve — could find a truthy value for a node id that does not exist, and a // `condition` comparing it takes the wrong branch. ADR-0093's contract is that an absent output is // `undefined`; this pins that it holds inside the VM, not only at the boundary. - const scope = { inputs: { a: 1 }, ctx: { k: 'v' }, outputs: { real: 1 }, branches: [1, 2] }; + const scope = mkScope({ + inputs: { a: 1 }, + ctx: { k: 'v' }, + outputs: { real: 1 }, + branches: [1, 2], + }); const evaluate = (expression: string): unknown => sandbox.evaluate({ expression, kind: 'merge_fn', scope }); diff --git a/packages/core/src/expression/sandbox.ts b/packages/core/src/expression/sandbox.ts index 4d70e9b3..f0bf5b4b 100644 --- a/packages/core/src/expression/sandbox.ts +++ b/packages/core/src/expression/sandbox.ts @@ -20,7 +20,8 @@ * are never created, and `Math.random` is neutralized, so the surface is deterministic and I/O-free. * - **JSON-only marshaling** — the scope crosses as plain JSON (host stringify → VM parse), so no live * host object/getter ever enters and a `{"__proto__":…}` key lands as own data (no prototype - * pollution); it is deep-frozen and bound as `const` lexical names inside a strict IIFE. + * pollution); clean data is deep-frozen and bound as const names in a separate strict function. + * Denied declared values never enter the VM; private accessors diagnose ordinary denied reads. * - **Caps are non-idempotent safety nets** — quickjs exposes a wall-clock deadline (not an opcode * counter), so a cap-trip surfaces as the error path, never a stable result. A fresh runtime+context * is created and disposed per evaluation (full isolation; OOM-safe — a tripped runtime is discarded). @@ -37,19 +38,25 @@ import { type VmCallResult, } from 'quickjs-emscripten-core'; -import { SandboxError } from '../errors.js'; +import { SandboxError, WorkflowContextError } from '../errors.js'; +import type { WorkflowContextProjection } from '../interpolation/provenance.js'; +import { buildExpressionProgram } from './program.js'; +import { readPrivateExpressionTransport, decodeExpressionPayload } from './transport.js'; /** Which authored construct the expression came from — drives the per-kind result contract. */ export type ExpressionKind = 'condition' | 'transform' | 'merge_fn'; /** * The one canonical scope an expression sees. `branches` is present only for `merge_fn` (the branch - * outputs in static `parallel_of` declaration order). Secrets are never injected — the caller filters - * any secret-tainted value out before evaluation (defense-in-depth over the 1.L2 parse-time gate). + * outputs in static `parallel_of` declaration order). Required owned provenance excludes newly + * declared secret input/context values. Historical outputs and arbitrary authored literals are not + * scrubbed by this boundary. */ export interface ExpressionScope { readonly inputs: Readonly>; - readonly ctx: Readonly>; + readonly ctx: WorkflowContextProjection; + /** Owned declared secret-input names; omitted optional slots remain absent in the VM. */ + readonly deniedInputNames: readonly string[]; /** Completed upstream node outputs, keyed by node id — surfaced to the expression as `run.outputs`. */ readonly outputs: Readonly>; /** `merge_fn` only — branch outputs in static declaration order (never arrival order). */ @@ -127,23 +134,6 @@ const SANDBOX_INTRINSICS: Intrinsics = { BignumExt: false, }; -/** - * Reject a pathologically deep scope before it reaches the VM. A very deeply nested literal overflows - * the HOST stack inside `evalCode` (a raw `RangeError`, not a returned VM error) and the wasm teardown - * then prints an alarming abort to stderr; an explicit bound keeps the failure a clean SandboxError. - * Real run state is shallow; this only rejects adversarial input, far below the host-overflow depth. - */ -const MAX_SCOPE_DEPTH = 256; - -/** - * Reject a pathologically large expression *string* before `evalCode`. A deeply-nested expression - * overflows the HOST stack inside the parser (a raw `RangeError` whose stack-vs-runtime classification - * is then host-engine-specific) — this bound, the companion to {@link MAX_SCOPE_DEPTH} for the scope, - * keeps that branch unreachable for any realistic input (a real `condition`/`transform`/`merge_fn` is - * tens to a few hundred chars; 100 KB is orders of magnitude above any authored expression). - */ -const MAX_EXPRESSION_CHARS = 100_000; - /** * Load the QuickJS wasm module once per process (instantiation is the expensive step; runtimes and * contexts are cheap and created fresh per evaluation). Memoized so concurrent callers share it. @@ -169,94 +159,21 @@ export async function createExpressionSandbox(options?: { evaluate(input: EvaluateInput): unknown { try { const limits = input.limits ?? defaultLimits; - const program = buildProgram(input.expression, input.scope); - const outcome = runProgram(module, program, limits); + const program = buildExpressionProgram(input.expression, input.scope); + const outcome = runProgram(module, program, limits, input.kind); return validateResult(outcome.value, outcome.type, input.kind); } catch (err) { // Contract guarantee: `evaluate` only ever throws a classified SandboxError. A SandboxError // passes through; any OTHER host throw — notably a failure constructing the runtime/context, // which happens before `runProgram`'s own try — is classified here rather than leaked raw. + if (err instanceof WorkflowContextError) + throw new SandboxError('scope', 'the expression scope provenance is missing or invalid'); throw err instanceof SandboxError ? err : hostErrorToSandbox(err); } }, }; } -/** - * Build the single VM program: neutralize `Math.random`, JSON-parse the (double-encoded) scope into - * a deep-frozen object, bind it as `const` lexical names, and return the author's expression. The - * whole program runs in strict mode; the VM is the security boundary, so inlining the expression is - * safe (it cannot reach `eval`/`Function`/`Date`/I/O — none exist). - */ -function buildProgram(expression: string, scope: ExpressionScope): string { - if (expression.length > MAX_EXPRESSION_CHARS) { - throw new SandboxError('syntax', 'the expression is too large to evaluate'); - } - const envelope = { - inputs: scope.inputs, - ctx: scope.ctx, - run: { outputs: scope.outputs }, - branches: scope.branches ?? null, - }; - assertBoundedDepth(envelope); - let scopeJson: string; - try { - scopeJson = JSON.stringify(envelope); - } catch (cause) { - // The scope itself (run state) was not serializable — an engine/caller fault, not an author bug. - throw new SandboxError('scope', 'the expression scope could not be serialized for evaluation', { - cause, - }); - } - // Double-encode: `scopeJson` is a JSON string; stringifying it again yields a safe JS string - // literal. `JSON.parse` inside the VM rebuilds plain data — no live host reference crosses, and a - // `__proto__` key materializes as an own data property (never the prototype setter). - const scopeLiteral = JSON.stringify(scopeJson); - return [ - '"use strict";', - // No intrinsic flag omits a single Math method, so neutralize random here (it is the only - // non-deterministic builtin BaseObjects ships): delete it, with a throwing override as a fallback. - 'try { delete Math.random; } catch (e) {}', - 'if (typeof Math.random === "function") {', - ' Math.random = function () { throw new RangeError("Math.random is disabled in the expression sandbox"); };', - '}', - // Freeze Math so a re-add (Math.random = …) cannot reintroduce an entropy vector if a future change - // ever ships a seedable builtin; Math.max/PI/floor keep working, Math.random stays absent. - 'Object.freeze(Math);', - '(function () {', - ` const __scope = JSON.parse(${scopeLiteral});`, - // **Rebuild every object on a NULL prototype, then freeze.** The host builds `run.outputs` as a - // null-prototype record precisely so an absent id reads `undefined`, and `JSON.parse` here threw that - // away: inside the VM the record inherited from `Object.prototype`, so `run.outputs.constructor` was a - // FUNCTION and `run.outputs.toString` likewise. A computed read the static scanner deliberately allows - // — `run.outputs[k]` — could therefore find a truthy value for a node id that does not exist, and a - // `condition` comparing it takes the wrong branch. ADR-0093's contract is that an out-of-closure or - // absent output is `undefined`; this makes that true inside the VM too, not just at the boundary. - ' const __reproto = function (v) {', - ' if (v === null || typeof v !== "object") { return v; }', - ' if (Array.isArray(v)) {', - ' for (let i = 0; i < v.length; i++) { v[i] = __reproto(v[i]); }', - ' return Object.freeze(v);', - ' }', - ' const bare = Object.create(null);', - ' const keys = Object.keys(v);', - ' for (let i = 0; i < keys.length; i++) { bare[keys[i]] = __reproto(v[keys[i]]); }', - ' return Object.freeze(bare);', - ' };', - ' const __bare = __reproto(__scope);', - ' const inputs = __bare.inputs;', - ' const ctx = __bare.ctx;', - ' const run = __bare.run;', - ' const branches = __bare.branches;', - // The expression goes on its OWN line so a trailing line comment (`expr // note`) cannot comment out - // the closing `);` (which would be a spurious SyntaxError); the `(`/`)` still parenthesize it. - ' return (', - expression, - ' );', - '})()', - ].join('\n'); -} - /** A successful evaluation: the marshaled value plus its VM-side `typeof` (the authoritative type). */ interface EvalOutcome { readonly value: unknown; @@ -272,6 +189,7 @@ function runProgram( module: QuickJSWASMModule, program: string, limits: SandboxLimits, + kind: ExpressionKind, ): EvalOutcome { const runtime = module.newRuntime({ memoryLimitBytes: limits.memoryBytes, @@ -299,19 +217,51 @@ function runProgram( result.error.dispose(); throw classifyError(dumped, Date.now() >= deadline); } - // The VM-side `typeof` is authoritative — `dump()` coerces a function to `{}`, hiding a - // non-serializable result. Capture it, then marshal across the validated `any`→`unknown` boundary. - const type = context.typeof(result.value); + // The first call returns only our private serialization closure, never an authored object. + // Keep the original deadline installed: serialization cannot gain a fresh execution budget. + const serializationClosure = result.value; try { - const value = context.dump(result.value) as unknown; - succeeded = true; - return { value, type }; - } catch (cause) { - throw new SandboxError( - 'non_serializable', - 'the expression returned a value that could not be marshaled', - { cause }, + if (context.typeof(serializationClosure) !== 'function') + throw new SandboxError( + 'runtime', + 'the expression returned invalid private result transport', + ); + const serialized = context.callFunction(serializationClosure, context.undefined); + if (serialized.error) { + serialized.error.dispose(); + throw serializationFailure(kind); + } + result = serialized; + } finally { + serializationClosure.dispose(); + } + try { + if (context.typeof(result.value) !== 'string') + throw new SandboxError( + 'runtime', + 'the expression returned invalid private result transport', + ); + const transport = readPrivateExpressionTransport( + context.getString(result.value), + limits.memoryBytes, ); + if (transport.kind === 'denied') + throw new SandboxError( + 'secret_context', + 'secret-derived values are unavailable to expressions', + ); + if (transport.kind === 'failure') { + if (transport.stage === 'evaluation') + throw classifyError( + { name: transport.name, message: transport.message }, + Date.now() >= deadline, + ); + // Preserve original dump/validateResult disposition even when authored serialization throws. + throw serializationFailure(kind); + } + const value = decodeExpressionPayload(transport); + succeeded = true; + return { value, type: transport.originalType }; } finally { result.value.dispose(); } @@ -322,25 +272,13 @@ function runProgram( } } -/** Walk the scope iteratively (no host recursion) and reject a pathologically deep value (SEC). */ -function assertBoundedDepth(root: unknown): void { - const stack: Array<{ node: unknown; depth: number }> = [{ node: root, depth: 0 }]; - for (;;) { - const item = stack.pop(); - if (item === undefined) { - return; - } - const { node, depth } = item; - if (node === null || typeof node !== 'object') { - continue; - } - if (depth > MAX_SCOPE_DEPTH) { - throw new SandboxError('scope', 'the expression scope is nested too deeply to evaluate'); - } - for (const value of Object.values(node as Record)) { - stack.push({ node: value, depth: depth + 1 }); - } - } +function serializationFailure(kind: ExpressionKind): SandboxError { + return kind === 'condition' + ? new SandboxError( + 'result_type', + 'a condition expression must evaluate to a boolean, string, or number', + ) + : new SandboxError('non_serializable', 'the expression returned a non-serializable value'); } /** @@ -361,7 +299,7 @@ export function hostErrorToSandbox(cause: unknown): SandboxError { return new SandboxError('runtime', 'the expression failed to evaluate', { detail, cause }); } -/** Best-effort dump of a thrown VM error handle — the value, for classification + a non-secret detail. */ +/** Best-effort dump of a thrown VM error handle — the value, for classification + private potentially sensitive detail. */ function safeDump(context: QuickJSContext, handle: QuickJSHandle): unknown { try { return context.dump(handle) as unknown; @@ -402,7 +340,7 @@ function classifyError(dumped: unknown, deadlinePassed: boolean): SandboxError { return new SandboxError('runtime', 'the expression failed to evaluate', { detail }); } -/** Render a dumped VM error as `Name: message` (a non-secret diagnostic for the internal `detail`). */ +/** Render a dumped VM error as `Name: message` (a private diagnostic for the internal `detail`). */ function errorText(dumped: unknown): string { const name = errorName(dumped); const message = errorMessage(dumped); @@ -478,9 +416,9 @@ function validateResult(value: unknown, type: string, kind: ExpressionKind): unk } // transform | merge_fn — the result becomes persisted node output, so it must be JSON-serializable. // Reject a top-level function/symbol/undefined/bigint by type (a bigint would crash a downstream - // JSON.stringify), and an object `dump()` could not serialize: it does not throw on a cycle — it - // coerces an unserializable object to a string, so a VM-side `object` whose marshaled value is not an - // object is the tell. (Map/Set→{} and NaN/Infinity→null follow JSON.stringify semantics — see the + // JSON.stringify). A VM-side object whose single serialized payload becomes a scalar is refused, + // matching the original dump/validateResult contract; null remains accepted. Map/Set→{} and nested + // NaN/Infinity→null follow JSON.stringify semantics — see the // spec author guidance.) if (type === 'function' || type === 'symbol' || type === 'undefined' || type === 'bigint') { throw new SandboxError( diff --git a/packages/core/src/expression/scan-soundness.test.ts b/packages/core/src/expression/scan-soundness.test.ts index 644e51b8..bd831ec5 100644 --- a/packages/core/src/expression/scan-soundness.test.ts +++ b/packages/core/src/expression/scan-soundness.test.ts @@ -1,3 +1,7 @@ +import { + createWorkflowContextProjection, + ownSecretInputNames, +} from '../interpolation/provenance.js'; import { beforeAll, describe, expect, it } from 'vitest'; import { createExpressionSandbox, type ExpressionSandbox } from './sandbox.js'; @@ -57,7 +61,8 @@ describe('the scan never disagrees with the sandbox (ADR-0093 §2 soundness)', ( kind: 'transform', scope: { inputs: { a: { run: { outputs: { ghost: 1 } } }, shadow: { outputs: { ghost: 1 } } }, - ctx: {}, + ctx: createWorkflowContextProjection({ values: {}, deniedKeys: [], orderedKeys: [] }), + deniedInputNames: ownSecretInputNames([]).deniedInputNames, outputs: {}, }, }); diff --git a/packages/core/src/expression/secret-context.test.ts b/packages/core/src/expression/secret-context.test.ts new file mode 100644 index 00000000..3ee3e0e0 --- /dev/null +++ b/packages/core/src/expression/secret-context.test.ts @@ -0,0 +1,462 @@ +/** ADR-0113 regressions execute the installed QuickJS VM, including its finite latch limit. */ +import { beforeAll, describe, expect, it } from 'vitest'; + +import { SandboxError } from '../errors.js'; +import { + createWorkflowContextProjection, + ownSecretInputNames, +} from '../interpolation/provenance.js'; +import { buildExpressionProgram } from './program.js'; +import { + createExpressionSandbox, + DEFAULT_SANDBOX_LIMITS, + type EvaluateInput, + type ExpressionKind, + type ExpressionSandbox, + type ExpressionScope, +} from './sandbox.js'; + +const CANARY = 'private-input-canary-ADR0113-never-in-QuickJS'; +const kinds: ExpressionKind[] = ['condition', 'transform', 'merge_fn']; +let sandbox: ExpressionSandbox; +beforeAll(async () => { + sandbox = await createExpressionSandbox(); +}); + +function scope(): ExpressionScope { + return { + inputs: { public: 'available', credential: CANARY }, + ctx: createWorkflowContextProjection({ + values: { first: 'alpha', last: 'omega' }, + deniedKeys: ['derived'], + orderedKeys: ['first', 'derived', 'last'], + }), + deniedInputNames: ownSecretInputNames(['credential', 'optional']).deniedInputNames, + outputs: { upstream: { count: 7 } }, + branches: [{ id: 'first' }, { id: 'second' }], + }; +} + +function failure(input: EvaluateInput): SandboxError { + let caught: unknown; + try { + sandbox.evaluate(input); + } catch (error) { + caught = error; + } + expect(caught, 'VM_CLASSIFIED_REFUSAL').toBeInstanceOf(SandboxError); + if (!(caught instanceof SandboxError)) throw caught; + return caught; +} + +function expectDenial(expression: string, kind: ExpressionKind = 'transform'): void { + const error = failure({ expression, kind, scope: scope() }); + expect(error).toMatchObject({ + code: 'sandbox_error', + reason: 'secret_context', + retryable: false, + message: 'secret-derived values are unavailable to expressions', + }); + expect(error.detail).toBeUndefined(); + expect(error.cause).toBeUndefined(); + expect(JSON.stringify(error)).not.toContain(CANARY); + expect(error.stack).not.toContain(CANARY); +} + +describe('denied values in the real expression VM', () => { + const reads = [ + 'ctx.derived', + 'ctx[["der", "ived"].join("")]', + 'JSON.stringify(ctx)', + 'ctx', + '({ nested: [ctx] })', + '({ ...ctx })', + 'Object.values(ctx)', + '(() => { try { return ctx.derived; } catch { return "caught"; } })()', + '({ get value() { try { return ctx.derived; } catch { return "caught"; } } })', + '({ toJSON() { try { return ctx.derived; } catch { return { ok: true }; } } })', + 'inputs.credential', + 'inputs[["creden", "tial"].join("")]', + 'JSON.stringify(inputs)', + 'inputs', + '(() => { try { return inputs.credential; } catch { return true; } })()', + 'Object.getOwnPropertyDescriptor(ctx, "derived").get()', + 'Object.getOwnPropertyDescriptor(inputs, "credential").get()', + 'Reflect.get(ctx, "derived")', + ]; + for (const kind of kinds) { + it.each(reads)( + `${kind}: observed read refuses even through catch/serialization: %s`, + (expression) => { + expectDenial(expression, kind); + }, + ); + } + + it('excludes raw supplied values before program construction, including denied host getters', () => { + const original = scope(); + expect(buildExpressionProgram('ctx.first', original)).not.toContain(CANARY); + const inputs = { public: 'available' }; + Object.defineProperty(inputs, 'credential', { + enumerable: true, + get() { + throw new Error(CANARY); + }, + }); + expect(buildExpressionProgram('inputs.public', { ...original, inputs })).not.toContain(CANARY); + expect( + sandbox.evaluate({ + kind: 'transform', + expression: 'inputs.public', + scope: { ...original, inputs }, + }), + ).toBe('available'); + }); + + it('keeps successful clean values and declared branch order usable alongside denied keys', () => { + expect( + sandbox.evaluate({ + kind: 'transform', + scope: scope(), + expression: + '({ input: inputs.public, first: ctx.first, last: ctx.last, count: run.outputs.upstream.count, branches: branches.map(b => b.id), keys: Object.keys(ctx) })', + }), + ).toEqual({ + input: 'available', + first: 'alpha', + last: 'omega', + count: 7, + branches: ['first', 'second'], + keys: ['first', 'derived', 'last'], + }); + expect( + sandbox.evaluate({ + kind: 'condition', + scope: scope(), + expression: 'ctx.first === "alpha" && inputs.public === "available"', + }), + ).toBe(true); + }); + + it('pins caught pre-accessor stack exhaustion without promising universal attempted-read detection', () => { + const expression = + '(() => { function f() { try { return f(); } catch { try { return ctx.derived; } catch { return "SWALLOWED"; } } } return f(); })()'; + expect(buildExpressionProgram(expression, scope())).not.toContain(CANARY); + // The installed VM fails to enter the accessor at this exhausted stack: no latch body executes. + expect( + sandbox.evaluate({ + expression, + kind: 'transform', + scope: scope(), + limits: DEFAULT_SANDBOX_LIMITS, + }), + ).toBe('SWALLOWED'); + expectDenial('(() => { try { return ctx.derived; } catch { return "SWALLOWED"; } })()'); + }); + + it('keeps uncaught stack exhaustion classified as a resource failure', () => { + expect( + failure({ + kind: 'transform', + scope: scope(), + expression: '(() => { function f(){ return f(); } return f(); })()', + }).reason, + ).toBe('stack'); + }); +}); + +describe('private control state cannot be forged by authored code', () => { + it.each([ + '(() => { try { ctx.derived; } catch {} eval("var denied = false"); return true; })()', + '(() => { try { ctx.derived; } catch {} Function("globalThis.denied=false")(); return true; })()', + '(() => { try { ctx.derived; } catch {} globalThis.denied=false; globalThis.payloadJson="true"; return true; })()', + '(() => { JSON.stringify=()=>"true"; JSON.parse=()=>({}); Object.create=()=>({}); Object.freeze=x=>x; try { ctx.derived; } catch {} return true; })()', + '(() => { try { ctx.derived; } catch {} Object.prototype.toJSON=function(){ return { version:1, kind:"success", originalType:"boolean", payloadJson:"true" }; }; return { ok:true }; })()', + '(() => { Object.prototype.toJSON=function(){ try { ctx.derived; } catch {} return { ok:true }; }; return { authored:true }; })()', + '(() => { try { ctx.derived; } catch {} try { ctx={}; } catch {} return true; })()', + ])('retains denial after tampering: %s', (expression) => expectDenial(expression)); + + it('uses captured intrinsics for an exact clean result after global tampering', () => { + expect( + sandbox.evaluate({ + kind: 'transform', + scope: scope(), + expression: + '(() => { JSON.stringify=()=>"forged"; JSON.parse=()=>null; Object.create=()=>({}); Number.isNaN=()=>true; Object.is=()=>true; return { value:42 }; })()', + }), + ).toEqual({ value: 42 }); + }); + + it('never invokes inherited toJSON again on its control envelope', () => { + expect( + sandbox.evaluate({ + kind: 'transform', + scope: scope(), + expression: + '(() => { let calls=0; Object.prototype.toJSON=function(){ calls++; if(calls>1){ ctx.derived; } return { calls }; }; return { authored:true }; })()', + }), + ).toEqual({ calls: 1 }); + }); + + it('keeps private closure bindings inaccessible to eval and Function', () => { + expect( + sandbox.evaluate({ + kind: 'transform', + scope: scope(), + expression: + '[eval("typeof denied"),eval("typeof stringify"), Function("return typeof denied")(), Function("return typeof ctx")()]', + }), + ).toEqual(['undefined', 'undefined', 'undefined', 'undefined']); + }); + + it.each(['inputs', 'ctx', 'run', 'branches'])('binds %s as const', (name) => { + expect( + failure({ + kind: 'transform', + scope: scope(), + expression: `(() => { ${name} = {}; return true; })()`, + }).reason, + ).toBe('runtime'); + }); + + it('treats control-shaped strings and objects as authored data', () => { + const forged = { version: 1, kind: 'denied' }; + for (const kind of kinds) { + expect( + sandbox.evaluate({ + kind, + scope: scope(), + expression: JSON.stringify(JSON.stringify(forged)), + }), + ).toBe(JSON.stringify(forged)); + } + expect( + sandbox.evaluate({ + kind: 'transform', + scope: scope(), + expression: '({ version:1, kind:"denied" })', + }), + ).toEqual(forged); + expect( + failure({ + kind: 'condition', + scope: scope(), + expression: '(() => { throw new Error("secret_context"); })()', + }).reason, + ).toBe('runtime'); + }); +}); + +describe('input presence, order and explicit provenance', () => { + it('owns provenance against caller mutation and facade callback mutation attempts', () => { + const callerNames = new Set(['credential', 'optional']); + const names = ownSecretInputNames(callerNames); + const values = { first: 'alpha' }; + const deniedKeys = ['derived']; + const orderedKeys = ['first', 'derived']; + const projection = createWorkflowContextProjection({ values, deniedKeys, orderedKeys }); + callerNames.clear(); + values.first = CANARY; + deniedKeys.length = 0; + orderedKeys.reverse(); + names.secretInputNames.forEach((_value, _key, membership) => { + expect(membership).toBe(names.secretInputNames); + expect('clear' in membership).toBe(false); + expect(() => Set.prototype.clear.call(membership)).toThrow(TypeError); + }); + expect(Reflect.set(names.deniedInputNames, '0', 'public')).toBe(false); + const ownedScope = { ...scope(), ctx: projection, deniedInputNames: names.deniedInputNames }; + expect(buildExpressionProgram('ctx.first', ownedScope)).not.toContain(CANARY); + expect( + sandbox.evaluate({ + kind: 'transform', + scope: ownedScope, + expression: '[ctx.first, Object.keys(ctx)]', + }), + ).toEqual(['alpha', ['first', 'derived']]); + for (const expression of ['ctx.derived', 'inputs.credential']) { + expect(failure({ kind: 'transform', scope: ownedScope, expression }).reason).toBe( + 'secret_context', + ); + } + }); + + it('preserves omitted optional secrets across all ordinary reflection and direct reads', () => { + expect( + sandbox.evaluate({ + kind: 'transform', + scope: scope(), + expression: `({ + keys:Object.keys(inputs), names:Object.getOwnPropertyNames(inputs), reflect:Reflect.ownKeys(inputs), + descriptors:Object.keys(Object.getOwnPropertyDescriptors(inputs)), + descriptorAbsent:Object.getOwnPropertyDescriptor(inputs,"optional")===undefined, + hasOwn:Object.prototype.hasOwnProperty.call(inputs,"optional"), + inResult:"optional" in inputs, absent:inputs.optional===undefined, + suppliedHasOwn:Object.prototype.hasOwnProperty.call(inputs,"credential"), suppliedIn:"credential" in inputs, + suppliedGetter:typeof Object.getOwnPropertyDescriptor(inputs,"credential").get, + frozen:Object.isFrozen(inputs)&&Object.isFrozen(ctx), + bare:Object.getPrototypeOf(inputs)===null&&Object.getPrototypeOf(ctx)===null + })`, + }), + ).toEqual({ + keys: ['public', 'credential'], + names: ['public', 'credential'], + reflect: ['public', 'credential'], + descriptors: ['public', 'credential'], + descriptorAbsent: true, + hasOwn: false, + inResult: false, + absent: true, + suppliedHasOwn: true, + suppliedIn: true, + suppliedGetter: 'function', + frozen: true, + bare: true, + }); + expectDenial('inputs.credential === undefined'); + }); + + it('retains clean/denied interleaving, integer enumeration and own prototype-shaped keys', () => { + const values = { + before: 'a', + ['__proto__']: 'own proto', + after: 'z', + constructor: 'own constructor', + toString: 'own string', + }; + const projection = createWorkflowContextProjection({ + values, + deniedKeys: ['secret', '2', '10'], + orderedKeys: ['before', '10', 'secret', '__proto__', '2', 'after', 'constructor', 'toString'], + }); + const inputs = { + before: 'a', + '10': CANARY, + secret: CANARY, + ['__proto__']: 'own proto', + '2': 'two', + after: 'z', + }; + const orderedScope: ExpressionScope = { + ...scope(), + ctx: projection, + inputs, + deniedInputNames: ['10', 'secret'], + }; + expect( + sandbox.evaluate({ + kind: 'transform', + scope: orderedScope, + expression: + '({ ctxKeys:Object.keys(ctx), inputKeys:Reflect.ownKeys(inputs), proto:ctx.__proto__, ctor:ctx.constructor, str:ctx.toString, two:inputs[2] })', + }), + ).toEqual({ + ctxKeys: ['2', '10', 'before', 'secret', '__proto__', 'after', 'constructor', 'toString'], + inputKeys: ['2', '10', 'before', 'secret', '__proto__', 'after'], + proto: 'own proto', + ctor: 'own constructor', + str: 'own string', + two: 'two', + }); + }); + + it.each<[string, unknown]>([ + ['ctx', undefined], + ['ctx', {}], + ['ctx', { values: { first: CANARY }, deniedKeys: ['first'], orderedKeys: ['first'] }], + ['ctx', { values: {}, deniedKeys: ['derived'], orderedKeys: [] }], + ['deniedInputNames', undefined], + ['deniedInputNames', ['credential', 'credential']], + ])( + 'refuses malformed %s metadata before execution with fixed scope diagnostics', + (key, value) => { + const invalid = { ...scope() }; + Reflect.set(invalid, key, value); + const error = failure({ + kind: 'transform', + scope: invalid, + expression: '"must not execute"', + }); + expect(error).toMatchObject({ + reason: 'scope', + code: 'sandbox_error', + retryable: false, + message: 'the expression scope provenance is missing or invalid', + }); + expect(error.detail).toBeUndefined(); + expect(error.cause).toBeUndefined(); + expect(JSON.stringify(error)).not.toContain(CANARY); + }, + ); +}); + +describe.each(kinds)('%s result compatibility through real QuickJS', (kind) => { + it.each<[string, unknown]>([ + ['true', true], + ['false', false], + ['""', ''], + ['"hello"', 'hello'], + ['42', 42], + ['-3.5', -3.5], + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ['-Infinity', Number.NEGATIVE_INFINITY], + ['-0', -0], + ])('preserves exact primitive %s', (expression, expected) => { + expect(sandbox.evaluate({ kind, scope: scope(), expression })).toBe(expected); + }); + + it.each<[string, unknown]>([ + ['({ value:42 })', { value: 42 }], + ['[1,"two",null]', [1, 'two', null]], + ['null', null], + ['new Map([["a",1]])', {}], + ['new Set([1,2])', {}], + ['({ toJSON(){ return null; } })', null], + [ + '({ omitted:undefined, fn:()=>1, sym:Symbol("s"), nan:NaN, inf:Infinity, zero:-0, boxed:new Number(2), arr:[undefined,()=>1,Symbol("s"),NaN] })', + { nan: null, inf: null, zero: 0, boxed: 2, arr: [null, null, null, null] }, + ], + [ + '(() => { let calls=0; return { toJSON(){ calls++; if(calls>1) throw new Error("second call"); return { calls }; } }; })()', + { calls: 1 }, + ], + ])('retains object contract for %s', (expression, expected) => { + if (kind === 'condition') + expect(failure({ kind, scope: scope(), expression }).reason).toBe('result_type'); + else expect(sandbox.evaluate({ kind, scope: scope(), expression })).toEqual(expected); + }); + + it.each([ + 'undefined', + '(()=>1)', + 'Symbol("value")', + '1n', + 'new Number(2)', + 'new Boolean(false)', + 'new String("value")', + '({ toJSON(){ return "scalar"; } })', + '({ toJSON(){ return 42; } })', + '({ toJSON(){ return false; } })', + '({ toJSON(){ return undefined; } })', + '({ nested:1n })', + '(() => { const cyclic={}; cyclic.self=cyclic; return cyclic; })()', + '({ get value(){ throw new Error("getter failure"); } })', + '({ toJSON(){ throw new Error("serialization failure"); } })', + ])('retains refusal for %s', (expression) => { + expect(failure({ kind, scope: scope(), expression }).reason).toBe( + kind === 'condition' ? 'result_type' : 'non_serializable', + ); + }); + + it('preserves the existing serialization interruption disposition', () => { + const error = failure({ + kind, + scope: scope(), + expression: '({ toJSON(){ while(true){} } })', + limits: { ...DEFAULT_SANDBOX_LIMITS, timeoutMs: 20 }, + }); + expect(error.reason).toBe(kind === 'condition' ? 'result_type' : 'non_serializable'); + expect(error.retryable).toBe(false); + }); +}); diff --git a/packages/core/src/expression/transport.test.ts b/packages/core/src/expression/transport.test.ts new file mode 100644 index 00000000..ef43973d --- /dev/null +++ b/packages/core/src/expression/transport.test.ts @@ -0,0 +1,229 @@ +/** Direct hostile-boundary controls for the private VM result channel (ADR-0113). */ +import { Buffer } from 'node:buffer'; + +import { describe, expect, it } from 'vitest'; + +import { SandboxError } from '../errors.js'; +import { decodeExpressionPayload, readPrivateExpressionTransport } from './transport.js'; + +const MEMORY = 16 * 1024 * 1024; +const CANARY = 'hostile-transport-canary'; + +function decode(text: string, bound = MEMORY): unknown { + const transport = readPrivateExpressionTransport(text, bound); + return transport.kind === 'success' ? decodeExpressionPayload(transport) : transport; +} + +function expectFailure( + text: string, + reason: 'runtime' | 'memory' = 'runtime', + bound = MEMORY, +): void { + try { + decode(text, bound); + } catch (error) { + expect(error).toBeInstanceOf(SandboxError); + if (!(error instanceof SandboxError)) throw error; + expect(error).toMatchObject({ + code: 'sandbox_error', + reason, + retryable: false, + message: + reason === 'memory' + ? 'the expression result exceeded its transport limit' + : 'the expression returned invalid private result transport', + }); + expect(error.detail).toBeUndefined(); + expect(error.cause).toBeUndefined(); + expect(JSON.stringify(error)).not.toContain(CANARY); + return; + } + throw new Error('expected malformed transport to fail'); +} + +describe('closed private transport arms', () => { + it.each([ + { version: 1, kind: 'denied' }, + { + version: 1, + kind: 'failure', + stage: 'evaluation', + name: 'TypeError', + message: 'authored message', + }, + { version: 1, kind: 'failure', stage: 'serialization', originalType: 'object' }, + ])('accepts the exact $kind arm', (arm) => { + expect(readPrivateExpressionTransport(JSON.stringify(arm), MEMORY)).toEqual(arm); + }); + + it.each([ + null, + [], + true, + 1, + CANARY, + {}, + { version: 2, kind: 'denied' }, + { version: '1', kind: 'denied' }, + { version: 1, kind: 'unknown' }, + { version: 1, kind: 'denied', extra: CANARY }, + { version: 1, kind: 'denied', payloadJson: 'true' }, + { version: 1, kind: 'failure', stage: 'unknown' }, + { version: 1, kind: 'failure', stage: 'evaluation', name: 'Error' }, + { version: 1, kind: 'failure', stage: 'evaluation', name: 42, message: CANARY }, + { + version: 1, + kind: 'failure', + stage: 'evaluation', + name: 'Error', + message: CANARY, + extra: true, + }, + { version: 1, kind: 'failure', stage: 'serialization', originalType: 'unknown' }, + { + version: 1, + kind: 'failure', + stage: 'serialization', + originalType: 'object', + message: CANARY, + }, + { version: 1, kind: 'success', originalType: 'string', payloadJson: 42 }, + { version: 1, kind: 'success', originalType: 'string' }, + { version: 1, kind: 'success', originalType: 'unknown', payloadJson: 'null' }, + { version: 1, kind: 'success', originalType: 'string', payloadJson: '"ok"', extra: CANARY }, + { + version: 1, + kind: 'success', + originalType: 'number', + payloadJson: 'null', + numberTag: 'unknown', + }, + { version: 1, kind: 'success', originalType: 'number', payloadJson: 'null', numberTag: 0 }, + ])('rejects malformed arm %# with fixed content-free diagnostics', (arm) => + expectFailure(JSON.stringify(arm)), + ); + + it.each(['', '{', CANARY, '{"version":1,"kind":"denied"} trailing'])( + 'refuses malformed JSON %#', + (text) => expectFailure(text), + ); + + it('rejects own prototype-shaped extras', () => { + expectFailure('{"version":1,"kind":"denied","__proto__":{"polluted":true}}'); + }); +}); + +describe('typed payload and numeric tags', () => { + it.each<[string, string, unknown]>([ + ['boolean', 'true', true], + ['string', '"value"', 'value'], + ['number', '42', 42], + ['object', '{"nested":[1,null]}', { nested: [1, null] }], + ['object', 'null', null], + ['undefined', 'null', null], + ['function', 'null', null], + ['symbol', 'null', null], + ['bigint', 'null', null], + ])( + 'decodes %s independently of evaluation-kind validation', + (originalType, payloadJson, expected) => { + expect( + decode(JSON.stringify({ version: 1, kind: 'success', originalType, payloadJson })), + ).toEqual(expected); + }, + ); + + it.each<[string, number]>([ + ['nan', Number.NaN], + ['positive_infinity', Number.POSITIVE_INFINITY], + ['negative_infinity', Number.NEGATIVE_INFINITY], + ['negative_zero', -0], + ])('preserves exact %s tag', (numberTag, expected) => { + expect( + decode( + JSON.stringify({ + version: 1, + kind: 'success', + originalType: 'number', + payloadJson: 'null', + numberTag, + }), + ), + ).toBe(expected); + }); + + it.each([ + ['boolean', '"true"'], + ['string', 'false'], + ['number', 'null'], + ['number', '"42"'], + ['number', '1e400'], + ['undefined', '{}'], + ['function', 'false'], + ['symbol', '[]'], + ['bigint', '1'], + ['object', CANARY], + ['string', '{'], + ])('refuses inconsistent %s payload %s', (originalType, payloadJson) => { + expectFailure(JSON.stringify({ version: 1, kind: 'success', originalType, payloadJson })); + }); + + it.each(['nan', 'positive_infinity', 'negative_infinity', 'negative_zero'])( + 'rejects %s on wrong type or non-null payload', + (numberTag) => { + expectFailure( + JSON.stringify({ + version: 1, + kind: 'success', + originalType: 'object', + payloadJson: 'null', + numberTag, + }), + ); + expectFailure( + JSON.stringify({ + version: 1, + kind: 'success', + originalType: 'number', + payloadJson: '0', + numberTag, + }), + ); + }, + ); +}); + +describe('active transport byte cap', () => { + it.each(['ascii', 'é', '漢', '😀', '\ud800', 'mixed-é-漢-😀'])( + 'accepts exact UTF-8 capacity and rejects one byte below: %s', + (value) => { + const text = JSON.stringify({ + version: 1, + kind: 'success', + originalType: 'string', + payloadJson: JSON.stringify(value.repeat(16)), + }); + // Independent platform reference; do not derive the expected cap with the implementation helper. + const bytes = Buffer.byteLength(text, 'utf8'); + expect(decode(text, bytes)).toBe(value.repeat(16)); + expectFailure(text, 'memory', bytes - 1); + }, + ); + + it('refuses ASCII length overflow before parsing malformed transport', () => { + const text = CANARY.repeat(8); + expect(text.length).toBe(Buffer.byteLength(text, 'utf8')); + expectFailure(text, 'memory', text.length - 1); + }); + + it('counts multibyte transport after the cheap character-length screen passes', () => { + const text = JSON.stringify({ + version: 1, + kind: 'success', + originalType: 'string', + payloadJson: JSON.stringify('😀'.repeat(128)), + }); + expect(Buffer.byteLength(text, 'utf8')).toBeGreaterThan(text.length); + expectFailure(text, 'memory', text.length); + }); +}); diff --git a/packages/core/src/expression/transport.ts b/packages/core/src/expression/transport.ts new file mode 100644 index 00000000..70c77f33 --- /dev/null +++ b/packages/core/src/expression/transport.ts @@ -0,0 +1,113 @@ +/** Strict, bounded primitive-only private VM result transport (ADR-0113). */ +import { utf8ByteLength } from '@relavium/shared'; +import { z } from 'zod'; + +import { SandboxError } from '../errors.js'; + +const valueType = z.enum([ + 'undefined', + 'function', + 'symbol', + 'bigint', + 'boolean', + 'string', + 'number', + 'object', +]); +const numberTag = z.enum(['nan', 'positive_infinity', 'negative_infinity', 'negative_zero']); +const transportSchema = z.union([ + z.object({ version: z.literal(1), kind: z.literal('denied') }).strict(), + z + .object({ + version: z.literal(1), + kind: z.literal('failure'), + stage: z.literal('evaluation'), + name: z.string(), + message: z.string(), + }) + .strict(), + z + .object({ + version: z.literal(1), + kind: z.literal('failure'), + stage: z.literal('serialization'), + originalType: valueType, + }) + .strict(), + z + .object({ + version: z.literal(1), + kind: z.literal('success'), + originalType: valueType, + payloadJson: z.string(), + numberTag: numberTag.optional(), + }) + .strict(), +]); +export type PrivateExpressionTransport = z.infer; +type SuccessTransport = Extract; + +function invalidTransport(): SandboxError { + return new SandboxError('runtime', 'the expression returned invalid private result transport'); +} + +export function readPrivateExpressionTransport( + text: string, + memoryBytes: number, +): PrivateExpressionTransport { + // Cheap length screen precedes UTF-8 counting, then the only host JSON parse of the envelope. + if (text.length > memoryBytes || utf8ByteLength(text) > memoryBytes) + throw new SandboxError('memory', 'the expression result exceeded its transport limit'); + let raw: unknown; + try { + raw = JSON.parse(text) as unknown; + } catch { + throw invalidTransport(); + } + const parsed = transportSchema.safeParse(raw); + if (!parsed.success) throw invalidTransport(); + return parsed.data; +} + +export function decodeExpressionPayload(transport: SuccessTransport): unknown { + let value: unknown; + try { + value = JSON.parse(transport.payloadJson) as unknown; + } catch { + throw invalidTransport(); + } + if (transport.numberTag !== undefined) { + if (transport.originalType !== 'number' || value !== null) throw invalidTransport(); + switch (transport.numberTag) { + case 'nan': + return Number.NaN; + case 'positive_infinity': + return Number.POSITIVE_INFINITY; + case 'negative_infinity': + return Number.NEGATIVE_INFINITY; + case 'negative_zero': + return -0; + } + } + switch (transport.originalType) { + case 'number': + if (typeof value !== 'number' || !Number.isFinite(value)) throw invalidTransport(); + break; + case 'string': + if (typeof value !== 'string') throw invalidTransport(); + break; + case 'boolean': + if (typeof value !== 'boolean') throw invalidTransport(); + break; + // Object toJSON scalar/null behavior is classified by the existing per-kind result validator. + case 'object': + break; + case 'undefined': + case 'function': + case 'symbol': + case 'bigint': + if (value !== null) throw invalidTransport(); + break; + } + return value; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 166568d9..76be7dca 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,6 +26,7 @@ export { WorkflowSecretLeakError, WorkflowGraphError, InterpolationError, + WorkflowContextError, SandboxError, } from './errors.js'; export type { @@ -58,8 +59,19 @@ export { } from './interpolation/analyze.js'; // The `{{ … }}` runtime resolver (1.L2) — evaluate templates against a run scope, eager-once context. -export { resolveTemplate, resolveContext } from './interpolation/resolve.js'; +export { resolveTemplate, resolveContext, resolvePublicTemplate } from './interpolation/resolve.js'; export type { RunScope, ResolverCapabilities } from './interpolation/scope.js'; +export type { PublicTemplateScope } from './interpolation/resolve.js'; +export { + createWorkflowContextProjection, + createWorkflowProvenance, + ownSecretInputNames, +} from './interpolation/provenance.js'; +export type { + WorkflowContextProjection, + WorkflowProvenance, + OwnedSecretInputNames, +} from './interpolation/provenance.js'; // DAG builder + RunPlan (1.M) — compile a validated definition into an executable, topologically // ordered plan; the run loop (1.N) and AgentRunner (1.O) consume it. diff --git a/packages/core/src/interpolation/analyze.ts b/packages/core/src/interpolation/analyze.ts index 03a172f3..479f262f 100644 --- a/packages/core/src/interpolation/analyze.ts +++ b/packages/core/src/interpolation/analyze.ts @@ -215,7 +215,7 @@ function projectTaint(tainted: ReadonlyMap): TaintSe * rescan-to-fixpoint loop: that was O(N²) over the entry count and let a small reversed-laundering * chain YAML stall the synchronous parse gate. */ -function computeTaint(spec: Workflow['workflow']): TaintSets { +export function computeTaint(spec: Workflow['workflow']): TaintSets { const graph: TaintGraph = { tainted: new Map(), dependents: new Map(), queue: [] }; for (const input of spec.inputs ?? []) { if (input.type === 'secret') { diff --git a/packages/core/src/interpolation/provenance.test.ts b/packages/core/src/interpolation/provenance.test.ts new file mode 100644 index 00000000..122332c1 --- /dev/null +++ b/packages/core/src/interpolation/provenance.test.ts @@ -0,0 +1,306 @@ +import { WorkflowSchema, type Workflow } from '@relavium/shared'; +import { describe, expect, it } from 'vitest'; + +import { WorkflowContextError } from '../errors.js'; +import { + createWorkflowContextProjection, + createWorkflowProvenance, + ownSecretInputNames, +} from './provenance.js'; +import { resolveContext } from './resolve.js'; + +const CANARY = 'provenance-value-canary-0113'; + +function workflow(): Workflow { + return WorkflowSchema.parse({ + schema_version: '1.0', + workflow: { + id: 'provenance', + inputs: [ + { name: 'token', type: 'secret' }, + { name: 'public_text', type: 'string' }, + ], + context: [ + { key: 'first', value: '{{inputs.token}}' }, + { key: 'public', value: '{{inputs.public_text}}' }, + { key: 'second', value: 'nested={{ctx.first}}' }, + { key: 'third', value: '{{ctx.second | length}}' }, + { key: 'literal', value: CANARY }, + ], + nodes: [{ id: 'start', type: 'input' }], + edges: [], + }, + }); +} + +function expectInvalid(action: () => unknown): void { + try { + action(); + } catch (error) { + if (!(error instanceof WorkflowContextError)) throw error; + expect(error.code).toBe('validation'); + expect(error.retryable).toBe(false); + expect(error.message).toBe('workflow context provenance is missing or invalid'); + for (const field of ['cause', 'detail', 'source', 'issues']) { + expect(Object.hasOwn(error, field)).toBe(false); + } + expect(JSON.stringify(error)).not.toContain(CANARY); + return; + } + throw new Error('expected fixed provenance validation failure'); +} + +describe('createWorkflowProvenance — declared-source authority', () => { + it('removes direct and transitive derived values while preserving trusted raw resolution', async () => { + const definition = workflow(); + const authority = createWorkflowProvenance(definition); + const raw = await resolveContext(definition, { token: CANARY, public_text: 'hello' }); + expect(raw).toMatchObject({ + first: CANARY, + second: `nested=${CANARY}`, + third: String(`nested=${CANARY}`.length), + public: 'hello', + literal: CANARY, + }); + const projection = authority.qualifyContext(raw); + expect(projection.values, 'ENGINE_CONTEXT_RAW_EXCLUSION').toEqual({ + public: 'hello', + literal: CANARY, + }); + expect(projection.deniedKeys).toEqual(['first', 'second', 'third']); + expect(projection.orderedKeys).toEqual(['first', 'public', 'second', 'third', 'literal']); + expect([...authority.secretInputNames]).toEqual(['token']); + expect(authority.deniedInputNames).toEqual(['token']); + expect(Object.getPrototypeOf(projection.values)).toBeNull(); + for (const denied of ['first', 'second', 'third']) { + expect(Object.hasOwn(projection.values, denied)).toBe(false); + } + }); + + it('uses declarations even for equal clean values or a derived value without secret bytes', () => { + const projection = createWorkflowProvenance(workflow()).qualifyContext({ + first: 'different bytes', + public: CANARY, + third: '0', + }); + expect(projection.values).toEqual({ public: CANARY }); + expect(projection.deniedKeys).toEqual(['first', 'third']); + }); + + it('retains the object-only default graph and propagates reverse-declared dependencies', () => { + // Templated defaults cannot pass current YAML admission. Trusted object embeddings still + // receive the existing declared-source analysis rather than a weaker second authority. + const base = workflow(); + const definition: Workflow = { + ...base, + workflow: { + ...base.workflow, + inputs: [ + { name: 'laundered', type: 'string', default: '{{ctx.later}}' }, + { name: 'token', type: 'secret' }, + ], + context: [ + { key: 'earlier', value: '{{inputs.laundered}}' }, + { key: 'later', value: '{{inputs.token}}' }, + { key: 'clean', value: 'unchanged' }, + ], + }, + }; + const authority = createWorkflowProvenance(definition); + expect(authority.secretInputNames.has('token')).toBe(true); + expect(authority.secretInputNames.has('laundered')).toBe(true); + const projection = authority.qualifyContext({ + earlier: CANARY, + later: CANARY, + clean: 'unchanged', + }); + expect(projection.deniedKeys).toEqual(['earlier', 'later']); + expect(projection.values).toEqual({ clean: 'unchanged' }); + }); + + it('owns provenance independently of later admitted-workflow mutations', () => { + const definition = workflow(); + const authority = createWorkflowProvenance(definition); + definition.workflow.inputs?.splice(0); + definition.workflow.context?.splice(0); + const projection = authority.qualifyContext({ first: CANARY, public: 'still clean' }); + expect(authority.secretInputNames.has('token')).toBe(true); + expect(authority.deniedInputNames).toEqual(['token']); + expect(projection.values).toEqual({ public: 'still clean' }); + expect(projection.deniedKeys).toEqual(['first']); + }); + + it('does not even read a denied raw property while qualifying its keys', () => { + let deniedReads = 0; + const raw: Record = { public: 'safe' }; + Object.defineProperty(raw, 'first', { + enumerable: true, + get() { + deniedReads += 1; + throw new Error(CANARY); + }, + }); + const projection = createWorkflowProvenance(workflow()).qualifyContext(raw); + expect(deniedReads).toBe(0); + expect(projection.values).toEqual({ public: 'safe' }); + expect(projection.deniedKeys).toEqual(['first']); + expect(JSON.stringify(projection)).not.toContain(CANARY); + raw['public'] = CANARY; + expect(projection.values['public']).toBe('safe'); + }); + + it('preserves integer enumeration and clean/denied interleaving in the original full order', () => { + const definition = WorkflowSchema.parse({ + schema_version: '1.0', + workflow: { + id: 'ordered', + inputs: [{ name: 'token', type: 'secret' }], + context: [ + { key: '10', value: '{{inputs.token}}' }, + { key: '2', value: 'two' }, + { key: 'alpha', value: '{{inputs.token}}' }, + { key: 'beta', value: 'clean' }, + { key: 'gamma', value: '{{ctx.alpha}}' }, + ], + nodes: [{ id: 'start', type: 'input' }], + edges: [], + }, + }); + const projection = createWorkflowProvenance(definition).qualifyContext({ + '10': CANARY, + '2': 'two', + alpha: CANARY, + beta: 'clean', + gamma: CANARY, + }); + expect(projection.orderedKeys).toEqual(['2', '10', 'alpha', 'beta', 'gamma']); + expect(projection.deniedKeys).toEqual(['10', 'alpha', 'gamma']); + expect(Object.keys(projection.values)).toEqual(['2', 'beta']); + }); +}); + +describe('createWorkflowContextProjection — explicit owned metadata', () => { + it('copies and freezes every DTO component while preserving safe prototype-shaped names', () => { + const values: Record = { constructor: 'constructor text', toString: 'text' }; + Object.defineProperty(values, '__proto__', { value: 'proto text', enumerable: true }); + const dto = { + values, + deniedKeys: ['denied'], + orderedKeys: ['constructor', 'denied', '__proto__', 'toString'], + }; + const projection = createWorkflowContextProjection(dto); + dto.values['constructor'] = CANARY; + dto.deniedKeys.splice(0); + dto.orderedKeys.reverse(); + expect(projection.values['constructor']).toBe('constructor text'); + expect(Reflect.get(projection.values, '__proto__')).toBe('proto text'); + expect(projection.values['toString']).toBe('text'); + expect(projection.deniedKeys).toEqual(['denied']); + expect(projection.orderedKeys).toEqual(['constructor', 'denied', '__proto__', 'toString']); + expect(Object.getPrototypeOf(projection.values)).toBeNull(); + for (const owned of [ + projection, + projection.values, + projection.deniedKeys, + projection.orderedKeys, + ]) { + expect(Object.isFrozen(owned)).toBe(true); + } + expect(Reflect.set(projection.values, 'extra', CANARY)).toBe(false); + expect(Reflect.set(projection.deniedKeys, '0', 'changed')).toBe(false); + }); + + it('accepts an explicitly empty projection', () => { + expect( + createWorkflowContextProjection({ values: {}, deniedKeys: [], orderedKeys: [] }), + ).toEqual({ + values: {}, + deniedKeys: [], + orderedKeys: [], + }); + }); + + it.each([ + undefined, + null, + {}, + { values: {}, deniedKeys: [] }, + { values: {}, deniedKeys: [], orderedKeys: [], extra: CANARY }, + { values: { clean: CANARY }, deniedKeys: [], orderedKeys: [] }, + { values: {}, deniedKeys: ['denied'], orderedKeys: [] }, + { values: {}, deniedKeys: [], orderedKeys: ['unknown'] }, + { values: {}, deniedKeys: ['denied', 'denied'], orderedKeys: ['denied'] }, + { values: {}, deniedKeys: ['denied'], orderedKeys: ['denied', 'denied'] }, + { values: { same: CANARY }, deniedKeys: ['same'], orderedKeys: ['same'] }, + { values: { 'bad.name': CANARY }, deniedKeys: [], orderedKeys: ['bad.name'] }, + { values: {}, deniedKeys: ['bad name'], orderedKeys: ['bad name'] }, + { values: { clean: 1 }, deniedKeys: [], orderedKeys: ['clean'] }, + ])('refuses missing/malformed metadata without retaining supplied data: %j', (dto) => { + expectInvalid(() => createWorkflowContextProjection(dto)); + }); + + it('replaces a hostile DTO getter error with the fixed content-free diagnostic', () => { + const dto = { + get values(): Readonly> { + throw new Error(CANARY); + }, + deniedKeys: [], + orderedKeys: [], + }; + expectInvalid(() => createWorkflowContextProjection(dto)); + }); +}); + +describe('ownSecretInputNames — immutable membership', () => { + it('copies caller membership and never exposes its backing Set to retained callbacks/iterators', () => { + const original = new Set(['token', '__proto__', 'constructor']); + const owned = ownSecretInputNames(original); + const iterator = owned.secretInputNames.values(); + const entries = owned.secretInputNames.entries(); + const callbackViews: ReadonlySet[] = []; + const observed: string[] = []; + owned.secretInputNames.forEach((value, key, view) => { + expect(key).toBe(value); + expect(view).toBe(owned.secretInputNames); + callbackViews.push(view); + observed.push(value); + }); + original.clear(); + original.add('injected'); + for (const view of [owned.secretInputNames, ...callbackViews]) { + expect(Object.isFrozen(view)).toBe(true); + expect(Reflect.get(view, 'clear')).toBeUndefined(); + expect(Reflect.get(view, 'delete')).toBeUndefined(); + expect(Reflect.get(view, 'add')).toBeUndefined(); + expect(() => Set.prototype.clear.call(view)).toThrow(TypeError); + expect(view.has('token')).toBe(true); + expect(view.has('injected')).toBe(false); + } + expect([...iterator]).toEqual(['token', '__proto__', 'constructor']); + expect([...entries]).toEqual([ + ['token', 'token'], + ['__proto__', '__proto__'], + ['constructor', 'constructor'], + ]); + expect([...owned.secretInputNames.keys()]).toEqual(observed); + expect([...owned.secretInputNames]).toEqual(observed); + expect(owned.secretInputNames.size).toBe(3); + expect(owned.deniedInputNames).toEqual(['token', '__proto__', 'constructor']); + expect(Object.isFrozen(owned)).toBe(true); + expect(Object.isFrozen(owned.deniedInputNames)).toBe(true); + }); + + it.each([['duplicate', 'duplicate'], ['bad.name'], [''], ['bad name']])( + 'refuses duplicate or non-referenceable membership %j', + (...names) => expectInvalid(() => ownSecretInputNames(names)), + ); + + it('replaces a failed input iterator with a fixed diagnostic', () => { + const broken: Iterable = { + [Symbol.iterator](): Iterator { + throw new Error(CANARY); + }, + }; + expectInvalid(() => ownSecretInputNames(broken)); + }); +}); diff --git a/packages/core/src/interpolation/provenance.ts b/packages/core/src/interpolation/provenance.ts new file mode 100644 index 00000000..eab412fc --- /dev/null +++ b/packages/core/src/interpolation/provenance.ts @@ -0,0 +1,132 @@ +/** Owned declared-source provenance at the engine/public-consumer boundary (ADR-0113). */ +import { isReferenceableInputName, type Workflow } from '@relavium/shared'; +import { z } from 'zod'; + +import { WorkflowContextError } from '../errors.js'; +import { computeTaint } from './analyze.js'; + +/** Clean strings and the full original key order; denied values are never retained here. */ +export interface WorkflowContextProjection { + readonly values: Readonly>; + readonly deniedKeys: readonly string[]; + readonly orderedKeys: readonly string[]; +} + +const nameSchema = z.string().refine(isReferenceableInputName); +const namesSchema = z.array(nameSchema); +const projectionSchema = z + .object({ + // Keep the original record for the owned copy below: z.record's rebuild loses __proto__. + values: z.custom>>( + (value) => z.record(nameSchema, z.string()).safeParse(value).success, + ), + deniedKeys: namesSchema, + orderedKeys: namesSchema, + }) + .strict() + .superRefine((projection, ctx) => { + const clean = new Set(Object.keys(projection.values)); + const denied = new Set(projection.deniedKeys); + const ordered = new Set(projection.orderedKeys); + if ( + denied.size !== projection.deniedKeys.length || + ordered.size !== projection.orderedKeys.length || + [...denied].some((key) => clean.has(key)) || + ordered.size !== clean.size + denied.size || + [...ordered].some((key) => !clean.has(key) && !denied.has(key)) + ) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'invalid context provenance' }); + } + }); + +/** Validate and own explicit embedding metadata; missing metadata never means clean. */ +export function createWorkflowContextProjection(value: unknown): WorkflowContextProjection { + try { + const parsed = projectionSchema.safeParse(value); + if (!parsed.success) throw new WorkflowContextError(); + const values: Record = {}; + Object.setPrototypeOf(values, null); + for (const key of parsed.data.orderedKeys) { + if (Object.hasOwn(parsed.data.values, key)) { + const entry = parsed.data.values[key]; + if (typeof entry !== 'string') throw new WorkflowContextError(); + values[key] = entry; + } + } + return Object.freeze({ + values: Object.freeze(values), + deniedKeys: Object.freeze([...parsed.data.deniedKeys]), + orderedKeys: Object.freeze([...parsed.data.orderedKeys]), + }); + } catch { + // Metadata/getter/schema errors may contain data. This boundary has only a fixed diagnostic. + throw new WorkflowContextError(); + } +} + +export interface OwnedSecretInputNames { + readonly secretInputNames: ReadonlySet; + readonly deniedInputNames: readonly string[]; +} + +/** Copy membership at embedding entry; neither callbacks nor iterators expose a mutable Set. */ +export function ownSecretInputNames(names: Iterable): OwnedSecretInputNames { + try { + const parsed = namesSchema.safeParse([...names]); + if (!parsed.success) throw new WorkflowContextError(); + const backing = new Set(parsed.data); + if (backing.size !== parsed.data.length) throw new WorkflowContextError(); + const deniedInputNames = Object.freeze([...backing]); + const view: ReadonlySet = Object.freeze({ + get size() { + return backing.size; + }, + has: (name: string) => backing.has(name), + keys: () => backing.keys(), + values: () => backing.values(), + entries: () => backing.entries(), + [Symbol.iterator]: () => backing[Symbol.iterator](), + forEach: ( + callback: (value: string, key: string, set: ReadonlySet) => void, + thisArg?: unknown, + ) => { + for (const name of backing) callback.call(thisArg, name, name, view); + }, + }); + return Object.freeze({ secretInputNames: view, deniedInputNames }); + } catch { + throw new WorkflowContextError(); + } +} + +export interface WorkflowProvenance extends OwnedSecretInputNames { + qualifyContext(resolved: Readonly>): WorkflowContextProjection; +} + +/** One linear taint authority per admitted workflow, reused by fresh/resume/preparation producers. */ +export function createWorkflowProvenance(workflow: Workflow): WorkflowProvenance { + const taint = computeTaint(workflow.workflow); + const names = ownSecretInputNames(taint.inputs.keys()); + return Object.freeze({ + ...names, + qualifyContext(resolved: Readonly>): WorkflowContextProjection { + try { + const values: Record = {}; + Object.setPrototypeOf(values, null); + const deniedKeys: string[] = []; + const orderedKeys = Object.keys(resolved); + for (const key of orderedKeys) { + if (taint.ctx.has(key)) deniedKeys.push(key); + else { + const value = resolved[key]; + if (typeof value !== 'string') throw new WorkflowContextError(); + values[key] = value; + } + } + return createWorkflowContextProjection({ values, deniedKeys, orderedKeys }); + } catch { + throw new WorkflowContextError(); + } + }, + }); +} diff --git a/packages/core/src/interpolation/public-template.test.ts b/packages/core/src/interpolation/public-template.test.ts new file mode 100644 index 00000000..457eeef1 --- /dev/null +++ b/packages/core/src/interpolation/public-template.test.ts @@ -0,0 +1,266 @@ +import { WorkflowSchema } from '@relavium/shared'; +import { describe, expect, it } from 'vitest'; + +import { InterpolationError, WorkflowContextError } from '../errors.js'; +import { + createWorkflowContextProjection, + createWorkflowProvenance, + ownSecretInputNames, +} from './provenance.js'; +import { + resolveContext, + resolvePublicTemplate, + resolveTemplate, + type PublicTemplateScope, +} from './resolve.js'; +import type { ResolverCapabilities } from './scope.js'; + +const CANARY = 'public-template-value-canary-0113'; + +function publicScope(): PublicTemplateScope { + return { + inputs: { token: CANARY, name: 'Ada', path: 'clean.txt' }, + ctx: createWorkflowContextProjection({ + values: { greeting: 'Hello' }, + deniedKeys: ['alias'], + orderedKeys: ['alias', 'greeting'], + }), + secretInputNames: ownSecretInputNames(['token']).secretInputNames, + outputs: { scan: { score: 9 } }, + }; +} + +async function expectSecretRefusal( + text: string, + scope: PublicTemplateScope, + caps: ResolverCapabilities = {}, +): Promise { + let caught: unknown; + try { + await resolvePublicTemplate(text, scope, caps); + } catch (error) { + caught = error; + } + expect(caught, 'PUBLIC_TEMPLATE_SECRET_REFUSAL').toBeInstanceOf(InterpolationError); + if (!(caught instanceof InterpolationError)) throw caught; + expect(caught.code, 'PUBLIC_TEMPLATE_SECRET_REFUSAL').toBe('secret_reference'); + expect(caught.message).toBe('secret-derived values are unavailable to text templates'); + for (const field of ['location', 'detail', 'source', 'cause']) { + expect(Reflect.get(caught, field)).toBeUndefined(); + } + expect(JSON.stringify(caught)).not.toContain(CANARY); +} + +describe('resolvePublicTemplate — real clean consumer', () => { + it('resolves independent clean text, output paths and defaults exactly', async () => { + await expect( + resolvePublicTemplate( + '{{ctx.greeting}}, {{inputs.name}}! score={{run.outputs["scan"].score}}; {{inputs.missing | default("fallback")}}', + publicScope(), + ), + ).resolves.toBe('Hello, Ada! score=9; fallback'); + await expect(resolvePublicTemplate('literal text', publicScope())).resolves.toBe( + 'literal text', + ); + }); + + it('permits a clean read_file capability and forwards the live signal', async () => { + const controller = new AbortController(); + const calls: unknown[][] = []; + await expect( + resolvePublicTemplate( + '{{inputs.path | read_file | length}}', + publicScope(), + { + readFile: (path, signal) => { + calls.push([path, signal]); + return 'contents'; + }, + }, + controller.signal, + ), + ).resolves.toBe('8'); + expect(calls).toEqual([['clean.txt', controller.signal]]); + }); + + it('keeps ordinary cancellation behaviour on a clean template', async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + resolvePublicTemplate('{{inputs.name}}', publicScope(), {}, controller.signal), + ).rejects.toMatchObject({ code: 'aborted' }); + }); + + it('does not value-scan clean user strings even if they equal declared secret bytes', async () => { + await expect( + resolvePublicTemplate('{{inputs.name}}/{{ctx.greeting}}', { + ...publicScope(), + inputs: { token: CANARY, name: CANARY }, + ctx: createWorkflowContextProjection({ + values: { greeting: CANARY }, + deniedKeys: [], + orderedKeys: ['greeting'], + }), + }), + ).resolves.toBe(`${CANARY}/${CANARY}`); + }); +}); + +describe('resolvePublicTemplate — declaration-based refusal before effects', () => { + it.each([ + '{{ctx.alias}}', + '{{ctx.alias | default("fallback")}}', + '{{ctx.alias.child | default("fallback") | read_file}}', + '{{inputs.token}}', + '{{inputs.token | length}}', + '{{inputs.token.child | default("fallback")}}', + '{{inputs.token | read_file}}', + ])('rejects %s before filters/defaults and without value access', async (text) => { + let reads = 0; + const inputs: Record = { name: 'Ada' }; + Object.defineProperty(inputs, 'token', { + enumerable: true, + get() { + reads += 1; + throw new Error(CANARY); + }, + }); + const fileReads: string[] = []; + await expectSecretRefusal( + text, + { ...publicScope(), inputs }, + { + readFile: (path) => { + fileReads.push(path); + return 'forbidden'; + }, + }, + ); + expect(reads).toBe(0); + expect(fileReads).toEqual([]); + }); + + it.each(['{{ctx.alias}}', '{{inputs.token | default("fallback")}}'])( + 'preflights a denied reference after a clean read in the whole template: %s', + async (denied) => { + const paths: string[] = []; + await expectSecretRefusal(`{{inputs.path | read_file}} later ${denied}`, publicScope(), { + readFile: (path) => { + paths.push(path); + return 'clean result'; + }, + }); + expect(paths).toEqual([]); + }, + ); + + it('refuses an omitted optional secret input by declaration while clean defaults still work', async () => { + const definition = WorkflowSchema.parse({ + schema_version: '1.0', + workflow: { + id: 'optional-secret', + inputs: [{ name: 'token', type: 'secret', required: false }], + nodes: [{ id: 'start', type: 'input' }], + edges: [], + }, + }); + const authority = createWorkflowProvenance(definition); + const scope: PublicTemplateScope = { + inputs: {}, + ctx: authority.qualifyContext({}), + outputs: {}, + secretInputNames: authority.secretInputNames, + }; + expect(Object.hasOwn(scope.inputs, 'token')).toBe(false); + await expectSecretRefusal('{{inputs.token | default("fallback")}}', scope); + await expect(resolvePublicTemplate('{{inputs.clean | default("works")}}', scope)).resolves.toBe( + 'works', + ); + }); + + it('consumes actual transitive context provenance from a schema-valid object outside parser taint checks', async () => { + const definition = WorkflowSchema.parse({ + schema_version: '1.0', + workflow: { + id: 'hand-built', + inputs: [{ name: 'token', type: 'secret' }], + context: [ + { key: '__proto__', value: '{{inputs.token}}' }, + { key: 'constructor', value: '{{ctx.__proto__}}' }, + { key: 'clean', value: 'safe' }, + ], + nodes: [{ id: 'start', type: 'input' }], + edges: [], + }, + }); + const inputs = { token: CANARY }; + const raw = await resolveContext(definition, inputs); + const authority = createWorkflowProvenance(definition); + const scope: PublicTemplateScope = { + inputs, + outputs: {}, + ctx: authority.qualifyContext(raw), + secretInputNames: authority.secretInputNames, + }; + expect(Reflect.get(raw, '__proto__')).toBe(CANARY); + await expectSecretRefusal('{{ctx.__proto__ | default("fallback")}}', scope); + await expectSecretRefusal('{{ctx.constructor}}', scope); + await expectSecretRefusal('{{inputs.token}}', scope); + await expect(resolvePublicTemplate('{{ctx.clean}}', scope)).resolves.toBe('safe'); + await expect( + resolveTemplate('{{ctx.constructor}}/{{inputs.token}}', { inputs, ctx: raw, outputs: {} }), + ).resolves.toBe(`${CANARY}/${CANARY}`); + }); + + it('retains refusal after the original membership Set and projection DTO are mutated', async () => { + const sourceNames = new Set(['token']); + const names = ownSecretInputNames(sourceNames); + const dto = { + values: { clean: 'safe' }, + deniedKeys: ['alias'], + orderedKeys: ['clean', 'alias'], + }; + const ctx = createWorkflowContextProjection(dto); + sourceNames.clear(); + dto.deniedKeys.splice(0); + dto.orderedKeys.splice(0); + dto.values.clean = CANARY; + const scope = { ...publicScope(), ctx, secretInputNames: names.secretInputNames }; + await expectSecretRefusal('{{inputs.token}}', scope); + await expectSecretRefusal('{{ctx.alias | default("fallback")}}', scope); + await expect(resolvePublicTemplate('{{ctx.clean}}', scope)).resolves.toBe('safe'); + }); + + it.each(['ctx', 'secretInputNames'])( + 'refuses missing %s metadata even for literal-only templates', + async (field) => { + const scope = publicScope(); + Reflect.deleteProperty(scope, field); + await expect(resolvePublicTemplate('literal text', scope)).rejects.toBeInstanceOf( + WorkflowContextError, + ); + await expect(resolvePublicTemplate('literal text', scope)).rejects.toMatchObject({ + code: 'validation', + retryable: false, + message: 'workflow context provenance is missing or invalid', + }); + }, + ); + + it('validates mutated low-level projection metadata before any capability', async () => { + const scope = publicScope(); + Object.defineProperty(scope, 'ctx', { + value: { values: { alias: CANARY }, deniedKeys: ['alias'], orderedKeys: ['alias'] }, + }); + const calls: string[] = []; + await expect( + resolvePublicTemplate('{{inputs.path | read_file}}', scope, { + readFile: (path) => { + calls.push(path); + return 'read'; + }, + }), + ).rejects.toMatchObject({ code: 'validation', retryable: false }); + expect(calls).toEqual([]); + }); +}); diff --git a/packages/core/src/interpolation/resolve.ts b/packages/core/src/interpolation/resolve.ts index 93a47a47..c79e9f6c 100644 --- a/packages/core/src/interpolation/resolve.ts +++ b/packages/core/src/interpolation/resolve.ts @@ -9,10 +9,10 @@ * resolved a single time, in declared order (so a later entry may read an earlier one), into a frozen * snapshot. Resolution never mutates the scope and never reaches the filesystem directly. * - * What this module does NOT do: it never resolves a `secret` into agent/human text — the parse-time - * taint gate (`analyzeSecretTaint`) already rejected that, and {@link RunScope} carries no `secrets` - * namespace. The whole-string JS fields (`condition`/`transform`/`merge_fn`) are not templates; they - * belong to the expression sandbox (1.AB), not here. + * The trusted raw resolveTemplate/resolveContext APIs preserve their host contract, including raw + * declared values. Public agent/human text must use resolvePublicTemplate: its whole-template preflight + * rejects declared secret input/context references before capabilities or filters, including callers + * that constructed a workflow without the parser. Bare JS belongs to the expression sandbox. * * Provenance: `resolveTemplate` returns a plain string, flattening any `read_file`- / `run.outputs`- * derived (untrusted) content together with literals — it carries no taint marker. The structural @@ -30,6 +30,11 @@ import { filterFn } from './filters.js'; import { getByPath } from './path.js'; import { parseTemplate, type InterpolationReference } from './references.js'; import type { ResolverCapabilities, RunScope } from './scope.js'; +import { + createWorkflowContextProjection, + ownSecretInputNames, + type WorkflowContextProjection, +} from './provenance.js'; /** * Resolve an authored template string to concrete text. A literal segment passes through verbatim; a @@ -57,6 +62,45 @@ export async function resolveTemplate( return out; } +/** Public text consumers must carry explicit owned provenance, even for hand-built workflows. */ +export interface PublicTemplateScope { + readonly inputs: Readonly>; + readonly ctx: WorkflowContextProjection; + readonly secretInputNames: ReadonlySet; + readonly outputs: Readonly>; +} + +export async function resolvePublicTemplate( + text: string, + scope: PublicTemplateScope, + caps: ResolverCapabilities = {}, + signal?: AbortSignalLike, +): Promise { + const ctx = createWorkflowContextProjection(scope.ctx); + const names = ownSecretInputNames(scope.secretInputNames); + const denied = new Set(ctx.deniedKeys); + // Inspect the whole template before any filter/read capability or fallback can run. + for (const segment of parseTemplate(text)) { + if (segment.kind !== 'reference') continue; + const ref = segment.reference; + if ( + (ref.kind === 'ctx' && denied.has(ref.identifier)) || + (ref.kind === 'inputs' && names.secretInputNames.has(ref.identifier)) + ) { + throw new InterpolationError( + 'secret_reference', + 'secret-derived values are unavailable to text templates', + ); + } + } + return resolveTemplate( + text, + { inputs: scope.inputs, ctx: ctx.values, outputs: scope.outputs }, + caps, + signal, + ); +} + /** * Eagerly resolve every `context` entry exactly once into a frozen, immutable snapshot (the spec's * eager-once context). Entries resolve in declared order, each seeing the inputs and the already- diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index fe8f0c97..0263cc38 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -1063,7 +1063,8 @@ function sanitizeInput( if (secretArgKeys !== undefined) { // **RECURSIVELY.** A top-level-only deletion misses `{"auth":{"api_key": …}}`, which is the ordinary // shape for an MCP tool whose schema this repo does not own. The shape scrub below is the second line; - // this is the first, and it is the one that knows the host's declared secret names. + // this is the first, using external declared names when supplied. Absence is unverified; + // no current authored mapping producer exists (tool-registry.md §Secret argument provenance). for (const key of Object.keys(out)) { out[key] = dropSecretKeysDeep(out[key], secretArgKeys); } diff --git a/packages/core/src/tools/types.ts b/packages/core/src/tools/types.ts index abcac4fe..26210696 100644 --- a/packages/core/src/tools/types.ts +++ b/packages/core/src/tools/types.ts @@ -391,7 +391,11 @@ export interface ToolDispatchContext { * `confirm` decision, and an absent `confirm` is fail-closed → denied. ABSENT ⇒ the workflow author-trust path, unchanged. */ readonly approval?: ToolApprovalContext; - /** Names of effective-arg keys that are secret-tainted (ADR-0029(c)) — rejected from non-credential args. */ + /** + * External effective-arg taint metadata (ADR-0029(c)); absent means unverified, not clean. + * No live authored mapping producer exists. A future mapped path must supply final effective-key + * provenance before approval/effects, including top-level parents of tainted nested values. + */ readonly secretArgKeys?: ReadonlySet; /** Engine delegate for `invoke_agent` — pure orchestration, not a ToolHost I/O capability. */ readonly invokeAgent?: (nodeId: string, input: unknown) => Promise; From aba979f4424fe7e44b95119519bc0279ceab1f69 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 03:32:44 +0300 Subject: [PATCH 032/222] test(cli): pin secret context privacy in native history Exercise the actual standard executor and QuickJS against file-backed SQLite through clean, denied, human-gate and same/fresh-engine resume paths. Assert nonempty event/step/run persistence and exact clean or masked outputs before checking newly derived synthetic canaries are absent. Refs: ADR-0113 Co-Authored-By: Codex --- .../src/engine/secret-context-history.test.ts | 392 ++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 apps/cli/src/engine/secret-context-history.test.ts diff --git a/apps/cli/src/engine/secret-context-history.test.ts b/apps/cli/src/engine/secret-context-history.test.ts new file mode 100644 index 00000000..ee77e61e --- /dev/null +++ b/apps/cli/src/engine/secret-context-history.test.ts @@ -0,0 +1,392 @@ +import { randomUUID } from 'node:crypto'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { parseWorkflow, type RunHandle, type WorkflowDefinition } from '@relavium/core'; +import { + createClient, + createRunHistoryStore, + createRunLeasePort, + runMigrations, + type DbClient, + type RunHistoryStore, +} from '@relavium/db'; +import type { RunEvent } from '@relavium/shared'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildEngine } from './build-engine.js'; +import { createHistoryCheckpointer } from './checkpointer.js'; +import { createCliHost } from './host.js'; + +// Synthetic test data, deliberately unrelated to provider credentials. +const CANARY = '__canary__sqlite_newly_derived_secret_0113__'; +const RESUPPLIED_CANARY = '__canary__sqlite_resupplied_secret_0113__'; +const DENIED_MESSAGE = 'secret-derived values are unavailable to expressions'; +const INPUTS = { token: CANARY, name: 'Ada' }; + +function workflow(nodes: string, edges: string): WorkflowDefinition { + return parseWorkflow(`schema_version: '1.0' +workflow: + id: secret-context-history + inputs: + - { name: token, type: secret, required: true } + - { name: name, type: string, required: true } + context: + - { key: greeting, value: 'Hello {{inputs.name}}' } + - { key: alias, value: '{{inputs.token}}' } + - { key: clean, value: ready } + - { key: transitive, value: '{{ctx.alias}}' } + - { key: nested, value: 'wrapped({{ctx.transitive}})' } + nodes: +${nodes} + edges: +${edges} +`); +} + +function transformWorkflow(expression: string, beforeGate = false): WorkflowDefinition { + return workflow( + ` - { id: start, type: input } +${beforeGate ? ' - { id: before, type: human_gate, gate_type: approval, message_template: "Review {{ctx.greeting}}" }\n' : ''} - id: derive + type: transform + transform: ${JSON.stringify(expression)} + - { id: after, type: human_gate, gate_type: approval, message_template: 'Result {{run.outputs["derive"] | json}}' } + - { id: out, type: output }`, + ` - { from: start, to: ${beforeGate ? 'before' : 'derive'} } +${beforeGate ? ' - { from: before, to: derive }\n' : ''} - { from: derive, to: after } + - { from: after, to: out }`, + ); +} + +describe('secret context at native SQLite/public boundaries (ADR-0113)', () => { + const directories: string[] = []; + const clients: DbClient[] = []; + const consumers: Array<{ handle: RunHandle; drained: Promise }> = []; + + afterEach(async () => { + for (const { handle } of consumers) handle.cancel(); + await Promise.all(consumers.map(({ drained }) => drained)); + await Promise.all(consumers.map(({ handle }) => handle.depart())); + consumers.length = 0; + for (const client of clients.splice(0)) client.sqlite.close(); + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } + }); + + function open(path: string, definition: WorkflowDefinition) { + const client = createClient(path); + clients.push(client); + runMigrations(client.db); + const store = createRunHistoryStore(client.db, { + uuid: randomUUID, + now: Date.now, + workflow: { + slug: definition.workflow.id, + name: definition.workflow.id, + definitionJson: JSON.stringify(definition), + }, + }); + return { client, store }; + } + + async function engine(store: RunHistoryStore) { + return await buildEngine({ + host: createCliHost(store, { + runLeases: createRunLeasePort(store), + checkpointer: createHistoryCheckpointer(store), + }), + // No adapter, keychain, environment credential or provider call is needed by these graphs. + providers: { + resolveProvider: () => { + throw new Error('unexpected provider resolution in non-agent workflow'); + }, + keyFor: () => { + throw new Error('unexpected credential access in non-agent workflow'); + }, + }, + }); + } + + async function fixture(definition: WorkflowDefinition) { + const directory = mkdtempSync(join(tmpdir(), 'relavium-secret-context-')); + directories.push(directory); + const path = join(directory, 'history.db'); + const persistence = open(path, definition); + return { ...persistence, path, engine: await engine(persistence.store) }; + } + + function consume(handle: RunHandle) { + const events: RunEvent[] = []; + let ready = () => {}; + const pausedOrFinished = new Promise((resolve) => { + ready = resolve; + }); + const drained = (async () => { + for await (const event of handle.events) { + events.push(event); + if ( + event.type === 'run:paused' || + event.type === 'run:completed' || + event.type === 'run:failed' || + event.type === 'run:cancelled' + ) { + ready(); + } + } + })(); + consumers.push({ handle, drained }); + return { events, pausedOrFinished, drained }; + } + + function expectNoNewSecret(client: DbClient, events: readonly RunEvent[]) { + const persistedEvents = client.sqlite + .prepare('SELECT payload_json FROM run_events ORDER BY run_id, seq') + .all(); + const steps = client.sqlite + .prepare('SELECT output_json FROM step_executions ORDER BY run_id, node_id') + .all(); + const runs = client.sqlite.prepare('SELECT output_json FROM runs ORDER BY id').all(); + // A negative assertion without actual public/persisted evidence would pass on a broken host. + expect(events.length).toBeGreaterThan(0); + expect(persistedEvents.length).toBeGreaterThan(0); + expect(steps.length).toBeGreaterThan(0); + expect(runs).toHaveLength(1); + for (const evidence of [events, persistedEvents, steps, runs]) { + expect(JSON.stringify(evidence)).not.toContain(CANARY); + expect(JSON.stringify(evidence)).not.toContain(RESUPPLIED_CANARY); + } + } + + function expectExpressionDenied(events: readonly RunEvent[], nodeId = 'derive') { + expect( + events.find((event) => event.type === 'node:failed' && event.nodeId === nodeId), + ).toMatchObject({ + type: 'node:failed', + nodeId, + error: { code: 'sandbox_error', message: DENIED_MESSAGE, retryable: false }, + }); + expect(events.at(-1)).toMatchObject({ + type: 'run:failed', + error: { code: 'sandbox_error', message: DENIED_MESSAGE, retryable: false }, + }); + expect(events.some((event) => event.type === 'run:completed')).toBe(false); + expect(events.some((event) => event.type === 'node:completed' && event.nodeId === nodeId)).toBe( + false, + ); + } + + it('persists exact clean output, original authored context, and unchanged admitted-input markers', async () => { + const definition = workflow( + ` - { id: start, type: input } + - id: derive + type: transform + transform: '({ greeting: ctx.greeting, clean: ctx.clean, keys: Object.keys(ctx) })' + - { id: out, type: output }`, + ` - { from: start, to: derive } + - { from: derive, to: out }`, + ); + const { client, engine: runtime } = await fixture(definition); + const handle = runtime.start({ workflow: definition, inputs: INPUTS }); + const { events, drained } = consume(handle); + await drained; + const output = { + greeting: 'Hello Ada', + clean: 'ready', + keys: ['greeting', 'alias', 'clean', 'transitive', 'nested'], + }; + expect(events.at(-1)).toMatchObject({ type: 'run:completed', outputs: { out: output } }); + const masked = { token: { secret: true, ref: 'inputs.token' }, name: 'Ada' }; + expect(events[0]).toMatchObject({ type: 'run:started', inputs: masked }); + expect(events).toContainEqual( + expect.objectContaining({ type: 'node:completed', nodeId: 'start', output: masked }), + ); + expect( + client.sqlite + .prepare("SELECT output_json FROM step_executions WHERE node_id = 'derive'") + .all(), + ).toEqual([{ output_json: JSON.stringify(output) }]); + expect(client.sqlite.prepare('SELECT output_json FROM runs').all()).toEqual([ + { output_json: JSON.stringify({ out: output }) }, + ]); + expect(client.sqlite.prepare('SELECT workflow_definition_snapshot FROM runs').all()).toEqual([ + { workflow_definition_snapshot: JSON.stringify(definition) }, + ]); + expectNoNewSecret(client, events); + }); + + it.each([ + ['direct context', 'ctx.alias'], + ['computed context', 'ctx[["al", "ias"].join("")]'], + ['serialized context', 'JSON.stringify(ctx)'], + ['transitive context', 'ctx.transitive'], + ['nested context', '({ nested: [ctx.nested] })'], + ['direct input', 'inputs.token'], + ['caught access', '(() => { try { return ctx.alias; } catch { return "clean fallback"; } })()'], + ['returned context', 'ctx'], + ['result getter', '({ get value() { return ctx.alias; } })'], + ['result toJSON', '({ toJSON() { return { value: ctx.alias }; } })'], + ])('refuses %s before a downstream human can see it', async (_label, expression) => { + const definition = transformWorkflow(expression); + const { client, engine: runtime } = await fixture(definition); + const { events, drained } = consume(runtime.start({ workflow: definition, inputs: INPUTS })); + await drained; + expectExpressionDenied(events); + expect(events.some((event) => event.type === 'human_gate:paused')).toBe(false); + expect( + client.sqlite + .prepare("SELECT output_json FROM step_executions WHERE node_id = 'derive'") + .all(), + ).toEqual([{ output_json: null }]); + expect(client.sqlite.prepare('SELECT output_json FROM runs').all()).toEqual([ + { + output_json: JSON.stringify({ + start: { token: { secret: true, ref: 'inputs.token' }, name: 'Ada' }, + }), + }, + ]); + expectNoNewSecret(client, events); + }); + + it('refuses context in a custom merge without persisting the derived value', async () => { + const definition = workflow( + ` - { id: start, type: input } + - { id: left, type: transform, transform: '"left"' } + - { id: right, type: transform, transform: '"right"' } + - { id: derive, type: merge, merge_strategy: custom, merge_fn: '({ branches, leaked: ctx.transitive })' } + - { id: out, type: output }`, + ` - { from: start, to: left } + - { from: start, to: right } + - { from: left, to: derive } + - { from: right, to: derive } + - { from: derive, to: out }`, + ); + const { client, engine: runtime } = await fixture(definition); + const { events, drained } = consume(runtime.start({ workflow: definition, inputs: INPUTS })); + await drained; + expectExpressionDenied(events); + expect(events).toContainEqual( + expect.objectContaining({ type: 'node:completed', nodeId: 'left', output: 'left' }), + ); + expectNoNewSecret(client, events); + }); + + it('refuses a context condition oracle before either branch runs', async () => { + const definition = workflow( + ` - { id: start, type: input } + - id: derive + type: condition + expression: 'ctx.alias.length > 0' + branches: [{ when: true, target_node: yes }] + default: no + - { id: yes, type: transform, transform: '"selected-yes"' } + - { id: no, type: transform, transform: '"selected-no"' } + - { id: out, type: output }`, + ` - { from: start, to: derive } + - { from: yes, to: out } + - { from: no, to: out }`, + ); + const { client, engine: runtime } = await fixture(definition); + const { events, drained } = consume(runtime.start({ workflow: definition, inputs: INPUTS })); + await drained; + expectExpressionDenied(events); + expect( + events.some((event) => event.type === 'node:started' && ['yes', 'no'].includes(event.nodeId)), + ).toBe(false); + expectNoNewSecret(client, events); + }); + + it.each(['{{inputs.token | default("public")}}', '{{ctx.transitive | json}}'])( + 'runtime-refuses hand-built human template %s before publishing a gate', + async (template) => { + const parsed = workflow( + ` - { id: start, type: input } + - { id: gate, type: human_gate, gate_type: approval, message_template: 'Review {{ctx.greeting}}' } + - { id: out, type: output }`, + ` - { from: start, to: gate } + - { from: gate, to: out }`, + ); + // Preserve the typed workflow while bypassing parse-time text taint refusal intentionally. + const definition: WorkflowDefinition = { + ...parsed, + workflow: { + ...parsed.workflow, + nodes: parsed.workflow.nodes.map((node) => + node.type === 'human_gate' ? { ...node, message_template: template } : node, + ), + }, + }; + const { client, engine: runtime } = await fixture(definition); + const { events, drained } = consume(runtime.start({ workflow: definition, inputs: INPUTS })); + await drained; + expect(events.at(-1)).toMatchObject({ + type: 'run:failed', + error: { code: 'validation', retryable: false }, + }); + expect(events.some((event) => event.type === 'human_gate:paused')).toBe(false); + expectNoNewSecret(client, events); + }, + ); + + it('publishes the exact clean downstream gate message and completes after same-process approval', async () => { + const definition = transformWorkflow('ctx.greeting'); + const { client, engine: runtime } = await fixture(definition); + const handle = runtime.start({ workflow: definition, inputs: INPUTS }); + const { events, pausedOrFinished, drained } = consume(handle); + await pausedOrFinished; + const gate = events.find((event) => event.type === 'human_gate:paused'); + expect(gate).toMatchObject({ message: 'Result "Hello Ada"' }); + if (gate?.type !== 'human_gate:paused') + throw new Error('clean workflow did not reach its gate'); + await runtime.resume(handle.runId, gate.gateId, { decision: 'approved', decidedBy: 'offline' }); + await drained; + expect(events.at(-1)).toMatchObject({ type: 'run:completed' }); + expectNoNewSecret(client, events); + }); + + it.each([false, true])( + 'retains denied context after a human gate (reopened file and fresh engine=%s)', + async (fresh) => { + const definition = transformWorkflow('ctx.transitive', true); + const original = await fixture(definition); + const handle = original.engine.start({ workflow: definition, inputs: INPUTS }); + const first = consume(handle); + await first.pausedOrFinished; + const gate = first.events.find((event) => event.type === 'human_gate:paused'); + expect(gate).toMatchObject({ nodeId: 'before', message: 'Review Hello Ada' }); + if (gate?.type !== 'human_gate:paused') + throw new Error('workflow did not reach its first gate'); + let activeClient = original.client; + let events = first.events; + if (fresh) { + expect((await handle.depart()).kind).toBe('detached'); + await first.drained; + original.client.sqlite.close(); + clients.splice(clients.indexOf(original.client), 1); + const reopened = open(original.path, definition); + activeClient = reopened.client; + const runtime = await engine(reopened.store); + const resumed = await runtime.resumeFromCheckpoint({ + runId: handle.runId, + workflow: definition, + inputs: { token: RESUPPLIED_CANARY }, + gateId: gate.gateId, + decision: { decision: 'approved', decidedBy: 'offline' }, + }); + const second = consume(resumed); + await second.drained; + events = [...first.events, ...second.events]; + } else { + await original.engine.resume(handle.runId, gate.gateId, { + decision: 'approved', + decidedBy: 'offline', + }); + await first.drained; + } + expectExpressionDenied(events); + expect(events.filter((event) => event.type === 'human_gate:paused')).toHaveLength(1); + expectNoNewSecret(activeClient, events); + }, + ); +}); From 3222a8c67e631d860b4b93b895993f53aa091d29 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 03:33:23 +0300 Subject: [PATCH 033/222] docs(docs): land qualified context contracts and author evidence Describe the implemented declared-source boundary and actual consumer APIs, with explicit finite denial, result parity and historical-data limits. Keep HTTP policy unshipped and external mapped-tool provenance unverified. Record scoped author gates and baseline/removal controls separately from the pending dedicated security sitting and independent cumulative review gates. Refs: ADR-0113 Co-Authored-By: Codex --- docs/reference/cli/commands.md | 15 +- .../reference/contracts/workflow-yaml-spec.md | 18 +- .../reference/desktop/keychain-and-secrets.md | 4 + docs/reference/shared-core/agent-runner.md | 72 +++-- docs/reference/shared-core/database-schema.md | 17 ++ .../shared-core/expression-sandbox-spec.md | 284 +++++++++--------- docs/reference/shared-core/tool-registry.md | 44 +-- .../phase-2.5.5-hardening-and-remediation.md | 57 ++++ docs/standards/security-review.md | 19 +- 9 files changed, 325 insertions(+), 205 deletions(-) diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 15c38df0..6d3ca004 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -80,8 +80,9 @@ a pipe/redirect, or `CI`.) found, missing key) is written to stderr as a structured `{ "type": "error", "code", "message" }` envelope (distinct from the run stream's `run:failed`/`node:failed`) and exits `2`; stdout stays empty. A pipe consumer reads stdout for events and uses the exit code + stderr for faults. -- **Secret-typed values stay masked** — the engine masks them as `{ "secret": true, "ref": … }` - before any renderer sees them; the NDJSON carries that masked shape, never a raw secret. +- **Declared secret input slots stay masked** — the engine emits them as + `{ "secret": true, "ref": … }`. This does not certify arbitrary or historical event/output content; + see [historical output/error data](../shared-core/database-schema.md#historical-output-and-error-data). Exit codes are CI-friendly (see [Exit codes](#exit-codes)). @@ -239,6 +240,10 @@ For this detector probe only, a recognised bare template reference without a pat Replays a past run's persisted `run_events` in `seq` order (the same data the desktop run-detail drawer replays) — a terse line per event in human mode. Under `--json` it emits each **raw [RunEvent](../contracts/sse-event-schema.md) as one NDJSON line** — the same `RunEvent` data `relavium run --json` streamed (this is the "raw RunEvent JSON" the run-detail replay consumes — no separate `--raw` flag). For a run paused at a gate, the `human_gate:paused` event surfaces the **`gateId`** to copy into `relavium gate --gate `. An unknown `runId` is an invalid invocation (exit `2`). +Replay retains the stored content; current declared-secret context protection does not scrub or +certify older outputs. Apply the [historical output/error guidance](../shared-core/database-schema.md#historical-output-and-error-data) +before sharing replay output or recovering the run. + Raw historical events preserve absent fields: the reader does not synthesize `usageCoverage`, `cumulativeUnpricedCalls` or invocation correlation; absence cannot certify complete or free coverage. @@ -307,7 +312,11 @@ printf 'api_key=%s\n' "$API_KEY" | relavium gate --approve --secret-stdi - What this proves is the **slot**, not the credential: the engine verifies that the same named `secret` input was re-supplied and cannot tell whether the value is the same key or a rotated one. ADR-0083 §6 states that limit rather than implying more. - **Idempotent.** A doubled decision — the run already finished, or the named gate was already resolved — is a clean exit-`0` no-op, never a double-advance (it leans on the engine's checkpoint/gate-state idempotency). An unknown `runId` is exit `2`. Idempotency is **per gate**, though: on a *sequential* multi-gate workflow a blind repeat *without* `--gate` (after the first decision advanced the run and it re-paused at the **next** gate) auto-fills and resolves *that* gate — so an automated retry-until-exit-`0` loop should **pin `--gate `** to avoid resolving later gates unattended. -> **Implementation status (2.G).** `relavium gate` runs in a **fresh process** from the original `run`: it reloads the run's frozen `WorkflowDefinition` + inputs from the durable history snapshot (2.H), reconstructs the paused checkpoint from the persisted event log, and calls `engine.resumeFromCheckpoint` over the same store — then drives the resumed run to its terminal (exit `0` complete / `1` failed / `3` paused again at a later gate). The recorded `decidedBy` is the constant `cli` (a deterministic, non-PII marker; the desktop/portal supply a real user id). Budget-cap pauses (`budget:paused`, [ADR-0028](../../decisions/0028-workflow-resource-governance.md)) are **not** resolved here — that is the separate [`relavium budget resume`](#relavium-budget-resume) surface. A run that declares a **`secret`-typed input** is resumed by re-supplying it on stdin with `--secret-stdin` (above); without the flag `relavium gate` still **fails closed (exit `2`)**, because secrets are never persisted in plaintext (only a masked placeholder is, ADR-0006/0036) and there is nothing to restore. The engine enforces the same rule independently — it refuses a resume whose masked slot was not filled, and refuses the placeholder itself as a value. The [`relavium gate list`](#relavium-gate-list) multi-gate listing is live (2.I). +> **Implementation status (2.G).** `relavium gate` runs in a **fresh process** from the original `run`: it reloads the run's frozen `WorkflowDefinition` + inputs from the durable history snapshot (2.H), reconstructs the paused checkpoint from the persisted event log, and calls `engine.resumeFromCheckpoint` over the same store — then drives the resumed run to its terminal (exit `0` complete / `1` failed / `3` paused again at a later gate). The recorded `decidedBy` is the constant `cli` (a deterministic, non-PII marker; the desktop/portal supply a real user id). Budget-cap pauses (`budget:paused`, [ADR-0028](../../decisions/0028-workflow-resource-governance.md)) are **not** resolved here — that is the separate [`relavium budget resume`](#relavium-budget-resume) surface. A run that declares a **`secret`-typed input** is resumed by re-supplying it on stdin with `--secret-stdin` (above); without the flag `relavium gate` still **fails closed (exit `2`)**, because the declared input slot retains only a masked placeholder (ADR-0006/0036) and has no raw value to restore. The engine enforces the same rule independently — it refuses a resume whose masked slot was not filled, and refuses the placeholder itself as a value. The [`relavium gate list`](#relavium-gate-list) multi-gate listing is live (2.I). + +Current context qualification on resume does not rewrite completed outputs restored from history; +the [historical output/error guidance](../shared-core/database-schema.md#historical-output-and-error-data) +also applies to human and budget recovery. ### `relavium budget resume` diff --git a/docs/reference/contracts/workflow-yaml-spec.md b/docs/reference/contracts/workflow-yaml-spec.md index f69788ce..c2f16814 100644 --- a/docs/reference/contracts/workflow-yaml-spec.md +++ b/docs/reference/contracts/workflow-yaml-spec.md @@ -180,7 +180,17 @@ a CLI has only strings, so `--input count=3` becomes the number `3` before the e is strict**: it applies defaults, rejects unknown keys, and enforces every rule above against the value it receives, rejecting `"3"` for a `number`. That split is what makes two surfaces behave identically. -> **Secrets are never interpolated into agent text.** A `secret`-typed input may feed a tool credential/header field, but the parser **rejects** a `secret` input interpolated into a `prompt_template` or any agent/tool text — masking only covers *event* payloads, so an interpolated secret would otherwise reach the model and be persisted in the message store. The rejection is **transitive** (taint-tracked through `context` entries and any derived value — a secret cannot be laundered through an intermediate variable). This is a security tightening; see [ADR-0029](../../decisions/0029-tool-policy-hardening.md). +> **Declared secrets are unavailable to public text and expressions.** The parser rejects a +> `secret` input interpolated into `prompt_template` or other agent/tool text, transitively through +> `context` entries ([ADR-0029](../../decisions/0029-tool-policy-hardening.md)). Runtime public +> template resolution also refuses denied context/direct secret-input references before filters +> or defaults, including hand-constructed workflows. Expressions receive denied accessors for +> secret-derived context and supplied secret inputs, rather than raw values or direct-input display +> markers; omitted optional secrets remain absent. Run-start/input-node/display markers remain +> unchanged. The exact provenance and finite observed-access contract is in +> [the expression specification](../shared-core/expression-sandbox-spec.md#secret-context-boundary). +> The separate [tool provenance boundary](../shared-core/tool-registry.md#secret-argument-provenance) +> does not claim a live authored tool-input mapping producer. ## Context and interpolation @@ -194,7 +204,11 @@ context: value: '{{inputs.file_path | read_file}}' # pipe filters are supported ``` -A direct `{{secrets.}}` reference in a workflow `context` value is refused at parse time, even when no later field reads it: that namespace has no workflow-context resolver. This does not activate Proposed [ADR-0060](../../decisions/0060-session-ctx-prompt-interpolation.md). A declared caller-supplied `secret` input may still propagate through `inputs` and intermediate `ctx` entries; the existing transitive taint rule refuses a read of that value by model/human text. MCP-server `env` named-secret resolution remains its separate credential boundary. +A direct `{{secrets.}}` reference in a workflow `context` value is refused at parse time, even when no later field reads it: that namespace has no workflow-context resolver. This does not activate Proposed [ADR-0060](../../decisions/0060-session-ctx-prompt-interpolation.md). A declared caller-supplied `secret` input may still propagate during trusted raw context resolution; +the engine qualifies that result before execution or budget preparation. Only clean strings and +denied-key metadata enter the public context projection. Parser and runtime public-template +consumers refuse model/human text reads, while bare expressions use the +[VM denial contract](../shared-core/expression-sandbox-spec.md#secret-context-boundary). MCP-server `env` named-secret resolution remains its separate credential boundary. Common interpolation namespaces: diff --git a/docs/reference/desktop/keychain-and-secrets.md b/docs/reference/desktop/keychain-and-secrets.md index 37e264b6..c3f6bb45 100644 --- a/docs/reference/desktop/keychain-and-secrets.md +++ b/docs/reference/desktop/keychain-and-secrets.md @@ -65,6 +65,10 @@ The global run-history database (`~/.relavium/history.db`) is encrypted with SQL The **Phase-2 CLI does not use SQLCipher**: it opens the same `history.db` with `better-sqlite3` **unencrypted**, guarded by `0600`/`0700` OS file permissions ([ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md)). Relavium-managed provider keys stay here in the OS keychain. The CLI accepts first-run declared `secret` inputs through bounded [non-TTY stdin](../cli/commands.md#relavium-run), rejects them in ordinary `--input` arguments, and persists their recorded input slots and input-node results as `{ secret: true, ref }` placeholders. The workflow definition snapshot faithfully records the parsed workflow and may contain other authored literals. This is a key-custody and input-slot guarantee, not a guarantee that the file contains no credentials or sensitive content, and [ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md)'s note of 2026-09-14 says what it does hold: tool-I/O redaction is shape-based and applied upstream to event copies rather than by the bus, user-injected content (an `@`-attached file, `!`-shell output) is at rest as the user's own data, and a committed tier-3 run effect keeps its bounded result until that run's sweep. Session effect rows hold no result and never replay ([ADR-0098](../../decisions/0098-a-session-effect-row-holds-no-result-and-never-replays.md)); their physical erasure guarantee requires secure deletion and a successful WAL checkpoint, with busy-reader and pre-upgrade freed-page residuals stated in [effect-journal.md §11](../shared-core/effect-journal.md#11-secrets-what-a-row-may-hold). The two at-rest postures cannot share one file at the same path; reconciling cross-surface coexistence is a named Phase-3 follow-on (ADR-0050). +The [historical output/error guidance](../shared-core/database-schema.md#historical-output-and-error-data) +owns the separate limit for previously persisted secret-derived content and replay/resume; +current context qualification does not scrub or certify those records. + ## Key custody and retained content | Surface | Guarantee | diff --git a/docs/reference/shared-core/agent-runner.md b/docs/reference/shared-core/agent-runner.md index b50b9c8a..cb48d9a5 100644 --- a/docs/reference/shared-core/agent-runner.md +++ b/docs/reference/shared-core/agent-runner.md @@ -2,7 +2,7 @@ > Status: Living -> Last updated: 2026-10-11 +> Last updated: 2026-10-16 - **Related**: [llm-provider-seam.md](llm-provider-seam.md), [tool-registry.md](tool-registry.md), [run-plan.md](run-plan.md), [built-in-tools.md](built-in-tools.md), [../contracts/sse-event-schema.md](../contracts/sse-event-schema.md), [../../decisions/0038-agentrunner-llm-call-boundary.md](../../decisions/0038-agentrunner-llm-call-boundary.md), [../../decisions/0039-same-provider-reasoning-replay.md](../../decisions/0039-same-provider-reasoning-replay.md), [../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md), [../../decisions/0037-engine-tool-execution-boundary.md](../../decisions/0037-engine-tool-execution-boundary.md), [../../standards/error-handling.md](../../standards/error-handling.md) @@ -58,7 +58,7 @@ The runner owns the **cost path** itself — one `CostTracker` per node executio 2. **Build the fallback plan.** The primary uses `node.model ?? agent.model`. With an authored `node.retry ?? agent.retry` budget it has one within-chain attempt; without one it has the shared runner's unauthored two-attempt default. Above-chain node retry does not multiply within-chain primary attempts ([ADR-0040](../../decisions/0040-node-retry-budget-above-the-chain.md)). Each `fallback_chain` entry keeps its own `max_attempts`. **One `FallbackChain` per node execution**, reused across the tool loop so per-provider cooldown and the [ADR-0039](../../decisions/0039-same-provider-reasoning-replay.md) strip-latch survive. - **Generative fork (1.AG Section C, [ADR-0045](../../decisions/0045-async-media-job-loop-poll-checkpoint-resume-cancel.md) §1/§6).** After the plan + the resolved prompt, the primary model's surface is read via `resolveMediaSurface?.(primary.model) ?? 'chat'`. A **`'generative'`** result dispatches to the separate-endpoint `generateMedia` (**one** provider, **no** chain failover — a generative call is provider-bound) and returns early: an empty-prompt / multi-modality node fails `validation`; a pre-egress budget gate runs first (gate-only — the [ADR-0028](../../decisions/0028-workflow-resource-governance.md) pre-egress governor; the token estimate is pinned to zero for a token-free generative call, see the code comment); the SYNC `{ media }` becomes the `{ text: '', media: [part] }` node output (de-inlined to a `media://` handle like the inline path); exactly **one** realized `cost:updated` is emitted (request volume × per-model media rate) — **unless a rate is missing**, which since [ADR-0089](../../decisions/0089-media-correctness-four-boundaries.md) §4 is **unpriced, not zero**: under the non-strict default the numeric total may still floor at 0 but the event carries `priced: false` and the modality is counted in `unpriced_calls`, and under the authored `strict_cost_cap` the call is **refused before provider egress**, so no realized event is emitted at all. *(This clause said "degrade-to-0" until 2026-09-02; that behaviour is exactly the `CR-55` defect — a `0` the governor cannot tell from "nothing to charge".)* A `jobId` (async LRO) is Section D. A **`'chat'`** model continues to the turn core below. 3. **Narrow the tool grant.** `node.tools` must be a **subset** of `agent.tools` — a widening attempt ⇒ `validation` ([ADR-0029](../../decisions/0029-tool-policy-hardening.md)). -4. **Assemble messages.** `system` = **authored text ONLY** (`agent.system_prompt` + `node.system_prompt_append`), concatenated **verbatim — 1.O does not interpolate the system role**, so an untrusted `{{ run.outputs }}` / `read_file` reference can never resolve into `system` (it ships as literal authored text). The parser still *collects* a `{{ … }}` reference in `system_prompt_append` for the parse-time **secret-taint** scan (catching a stray `{{ secrets.* }}`), but does not promise dispatch-time resolution of system fields. Only the resolved `prompt_template` — which may draw on untrusted `run.outputs` / `read_file` — lands in a **`user`** position, never `system` ([security-review.md §Prompt-injection](../../standards/security-review.md#prompt-injection-posture), the structural placement guarantee — no value-level taint carrier needed for an agent node, which cannot launder a secret into `run.outputs`). *(A future parse-time gate that admits trusted `{{ inputs }}` / `{{ ctx }}` in system fields while rejecting untrusted sources is a recorded follow-up.)* +4. **Assemble messages.** `system` = **authored text ONLY** (`agent.system_prompt` + `node.system_prompt_append`), concatenated **verbatim — 1.O does not interpolate the system role**, so an untrusted `{{ run.outputs }}` / `read_file` reference can never resolve into `system` (it ships as literal authored text). The parser still *collects* a `{{ … }}` reference in `system_prompt_append` for the parse-time **secret-taint** scan (catching a stray `{{ secrets.* }}`), but does not promise dispatch-time resolution of system fields. Only the resolved `prompt_template` — which may draw on untrusted `run.outputs` / `read_file` — lands in a **`user`** position, never `system` ([security-review.md §Prompt-injection](../../standards/security-review.md#prompt-injection-posture), the structural placement guarantee; public prompt resolution also enforces the [qualified context boundary](#qualified-context-consumers)). *(A future parse-time gate that admits trusted `{{ inputs }}` / `{{ ctx }}` in system fields while rejecting untrusted sources is a recorded follow-up.)* 5. **`output_schema` (node override wins over the agent default).** Lowered to `LlmRequest.responseFormat` (a **request-side hint**), **and** validated **node-side**: the seam's `responseFormat` does not guarantee a schema-conformant response (DeepSeek degrades to bare `json_object`), so the runner parses the output and a non-JSON result ⇒ `validation` ([ADR-0038](../../decisions/0038-agentrunner-llm-call-boundary.md), [error-handling.md](../../standards/error-handling.md)). The check is **two steps**: the output is parsed as JSON, then validated against the **compiled** `output_schema` — a conformance miss is `validation` (`retryable: false`), exactly as ADR-0038 required. Compilation uses the allowlist-strict JSON-Schema subset ([ADR-0092](../../decisions/0092-output-schema-is-validated-by-the-compiler-we-already-own.md); the subset is a written contract, and a schema outside it is refused at parse rather than silently unenforced). *(This clause read "parse-as-JSON; deep conformance deferred… needs a validator dependency" until 2026-09-02 — the dependency premise was false, the compiler was already in-house.)* 6. **Run the turn core**, map its result to `NodeOutcome.completed` (`output` = the parsed structured value or the assistant text; `tokensUsed = { input, output, model }`), or map a classified `AgentTurnError` to `NodeOutcome.failed`. @@ -370,32 +370,42 @@ these paths authorizes another provider call, releases an uncertain admission or successful settlement. The supported host admission callback is the failure boundary; a distinct second synchronous cleanup error is not claimed for the current built-in governor. -## Proposed Wave 3 qualified context consumers - -**Approved design, unshipped — 2026-10-16.** [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md) -was approved by the maintainer. Exact projection, VM/input compatibility, result transport and -finite refusal scope belong to the -[expression specification](expression-sandbox-spec.md#proposed-wave-3-secret-context-boundary). - -WorkflowEngine qualifies raw resolveContext output at fresh start/resume and -standalone cross-process budget preparation before storing or invoking callbacks. -NodeExecContext and NodePreparationContext carry the same required projection and -owned immutable secret-input membership. The preparation Pick must actually include -it. Public prompt/agent/human lowering rejects denied context and direct secret -inputs before filters/defaults, including hand-constructed workflows. Preparation -has no sandbox or unused VM-only field. Condition/transform/merge scope omits raw -secret inputs and supplies required deniedInputNames; direct expression-input -markers deliberately change, while input-node/display masking stays. - -Migrate all actual engine producers (fresh execution, budgetPreparationContext and -cross-process resumePreparation.context), the exported scope interface and every -co-located double. Derive/copy secret names once per admitted workflow; no exposed -live mutable Set or resume-time second derivation. Witness each producer and actual -VM/template consumer. Reject absent metadata; clean embeddings explicitly create -empty-denial projections. Trusted raw NodeExecContext.inputs and the raw resolver -remain host APIs, not a universal custom-executor confidentiality boundary. - -Update the stale buildExpressionScope/maskSecretInputs and sandbox/error-detail -comments with their actual split duties. Existing historical persisted outputs -remain outside the new projection guarantee and receive at-rest/recovery guidance, -not silent rewriting. Optional tool provenance is its separate canonical boundary. + + +## Qualified context consumers + +**Implementation contract — 2026-10-16.** Current Step 3 source implements +[ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md); +implementation acceptance and independent review gates remain pending in the +[Wave 3 register](../../roadmap/current.md#wave-3--clear-the-ground). Exact projection, +public-template refusal, VM/input compatibility, result transport and finite observed-access +semantics belong to the [expression specification](expression-sandbox-spec.md#secret-context-boundary). + +`WorkflowEngine` immediately qualifies trusted raw `resolveContext` output at fresh start +and resume, before storing context or invoking execution/preparation callbacks. Context is +re-resolved on resume because it is not checkpointed. The admitted workflow supplies one +owned provenance authority shared by execution and `budgetPreparationContext`. Standalone +cross-process budget preparation creates that authority, qualifies the resolved context and +passes both through `resumePreparation.context` / `resumePreparation.provenance`; the resumed +execution reuses them rather than deriving a second mutable secret-name set. + +`NodeExecContext` and `NodePreparationContext` require the same context projection and owned +`secretInputNames` membership; the preparation Pick includes both. Agent prompt resolution +and human-gate `message_template` / `assignee` resolution use `resolvePublicTemplate`. +Budget preparation uses the same prompt consumer, without a VM-only metadata field. Denied +context and direct declared secret-input references refuse before any filters, defaults or +read capabilities, including on hand-constructed workflows that bypass the parser. Clean +fields retain their existing resolution and user-role placement. Authored system text is +still concatenated verbatim, not interpolated. + +Condition/transform/custom-merge handlers use `buildExpressionScope` with the required +projection and explicit denied input names. Direct supplied secret inputs become VM-owned +denied accessors; omitted optional inputs remain absent. `maskSecretInputs` retains its +separate run-start/input-node/display marker duty. The exported raw resolver and trusted raw +`NodeExecContext.inputs` remain host APIs; this is not a confidentiality boundary against a +custom trusted executor or a host that fabricates provenance. + +Historical persisted output and private error data retain the +[at-rest/recovery limit](database-schema.md#historical-output-and-error-data). +Optional external tool-argument provenance has its separate +[canonical boundary](tool-registry.md#secret-argument-provenance). diff --git a/docs/reference/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index 1910e978..93400fef 100644 --- a/docs/reference/shared-core/database-schema.md +++ b/docs/reference/shared-core/database-schema.md @@ -1332,6 +1332,23 @@ as masked slots; arbitrary retained content can nevertheless contain credentials sensitive material. A future desktop/cloud writer inherits these upstream field protections, not a promise that a pass-through writer performs universal content redaction. +### Historical output and error data + +[ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md)'s +[current context projection](expression-sandbox-spec.md#secret-context-boundary) excludes newly +projected declared secret-input/context values from public text and expression scopes. It does +not scrub stored history, scan values, or certify historical resume/replay data as secret-free. +Previously persisted event, step and run outputs can contain secret-derived content, and +restored `run.outputs` can expose it again. Historical or arbitrary authored `SandboxError.detail` +is private potentially sensitive error text; never automatically render it as a public diagnostic. +Arbitrary literals/user/model/tool content are not declared-source taint merely because they +contain sensitive-looking values. + +Before sharing history or recovering a run, review retained outputs and the workflow's downstream +consumers under this sensitivity limit. Current fixed denial diagnostics do not certify an older +record or sanitize replayed content. Existing effect/money recovery requirements remain in the +[effect journal](effect-journal.md) and [CLI recovery guidance](../cli/commands.md#exit-codes). + ## Phase 2 (PostgreSQL) divergences > The following applies only to **Phase 2 cloud execution**. None of it ships in Phase 1. See [../../architecture/cloud-phase-2.md](../../architecture/cloud-phase-2.md) for the full design and the complete 14-item SQLite-vs-Postgres porting table. diff --git a/docs/reference/shared-core/expression-sandbox-spec.md b/docs/reference/shared-core/expression-sandbox-spec.md index 9948cb3e..40aed445 100644 --- a/docs/reference/shared-core/expression-sandbox-spec.md +++ b/docs/reference/shared-core/expression-sandbox-spec.md @@ -33,24 +33,26 @@ actionable error — not silently treated as `js` and failed at eval. There is * ## The expression scope (the one canonical binding) -Each expression is evaluated against a single **frozen, JSON-only** scope. The bindings are exactly: +Each expression receives a single frozen scope rebuilt from **JSON-only transport**; denied +values become VM-owned accessors before authored execution. The bindings are exactly: | Binding | Shape | Meaning | |---------|-------|---------| -| `inputs` | object | this node's resolved inputs (its declared `{{ … }}` references, already resolved) | -| `ctx` | object | the workflow context/variables (the eager-once frozen snapshot, per [workflow-yaml-spec.md](../contracts/workflow-yaml-spec.md)) | +| `inputs` | object | admitted run inputs; supplied declared secret slots are denied accessors, while omitted optional inputs remain absent | +| `ctx` | object | the eager-once context projection: clean strings and denied accessors in original key order; see [secret context boundary](#secret-context-boundary) | | `run.outputs` | object keyed by **node id** | completed upstream node outputs, e.g. `run.outputs["classify"].sentiment` — **never** a bare `output` | | `branches` | array | **`merge_fn` only** — the branch outputs to combine, in the stable **`FanInPlanConfig.branchNodeIds` order** (never arrival/completion order; the order itself is defined by [run-plan.md §fan-in branch order](run-plan.md)). `run.outputs` is also available, so a branch may be referenced by node id. | Rules: -- **Secrets are never injected.** The [ADR-0029(c)](../../decisions/0029-tool-policy-hardening.md) - parse-time taint gate (1.L2) is the primary guarantee for the `secrets.*` namespace; as - defense-in-depth the engine caller masks `secret`-typed **inputs** before evaluation — the 1.P - `condition`/`transform`/`merge_fn` handlers replace each `secret`-typed `inputs.` with its - `{ secret: true, ref }` marker (`buildExpressionScope`), and the agent caller (1.O) filters likewise — - so a raw secret value can never be read through `inputs.`, `JSON.stringify(inputs)`, or any - other path. +- **Newly projected declared secrets are never injected.** The engine excludes raw declared + secret inputs and secret-derived context strings before scope serialization. A supplied secret + input or denied context read runs a VM-owned accessor; an observed read fails with + `secret_context`, including ordinary caught reads and serialization-time reads. Direct expression + inputs no longer expose `{ secret: true, ref }` display markers. Omitted optional secret inputs + remain absent. Key-only enumeration is allowed; see the [finite denial contract](#private-evaluation-and-finite-denial). + This is declared-source provenance, not value scanning: arbitrary literals and historical + `run.outputs` are outside that guarantee. - **The scope is data-only and deeply immutable.** It is crossed into the VM as plain JSON (see [Marshaling & isolation](#marshaling--isolation)); each binding is installed as a non-writable, non-configurable property over a deep-frozen value. An expression cannot mutate the scope, and a @@ -151,20 +153,22 @@ ADR. These numbers are the single source of truth; every surface uses them uncha |------|-----------------|-----------| | `condition` | a `boolean` \| `string` \| `number`, compared to each `when` by strict `===` (no coercion) | a result outside that set → fatal `sandbox_error` (`result_type`). *(No-`when`-match-and-no-`default` is the **1.P condition handler's** concern when it applies the result — not the sandbox.)* | | `transform` | a single JSON-serializable result (the node's whole output) | a function, symbol, top-level `undefined`, top-level **`BigInt`**, a top-level **boxed primitive** (`new String`/`new Number`/`new Boolean`), or circular result → fatal `sandbox_error` (`non_serializable`) | -| `merge_fn` | a JSON-serializable object | as `transform` | +| `merge_fn` | the same result contract as `transform` | as `transform` | > **Lossy JSON coercion (author guidance).** A `transform`/`merge_fn` result is taken as -> JSON-serializable, so standard `JSON.stringify` lossy cases apply: `Map`/`Set` → `{}`, -> `NaN`/`Infinity`/`-Infinity` → `null`, `-0` → `0`. These **pass** validation (they *are* -> JSON-serializable) but lose information — return plain JSON values to avoid surprise. A top-level -> `BigInt`, by contrast, is **rejected** (`JSON.stringify` throws on it — it is not serializable). +> JSON-serializable, so nested standard `JSON.stringify` lossy cases apply: `Map`/`Set` → `{}`, +> `NaN`/`Infinity`/`-Infinity` → `null`, `-0` → `0`. Top-level numeric values instead retain +> their exact value/sign at `evaluate`; later JSON persistence retains its existing normalization. +> A top-level `BigInt` is **rejected**. See [result parity](#primitive-transport-and-result-parity). > > **Boxed primitives are rejected (deliberate over-rejection).** A top-level `new String("x")` / > `new Number(1)` / `new Boolean(true)` is rejected as `non_serializable` even though it round-trips > through `JSON.stringify` cleanly. The host validator keys on the **VM-side `typeof`** (which is > `'object'` for a wrapper) while the marshaled value is a host primitive, and treats that -> object-vs-primitive mismatch as the tell of a non-marshalable result. The contract is therefore -> slightly stricter than raw JSON-serializability: **return a plain primitive**, never a wrapper. +> object-vs-primitive mismatch as the tell of a non-marshalable result. An object whose `toJSON` +> returns a non-null scalar is refused for the same reason; the existing null-result exception +> remains. The contract is therefore slightly stricter than raw JSON-serializability: +> **return a plain primitive**, never a wrapper. > > **Unsettled `run.outputs` reads.** A JS-expression `run.outputs["x"]` read is still **not** wired as a > dependency edge by the DAG builder — only `{{ … }}` template references are. Since @@ -186,14 +190,15 @@ Every sandbox failure surfaces as the closed `ErrorCode` member **`sandbox_error `node:failed` / `run:failed` events with a user-safe message and an internal correlation id. The retryable/fatal split (owned by [error-handling.md](../../standards/error-handling.md)) is: -| Cause | `dump()` surface (quickjs) | `reason` | Classification | +| Cause | Classification evidence | `reason` | Classification | |-------|----------------------------|----------|----------------| | Syntax error (invalid JS) | object `{ name: "SyntaxError" }` | `syntax` | **fatal** — deterministic | | Runtime error (`ReferenceError`/`TypeError`/a thrown `Error`: undefined var, bad property, `Math.random` call) | object `{ name, message }` | `runtime` | **fatal** — deterministic | | Non-conforming result (bad `condition` type; non-serializable `transform`/`merge_fn`) | host-side validation | `result_type` / `non_serializable` | **fatal** — deterministic | | Memory / resource limit (out of memory, string too long) | object `{ name: "InternalError" }` | `memory` | **fatal** | | Stack overflow | object `{ name: "InternalError", message: "stack overflow" }` | `stack` | **fatal** | -| Injected scope not serializable (engine/caller fault, before VM eval) | host-side `JSON.stringify` throw, or a too-deep scope | `scope` | **fatal** | +| Injected scope not serializable or required provenance missing/invalid (engine/caller fault, before VM eval) | host-side validation / serialization / depth admission | `scope` | **fatal** | +| Observed denied input/context accessor | private denied transport arm, never an authored error name/message | `secret_context` | **fatal**, fixed diagnostic without detail/cause | | Wall-clock timeout (the genuine interrupt) | `{ name: "InternalError", message: "interrupted" }` **and** the host deadline passed | `timeout` | **retryable** — non-idempotent; bounded by the node retry budget (1.S) | > **Classification is by error `name` + the host-side deadline — never an author-controlled message.** @@ -206,8 +211,9 @@ retryable/fatal split (owned by [error-handling.md](../../standards/error-handli **Message scrubbing (binding).** The user-facing `sandbox_error` message is the code plus a generic, secret-free string — it **never** echoes the expression source, a variable name, a scope value, an -output, or a host stack trace. Full detail (including the raw `dump()`) goes to internal logs only, -keyed by the correlation id. This mirrors the `LlmError`-message discipline in +output, or a host stack trace. Ordinary `SandboxError.detail` can contain authored error text and +is private potentially sensitive data; do not render it or treat it as universally safe to log. +The `secret_context` arm contains no detail or cause. This mirrors the `LlmError`-message discipline in [security-review.md](../../standards/security-review.md). > Syntax validity is checked at **evaluation** (1.AB), not at parse (1.L) — Zod validates the @@ -222,9 +228,11 @@ keyed by the correlation id. This mirrors the `LlmError`-message discipline in cannot reach a host reference. `JSON.parse` installs a `__proto__` key as an **own data property** (never the prototype setter), so an attacker-shaped `{"__proto__":…}` arriving via model/tool-derived `run.outputs` cannot poison the prototype chain. -- **Immutable, lexically-bound scope.** The parsed scope is **deep-frozen** (recursive `Object.freeze`) - and exposed as `const` lexical bindings (`inputs`, `ctx`, `run`, and `branches` for `merge_fn`) inside - a strict-mode IIFE — it is never installed on the VM global, and a write to a binding throws. A +- **Immutable, lexically-bound scope.** The VM rebuilds frozen null-prototype records from the + parsed JSON and installs denied accessors before authored execution. Scope bindings (`inputs`, + `ctx`, `run`, and `branches` for `merge_fn`) are `const` in a separate strict lexical function, + outside the private latch/serializer environment. They are never installed on the VM global, + and a write to a binding throws. A pathologically deep scope is rejected (`scope`) before injection (it would overflow the host stack inside `evalCode`). - **Fresh context per evaluation.** A new runtime + context is created and disposed for each @@ -257,135 +265,123 @@ forbidden capability — `Date`/`Math.random`/`Promise` absent, secret-in-scope error, cap-trip classification, determinism over shuffled `run.outputs` key order) are a binding part of 1.AB per [testing.md](../../standards/testing.md#security-critical-primitive-tests-direct-negative-case). -## Proposed Wave 3 secret context boundary + -**Approved design, unshipped — 2026-10-16.** [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md) -was approved by the maintainer, including deliberate replacement of direct secret-input -expression markers. Display/run-start/input-node markers remain unchanged. +## Secret context boundary + +**Implementation contract — 2026-10-16.** This section describes the current Step 3 source +under [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md). +Implementation acceptance and independent review gates remain pending in the +[Wave 3 register](../../roadmap/current.md#wave-3--clear-the-ground). ### Owned provenance and consumers -One WorkflowContextProjection owns values: Readonly> (clean -keys only), deniedKeys: readonly string[] and orderedKeys: readonly string[]. -Copy/freeze all three and detach record prototypes. orderedKeys is the original -Object.keys(resolved) order, not a new lexical sort. Validate unique well-formed -keys and that orderedKeys is exactly the disjoint union of clean and denied keys. -Reconstruct scope data/accessors in this full order, preserving existing context -declaration order and ECMAScript integer-key enumeration. Separate clean/denied -lists alone cannot recover their original interleaving. One pure factory qualifies -resolved context using the existing -linear taint graph. Required execution/preparation ctx becomes this projection; -the exported trusted raw resolver contract stays unchanged. Missing/malformed -projection uses existing validation ErrorCode with fixed content-free message, -never an implicit clean default. Shape checks do not detect a dishonest embedding. - -Derive secret-input membership once from that same admitted workflow. Own its -backing data; expose a ReadonlySet-compatible membership/iteration facade without -Set mutation methods and an immutable ordered denied-name array from the same -factory. Facade callbacks/iterators must never expose the mutable backing Set -(including forEach’s third argument). Copy caller-provided names at embedding -entry: Object.freeze(new Set()) -or a live caller Set is insufficient. Every fresh/resume/preparation producer uses -that same authority, rather than recomputing a mutable set at resume. - -- resolvePublicTemplate uses the existing reference lexer, rejecting denied ctx - **and direct secret input** references before filters/defaults. Existing - InterpolationError reason secret_reference maps to non-retryable validation. - This is required for manually constructed workflow objects as well as parsed - YAML; parser refusal alone is insufficient. Agent/human text and budget prompt - preparation use this same consumer; preparation has no VM-only metadata. -- ExpressionScope requires the context projection and explicit immutable - deniedInputNames; clean embeddings provide explicit empty metadata. Scope - construction omits raw secret inputs and supplies clean values plus denied - names. Preserve actual input own-key order/presence: present denied keys use - private null placeholders which become accessors before author code. For an - omitted optional secret input, create **no property/accessor**: ordinary - undefined, hasOwn/in and all own-key/descriptor observations retain baseline - absence. Compute VM denied keys as present admitted keys intersected with owned - secret membership; public-template refusal still uses declaration membership. - Never introduce secret defaults or relax required-input admission. Placeholders are - transport data, never readable expression values or display markers. Trusted - NodeExecContext.inputs - remains raw; input-node/public display continues using maskSecretInputs. -- QuickJS rebuilds frozen null-prototype scope records and denied accessors with - only the present denied input keys specified above; context uses its full - ordered projection. Clean values and key-only enumeration remain - usable. Scope metadata validation precedes serialization; no newly projected - raw denied input/context bytes enter the VM. +`WorkflowContextProjection` contains `values: Readonly>` (clean keys +only), `deniedKeys: readonly string[]` and `orderedKeys: readonly string[]`. Its factory +copies/freezes all three and detaches the value record's prototype. Keys must be referenceable +identifiers, denied/ordered keys unique, and `orderedKeys` exactly the disjoint union of clean +and denied keys. The full order is the original `Object.keys(resolved)` order, preserving +context declaration order and ECMAScript integer-key enumeration rather than sorting. +Denied context values are absent from this projection. + +`createWorkflowProvenance` computes one authority from the existing linear declared-source +taint graph per admitted workflow. It owns both a `ReadonlySet`-compatible secret-input +membership facade and an immutable ordered `deniedInputNames` array. The facade has no Set +mutation methods; its callbacks and iterators do not expose the mutable backing Set, including +`forEach`'s third argument. `ownSecretInputNames` copies embedding-supplied membership; +`Object.freeze(new Set())` or retaining a caller's live Set would not provide that ownership. +Shape validation cannot detect a dishonest trusted embedding's fabricated provenance. + +Execution/preparation require this projection and owned membership. Missing or malformed +metadata at an engine/public-template boundary raises fixed, content-free +`WorkflowContextError` (`validation`, non-retryable). Direct `ExpressionSandbox.evaluate` +converts that metadata failure to fixed `SandboxError.reason: scope`, preserving its guarantee +to throw only `SandboxError`. Clean embeddings supply an explicit empty-denial projection +and explicit `deniedInputNames`, never an implicit clean default. + +`resolvePublicTemplate` uses the existing reference lexer to inspect the **entire** template +before resolving any reference or invoking any filter/capability. It refuses denied `ctx` +references and direct declared secret-input references, including optional secrets absent +from the input map, before a `default` can replace them. The fixed `InterpolationError` +reason `secret_reference` maps to non-retryable `validation`. This applies to hand-constructed +workflow objects as well as parsed YAML. The [runner contract](agent-runner.md#qualified-context-consumers) +owns actual fresh/resume/preparation producers and text consumers; preparation has no +sandbox-only field. The exported trusted raw `resolveContext` contract stays unchanged. + +`ExpressionScope.ctx` is the required projection, with explicit `deniedInputNames`. Scope +construction excludes raw secret inputs, preserving actual input own-key order/presence. +Present denied input keys use private null transport placeholders which become frozen VM-owned +accessors before authored code. Omitted optional secret inputs get **no** property/accessor: +`undefined`, `hasOwn`/`in`, own keys and descriptors retain baseline absence. VM denied input +keys are present admitted keys intersected with owned declaration membership. Placeholders are +never readable expression values or display markers. Required-input admission and the ban +on secret defaults remain unchanged; trusted `NodeExecContext.inputs` remains raw, while +run-start/input-node/display masking retains its existing marker contract. ### Private evaluation and finite denial -Author code runs in a separate strict lexical function receiving only the frozen -public pack. Bind inputs/ctx/run/branches as const, outside the private latch and -captured serializer closure. Capture needed intrinsics before authored execution. -When a denied accessor body runs it sets the private sticky latch before throwing. -Perform all authored result serialization before the final latch check; ordinary -caught access, getters and toJSON cannot clear an already-set latch. - -This is **not** universal attempted-access detection. A stack/resource exception -before the accessor body may be caught without setting the latch; the installed -QuickJS intake reproduces it. Successful clean fallback then carries no raw denied -value because none was supplied. Pin that finite semantic limitation separately -from ordinary caught-read denial. Uncaught resource/abort/timeout retains its -existing disposition; do not mislabel it successful denial. Confidentiality rests -on raw-value exclusion, not on the latch. - -Observed denial yields SandboxError.reason secret_context under existing -code sandbox_error, retryable=false, fixed message “secret-derived values are -unavailable to expressions”, with no source/value detail or cause. Only this -private denial arm is independent of authored error name/message; existing ordinary -error classification remains. Historical/arbitrary authored SandboxError.detail -is private potentially sensitive text, never a universally safe diagnostic. - -ADR-0093's alternative (b) concerned before-money-spent dependency ordering and -keeps its literal parse scan. These accessors address runtime privacy/semantic -diagnosis; they do not reverse that ordering choice or add a host function. +QuickJS reconstructs frozen null-prototype scope records, using the full context key order +and only present denied input keys. Authored code runs in a separate strict lexical function +receiving only the frozen public pack, with `const` scope bindings. Trusted intrinsics are +captured before authored execution. A denied accessor sets the private sticky latch before +throwing; ordinary caught reads, returned getters and `toJSON` reads cannot clear it. + +This is **finite observed-access detection**. A stack/resource exception before the accessor +body can be caught without setting the latch. The installed-VM regression's exhausted-stack +case returns its authored clean fallback string `"SWALLOWED"`; the ordinary caught-read case +instead produces `secret_context`. That successful fallback carries no raw denied value because +none was supplied; it is not a denial outcome. An uncaught +pre-accessor resource failure keeps its ordinary classification. Confidentiality rests on +raw-value exclusion, not universal detection of every attempted read. A serialization interrupt +has the separate baseline result disposition below. + +When the private serialization closure completes with an observed denial, it produces +`SandboxError.reason: secret_context`, existing `code: sandbox_error`, +`retryable: false`, and the fixed message “secret-derived values are unavailable to expressions”, +without source/value detail or cause. Only this private denial arm is independent of authored +error names/messages; ordinary error classification remains unchanged. + +ADR-0093's parse scan still protects before-money-spent dependency ordering. These VM-side +accessors diagnose runtime privacy reads; they add no host function and do not reverse that +scheduling decision. ### Primitive transport and result parity -Versioned private control arms are success, failure or denied. The success arm -contains trusted original VM typeof, a primitive payloadJson string, and a closed -number tag where needed; no authored object is embedded in the outer envelope. -Serialize the author result at most once, finish any author-controlled access, -then check the latch. Serialize the null-prototype control envelope using captured -intrinsics and return it as a primitive string. Later envelope serialization must -not call another authored toJSON/getter. Host checks VM typeof, extracts getString, -parses/strictly validates the private arm and decodes the payload. Never dump a -prototype-bearing VM object after latch validation. Author values cannot select an -outer arm. Malformed transport is fixed sandbox failure, not success or secret proof. - -Bound the full transport's UTF-8 bytes by the evaluation's active memoryBytes, -with cheap length precheck before the bounded byte count; default resource limits -remain those above. No independent unbounded host parse/dump is permitted. An -oversized transport is a fixed non-retryable resource failure, not truncated data. - -Preserve actual evaluate/validateResult semantics, separately from eventual JSON -persistence. Installed baseline controls confirm: - -| Result | condition | transform / merge_fn | +One evaluation uses **two native calls in the same VM under the original deadline**. The first +evaluates authored code and returns only a private VM-owned serialization closure. The second +invokes that closure to serialize the captured authored result at most once, then performs the +final latch check. No successful authored object is dumped across the bridge. A completed closure's +observed denial wins over a caught serialization error. A serialization failure without denial, +or a native serialization interrupt that prevents completion of the closure, preserves baseline +disposition: `result_type` for `condition`, `non_serializable` for `transform`/`merge_fn`. +The native interrupt does not receive a new deadline or become a successful denial observation. + +The versioned private transport has closed `success`, `failure` and `denied` arms. Success +contains the trusted original VM `typeof`, a primitive `payloadJson` string and, where needed, +a closed number tag. The null-prototype outer envelope contains only trusted primitive metadata +and that string, so its later serialization cannot execute an authored getter or `toJSON`. +The host checks VM type, calls `getString`, parses/strictly validates the private arm and decodes +the payload. Author values cannot select the outer arm. Malformed transport is a fixed sandbox +failure, never success or evidence of secrecy. + +The full transport's UTF-8 byte count is bounded by the evaluation's active `memoryBytes`, +with a cheap character-length precheck before bounded byte counting and host parsing. +Oversize transport raises a fixed non-retryable `memory` failure without truncation. Default +resource limits remain unchanged; no unbounded host object dump/parse is introduced. + +| Result | `condition` | `transform` / `merge_fn` | |---|---|---| | Boolean/string/finite number | Preserve exact value | Preserve exact value | -| Top-level NaN, ±Infinity, -0 | Preserve numeric value/sign with private nan/positive_infinity/negative_infinity/negative_zero tags | Same; later JSON persistence keeps its existing normalization | -| Object/array/null, including Map/Set | result_type failure | Existing JSON object semantics; Map/Set becomes {}; cycles fail | -| Boxed primitive or object whose toJSON becomes non-null scalar | result_type failure | Existing non_serializable failure; do not silently accept JSON-normalized scalar | -| Undefined/function/symbol/bigint | result_type failure | non_serializable failure | - -For object results, preserve nested omission/null/boxed behavior and the existing -null-result exception; successful JSON serialization alone does not override the -original VM type checks. Pin stateful toJSON call count and no second author -serialization, numeric tags and every result arm in real installed QuickJS tests. -For omitted optional secret inputs also pin Object.keys, own-property names and -descriptors, hasOwn/in, Reflect.ownKeys where supported, and direct undefined -comparison. A supplied secret must actually trigger denial; declared-secret text -references still refuse. Do not certify absence by enumerable keys alone. -Changes needed beyond this parity require an explicit decision, not an accidental -transport side effect. - -The [runner](agent-runner.md#proposed-wave-3-qualified-context-consumers) owns -producer migration; [tool provenance](tool-registry.md#proposed-wave-3-http-authority-and-secret-provenance) -records unsupported mapping separately. Actual engine/SQLite controls scan -run_events.payload_json, step_executions.output_json and runs.output_json with -successful clean persisted positives. Previously stored outputs/detail can contain -sensitive data and may be replayed; this change neither scrubs history nor certifies -legacy run.outputs secret-free. Update current at-rest/recovery guidance and three -stale scope/error comments during landing, without rewriting Accepted ADR history. +| Top-level NaN, ±Infinity, -0 | Preserve numeric value/sign using private tags | Same; later JSON persistence keeps existing normalization | +| Object/array/null, including Map/Set | `result_type` | Existing JSON object semantics; Map/Set → `{}`; cycles fail | +| Boxed primitive or object whose `toJSON` returns a non-null scalar | `result_type` | `non_serializable` | +| Undefined/function/symbol/bigint | `result_type` | `non_serializable` | + +Nested omission/null/boxed behavior and the existing null-result exception remain unchanged. +Successful JSON serialization alone does not override original VM type checks. A stateful +`toJSON` runs once, with no second authored serialization after the final latch check. + +The [tool provenance boundary](tool-registry.md#secret-argument-provenance) records the unsupported +mapping path separately. This projection does not rewrite history or certify replayed +`run.outputs` or private error detail as secret-free; the current +[at-rest/recovery guidance](database-schema.md#historical-output-and-error-data) owns that limit. diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index 5a57ec3d..b360ccd7 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -281,8 +281,9 @@ interface ToolDispatchContext { // defines the interface + the invocation point, the host supplies the implementation — ADR-0037-clean). readonly approval?: { readonly confirm?: ConfirmActionHook }; // 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). + // any present one from tool args (re-applying the parse-time taint gate on the effective set). + // No live authored mapping producer exists. Absent means unverified external provenance, not clean; + // see Secret argument provenance below before activating a future mapped path. 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 @@ -391,7 +392,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 | enforced at **plan build** when the host marks the grant complete (`BuildRunPlanOptions.toolGrantsFinal`), with `resolveGrant` as the dispatch floor for a directly-constructed executor ([ADR-0094](../../decisions/0094-a-tool-grant-is-checked-when-the-plan-is-built.md)); 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 **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 | +| secret args | Parser refusal covers declared secret references in text (transitive taint, 0029(c)); externally supplied `secretArgKeys` is checked on **top-level** effective-arg keys before approvals/effects (step 3). There is no live authored mapping producer; absent metadata is unverified. See [secret argument provenance](#secret-argument-provenance) for the future mapped-path obligation | host resolves an opaque `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 @@ -501,9 +502,11 @@ tool to the catalog never widens what a given node may call. An MCP or media too ## Proposed Wave 3 HTTP authority and secret provenance -**Approved design, unshipped — 2026-10-16.** [ADR-0112](../../decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) -and [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md) -were approved by the maintainer. Implementation remains staged in the Wave 3 plan. +**HTTP authority: approved design, unshipped — 2026-10-16.** +[ADR-0112](../../decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) +was approved by the maintainer; its HTTP implementation remains staged in the Wave 3 plan. +The separate [secret argument provenance](#secret-argument-provenance) section describes the +current boundary under ADR-0113. This combined heading remains for existing inbound links. HTTP policy compares canonical **hostname**, not URL host-with-port. Reuse the shared HTTPS authority parser and one pure DNS normalizer/comparator for both @@ -530,15 +533,20 @@ and pending hosts share it, so failed replacement cannot restore a grant. A request admitted before narrowing may finish. Workflow domains remain separately owned and are not switched to global chat policy. -At implementation landing correct both the dispatch-context secret-argument -comment and Guardrail enforcement's “1.O/1.V producer” claim. No live authored -tool-input mapping producer exists; agent/session config is empty. The optional -external `secretArgKeys` is unverified when absent, not a verified clean result. -Nonempty tainted effective arguments continue to refuse before approvals/effects. - -Any future mapped path must fail closed on absent provenance and project taint -after final model/mapping/config precedence to top-level keys, including nested -parents, before approval persistence or dispatch. Marker serialization cannot -clear provenance. This is a prerequisite for activating that feature, not a claim -that this wave implemented or exercised it. Actual expression-context repair and -#92's documented mapping limitation receive separate dispositions. +## Secret argument provenance + +**Current boundary — 2026-10-16.** +[ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md) +retains the optional external `secretArgKeys` interface. Nonempty tainted effective-argument +keys refuse before approvals/effects. Its absence is **unverified external provenance**, +not proof that effective arguments are clean. No live authored tool-input mapping producer +exists: current agent/session dispatches supply empty config, and model JSON has no +declared-input provenance. Neither this guard nor expression-context qualification detects +arbitrary credentials pasted into model/tool text. This is the documented #92 mapping +limitation, separate from the repaired [expression-context exposure](expression-sandbox-spec.md#secret-context-boundary). + +Activating any future mapped path requires fail-closed refusal of missing provenance and +projection of taint **after final model/mapping/config precedence** to top-level effective +argument keys, including parents of tainted nested paths, before approval persistence or +effect dispatch. Marker serialization cannot clear provenance. That is a prerequisite for +a future feature, not an implemented or exercised mapping producer in this wave. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 4577f0c8..fac24371 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4318,3 +4318,60 @@ Next is Step 3, ADR-0113 secret provenance and actual VM/public-template consume with a dedicated security sitting. No user decision or review finding remains pending for Step 2. Detailed reports and working controls remain Git-ignored; Step 19 retains whole-wave acceptance/publication. + + +### Wave 3 Step 3 author candidate — 2026-10-16 + +**Implemented; security and independent acceptance pending.** Core source and its +producer/VM/template regressions are committed at `8813391b`; the actual native +SQLite integration is `aba979f4`. ADR-0113 owns the explicit low-level projection +and direct secret-input expression compatibility change. The existing linear +source graph supplies immutable owned context/input authority to fresh execution, +same-process resume and cross-process budget preparation, with actual public +prompt/human and expression consumers. Trusted raw resolution and display markers +retain their separate contracts. No future authored tool-mapping producer is +invented; #92's mapping limitation remains distinct from the repaired exposure. + +The actual QuickJS suite preserves optional-secret absence, original key order, +clean result semantics, numeric tags and one authored serialization. A private +VM-owned closure separates evaluation and serialization native calls in the same +VM under the original deadline; it never exports an authored object. The first +focused run exposed a serialization-interrupt classification regression; the +corrected source preserves baseline result_type/non_serializable outcomes. The +finite exhausted-stack witness returns its clean fallback without a denial; it +carries no raw denied value. Ordinary executed/caught reads produce fixed +secret_context with no detail/cause. Canonical source/error comments and at-rest/ +recovery guidance explicitly retain historical/arbitrary content sensitivity. + +Author workspace lint/typecheck/tests pass: **597 files / 13,442 passing tests / +20 established skips** across six workspaces. Build passes all six tasks; +platform/vendor/dependency fences, isolation and changed-source formatting pass. +These are local author gates, not full CI/coverage/platform or whole-wave proof. +New focused ownership/public-template/VM/transport/producer/native-SQLite files +contribute 29/19/184/67/6/18 tests respectively. They include independently +successful clean execution/persistence and actual same/fresh-engine secret resupply. + +An isolated build of the exact Step 2 core at `873da1c34` reproduces the repaired +leak with real WorkflowEngine, standard executor, QuickJS and file-backed SQLite. +The clean positive completes with exact outputs and nonempty rows. A successful +ctx.alias result places the synthetic canary in public events and all three +required columns (run_events.payload_json, step_executions.output_json and +runs.output_json); a separate downstream human gate displays it. Cancellation of +that separate gate graph is not used to infer run-output leakage. No live product +source/dist was rolled back, and no provider/key/network call was made. + +Three local fixed/removal/restored cohorts pin context qualification, public +whole-template refusal and the private VM latch. Removing them causes **6 / 6 / +64 named assertion failures**, respectively; each fixed/restored inventory passes +29 / 19 / 184 tests and restores exact source bytes. The installed runner reporter +records zero unhandled errors in all nine final phases. The first exploratory +cohort attempt had a mismatched named-witness check; it was rejected and retained, +then the permanent assertion marker was made explicit before the final complete +cohorts. This is scoped author evidence, not independent review acceptance or a +replacement for Step 19's repeatable whole-wave publication. + +Dedicated secret-flow security sitting and two NEW independent complete cumulative +clean reviews after the last material correction remain required before Step 4. +Detailed baseline hashes, runtime identities, local probes and reports remain +Git-ignored under docs/reviews; no production credential, paid call, release, +archive upload or user decision is needed for this increment. diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index 63ec2481..8130cf4f 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -613,16 +613,21 @@ security invariants** a review must confirm are: exist *inside* the VM but are contained — they reach no host reference and no forbidden capability, so they are harmless; the guarantee rests on the isolation + capability removal, not on deleting every reflective handle to `Function`. -- **JSON-only marshaling, immutable global.** Scope crosses the boundary as plain JSON (host +- **JSON-only marshaling, immutable scope.** Scope crosses the boundary as plain JSON (host `stringify` → VM `parse`), so no live host object/getter/function leaks in and a `{"__proto__":…}` in model/tool-derived `run.outputs` cannot poison the prototype chain; each binding is installed non-writable/non-configurable over a deep-frozen value. -- **Deterministic within caps; caps are non-idempotent safety nets.** A successful evaluation is a - pure function of the scope (no clock/RNG/async). The wall-clock timeout, memory, and stack caps are - safety nets, not results — a trip surfaces as a typed `sandbox_error`, never a stable value. -- **Secret-free, scrubbed errors.** Secrets are never injected (ADR-0029(c) gate + an injection-time - filter); a `sandbox_error` message is the code + a generic string — never the expression source, a - variable name, a scope value, or a host stack. +- **Deterministic within caps; caps are non-idempotent safety nets.** No clock/RNG/async is + supplied. Uncaught resource failures surface as typed `sandbox_error`; a caught pre-accessor + stack failure can return a clean fallback without an observed denial. The + [finite denial contract](../reference/shared-core/expression-sandbox-spec.md#private-evaluation-and-finite-denial) + owns this installed-VM limit; do not claim universal attempted-read detection. +- **Declared-source exclusion and fixed public errors.** Owned provenance excludes newly + declared secret-input/context values before VM injection and preflights public templates. + Observed denied access uses the fixed private denial arm; ordinary `sandbox_error` messages + also exclude source/value/stack text. Private detail/cause and historical outputs can still + contain sensitive data and are not safe public/logging fields. Exact scope, transport and + historical-data limits belong to the [expression specification](../reference/shared-core/expression-sandbox-spec.md#secret-context-boundary). ## Prompt-injection posture From 2f3f3c714773832e7f27b991c6bdc8d05c8f5f22 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 04:10:25 +0300 Subject: [PATCH 034/222] fix(core): preserve expression parity and own context definitions Retain native diagnostic result/error conversion before the private denial check, validate empty public templates, and resolve the same context snapshot used by the taint authority. Pin real VM and engine regressions. Refs: ADR-0113 Co-Authored-By: Codex --- packages/core/src/engine/agent-runner.ts | 6 +- .../context-definition-ownership.test.ts | 266 ++++++++++++ .../engine/empty-template-provenance.test.ts | 339 +++++++++++++++ packages/core/src/engine/engine.ts | 4 +- .../src/engine/node-handlers/human-gate.ts | 6 +- .../core/src/expression/error-parity.test.ts | 396 ++++++++++++++++++ packages/core/src/expression/program.ts | 54 ++- packages/core/src/expression/sandbox.ts | 26 +- .../core/src/expression/transport.test.ts | 43 +- packages/core/src/expression/transport.ts | 12 +- packages/core/src/index.ts | 2 +- packages/core/src/interpolation/provenance.ts | 12 +- packages/core/src/interpolation/resolve.ts | 18 +- 13 files changed, 1146 insertions(+), 38 deletions(-) create mode 100644 packages/core/src/engine/context-definition-ownership.test.ts create mode 100644 packages/core/src/engine/empty-template-provenance.test.ts create mode 100644 packages/core/src/expression/error-parity.test.ts diff --git a/packages/core/src/engine/agent-runner.ts b/packages/core/src/engine/agent-runner.ts index d601a9d4..45088d4d 100644 --- a/packages/core/src/engine/agent-runner.ts +++ b/packages/core/src/engine/agent-runner.ts @@ -485,10 +485,8 @@ async function prepareAgentDispatch( if (!grant.ok) return failed('validation', grant.message, false); const grantedToolIds = new Set(grant.ids); - const prompt = - node.prompt_template === undefined - ? { ok: true as const, text: '' } - : await resolvePrompt(node.prompt_template, ctx, deps); + // Empty public text still crosses the required provenance boundary for low-level embeddings. + const prompt = await resolvePrompt(node.prompt_template ?? '', ctx, deps); if (!prompt.ok) return failed('validation', prompt.message, false); // Inline-vs-generative routing (1.AG Section C, ADR-0045 §1): a resolved model whose `media_surface` is diff --git a/packages/core/src/engine/context-definition-ownership.test.ts b/packages/core/src/engine/context-definition-ownership.test.ts new file mode 100644 index 00000000..59087114 --- /dev/null +++ b/packages/core/src/engine/context-definition-ownership.test.ts @@ -0,0 +1,266 @@ +/** ADR-0113: taint classification and raw resolution share one owned context definition snapshot. */ +import type { RunEvent, Workflow } from '@relavium/shared'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { createExpressionSandbox, type ExpressionSandbox } from '../expression/sandbox.js'; +import { createWorkflowProvenance } from '../interpolation/provenance.js'; +import { resolveContext } from '../interpolation/resolve.js'; +import { parseWorkflow } from '../parser.js'; +import { WorkflowEngine } from './engine.js'; +import { createInMemoryHost } from './execution-host.js'; +import { createStandardNodeExecutor } from './node-handlers/dispatcher.js'; +import type { RunHandle } from './run-handle.js'; + +const CANARY = 'ADR0113-CONTEXT-DEFINITION-PRIVATE-CANARY'; +const INPUTS = { token: CANARY, path: 'public-file' }; +let sandbox: ExpressionSandbox; +beforeAll(async () => { + sandbox = await createExpressionSandbox(); +}); + +type Read = 'clean' | 'denied'; + +function workflow(read: Read, options: { blocked?: boolean; gated?: boolean } = {}) { + return parseWorkflow( + JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'context-definition-ownership', + inputs: [ + { name: 'token', type: 'secret' }, + { name: 'path', type: 'string' }, + ], + context: [ + ...(options.blocked ? [{ key: 'doc', value: '{{ inputs.path | read_file }}' }] : []), + { key: 'clean', value: 'public' }, + { key: 'alias', value: '{{ inputs.token }}' }, + { key: 'nested', value: '{{ ctx.alias }}' }, + ], + nodes: [ + ...(options.gated + ? [ + { + id: 'gate', + type: 'human_gate', + gate_type: 'approval', + message_template: 'Review {{ ctx.clean }}', + }, + ] + : []), + { + id: 'result', + type: 'transform', + transform: + read === 'denied' + ? "(() => { try { return ctx.nested; } catch { return 'escaped'; } })()" + : `({ clean: ctx.clean, keys: Object.keys(ctx)${options.blocked ? ', doc: ctx.doc' : ''} })`, + }, + ], + edges: options.gated ? [{ from: 'gate', to: 'result' }] : [], + }, + }), + ); +} + +function definitions(wf: Workflow) { + const context = wf.workflow.context; + if (context === undefined) throw new Error('fixture context missing'); + return context; +} + +function entry(wf: Workflow, key: string) { + const found = definitions(wf).find((candidate) => candidate.key === key); + if (found === undefined) throw new Error(`fixture context entry ${key} missing`); + return found; +} + +async function drain(handle: RunHandle): Promise { + const events: RunEvent[] = []; + for await (const event of handle.events) events.push(event); + return events; +} + +function deferred() { + let settle: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + settle = resolve; + }); + return { + promise, + resolve(value: T) { + if (settle === undefined) throw new Error('deferred fixture was not initialized'); + settle(value); + }, + }; +} + +function assertOutcome(events: readonly RunEvent[], read: Read, blocked = false): void { + if (read === 'clean') { + expect(events.at(-1)?.type).toBe('run:completed'); + expect(events, 'WORKFLOW_CONTEXT_DEFINITION_OWNERSHIP').toContainEqual( + expect.objectContaining({ + type: 'node:completed', + nodeId: 'result', + output: { + clean: 'public', + keys: [...(blocked ? ['doc'] : []), 'clean', 'alias', 'nested'], + ...(blocked ? { doc: 'public-file-content' } : {}), + }, + }), + ); + } else { + expect(events.at(-1)?.type).toBe('run:failed'); + expect( + events.find((event) => event.type === 'node:failed' && event.nodeId === 'result'), + ).toMatchObject({ + type: 'node:failed', + nodeId: 'result', + error: { code: 'sandbox_error', retryable: false }, + }); + expect( + events.some((event) => event.type === 'node:completed' && event.nodeId === 'result'), + ).toBe(false); + } + expect(JSON.stringify(events)).not.toContain(CANARY); +} + +const mutations: { name: string; apply: (wf: Workflow) => void }[] = [ + { + name: 'clean value changed to a secret reference', + apply: (wf) => { + entry(wf, 'clean').value = '{{ inputs.token }}'; + }, + }, + { + name: 'secret value changed to a public literal', + apply: (wf) => { + entry(wf, 'alias').value = 'replacement-public'; + }, + }, + { + name: 'secret entry renamed to overwrite a clean key', + apply: (wf) => { + entry(wf, 'alias').key = 'clean'; + }, + }, + { + name: 'array appended with a secret overwrite of a clean key', + apply: (wf) => { + definitions(wf).push({ key: 'clean', value: '{{ inputs.token }}' }); + }, + }, + { + name: 'array reordered before its transitive dependency', + apply: (wf) => { + definitions(wf).reverse(); + }, + }, + { + name: 'array replaced with public definitions', + apply: (wf) => { + wf.workflow.context = [{ key: 'clean', value: 'replacement-public' }]; + }, + }, +]; + +describe('owned context definitions at actual engine consumers (ADR-0113)', () => { + describe.each(['clean', 'denied'])('%s expression', (read) => { + for (const { name, apply } of mutations) { + it(`ignores immediate caller mutation: ${name}`, async () => { + const wf = workflow(read); + const engine = new WorkflowEngine({ + host: createInMemoryHost(), + executor: createStandardNodeExecutor({ sandbox }), + }); + const handle = engine.start({ workflow: wf, inputs: INPUTS }); + apply(wf); + assertOutcome(await drain(handle), read); + }); + } + + it('ignores mutation of not-yet-resolved definitions while a context read is blocked', async () => { + const wf = workflow(read, { blocked: true }); + const entered = deferred(); + const release = deferred(); + const paths: string[] = []; + const engine = new WorkflowEngine({ + host: createInMemoryHost(), + executor: createStandardNodeExecutor({ sandbox }), + resolverCapabilities: { + readFile: (path) => { + paths.push(path); + entered.resolve(undefined); + return release.promise; + }, + }, + }); + const handle = engine.start({ workflow: wf, inputs: INPUTS }); + const events = drain(handle); + await entered.promise; + entry(wf, 'clean').value = '{{ inputs.token }}'; + entry(wf, 'alias').key = 'clean'; + entry(wf, 'nested').value = 'replacement-public'; + definitions(wf).push({ key: 'extra', value: '{{ inputs.token }}' }); + release.resolve('public-file-content'); + assertOutcome(await events, read, true); + expect(paths).toEqual(['public-file']); + }); + + it('retains the originally owned context definitions through same-process human resume', async () => { + const wf = workflow(read, { gated: true }); + const engine = new WorkflowEngine({ + host: createInMemoryHost(), + executor: createStandardNodeExecutor({ sandbox }), + }); + const handle = engine.start({ workflow: wf, inputs: INPUTS }); + const events: RunEvent[] = []; + let paused = false; + for await (const event of handle.events) { + events.push(event); + if (event.type === 'human_gate:paused') { + expect(event.message).toBe('Review public'); + entry(wf, 'clean').value = '{{ inputs.token }}'; + entry(wf, 'alias').key = 'clean'; + definitions(wf).reverse(); + paused = true; + await engine.resume(handle.runId, event.gateId, { + decision: 'approved', + decidedBy: 'context-ownership-test', + }); + } + } + expect(paused).toBe(true); + assertOutcome(events, read); + }); + }); + + it('owns frozen definitions for both derivation and resolution while the trusted raw API stays live', async () => { + const wf = workflow('clean'); + const original = definitions(wf); + const provenance = createWorkflowProvenance(wf); + const owned = provenance.contextSource.workflow.context; + expect(owned).not.toBe(original); + expect(owned?.[0]).not.toBe(original[0]); + expect(Object.isFrozen(provenance.contextSource)).toBe(true); + expect(Object.isFrozen(provenance.contextSource.workflow)).toBe(true); + expect(Object.isFrozen(owned)).toBe(true); + for (const definition of owned ?? []) expect(Object.isFrozen(definition)).toBe(true); + + entry(wf, 'clean').value = '{{ inputs.token }}'; + entry(wf, 'alias').value = 'replacement-public'; + original.push({ key: 'extra', value: 'added-public' }); + const resolved = await resolveContext(provenance.contextSource, INPUTS); + expect(resolved).toEqual({ clean: 'public', alias: CANARY, nested: CANARY }); + expect(provenance.qualifyContext(resolved)).toEqual({ + values: { clean: 'public' }, + deniedKeys: ['alias', 'nested'], + orderedKeys: ['clean', 'alias', 'nested'], + }); + expect(await resolveContext(wf, INPUTS)).toEqual({ + clean: CANARY, + alias: 'replacement-public', + nested: 'replacement-public', + extra: 'added-public', + }); + }); +}); diff --git a/packages/core/src/engine/empty-template-provenance.test.ts b/packages/core/src/engine/empty-template-provenance.test.ts new file mode 100644 index 00000000..e9170203 --- /dev/null +++ b/packages/core/src/engine/empty-template-provenance.test.ts @@ -0,0 +1,339 @@ +/** ADR-0113 / review R3: optional public text cannot make provenance optional. */ +import type { LlmProvider, LlmRequest, ModelPricing, StreamChunk } from '@relavium/llm'; +import type { RunEvent } from '@relavium/shared'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { createExpressionSandbox, type ExpressionSandbox } from '../expression/sandbox.js'; +import { + createWorkflowContextProjection, + ownSecretInputNames, +} from '../interpolation/provenance.js'; +import { parseWorkflow } from '../parser.js'; +import type { PlanVertex } from '../run-plan.js'; +import { createAgentNodeExecutor, type AgentRunnerDeps } from './agent-runner.js'; +import { WorkflowEngine } from './engine.js'; +import { createAbortController, createInMemoryHost } from './execution-host.js'; +import type { NodeExecContext, NodeExecutor } from './node-executor.js'; +import { createStandardNodeExecutor } from './node-handlers/dispatcher.js'; + +const PRIVATE = 'EMPTY-TEMPLATE-MALFORMED-PRIVATE-CANARY'; +const MODEL = 'offline-empty-template'; +const VALIDATION = { + kind: 'failed', + error: { + code: 'validation', + message: 'workflow context provenance is missing or invalid', + retryable: false, + }, +}; +const PRICES = new Map([ + [ + MODEL, + { + provider: 'openai', + nativeId: MODEL, + displayName: MODEL, + contextWindowTokens: 128_000, + maxOutputTokens: 1000, + inputPerMtokMicrocents: 1_000_000, + outputPerMtokMicrocents: 1_000_000, + cachedInputPerMtokMicrocents: 0, + }, + ], +]); + +let sandbox: ExpressionSandbox; +beforeAll(async () => { + sandbox = await createExpressionSandbox(); +}); + +function vertex(kind: 'agent' | 'gate', explicitEmpty = false): PlanVertex { + return { + id: kind, + type: kind === 'agent' ? 'agent' : 'human_in_the_loop', + dependencies: [], + dependents: [], + ancestors: [], + inputSites: [], + config: + kind === 'agent' + ? { + kind: 'agent', + node: { + id: kind, + type: 'agent', + agent_ref: 'offline', + max_tokens: 16, + ...(explicitEmpty ? { prompt_template: '' } : {}), + }, + resolvedAgent: { + id: 'offline', + provider: 'openai', + model: MODEL, + system_prompt: 'Public system instruction', + }, + } + : { + kind: 'human_in_the_loop', + node: { + id: kind, + type: 'human_gate', + gate_type: 'approval', + ...(explicitEmpty ? { message_template: '' } : {}), + }, + }, + }; +} + +function context(planVertex: PlanVertex): NodeExecContext { + return { + vertex: planVertex, + inputs: {}, + runOutputs: new Map(), + ctx: createWorkflowContextProjection({ values: {}, deniedKeys: [], orderedKeys: [] }), + secretInputNames: ownSecretInputNames([]).secretInputNames, + toolPolicy: {}, + emit: () => undefined, + signal: createAbortController().signal, + attemptNumber: 1, + }; +} + +// These are unmigrated runtime embeddings, not valid TypeScript producers. Mutate only the +// embedding's own context copy so the actual public consumer has to validate the boundary. +const INVALID_METADATA = [ + { + name: 'missing context projection', + corrupt: (ctx: NodeExecContext) => Reflect.deleteProperty(ctx, 'ctx'), + }, + { + name: 'malformed context projection', + corrupt: (ctx: NodeExecContext) => + Reflect.set(ctx, 'ctx', { values: { private: PRIVATE }, deniedKeys: [], orderedKeys: [] }), + }, + { + name: 'missing secret-input membership', + corrupt: (ctx: NodeExecContext) => Reflect.deleteProperty(ctx, 'secretInputNames'), + }, +]; + +function fixture() { + const requests: LlmRequest[] = []; + let keyReads = 0; + const provider: LlmProvider = { + id: 'openai', + supports: { + tools: false, + streaming: true, + parallelToolCalls: false, + vision: false, + promptCache: false, + reasoning: false, + media: { + input: { image: false, audio: false, video: false, document: false }, + outputCombinations: [], + }, + }, + generate: () => { + throw new Error('offline fixture expects streaming'); + }, + async *stream(request): AsyncGenerator { + requests.push(request); + await Promise.resolve(); + yield { type: 'text_delta', text: 'public answer' }; + yield { + type: 'stop', + stopReason: 'stop', + usage: { kind: 'complete', usage: { inputTokens: 1, outputTokens: 1 } }, + }; + }, + }; + const deps: AgentRunnerDeps = { + resolveProvider: () => provider, + keyFor: () => { + keyReads += 1; + return 'synthetic-offline-key'; + }, + sleep: () => Promise.resolve(), + tools: [], + resolvePrice: PRICES, + registry: { + has: () => false, + list: () => [], + dispatch: () => Promise.reject(new Error('offline fixture advertises no tools')), + }, + }; + return { + standard: createStandardNodeExecutor({ sandbox, agent: deps }), + agent: createAgentNodeExecutor(deps), + requests, + keyReads: () => keyReads, + }; +} + +describe('empty public templates retain the required provenance boundary (ADR-0113 / R3)', () => { + for (const explicitEmpty of [false, true]) { + const text = explicitEmpty ? 'explicit empty text' : 'absent optional text'; + + it.each(INVALID_METADATA)( + `standard human gate refuses $name with ${text}`, + async ({ corrupt }) => { + const run = fixture(); + const ctx = context(vertex('gate', explicitEmpty)); + expect(corrupt(ctx)).toBe(true); + expect(await run.standard.execute(ctx)).toEqual(VALIDATION); + expect(run.requests).toEqual([]); + expect(run.keyReads()).toBe(0); + }, + ); + + for (const executorName of ['agent', 'standard'] as const) { + it.each(INVALID_METADATA)( + `${executorName} execution refuses $name with ${text} before provider invocation`, + async ({ corrupt }) => { + const run = fixture(); + const ctx = context(vertex('agent', explicitEmpty)); + expect(corrupt(ctx)).toBe(true); + expect(await run[executorName].execute(ctx)).toEqual(VALIDATION); + expect(run.requests).toEqual([]); + expect(run.keyReads()).toBe(0); + }, + ); + + it.each(INVALID_METADATA)( + `${executorName} preparation refuses $name with ${text} without producing a quotable dispatch`, + async ({ corrupt }) => { + const run = fixture(); + const ctx = context(vertex('agent', explicitEmpty)); + expect(corrupt(ctx)).toBe(true); + const prepare = run[executorName].prepareBudgetDispatch?.bind(run[executorName]); + if (prepare === undefined) throw new Error('agent preparation is required'); + expect(await prepare(ctx)).toEqual(VALIDATION); + expect(run.requests).toEqual([]); + expect(run.keyReads()).toBe(0); + }, + ); + } + + it(`valid explicit empty projection permits a standard human gate with ${text}`, async () => { + const run = fixture(); + const outcome = await run.standard.execute(context(vertex('gate', explicitEmpty))); + expect(outcome).toEqual({ kind: 'paused', gate: { gateType: 'approval', message: '' } }); + if (outcome.kind !== 'paused') throw new Error('expected human gate'); + expect(Object.hasOwn(outcome.gate, 'assignee')).toBe(false); + }); + + it(`valid explicit empty projection permits execution and preparation with ${text}`, async () => { + const run = fixture(); + const ctx = context(vertex('agent', explicitEmpty)); + const prepare = run.standard.prepareBudgetDispatch?.bind(run.standard); + if (prepare === undefined) throw new Error('standard preparation is required'); + const prepared = await prepare(ctx); + if (prepared.kind !== 'prepared') throw new Error('expected empty-text preparation'); + expect(prepared.preparation.quote({ strictCostCap: false, resolvePrice: PRICES }).kind).toBe( + 'quoted', + ); + expect(run.requests).toEqual([]); + expect(run.keyReads()).toBe(0); + expect(await prepared.preparation.execute(ctx)).toMatchObject({ + kind: 'completed', + output: 'public answer', + }); + expect(await run.agent.execute(context(vertex('agent', explicitEmpty)))).toMatchObject({ + kind: 'completed', + output: 'public answer', + }); + expect(run.requests).toHaveLength(2); + expect(run.requests.map((request) => request.messages)).toEqual([[], []]); + }); + } + + it('retains an explicitly authored empty assignee', async () => { + const planVertex = vertex('gate'); + if (planVertex.config.kind !== 'human_in_the_loop') throw new Error('expected gate vertex'); + const withAssignee: PlanVertex = { + ...planVertex, + config: { + ...planVertex.config, + node: { ...planVertex.config.node, assignee: '' }, + }, + }; + expect(await fixture().standard.execute(context(withAssignee))).toEqual({ + kind: 'paused', + gate: { gateType: 'approval', message: '', assignee: '' }, + }); + }); + + it('preserves pre-aborted gate cancellation even when provenance is missing', async () => { + const run = fixture(); + const ctx = context(vertex('gate')); + const abort = createAbortController(); + abort.abort(); + Reflect.set(ctx, 'signal', abort.signal); + Reflect.deleteProperty(ctx, 'ctx'); + expect(await run.standard.execute(ctx)).toMatchObject({ + kind: 'failed', + error: { code: 'cancelled', retryable: false }, + }); + expect(run.requests).toEqual([]); + expect(run.keyReads()).toBe(0); + }); + + it('preserves pre-aborted agent execution after preparation without quoting or provider invocation', async () => { + const run = fixture(); + const ctx = context(vertex('agent')); + const abort = createAbortController(); + abort.abort(); + Reflect.set(ctx, 'signal', abort.signal); + const prepare = run.agent.prepareBudgetDispatch?.bind(run.agent); + if (prepare === undefined) throw new Error('agent preparation is required'); + // Preparation historically lowers a request even when already aborted. This correction + // preserves that contract; cancellation is enforced when executing, without calling quote. + const prepared = await prepare(ctx); + if (prepared.kind !== 'prepared') throw new Error('expected existing preparation behavior'); + expect(await prepared.preparation.execute(ctx)).toMatchObject({ + kind: 'failed', + error: { code: 'cancelled', retryable: false }, + }); + expect(await run.agent.execute(ctx)).toMatchObject({ + kind: 'failed', + error: { code: 'cancelled', retryable: false }, + }); + expect(run.requests).toEqual([]); + expect(run.keyReads()).toBe(0); + }); + + it('fails the real engine instead of publishing a template-free gate when an embedding drops ctx', async () => { + const run = fixture(); + const executor: NodeExecutor = { + execute: (ctx) => { + const missing = { ...ctx }; + expect(Reflect.deleteProperty(missing, 'ctx')).toBe(true); + return run.standard.execute(missing); + }, + }; + const engine = new WorkflowEngine({ host: createInMemoryHost(), executor }); + const workflow = parseWorkflow( + JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'empty-template-provenance', + nodes: [{ id: 'gate', type: 'human_gate', gate_type: 'approval' }], + edges: [], + }, + }), + ); + const handle = engine.start({ workflow }); + const events: RunEvent[] = []; + for await (const event of handle.events) { + events.push(event); + // A regressed implementation parks indefinitely; cancellation is cleanup only. + if (event.type === 'human_gate:paused') handle.cancel(); + } + expect(events.some((event) => event.type === 'human_gate:paused')).toBe(false); + const failure = events.find((event) => event.type === 'node:failed'); + expect(failure?.type === 'node:failed' && failure.error).toMatchObject(VALIDATION.error); + expect(events.at(-1)?.type).toBe('run:failed'); + expect(run.requests).toEqual([]); + }); +}); diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index 292f0d3b..dfe59476 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -1381,7 +1381,7 @@ class RunExecution { async #resolveContextOrFail(): Promise { try { const resolved = await resolveContext( - this.#workflow, + this.#provenance.contextSource, this.#inputs, this.#resolverCapabilities, this.#abort.signal, @@ -6258,7 +6258,7 @@ export class WorkflowEngine { try { const provenance = createWorkflowProvenance(input.workflow); const resolved = await resolveContext( - input.workflow, + provenance.contextSource, inputs, this.#resolverCapabilities, signal, diff --git a/packages/core/src/engine/node-handlers/human-gate.ts b/packages/core/src/engine/node-handlers/human-gate.ts index 6776c0a7..2d451733 100644 --- a/packages/core/src/engine/node-handlers/human-gate.ts +++ b/packages/core/src/engine/node-handlers/human-gate.ts @@ -45,10 +45,8 @@ async function runHumanGate( let message: string; let assignee: string | undefined; try { - message = - node.message_template === undefined - ? '' - : await resolvePublicTemplate(node.message_template, scope, caps, ctx.signal); + // An absent template does not waive required execution metadata. + message = await resolvePublicTemplate(node.message_template ?? '', scope, caps, ctx.signal); assignee = node.assignee === undefined ? undefined diff --git a/packages/core/src/expression/error-parity.test.ts b/packages/core/src/expression/error-parity.test.ts new file mode 100644 index 00000000..0e888783 --- /dev/null +++ b/packages/core/src/expression/error-parity.test.ts @@ -0,0 +1,396 @@ +/** ADR-0113: retain installed-bridge value/error semantics inside the private VM transport. */ +import { beforeAll, describe, expect, it } from 'vitest'; + +import { SandboxError } from '../errors.js'; +import { + createWorkflowContextProjection, + ownSecretInputNames, +} from '../interpolation/provenance.js'; +import { + createExpressionSandbox, + type ExpressionKind, + type ExpressionSandbox, + type ExpressionScope, +} from './sandbox.js'; + +const kinds: ExpressionKind[] = ['condition', 'transform', 'merge_fn']; +const stackString: unknown = expect.any(String); +let sandbox: ExpressionSandbox; +beforeAll(async () => { + sandbox = await createExpressionSandbox(); +}); + +function cleanScope(): ExpressionScope { + return { + inputs: { public: 'available' }, + ctx: createWorkflowContextProjection({ + values: { public: 'clean' }, + deniedKeys: [], + orderedKeys: ['public'], + }), + deniedInputNames: ownSecretInputNames([]).deniedInputNames, + outputs: {}, + branches: [], + }; +} + +function deniedScope(): ExpressionScope { + return { + ...cleanScope(), + inputs: { public: 'available', credential: 'withheld-before-VM-intake' }, + ctx: createWorkflowContextProjection({ + values: { public: 'clean' }, + deniedKeys: ['derived'], + orderedKeys: ['public', 'derived'], + }), + deniedInputNames: ownSecretInputNames(['credential']).deniedInputNames, + }; +} + +function failure( + expression: string, + kind: ExpressionKind, + scope: ExpressionScope = cleanScope(), +): SandboxError { + let caught: unknown; + try { + sandbox.evaluate({ expression, kind, scope }); + } catch (error) { + caught = error; + } + expect(caught, 'the actual installed VM must produce a classified refusal').toBeInstanceOf( + SandboxError, + ); + if (!(caught instanceof SandboxError)) throw new Error('expected a SandboxError'); + return caught; +} + +interface ReturnedCase { + readonly label: string; + readonly expression: string; + readonly expected: unknown; +} + +// Values were characterized against the accepted bridge. Stack text embeds wrapper locations; +// only its presence/type is compatible across the old and private wrappers. +const returnedCases: ReturnedCase[] = [ + { + label: 'Error metadata', + expression: 'new Error("clean")', + expected: { name: 'Error', message: 'clean', stack: stackString }, + }, + { + label: 'TypeError and enumerable own data', + expression: 'Object.assign(new TypeError("clean diagnostic"), {info:42})', + expected: { + info: 42, + name: 'TypeError', + message: 'clean diagnostic', + stack: stackString, + }, + }, + { + label: 'stateful Error toJSON called once with supplemental native fields', + expression: + '(() => {let calls=0;const e=new Error("clean");e.toJSON=function(){calls++;if(calls>1)throw new Error("second serialization");return {calls};};return e;})()', + expected: { calls: 1, name: 'Error', message: 'clean', stack: stackString }, + }, + { + label: 'toJSON fields take precedence over supplemental fields', + expression: + '(() => {const e=new Error("clean");e.toJSON=()=>({name:"Custom",message:"custom",stack:"custom-stack",fileName:"own",lineNumber:5,info:42});return e;})()', + expected: { + name: 'Custom', + message: 'custom', + stack: 'custom-stack', + fileName: 'own', + lineNumber: 5, + info: 42, + }, + }, + { + label: 'present falsy toJSON metadata remains present', + expression: + '(() => {const e=new Error("clean");e.toJSON=()=>({name:null,message:false,stack:0,fileName:null,lineNumber:null});return e;})()', + expected: { name: null, message: false, stack: 0, fileName: null, lineNumber: null }, + }, + { + label: 'deleted Error metadata falls back to inherited values', + expression: '(() => {const e=new Error("clean");delete e.message;delete e.stack;return e;})()', + expected: { name: 'Error', message: '' }, + }, + { + label: 'undefined metadata is omitted', + expression: + '(() => {const e=new Error("clean");e.name=undefined;e.message=undefined;e.stack=undefined;return e;})()', + expected: {}, + }, + { + label: 'nonenumerable special fields are hydrated', + expression: + '(() => {const e=new Error("clean");Object.defineProperty(e,"name",{value:"Special"});Object.defineProperty(e,"fileName",{value:"clean.js"});Object.defineProperty(e,"lineNumber",{value:7});return e;})()', + expected: { + name: 'Special', + message: 'clean', + stack: stackString, + fileName: 'clean.js', + lineNumber: 7, + }, + }, + { + label: 'inherited ordinary-object metadata is hydrated', + expression: 'Object.create({name:"SyntaxError",message:"inherited"})', + expected: { name: 'SyntaxError', message: 'inherited' }, + }, + { + label: 'nonenumerable ordinary-object metadata is hydrated', + expression: + '(() => {const e={};Object.defineProperty(e,"name",{value:"SyntaxError"});Object.defineProperty(e,"message",{value:"hidden"});return e;})()', + expected: { name: 'SyntaxError', message: 'hidden' }, + }, + { + label: 'a failed special-field getter does not discard other Error fields', + expression: + '(() => {const e=new Error("clean");Object.defineProperty(e,"name",{get(){throw 42;}});return e;})()', + expected: { message: 'clean', stack: stackString }, + }, + { + label: 'existing serialized field avoids a failing supplemental getter', + expression: + '(() => {const e=new Error("clean");e.toJSON=()=>({name:"kept"});Object.defineProperty(e,"name",{get(){throw 42;}});return e;})()', + expected: { name: 'kept', message: 'clean', stack: stackString }, + }, + { + label: 'Error toJSON null remains null', + expression: 'Object.assign(new Error("clean"), {toJSON(){return null;}})', + expected: null, + }, + { + label: 'nested Errors keep ordinary JSON semantics', + expression: '({nested:new Error("nested"),clean:ctx.public,input:inputs.public})', + expected: { nested: {}, clean: 'clean', input: 'available' }, + }, + { + label: 'ordinary clean stateful toJSON remains single-call', + expression: + '(() => {let calls=0;return {toJSON(){calls++;if(calls>1)throw new Error("second serialization");return {calls,clean:ctx.public};}};})()', + expected: { calls: 1, clean: 'clean' }, + }, +]; + +describe.each(kinds)('%s top-level Error result parity in actual QuickJS', (kind) => { + it.each(returnedCases)('$label', ({ expression, expected }) => { + if (kind === 'condition') expect(failure(expression, kind).reason).toBe('result_type'); + else { + const result = sandbox.evaluate({ expression, kind, scope: cleanScope() }); + expect(result, 'NATIVE_RESULT_PROJECTION_PARITY').toEqual(expected); + if ( + expected !== null && + typeof expected === 'object' && + Reflect.get(expected, 'stack') === stackString + ) { + expect(result).not.toBeNull(); + if (result === null || typeof result !== 'object') throw new Error('expected an object'); + expect(typeof Reflect.get(result, 'stack')).toBe('string'); + } + } + }); + + it.each([ + 'Object(2n)', + 'Object.assign(new Error("clean"),{toJSON(){return "scalar";}})', + 'Object.assign(new Error("clean"),{toJSON(){return undefined;}})', + 'Object.assign(new Error("clean"),{toJSON(){throw 42;}})', + '(() => {const e=new Error("clean");e.self=e;return e;})()', + ])('retains unsuccessful result conversion: %s', (expression) => { + expect(failure(expression, kind).reason).toBe( + kind === 'condition' ? 'result_type' : 'non_serializable', + ); + }); +}); + +interface ThrownCase { + readonly label: string; + readonly value: string; + readonly reason: SandboxError['reason']; + readonly detail: string; +} + +const thrownCases: ThrownCase[] = [ + { + label: 'serialized fields replace original resource-shaped metadata', + value: + '{name:"InternalError",message:"stack overflow",toJSON(){return {name:"Error",message:"old"};}}', + reason: 'runtime', + detail: 'Error: old', + }, + { + label: 'toJSON supplies syntax classification', + value: '{toJSON(){return {name:"SyntaxError",message:"x"};}}', + reason: 'syntax', + detail: 'SyntaxError: x', + }, + { + label: 'toJSON supplies resource classification', + value: '{toJSON(){return {name:"InternalError",message:"out of memory"};}}', + reason: 'memory', + detail: 'InternalError: out of memory', + }, + { + label: 'Error subclass', + value: 'new TypeError("clean type")', + reason: 'runtime', + detail: 'TypeError: clean type', + }, + { + label: 'Error toJSON supplies different metadata once', + value: + '(() => {let calls=0;const e=new Error("clean");e.toJSON=function(){calls++;if(calls>1)throw 42;return {name:"SyntaxError",message:"calls="+calls};};return e;})()', + reason: 'syntax', + detail: 'SyntaxError: calls=1', + }, + { + label: 'original Error resource-shaped fields', + value: 'Object.assign(new Error("clean"),{name:"InternalError",message:"stack overflow"})', + reason: 'stack', + detail: 'InternalError: stack overflow', + }, + { + label: 'failed ordinary serialization uses fallback text', + value: '{name:"SyntaxError",message:"z",toJSON(){throw 42;}}', + reason: 'runtime', + detail: '[object Object]', + }, + { + label: 'null serialization does not retain original metadata', + value: '{name:"SyntaxError",message:"z",toJSON(){return null;}}', + reason: 'runtime', + detail: 'null', + }, + { + label: 'ordinary cycle uses fallback text', + value: '(() => {const e={name:"SyntaxError",message:"cycle"};e.self=e;return e;})()', + reason: 'runtime', + detail: '[object Object]', + }, + { + label: 'cyclic native Error keeps native thrown-error metadata', + value: '(() => {const e=new Error("clean");e.self=e;return e;})()', + reason: 'runtime', + detail: 'Error: clean', + }, + { + label: 'inherited ordinary metadata', + value: 'Object.create({name:"SyntaxError",message:"inherited"})', + reason: 'syntax', + detail: 'SyntaxError: inherited', + }, + { + label: 'nonenumerable ordinary metadata', + value: + '(() => {const e={};Object.defineProperty(e,"name",{value:"SyntaxError"});Object.defineProperty(e,"message",{value:"hidden"});return e;})()', + reason: 'syntax', + detail: 'SyntaxError: hidden', + }, + { + label: 'missing name', + value: '{message:"message only"}', + reason: 'runtime', + detail: 'message only', + }, + { + label: 'missing message', + value: '{name:"SyntaxError"}', + reason: 'syntax', + detail: 'SyntaxError', + }, + { + label: 'string serialization', + value: '{toJSON(){return "text";}}', + reason: 'runtime', + detail: 'text', + }, + { + label: 'number serialization', + value: '{toJSON(){return 42;}}', + reason: 'runtime', + detail: '42', + }, + { + label: 'boolean serialization', + value: '{toJSON(){return false;}}', + reason: 'runtime', + detail: 'false', + }, + { + label: 'undefined serialization uses fallback text', + value: '{toJSON(){return undefined;}}', + reason: 'runtime', + detail: '[object Object]', + }, + { label: 'number primitive', value: '42', reason: 'runtime', detail: '42' }, + { label: 'NaN primitive', value: 'NaN', reason: 'runtime', detail: 'NaN' }, + { label: 'positive infinity', value: 'Infinity', reason: 'runtime', detail: 'Infinity' }, + { label: 'negative infinity', value: '-Infinity', reason: 'runtime', detail: '-Infinity' }, + { label: 'null primitive', value: 'null', reason: 'runtime', detail: 'null' }, + { label: 'boolean primitive', value: 'true', reason: 'runtime', detail: 'true' }, + { label: 'undefined primitive', value: 'undefined', reason: 'runtime', detail: 'undefined' }, + { label: 'string primitive', value: '"plain"', reason: 'runtime', detail: 'plain' }, + { label: 'symbol primitive', value: 'Symbol("S")', reason: 'runtime', detail: 'Symbol(S)' }, + { label: 'bigint primitive', value: '2n', reason: 'runtime', detail: '2' }, + { label: 'boxed string', value: 'new String("S")', reason: 'runtime', detail: 'S' }, + { label: 'boxed number', value: 'new Number(2)', reason: 'runtime', detail: '2' }, + { label: 'boxed boolean', value: 'new Boolean(false)', reason: 'runtime', detail: 'false' }, +]; + +describe.each(kinds)('%s ordinary thrown-value parity in actual QuickJS', (kind) => { + it.each(thrownCases)('$label', ({ value, reason, detail }) => { + const error = failure(`(() => {throw (${value});})()`, kind); + expect(error, 'NATIVE_THROWN_CLASSIFICATION_PARITY').toMatchObject({ + code: 'sandbox_error', + reason, + retryable: false, + detail, + }); + expect(error.cause).toBeUndefined(); + }); +}); + +describe.each(kinds)('%s denial wins during error conversion', (kind) => { + const reads = ['ctx.derived', 'inputs.credential']; + for (const read of reads) { + const swallowed = `try {${read};} catch {}`; + it.each([ + `(() => {throw {get name(){${swallowed}return "SyntaxError";},message:"authored"};})()`, + `(() => {throw {name:"SyntaxError",get message(){${swallowed}return "authored";}};})()`, + `(() => {throw {toJSON(){${swallowed}return {name:"SyntaxError",message:"authored"};}};})()`, + `(() => {const e=new Error("authored");e.toJSON=function(){${swallowed}return {name:"SyntaxError"};};throw e;})()`, + `(() => {const e=new Error("authored");e.toJSON=function(){${swallowed}return {ok:true};};return e;})()`, + ...['name', 'message', 'stack', 'fileName', 'lineNumber'].map( + (field) => + `(() => {const e=new Error("authored");Object.defineProperty(e,"${field}",{get(){${swallowed}return "authored";}});return e;})()`, + ), + ])(`observes ${read} even when conversion catches it: %s`, (expression) => { + const error = failure(expression, kind, deniedScope()); + expect(error).toMatchObject({ + code: 'sandbox_error', + reason: 'secret_context', + retryable: false, + message: 'secret-derived values are unavailable to expressions', + }); + expect(error.detail).toBeUndefined(); + expect(error.cause).toBeUndefined(); + expect(JSON.stringify(error)).not.toContain('withheld-before-VM-intake'); + }); + } + + it('keeps independent clean success available alongside denied names', () => { + const expression = + kind === 'condition' + ? 'ctx.public === "clean" && inputs.public === "available"' + : '({context:ctx.public,input:inputs.public})'; + expect(sandbox.evaluate({ expression, kind, scope: deniedScope() })).toEqual( + kind === 'condition' ? true : { context: 'clean', input: 'available' }, + ); + }); +}); diff --git a/packages/core/src/expression/program.ts b/packages/core/src/expression/program.ts index 41f3f596..a215411e 100644 --- a/packages/core/src/expression/program.ts +++ b/packages/core/src/expression/program.ts @@ -51,8 +51,10 @@ export function buildExpressionProgram(expression: string, scope: ExpressionScop 'if (typeof Math.random === "function") { Math.random=function(){throw new RangeError("Math.random is disabled in the expression sandbox");}; }', 'Object.freeze(Math);', '(function () {', - ' const stringify=JSON.stringify, parse=JSON.parse, make=Object.create, freeze=Object.freeze, asString=String;', + ' const stringify=JSON.stringify, parse=JSON.parse, make=Object.create, freeze=Object.freeze, asString=String, setPrototypeOf=Object.setPrototypeOf;', ' const keys=Object.keys, define=Object.defineProperty, isArray=Array.isArray, isNaN=Number.isNaN, is=Object.is;', + ' const apply=Reflect.apply, numberValue=Number.prototype.valueOf, booleanValue=Boolean.prototype.valueOf, stringValue=String.prototype.valueOf;', + ' const bigintValue=typeof BigInt==="function"?BigInt.prototype.valueOf:undefined;', ` const invoke=Function("__public", ${JSON.stringify(body)});`, ` const data=parse(${JSON.stringify(scopeJson)});`, ' let denied=false;', @@ -69,30 +71,64 @@ export function buildExpressionProgram(expression: string, scope: ExpressionScop ' for(let i=0;i");}', + ' }', ' if(!failed){', ' try {', ' if(originalType==="number" && (isNaN(value) || value===1/0 || value===-1/0 || is(value,-0))){', ' numberTag=isNaN(value)?"nan":value===1/0?"positive_infinity":value===-1/0?"negative_infinity":"negative_zero";payloadJson="null";', ' } else if(originalType==="undefined" || originalType==="function" || originalType==="symbol" || originalType==="bigint")payloadJson="null";', - ' else {payloadJson=stringify(value);if(typeof payloadJson!=="string")serialFailed=true;}', + ' else {payloadJson=snapshot(value);if(typeof payloadJson!=="string")serialFailed=true;}', ' } catch(error){serialFailed=true;}', ' }', // All authored execution/serialization is over. Outer data is null-prototype and primitives only. ' const result=make(null);result.version=1;', ' if(denied)result.kind="denied";', - ' else if(failed){result.kind="failure";result.stage="evaluation";result.name=name;result.message=message;}', + ' else if(failed){result.kind="failure";result.stage="evaluation";result.payloadJson=errorJson;}', ' else if(serialFailed){result.kind="failure";result.stage="serialization";result.originalType=originalType;}', ' else {result.kind="success";result.originalType=originalType;result.payloadJson=payloadJson;if(numberTag!==undefined)result.numberTag=numberTag;}', ' return stringify(result);', ' };', + ' define(finish,"evaluationFailed",{value:failed});return freeze(finish);', '})()', ].join('\n'); } diff --git a/packages/core/src/expression/sandbox.ts b/packages/core/src/expression/sandbox.ts index f0bf5b4b..1df526cd 100644 --- a/packages/core/src/expression/sandbox.ts +++ b/packages/core/src/expression/sandbox.ts @@ -41,7 +41,11 @@ import { import { SandboxError, WorkflowContextError } from '../errors.js'; import type { WorkflowContextProjection } from '../interpolation/provenance.js'; import { buildExpressionProgram } from './program.js'; -import { readPrivateExpressionTransport, decodeExpressionPayload } from './transport.js'; +import { + readPrivateExpressionTransport, + decodeExpressionPayload, + decodeThrownPayload, +} from './transport.js'; /** Which authored construct the expression came from — drives the per-kind result contract. */ export type ExpressionKind = 'condition' | 'transform' | 'merge_fn'; @@ -226,9 +230,24 @@ function runProgram( 'runtime', 'the expression returned invalid private result transport', ); + const failedTag = context.getProp(serializationClosure, 'evaluationFailed'); + let evaluationFailed: boolean; + try { + if (context.typeof(failedTag) !== 'boolean') + throw new SandboxError( + 'runtime', + 'the expression returned invalid private result transport', + ); + evaluationFailed = context.eq(failedTag, context.true); + } finally { + failedTag.dispose(); + } const serialized = context.callFunction(serializationClosure, context.undefined); if (serialized.error) { serialized.error.dispose(); + // A failed thrown-value conversion remains an ordinary runtime fault. Its VM-owned tag + // cannot be selected by the authored value and grants no extra serialization time. + if (evaluationFailed) throw classifyError('', false); throw serializationFailure(kind); } result = serialized; @@ -252,10 +271,7 @@ function runProgram( ); if (transport.kind === 'failure') { if (transport.stage === 'evaluation') - throw classifyError( - { name: transport.name, message: transport.message }, - Date.now() >= deadline, - ); + throw classifyError(decodeThrownPayload(transport.payloadJson), Date.now() >= deadline); // Preserve original dump/validateResult disposition even when authored serialization throws. throw serializationFailure(kind); } diff --git a/packages/core/src/expression/transport.test.ts b/packages/core/src/expression/transport.test.ts index ef43973d..44f7df93 100644 --- a/packages/core/src/expression/transport.test.ts +++ b/packages/core/src/expression/transport.test.ts @@ -4,7 +4,11 @@ import { Buffer } from 'node:buffer'; import { describe, expect, it } from 'vitest'; import { SandboxError } from '../errors.js'; -import { decodeExpressionPayload, readPrivateExpressionTransport } from './transport.js'; +import { + decodeExpressionPayload, + decodeThrownPayload, + readPrivateExpressionTransport, +} from './transport.js'; const MEMORY = 16 * 1024 * 1024; const CANARY = 'hostile-transport-canary'; @@ -42,14 +46,47 @@ function expectFailure( } describe('closed private transport arms', () => { + it.each<[string, unknown]>([ + ['{"name":"SyntaxError","message":"clean"}', { name: 'SyntaxError', message: 'clean' }], + ['"primitive failure"', 'primitive failure'], + ['null', null], + ['42', 42], + ])('decodes already-bounded thrown payload %s', (payload, expected) => { + const arm = readPrivateExpressionTransport( + JSON.stringify({ version: 1, kind: 'failure', stage: 'evaluation', payloadJson: payload }), + MEMORY, + ); + expect(arm.kind).toBe('failure'); + if (arm.kind !== 'failure' || arm.stage !== 'evaluation') + throw new Error('missing evaluation failure arm'); + expect(decodeThrownPayload(arm.payloadJson)).toEqual(expected); + }); + + it('refuses malformed thrown JSON without echoing content', () => { + let caught: unknown; + try { + decodeThrownPayload(CANARY); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(SandboxError); + if (!(caught instanceof SandboxError)) throw caught; + expect(caught).toMatchObject({ + code: 'sandbox_error', + reason: 'runtime', + message: 'the expression returned invalid private result transport', + }); + expect(caught.detail).toBeUndefined(); + expect(caught.cause).toBeUndefined(); + }); + it.each([ { version: 1, kind: 'denied' }, { version: 1, kind: 'failure', stage: 'evaluation', - name: 'TypeError', - message: 'authored message', + payloadJson: '{"name":"TypeError","message":"authored message"}', }, { version: 1, kind: 'failure', stage: 'serialization', originalType: 'object' }, ])('accepts the exact $kind arm', (arm) => { diff --git a/packages/core/src/expression/transport.ts b/packages/core/src/expression/transport.ts index 70c77f33..2f8761a5 100644 --- a/packages/core/src/expression/transport.ts +++ b/packages/core/src/expression/transport.ts @@ -22,8 +22,7 @@ const transportSchema = z.union([ version: z.literal(1), kind: z.literal('failure'), stage: z.literal('evaluation'), - name: z.string(), - message: z.string(), + payloadJson: z.string(), }) .strict(), z @@ -111,3 +110,12 @@ export function decodeExpressionPayload(transport: SuccessTransport): unknown { } return value; } + +/** Only called after the full primitive envelope has passed the byte/shape bound. */ +export function decodeThrownPayload(payloadJson: string): unknown { + try { + return JSON.parse(payloadJson) as unknown; + } catch { + throw invalidTransport(); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 76be7dca..46dc9c8b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -61,7 +61,7 @@ export { // The `{{ … }}` runtime resolver (1.L2) — evaluate templates against a run scope, eager-once context. export { resolveTemplate, resolveContext, resolvePublicTemplate } from './interpolation/resolve.js'; export type { RunScope, ResolverCapabilities } from './interpolation/scope.js'; -export type { PublicTemplateScope } from './interpolation/resolve.js'; +export type { PublicTemplateScope, ContextResolutionSource } from './interpolation/resolve.js'; export { createWorkflowContextProjection, createWorkflowProvenance, diff --git a/packages/core/src/interpolation/provenance.ts b/packages/core/src/interpolation/provenance.ts index eab412fc..52f07325 100644 --- a/packages/core/src/interpolation/provenance.ts +++ b/packages/core/src/interpolation/provenance.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { WorkflowContextError } from '../errors.js'; import { computeTaint } from './analyze.js'; +import type { ContextResolutionSource } from './resolve.js'; /** Clean strings and the full original key order; denied values are never retained here. */ export interface WorkflowContextProjection { @@ -100,15 +101,24 @@ export function ownSecretInputNames(names: Iterable): OwnedSecretInputNa } export interface WorkflowProvenance extends OwnedSecretInputNames { + /** Resolution and taint analysis use these same owned definitions for the invocation. */ + readonly contextSource: ContextResolutionSource; qualifyContext(resolved: Readonly>): WorkflowContextProjection; } /** One linear taint authority per admitted workflow, reused by fresh/resume/preparation producers. */ export function createWorkflowProvenance(workflow: Workflow): WorkflowProvenance { - const taint = computeTaint(workflow.workflow); + const context = (workflow.workflow.context ?? []).map(({ key, value }) => + Object.freeze({ key, value }), + ); + const taint = computeTaint({ ...workflow.workflow, context }); + const contextSource = Object.freeze({ + workflow: Object.freeze({ context: Object.freeze(context) }), + }); const names = ownSecretInputNames(taint.inputs.keys()); return Object.freeze({ ...names, + contextSource, qualifyContext(resolved: Readonly>): WorkflowContextProjection { try { const values: Record = {}; diff --git a/packages/core/src/interpolation/resolve.ts b/packages/core/src/interpolation/resolve.ts index c79e9f6c..d00c092e 100644 --- a/packages/core/src/interpolation/resolve.ts +++ b/packages/core/src/interpolation/resolve.ts @@ -22,7 +22,7 @@ * not something this pure resolver can enforce once provenance is flattened. */ -import type { AbortSignalLike, Workflow } from '@relavium/shared'; +import type { AbortSignalLike } from '@relavium/shared'; import { InterpolationError } from '../errors.js'; @@ -101,6 +101,13 @@ export async function resolvePublicTemplate( ); } +/** The resolver needs only context definitions; a full trusted Workflow remains compatible. */ +export interface ContextResolutionSource { + readonly workflow: { + readonly context?: readonly { readonly key: string; readonly value: string }[] | undefined; + }; +} + /** * Eagerly resolve every `context` entry exactly once into a frozen, immutable snapshot (the spec's * eager-once context). Entries resolve in declared order, each seeing the inputs and the already- @@ -108,16 +115,13 @@ export async function resolvePublicTemplate( * @throws {InterpolationError} when a context value cannot be resolved (e.g. `read_file` on a bad path). */ export async function resolveContext( - workflow: Workflow, + workflow: ContextResolutionSource, inputs: Readonly>, caps: ResolverCapabilities = {}, signal?: AbortSignalLike, ): Promise>> { - // A null-prototype accumulator so a context key named `__proto__`/`constructor` is stored as a real - // own property rather than being silently dropped (or mutating a prototype). NOTE for 1.R: this - // null-proto guard is in-memory only — when this frozen `ctx` is persisted/transported for - // checkpoint/resume it MUST go through `structuredClone` (which preserves the null prototype), never - // `JSON.stringify` → `JSON.parse`, which would re-materialize a `__proto__` key as a real setter. + // Store prototype-shaped keys as ordinary own data. Engine resolution uses owned definitions and + // immediately qualifies this trusted raw result; raw context is never carried in a checkpoint. const ctx = Object.create(null) as Record; for (const entry of workflow.workflow.context ?? []) { abortIfCancelled(signal); From a39bf93f2c07f6cdd37e2c6e36010bfc01bfc702 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 04:10:25 +0300 Subject: [PATCH 035/222] test(cli): pin context ownership and error results in SQLite Exercise successful clean outputs through the actual standard executor and file-backed history, including caller definition mutation and hydrated native Error metadata. Assert nonempty public/event/step/run projections. Refs: ADR-0113 Co-Authored-By: Codex --- .../src/engine/secret-context-history.test.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/apps/cli/src/engine/secret-context-history.test.ts b/apps/cli/src/engine/secret-context-history.test.ts index ee77e61e..b0e0e298 100644 --- a/apps/cli/src/engine/secret-context-history.test.ts +++ b/apps/cli/src/engine/secret-context-history.test.ts @@ -216,6 +216,84 @@ describe('secret context at native SQLite/public boundaries (ADR-0113)', () => { expectNoNewSecret(client, events); }); + it('keeps context analysis and native history on the admitted definition after caller mutation', async () => { + const definition = workflow( + ` - { id: start, type: input } + - { id: derive, type: transform, transform: 'ctx.greeting' } + - { id: out, type: output }`, + ` - { from: start, to: derive } + - { from: derive, to: out }`, + ); + const frozenSnapshot = JSON.stringify(definition); + const { client, engine: runtime } = await fixture(definition); + const handle = runtime.start({ workflow: definition, inputs: INPUTS }); + const greeting = definition.workflow.context?.find((entry) => entry.key === 'greeting'); + if (greeting === undefined) throw new Error('missing authored greeting'); + greeting.value = '{{inputs.token}}'; + definition.workflow.context?.push({ key: 'later', value: '{{inputs.token}}' }); + const { events, drained } = consume(handle); + await drained; + expect(events.at(-1)).toMatchObject({ + type: 'run:completed', + outputs: { out: 'Hello Ada' }, + }); + expect( + client.sqlite + .prepare("SELECT output_json FROM step_executions WHERE node_id = 'derive'") + .all(), + ).toEqual([{ output_json: JSON.stringify('Hello Ada') }]); + expect(client.sqlite.prepare('SELECT output_json FROM runs').all()).toEqual([ + { output_json: JSON.stringify({ out: 'Hello Ada' }) }, + ]); + expect(client.sqlite.prepare('SELECT workflow_definition_snapshot FROM runs').all()).toEqual([ + { workflow_definition_snapshot: frozenSnapshot }, + ]); + expectNoNewSecret(client, events); + }); + + it('persists hydrated Error result fields through the actual standard executor', async () => { + const definition = workflow( + ` - { id: start, type: input } + - id: derive + type: transform + transform: 'Object.assign(new TypeError("clean diagnostic"), {info:42})' + - { id: out, type: output }`, + ` - { from: start, to: derive } + - { from: derive, to: out }`, + ); + const { client, engine: runtime } = await fixture(definition); + const { events, drained } = consume(runtime.start({ workflow: definition, inputs: INPUTS })); + await drained; + const derived = events.find( + (event) => event.type === 'node:completed' && event.nodeId === 'derive', + ); + expect(derived).toMatchObject({ + type: 'node:completed', + output: { + info: 42, + name: 'TypeError', + message: 'clean diagnostic', + }, + }); + if (derived?.type !== 'node:completed') throw new Error('missing completed derivation'); + if (derived.output === null || typeof derived.output !== 'object') + throw new Error('missing hydrated diagnostic object'); + expect(typeof Reflect.get(derived.output, 'stack')).toBe('string'); + expect(events.at(-1)).toMatchObject({ + type: 'run:completed', + outputs: { out: derived.output }, + }); + expect( + client.sqlite + .prepare("SELECT output_json FROM step_executions WHERE node_id = 'derive'") + .all(), + ).toEqual([{ output_json: JSON.stringify(derived.output) }]); + expect(client.sqlite.prepare('SELECT output_json FROM runs').all()).toEqual([ + { output_json: JSON.stringify({ out: derived.output }) }, + ]); + expectNoNewSecret(client, events); + }); + it.each([ ['direct context', 'ctx.alias'], ['computed context', 'ctx[["al", "ias"].join("")]'], From 1728c9b4faeb4240d1750c93c242b59e3029cd41 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 04:10:25 +0300 Subject: [PATCH 036/222] docs(docs): record step 3 review corrections and author gates Record the nonclean initial review and security sitting, verified converter and ownership repairs, exact-restored causal controls and fresh author gates. Keep security revalidation and two new complete clean rounds pending. Refs: ADR-0113 Co-Authored-By: Codex --- docs/reference/shared-core/agent-runner.md | 5 ++ .../shared-core/expression-sandbox-spec.md | 19 +++++++ .../phase-2.5.5-hardening-and-remediation.md | 51 +++++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/docs/reference/shared-core/agent-runner.md b/docs/reference/shared-core/agent-runner.md index cb48d9a5..8b3238e2 100644 --- a/docs/reference/shared-core/agent-runner.md +++ b/docs/reference/shared-core/agent-runner.md @@ -388,6 +388,8 @@ owned provenance authority shared by execution and `budgetPreparationContext`. S cross-process budget preparation creates that authority, qualifies the resolved context and passes both through `resumePreparation.context` / `resumePreparation.provenance`; the resumed execution reuses them rather than deriving a second mutable secret-name set. +Context analysis and resolution use the authority's same owned definition snapshot, so caller +edits after admission or during an awaited context capability cannot change the analysed source. `NodeExecContext` and `NodePreparationContext` require the same context projection and owned `secretInputNames` membership; the preparation Pick includes both. Agent prompt resolution @@ -397,6 +399,9 @@ context and direct declared secret-input references refuse before any filters, d read capabilities, including on hand-constructed workflows that bypass the parser. Clean fields retain their existing resolution and user-role placement. Authored system text is still concatenated verbatim, not interpolated. +An absent public template still resolves the empty default through the provenance boundary; +it does not waive required context or secret-input membership metadata. Optional assignee +presence and existing cancellation/preparation outcomes remain unchanged. Condition/transform/custom-merge handlers use `buildExpressionScope` with the required projection and explicit denied input names. Direct supplied secret inputs become VM-owned diff --git a/docs/reference/shared-core/expression-sandbox-spec.md b/docs/reference/shared-core/expression-sandbox-spec.md index 40aed445..cffe2be2 100644 --- a/docs/reference/shared-core/expression-sandbox-spec.md +++ b/docs/reference/shared-core/expression-sandbox-spec.md @@ -292,6 +292,13 @@ mutation methods; its callbacks and iterators do not expose the mutable backing `Object.freeze(new Set())` or retaining a caller's live Set would not provide that ownership. Shape validation cannot detect a dishonest trusted embedding's fabricated provenance. +The authority also snapshots/freezes the context definition array and its key/value entries +before analysis. Engine resolution consumes that same `contextSource`, including across +awaited capabilities and same-process resume. A caller mutation after `start()` cannot make +resolution consume a different context definition from the one whose taint was analysed. +The trusted raw resolver remains compatible with full workflows and accepts a minimal +read-only context source; only engine resolution chooses the owned invocation source. + Execution/preparation require this projection and owned membership. Missing or malformed metadata at an engine/public-template boundary raises fixed, content-free `WorkflowContextError` (`validation`, non-retryable). Direct `ExpressionSandbox.evaluate` @@ -356,6 +363,17 @@ or a native serialization interrupt that prevents completion of the closure, pre disposition: `result_type` for `condition`, `non_serializable` for `transform`/`merge_fn`. The native interrupt does not receive a new deadline or become a successful denial observation. +Top-level result conversion retains the installed bridge's supplemental `name`, `message`, +`stack`, `fileName` and `lineNumber` fields, including inherited/non-enumerable fields when +the serialized result does not supply a value. Hydration happens inside the single authored +serialization; subsequent encoding uses detached JSON records/arrays without authored +prototypes or callbacks. Nested Error values retain ordinary JSON semantics. Stack strings +describe the current VM wrapper; their internal frame text is not a stable cross-version value. +Ordinary thrown values use the same VM-owned conversion and fallback-text semantics before +the unchanged host classifier. Their bounded JSON payload is private error transport, never +an authored control arm. A VM-owned immutable Boolean distinguishes failed evaluation from +successful-result serialization if the native closure cannot complete; it grants no extra time. + The versioned private transport has closed `success`, `failure` and `denied` arms. Success contains the trusted original VM `typeof`, a primitive `payloadJson` string and, where needed, a closed number tag. The null-prototype outer envelope contains only trusted primitive metadata @@ -374,6 +392,7 @@ resource limits remain unchanged; no unbounded host object dump/parse is introdu | Boolean/string/finite number | Preserve exact value | Preserve exact value | | Top-level NaN, ±Infinity, -0 | Preserve numeric value/sign using private tags | Same; later JSON persistence keeps existing normalization | | Object/array/null, including Map/Set | `result_type` | Existing JSON object semantics; Map/Set → `{}`; cycles fail | +| Top-level Error / Error subclass | `result_type` | Retain enumerable data plus supplemental diagnostic fields; nested Errors keep ordinary JSON semantics | | Boxed primitive or object whose `toJSON` returns a non-null scalar | `result_type` | `non_serializable` | | Undefined/function/symbol/bigint | `result_type` | `non_serializable` | diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index fac24371..f94d267a 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4375,3 +4375,54 @@ clean reviews after the last material correction remain required before Step 4. Detailed baseline hashes, runtime identities, local probes and reports remain Git-ignored under docs/reviews; no production credential, paid call, release, archive upload or user decision is needed for this increment. + + +### Wave 3 Step 3 review corrections — 2026-10-16 + +The first complete cumulative review of `3222a8c6` is **not clean**: two High +findings identify lost native top-level Error result metadata and changed ordinary +thrown-value classification; one Medium identifies missing required provenance +validation when agent/human templates are absent. The separate secret-flow +security sitting corroborates the classification finding and is also **not clean**. +Neither counts toward acceptance. The parent reads both full reports, independently +compares the pinned Step 2 runtime and verifies the findings before correction. + +The private VM converter now preserves the pinned bridge's supplemental diagnostic +fields and ordinary thrown-value normalization under the original shared deadline. +Authored result/error conversion completes before the private denial check; only +inert detached JSON reaches final encoding and the bounded primitive transport. +Boxed values, nonfinite numbers, failed/cyclic/stateful conversion and denial during +Error getters/toJSON have permanent actual-QuickJS controls. Stack frame text +reflects the private wrapper and is not a stable ABI. Empty agent and human text +still validates required owned provenance; absent assignee and existing cancellation +contracts remain intact. New files contribute 216 parity and 38 empty-template +regressions; the original red runs independently expose the repaired mechanisms. + +An additional parent reproduction finds a live context-definition ownership gap: +a caller changes an originally clean definition after start, while taint analysis +retains the earlier definition; successful node output exposes the synthetic +canary. Taint analysis and raw resolution now consume the same frozen owned +context definitions in fresh, resumed and budget-preparation paths. This is a +context-definition snapshot, not a claim that every workflow field is immutable. +Seventeen permanent actual-engine controls cover immediate and suspended-read +mutations, same-process resume and trusted raw-resolver compatibility. Actual +file-backed SQLite also pins the successful clean mutation control and native +Error-result persistence, with nonempty event/step/run rows. + +Final corrected-source author gates pass: all workspace lint/typecheck, **600 files +/ 13,720 passing tests / 20 established skips**, six-workspace isolation, source +formatting/whitespace, dependency/vendor/platform fences and 3,365 relative links +across 72 changed Markdown files. All six build tasks pass (five cached). +Six fresh fixed/removal/restored cohorts pin context qualification, +public-template refusal, finite VM latch, native result projection, thrown-value +projection and actual engine definition binding. Removals produce **6 / 6 / 64 / +60 / 81 / 8** named assertion failures; fixed/restored inventories are **29 / 19 / +184 / 216 / 216 / 17**. All eighteen final runner receipts report zero unhandled +errors and all sources restore byte-identically before the final full author suite. +An exploratory witness-name mismatch was rejected rather than certified. + +These remain local author controls, not full CI/coverage/native-platform or +whole-wave certification. A fresh security revalidation and two NEW independent +complete cumulative clean rounds after the last material correction remain +required before Step 4. Baseline evidence, detailed reports and local controls +stay Git-ignored. No new decision, credential, paid call or release is pending. From b88e87867a118c6a405299bcf1fe8977610df0f0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 04:22:51 +0300 Subject: [PATCH 037/222] fix(core): preserve native diagnostic hydration order Finish the original JSON traversal before collecting supplemental fields, then convert supplemental values before the private denial check. Remove replacer emulation and pin getter order, ordinary errors and actual workflow output-schema success without a second original toJSON invocation. Refs: ADR-0113 Co-Authored-By: Codex --- .../expression-conversion-order.test.ts | 50 ++++++++++++++ .../core/src/expression/error-parity.test.ts | 68 +++++++++++++++++++ packages/core/src/expression/program.ts | 32 ++++----- 3 files changed, 131 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/engine/expression-conversion-order.test.ts diff --git a/packages/core/src/engine/expression-conversion-order.test.ts b/packages/core/src/engine/expression-conversion-order.test.ts new file mode 100644 index 00000000..1fb45f2c --- /dev/null +++ b/packages/core/src/engine/expression-conversion-order.test.ts @@ -0,0 +1,50 @@ +/** ADR-0113: native conversion order preserves an actual workflow's output-schema success. */ +import type { RunEvent } from '@relavium/shared'; +import { expect, it } from 'vitest'; + +import { createExpressionSandbox } from '../expression/sandbox.js'; +import { parseWorkflow } from '../parser.js'; +import { WorkflowEngine } from './engine.js'; +import { createInMemoryHost } from './execution-host.js'; +import { createStandardNodeExecutor } from './node-handlers/dispatcher.js'; + +it('retains successful clean schema validation after delayed diagnostic hydration', async () => { + const workflow = parseWorkflow( + JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'conversion-order', + nodes: [ + { + id: 'derive', + type: 'transform', + transform: + '(() => {let n=0;return {get name(){n++;return undefined},get message(){return n}};})()', + output_schema: { + type: 'object', + properties: { message: { type: 'number', const: 1 } }, + required: ['message'], + additionalProperties: false, + }, + }, + { id: 'out', type: 'output' }, + ], + edges: [{ from: 'derive', to: 'out' }], + }, + }), + ); + const engine = new WorkflowEngine({ + host: createInMemoryHost(), + executor: createStandardNodeExecutor({ sandbox: await createExpressionSandbox() }), + }); + const handle = engine.start({ workflow, inputs: {} }); + const events: RunEvent[] = []; + for await (const event of handle.events) events.push(event); + expect(events.at(-1), 'NATIVE_HYDRATION_WORKFLOW_SUCCESS').toMatchObject({ + type: 'run:completed', + outputs: { out: { message: 1 } }, + }); + expect(events).toContainEqual( + expect.objectContaining({ type: 'node:completed', nodeId: 'derive', output: { message: 1 } }), + ); +}); diff --git a/packages/core/src/expression/error-parity.test.ts b/packages/core/src/expression/error-parity.test.ts index 0e888783..6975b786 100644 --- a/packages/core/src/expression/error-parity.test.ts +++ b/packages/core/src/expression/error-parity.test.ts @@ -74,6 +74,36 @@ interface ReturnedCase { // Values were characterized against the accepted bridge. Stack text embeds wrapper locations; // only its presence/type is compatible across the old and private wrappers. const returnedCases: ReturnedCase[] = [ + { + label: 'ordinary traversal precedes repeated undefined diagnostic reads', + expression: + '(() => {let n=0;return {get name(){n++;return undefined},get message(){return n}};})()', + expected: { message: 1 }, + }, + { + label: 'late hydrated field follows original sibling values', + expression: + '(() => {let n=0;return {get name(){return ++n===1?undefined:"later"},get message(){return n}};})()', + expected: { message: 1, name: 'later' }, + }, + { + label: 'undefined message does not advance original stack and name reads', + expression: + '(() => {let n=0;return {get message(){n++;return undefined},get stack(){return n},get name(){return n}};})()', + expected: { stack: 1, name: 1 }, + }, + { + label: 'non-diagnostic siblings complete before hydration', + expression: + '(() => {let n=0;return {get name(){return ++n===1?undefined:"post"},get tail(){n+=10;return n},get message(){return n}};})()', + expected: { tail: 11, message: 11, name: 'post' }, + }, + { + label: 'all supplemental getters precede supplemental value callbacks', + expression: + '(() => {let n=0;return {toJSON(){return {}},get name(){return {toJSON(k){return {key:k,n:++n}}}},get message(){return n}};})()', + expected: { name: { key: 'name', n: 1 }, message: 0 }, + }, { label: 'Error metadata', expression: 'new Error("clean")', @@ -184,6 +214,14 @@ describe.each(kinds)('%s top-level Error result parity in actual QuickJS', (kind else { const result = sandbox.evaluate({ expression, kind, scope: cleanScope() }); expect(result, 'NATIVE_RESULT_PROJECTION_PARITY').toEqual(expected); + if ( + result !== null && + typeof result === 'object' && + expected !== null && + typeof expected === 'object' + ) { + expect(Object.keys(result), 'NATIVE_RESULT_KEY_ORDER').toEqual(Object.keys(expected)); + } if ( expected !== null && typeof expected === 'object' && @@ -217,6 +255,20 @@ interface ThrownCase { } const thrownCases: ThrownCase[] = [ + { + label: 'resource classification uses ordinary traversal before hydration', + value: + '(() => {let n=0;return {get name(){return ++n===1?undefined:"InternalError"},get message(){return n===1?"out of memory":"stack overflow"}};})()', + reason: 'memory', + detail: 'InternalError: out of memory', + }, + { + label: 'late hydration cannot invalidate an already serialized sibling', + value: + '(() => {let n=0;return {get name(){n++;return undefined},get message(){if(n>1)throw 42;return "ok"}};})()', + reason: 'runtime', + detail: 'ok', + }, { label: 'serialized fields replace original resource-shaped metadata', value: @@ -394,3 +446,19 @@ describe.each(kinds)('%s denial wins during error conversion', (kind) => { ); }); }); + +describe.each(kinds)('%s supplemental callback ordering and privacy', (kind) => { + it.each([ + 'Object.create({name:{toJSON(){try{ctx.derived;}catch{}return "clean";}}})', + '(() => {throw Object.create({name:{toJSON(){try{inputs.credential;}catch{}return "clean";}}});})()', + ])('checks denial after supplemental value conversion: %s', (expression) => { + const error = failure(expression, kind, deniedScope()); + expect(error).toMatchObject({ + code: 'sandbox_error', + reason: 'secret_context', + retryable: false, + }); + expect(error.detail).toBeUndefined(); + expect(error.cause).toBeUndefined(); + }); +}); diff --git a/packages/core/src/expression/program.ts b/packages/core/src/expression/program.ts index a215411e..7833d404 100644 --- a/packages/core/src/expression/program.ts +++ b/packages/core/src/expression/program.ts @@ -53,8 +53,6 @@ export function buildExpressionProgram(expression: string, scope: ExpressionScop '(function () {', ' const stringify=JSON.stringify, parse=JSON.parse, make=Object.create, freeze=Object.freeze, asString=String, setPrototypeOf=Object.setPrototypeOf;', ' const keys=Object.keys, define=Object.defineProperty, isArray=Array.isArray, isNaN=Number.isNaN, is=Object.is;', - ' const apply=Reflect.apply, numberValue=Number.prototype.valueOf, booleanValue=Boolean.prototype.valueOf, stringValue=String.prototype.valueOf;', - ' const bigintValue=typeof BigInt==="function"?BigInt.prototype.valueOf:undefined;', ` const invoke=Function("__public", ${JSON.stringify(body)});`, ` const data=parse(${JSON.stringify(scopeJson)});`, ' let denied=false;', @@ -71,7 +69,7 @@ export function buildExpressionProgram(expression: string, scope: ExpressionScop ' for(let i=0;i Date: Fri, 16 Oct 2026 04:22:51 +0300 Subject: [PATCH 038/222] docs(docs): record the verified conversion-order correction Keep the nonclean cumulative review and security findings attributed to their actual candidate. Record native phase ordering, permanent red/green controls, 21 exact-restored causal phases and fresh author gates; acceptance is pending. Refs: ADR-0113 Co-Authored-By: Codex --- .../shared-core/expression-sandbox-spec.md | 9 ++-- .../phase-2.5.5-hardening-and-remediation.md | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/docs/reference/shared-core/expression-sandbox-spec.md b/docs/reference/shared-core/expression-sandbox-spec.md index cffe2be2..af803a4b 100644 --- a/docs/reference/shared-core/expression-sandbox-spec.md +++ b/docs/reference/shared-core/expression-sandbox-spec.md @@ -365,9 +365,12 @@ The native interrupt does not receive a new deadline or become a successful deni Top-level result conversion retains the installed bridge's supplemental `name`, `message`, `stack`, `fileName` and `lineNumber` fields, including inherited/non-enumerable fields when -the serialized result does not supply a value. Hydration happens inside the single authored -serialization; subsequent encoding uses detached JSON records/arrays without authored -prototypes or callbacks. Nested Error values retain ordinary JSON semantics. Stack strings +the serialized result does not supply a value. The original result's JSON traversal completes +first, retaining its getter order and invoking its `toJSON` once. Missing diagnostic fields +are then read in the native fixed order; all supplemental getters finish before supplemental +values are serialized. These authored conversions occur before the final latch check. +Subsequent encoding uses detached JSON records/arrays without authored prototypes or callbacks. +Nested Error values retain ordinary JSON semantics. Stack strings describe the current VM wrapper; their internal frame text is not a stable cross-version value. Ordinary thrown values use the same VM-owned conversion and fallback-text semantics before the unchanged host classifier. Their bounded JSON payload is private error transport, never diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index f94d267a..49e36aed 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4426,3 +4426,48 @@ whole-wave certification. A fresh security revalidation and two NEW independent complete cumulative clean rounds after the last material correction remain required before Step 4. Baseline evidence, detailed reports and local controls stay Git-ignored. No new decision, credential, paid call or release is pending. + + +### Wave 3 Step 3 conversion-order correction — 2026-10-16 + +The NEW complete cumulative review at `1728c9b4` is **not clean**: one High +finding shows diagnostic hydration occurring before ordinary JSON traversal +finishes. The fresh security revalidation independently corroborates it; no +additional declared-secret confidentiality or ambient-authority defect is verified. +The parent reads both complete reports and reproduces the issue against the +isolated accepted Step 2 runtime. Neither review counts toward acceptance. + +An undefined diagnostic getter changes a later sibling value from 1 to 2; +the actual WorkflowEngine/standard executor consequently fails a previously +successful output schema. The same sequencing defect changes an ordinary thrown +object from memory to stack classification. A separate supplemental object's +`toJSON` can also run before later diagnostic getters, changing their values. +These are one converter-order defect, not three independently counted findings. + +The converter now completes the original JSON traversal once, then reads all +missing diagnostic fields in the pinned bridge's fixed order, then serializes +supplemental values before the private denial check. It removes the eager root +replacer and boxed-value emulation. Final encoding still contains detached JSON +only; the original authored `toJSON` is not invoked again, no successful authored +object is dumped after denial observation, and the original VM deadline remains. +Permanent actual-VM controls pin sibling values/key order, supplemental callback +ordering, ordinary thrown classification and denied supplemental conversion. +The parity suite grows from 216 to 243 cases; its pre-fix run has 16 genuine +assertion failures. A separate real-engine output-schema test fails before repair +and completes with exact output after it. Eleven independent old/current probes +also match after correction. Detailed raw probes remain local and Git-ignored. + +Fresh final author gates pass on the restored corrected source: all workspace +lint/typecheck, **601 files / 13,748 passing tests / 20 established skips**, all +six build tasks (five cached), fences, 601-file/six-workspace isolation, changed- +source formatting/whitespace and 3,365 relative links. Seven fresh causal cohorts +produce **6 / 6 / 64 / 75 / 84 / 8 / 1** named removal failures, with passing +fixed/restored inventories **29 / 19 / 184 / 243 / 243 / 17 / 1**, respectively. +All 21 final runner receipts record zero unhandled errors; every source restores +byte-identically before the final passing full author suite. The seventh cohort +pins the actual engine output-schema success rather than a fabricated converter. + +Fresh security revalidation and two NEW independent complete cumulative clean +rounds after this final material fix remain required before Step 4. All earlier +nonclean rounds remain explicitly nonclean. These author gates are not full CI, +coverage, native-platform or whole-wave acceptance; later plan ownership remains. From 7567c77bc9f94d761731e29d5264df0929d6d262 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 04:39:22 +0300 Subject: [PATCH 039/222] fix(core): retain native primitive and scalar conversion paths Avoid diagnostic prototype reads for native primitives and converted scalar representations; bypass authored BigInt JSON hooks for thrown primitives. Keep object conversion, original type and private denial ordering intact. Pin primitive, boxed and scalar compatibility with actual VM regressions. Refs: ADR-0113 Co-Authored-By: Codex --- .../core/src/expression/error-parity.test.ts | 73 +++++++++++++++++++ packages/core/src/expression/program.ts | 8 +- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/packages/core/src/expression/error-parity.test.ts b/packages/core/src/expression/error-parity.test.ts index 6975b786..4f0a18a7 100644 --- a/packages/core/src/expression/error-parity.test.ts +++ b/packages/core/src/expression/error-parity.test.ts @@ -255,6 +255,54 @@ interface ThrownCase { } const thrownCases: ThrownCase[] = [ + { + label: 'boxed number retains scalar conversion without prototype diagnostics', + value: + '(() => {Object.defineProperty(Number.prototype,"name",{get(){throw 42;}});return new Number(42);})()', + reason: 'runtime', + detail: '42', + }, + { + label: 'boxed string retains scalar conversion without prototype diagnostics', + value: + '(() => {Object.defineProperty(String.prototype,"name",{get(){throw 42;}});return new String("ok");})()', + reason: 'runtime', + detail: 'ok', + }, + { + label: 'boxed boolean retains scalar conversion without prototype diagnostics', + value: + '(() => {Object.defineProperty(Boolean.prototype,"name",{get(){throw 42;}});return new Boolean(true);})()', + reason: 'runtime', + detail: 'true', + }, + { + label: 'object toJSON scalar retains conversion without prototype diagnostics', + value: + '(() => {Object.defineProperty(Number.prototype,"name",{get(){throw 42;}});return {toJSON(){return 42;}};})()', + reason: 'runtime', + detail: '42', + }, + { + label: 'thrown string ignores primitive diagnostic getters', + value: + '(() => {Object.defineProperty(String.prototype,"name",{get(){throw 42;}});return "primitive";})()', + reason: 'runtime', + detail: 'primitive', + }, + { + label: 'thrown boolean ignores primitive diagnostic getters', + value: + '(() => {Object.defineProperty(Boolean.prototype,"name",{get(){throw 42;}});return true;})()', + reason: 'runtime', + detail: 'true', + }, + { + label: 'thrown bigint bypasses authored JSON hooks', + value: '(() => {BigInt.prototype.toJSON=function(){return "changed";};return 2n;})()', + reason: 'runtime', + detail: '2', + }, { label: 'resource classification uses ordinary traversal before hydration', value: @@ -462,3 +510,28 @@ describe.each(kinds)('%s supplemental callback ordering and privacy', (kind) => expect(error.cause).toBeUndefined(); }); }); + +describe.each(kinds)('%s native primitive result paths', (kind) => { + const values: { constructor: string; expression: string; expected: unknown }[] = [ + { constructor: 'Number', expression: '42', expected: 42 }, + { constructor: 'String', expression: '"ok"', expected: 'ok' }, + { constructor: 'Boolean', expression: 'true', expected: true }, + ]; + for (const value of values) { + it.each(['name', 'message', 'stack', 'fileName', 'lineNumber'])( + `${value.constructor} result ignores unused %s diagnostic getter`, + (field) => { + const expression = `(() => {Object.defineProperty(${value.constructor}.prototype,"${field}",{get(){throw 42;}});return ${value.expression};})()`; + let result: unknown; + let caught: unknown; + try { + result = sandbox.evaluate({ expression, kind, scope: cleanScope() }); + } catch (error) { + caught = error; + } + expect(caught, 'NATIVE_PRIMITIVE_FAST_PATH').toBeUndefined(); + expect(result, 'NATIVE_PRIMITIVE_FAST_PATH').toEqual(value.expected); + }, + ); + } +}); diff --git a/packages/core/src/expression/program.ts b/packages/core/src/expression/program.ts index 7833d404..87fec115 100644 --- a/packages/core/src/expression/program.ts +++ b/packages/core/src/expression/program.ts @@ -83,15 +83,19 @@ export function buildExpressionProgram(expression: string, scope: ExpressionScop ' }return copy;', ' };', ' const snapshot=function(v){', + // Primitive dump paths never inspect diagnostic prototypes or authored JSON hooks. + ' const type=typeof v;', + ' if(type==="bigint" || type==="undefined" || type==="symbol")return stringify(asString(v));', + ' if(v===null || (type!=="object" && type!=="function"))return stringify(v);', ' let encoded,copy;', // Complete the original traversal before supplementary getters can run. The original // result/toJSON runs once; supplemental values follow the pinned bridge's second pass. ' try{encoded=stringify(v);if(typeof encoded!=="string")throw 0;copy=detach(parse(encoded));}', ' catch(error){const text=asString(v);try{return stringify(detach(parse(text)));}catch(ignored){return stringify(text);}}', ' const diagnosticKeys=["name","message","stack","fileName","lineNumber"];', - ' if(copy!==null){for(let i=0;i Date: Fri, 16 Oct 2026 04:39:22 +0300 Subject: [PATCH 040/222] docs(docs): record native conversion repairs and pending review gates Attribute the interrupted review as incomplete and the security sitting as nonclean. Record primitive/scalar contracts, actual red-green regressions, 27 exact-restored causal phases and corrected-source author gates. Refs: ADR-0113 Co-Authored-By: Codex --- .../shared-core/expression-sandbox-spec.md | 6 +++ .../phase-2.5.5-hardening-and-remediation.md | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/docs/reference/shared-core/expression-sandbox-spec.md b/docs/reference/shared-core/expression-sandbox-spec.md index af803a4b..bc72d8f3 100644 --- a/docs/reference/shared-core/expression-sandbox-spec.md +++ b/docs/reference/shared-core/expression-sandbox-spec.md @@ -363,6 +363,12 @@ or a native serialization interrupt that prevents completion of the closure, pre disposition: `result_type` for `condition`, `non_serializable` for `transform`/`merge_fn`. The native interrupt does not receive a new deadline or become a successful denial observation. +Primitive results follow their original-type conversion paths without diagnostic prototype +reads or authored JSON hooks. Thrown BigInt values also bypass `toJSON`. Boxed values and +objects whose `toJSON` returns a scalar still undergo object JSON conversion and retain +original-VM-type classification. Diagnostic hydration applies only when that converted JSON +is an object/array; scalar representations do not acquire diagnostic prototype reads. + Top-level result conversion retains the installed bridge's supplemental `name`, `message`, `stack`, `fileName` and `lineNumber` fields, including inherited/non-enumerable fields when the serialized result does not supply a value. The original result's JSON traversal completes diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 49e36aed..7ba556fb 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4471,3 +4471,57 @@ Fresh security revalidation and two NEW independent complete cumulative clean rounds after this final material fix remain required before Step 4. All earlier nonclean rounds remain explicitly nonclean. These author gates are not full CI, coverage, native-platform or whole-wave acceptance; later plan ownership remains. + + +### Wave 3 Step 3 primitive-conversion correction — 2026-10-16 + +The attempted NEW cumulative review at `a2db0b77` verifies one further High: +primitive values enter object diagnostic hydration, invoking prototype getters +that the accepted native primitive conversion never reads. The tool interrupts +that agent's turn; its retained report is explicitly **partial and nonclean**, not +complete cumulative acceptance. The separate completed security revalidation +independently corroborates the primitive discrepancy and is also **nonclean**; +no additional declared-secret confidentiality or authority issue is verified. +The parent reads both reports and reproduces the ordinary API difference against +accepted Step 2. No incomplete or earlier nonclean round counts toward acceptance. + +A finite Number/String/Boolean value must not invoke an unused diagnostic getter. +Thrown primitive conversion has the same requirement; a custom BigInt `toJSON` +previously changed ordinary private detail from `2` to `changed`. Conversion now +branches on the original primitive type before object hydration or JSON hooks. +Boxed values and objects yielding scalar JSON keep their separate object path; +numeric tags, the original deadline, supplemental conversion before denial and +inert final transport remain intact. + +The actual-VM parity suite grows from 243 to 309 cases. The first 54 new pre-fix +assertion failures cover every diagnostic field on Number/String/Boolean across +all three expression kinds and ordinary thrown String/Boolean/BigInt behavior. +A further parent comparison exposes the same unwanted lookup after object JSON +conversion yields a scalar: boxed Number/String/Boolean and object `toJSON`. +Twelve additional regressions fail before the converted-shape guard and pass +after it. Hydration now requires converted object/array data; original object +conversion and original-type classification still apply. The corrected focused +suite passes. These are synthetic local compatibility controls, without real +credentials, provider calls or external targets. + +Fresh corrected-source author gates pass: all workspace lint/typecheck, **601 files +/ 13,814 passing tests / 20 established skips**, six builds (five cached), source +format/whitespace, vendor/platform/dependency fences, 601-file/six-workspace +isolation and 3,365 relative links. Ordinary primitive and boxed/scalar old/current +comparisons match after repair. + +Nine final fixed/removal/restored cohorts pin qualification, template refusal, +finite latch, result projection, thrown projection, owned definition binding, +actual engine hydration order, native primitive conversion and scalar hydration. +They produce **6 / 6 / 64 / 75 / 105 / 8 / 1 / 6 / 12** named removal failures; +fixed/restored inventories are **29 / 19 / 184 / 309 / 309 / 17 / 1 / 309 / 309**. +All 27 final runner receipts record zero unhandled errors and exact source +restoration precedes the final passing full suite. A truncated Vitest witness +name rejected an exploratory attempt; it was not published as acceptance, and +the final run pins the stable unique named assertion plus its mechanism marker. + +Fresh security revalidation and two NEW independent complete cumulative clean +reviews after this final correction remain required before Step 4. The interrupted +review is incomplete; none of the nonclean or incomplete sittings is counted. +These local author controls do not claim full CI/coverage/native-platform, +whole-wave acceptance, a release or new permission. Detailed records stay ignored. From 011e1df5be49d804767bafbcbd220a2bdd6283cd Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 04:56:49 +0300 Subject: [PATCH 041/222] docs(docs): accept step 3 and advance the error substrate Join two new complete cumulative clean reviews and the separate clean secret-flow sitting at reviewed source 5d8c0b0c. Record exact author/causal and independent execution scopes, preserve prior nonclean records and historical limits, and land ADR notes without rewriting prior decisions. Refs: ADR-0113 Co-Authored-By: Codex --- AGENTS.md | 4 +- CLAUDE.md | 4 +- docs/decisions/0027-expression-sandbox.md | 12 ++++++ docs/decisions/0029-tool-policy-hardening.md | 12 ++++++ .../0037-engine-tool-execution-boundary.md | 12 ++++++ ...a-resume-that-verifies-its-own-identity.md | 12 ++++++ ...sion-sees-only-what-it-is-ordered-after.md | 12 ++++++ ...ontext-keeps-provenance-at-public-sinks.md | 12 ++++++ docs/reference/shared-core/agent-runner.md | 9 ++-- .../shared-core/expression-sandbox-spec.md | 9 ++-- docs/roadmap/current.md | 4 +- .../phase-2.5.5-hardening-and-remediation.md | 42 +++++++++++++++++++ docs/standards/security-review.md | 26 ++++++++++++ 13 files changed, 159 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 016f2304..6a3997ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,7 +82,9 @@ whose ADR-0110–0113/contracts/nineteen-increment plan were approved on 2026-10 Its serialized multi-file Step 1 is independently accepted on `development` (2026-10-16); Step 2 is also accepted after two NEW clean complete cumulative rounds following its historical-reader corrections. Step 3 (ADR-0113 secret provenance) -is active. Later implementation and whole-wave acceptance +is independently accepted after two NEW complete cumulative clean rounds and a +separate clean security sitting. Step 4 (error/envelope substrate) is active. +Later implementation and whole-wave acceptance remain staged in the canonical roadmap. See [docs/roadmap/current.md](docs/roadmap/current.md) for live status. diff --git a/CLAUDE.md b/CLAUDE.md index 4b1cc1a9..e4f19c90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,7 +171,9 @@ whose ADR-0110–0113/contracts/nineteen-increment plan were approved on 2026-10 Its serialized multi-file Step 1 is independently accepted on `development` (2026-10-16); Step 2 is also accepted after two NEW clean complete cumulative rounds following its historical-reader corrections. Step 3 (ADR-0113 secret provenance) -is active. Later implementation and whole-wave acceptance +is independently accepted after two NEW complete cumulative clean rounds and a +separate clean security sitting. Step 4 (error/envelope substrate) is active. +Later implementation and whole-wave acceptance remain staged in the canonical roadmap. For live status, per-PR history, milestone dates, and open obligations, see the canonical diff --git a/docs/decisions/0027-expression-sandbox.md b/docs/decisions/0027-expression-sandbox.md index 6f554264..1c4bae15 100644 --- a/docs/decisions/0027-expression-sandbox.md +++ b/docs/decisions/0027-expression-sandbox.md @@ -199,3 +199,15 @@ on the pinned variant. It cannot reopen the engine choice. ## Wave 3 decision acceptance — 2026-10-16 > Amended 2026-10-16: [ADR-0113](0113-secret-derived-context-keeps-provenance-at-public-sinks.md) is accepted by the maintainer. Expression scopes exclude present declared-secret inputs and secret-derived context rather than passing expression markers. Display/run-start/input-node masking remains unchanged. Executed denied access has a finite private-latch diagnosis; omitted optional inputs retain absence and no host function is introduced. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. + + +## Wave 3 Step 3 implementation landing — 2026-10-16 + +The ADR-0113 portion is implemented and independently accepted at `5d8c0b0c` on +`development`, after two NEW complete cumulative clean reviews and a separate +clean secret-flow security sitting. The [Step 3 acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-3-acceptance--2026-10-16) +joins every verified correction, permanent/causal evidence and precise trust/ +historical-content limits. Earlier decision-acceptance notes do not claim this +later implementation landing. Other Wave 3 decisions and increments remain +staged; this note does not close ADR-0112 policy work, whole-wave publication or +merge. No historical ADR body is rewritten. diff --git a/docs/decisions/0029-tool-policy-hardening.md b/docs/decisions/0029-tool-policy-hardening.md index 50993e09..959772ea 100644 --- a/docs/decisions/0029-tool-policy-hardening.md +++ b/docs/decisions/0029-tool-policy-hardening.md @@ -134,3 +134,15 @@ future feature, deferral or claim of merge-commit execution. > Amended 2026-10-16: [ADR-0112](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) is accepted by the maintainer. The built-in chat HTTP tool obtains global-only, invocation-live monotonically narrowing authority, independent of tool grants, approval and SSRF. Workflow, shell, provider and MCP egress retain their separate contracts. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. [ADR-0113](0113-secret-derived-context-keeps-provenance-at-public-sinks.md) additionally refines this boundary: Declared-source provenance removes raw derived values before standard VM/template sinks; external secretArgKeys remains an unverified optional compatibility field with no current mapping producer. + + +## Wave 3 Step 3 implementation landing — 2026-10-16 + +The ADR-0113 portion is implemented and independently accepted at `5d8c0b0c` on +`development`, after two NEW complete cumulative clean reviews and a separate +clean secret-flow security sitting. The [Step 3 acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-3-acceptance--2026-10-16) +joins every verified correction, permanent/causal evidence and precise trust/ +historical-content limits. Earlier decision-acceptance notes do not claim this +later implementation landing. Other Wave 3 decisions and increments remain +staged; this note does not close ADR-0112 policy work, whole-wave publication or +merge. No historical ADR body is rewritten. diff --git a/docs/decisions/0037-engine-tool-execution-boundary.md b/docs/decisions/0037-engine-tool-execution-boundary.md index ffdc912d..ffe945c5 100644 --- a/docs/decisions/0037-engine-tool-execution-boundary.md +++ b/docs/decisions/0037-engine-tool-execution-boundary.md @@ -96,3 +96,15 @@ future feature, deferral or claim of merge-commit execution. > Amended 2026-10-16: [ADR-0113](0113-secret-derived-context-keeps-provenance-at-public-sinks.md) is accepted by the maintainer. The engine owns qualified context/input provenance before standard execution and budget preparation. Trusted raw resolver/host inputs are not a public secret-free domain. The optional external tool provenance field has no current authored mapping producer and is not proof of cleanliness. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. [ADR-0112](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) additionally refines this boundary: The immutable admitted PolicyTarget and existing beforeDispatch seam supply the final live chat HTTP check after approval/effect preparation; tool grants and approval remain independent. + + +## Wave 3 Step 3 implementation landing — 2026-10-16 + +The ADR-0113 portion is implemented and independently accepted at `5d8c0b0c` on +`development`, after two NEW complete cumulative clean reviews and a separate +clean secret-flow security sitting. The [Step 3 acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-3-acceptance--2026-10-16) +joins every verified correction, permanent/causal evidence and precise trust/ +historical-content limits. Earlier decision-acceptance notes do not claim this +later implementation landing. Other Wave 3 decisions and increments remain +staged; this note does not close ADR-0112 policy work, whole-wave publication or +merge. No historical ADR body is rewritten. diff --git a/docs/decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md b/docs/decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md index 46fa9ef2..ca4d6e49 100644 --- a/docs/decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md +++ b/docs/decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md @@ -405,3 +405,15 @@ future feature, deferral or claim of merge-commit execution. ## Wave 3 decision acceptance — 2026-10-16 > Amended 2026-10-16: [ADR-0113](0113-secret-derived-context-keeps-provenance-at-public-sinks.md) is accepted by the maintainer. Only expression-input marker compatibility is replaced: present admitted secret values are excluded and use denied accessors. Omitted optional secrets remain absent, secret defaults remain forbidden and required/default admission stays unchanged. Run-start/display/input-node marker contracts remain accepted. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. + + +## Wave 3 Step 3 implementation landing — 2026-10-16 + +The ADR-0113 portion is implemented and independently accepted at `5d8c0b0c` on +`development`, after two NEW complete cumulative clean reviews and a separate +clean secret-flow security sitting. The [Step 3 acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-3-acceptance--2026-10-16) +joins every verified correction, permanent/causal evidence and precise trust/ +historical-content limits. Earlier decision-acceptance notes do not claim this +later implementation landing. Other Wave 3 decisions and increments remain +staged; this note does not close ADR-0112 policy work, whole-wave publication or +merge. No historical ADR body is rewritten. diff --git a/docs/decisions/0093-an-expression-sees-only-what-it-is-ordered-after.md b/docs/decisions/0093-an-expression-sees-only-what-it-is-ordered-after.md index 9fb50b2d..beb007fc 100644 --- a/docs/decisions/0093-an-expression-sees-only-what-it-is-ordered-after.md +++ b/docs/decisions/0093-an-expression-sees-only-what-it-is-ordered-after.md @@ -188,3 +188,15 @@ is the phrasing that survives the next shape being found. ## Wave 3 decision acceptance — 2026-10-16 > Amended 2026-10-16: [ADR-0113](0113-secret-derived-context-keeps-provenance-at-public-sinks.md) is accepted by the maintainer. The literal dependency scan and before-money-spent scheduling rule remain accepted. Runtime secret exclusion uses finite VM accessors for a different purpose: diagnosis of executed denied reads, not replacement of parse-time out-of-closure admission. No universal attempted-access detection is claimed. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. + + +## Wave 3 Step 3 implementation landing — 2026-10-16 + +The ADR-0113 portion is implemented and independently accepted at `5d8c0b0c` on +`development`, after two NEW complete cumulative clean reviews and a separate +clean secret-flow security sitting. The [Step 3 acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-3-acceptance--2026-10-16) +joins every verified correction, permanent/causal evidence and precise trust/ +historical-content limits. Earlier decision-acceptance notes do not claim this +later implementation landing. Other Wave 3 decisions and increments remain +staged; this note does not close ADR-0112 policy work, whole-wave publication or +merge. No historical ADR body is rewritten. diff --git a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md index 52abb94f..b49543d5 100644 --- a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md +++ b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md @@ -216,3 +216,15 @@ contracts and the nineteen-increment Wave 3 plan. Earlier proposal wording above records the review stage; approval is complete. Implementation, independent review gates and canonical promotion remain staged in that plan. Acceptance does not claim shipping behaviour, an actual release or production custody setup. + + +## Wave 3 Step 3 implementation landing — 2026-10-16 + +The ADR-0113 portion is implemented and independently accepted at `5d8c0b0c` on +`development`, after two NEW complete cumulative clean reviews and a separate +clean secret-flow security sitting. The [Step 3 acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-3-acceptance--2026-10-16) +joins every verified correction, permanent/causal evidence and precise trust/ +historical-content limits. Earlier decision-acceptance notes do not claim this +later implementation landing. Other Wave 3 decisions and increments remain +staged; this note does not close ADR-0112 policy work, whole-wave publication or +merge. No historical ADR body is rewritten. diff --git a/docs/reference/shared-core/agent-runner.md b/docs/reference/shared-core/agent-runner.md index 8b3238e2..4fe67426 100644 --- a/docs/reference/shared-core/agent-runner.md +++ b/docs/reference/shared-core/agent-runner.md @@ -374,10 +374,11 @@ second synchronous cleanup error is not claimed for the current built-in governo ## Qualified context consumers -**Implementation contract — 2026-10-16.** Current Step 3 source implements -[ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md); -implementation acceptance and independent review gates remain pending in the -[Wave 3 register](../../roadmap/current.md#wave-3--clear-the-ground). Exact projection, +**Accepted implementation — 2026-10-16.** Step 3 implements +[ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md). +The [acceptance register](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-3-acceptance--2026-10-16) +joins two NEW complete cumulative clean reviews, the separate clean security sitting +and exact-source evidence; whole Wave 3 remains open. Exact projection, public-template refusal, VM/input compatibility, result transport and finite observed-access semantics belong to the [expression specification](expression-sandbox-spec.md#secret-context-boundary). diff --git a/docs/reference/shared-core/expression-sandbox-spec.md b/docs/reference/shared-core/expression-sandbox-spec.md index bc72d8f3..75e26e14 100644 --- a/docs/reference/shared-core/expression-sandbox-spec.md +++ b/docs/reference/shared-core/expression-sandbox-spec.md @@ -269,10 +269,11 @@ error, cap-trip classification, determinism over shuffled `run.outputs` key orde ## Secret context boundary -**Implementation contract — 2026-10-16.** This section describes the current Step 3 source -under [ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md). -Implementation acceptance and independent review gates remain pending in the -[Wave 3 register](../../roadmap/current.md#wave-3--clear-the-ground). +**Accepted implementation — 2026-10-16.** Step 3 implements +[ADR-0113](../../decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md), +with two NEW complete cumulative clean reviews and a separate clean security sitting. +The [acceptance register](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-3-acceptance--2026-10-16) +records exact source, evidence and preserved limits; whole Wave 3 remains open. ### Owned provenance and consumers diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 1ffaf4ef..195caff2 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1114,7 +1114,9 @@ records completed decision gates and staged implementation. after two NEW independent complete clean reviews. [Step 2 is also accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-2-acceptance--2026-10-16) with two NEW complete clean cumulative rounds after its historical-reader corrections; -Step 3 (ADR-0113 secret provenance) is active. The +[Step 3 is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-3-acceptance--2026-10-16) +after two NEW complete cumulative clean rounds and a separate clean security sitting. +Step 4 (error/envelope substrate) is active. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 7ba556fb..3718e1e6 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4525,3 +4525,45 @@ reviews after this final correction remain required before Step 4. The interrupt review is incomplete; none of the nonclean or incomplete sittings is counted. These local author controls do not claim full CI/coverage/native-platform, whole-wave acceptance, a release or new permission. Detailed records stay ignored. + + +### Wave 3 Step 3 acceptance — 2026-10-16 + +Step 3 is independently accepted at `5d8c0b0cf91604c2c270ff0351b4168508fe6041`. +Two NEW independent complete cumulative clean reviews after the final material +fix cover all **42 changed paths / 108 hunks**, including all thirteen added +files and actual consumers. Selected reviewers are gpt-6-astra/high (30 files / +1,513 independently passing tests) and gpt-6.1-sol/high (20 files / 1,193 tests). +Both report zero findings and independently run the 26 retained ordinary +Step-2/current comparisons with matching outcomes. The parent reads both full +reports. Earlier nonclean sittings and the interrupted incomplete review are +not counted. Detailed reports remain local and Git-ignored. + +The separate NEW secret-flow security sitting is clean at the same source, +with **19 files / 1,156 independently passing tests**, no skips and independent +inspection of all 27 causal phases. It traces actual producer/preparation/resume, +VM/template, standard-handler/public-event and native SQLite boundaries; its +mandatory checklist distinguishes unchanged/out-of-scope paths. This is a +security disposition, not a third complete correctness round. Test counts overlap +and are not added to author or other reviewer totals. + +ADR-0113 implementation and the actual context exposure are complete. #92 has +its approved explicit documented limitation: no current authored tool-mapping +producer exists, optional external provenance is unverified, and future mapping +must supply final effective-argument provenance. No fake producer or general +value-scanning guarantee is introduced. Canonical contracts preserve required +owned low-level metadata, direct-input compatibility, optional absence/key order, +finite observed-access semantics, native result/error conversion and trusted raw/ +historical-content limits. All verified review findings and the parent-owned +context-definition race are repaired; no scoped review finding remains open. + +Final author evidence remains precisely attributed: **601 files / 13,814 passing +tests / 20 established skips**, workspace lint/typecheck, six builds (five cached), +fences, isolation, formatting/whitespace and relative links. Nine exact-restored +causal cohorts and the final full passing suite precede acceptance. This metadata +landing does not claim execution at a successor, full CI/coverage/native-platform, +whole-wave acceptance or merge. Step 19 retains those final gates/publication. + +Next is Step 4's approved minimal error/envelope substrate. Read-only inventories +for Steps 4–5 are preparation, not implementation acceptance. No user decision, +credential, provider capture, billing or release action is pending. diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index 8130cf4f..df883ed2 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -515,6 +515,32 @@ author-build attribution. No provider/keychain/user-history access or paid reque was used. These sittings do not substitute for final fresh build, CI, native Windows, bare/enforced coverage, physical mechanism evidence or independent whole-wave rounds. +### Sitting: Wave 3 declared-source secret flow — ADR-0113 (2026-10-16) + +A NEW independent cumulative sitting is **clean** at `5d8c0b0c`. The parent reads +the full report and joins its mandatory checklist with the separate two NEW +complete correctness rounds in the [Step 3 acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-3-acceptance--2026-10-16). +Earlier nonclean and interrupted sittings remain nonclean/incomplete; they are +not inherited as acceptance. + +| Reviewed control | Actual evidence | +|---|---| +| Owned source classification and raw-value exclusion at actual producers | Existing linear graph, frozen definitions shared by analysis/resolution, immutable input membership, fresh/resume/quoted preparation and caller-mutation regressions | +| Public text and isolated VM consumers | Whole-template refusal before capabilities/defaults, required empty-text metadata, actual QuickJS/standard handlers, private transport, original deadline, result/error parity and finite observed-access controls | +| Real persistence and meaningful positives | Actual CLI host + file-backed SQLite; exact clean event/step/run outputs, nonempty scans of all three required columns, downstream gates and same/fresh-engine resupply | +| Load-bearing controls and baseline provenance | Independent inspection of nine fixed/removal/restored cohorts / 27 zero-unhandled receipts and retained baseline source/native identities; author execution and reviewer inspection remain distinct | + +Fresh reviewer execution passes **19 files / 1,156 tests**, zero skips. Nine former +primitive discrepancies now require actual baseline equality. No unresolved +confidentiality, private-arm or ambient-authority finding is verified. Unchanged +provider/keychain/network/desktop paths are explicitly not newly recertified; +no real key, provider request, network target or production storage is exercised. +Optional external tool provenance remains unverified with no current mapping +producer. Raw trusted inputs/resolvers/custom executors and arbitrary/historical +content retain their documented limits; no universal content scrubbing, history +rewrite or attempted-access detector is claimed. Exact contracts remain in the +[expression specification](../reference/shared-core/expression-sandbox-spec.md#secret-context-boundary). + ## Sandbox and tool policy (`run_command`, node tools, secret inputs) This is the **binding home** for the tool-policy rules; the rationale is From b11b26d7cd20830060961fc97032c8e6ba5dd335 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 05:11:12 +0300 Subject: [PATCH 042/222] feat(shared): add common error ancestry and validate event envelopes Preserve native constructor and family policy while sharing correlation validation without replacing full event payloads. Retain public envelope exports and pin cause, payload and forward-reader compatibility. Refs: Wave 3 Step 4 (#213, #219), ADR-0011 Co-Authored-By: Codex --- packages/shared/src/canonical.ts | 4 +- .../src/event-envelope-compatibility.test.ts | 365 ++++++++++++++++++ packages/shared/src/existing-ancestor.ts | 4 +- packages/shared/src/index.ts | 1 + packages/shared/src/media-admission.ts | 4 +- packages/shared/src/relavium-error.test.ts | 108 ++++++ packages/shared/src/relavium-error.ts | 9 + packages/shared/src/run-event.ts | 14 +- packages/shared/src/run-history-errors.ts | 4 +- packages/shared/src/run-suspension.ts | 4 +- packages/shared/src/run.ts | 10 +- packages/shared/src/usage-evidence.ts | 4 +- 12 files changed, 518 insertions(+), 13 deletions(-) create mode 100644 packages/shared/src/event-envelope-compatibility.test.ts create mode 100644 packages/shared/src/relavium-error.test.ts create mode 100644 packages/shared/src/relavium-error.ts diff --git a/packages/shared/src/canonical.ts b/packages/shared/src/canonical.ts index de169a84..4f37c78b 100644 --- a/packages/shared/src/canonical.ts +++ b/packages/shared/src/canonical.ts @@ -1,3 +1,5 @@ +import { RelaviumError } from './relavium-error.js'; + /** * Deterministic serialization — the one canonical form two independent implementations must agree on. */ @@ -9,7 +11,7 @@ * and an `undefined` property both as `null`. Each of those merges two distinguishable values into one * digest, in a function whose only job is to tell values apart. */ -export class NonCanonicalValueError extends Error { +export class NonCanonicalValueError extends RelaviumError { constructor(message: string) { super(message); this.name = 'NonCanonicalValueError'; diff --git a/packages/shared/src/event-envelope-compatibility.test.ts b/packages/shared/src/event-envelope-compatibility.test.ts new file mode 100644 index 00000000..d7659341 --- /dev/null +++ b/packages/shared/src/event-envelope-compatibility.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, it } from 'vitest'; + +import { + BaseEventSchema, + HumanGatePausedEventSchema, + RunEventSchema, + RunOrSessionEventSchema, + SessionEventSchema, + parseStoredRunEvent, + type BaseEvent, + type HumanGateEvent, +} from './index.js'; + +const envelope = { timestamp: '2026-10-16T12:00:00.000Z', sequenceNumber: 3 }; +const runEnvelope = { ...envelope, runId: 'run-envelope-regression' }; +const sessionEnvelope = { ...envelope, sessionId: 'session-envelope-regression' }; +const usageCoverage = { unverified: false, pendingInvocations: 1, unknownCalls: 2 }; +const invocation = { + producerId: 'producer-envelope-regression', + ordinal: 2, + dispatchId: 'dispatch-envelope-regression', + round: 1, + chainAttempt: 1, +}; + +// A computed own property is data, unlike an object literal's __proto__ setter syntax. +const inputs = { + ['__proto__']: { admitted: ['own-property', 7] }, + nested: { values: [null, false, { result: 'retained' }] }, + secretInput: { secret: true, ref: 'inputs.secretInput' }, +}; +const runStarted = { + type: 'run:started', + ...runEnvelope, + workflowId: '11111111-2222-4333-8444-555555555555', + inputs, + executionMode: 'local', + cumulativeCostMicrocents: 19, + cumulativeUnpricedCalls: 2, + usageCoverage, +}; +const gatePaused = { + type: 'human_gate:paused', + ...runEnvelope, + nodeId: 'review-node', + gateId: 'review-gate', + gateType: 'review', + message: 'Review the result', + assignee: 'reviewer', + timeoutMs: 1000, + timeoutAction: 'reject', + expiresAt: '2026-10-16T12:00:01.000Z', +}; +const authorization = { + type: 'budget:authorization', + ...runEnvelope, + nodeId: 'agent-node', + gateId: 'budget-gate', + authorization: { + state: 'paused', + allowance: { + kind: 'frozen', + quote: { + kind: 'unpriced', + excludedEntries: [ + { + index: 0, + model: 'unpriced-model', + provider: 'openai', + endpoint: 'custom', + reason: 'unpriced_model', + }, + ], + }, + }, + spentMicrocents: 19, + limitMicrocents: 20, + timeoutMs: 1000, + timeoutAction: 'reject', + expiresAt: '2026-10-16T12:00:01.000Z', + }, +}; +const sessionCompleted = { + type: 'session:turn_completed', + ...sessionEnvelope, + stopReason: 'error', + tokensUsed: { input: 15, output: 5, model: 'model-envelope-regression' }, + error: { + code: 'tool_failed', + message: 'Tool execution failed', + retryable: true, + correlationId: 'failure-envelope-regression', + }, + toolHistory: [ + { + call: { type: 'tool_call', id: 'session-tool:1:0', name: 'read_file', argsBytes: 22 }, + result: { + type: 'tool_result', + toolCallId: 'session-tool:1:0', + resultBytes: 37, + outcome: 'error', + }, + }, + ], + cumulativeCostMicrocents: 19, + cumulativeUnpricedCalls: 2, + usageCoverage, +}; +const dualPayloads = [ + { + type: 'agent:approval_requested', + nodeId: 'agent-node', + toolId: 'write_file', + action: 'fs_write', + preview: { path: '/workspace/result.txt' }, + attemptNumber: 2, + }, + { + type: 'budget:estimate_committed', + nodeId: 'agent-node', + model: 'model-envelope-regression', + attemptNumber: 2, + estimateMicrocents: 500, + cumulativeConservativeMicrocents: 700, + }, + { + type: 'cost:usage_disposition', + nodeId: 'agent-node', + model: 'model-envelope-regression', + invocation, + disposition: { kind: 'incomplete', evidence: { kind: 'missing', reason: 'not_reported' } }, + receiptPending: true, + usageCoverage, + }, +]; + +describe('event envelope compatibility', () => { + describe('BaseEventSchema public correlation contract', () => { + it.each([ + ['run', runEnvelope], + ['session', sessionEnvelope], + ])('accepts the %s envelope through the retained BaseEvent export', (_family, correlation) => { + const expected = { type: 'agent:token', ...correlation }; + const parsed: BaseEvent = BaseEventSchema.parse(expected); + expect(parsed).toEqual(expected); + }); + + it.each([ + ['neither', envelope], + ['both', { ...runEnvelope, sessionId: sessionEnvelope.sessionId }], + ])('rejects %s correlation keys directly', (_case, correlation) => { + // safeParse keeps the causal failure an AssertionError rather than an unrelated parse throw. + expect( + BaseEventSchema.safeParse({ type: 'agent:token', ...correlation }).success, + 'EVENT_ENVELOPE_CORRELATION', + ).toBe(false); + }); + }); + + describe('complete event payload preservation', () => { + it.each([ + ['run admission and own __proto__ inputs', RunEventSchema, runStarted], + ['budget authorization', RunEventSchema, authorization], + ['human gate pause', RunEventSchema, gatePaused], + [ + 'human gate resume', + RunEventSchema, + { + type: 'human_gate:resumed', + ...runEnvelope, + nodeId: 'review-node', + gateId: 'review-gate', + decision: 'input_provided', + decidedBy: 'reviewer', + payload: { nested: [{ answer: 42 }] }, + }, + ], + ['session terminal and tool history', SessionEventSchema, sessionCompleted], + ] as const)('preserves %s in its family and the bus schema', (_case, schema, expected) => { + expect(schema.parse(expected), 'FULL_EVENT_PAYLOAD_PRESERVATION').toEqual(expected); + expect(RunOrSessionEventSchema.parse(expected), 'FULL_EVENT_PAYLOAD_PRESERVATION').toEqual( + expected, + ); + }); + + it('retains own __proto__ input data without changing the record prototype', () => { + const parsed = RunEventSchema.parse(runStarted); + expect(parsed.type).toBe('run:started'); + if (parsed.type !== 'run:started') throw new Error('expected run admission event'); + expect(Object.hasOwn(parsed.inputs, '__proto__')).toBe(true); + expect(parsed.inputs['__proto__']).toEqual({ admitted: ['own-property', 7] }); + expect(Object.getPrototypeOf(parsed.inputs)).toBe(Object.prototype); + }); + + it('retains the public HumanGateEvent convenience type', () => { + const parsed: HumanGateEvent = HumanGatePausedEventSchema.parse(gatePaused); + expect(parsed).toEqual(gatePaused); + }); + + it.each(dualPayloads)('preserves $type on both complete correlation envelopes', (payload) => { + for (const correlation of [runEnvelope, sessionEnvelope]) { + const expected = { ...correlation, ...payload }; + expect(RunEventSchema.parse(expected), 'FULL_EVENT_PAYLOAD_PRESERVATION').toEqual(expected); + expect(RunOrSessionEventSchema.parse(expected), 'FULL_EVENT_PAYLOAD_PRESERVATION').toEqual( + expected, + ); + } + }); + + it.each(dualPayloads)( + 'refuses both and neither keys for $type in complete schemas', + (payload) => { + for (const correlation of [ + envelope, + { ...runEnvelope, sessionId: sessionEnvelope.sessionId }, + ]) { + const candidate = { ...correlation, ...payload }; + expect(RunEventSchema.safeParse(candidate).success).toBe(false); + expect(RunOrSessionEventSchema.safeParse(candidate).success).toBe(false); + } + }, + ); + + it.each([ + ['run', RunEventSchema, runStarted, { sessionId: 'opposite-session' }], + ['session', SessionEventSchema, sessionCompleted, { runId: 'opposite-run' }], + ] as const)( + 'strips opposite keys and future optional fields on %s-only variants', + (_case, schema, expected, opposite) => { + const candidate = { ...expected, ...opposite, futureOptional: { feature: true } }; + expect(schema.parse(candidate)).toEqual(expected); + expect(RunOrSessionEventSchema.parse(candidate)).toEqual(expected); + }, + ); + + it('keeps accounting-boundary strict unknown-field rejection', () => { + const payload = dualPayloads[2]; + const candidate = { ...runEnvelope, ...payload, futureOptional: true }; + expect(RunEventSchema.safeParse(candidate).success).toBe(false); + expect(RunOrSessionEventSchema.safeParse(candidate).success).toBe(false); + }); + }); + + describe('existing cross-field guards', () => { + const mediaSubmitted = { + type: 'media_job:submitted', + ...runEnvelope, + nodeId: 'media-node', + jobId: 'media-job', + provider: 'openai', + model: 'video-model', + modality: 'video', + startedAt: envelope.timestamp, + deadlineAt: '2026-10-16T12:00:01.000Z', + }; + + it.each([ + ['gate timeout requires duration', { ...gatePaused, timeoutMs: undefined }, 'timeoutAction'], + [ + 'pause requires a reason', + { type: 'run:paused', ...runEnvelope, pendingGateCount: 0, gateIds: [] }, + 'gateIds', + ], + [ + 'pause count matches gates', + { type: 'run:paused', ...runEnvelope, pendingGateCount: 2, gateIds: ['gate-1'] }, + 'pendingGateCount', + ], + [ + 'media deadline follows submit', + { ...mediaSubmitted, deadlineAt: '2026-10-16T11:59:59.000Z' }, + 'deadlineAt', + ], + [ + 'frozen media cost requires units', + { ...mediaSubmitted, acceptedCostMicrocents: 10 }, + 'units', + ], + [ + 'approval action matches preview', + { ...runEnvelope, ...dualPayloads[0], preview: { command: 'touch result.txt' } }, + 'preview', + ], + [ + 'conservative total includes commitment', + { ...runEnvelope, ...dualPayloads[1], cumulativeConservativeMicrocents: 499 }, + 'cumulativeConservativeMicrocents', + ], + [ + 'realized total includes charge', + { + type: 'cost:attempt_settled', + ...runEnvelope, + nodeId: 'agent-node', + model: 'model', + attemptNumber: 1, + inputTokens: 10, + outputTokens: 5, + costMicrocents: 20, + cumulativeCostMicrocents: 19, + priced: true, + }, + 'cumulativeCostMicrocents', + ], + [ + 'receipt requires incomplete disposition', + { + ...runEnvelope, + ...dualPayloads[2], + disposition: { kind: 'not_invoked', reason: 'cancelled' }, + }, + 'receiptPending', + ], + ] as const)('retains %s', (_case, candidate, issueField) => { + const parsed = RunEventSchema.safeParse(candidate); + expect(parsed.success).toBe(false); + if (!parsed.success) { + expect(parsed.error.issues.some((issue) => issue.path[0] === issueField)).toBe(true); + } + expect(RunOrSessionEventSchema.safeParse(candidate).success).toBe(false); + }); + }); + + describe('stored run forward-reader discriminator boundary', () => { + it.each([ + ['missing body', { type: 'test:never_real_envelope_event' }], + [ + 'invalid body', + { + type: 'test:never_real_envelope_event', + runId: '', + sessionId: 7, + sequenceNumber: -1, + timestamp: null, + }, + ], + ])('drops an unknown nonempty type with %s', (_case, candidate) => { + expect(parseStoredRunEvent(candidate)).toBeUndefined(); + expect(RunEventSchema.safeParse(candidate).success).toBe(false); + }); + + it.each([ + ['missing type', {}], + ['empty type', { type: '' }], + ['numeric type', { type: 7 }], + ['null type', { type: null }], + ['known type with missing body', { type: 'run:started' }], + ['known type with bad envelope', { ...runStarted, sequenceNumber: -1 }], + ['known type with bad payload', { ...runStarted, workflowId: 'invalid-uuid' }], + [ + 'known type with broken cross-field invariant', + { ...runEnvelope, ...dualPayloads[1], cumulativeConservativeMicrocents: 0 }, + ], + ])('throws for %s', (_case, candidate) => { + expect(() => parseStoredRunEvent(candidate)).toThrow(); + }); + + it('returns the complete known payload while stripping future optional fields', () => { + expect( + parseStoredRunEvent({ ...runStarted, futureOptional: true }), + 'FULL_EVENT_PAYLOAD_PRESERVATION', + ).toEqual(runStarted); + }); + }); +}); diff --git a/packages/shared/src/existing-ancestor.ts b/packages/shared/src/existing-ancestor.ts index e699a0b2..371fff01 100644 --- a/packages/shared/src/existing-ancestor.ts +++ b/packages/shared/src/existing-ancestor.ts @@ -1,4 +1,6 @@ /** Host-injected path inspection; this shared algorithm has no platform imports. */ +import { RelaviumError } from './relavium-error.js'; + export interface ExistingAncestorOperations { readonly realpath: (path: string) => Promise; readonly lstat: (path: string) => Promise<{ isDirectory(): boolean }>; @@ -8,7 +10,7 @@ export interface ExistingAncestorOperations { } /** Content-free failure: raw host errors can contain an absolute path or sensitive filename. */ -export class ExistingAncestorError extends Error { +export class ExistingAncestorError extends RelaviumError { readonly reason: 'unresolvable' | 'not_directory' | 'no_ancestor'; constructor(reason: ExistingAncestorError['reason']) { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 952fc3eb..7fb68275 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -18,6 +18,7 @@ * the public surface is the named domain schemas and their inferred types below. */ +export * from './relavium-error.js'; export * from './bytes.js'; export * from './canonical.js'; export * from './deep-equal.js'; diff --git a/packages/shared/src/media-admission.ts b/packages/shared/src/media-admission.ts index d1496f0b..88c60da8 100644 --- a/packages/shared/src/media-admission.ts +++ b/packages/shared/src/media-admission.ts @@ -1,3 +1,5 @@ +import { RelaviumError } from './relavium-error.js'; + import { MEDIA_HANDLE_PATTERN, mediaModalityOf, type DurableMediaMeta } from './content.js'; export const MAX_MEDIA_OBJECT_BYTES = 25 * 1024 * 1024; @@ -51,7 +53,7 @@ const MESSAGES: Readonly> = { function isMediaAdmissionErrorCode(value: unknown): value is MediaAdmissionErrorCode { return typeof value === 'string' && Object.hasOwn(MESSAGES, value); } -export class MediaAdmissionError extends Error { +export class MediaAdmissionError extends RelaviumError { readonly code: MediaAdmissionErrorCode; constructor(code: MediaAdmissionErrorCode) { super(MESSAGES[code]); diff --git a/packages/shared/src/relavium-error.test.ts b/packages/shared/src/relavium-error.test.ts new file mode 100644 index 00000000..ebc709ce --- /dev/null +++ b/packages/shared/src/relavium-error.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { + AppendConflictError, + CorruptRunEventError, + isAppendConflictError, + isCorruptRunEventError, + RelaviumError, +} from './index.js'; + +// No constructor or fields: exercise the public base's inherited native constructor directly. +class NativeForwardingError extends RelaviumError {} + +describe('RelaviumError native compatibility', () => { + it('retains native defaults without imposing a family name or discriminant', () => { + const error = new NativeForwardingError(); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(RelaviumError); + expect(error).toBeInstanceOf(NativeForwardingError); + expect(error.name).toBe('Error'); + expect(error.message).toBe(''); + for (const key of ['name', 'message', 'cause', 'code', 'kind', 'reason', 'context']) { + expect(Object.hasOwn(error, key)).toBe(false); + } + }); + + it('preserves native message descriptors and stack diagnostics', () => { + const error = new NativeForwardingError('native forwarding'); + expect(Object.getOwnPropertyDescriptor(error, 'message')).toEqual({ + value: 'native forwarding', + writable: true, + enumerable: false, + configurable: true, + }); + expect(error.toString()).toBe('Error: native forwarding'); + expect(error.stack).toContain('Error: native forwarding'); + expect(Object.keys(error)).toEqual([]); + }); + + it('distinguishes absent cause from an explicitly supplied undefined cause', () => { + for (const error of [ + new NativeForwardingError('message'), + new NativeForwardingError('message', undefined), + new NativeForwardingError('message', {}), + ]) { + expect(Object.hasOwn(error, 'cause')).toBe(false); + } + expect( + Object.getOwnPropertyDescriptor( + new NativeForwardingError('message', { cause: undefined }), + 'cause', + ), + ).toEqual({ value: undefined, writable: true, enumerable: false, configurable: true }); + }); + + it('forwards a cause by identity without cloning or changing it', () => { + const cause = Object.freeze({ detail: 'diagnostic context' }); + const error = new NativeForwardingError('message', { cause }); + expect(error.cause, 'NATIVE_ERROR_CAUSE_IDENTITY').toBe(cause); + expect(Object.getOwnPropertyDescriptor(error, 'cause')?.enumerable).toBe(false); + }); + + it('preserves actual shared constructor context and mandatory cause semantics', () => { + const cause = new SyntaxError('invalid stored JSON'); + const corrupt = new CorruptRunEventError('run-1', 3, 'run:started', cause); + expect(corrupt).toBeInstanceOf(RelaviumError); + expect(corrupt).toMatchObject({ + name: 'CorruptRunEventError', + code: 'corrupt_run_event', + runId: 'run-1', + sequenceNumber: 3, + eventType: 'run:started', + }); + expect(corrupt.cause).toBe(cause); + expect( + Object.hasOwn(new CorruptRunEventError('run-1', 3, 'run:started', undefined), 'cause'), + ).toBe(true); + + const append = new AppendConflictError('run-1', 2, 3); + expect(append).toBeInstanceOf(RelaviumError); + expect(append).toMatchObject({ + name: 'AppendConflictError', + runId: 'run-1', + expectedLastSequenceNumber: 2, + actualLastSequenceNumber: 3, + }); + for (const key of ['code', 'cause']) { + expect(Object.hasOwn(append, key)).toBe(false); + } + expect(append.incomingSequenceNumber).toBeUndefined(); + expect(new AppendConflictError('run-1', 2, 3, 2).incomingSequenceNumber).toBe(2); + expect(isAppendConflictError(append)).toBe(true); + expect(isAppendConflictError(corrupt)).toBe(false); + }); + + it('keeps the corruption guard structural across a simulated foreign class identity', () => { + // An independently defined class models another bundle; this is not an actual bundle execution. + class ForeignCorruptRunEventError extends Error { + readonly code = 'corrupt_run_event'; + } + const foreign = new ForeignCorruptRunEventError('damaged row'); + expect(foreign).not.toBeInstanceOf(CorruptRunEventError); + expect(foreign).not.toBeInstanceOf(RelaviumError); + expect(isCorruptRunEventError(foreign)).toBe(true); + expect(isCorruptRunEventError({ code: 'corrupt_run_event' })).toBe(false); + expect(isCorruptRunEventError(new Error('corrupt_run_event'))).toBe(false); + }); +}); diff --git a/packages/shared/src/relavium-error.ts b/packages/shared/src/relavium-error.ts new file mode 100644 index 00000000..b543040b --- /dev/null +++ b/packages/shared/src/relavium-error.ts @@ -0,0 +1,9 @@ +/** + * Common native Error ancestry for owned production error families. + * + * This base adds no message, name, discriminant, context, cause or serialization policy. + * Native constructor forwarding preserves each existing family's contract. Membership alone + * is neither a safe public diagnostic nor an authority/foreign-bundle classification guard; + * consumers retain their existing typed or structural boundary checks. + */ +export abstract class RelaviumError extends Error {} diff --git a/packages/shared/src/run-event.ts b/packages/shared/src/run-event.ts index 2b7ad626..29cc32eb 100644 --- a/packages/shared/src/run-event.ts +++ b/packages/shared/src/run-event.ts @@ -74,8 +74,13 @@ const dualBase = { ...timestampSeq, }; -/** The common envelope (the spec's `BaseEvent`): both correlation keys optional at the type level. */ -export const BaseEventSchema = z.object({ type: z.string(), ...dualBase }); +/** + * The common envelope (the spec's `BaseEvent`): both correlation keys optional at the type level, + * with exactly one required on parse. This is an envelope projection, not a full-event parser. + */ +export const BaseEventSchema = z + .object({ type: z.string(), ...dualBase }) + .superRefine(refineCorrelationKey); export type BaseEvent = z.infer; // --- Shared field schemas -------------------------------------------------------------------- @@ -809,7 +814,10 @@ type RunEventUnion = z.infer; * events (every `...dualBase` member — see its definition above) declare both keys as optional, so * this is where neither/both is rejected. */ -function refineCorrelationKey(event: RunEventUnion, ctx: z.RefinementCtx): void { +function refineCorrelationKey( + event: { readonly runId?: string | undefined; readonly sessionId?: string | undefined }, + ctx: z.RefinementCtx, +): void { const hasRunId = 'runId' in event && event.runId !== undefined; const hasSessionId = 'sessionId' in event && event.sessionId !== undefined; if (hasRunId === hasSessionId) { diff --git a/packages/shared/src/run-history-errors.ts b/packages/shared/src/run-history-errors.ts index 7ea4318f..47c06fae 100644 --- a/packages/shared/src/run-history-errors.ts +++ b/packages/shared/src/run-history-errors.ts @@ -1,9 +1,11 @@ +import { RelaviumError } from './relavium-error.js'; + /** * One platform-free corruption error for durable and in-memory run-history readers. * The database package re-exports this public error for existing consumers; core must not import db. * Discovery and strict replay refuse known damaged rows rather than infer partial authority. */ -export class CorruptRunEventError extends Error { +export class CorruptRunEventError extends RelaviumError { override readonly name = 'CorruptRunEventError'; readonly code = 'corrupt_run_event' as const; diff --git a/packages/shared/src/run-suspension.ts b/packages/shared/src/run-suspension.ts index 94191440..a2d75043 100644 --- a/packages/shared/src/run-suspension.ts +++ b/packages/shared/src/run-suspension.ts @@ -1,4 +1,6 @@ /** Provider-free ordered suspension history shared by checkpoint and interruption discovery. */ +import { RelaviumError } from './relavium-error.js'; + import type { BudgetAllowanceState, BudgetAuthorizationState } from './budget-authorization.js'; import type { RunEvent, @@ -18,7 +20,7 @@ export type RunSuspensionCorruption = | 'gate_state_conflict' | 'legacy_gate_join_missing' | 'legacy_gate_join_ambiguous'; -export class RunSuspensionCorruptionError extends Error { +export class RunSuspensionCorruptionError extends RelaviumError { readonly code: RunSuspensionCorruption; constructor(code: RunSuspensionCorruption) { super('the stored gate history is inconsistent'); diff --git a/packages/shared/src/run.ts b/packages/shared/src/run.ts index 2f9122fd..6f4ef979 100644 --- a/packages/shared/src/run.ts +++ b/packages/shared/src/run.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { RelaviumError } from './relavium-error.js'; + import { nonEmptyString, nonNegativeInt } from './common.js'; import { EXECUTION_MODES } from './constants.js'; import { ErrorCodeSchema, type RunEvent } from './run-event.js'; @@ -188,7 +190,7 @@ export interface RunFence { * Typed rather than a bare `Error` (error-handling.md): the engine has to tell "the write failed" from "the * write was REFUSED because someone else moved the log", and only the second one means another writer exists. */ -export class AppendConflictError extends Error { +export class AppendConflictError extends RelaviumError { override readonly name = 'AppendConflictError'; readonly runId: string; readonly expectedLastSequenceNumber: number; @@ -230,7 +232,7 @@ export function isAppendConflictError(value: unknown): value is AppendConflictEr * owner any more, and you must stop" — including stopping short of the terminal, because the run's real * outcome now belongs to somebody else (ADR-0079 §5). */ -export class LeaseFencedError extends Error { +export class LeaseFencedError extends RelaviumError { override readonly name = 'LeaseFencedError'; readonly runId: string; readonly ownerId: string; @@ -509,7 +511,7 @@ export function blocksResume(record: { * `ToolEffectNeedsAttentionError` exists for, and a store that swallowed it reported durable success for an * effect nothing recorded. */ -export class EffectTransitionError extends Error { +export class EffectTransitionError extends RelaviumError { override readonly name = 'EffectTransitionError'; readonly identity: EffectIdentity; readonly attemptedState: EffectState; @@ -534,7 +536,7 @@ export function isEffectTransitionError(value: unknown): value is EffectTransiti } /** Another attempt already holds this {@link EffectIdentity} — the concurrency collision, not a fault. */ -export class EffectConflictError extends Error { +export class EffectConflictError extends RelaviumError { override readonly name = 'EffectConflictError'; readonly identity: EffectIdentity; diff --git a/packages/shared/src/usage-evidence.ts b/packages/shared/src/usage-evidence.ts index 0e393984..b3f73a5b 100644 --- a/packages/shared/src/usage-evidence.ts +++ b/packages/shared/src/usage-evidence.ts @@ -1,5 +1,7 @@ /** Canonical complete quantities and inert W8 evidence foundations (ADR-0104). */ import { z } from 'zod'; + +import { RelaviumError } from './relavium-error.js'; import { MEDIA_BILLED_MODALITIES } from './constants.js'; const nonNegativeInt = z.number().int().nonnegative(); @@ -123,7 +125,7 @@ export const UsageEvidenceSchema = z.discriminatedUnion('kind', [ export type UsageEvidence = z.infer; /** The owned boundary never exposes input, Zod issues or a hostile trap's cause. */ -export class InvalidUsageEvidenceError extends Error { +export class InvalidUsageEvidenceError extends RelaviumError { readonly code = 'invalid_usage_evidence'; constructor() { super('Invalid usage evidence'); From a1fb05e1908c96b3cd8840ff912d774a523d07d5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 05:11:12 +0300 Subject: [PATCH 043/222] refactor(llm): preserve error families on shared ancestry Keep config descendants and the plain normalized LlmError seam intact. Preserve wrapper context and constructor semantics. Refs: Wave 3 Step 4 (#213, #219), ADR-0011 Co-Authored-By: Codex --- .../llm/src/error-base-compatibility.test.ts | 62 +++++++++++++++++++ packages/llm/src/errors.ts | 4 +- packages/llm/src/llm-error.ts | 3 +- 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 packages/llm/src/error-base-compatibility.test.ts diff --git a/packages/llm/src/error-base-compatibility.test.ts b/packages/llm/src/error-base-compatibility.test.ts new file mode 100644 index 00000000..3a8ec8ba --- /dev/null +++ b/packages/llm/src/error-base-compatibility.test.ts @@ -0,0 +1,62 @@ +import { RelaviumError } from '@relavium/shared'; +import { describe, expect, it } from 'vitest'; + +import { + LlmConfigError, + LlmProviderError, + makeLlmError, + ToolSchemaError, + UnknownModelError, +} from './index.js'; + +describe('LLM error family compatibility', () => { + it('retains config descendants and caller-owned structured context', () => { + const knownModels = Object.freeze(['model-a', 'model-b']); + const error = new UnknownModelError('model-c', knownModels); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(RelaviumError); + expect(error).toBeInstanceOf(LlmConfigError); + expect(error).toBeInstanceOf(UnknownModelError); + expect(error).toMatchObject({ + name: 'UnknownModelError', + code: 'unknown_model', + modelId: 'model-c', + message: "unknown model id 'model-c' — not in the pricing table", + }); + expect(error.knownModels).toBe(knownModels); + expect(Object.hasOwn(error, 'cause')).toBe(false); + }); + + it('retains the reason and provider fields of a second config descendant', () => { + const error = new ToolSchemaError('gemini', 'lookup', 'unsupported keyword'); + expect(error).toBeInstanceOf(RelaviumError); + expect(error).toBeInstanceOf(LlmConfigError); + expect(error).toMatchObject({ + name: 'ToolSchemaError', + code: 'unsupported_tool_schema', + provider: 'gemini', + toolName: 'lookup', + reason: 'unsupported keyword', + }); + }); + + it('retains the plain normalized seam value and wrapper identity without projecting fields', () => { + const cause = new Error('transport unavailable'); + const llmError = makeLlmError({ + provider: 'openai', + kind: 'transport', + message: 'provider unavailable', + cause, + }); + expect(llmError).not.toBeInstanceOf(Error); + const error = new LlmProviderError(llmError); + expect(error).toBeInstanceOf(RelaviumError); + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ name: 'LlmProviderError', message: 'provider unavailable' }); + expect(error.llmError).toBe(llmError); + expect(error.llmError.cause).toBe(cause); + for (const key of ['code', 'kind', 'cause', 'retryable']) { + expect(Object.hasOwn(error, key)).toBe(false); + } + }); +}); diff --git a/packages/llm/src/errors.ts b/packages/llm/src/errors.ts index 4c38c8c4..0f24ae23 100644 --- a/packages/llm/src/errors.ts +++ b/packages/llm/src/errors.ts @@ -1,4 +1,4 @@ -import { extractHttpsHost } from '@relavium/shared'; +import { RelaviumError, extractHttpsHost } from '@relavium/shared'; import type { CapabilityFlags, ProviderId } from './types.js'; @@ -20,7 +20,7 @@ export type LlmConfigErrorCode = | 'invalid_base_url'; /** Base for the seam's thrown config errors — narrow on `code`, never on `message`. */ -export abstract class LlmConfigError extends Error { +export abstract class LlmConfigError extends RelaviumError { abstract readonly code: LlmConfigErrorCode; } diff --git a/packages/llm/src/llm-error.ts b/packages/llm/src/llm-error.ts index e4fd5cef..f0b2b372 100644 --- a/packages/llm/src/llm-error.ts +++ b/packages/llm/src/llm-error.ts @@ -1,4 +1,5 @@ import { + RelaviumError, ownUsageEvidence, InvalidUsageEvidenceError, stripTerminalControls, @@ -76,7 +77,7 @@ export function kindFromHttpStatus(status: number): LlmErrorKind { * `FallbackChain` (1.K) catches this and narrows on `.llmError` for its retry/fatal decision. A * `stream` surfaces the same `LlmError` in an `error` `StreamChunk` instead of throwing. */ -export class LlmProviderError extends Error { +export class LlmProviderError extends RelaviumError { readonly llmError: LlmError; constructor(llmError: LlmError) { From a8a255e1f55c22b36ebe2d555c4ef040a37e0fad Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 05:11:12 +0300 Subject: [PATCH 044/222] refactor(core): retain engine error contracts on shared ancestry Migrate owned roots without changing constructors, discriminants or private authority. Preserve structural foreign-budget classification and existing parser, tool and durability family contracts. Refs: Wave 3 Step 4 (#213, #219), ADR-0011 Co-Authored-By: Codex --- packages/core/src/agent-parser.ts | 4 +- packages/core/src/engine/agent-session.ts | 4 +- packages/core/src/engine/agent-turn.ts | 4 +- packages/core/src/engine/budget-governor.ts | 8 +- .../core/src/engine/dispatch-allowance.ts | 4 +- .../engine/error-base-compatibility.test.ts | 184 ++++++++++++++++++ packages/core/src/engine/errors.ts | 4 +- packages/core/src/engine/invariant-error.ts | 4 +- packages/core/src/engine/media-pin-error.ts | 4 +- packages/core/src/engine/money-durability.ts | 4 +- packages/core/src/errors.ts | 10 +- packages/core/src/tools/errors.ts | 4 +- 12 files changed, 218 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/engine/error-base-compatibility.test.ts diff --git a/packages/core/src/agent-parser.ts b/packages/core/src/agent-parser.ts index 6085f391..74a3c28c 100644 --- a/packages/core/src/agent-parser.ts +++ b/packages/core/src/agent-parser.ts @@ -14,7 +14,7 @@ import { schemaIssueReason } from './schema-issue.js'; import { LineCounter, YAMLParseError } from 'yaml'; import type { ZodIssue } from 'zod'; -import { AgentSchema, type Agent } from '@relavium/shared'; +import { RelaviumError, AgentSchema, type Agent } from '@relavium/shared'; import { MAX_SOURCE_CHARS } from './parser.js'; import { outputSchemaRefusals } from './output-schema.js'; @@ -34,7 +34,7 @@ export interface ParseAgentOptions { * For an `agent_syntax` YAML fault the `line`/`column` (1-based) of the fault are attached when the loader * reports a position — parity with `WorkflowSyntaxError` (errors.ts) — and are also folded into the message. */ -export class AgentParseError extends Error { +export class AgentParseError extends RelaviumError { readonly code: 'agent_syntax' | 'agent_validation'; readonly fields: readonly string[]; /** 1-based line of a YAML syntax fault, when the loader reports a position (`agent_syntax` only). */ diff --git a/packages/core/src/engine/agent-session.ts b/packages/core/src/engine/agent-session.ts index 381247bf..b0dd867e 100644 --- a/packages/core/src/engine/agent-session.ts +++ b/packages/core/src/engine/agent-session.ts @@ -39,7 +39,7 @@ import type { EffectCorrelation, EffectDispatchPort, } from '@relavium/shared'; -import { createSessionToolCallId, unwiredEffectJournal } from '@relavium/shared'; +import { RelaviumError, createSessionToolCallId, unwiredEffectJournal } from '@relavium/shared'; import { WorkflowGraphError, type GraphIssue } from '../errors.js'; import { collectAgentCeilingIssues } from '../limits.js'; @@ -440,7 +440,7 @@ export interface AgentSessionParams { type SessionStatus = 'created' | 'idle' | 'running' | 'cancelled'; /** A typed, secret-free session API-misuse error (never a raw string throw — error-handling.md). */ -export class SessionStateError extends Error { +export class SessionStateError extends RelaviumError { override readonly name = 'SessionStateError'; constructor( readonly code: 'not_started' | 'already_started' | 'not_active' | 'invalid_resume_state', diff --git a/packages/core/src/engine/agent-turn.ts b/packages/core/src/engine/agent-turn.ts index dcd738da..e2977e15 100644 --- a/packages/core/src/engine/agent-turn.ts +++ b/packages/core/src/engine/agent-turn.ts @@ -72,7 +72,7 @@ import { type ToolDef as LlmToolDef, } from '@relavium/llm'; -import { SessionToolHistoryEntrySchema } from '@relavium/shared'; +import { RelaviumError, SessionToolHistoryEntrySchema } from '@relavium/shared'; import { ADMISSION_CEILINGS } from '../limits.js'; import { ToolDispatchError } from '../tools/errors.js'; @@ -311,7 +311,7 @@ export interface AgentTurnResult { * the run-path adapter maps it to a `NodeOutcome.failed`. The internal correlation id rides the * event, never this message. */ -export class AgentTurnError extends Error { +export class AgentTurnError extends RelaviumError { override readonly name = 'AgentTurnError'; /** * The accumulated token usage of the turn at the moment it failed (EA2, ADR-0055). Populated by diff --git a/packages/core/src/engine/budget-governor.ts b/packages/core/src/engine/budget-governor.ts index 3dbbb61f..34c5b7f7 100644 --- a/packages/core/src/engine/budget-governor.ts +++ b/packages/core/src/engine/budget-governor.ts @@ -1,3 +1,5 @@ +import { RelaviumError } from '@relavium/shared'; + import { estimateResolvedRequestCost, outputTokensReservation, @@ -88,7 +90,7 @@ export type BudgetExceededReason = * Thrown when a priced pre-egress projection exceeds a configured `on_exceed: fail` cap, or when strict-cost mode * refuses an unpriced prospective call. The turn/run adapter maps this to the `budget_exceeded` `ErrorCode`. */ -export class BudgetExceededError extends Error { +export class BudgetExceededError extends RelaviumError { override readonly name = 'BudgetExceededError'; /** * The stable discriminant (D10). Callers narrow on `.code`, never on `.name` or `.message` — and never on @@ -128,7 +130,7 @@ export class BudgetExceededError extends Error { * `on_exceed: pause_for_approval` is set. The agent runner maps this to a `paused` node outcome * (reusing the human-gate seam) so the run can be resumed via `engine.resume(runId, gateId, decision)`. */ -export class BudgetPauseError extends Error { +export class BudgetPauseError extends RelaviumError { override readonly name = 'BudgetPauseError'; /** The stable discriminant (D10) — see {@link BudgetExceededError.code}. */ readonly code = 'budget_paused' as const; @@ -295,7 +297,7 @@ export function isBudgetPauseError(value: unknown): value is BudgetPauseError { * consuming cap capacity when this throws — §2: "a persistence failure never releases capacity; it fails the * active owner loudly while preserving the conservative reservation in memory for the terminal path." */ -export class CommitmentDurabilityError extends Error { +export class CommitmentDurabilityError extends RelaviumError { override readonly name = 'CommitmentDurabilityError'; readonly code = 'commitment_not_durable' as const; diff --git a/packages/core/src/engine/dispatch-allowance.ts b/packages/core/src/engine/dispatch-allowance.ts index eb7090ed..e04f1e58 100644 --- a/packages/core/src/engine/dispatch-allowance.ts +++ b/packages/core/src/engine/dispatch-allowance.ts @@ -1,3 +1,5 @@ +import { RelaviumError } from '@relavium/shared'; + const TOKEN = Symbol('dispatch-allowance'); /** Opaque process-local ownership; the book's WeakMap is authoritative, not the object's shape. */ @@ -7,7 +9,7 @@ export interface DispatchAllowanceToken { export type AllowanceRefusal = 'owner_invalid' | 'in_flight' | 'exhausted'; -export class InvalidDispatchAllowanceError extends Error { +export class InvalidDispatchAllowanceError extends RelaviumError { readonly code = 'invalid_dispatch_allowance'; constructor( readonly reason: diff --git a/packages/core/src/engine/error-base-compatibility.test.ts b/packages/core/src/engine/error-base-compatibility.test.ts new file mode 100644 index 00000000..8829acbd --- /dev/null +++ b/packages/core/src/engine/error-base-compatibility.test.ts @@ -0,0 +1,184 @@ +import { RelaviumError } from '@relavium/shared'; +import { describe, expect, it } from 'vitest'; + +import { + BudgetExceededError, + BudgetPauseError, + CommitmentDurabilityError, + EngineStateError, + isBudgetExceededError, + isBudgetPauseError, + ToolDispatchError, + ToolExecutionError, + WorkflowParseError, + WorkflowSyntaxError, +} from '../index.js'; +import { AgentTurnError } from './agent-turn.js'; + +// Distinct native Error subclasses simulate foreign bundle identities, not actual bundled execution. +class ForeignBudgetExceededError extends Error { + readonly code = 'budget_exceeded'; + constructor(readonly limitMicrocents: unknown) { + super('foreign budget refusal'); + } +} + +class ForeignBudgetPauseError extends Error { + readonly code = 'budget_paused'; + constructor(readonly thresholdPct: unknown) { + super('foreign budget pause'); + } +} + +describe('engine error family compatibility', () => { + it('preserves parser descendants, source position and opaque cause identity', () => { + const cause = new SyntaxError('invalid YAML'); + const error = new WorkflowSyntaxError('workflow syntax is invalid', { + source: 'workflow.relavium.yaml', + line: 2, + column: 4, + cause, + }); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(RelaviumError); + expect(error).toBeInstanceOf(WorkflowParseError); + expect(error).toBeInstanceOf(WorkflowSyntaxError); + expect(error).toMatchObject({ + name: 'WorkflowSyntaxError', + code: 'invalid_yaml', + source: 'workflow.relavium.yaml', + line: 2, + column: 4, + }); + expect(error.cause).toBe(cause); + const withoutContext = new WorkflowSyntaxError('invalid YAML', { cause: undefined }); + expect(Object.hasOwn(withoutContext, 'cause')).toBe(false); + expect(withoutContext.source).toBeUndefined(); + expect(withoutContext.line).toBeUndefined(); + expect(withoutContext.column).toBeUndefined(); + }); + + it('preserves API-boundary context identity and its optional cause convention', () => { + const issues = Object.freeze([]); + const cause = Object.freeze({ reason: 'storage unavailable' }); + const error = new EngineStateError('unknown_run', 'unknown run', { + runId: 'run-1', + gateId: 'gate-1', + cause, + issues, + }); + expect(error).toBeInstanceOf(RelaviumError); + expect(error).toMatchObject({ + name: 'EngineStateError', + code: 'unknown_run', + runId: 'run-1', + gateId: 'gate-1', + }); + expect(error.issues).toBe(issues); + expect(error.cause).toBe(cause); + expect( + Object.hasOwn( + new EngineStateError('unknown_run', 'unknown run', { cause: undefined }), + 'cause', + ), + ).toBe(false); + }); + + it('preserves tool family ancestry and independently selected execution policy', () => { + const cause = new Error('host failure'); + const error = new ToolExecutionError('read_file', 'read failed', cause, { + recoverable: true, + retryable: false, + }); + expect(error).toBeInstanceOf(RelaviumError); + expect(error).toBeInstanceOf(ToolDispatchError); + expect(error).toMatchObject({ + name: 'ToolExecutionError', + code: 'execution_failed', + runErrorCode: 'tool_failed', + toolId: 'read_file', + recoverable: true, + retryable: false, + }); + expect(error.cause).toBe(cause); + expect(Object.hasOwn(new ToolExecutionError('read_file', 'read failed'), 'cause')).toBe(false); + }); + + it('preserves mandatory undefined cause and node attribution on durability errors', () => { + const error = new CommitmentDurabilityError(undefined, 'agent-node'); + expect(error).toBeInstanceOf(RelaviumError); + expect(error).toMatchObject({ + name: 'CommitmentDurabilityError', + code: 'commitment_not_durable', + nodeId: 'agent-node', + }); + expect(Object.hasOwn(error, 'cause')).toBe(true); + expect(error.cause).toBeUndefined(); + }); + + it('retains actual budget constructor reason and threshold context', () => { + const exceeded = new BudgetExceededError(4, 10, undefined); + const paused = new BudgetPauseError(4, 10, 80); + expect(exceeded).toBeInstanceOf(RelaviumError); + expect(paused).toBeInstanceOf(RelaviumError); + expect(exceeded).toMatchObject({ + name: 'BudgetExceededError', + code: 'budget_exceeded', + spentMicrocents: 4, + limitMicrocents: 10, + projectedMicrocents: undefined, + reason: 'unpriced_model', + }); + expect(paused).toMatchObject({ + name: 'BudgetPauseError', + code: 'budget_paused', + spentMicrocents: 4, + limitMicrocents: 10, + thresholdPct: 80, + }); + expect(isBudgetExceededError(exceeded)).toBe(true); + expect(isBudgetPauseError(paused)).toBe(true); + }); + + it('recognizes simulated foreign budgets without local family or shared base identity', () => { + const exceeded = new ForeignBudgetExceededError(10); + const paused = new ForeignBudgetPauseError(80); + expect(exceeded).not.toBeInstanceOf(BudgetExceededError); + expect(paused).not.toBeInstanceOf(BudgetPauseError); + expect(exceeded).not.toBeInstanceOf(RelaviumError); + expect(paused).not.toBeInstanceOf(RelaviumError); + expect(isBudgetExceededError(exceeded), 'FOREIGN_BUDGET_GUARD').toBe(true); + expect(isBudgetPauseError(paused), 'FOREIGN_BUDGET_GUARD').toBe(true); + expect(isBudgetPauseError(exceeded)).toBe(false); + expect(isBudgetExceededError(paused)).toBe(false); + }); + + it.each([undefined, null, '10', {}])( + 'refuses a foreign budget with nonnumeric field %j', + (field) => { + expect(isBudgetExceededError(new ForeignBudgetExceededError(field))).toBe(false); + expect(isBudgetPauseError(new ForeignBudgetPauseError(field))).toBe(false); + }, + ); + + it('refuses code collisions, opposite shape fields and non-Error lookalikes', () => { + const collision = new AgentTurnError('budget_exceeded', 'turn budget failed', false); + expect(collision).toBeInstanceOf(RelaviumError); + expect(isBudgetExceededError(collision)).toBe(false); + expect(isBudgetPauseError(collision)).toBe(false); + for (const candidate of [ + Object.assign(new Error('wrong field'), { code: 'budget_exceeded', thresholdPct: 80 }), + Object.assign(new Error('wrong field'), { code: 'budget_paused', limitMicrocents: 10 }), + Object.assign(new Error('wrong code'), { + code: 'other', + limitMicrocents: 10, + thresholdPct: 80, + }), + { code: 'budget_exceeded', limitMicrocents: 10 }, + { code: 'budget_paused', thresholdPct: 80 }, + ]) { + expect(isBudgetExceededError(candidate)).toBe(false); + expect(isBudgetPauseError(candidate)).toBe(false); + } + }); +}); diff --git a/packages/core/src/engine/errors.ts b/packages/core/src/engine/errors.ts index d05ad1d5..9703e03f 100644 --- a/packages/core/src/engine/errors.ts +++ b/packages/core/src/engine/errors.ts @@ -13,6 +13,8 @@ * (a UUID / opaque id, never a secret) and never carries run inputs, a node output, or a host stack. */ +import { RelaviumError } from '@relavium/shared'; + import type { InputAdmissionIssue } from './input-admission.js'; /** Stable discriminant for an engine-API-boundary fault — narrow on this, never on `message`. */ @@ -67,7 +69,7 @@ export function isTransientEngineStateError(error: EngineStateError): boolean { * `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 { +export class EngineStateError extends RelaviumError { readonly code: EngineStateErrorCode; /** The run this fault concerns, when applicable — a `runId` (UUID), never a secret. */ readonly runId?: string; diff --git a/packages/core/src/engine/invariant-error.ts b/packages/core/src/engine/invariant-error.ts index e825be4a..33dfb18c 100644 --- a/packages/core/src/engine/invariant-error.ts +++ b/packages/core/src/engine/invariant-error.ts @@ -21,6 +21,8 @@ * is secret-free — it describes the structural breach, never a draft payload. */ +import { RelaviumError } from '@relavium/shared'; + /** Stable discriminant for a run-loop substrate invariant breach — narrow on this, never on `message`. */ export type RunLoopInvariantCode = | 'both_correlation_keys' // an event draft carried BOTH runId and sessionId (exactly one is required) @@ -30,7 +32,7 @@ export type RunLoopInvariantCode = | 'media_store_unavailable' // a media-bearing event was emitted but no MediaStore was injected (1.AF, I3) | 'event_too_large'; // a NON-terminal durable event exceeded CR-32's size bound (ADR-0087 §2); a terminal is exempt -export class RunLoopInvariantError extends Error { +export class RunLoopInvariantError extends RelaviumError { readonly code: RunLoopInvariantCode; constructor(code: RunLoopInvariantCode, message: string) { diff --git a/packages/core/src/engine/media-pin-error.ts b/packages/core/src/engine/media-pin-error.ts index 0189d611..486bee06 100644 --- a/packages/core/src/engine/media-pin-error.ts +++ b/packages/core/src/engine/media-pin-error.ts @@ -23,7 +23,7 @@ * also why the reason has to be this specific. */ -import { MediaAdmissionError } from '@relavium/shared'; +import { RelaviumError, MediaAdmissionError } from '@relavium/shared'; import { ADMISSION_CEILINGS } from '../limits.js'; @@ -66,7 +66,7 @@ interface CapturedPinFailure { // A prototype, public JS constructor call or mutable Error field cannot register this authority. const PIN_FAILURES = new WeakMap(); -export class NodeMediaPinError extends Error { +export class NodeMediaPinError extends RelaviumError { override readonly name = 'NodeMediaPinError'; readonly failure: NodeFailure; diff --git a/packages/core/src/engine/money-durability.ts b/packages/core/src/engine/money-durability.ts index b2c94649..22f10d57 100644 --- a/packages/core/src/engine/money-durability.ts +++ b/packages/core/src/engine/money-durability.ts @@ -22,6 +22,8 @@ * without consuming an error; it is not a money barrier or a durability certificate. */ +import { RelaviumError } from '@relavium/shared'; + import type { UsageDisposition, UsageInvocation } from '@relavium/shared'; export interface PendingUsageDraft { @@ -39,7 +41,7 @@ export interface UsageDispositionDraft { } /** A realized-cost ledger write that did not reach the store. Carries the owning node for attribution. */ -export class LedgerDurabilityError extends Error { +export class LedgerDurabilityError extends RelaviumError { readonly nodeId: string | undefined; constructor(cause: unknown, nodeId?: string) { diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 7b50c61e..0bd37681 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -14,6 +14,8 @@ * against a run scope. */ +import { RelaviumError } from '@relavium/shared'; + export type WorkflowParseErrorCode = | 'invalid_yaml' | 'schema_validation' @@ -81,7 +83,7 @@ export interface SecretLeak { } /** Base for every parser error — callers narrow on `code`, never on `message`. */ -export abstract class WorkflowParseError extends Error { +export abstract class WorkflowParseError extends RelaviumError { abstract readonly code: WorkflowParseErrorCode; /** A workspace-relative source label, when the caller supplied one (never an absolute path). */ readonly source?: string; @@ -219,7 +221,7 @@ export type InterpolationErrorCode = * message names the offending reference (its verbatim `{{ … }}`) and never a resolved value; an * absolute path from a host `readFile` failure stays on the `cause` (for logs), never in the message. */ -export class InterpolationError extends Error { +export class InterpolationError extends RelaviumError { readonly code: InterpolationErrorCode; /** The offending `{{ … }}` occurrence, verbatim — names the reference, never a resolved value. */ readonly location?: string; @@ -239,7 +241,7 @@ export class InterpolationError extends Error { } /** Missing/malformed explicit provenance at a public execution boundary (ADR-0113). */ -export class WorkflowContextError extends Error { +export class WorkflowContextError extends RelaviumError { readonly code = 'validation'; readonly retryable = false; constructor() { @@ -277,7 +279,7 @@ export type SandboxErrorReason = * Both are **non-enumerable** (like a thrown error's `cause`), so a naive `JSON.stringify(err)` / * spread cannot leak them. Callers narrow on {@link reason} / {@link retryable}, never on `message`. */ -export class SandboxError extends Error { +export class SandboxError extends RelaviumError { /** The closed-enum run `ErrorCode` this maps to (sse-event-schema.md). */ readonly code = 'sandbox_error'; /** Stable fault discriminant — the one field policy (retry vs fail) branches on. */ diff --git a/packages/core/src/tools/errors.ts b/packages/core/src/tools/errors.ts index eac36b20..6c8a8b05 100644 --- a/packages/core/src/tools/errors.ts +++ b/packages/core/src/tools/errors.ts @@ -8,6 +8,8 @@ * [tool-registry.md](../../../../docs/reference/shared-core/tool-registry.md#error-taxonomy) and ADR-0037. */ +import { RelaviumError } from '@relavium/shared'; + import type { ErrorCode } from '@relavium/shared'; import type { ToolId } from './types.js'; @@ -25,7 +27,7 @@ export type ToolErrorCode = | '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 { +export abstract class ToolDispatchError extends RelaviumError { /** The tool-error discriminant. */ abstract readonly code: ToolErrorCode; /** The closed run `ErrorCode` this maps to (sse-event-schema.md). */ From 72eccc8fd985a2b0b02b31e0e2f3f9731ce6e0f1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 05:11:12 +0300 Subject: [PATCH 045/222] refactor(db): preserve storage error families on shared ancestry Retain existing causes, context, aliases and boundary guards while joining the shared native error hierarchy. Refs: Wave 3 Step 4 (#213, #219), ADR-0011 Co-Authored-By: Codex --- packages/db/src/client.ts | 4 +- .../db/src/error-base-compatibility.test.ts | 86 +++++++++++++++++++ packages/db/src/media-store-paths.ts | 4 +- packages/db/src/media-store-temporaries.ts | 4 +- packages/db/src/media-write.ts | 3 +- packages/db/src/model-catalog-store.ts | 3 +- packages/db/src/provider-store.ts | 4 +- packages/db/src/run-history-store.ts | 3 +- packages/db/src/safe-egress.ts | 3 +- packages/db/src/session-effect-privacy.ts | 4 +- packages/db/src/session-effect-turns.ts | 4 +- packages/db/src/session-store.ts | 3 +- packages/db/src/session-usage-write.ts | 4 +- 13 files changed, 116 insertions(+), 13 deletions(-) create mode 100644 packages/db/src/error-base-compatibility.test.ts diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index caf7cf84..ca70f80c 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,3 +1,5 @@ +import { RelaviumError } from '@relavium/shared'; + import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -73,7 +75,7 @@ export type DbOpenErrorCode = * carries the OS username, which a user-facing error is not supposed to leak. A caller that wants to show it * still can, redacted, from the field. */ -export class DbOpenError extends Error { +export class DbOpenError extends RelaviumError { readonly code: DbOpenErrorCode; /** The database path that was requested. A diagnostic field — deliberately NOT part of `message`. */ readonly path: string; diff --git a/packages/db/src/error-base-compatibility.test.ts b/packages/db/src/error-base-compatibility.test.ts new file mode 100644 index 00000000..13116517 --- /dev/null +++ b/packages/db/src/error-base-compatibility.test.ts @@ -0,0 +1,86 @@ +import { RelaviumError } from '@relavium/shared'; +import { describe, expect, it } from 'vitest'; + +import { + isProviderHeaderPolicyError, + isUnreadableRunEventLogError, + MediaEgressError, + MediaWriteError, + ModelCatalogCapabilitiesError, + ProviderHeaderPolicyError, + SafeEgressError, + UnreadableRunEventLogError, +} from './index.js'; + +describe('database error family compatibility', () => { + it('preserves optional native ErrorOptions on the catalog error without inventing a code', () => { + const absent = new ModelCatalogCapabilitiesError('invalid capabilities'); + const explicit = new ModelCatalogCapabilitiesError('invalid capabilities', { + cause: undefined, + }); + const cause = new SyntaxError('invalid JSON'); + const wrapped = new ModelCatalogCapabilitiesError('invalid capabilities', { cause }); + expect(absent).toBeInstanceOf(Error); + expect(absent).toBeInstanceOf(RelaviumError); + expect(absent).toMatchObject({ + name: 'ModelCatalogCapabilitiesError', + message: 'invalid capabilities', + }); + expect(Object.hasOwn(absent, 'cause')).toBe(false); + expect(Object.hasOwn(explicit, 'cause')).toBe(true); + expect(explicit.cause).toBeUndefined(); + expect(wrapped.cause).toBe(cause); + expect(Object.hasOwn(wrapped, 'code')).toBe(false); + }); + + it('preserves code-free write errors and the public egress alias identity', () => { + const write = new MediaWriteError('filesystem write failed'); + expect(write).toBeInstanceOf(RelaviumError); + expect(write).toMatchObject({ name: 'MediaWriteError', message: 'filesystem write failed' }); + expect(Object.hasOwn(write, 'code')).toBe(false); + expect(Object.hasOwn(write, 'cause')).toBe(false); + expect(MediaEgressError).toBe(SafeEgressError); + const egress = new MediaEgressError('network', 'connection failed'); + expect(egress).toBeInstanceOf(RelaviumError); + expect(egress).toBeInstanceOf(SafeEgressError); + expect(egress).toMatchObject({ + name: 'SafeEgressError', + code: 'network', + message: 'connection failed', + }); + }); + + it('preserves unreadable-log context and recognizes a simulated foreign error identity', () => { + const sequences = Object.freeze([2, 4]); + const error = new UnreadableRunEventLogError('run-1', sequences); + expect(error).toBeInstanceOf(RelaviumError); + expect(error).toMatchObject({ + name: 'UnreadableRunEventLogError', + code: 'unreadable_run_event_log', + runId: 'run-1', + }); + expect(error.skippedSequenceNumbers).toBe(sequences); + // Independently defined simulation; no actual second package bundle is loaded here. + class ForeignUnreadableRunEventLogError extends Error { + readonly code = 'unreadable_run_event_log'; + } + const foreign = new ForeignUnreadableRunEventLogError('cannot replay'); + expect(foreign).not.toBeInstanceOf(UnreadableRunEventLogError); + expect(foreign).not.toBeInstanceOf(RelaviumError); + expect(isUnreadableRunEventLogError(foreign)).toBe(true); + expect(isUnreadableRunEventLogError({ code: 'unreadable_run_event_log' })).toBe(false); + expect(isUnreadableRunEventLogError(new Error('cannot replay'))).toBe(false); + }); + + it('retains the fixed local provider-header diagnostic without cause or context additions', () => { + const error = new ProviderHeaderPolicyError(); + expect(error).toBeInstanceOf(RelaviumError); + expect(error).toMatchObject({ + name: 'ProviderHeaderPolicyError', + code: 'provider_header_policy', + }); + expect(isProviderHeaderPolicyError(error)).toBe(true); + expect(Object.hasOwn(error, 'cause')).toBe(false); + expect(Object.hasOwn(error, 'context')).toBe(false); + }); +}); diff --git a/packages/db/src/media-store-paths.ts b/packages/db/src/media-store-paths.ts index 4794d309..5e246763 100644 --- a/packages/db/src/media-store-paths.ts +++ b/packages/db/src/media-store-paths.ts @@ -1,8 +1,10 @@ +import { RelaviumError } from '@relavium/shared'; + import { lstatSync, mkdirSync, realpathSync, statSync, type Stats } from 'node:fs'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; /** Reason-only host failure; native errors may contain private absolute filenames. */ -export class MediaStoreFilesystemError extends Error { +export class MediaStoreFilesystemError extends RelaviumError { constructor(readonly reason: 'missing' | 'unsafe_path' | 'io') { super( reason === 'missing' diff --git a/packages/db/src/media-store-temporaries.ts b/packages/db/src/media-store-temporaries.ts index fdb71f0c..80f52645 100644 --- a/packages/db/src/media-store-temporaries.ts +++ b/packages/db/src/media-store-temporaries.ts @@ -1,3 +1,5 @@ +import { RelaviumError } from '@relavium/shared'; + import { randomUUID } from 'node:crypto'; import { constants, lstatSync, readdirSync, realpathSync, type Stats } from 'node:fs'; import { mkdir, open, rename, rmdir, unlink } from 'node:fs/promises'; @@ -22,7 +24,7 @@ export interface MediaTemporarySweepResult { readonly retained: number; readonly reasons: Readonly>>; } -export class MediaTemporaryMaintenanceError extends Error { +export class MediaTemporaryMaintenanceError extends RelaviumError { constructor( readonly reason: 'flags_required' | 'producer_active' | 'owner_unknown' | 'maintenance_active', ) { diff --git a/packages/db/src/media-write.ts b/packages/db/src/media-write.ts index 36ece0ea..bda58602 100644 --- a/packages/db/src/media-write.ts +++ b/packages/db/src/media-write.ts @@ -3,6 +3,7 @@ import { lstat, mkdir, realpath, rename, rm, writeFile } from 'node:fs/promises' import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'; import { + RelaviumError, deepestExistingReal, type AbortSignalLike, type MediaWritePort, @@ -39,7 +40,7 @@ export function createFilesystemMediaWrite(scopeRoot: string): MediaWritePort { } /** A media-write failure naming a reason only (never a path / the bytes) — mirrors `MediaEgressError`. */ -export class MediaWriteError extends Error { +export class MediaWriteError extends RelaviumError { constructor(message: string) { super(message); this.name = 'MediaWriteError'; diff --git a/packages/db/src/model-catalog-store.ts b/packages/db/src/model-catalog-store.ts index 00ec93e6..e8e7b40c 100644 --- a/packages/db/src/model-catalog-store.ts +++ b/packages/db/src/model-catalog-store.ts @@ -1,4 +1,5 @@ import { + RelaviumError, MEDIA_SURFACES, MODEL_CATALOG_SOURCES, type MediaSurface, @@ -301,7 +302,7 @@ function toListing(row: ModelCatalogRow): ModelCatalogListing { * isolates a single corrupt row: the host D15 capability projection swallows THIS to defer one model, but must * let a genuine store fault propagate. Names a reason only — never the column bytes. */ -export class ModelCatalogCapabilitiesError extends Error { +export class ModelCatalogCapabilitiesError extends RelaviumError { constructor(message: string, options?: ErrorOptions) { super(message, options); this.name = 'ModelCatalogCapabilitiesError'; diff --git a/packages/db/src/provider-store.ts b/packages/db/src/provider-store.ts index 33d19348..27810845 100644 --- a/packages/db/src/provider-store.ts +++ b/packages/db/src/provider-store.ts @@ -1,4 +1,4 @@ -import { PROVIDER_KINDS, type ProviderKind } from '@relavium/shared'; +import { RelaviumError, PROVIDER_KINDS, type ProviderKind } from '@relavium/shared'; import { and, asc, eq, isNull } from 'drizzle-orm'; import type { Db } from './client.js'; @@ -70,7 +70,7 @@ const HEADER_POLICY_MESSAGE = const INVALID_STORED_HEADERS = 'Stored provider headers are invalid.'; /** Content-free interim admission refusal (ADR-0108); never retain the supplied map or a foreign cause. */ -export class ProviderHeaderPolicyError extends Error { +export class ProviderHeaderPolicyError extends RelaviumError { readonly code = 'provider_header_policy'; constructor() { diff --git a/packages/db/src/run-history-store.ts b/packages/db/src/run-history-store.ts index 5f90f3ab..e48f0463 100644 --- a/packages/db/src/run-history-store.ts +++ b/packages/db/src/run-history-store.ts @@ -1,4 +1,5 @@ import { + RelaviumError, AppendConflictError, CorruptRunEventError, LeaseFencedError, @@ -201,7 +202,7 @@ export { CorruptRunEventError, isCorruptRunEventError } from '@relavium/shared'; * Distinct from {@link CorruptRunEventError}: that one means the DATA is damaged and upgrading will not help; * this one means the data is fine and this binary is too old, so the remedy is real and the user can perform it. */ -export class UnreadableRunEventLogError extends Error { +export class UnreadableRunEventLogError extends RelaviumError { override readonly name = 'UnreadableRunEventLogError'; readonly code = 'unreadable_run_event_log' as const; diff --git a/packages/db/src/safe-egress.ts b/packages/db/src/safe-egress.ts index 7603aace..2366de90 100644 --- a/packages/db/src/safe-egress.ts +++ b/packages/db/src/safe-egress.ts @@ -4,6 +4,7 @@ import { request as httpsRequest } from 'node:https'; import { isIP } from 'node:net'; import { + RelaviumError, extractEgressAuthority, isMetadataOrLinkLocal, isPrivateOrLocalHost, @@ -54,7 +55,7 @@ export const SAFE_EGRESS_ERROR_CODES = [ export type SafeEgressErrorCode = (typeof SAFE_EGRESS_ERROR_CODES)[number]; /** A typed egress failure. The `message` names a reason only — never the url/IP/bytes (secret-free). */ -export class SafeEgressError extends Error { +export class SafeEgressError extends RelaviumError { readonly code: SafeEgressErrorCode; constructor(code: SafeEgressErrorCode, message: string) { super(message); diff --git a/packages/db/src/session-effect-privacy.ts b/packages/db/src/session-effect-privacy.ts index 3931b848..eaca2b29 100644 --- a/packages/db/src/session-effect-privacy.ts +++ b/packages/db/src/session-effect-privacy.ts @@ -1,3 +1,5 @@ +import { RelaviumError } from '@relavium/shared'; + import { and, gte, isNotNull, lt } from 'drizzle-orm'; import type { Db } from './client.js'; @@ -8,7 +10,7 @@ import { runEffects } from './schema.js'; export type SessionEffectCheckpoint = 'complete' | 'deferred'; /** Fixed diagnostics: driver errors and stored content must never become disclosure text. */ -export class SessionEffectPrivacyError extends Error { +export class SessionEffectPrivacyError extends RelaviumError { constructor(readonly code: 'transaction_active' | 'checkpoint_failed') { super( code === 'transaction_active' diff --git a/packages/db/src/session-effect-turns.ts b/packages/db/src/session-effect-turns.ts index d8c636bc..f4ed14b8 100644 --- a/packages/db/src/session-effect-turns.ts +++ b/packages/db/src/session-effect-turns.ts @@ -1,4 +1,6 @@ /** ADR-0098: durable session effect identity, separate from the reconstructed max_turns counter. */ +import { RelaviumError } from '@relavium/shared'; + import { and, eq, gte, inArray, lt, or } from 'drizzle-orm'; import type { Db } from './client.js'; @@ -8,7 +10,7 @@ import { agentSessions, runEffects, sessionMessages } from './schema.js'; type Executor = Db | Parameters[0]>[0]; -export class SessionEffectTurnError extends Error { +export class SessionEffectTurnError extends RelaviumError { readonly code: 'session_missing' | 'history_invalid' | 'key_exhausted' | 'transaction_active'; constructor(code: SessionEffectTurnError['code']) { super('session effect-turn key could not be allocated'); diff --git a/packages/db/src/session-store.ts b/packages/db/src/session-store.ts index 17d237b2..9f56e8e6 100644 --- a/packages/db/src/session-store.ts +++ b/packages/db/src/session-store.ts @@ -1,4 +1,5 @@ import { + RelaviumError, AgentSessionSchema, canonicalJson, SessionMessageSchema, @@ -71,7 +72,7 @@ export interface SessionMessageMeta { readonly finishReason?: string; } -export class SessionMessageBoundaryError extends Error { +export class SessionMessageBoundaryError extends RelaviumError { readonly code: 'message_invalid' | 'metadata_invalid'; constructor(code: 'message_invalid' | 'metadata_invalid') { // Unknown property names and JSON syntax diagnostics can themselves contain tool content. Refusal diff --git a/packages/db/src/session-usage-write.ts b/packages/db/src/session-usage-write.ts index 50091828..b294d149 100644 --- a/packages/db/src/session-usage-write.ts +++ b/packages/db/src/session-usage-write.ts @@ -1,3 +1,5 @@ +import { RelaviumError } from '@relavium/shared'; + import { eq } from 'drizzle-orm'; import type { Db, TxDb } from './client.js'; @@ -5,7 +7,7 @@ import { withBusyRetry } from './retry.js'; import { agentSessions } from './schema.js'; /** Fixed, content-free failures of the session coverage foundation (ADR-0104). */ -export class SessionUsageError extends Error { +export class SessionUsageError extends RelaviumError { readonly code: | 'transaction_active' | 'history_invalid' From 22832a14f27fc2e0ca0a8362728316b0580ec262 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 05:11:13 +0300 Subject: [PATCH 046/222] refactor(mcp): preserve transport error family ancestry Retain MCP descendants, native options, cancellation names and connection-failure classification. Refs: Wave 3 Step 4 (#213, #219), ADR-0011 Co-Authored-By: Codex --- .../mcp/src/error-base-compatibility.test.ts | 56 +++++++++++++++++++ packages/mcp/src/errors.ts | 4 +- 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 packages/mcp/src/error-base-compatibility.test.ts diff --git a/packages/mcp/src/error-base-compatibility.test.ts b/packages/mcp/src/error-base-compatibility.test.ts new file mode 100644 index 00000000..cf52fbce --- /dev/null +++ b/packages/mcp/src/error-base-compatibility.test.ts @@ -0,0 +1,56 @@ +import { RelaviumError } from '@relavium/shared'; +import { describe, expect, it } from 'vitest'; + +import { McpAbortedError, McpConnectError, McpDeadlineError, McpError } from './index.js'; + +describe('MCP error family compatibility', () => { + it('preserves native options forwarding and the code-free family name', () => { + const absent = new McpError('MCP failed'); + const empty = new McpError('MCP failed', {}); + const explicit = new McpError('MCP failed', { cause: undefined }); + const cause = Object.freeze({ detail: 'connection diagnostic' }); + const wrapped = new McpError('MCP failed', { cause }); + expect(wrapped).toBeInstanceOf(Error); + expect(wrapped).toBeInstanceOf(RelaviumError); + expect(wrapped).toMatchObject({ name: 'McpError', message: 'MCP failed' }); + expect(wrapped.cause).toBe(cause); + expect(Object.hasOwn(absent, 'cause')).toBe(false); + expect(Object.hasOwn(empty, 'cause')).toBe(false); + expect(Object.hasOwn(explicit, 'cause')).toBe(true); + expect(explicit.cause).toBeUndefined(); + expect(Object.hasOwn(wrapped, 'code')).toBe(false); + }); + + it('retains deadline and cancellation descendants and their own phase context', () => { + const deadline = new McpDeadlineError('server-1', 'connect', 250); + const cancelled = new McpAbortedError('server-1', 'connect'); + for (const error of [deadline, cancelled]) { + expect(error).toBeInstanceOf(RelaviumError); + expect(error).toBeInstanceOf(McpError); + expect(error).toMatchObject({ serverId: 'server-1', phase: 'connect' }); + expect(Object.hasOwn(error, 'code')).toBe(false); + expect(Object.hasOwn(error, 'cause')).toBe(false); + } + expect(deadline.name).toBe('McpDeadlineError'); + expect(deadline.timeoutMs).toBe(250); + expect(cancelled.name).toBe('McpAbortedError'); + }); + + it('retains structural connect classification and opaque foreign cause identity', () => { + // This independently defined native error simulates a foreign transport, not a bundled package. + class ForeignNetworkError extends Error { + readonly code = 'ECONNREFUSED'; + } + const cause = new ForeignNetworkError('connection refused'); + const error = new McpConnectError('server-1', { cause }); + expect(error).toBeInstanceOf(RelaviumError); + expect(error).toBeInstanceOf(McpError); + expect(error).toMatchObject({ + name: 'McpConnectError', + serverId: 'server-1', + reason: 'network', + }); + expect(error.cause).toBe(cause); + expect(Object.hasOwn(error, 'code')).toBe(false); + }); +}); diff --git a/packages/mcp/src/errors.ts b/packages/mcp/src/errors.ts index d07d33ec..38f727b4 100644 --- a/packages/mcp/src/errors.ts +++ b/packages/mcp/src/errors.ts @@ -6,8 +6,10 @@ * surface `cause` verbatim in a structured `--json` envelope or a `RunEvent` — it strips it at that boundary. */ +import { RelaviumError } from '@relavium/shared'; + /** Base class for every `@relavium/mcp` error. */ -export class McpError extends Error { +export class McpError extends RelaviumError { constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = 'McpError'; From 84bad49d5920d156cf3dc50719e4ec66cef1ec6a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 05:11:13 +0300 Subject: [PATCH 047/222] refactor(cli): retain surface error contracts on shared ancestry Preserve CLI exits, config errors, host denials and keychain error semantics without treating common ancestry as safe diagnostics. Refs: Wave 3 Step 4 (#213, #219), ADR-0011 Co-Authored-By: Codex --- apps/cli/src/engine/mcp-consent.ts | 6 +- apps/cli/src/engine/tool-host/errors.ts | 4 +- .../process/error-base-compatibility.test.ts | 71 +++++++++++++++++++ apps/cli/src/process/errors.ts | 4 +- apps/cli/src/secrets/keychain.ts | 4 +- 5 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 apps/cli/src/process/error-base-compatibility.test.ts diff --git a/apps/cli/src/engine/mcp-consent.ts b/apps/cli/src/engine/mcp-consent.ts index e6e318fb..c4235e6d 100644 --- a/apps/cli/src/engine/mcp-consent.ts +++ b/apps/cli/src/engine/mcp-consent.ts @@ -13,7 +13,7 @@ import { import { access, realpath, stat } from 'node:fs/promises'; import { dirname, isAbsolute, resolve } from 'node:path'; -import { canonicalJson } from '@relavium/shared'; +import { RelaviumError, canonicalJson } from '@relavium/shared'; import { z } from 'zod'; import { stringifyJsonLine } from '../render/sanitize.js'; @@ -77,7 +77,7 @@ export interface ResolvedStdioSpawn { } /** Why a declaration could not be resolved to a program — a refusal before any prompt. */ -export class StdioResolutionError extends Error { +export class StdioResolutionError extends RelaviumError { constructor(message: string) { super(message); this.name = 'StdioResolutionError'; @@ -365,7 +365,7 @@ function refuseSymlink(path: string): void { } /** The grant store could not be written safely. A refusal, never a silent skip. */ -export class ConsentStoreError extends Error { +export class ConsentStoreError extends RelaviumError { constructor(message: string) { super(message); this.name = 'ConsentStoreError'; diff --git a/apps/cli/src/engine/tool-host/errors.ts b/apps/cli/src/engine/tool-host/errors.ts index 134df8de..6094633b 100644 --- a/apps/cli/src/engine/tool-host/errors.ts +++ b/apps/cli/src/engine/tool-host/errors.ts @@ -1,3 +1,5 @@ +import { RelaviumError } from '@relavium/shared'; + import { ToolDispatchError } from '@relavium/core'; import type { AbortSignalLike, ErrorCode } from '@relavium/shared'; @@ -12,7 +14,7 @@ import type { AbortSignalLike, ErrorCode } from '@relavium/shared'; * **reason only** — never a path, the bytes, the command, or an env value (the I3 boundary). Maps to the * retryable `tool_failed`. `name` is set from the concrete subclass so stacks/logs read accurately. */ -export class HostCapabilityError extends Error { +export class HostCapabilityError extends RelaviumError { constructor(message: string) { super(message); this.name = new.target.name; diff --git a/apps/cli/src/process/error-base-compatibility.test.ts b/apps/cli/src/process/error-base-compatibility.test.ts new file mode 100644 index 00000000..d3434e78 --- /dev/null +++ b/apps/cli/src/process/error-base-compatibility.test.ts @@ -0,0 +1,71 @@ +import { ToolDispatchError } from '@relavium/core'; +import { RelaviumError } from '@relavium/shared'; +import { describe, expect, it } from 'vitest'; + +import { ConfigError } from '../config/errors.js'; +import { + EgressCapabilityError, + EgressDeniedError, + HostCapabilityError, + HostDeniedError, +} from '../engine/tool-host/errors.js'; +import { CliError, isCliError, toUserFacing } from './errors.js'; + +describe('CLI error family compatibility', () => { + it('retains config descendants, contextual fields, cause identity and exit projection', () => { + const cause = new SyntaxError('invalid TOML'); + const error = new ConfigError('config.toml', 'invalid syntax', { cause }); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(RelaviumError); + expect(error).toBeInstanceOf(CliError); + expect(error).toBeInstanceOf(ConfigError); + expect(error).toMatchObject({ + name: 'ConfigError', + code: 'config_error', + filePath: 'config.toml', + message: 'config.toml: invalid syntax', + exitCode: 2, + }); + expect(error.cause).toBe(cause); + expect(isCliError(error)).toBe(true); + expect(toUserFacing(error)).toEqual({ + code: 'config_error', + message: 'config.toml: invalid syntax', + exitCode: 2, + }); + }); + + it('preserves CLI optional-cause omission instead of adopting native explicit-undefined presence', () => { + for (const error of [ + new CliError('invalid_invocation', 'invalid invocation'), + new CliError('invalid_invocation', 'invalid invocation', { cause: undefined }), + new ConfigError('config.toml', 'invalid syntax', { cause: undefined }), + ]) { + expect(Object.hasOwn(error, 'cause')).toBe(false); + } + expect(isCliError(new EgressCapabilityError('network failed'))).toBe(false); + }); + + it('retains new.target names and the distinct host capability and denial family contracts', () => { + const capability = new EgressCapabilityError('connection failed'); + expect(capability).toBeInstanceOf(RelaviumError); + expect(capability).toBeInstanceOf(HostCapabilityError); + expect(capability.name).toBe('EgressCapabilityError'); + expect(Object.hasOwn(capability, 'code')).toBe(false); + + const denied = new EgressDeniedError('domain denied', true); + expect(denied).toBeInstanceOf(RelaviumError); + expect(denied).toBeInstanceOf(ToolDispatchError); + expect(denied).toBeInstanceOf(HostDeniedError); + expect(denied).toMatchObject({ + name: 'EgressDeniedError', + code: 'tool_denied', + runErrorCode: 'tool_denied', + retryable: false, + recoverable: true, + }); + expect(new EgressDeniedError('domain denied').recoverable).toBe(false); + expect(denied.toolId).toBeUndefined(); + expect(Object.hasOwn(denied, 'cause')).toBe(false); + }); +}); diff --git a/apps/cli/src/process/errors.ts b/apps/cli/src/process/errors.ts index d5b39585..44d5fee4 100644 --- a/apps/cli/src/process/errors.ts +++ b/apps/cli/src/process/errors.ts @@ -1,3 +1,5 @@ +import { RelaviumError } from '@relavium/shared'; + import { AgentParseError, EngineStateError, @@ -50,7 +52,7 @@ const EXIT_CODE_BY_ERROR: Readonly> = { * `message` is **user-facing** and must never contain a secret, raw payload, stack trace, * or internal path. */ -export class CliError extends Error { +export class CliError extends RelaviumError { readonly code: CliErrorCode; readonly exitCode: ExitCode; diff --git a/apps/cli/src/secrets/keychain.ts b/apps/cli/src/secrets/keychain.ts index 674f6916..7584faf9 100644 --- a/apps/cli/src/secrets/keychain.ts +++ b/apps/cli/src/secrets/keychain.ts @@ -10,6 +10,8 @@ * header at call time; the `llm_providers` row stores only the `account` ref ([ADR-0006](../../../../docs/decisions/0006-os-keychain-for-api-keys.md)). */ +import { RelaviumError } from '@relavium/shared'; + const DEFAULT_KEY_ID = 'default'; /** The keychain `account` for a provider key — `{providerId}:{keyId}` (keychain-and-secrets.md §Entry naming). */ @@ -26,7 +28,7 @@ export function keychainAccount(providerId: string, keyId: string = DEFAULT_KEY_ * plaintext store, so this does not violate the no-silent-plaintext-fallback rule — * keychain-and-secrets.md §Operational notes.) */ -export class KeychainUnavailableError extends Error { +export class KeychainUnavailableError extends RelaviumError { constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = 'KeychainUnavailableError'; From b11703a0273596765be8b5143e2922d35660c62e Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 05:11:13 +0300 Subject: [PATCH 048/222] docs(docs): record error envelope contracts and author evidence Record the current production-family inventory, preservation-sensitive envelope behavior and attributed author controls. Independent cumulative reviews remain required before Step 5. Refs: Wave 3 Step 4 (#213, #219) Co-Authored-By: Codex --- docs/reference/contracts/sse-event-schema.md | 11 ++++ .../phase-2.5.5-hardening-and-remediation.md | 61 +++++++++++++++++++ docs/standards/error-handling.md | 9 +++ 3 files changed, 81 insertions(+) diff --git a/docs/reference/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index 090eb6cc..ee820fee 100644 --- a/docs/reference/contracts/sse-event-schema.md +++ b/docs/reference/contracts/sse-event-schema.md @@ -38,6 +38,17 @@ interface BaseEvent { > **Correlation key.** Exactly one of `runId` / `sessionId` is present — `runId` on a workflow run, `sessionId` on an agent session. The reused `agent:token` / `agent:reasoning` / `agent:tool_call` / `agent:tool_result` / `cost:updated` / `budget:estimate_committed` events carry `runId` on a run and `sessionId` on a session; `agent:approval_requested` (ADR-0057) is also dual-envelope but **session-only-emitted** in Phase 2.5. Consumers route on whichever is present. +The exported `BaseEventSchema` validates this correlation invariant and projects +only the envelope. Full event parsers share that check while retaining each +variant's declared payload; a full event must never be replaced by its envelope +projection. The exported `BaseEvent` and `HumanGateEvent` types remain available. +Correlation validation applies after variant parsing: a run-only or session-only +lenient variant still strips an undeclared opposite key, whereas a dual-envelope +variant rejects both or neither. Existing strict accounting boundaries and other +cross-field guards retain their stricter rules. The stored run reader still drops +an unknown nonempty discriminator independently of its body, while refusing a +known damaged event; envelope validation does not precede discriminator selection. + `sequenceNumber` is monotonic per run and is the basis for **gap detection**: if a consumer sees a jump in `sequenceNumber`, it triggers a full state resync (re-read the durable run state) rather than trusting a partial view. This is what makes reconnection lossless. The **envelope** fields (`sessionId` / `runId`, `sequenceNumber`, `timestamp`) are stamped by the bus, not the producer: `WorkflowEngine` emits through the `RunEventBus`, and `AgentSession` (1.V) emits *envelope-free payload drafts* through an injected `SessionEventSink` — wiring that sink onto the bus, where the per-session `sequenceNumber` (and its same gap/resync rule) is assigned, is **1.W**. So a session's monotonic numbering is the bus's responsibility, not the session core's. ## The `RunEvent` union diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 3718e1e6..bb18e232 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4567,3 +4567,64 @@ whole-wave acceptance or merge. Step 19 retains those final gates/publication. Next is Step 4's approved minimal error/envelope substrate. Read-only inventories for Steps 4–5 are preparation, not implementation acceptance. No user decision, credential, provider capture, billing or release action is pending. + + +### Wave 3 Step 4 error/envelope substrate — implementation checkpoint, 2026-10-16 + +The approved #213/#219 increment introduces the minimal shared `RelaviumError` +without a new discriminant, name, context or cause policy. Existing family +constructors and descendant parents remain unchanged. The source inventory +replaces the historical 15/17 estimates: **96 existing error declarations**, +including 86 module exports and ten private control-flow sentinels. Of those +exports, **83 belong to production** (60 unique package-root-public identities +and 23 implementation exports); three others belong to capture/conformance +tooling. Public aliases/re-exports do not create another class identity. + +Migration changes **46 direct native-Error roots across 37 production files**. +Their 37 existing descendants acquire the same ancestry through their current +parents. The new base itself is additional to the 96-declaration intake. Private +sentinels, capture/conformance errors, vendor exceptions and plain seam unions +retain their representations. This is a scoped disposition, not a claim that +every thrown value or private control-flow path has shared ancestry. + +| Package | Production classes reached | Direct roots changed | Existing roots | +| --- | ---: | ---: | --- | +| `packages/shared` | 10 | 10 | `NonCanonicalValueError`, `ExistingAncestorError`, `MediaAdmissionError`, `CorruptRunEventError`, `RunSuspensionCorruptionError`, `AppendConflictError`, `LeaseFencedError`, `EffectTransitionError`, `EffectConflictError`, `InvalidUsageEvidenceError` | +| `packages/llm` | 9 | 2 | `LlmConfigError`, `LlmProviderError` | +| `packages/core` | 29 | 16 | `AgentParseError`, `SessionStateError`, `AgentTurnError`, `BudgetExceededError`, `BudgetPauseError`, `CommitmentDurabilityError`, `InvalidDispatchAllowanceError`, `EngineStateError`, `RunLoopInvariantError`, `NodeMediaPinError`, `LedgerDurabilityError`, `WorkflowParseError`, `InterpolationError`, `WorkflowContextError`, `SandboxError`, `ToolDispatchError` | +| `packages/db` | 12 | 12 | `DbOpenError`, `MediaStoreFilesystemError`, `MediaTemporaryMaintenanceError`, `MediaWriteError`, `ModelCatalogCapabilitiesError`, `ProviderHeaderPolicyError`, `UnreadableRunEventLogError`, `SafeEgressError`, `SessionEffectPrivacyError`, `SessionEffectTurnError`, `SessionMessageBoundaryError`, `SessionUsageError` | +| `packages/mcp` | 8 | 1 | `McpError` | +| `apps/cli` | 15 | 5 | `StdioResolutionError`, `ConsentStoreError`, `HostCapabilityError`, `CliError`, `KeychainUnavailableError` | + +Existing structural budget, corruption, unreadable-history, provider-header and +media-admission boundary classifiers retain their individual acceptance rules. +Local identity guards and private WeakMap authority also remain unchanged; the +base is neither a safe-message classifier nor cross-bundle authority. New foreign +class tests are explicitly simulations, not execution of a second packed bundle. + +The retained `BaseEventSchema` now uses the same parsed correlation refinement +as the full run family. Direct both/neither cases fail; declared full event +payloads remain intact. Generic envelope projection is not substituted for a full +event. Lenient opposite-key/future-field stripping, strict accounting boundaries, +nine cross-field guards and unknown-discriminator forward reading are preserved. +The authored envelope suite first reports exactly two assertion failures on the +unchanged implementation and passes all forty controls after repair. + +Six package/surface compatibility files add thirty constructor/guard controls. +Four local fixed/removal/restored cohorts pin direct envelope correlation, full +payload preservation, native cause forwarding and foreign structural budgets. +Passing fixed/restored inventories are **40 / 40 / 6 / 11**; removal produces +**2 / 10 / 3 / 1** named assertion failures. All twelve final runner receipts +record zero unhandled errors, and exact source restoration is verified before +broad author gates. The retained controls are local author evidence, not full CI, +coverage, native-platform or whole-wave acceptance. + +Broad author checks pass: **608 files / 13,884 passing tests / 20 established +skips**, all workspace lint/typecheck, source formatting/whitespace, vendor and +platform/dependency fences, 608-file/six-workspace isolation and 3,511 relative +links across 74 changed Markdown files. Dependency outputs are built before +native/dist consumers; all six build tasks pass. + +Two NEW independent complete cumulative clean reviews remain required before +Step 5. Detailed source inventories and reports remain Git-ignored. No new ADR, +user decision, credential or external operation is needed. diff --git a/docs/standards/error-handling.md b/docs/standards/error-handling.md index 15105b40..5f840ce4 100644 --- a/docs/standards/error-handling.md +++ b/docs/standards/error-handling.md @@ -11,6 +11,15 @@ and cost accounting that are product features. ## Typed errors, never strings +Owned production error families inherit the minimal `RelaviumError` exported by +`@relavium/shared`. It forwards the native `Error` constructor without imposing a +name, discriminant, context or cause policy; each existing family owns those +contracts. Plain seam values such as `LlmError`, foreign SDK exceptions and private +control-flow sentinels retain their separate representations. Shared ancestry is +neither proof of a safe diagnostic nor a substitute for an existing structural +boundary guard: independently bundled class identities can differ. Existing local +identity and private-authority guards also retain their own contracts. + - Errors are **typed and discriminated**, never bare `Error` and never thrown strings (see [code-style-typescript.md](code-style-typescript.md)). Each error type carries a stable discriminant (a `kind` / `code`) callers narrow on — not `error.message`, which From b16c3b4c29a82396c8f060f6e3ed199e2f340476 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 05:19:36 +0300 Subject: [PATCH 049/222] docs(docs): correct step 4 inventory and causal attribution Distinguish module-exported error descendants from the unchanged private timeout descendant. Record nine direct payload assertions and one secondary test-local TypeError without conflating runner errors. Refs: Wave 3 Step 4 (#213, #219) Co-Authored-By: Codex --- .../phase-2.5.5-hardening-and-remediation.md | 38 +++++++++++++++---- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index bb18e232..cee72578 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4575,19 +4575,21 @@ The approved #213/#219 increment introduces the minimal shared `RelaviumError` without a new discriminant, name, context or cause policy. Existing family constructors and descendant parents remain unchanged. The source inventory replaces the historical 15/17 estimates: **96 existing error declarations**, -including 86 module exports and ten private control-flow sentinels. Of those +including 86 module exports and ten non-exported implementation error classes. Of those exports, **83 belong to production** (60 unique package-root-public identities and 23 implementation exports); three others belong to capture/conformance tooling. Public aliases/re-exports do not create another class identity. Migration changes **46 direct native-Error roots across 37 production files**. -Their 37 existing descendants acquire the same ancestry through their current -parents. The new base itself is additional to the 96-declaration intake. Private -sentinels, capture/conformance errors, vendor exceptions and plain seam unions -retain their representations. This is a scoped disposition, not a claim that +Their 37 existing module-exported production descendants acquire the same +ancestry through their current parents. The unchanged private `OsTimeoutError` +also inherits it through `OsCapabilityError`; it is additional to that export- +scoped count. The new base itself is additional to the 96-declaration intake. +Private class sources, capture/conformance errors, vendor exceptions and plain +seam unions retain their existing implementations. This is a scoped disposition, not a claim that every thrown value or private control-flow path has shared ancestry. -| Package | Production classes reached | Direct roots changed | Existing roots | +| Package | Module-exported production classes reached | Direct roots changed | Existing roots | | --- | ---: | ---: | --- | | `packages/shared` | 10 | 10 | `NonCanonicalValueError`, `ExistingAncestorError`, `MediaAdmissionError`, `CorruptRunEventError`, `RunSuspensionCorruptionError`, `AppendConflictError`, `LeaseFencedError`, `EffectTransitionError`, `EffectConflictError`, `InvalidUsageEvidenceError` | | `packages/llm` | 9 | 2 | `LlmConfigError`, `LlmProviderError` | @@ -4614,7 +4616,9 @@ Six package/surface compatibility files add thirty constructor/guard controls. Four local fixed/removal/restored cohorts pin direct envelope correlation, full payload preservation, native cause forwarding and foreign structural budgets. Passing fixed/restored inventories are **40 / 40 / 6 / 11**; removal produces -**2 / 10 / 3 / 1** named assertion failures. All twelve final runner receipts +**2 / 10 / 3 / 1** failed tests. The payload cohort has nine direct AssertionErrors +and one secondary test-local TypeError after its payload is removed; each cohort +contains its pinned named, marker-bearing assertion failure. All twelve final runner receipts record zero unhandled errors, and exact source restoration is verified before broad author gates. The retained controls are local author evidence, not full CI, coverage, native-platform or whole-wave acceptance. @@ -4628,3 +4632,23 @@ native/dist consumers; all six build tasks pass. Two NEW independent complete cumulative clean reviews remain required before Step 5. Detailed source inventories and reports remain Git-ignored. No new ADR, user decision, credential or external operation is needed. + + +### Wave 3 Step 4 first review corrections — 2026-10-16 + +The complete cumulative review at `b11703a0` covers all **50 changed paths / +97 hunks** and independently passes **23 files / 730 tests**. It verifies every +existing class member list unchanged, resolves public aliases, and audits all +twelve causal receipts and reconstructed mutation hashes. It reports two Low +checkpoint precision findings and no verified product defect; this sitting is +**not a clean acceptance round**. + +The parent reads the full report and verifies both findings. The inventory now +qualifies its 83-class/37-descendant counts as module-exported production and +explicitly records the additional unchanged private `OsTimeoutError` descendant. +The retained payload removal is accurately described as nine AssertionErrors +and one secondary test-local TypeError, separate from the zero-unhandled runner +receipt. Existing product and test bytes remain unchanged. These documentation +corrections do not claim a fresh source execution; author gates retain their +original source attribution. Two NEW complete cumulative clean reviews at the +corrected head remain required before Step 5. From e60b10478f5f5200f8ef55d01a6976c2dcb9fb87 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 05:39:35 +0300 Subject: [PATCH 050/222] docs(repo): accept reviewed wave 3 error envelope substrate Record two new complete clean cumulative review rounds, corrected inventory attribution and original author/causal evidence. Advance the serialized queue to tools and engine tails without inferring whole-wave CI, coverage, platform or merge acceptance. Refs: Wave 3 Step 4 (#213, #219) Co-Authored-By: Codex --- AGENTS.md | 4 +- CLAUDE.md | 4 +- docs/roadmap/current.md | 4 +- .../phase-2.5.5-hardening-and-remediation.md | 37 ++++++++++++++++++- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6a3997ec..8a3a0926 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,9 @@ Its serialized multi-file Step 1 is independently accepted on `development` (2026-10-16); Step 2 is also accepted after two NEW clean complete cumulative rounds following its historical-reader corrections. Step 3 (ADR-0113 secret provenance) is independently accepted after two NEW complete cumulative clean rounds and a -separate clean security sitting. Step 4 (error/envelope substrate) is active. +separate clean security sitting. Step 4 (error/envelope substrate) is independently +accepted after two NEW complete cumulative clean rounds; Step 5 (tools and engine +tails) is active. Later implementation and whole-wave acceptance remain staged in the canonical roadmap. diff --git a/CLAUDE.md b/CLAUDE.md index e4f19c90..b01a23ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,7 +172,9 @@ Its serialized multi-file Step 1 is independently accepted on `development` (2026-10-16); Step 2 is also accepted after two NEW clean complete cumulative rounds following its historical-reader corrections. Step 3 (ADR-0113 secret provenance) is independently accepted after two NEW complete cumulative clean rounds and a -separate clean security sitting. Step 4 (error/envelope substrate) is active. +separate clean security sitting. Step 4 (error/envelope substrate) is independently +accepted after two NEW complete cumulative clean rounds; Step 5 (tools and engine +tails) is active. Later implementation and whole-wave acceptance remain staged in the canonical roadmap. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 195caff2..e99bb02c 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1116,7 +1116,9 @@ after two NEW independent complete clean reviews. with two NEW complete clean cumulative rounds after its historical-reader corrections; [Step 3 is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-3-acceptance--2026-10-16) after two NEW complete cumulative clean rounds and a separate clean security sitting. -Step 4 (error/envelope substrate) is active. The +[Step 4 is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-4-acceptance--2026-10-16) +after two NEW complete cumulative clean rounds. Step 5 (tools and engine tails) +is active. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index cee72578..735f4945 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -206,11 +206,11 @@ reached. - **Unescape the quoted filter-argument text `parseArg` returns.** `parseArg` (`packages/core/src/interpolation/references.ts`) returns the raw backslash-escaped text of a quoted filter argument instead of unescaping it, contradicted by its own test's comment. Fix `parseArg` to unescape the quoted argument text per its documented contract, and correct the misleading test comment. *(S · `packages/core/src/interpolation/references.ts` · #82)* - ✅ **DONE ([Wave 3 Step 2](#wave-3-step-2-acceptance--2026-10-16)).** **Apply consistent duplicate/cross-field admission; certify existing input and session guards.** Shared leaf/full admission now enforces the approved duplicate, human-gate timeout and logical Run status matrices. Existing input defaults/enums and session compaction-role rules are certified. Historical session/workflow readers retain their former contracts; no rows are migrated or silently repaired. *(M · `packages/shared/src/{node,agent,workflow,session,run,common}.ts` · #89, #90, G18, #212, G16, G17, #210, #211)* - **Extend `analyzeSecretTaint`'s leak-reporting loop to cover context-value sites.** `analyzeSecretTaint` (`packages/core/src/interpolation/analyze.ts`) deliberately skips context-value sites in its leak-reporting loop, so a direct `{{secrets.*}}` reference never read elsewhere is not reported at parse time — the workflow only fails later, differently-shaped, at run start. Not an actual secret leak, but a real completeness gap in the "reject before a run starts, uniformly" contract. *(S · `packages/core/src/interpolation/analyze.ts` · G22)* -- **Delete the unused `BaseEventSchema`/`HumanGateEvent` exports or wire `BaseEventSchema` to validate the envelope it claims to model.** Both are public exports from `packages/shared/src/run-event.ts`, pinned by `index.test.ts`'s public-surface list, with zero consumers anywhere — and `BaseEventSchema` isn't even used to validate the envelope it claims to model. Delete the unused exports and pinning entries, or wire `BaseEventSchema` to actually validate the run-event envelope. *(S · `packages/shared/src/run-event.ts` · #213)* +- ✅ **DONE ([Wave 3 Step 4](#wave-3-step-4-acceptance--2026-10-16)).** **Retain and validate the public event envelope without stripping full payloads.** `BaseEventSchema` shares the full run family's parsed exactly-one-correlation check; `BaseEvent` and `HumanGateEvent` remain exported. Complete payload, strict accounting, lenient opposite-key and stored forward-reader contracts are preserved. *(S · `packages/shared/src/run-event.ts` · #213)* - **Correct `ctx.fsScope`'s wiring, blocked on a maintainer call.** `ctx.fsScope` is threaded through every dispatch context in `packages/core/src/tools/{types,registry,builtins}.ts` and `packages/core/src/engine/agent-session.ts`, but `enforcePolicy()` (`packages/core/src/tools/registry.ts`) has no `fsScoped` branch and no `FsCapability` method signature accepts it. **Blocked on a maintainer decision**: is this a deliberate "one host instance = one immutable scope" design, in which case only the doc comment needs correcting, or should it be threaded into `FsCapability`'s method signatures to support future per-turn scope escalation? *(S · `packages/core/src/tools/registry.ts` · #93)* - **Correct three stale engine doc comments to match current code.** `checkpoint.ts`'s module doc (`packages/core/src/engine/checkpoint.ts`) claims a "runId+nodeId+retryCount idempotency key" bounds resumed side effects; no such key exists. `builtins.ts`'s top comment still says egress is "not wired yet"; it shipped. `workflow-yaml-spec.md` documents `workflow.version` as Semver-format-enforced; `WorkflowSpecSchema` enforces no format. One sweep correcting each. *(S · `packages/core/src/engine/checkpoint.ts`, `packages/core/src/tools/builtins.ts`, `docs/reference/*/workflow-yaml-spec.md` · #78, #97, #214)* - **Remove the dead `RunEventBusOptions.validate` escape hatch and replace `kahnOrder`'s full re-sort with a priority structure.** `RunEventBusOptions.validate` (`packages/core/src/engine/event-bus.ts`) is documented as a hot-path escape hatch but no caller ever sets it — dead config, negligible cost today. `kahnOrder` (`packages/core/src/dag.ts`) re-sorts the entire ready-list on every pop instead of using a priority queue, also negligible at today's node counts. Both essentially-free cleanups; batch into one pass and revisit priority-queueing only if 2.6.P's subworkflows push node counts up. *(S · `packages/core/src/engine/event-bus.ts`, `packages/core/src/dag.ts` · #274, #275)* -- **Introduce a shared `RelaviumError` base class and migrate the 15 hand-rolled error classes onto it.** Error classes across `packages/core`, `packages/llm`, `packages/mcp`, and `apps/cli` (`BudgetExceededError`, `EngineStateError`, `RunLoopInvariantError`, and others) share no common base, each re-implementing its own conventions. `packages/shared` is the correct home. Introduce a minimal `RelaviumError` base class and migrate the existing classes onto it, preserving each class's existing `.code`/discriminant behavior. *(M · `packages/shared/src/*.ts` · #219)* +- ✅ **DONE ([Wave 3 Step 4](#wave-3-step-4-acceptance--2026-10-16)).** **Introduce minimal shared native error ancestry while retaining each family's contract.** The current export-scoped inventory replaces the historical 15/17 estimate: 46 roots across 37 files reach 83 module-exported production classes, with the additional unchanged private timeout descendant recorded separately. Constructors, names, discriminants, context/cause and structural/private-authority guards are preserved. *(M · `packages/shared/src/relavium-error.ts` and existing package families · #219)* - **Chat-to-workflow export hardening: continuity caveat and non-ASCII slug fallback; certify existing tool-content exclusion.** W7/PR #90 and Accepted [ADR-0095](../../decisions/0095-what-an-agent-session-remembers-across-turns.md) structurally exclude model-issued tool arguments/results from durable session rows and exported scaffolds. The earlier raw-tool-result export claim and its proposed opt-in/redaction decision are superseded by that contract; do not add a raw-content flag. Wave 2 still adds a clear continuity/review notice and deterministic ASCII title normalization with a session-identity fallback. Retained user/assistant text, `@` attachments and `!` output can remain sensitive: this is not universal transcript masking. Certify producer→SQLite→export rejection fixtures with #115; the [Wave 2 decision package](#decisions-presented-together-before-implementation) records D14/D15. *(M · `packages/core/src/export/serializer.ts` · #80, #81, #83)* - **Give `BudgetExceededError`/`BudgetPauseError` a `.code` field, blocked on a maintainer call.** These two errors (`packages/core/src/engine/budget-governor.ts`) expose no `.code`, unlike `EngineStateError`/`RunLoopInvariantError` in the same directory that `docs/standards/error-handling.md` names as the canonical model. Not a functional bug today. **Blocked on a maintainer decision**: should these errors adopt the `.code` convention, or is `instanceof`-narrowing an accepted exception for this pair? *(S · `packages/core/src/engine/budget-governor.ts` · G50)* - **Collapse two always-co-occurring branches in `engine.ts`'s `#dispatch` retry-sleep handling.** Purely cosmetic dead-code/maintenance-trap: two branches that always co-occur, one unreachable dead weight. No behavioral bug, but real, trivially-scheduled cleanup. *(S · `packages/core/src/engine/engine.ts` · #79)* @@ -4652,3 +4652,36 @@ receipt. Existing product and test bytes remain unchanged. These documentation corrections do not claim a fresh source execution; author gates retain their original source attribution. Two NEW complete cumulative clean reviews at the corrected head remain required before Step 5. + + +### Wave 3 Step 4 acceptance — 2026-10-16 + +Step 4 is independently accepted at `b16c3b4c29a82396c8f060f6e3ed199e2f340476`. +Two NEW complete cumulative clean reviews after the final correction cover all +**50 changed paths / 97 hunks**, including the eight additions, unchanged error +descendants and actual consumers. Selected reviewers are gpt-6.1-sol/high +(**26 files / 819 passing tests**) and gpt-6-astra/high (**29 files / 1,214 tests**), +with zero skips in their independent runs. The parent reads both complete reports. +The earlier two-Low sitting remains nonclean; both verified documentation findings +are fixed, with no scoped review finding left open. + +The minimal error base and direct envelope correction satisfy #219/#213. Every +existing class member list is unchanged; export-scoped counts, private inherited +timeout, structural versus local authority and native versus plain seam values +are explicit. Full payload and historical-reader controls remain effective. +Both reviewers independently audit all twelve retained causal receipts, source +and reconstructed mutant hashes; the secondary test-local payload TypeError is +accurately separated from zero runner-unhandled errors. + +Author evidence retains its original scope: **608 files / 13,884 passing tests / +20 established skips**, workspace lint/typecheck, six builds, source formatting, +fences, isolation and relative links. Reviewer counts overlap and are not added +to author counts. This metadata landing does not claim new source execution, +full CI/coverage/native-platform, whole-wave acceptance or merge. Detailed +reports remain local and Git-ignored. + +Step 5's tools/engine-tail implementation now proceeds automatically. Its read- +only test designs are preparation, not acceptance. Necessary same-defect registry +redaction prewalks and remaining identifier-sort consumers stay in that increment. +No new ADR, user decision, provider capture, credential or external operation is +pending. Step 19 retains final whole-wave validation and publication. From 7f33585094e4c7d90cf8c144d4e7d2650a198073 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 06:15:43 +0300 Subject: [PATCH 051/222] fix(core): bound tool inputs and preserve engine tail semantics Admit effective UTF-8 arguments before effects, preserve sibling aliases through all projections, close prompt and gate cancellation tails, and preserve authored DAG priority with a min-heap. Pin real registry/session/runner denial and compatibility controls. Refs: Wave 3 Step 5; ADR-0029; ADR-0037; ADR-0080; ADR-0113 Co-Authored-By: Codex --- .../core/src/dag-priority-equivalence.test.ts | 531 ++++++++++++++++++ packages/core/src/dag.ts | 41 +- ...agent-runner-literal-system-append.test.ts | 129 +++++ .../agent-runner-prompt-cancellation.test.ts | 323 +++++++++++ packages/core/src/engine/agent-runner.test.ts | 73 ++- packages/core/src/engine/agent-runner.ts | 10 +- .../core/src/engine/agent-session.test.ts | 90 ++- .../engine/empty-template-provenance.test.ts | 58 +- packages/core/src/engine/engine.ts | 12 +- ...event-bus-validation-compatibility.test.ts | 114 ++++ .../src/engine/human-gate-tail-race.test.ts | 148 +++++ .../human-gate-tail-cancellation.test.ts | 165 ++++++ .../src/engine/node-handlers/human-gate.ts | 3 + .../references-quoted-escapes.test.ts | 94 ++++ .../core/src/interpolation/references.test.ts | 4 +- packages/core/src/interpolation/references.ts | 20 +- .../core/src/tools/bounding-alias.test.ts | 212 +++++++ packages/core/src/tools/bounding.ts | 69 ++- .../src/tools/builtin-input-admission.test.ts | 373 ++++++++++++ .../src/tools/builtin-input-bounds.test.ts | 195 +++++++ .../src/tools/builtin-model-schema.test.ts | 240 ++++++++ .../tools/builtins-identifier-order.test.ts | 47 ++ packages/core/src/tools/builtins.test.ts | 8 +- packages/core/src/tools/builtins.ts | 249 ++++++-- packages/core/src/tools/errors.ts | 4 +- .../tools/registry-alias-projection.test.ts | 249 ++++++++ .../tools/registry-identifier-order.test.ts | 242 ++++++++ packages/core/src/tools/registry.test.ts | 44 +- packages/core/src/tools/registry.ts | 53 +- packages/core/src/tools/types.ts | 5 +- 30 files changed, 3659 insertions(+), 146 deletions(-) create mode 100644 packages/core/src/dag-priority-equivalence.test.ts create mode 100644 packages/core/src/engine/agent-runner-literal-system-append.test.ts create mode 100644 packages/core/src/engine/agent-runner-prompt-cancellation.test.ts create mode 100644 packages/core/src/engine/event-bus-validation-compatibility.test.ts create mode 100644 packages/core/src/engine/human-gate-tail-race.test.ts create mode 100644 packages/core/src/engine/node-handlers/human-gate-tail-cancellation.test.ts create mode 100644 packages/core/src/interpolation/references-quoted-escapes.test.ts create mode 100644 packages/core/src/tools/bounding-alias.test.ts create mode 100644 packages/core/src/tools/builtin-input-admission.test.ts create mode 100644 packages/core/src/tools/builtin-input-bounds.test.ts create mode 100644 packages/core/src/tools/builtin-model-schema.test.ts create mode 100644 packages/core/src/tools/builtins-identifier-order.test.ts create mode 100644 packages/core/src/tools/registry-alias-projection.test.ts create mode 100644 packages/core/src/tools/registry-identifier-order.test.ts diff --git a/packages/core/src/dag-priority-equivalence.test.ts b/packages/core/src/dag-priority-equivalence.test.ts new file mode 100644 index 00000000..f801b349 --- /dev/null +++ b/packages/core/src/dag-priority-equivalence.test.ts @@ -0,0 +1,531 @@ +import type { Agent, WorkflowNode } from '@relavium/shared'; +import { describe, expect, it } from 'vitest'; + +import { buildRunPlan } from './dag.js'; +import { WorkflowGraphError, WorkflowSecretLeakError, WorkflowValidationError } from './errors.js'; +import type { ReferenceSite } from './interpolation/collect.js'; +import { parseWorkflow } from './parser.js'; +import type { PlanConfig, PlanVertex, RunPlan } from './run-plan.js'; + +type Pair = readonly [string, string]; +interface GraphCase { + readonly seed: number; + readonly ids: readonly string[]; + readonly pairs: readonly Pair[]; +} +function ordinal(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} +function requireValue(value: T | undefined): T { + if (value === undefined) throw new Error('fixture index missing'); + return value; +} + +/** Independent full-sort Kahn oracle; edges come only from fixture inputs. */ +function fullSortReference(graph: GraphCase) { + const index = new Map(graph.ids.map((id, position) => [id, position])); + const before = new Map(graph.ids.map((id) => [id, new Set()])); + const after = new Map(graph.ids.map((id) => [id, new Set()])); + for (const [from, to] of graph.pairs) { + requireValue(before.get(to)).add(from); + requireValue(after.get(from)).add(to); + } + const degrees = new Map(graph.ids.map((id) => [id, requireValue(before.get(id)).size])); + const byAuthored = (a: string, b: string): number => + requireValue(index.get(a)) - requireValue(index.get(b)); + const ready = graph.ids.filter((id) => degrees.get(id) === 0); + const order: string[] = []; + while (ready.length > 0) { + ready.sort(byAuthored); + const id = requireValue(ready.shift()); + order.push(id); + for (const child of requireValue(after.get(id))) { + const degree = requireValue(degrees.get(child)) - 1; + degrees.set(child, degree); + if (degree === 0) ready.push(child); + } + } + const ancestors = (id: string): string[] => { + const found = new Set(); + const pending = [...requireValue(before.get(id))]; + while (pending.length > 0) { + const parent = requireValue(pending.pop()); + if (found.has(parent)) continue; + found.add(parent); + pending.push(...requireValue(before.get(parent))); + } + return [...found].sort(ordinal); + }; + return { + order, + unprocessed: graph.ids.filter((id) => !order.includes(id)), + before, + after, + ancestors, + byAuthored, + }; +} +function expectedPlan( + graph: GraphCase, + configs: readonly PlanConfig[], + inputSites: ReadonlyMap = new Map(), + extra: Pick = {}, +): RunPlan { + const ref = fullSortReference(graph); + const vertices = new Map(); + for (const config of configs) { + const id = config.node.id; + vertices.set(id, { + id, + type: config.kind, + dependencies: [...requireValue(ref.before.get(id))].sort(ref.byAuthored), + dependents: [...requireValue(ref.after.get(id))].sort(ref.byAuthored), + ancestors: ref.ancestors(id), + inputSites: inputSites.get(id) ?? [], + config, + }); + } + return { workflowId: 'priority', order: ref.order, vertices, ...extra }; +} +function actualPlan( + nodes: readonly WorkflowNode[], + pairs: readonly Pair[], + extra: Record = {}, + agents?: readonly Agent[], +): RunPlan { + return buildRunPlan( + parseWorkflow( + JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'priority', + nodes, + edges: pairs.map(([from, to]) => ({ from, to })), + ...extra, + ...(agents === undefined ? {} : { agents }), + }, + }), + ), + ); +} +function inputNodes(ids: readonly string[]): Extract[] { + return ids.map((id) => ({ id, type: 'input' })); +} +function generated(seed: number, count: number): GraphCase { + let state = seed + 1; + const next = (): number => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0; + return state; + }; + const shuffle = (values: readonly T[]): T[] => { + const out = [...values]; + for (let i = out.length - 1; i > 0; i -= 1) { + const j = next() % (i + 1); + const current = requireValue(out[i]); + out[i] = requireValue(out[j]); + out[j] = current; + } + return out; + }; + const ranking = Array.from({ length: count }, (_, index) => `node-${count - index}`); + const pairs: Pair[] = []; + for (let i = 0; i < count; i += 1) + for (let j = i + 1; j < count; j += 1) + if (next() % 5 === 0) pairs.push([requireValue(ranking[i]), requireValue(ranking[j])]); + return { seed, ids: shuffle(ranking), pairs: shuffle(pairs) }; +} +function outputSite(id: string, field: string, producer: string): ReferenceSite { + const raw = `{{run.outputs["${producer}"]}}`; + const reference = { + kind: 'node', + identifier: producer, + path: '', + filters: [], + raw, + } satisfies ReferenceSite['references'][number]; + return { + location: `node \`${id}\`.${field}`, + category: 'node-text', + segments: [{ kind: 'reference', reference }], + references: [reference], + }; +} + +describe('authored-minimum DAG priority equivalence (#275)', () => { + it.each([ + ['a', 'b', 'c'], + ['z-last', 'a-first', 'm-middle'], + ])('newly unlocked lower authored index precedes old ready vertex: %j', (...ids) => { + const graph: GraphCase = { + seed: 0, + ids, + pairs: [[requireValue(ids[0]), requireValue(ids[1])]], + }; + expect( + actualPlan(inputNodes(ids), graph.pairs), + 'complete plan uses minimum authored ready index', + ).toEqual( + expectedPlan( + graph, + inputNodes(ids).map((node) => ({ kind: 'input', node })), + ), + ); + expect(fullSortReference(graph).order).toEqual(ids); + }); + + it.each(Array.from({ length: 72 }, (_, seed) => generated(seed, 1 + (seed % 13))))( + 'generated admitted DAG plans equal independent full-sort plans: seed $seed', + (graph) => { + const nodes = inputNodes(graph.ids); + const extra = { + maxParallel: 7, + timeoutMs: 60000, + budget: { max_cost_microcents: 200000000, on_exceed: 'fail' }, + } satisfies Pick; + expect(fullSortReference(graph).unprocessed).toEqual([]); + expect( + actualPlan(nodes, graph.pairs, { + max_parallel: extra.maxParallel, + timeout_ms: extra.timeoutMs, + budget: extra.budget, + }), + `seed=${graph.seed}; authored=${graph.ids.join(',')}`, + ).toEqual( + expectedPlan( + graph, + nodes.map((node) => ({ kind: 'input', node })), + new Map(), + extra, + ), + ); + }, + ); + + it.each([ + { + name: 'chain', + ids: ['end', 'middle', 'start'], + pairs: [ + ['start', 'middle'], + ['middle', 'end'], + ], + order: ['start', 'middle', 'end'], + }, + { + name: 'diamond join with unrelated ready root', + ids: ['join', 'right', 'left', 'root', 'free'], + pairs: [ + ['root', 'left'], + ['root', 'right'], + ['left', 'join'], + ['right', 'join'], + ], + order: ['root', 'right', 'left', 'join', 'free'], + }, + { + name: 'disconnected roots', + ids: ['later', 'free', 'root', 'end'], + pairs: [ + ['root', 'later'], + ['later', 'end'], + ], + order: ['free', 'root', 'later', 'end'], + }, + ] satisfies readonly { + name: string; + ids: readonly string[]; + pairs: readonly Pair[]; + order: readonly string[]; + }[])('named topology pins oracle and actual complete plan: $name', ({ ids, pairs, order }) => { + const graph: GraphCase = { seed: 104, ids, pairs }; + const nodes = inputNodes(ids); + expect( + fullSortReference(graph).order, + 'independent oracle matches declared topology order', + ).toEqual(order); + const actual = actualPlan(nodes, pairs); + expect(actual.order, 'actual priority matches declared topology order').toEqual(order); + expect(actual).toEqual( + expectedPlan( + graph, + nodes.map((node) => ({ kind: 'input', node })), + ), + ); + }); + + it('literal system append still refuses declared secret references at actual parser admission', () => { + expect( + () => + parseWorkflow( + JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'secret-append', + inputs: [{ name: 'token', type: 'secret' }], + agents: [ + { + id: 'writer', + provider: 'anthropic', + model: 'model', + system_prompt: 'Clean authored system.', + }, + ], + nodes: [ + { + id: 'writer-node', + type: 'agent', + agent_ref: 'writer', + system_prompt_append: '{{inputs.token}}', + }, + ], + edges: [], + }, + }), + ), + 'literal assembly does not remove parser secret-taint admission', + ).toThrow(WorkflowSecretLeakError); + }); + + it('moderate wide frontier and duplicate structural edges preserve the whole plan', () => { + const ids = Array.from({ length: 48 }, (_, index) => `wide-${48 - index}`); + const pairs: Pair[] = ids.slice(1).map((id) => [requireValue(ids[0]), id]); + const graph = { seed: 100, ids, pairs: [...pairs, ...pairs] }; + const nodes = inputNodes(ids); + expect(actualPlan(nodes, graph.pairs)).toEqual( + expectedPlan( + graph, + nodes.map((node) => ({ kind: 'input', node })), + ), + ); + }); + + it('derived fanout and merge mappings preserve exact complete plan', () => { + const nodes: WorkflowNode[] = [ + { id: 'split', type: 'parallel', parallel_of: ['z-branch', 'a-branch'] }, + { id: 'join', type: 'merge', merge_strategy: 'first' }, + { id: 'a-branch', type: 'input' }, + { id: 'z-branch', type: 'input' }, + { id: 'done', type: 'output' }, + ]; + const explicit: Pair[] = [ + ['a-branch', 'join'], + ['z-branch', 'join'], + ['join', 'done'], + ]; + const graph = { + seed: 101, + ids: nodes.map((node) => node.id), + pairs: [...explicit, ['split', 'z-branch'], ['split', 'a-branch']] satisfies Pair[], + }; + const configs: PlanConfig[] = [ + { + kind: 'fan_out', + node: { id: 'split', type: 'parallel', parallel_of: ['z-branch', 'a-branch'] }, + branchNodeIds: ['z-branch', 'a-branch'], + }, + { + kind: 'fan_in', + node: { id: 'join', type: 'merge', merge_strategy: 'first' }, + requestedJoinStrategy: 'wait_first', + mergeStrategy: 'first', + branchNodeIds: ['z-branch', 'a-branch'], + }, + { kind: 'input', node: { id: 'a-branch', type: 'input' } }, + { kind: 'input', node: { id: 'z-branch', type: 'input' } }, + { kind: 'output', node: { id: 'done', type: 'output' }, feederNodeIds: ['join'] }, + ]; + expect( + actualPlan(nodes, explicit), + 'derived fanout plan matches independent mapping and priority', + ).toEqual(expectedPlan(graph, configs)); + }); + + it('agent system, literal append, prompt and gate references retain dependency and full-plan mapping', () => { + const agent: Agent = { + id: 'writer', + provider: 'anthropic', + model: 'model', + system_prompt: '{{run.outputs["system-up"]}}', + }; + const node: Extract = { + id: 'writer-node', + type: 'agent', + agent_ref: 'writer', + prompt_template: '{{run.outputs["prompt-up"]}}', + system_prompt_append: '{{run.outputs["append-up"]}}', + }; + const gate: Extract = { + id: 'gate', + type: 'human_gate', + gate_type: 'approval', + assignee: '{{run.outputs["writer-node"]}}', + message_template: '{{run.outputs["append-up"]}}', + }; + const nodes: WorkflowNode[] = [ + node, + gate, + ...inputNodes(['append-up', 'prompt-up', 'system-up']), + ]; + const explicit: Pair[] = [['append-up', 'writer-node']]; + const graph: GraphCase = { + seed: 102, + ids: nodes.map((entry) => entry.id), + pairs: [ + ...explicit, + ['system-up', 'writer-node'], + ['prompt-up', 'writer-node'], + ['writer-node', 'gate'], + ['append-up', 'gate'], + ], + }; + const configs: PlanConfig[] = [ + { kind: 'agent', node, resolvedAgent: agent }, + { kind: 'human_in_the_loop', node: gate }, + ...inputNodes(['append-up', 'prompt-up', 'system-up']).map( + (input): PlanConfig => ({ kind: 'input', node: input }), + ), + ]; + const sites = new Map([ + [ + 'writer-node', + [ + outputSite('writer-node', 'prompt_template', 'prompt-up'), + outputSite('writer-node', 'system_prompt_append', 'append-up'), + ], + ], + [ + 'gate', + [ + outputSite('gate', 'assignee', 'writer-node'), + outputSite('gate', 'message_template', 'append-up'), + ], + ], + ]); + expect(actualPlan(nodes, explicit, {}, [agent])).toEqual(expectedPlan(graph, configs, sites)); + }); + + it('derived condition branches/default and handled duplicate edges keep exact priority', () => { + const nodes: WorkflowNode[] = [ + { + id: 'route', + type: 'condition', + expression: 'true', + branches: [{ when: true, target_node: 'low' }], + default: 'high', + }, + ...inputNodes(['low', 'high', 'independent']), + ]; + const actual = actualPlan(nodes, [['route:true', 'low']]); + const graph: GraphCase = { + seed: 103, + ids: nodes.map((node) => node.id), + pairs: [ + ['route', 'low'], + ['route', 'high'], + ], + }; + const configs: PlanConfig[] = nodes.map((node) => + node.type === 'condition' + ? { kind: 'condition', node } + : { kind: 'input', node: { id: node.id, type: 'input' } }, + ); + expect(actual).toEqual(expectedPlan(graph, configs)); + }); + + it.each([ + { ids: ['a'], pairs: [['a', 'a']] }, + { + ids: ['a', 'b'], + pairs: [ + ['a', 'b'], + ['b', 'a'], + ], + }, + { + ids: ['free', 'a', 'b'], + pairs: [ + ['a', 'b'], + ['b', 'a'], + ], + }, + ...Array.from({ length: 8 }, (_, seed) => { + const ids = generated(seed, seed + 3).ids; + return { + ids, + pairs: ids.map((id, index): Pair => [id, requireValue(ids[(index + 1) % ids.length])]), + }; + }), + ] satisfies readonly { ids: readonly string[]; pairs: readonly Pair[] }[])( + 'cycles still raise only the typed deterministic graph error: %j', + ({ ids, pairs }) => { + let caught: unknown; + try { + actualPlan(inputNodes(ids), pairs); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(WorkflowGraphError); + if (!(caught instanceof WorkflowGraphError)) throw new Error('expected graph refusal'); + expect(caught.code).toBe('invalid_graph'); + expect(caught.issues.map((issue) => issue.kind)).toEqual(['cycle']); + expect(caught.issues[0]?.message).toBe( + 'the workflow has a dependency cycle: ' + caught.issues[0]?.field, + ); + if (ids.length === 1) expect(caught.issues[0]?.field).toBe('a → a'); + if (ids.includes('a') && ids.includes('b')) expect(caught.issues[0]?.field).toBe('b → a → b'); + }, + ); + + it.each([ + [ + { id: 'a', type: 'parallel', parallel_of: ['b'] }, + { id: 'b', type: 'parallel', parallel_of: ['a'] }, + ], + [ + { + id: 'a', + type: 'condition', + expression: 'true', + branches: [{ when: true, target_node: 'b' }], + }, + { + id: 'b', + type: 'condition', + expression: 'true', + branches: [{ when: true, target_node: 'a' }], + }, + ], + [ + { + id: 'a', + type: 'human_gate', + gate_type: 'approval', + message_template: '{{run.outputs["b"]}}', + }, + { id: 'b', type: 'human_gate', gate_type: 'approval', assignee: '{{run.outputs["a"]}}' }, + ], + ] satisfies readonly (readonly WorkflowNode[])[])( + 'cycles created solely by materialized edges remain refused: %j', + (...nodes) => { + let caught: unknown; + try { + actualPlan(nodes, []); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(WorkflowGraphError); + if (!(caught instanceof WorkflowGraphError)) + throw new Error('expected materialized cycle refusal'); + expect(caught.issues.map((issue) => issue.kind)).toEqual(['cycle']); + expect(caught.issues[0]?.field).toBe('b → a → b'); + }, + ); + + it('an empty authored workflow remains refused by admission', () => { + expect(() => + parseWorkflow( + JSON.stringify({ schema_version: '1.0', workflow: { id: 'empty', nodes: [], edges: [] } }), + ), + ).toThrow(WorkflowValidationError); + }); +}); diff --git a/packages/core/src/dag.ts b/packages/core/src/dag.ts index f13bc17b..10160d1b 100644 --- a/packages/core/src/dag.ts +++ b/packages/core/src/dag.ts @@ -645,7 +645,8 @@ function validateHandle( /** * Kahn's algorithm over the dependency graph. Each step emits the authored-**minimum** ready vertex, so * the authored-order tie-break holds across the ENTIRE ready set (not merely discovery order) — a fully - * deterministic, reproducible plan. (`ready` is re-sorted each step; the authored graph is small.) + * deterministic, reproducible plan. A min-heap preserves that choice without sorting the full ready + * set after each emitted vertex. */ function kahnOrder( nodes: Workflow['workflow']['nodes'], @@ -660,15 +661,45 @@ function kahnOrder( remaining.set(id, ins.size); } const ready: string[] = []; + const pushReady = (id: string): void => { + let index = ready.length; + ready.push(id); + while (index > 0) { + const parent = (index - 1) >>> 1; + const parentId = ready[parent]; + if (parentId === undefined || byAuthored(parentId, id) <= 0) break; + ready[index] = parentId; + index = parent; + } + ready[index] = id; + }; + const popReady = (): string | undefined => { + const first = ready[0]; + const last = ready.pop(); + if (last === undefined || ready.length === 0) return first; + let index = 0; + while (index * 2 + 1 < ready.length) { + let child = index * 2 + 1; + const left = ready[child]; + const right = ready[child + 1]; + if (left === undefined) break; + if (right !== undefined && byAuthored(right, left) < 0) child += 1; + const childId = ready[child]; + if (childId === undefined || byAuthored(last, childId) <= 0) break; + ready[index] = childId; + index = child; + } + ready[index] = last; + return first; + }; for (const node of nodes) { if ((remaining.get(node.id) ?? 0) === 0) { - ready.push(node.id); + pushReady(node.id); } } const order: string[] = []; while (ready.length > 0) { - ready.sort(byAuthored); - const id = ready.shift(); + const id = popReady(); if (id === undefined) { break; } @@ -677,7 +708,7 @@ function kahnOrder( const next = (remaining.get(consumer) ?? 0) - 1; remaining.set(consumer, next); if (next === 0) { - ready.push(consumer); + pushReady(consumer); } } } diff --git a/packages/core/src/engine/agent-runner-literal-system-append.test.ts b/packages/core/src/engine/agent-runner-literal-system-append.test.ts new file mode 100644 index 00000000..00f2793c --- /dev/null +++ b/packages/core/src/engine/agent-runner-literal-system-append.test.ts @@ -0,0 +1,129 @@ +import type { Agent } from '@relavium/shared'; +import type { LlmProvider, LlmRequest, StreamChunk } from '@relavium/llm'; +import { describe, expect, it, vi } from 'vitest'; + +import { + createWorkflowContextProjection, + ownSecretInputNames, +} from '../interpolation/provenance.js'; +import type { AgentPlanConfig, PlanVertex } from '../run-plan.js'; +import { createAgentNodeExecutor, type AgentRunnerDeps } from './agent-runner.js'; +import type { NodeExecContext } from './node-executor.js'; + +const agent: Agent = { + id: 'summarizer', + model: 'claude-opus-4-8', + provider: 'anthropic', + system_prompt: 'You summarize.', +}; +async function* reply(): AsyncGenerator { + await Promise.resolve(); + yield { type: 'text_delta', text: 'completed reply' }; + yield { + type: 'stop', + stopReason: 'stop', + usage: { kind: 'complete', usage: { inputTokens: 1, outputTokens: 1 } }, + }; +} +function vertexFor(append: string | undefined): PlanVertex { + const node: AgentPlanConfig['node'] = { + id: 'agent', + type: 'agent', + agent_ref: 'summarizer', + prompt_template: '{{run.outputs["up"]}}; {{inputs.text}}; {{ctx.topic}}', + ...(append === undefined ? {} : { system_prompt_append: append }), + }; + return { + id: 'agent', + type: 'agent', + dependencies: ['up'], + dependents: [], + ancestors: ['up'], + inputSites: [], + config: { kind: 'agent', node, resolvedAgent: agent }, + }; +} +function contextFor(vertex: PlanVertex): NodeExecContext { + return { + vertex, + runOutputs: new Map([['up', 'OUTPUT-CANARY']]), + inputs: { text: 'INPUT-CANARY' }, + ctx: createWorkflowContextProjection({ + values: { topic: 'CONTEXT-CANARY' }, + deniedKeys: [], + orderedKeys: ['topic'], + }), + secretInputNames: ownSecretInputNames([]).secretInputNames, + toolPolicy: {}, + emit: () => undefined, + signal: new AbortController().signal, + attemptNumber: 1, + }; +} + +describe('literal authored system append reaches completed provider dispatch (#87)', () => { + it.each([ + undefined, + '', + 'Ctx: {{run.outputs["up"]}}; {{inputs.text}}; {{ctx.topic}}', + ' first line\n{{inputs.text}}\\literal\nlast line ', + ])('preserves exact system separator, braces and whitespace: %j', async (append) => { + const requests: LlmRequest[] = []; + const stream = vi.fn((request: LlmRequest) => { + requests.push(request); + return reply(); + }); + const provider: LlmProvider = { + id: 'anthropic', + supports: { + tools: false, + streaming: true, + parallelToolCalls: false, + vision: false, + promptCache: false, + reasoning: false, + media: { + input: { image: false, audio: false, video: false, document: false }, + outputCombinations: [], + }, + }, + stream, + generate: () => { + throw new Error('unexpected generate'); + }, + }; + const deps: AgentRunnerDeps = { + resolveProvider: () => provider, + keyFor: () => 'offline-fixture-key', + sleep: () => Promise.resolve(), + tools: [], + registry: { + has: () => false, + list: () => [], + dispatch: () => { + throw new Error('unexpected tool dispatch'); + }, + }, + }; + const outcome = await createAgentNodeExecutor(deps).execute(contextFor(vertexFor(append))); + expect( + outcome, + 'literal append positivity requires completed actual provider dispatch', + ).toMatchObject({ kind: 'completed', output: 'completed reply' }); + expect(stream).toHaveBeenCalledOnce(); + expect(requests).toHaveLength(1); + const request = requests[0]; + if (request === undefined) throw new Error('completed dispatch omitted actual request'); + expect(request.system, 'system append is exact authored text').toBe( + 'You summarize.' + (append ? '\n\n' + append : ''), + ); + expect(request.messages).toMatchObject([ + { + role: 'user', + content: [{ type: 'text', text: 'OUTPUT-CANARY; INPUT-CANARY; CONTEXT-CANARY' }], + }, + ]); + for (const canary of ['OUTPUT-CANARY', 'INPUT-CANARY', 'CONTEXT-CANARY']) + expect(request.system).not.toContain(canary); + }); +}); diff --git a/packages/core/src/engine/agent-runner-prompt-cancellation.test.ts b/packages/core/src/engine/agent-runner-prompt-cancellation.test.ts new file mode 100644 index 00000000..c5bb862c --- /dev/null +++ b/packages/core/src/engine/agent-runner-prompt-cancellation.test.ts @@ -0,0 +1,323 @@ +import { + unwiredEffectJournal, + type AbortSignalLike, + type Agent, + type EffectDispatchPort, +} from '@relavium/shared'; +import type { LlmProvider, LlmRequest, StreamChunk } from '@relavium/llm'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createWorkflowContextProjection, + ownSecretInputNames, +} from '../interpolation/provenance.js'; +import * as resolver from '../interpolation/resolve.js'; +import type { ResolverCapabilities } from '../interpolation/scope.js'; +import type { AgentPlanConfig, PlanVertex } from '../run-plan.js'; +import { createAgentNodeExecutor, type AgentRunnerDeps } from './agent-runner.js'; +import type { + NodeExecContext, + NodePreparationContext, + NodeStreamEvent, + BudgetDispatchPreparationResult, + NodeOutcome, +} from './node-executor.js'; +import { createDispatchingNodeExecutor } from './node-handlers/dispatcher.js'; + +const originalResolve = resolver.resolvePublicTemplate; +afterEach(() => vi.restoreAllMocks()); +type Route = 'ordinary' | 'budget-preparation'; +type Surface = 'chat' | 'generative'; +const routes: readonly Route[] = ['ordinary', 'budget-preparation']; +const surfaces: readonly Surface[] = ['chat', 'generative']; +const agent: Agent = { + id: 'summarizer', + model: 'claude-opus-4-8', + provider: 'anthropic', + system_prompt: 'Summarize.', + reasoning_effort: 'high', +}; +function deferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} { + let resolve: ((value: T) => void) | undefined; + let reject: ((error: unknown) => void) | undefined; + const promise = new Promise((fulfill, fail) => { + resolve = fulfill; + reject = fail; + }); + if (resolve === undefined || reject === undefined) throw new Error('missing deferred callbacks'); + return { promise, resolve, reject }; +} +async function* reply(): AsyncGenerator { + await Promise.resolve(); + yield { type: 'text_delta', text: 'ANSWER' }; + yield { + type: 'stop', + stopReason: 'stop', + usage: { kind: 'complete', usage: { inputTokens: 1, outputTokens: 1 } }, + }; +} +function agentVertex(prompt: string | undefined, surface: Surface): PlanVertex { + const node: AgentPlanConfig['node'] = { + id: 'agent', + type: 'agent', + agent_ref: 'summarizer', + ...(prompt === undefined ? {} : { prompt_template: prompt }), + ...(surface === 'generative' ? { output_modalities: ['image'] } : {}), + }; + return { + id: 'agent', + type: 'agent', + dependencies: [], + dependents: [], + ancestors: [], + inputSites: [], + config: { kind: 'agent', node, resolvedAgent: agent }, + }; +} +function preparationContextOf(ctx: NodeExecContext): NodePreparationContext { + return { + vertex: ctx.vertex, + runOutputs: ctx.runOutputs, + inputs: ctx.inputs, + ctx: ctx.ctx, + secretInputNames: ctx.secretInputNames, + toolPolicy: ctx.toolPolicy, + signal: ctx.signal, + }; +} +function harness( + prompt: string | undefined, + surface: Surface, + readFile?: ResolverCapabilities['readFile'], +) { + const controller = new AbortController(); + const requests: LlmRequest[] = []; + const stream = vi.fn((request: LlmRequest) => { + requests.push(request); + return reply(); + }); + const generate = vi.fn(() => { + throw new Error('unexpected chat generate'); + }); + const generateMedia = vi.fn>(() => + Promise.resolve({ + media: { type: 'media', mimeType: 'image/png', source: { kind: 'base64', data: 'aW1n' } }, + }), + ); + const provider: LlmProvider = { + id: 'anthropic', + supports: { + tools: false, + streaming: true, + parallelToolCalls: false, + vision: false, + promptCache: false, + reasoning: true, + media: { + input: { image: false, audio: false, video: false, document: false }, + outputCombinations: [['image']], + ...(surface === 'generative' ? { surface: 'generative' } : {}), + }, + }, + stream, + generate, + generateMedia, + }; + const keyFor = vi.fn(() => 'offline-fixture-key'); + const preEgress = vi.fn>(() => undefined); + const notice = vi.fn>(); + const dispatch = vi.fn(() => { + throw new Error('unexpected tool dispatch'); + }); + const port = unwiredEffectJournal(); + const prepare = vi.fn((...args) => port.prepare(...args)); + const settle = vi.fn((...args) => port.settle(...args)); + const discard = vi.fn((...args) => port.discard(...args)); + const events: NodeStreamEvent[] = []; + const ctx: NodeExecContext = { + vertex: agentVertex(prompt, surface), + signal: controller.signal, + runOutputs: new Map(), + inputs: { path: 'prompt.txt', secret: 'trusted-secret-canary' }, + ctx: createWorkflowContextProjection({ + values: { public: 'clean' }, + deniedKeys: ['private'], + orderedKeys: ['private', 'public'], + }), + secretInputNames: ownSecretInputNames(['secret']).secretInputNames, + toolPolicy: {}, + emit: (event) => events.push(event), + attemptNumber: 1, + preEgress, + effects: { prepare, settle, discard }, + }; + const deps: AgentRunnerDeps = { + resolveProvider: () => provider, + resolveMediaSurface: () => surface, + keyFor, + preEgress, + onEffortWithheld: notice, + resolveEffortTiers: () => undefined, + sleep: () => Promise.resolve(), + tools: [], + registry: { has: () => false, list: () => [], dispatch }, + ...(readFile === undefined ? {} : { resolverCapabilities: { readFile } }), + }; + const executor = createDispatchingNodeExecutor({ agent: createAgentNodeExecutor(deps) }); + async function enter(route: Route): Promise { + if (route === 'ordinary') return executor.execute(ctx); + if (executor.prepareBudgetDispatch === undefined) + throw new Error('dispatcher dropped budget preparation'); + return executor.prepareBudgetDispatch(preparationContextOf(ctx)); + } + function assertNoEgress(route: Route): void { + for (const spy of [stream, generate, generateMedia, keyFor, preEgress, notice, dispatch]) + expect( + spy, + 'prompt refusal precedes credentials, provider egress, notices and dispatch', + ).not.toHaveBeenCalled(); + if (route === 'ordinary') + for (const spy of [prepare, settle, discard]) + expect( + spy, + 'ordinary prompt refusal precedes real effect-port entry', + ).not.toHaveBeenCalled(); + expect(events).toEqual([]); + } + return { + controller, + ctx, + executor, + requests, + stream, + generateMedia, + keyFor, + notice, + enter, + assertNoEgress, + }; +} +function assertCancelled(outcome: NodeOutcome | BudgetDispatchPreparationResult): void { + expect(outcome.kind, 'cancelled prompt never returns a prepared or completed dispatch').toBe( + 'failed', + ); + if (outcome.kind !== 'failed') throw new Error('expected failed prompt outcome'); + expect(outcome.error).toMatchObject({ code: 'cancelled', retryable: false }); + expect(outcome.error.message).not.toContain('HOST-READ-CANARY'); +} + +for (const route of routes) + for (const surface of surfaces) + describe(`agent prompt cancellation #76: ${route}/${surface}`, () => { + it('forwards the identical node signal to prompt read_file and admits a live dispatch', async () => { + const entered = deferred(); + const held = deferred(); + const reads: { path: string; signal: AbortSignalLike | undefined }[] = []; + const h = harness('{{inputs.path | read_file}}', surface, (path, signal) => { + reads.push({ path, signal }); + entered.resolve(); + return held.promise; + }); + const pending = h.enter(route); + await entered.promise; + expect + .soft(reads, 'read_file receives identical node signal') + .toEqual([{ path: 'prompt.txt', signal: h.ctx.signal }]); + expect.soft(reads[0]?.signal).toBe(h.ctx.signal); + held.resolve('resolved prompt'); + const outcome = await pending; + if (route === 'budget-preparation') { + expect(outcome.kind).toBe('prepared'); + if (outcome.kind !== 'prepared') throw new Error('live budget preparation refused'); + h.assertNoEgress(route); + expect(outcome.preparation.quote({ strictCostCap: false }).kind).toBe('quoted'); + expect(await outcome.preparation.execute(h.ctx)).toMatchObject({ kind: 'completed' }); + } else expect(outcome).toMatchObject({ kind: 'completed' }); + expect(surface === 'chat' ? h.stream : h.generateMedia).toHaveBeenCalledOnce(); + expect(h.keyFor).toHaveBeenCalledOnce(); + if (surface === 'chat') + expect(h.requests[0]?.messages).toMatchObject([ + { content: [{ text: 'resolved prompt' }] }, + ]); + }); + + it.each(['fulfill', 'reject'] as const)( + 'noncooperative prompt read %s after abort remains cancelled before admission', + async (settlement) => { + const entered = deferred(); + const held = deferred(); + const h = harness('{{inputs.path | read_file}}', surface, () => { + entered.resolve(); + return held.promise; + }); + const pending = h.enter(route); + await entered.promise; + h.controller.abort(); + if (settlement === 'fulfill') held.resolve('late host text'); + else held.reject(new Error('HOST-READ-CANARY /private/path')); + const outcome = await pending; + h.assertNoEgress(route); + assertCancelled(outcome); + }, + ); + + it('live prompt read rejection remains fatal validation', async () => { + const h = harness('{{inputs.path | read_file}}', surface, () => + Promise.reject(new Error('live unavailable read')), + ); + expect(await h.enter(route)).toMatchObject({ + kind: 'failed', + error: { code: 'validation', retryable: false }, + }); + h.assertNoEgress(route); + }); + + it.each(['literal', '', undefined])( + 'already aborted literal/empty/omitted prompt refuses before request preparation: %j', + async (prompt) => { + const h = harness(prompt, surface); + h.controller.abort(); + const outcome = await h.enter(route); + h.assertNoEgress(route); + assertCancelled(outcome); + }, + ); + + it.each(['{{inputs.path | read_file}}', 'literal', '', undefined])( + 'abort after real public resolver settles wins before continuation: %j', + async (prompt) => { + const trace: string[] = []; + const h = harness(prompt, surface, () => Promise.resolve('real read output')); + vi.spyOn(resolver, 'resolvePublicTemplate').mockImplementation(async (...args) => { + const text = await originalResolve(...args); + trace.push('resolver-settled'); + h.controller.abort(); + trace.push('abort-requested'); + return text; + }); + const outcome = await h.enter(route); + trace.push('handler-outcome'); + expect(trace).toEqual(['resolver-settled', 'abort-requested', 'handler-outcome']); + h.assertNoEgress(route); + assertCancelled(outcome); + }, + ); + + it.each([ + '{{inputs.secret | default("fallback") | read_file}}', + '{{ctx.private | default("fallback") | read_file}}', + ])('secret reference refusal still precedes reads and egress: %s', async (prompt) => { + const readFile = vi.fn(() => Promise.resolve('unreachable')); + const h = harness(prompt, surface, readFile); + expect(await h.enter(route)).toMatchObject({ + kind: 'failed', + error: { code: 'validation', retryable: false }, + }); + expect(readFile).not.toHaveBeenCalled(); + h.assertNoEgress(route); + }); + }); diff --git a/packages/core/src/engine/agent-runner.test.ts b/packages/core/src/engine/agent-runner.test.ts index 8915d260..7c2224a3 100644 --- a/packages/core/src/engine/agent-runner.test.ts +++ b/packages/core/src/engine/agent-runner.test.ts @@ -21,7 +21,15 @@ import { import type { AgentPlanConfig, PlanVertex } from '../run-plan.js'; import { BUILTIN_TOOLS } from '../tools/builtins.js'; import { delegateAvailable } from '../tools/delegates.js'; -import type { ToolCallPart, ToolRegistry, ToolResultPart } from '../tools/types.js'; +import { ToolPolicyError } from '../tools/errors.js'; +import { createToolRegistry } from '../tools/registry.js'; +import type { + ToolCallPart, + ToolDispatchContext, + ToolHost, + ToolRegistry, + ToolResultPart, +} from '../tools/types.js'; import { markUntrusted } from '../tools/untrusted.js'; import { buildMediaUnitsEstimate, @@ -35,7 +43,7 @@ import type { NodeExecContext, NodeStreamEvent, NodePreparationContext } from '. import { createDispatchingNodeExecutor } from './node-handlers/dispatcher.js'; import type { PreEgressInfo } from './agent-turn.js'; import { parseWorkflow } from '../parser.js'; -import { createInMemoryHost } from './execution-host.js'; +import { createInMemoryHost, createInMemoryEffectJournalStore } from './execution-host.js'; import { WorkflowEngine } from './engine.js'; import type { RunEvent } from '@relavium/shared'; @@ -315,6 +323,67 @@ function capsWithOutput(combinations: readonly (readonly OutputModality[])[]): C } describe('createAgentNodeExecutor — dispatch', () => { + it('keeps the actual workflow-agent git gate closed and gives a truthful manual remedy (#308)', async () => { + const spawn = vi.fn['spawn']>(() => + Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 0 }), + ); + const actual = createToolRegistry({ tools: BUILTIN_TOOLS, host: { process: { spawn } } }); + let captured: ToolDispatchContext | undefined; + let denial: unknown; + const registry: ToolRegistry = { + has: (id) => actual.has(id), + list: () => actual.list(), + dispatch: async (call, ctx) => { + captured = ctx; + try { + return await actual.dispatch(call, ctx); + } catch (error) { + denial = error; + throw error; + } + }, + }; + const p = provider([ + { type: 'tool_call_start', id: 'commit-call', name: 'git_commit' }, + { + type: 'tool_call_delta', + id: 'commit-call', + argsJsonDelta: '{"message":"reviewed changes"}', + }, + { type: 'tool_call_end', id: 'commit-call' }, + { ...STOP, stopReason: 'tool_use' }, + ]); + const stream = vi.fn((...args) => p.stream(...args)); + const exec = createAgentNodeExecutor( + deps({ ...p, stream }, { registry, tools: BUILTIN_TOOLS }), + ); + const { ctx } = ctxFor( + vertexFor({ + kind: 'agent', + node: agentNode(), + resolvedAgent: { ...AGENT, tools: ['git_commit'] }, + }), + ); + const journal = createInMemoryEffectJournalStore(); + const port = journal.for({ kind: 'run', runId: 'r-git', nodeId: 'n1', attempt: 1 }); + const prepare = vi.fn(port.prepare); + const outcome = await exec.execute({ ...ctx, effects: { ...port, prepare } }); + expect(outcome).toMatchObject({ + kind: 'failed', + error: { code: 'tool_denied', retryable: false }, + }); + expect(stream).toHaveBeenCalledTimes(1); + expect(captured?.gateApproved).toBe(false); + expect(captured).not.toHaveProperty('approval'); + expect(denial).toBeInstanceOf(ToolPolicyError); + if (!(denial instanceof ToolPolicyError)) + throw new Error('missing actual workflow gate denial'); + expect(denial.reason).toBe('gate_required'); + expect(denial.message).toMatch(/chat cannot provide.*review.*commit manually/i); + expect(prepare).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + expect(journal.rows()).toEqual([]); + }); it.each(['none', 'window', 'summary'] as const)( 'authored memory has no effect on a workflow agent node (%s)', async (type) => { diff --git a/packages/core/src/engine/agent-runner.ts b/packages/core/src/engine/agent-runner.ts index 45088d4d..f547e44a 100644 --- a/packages/core/src/engine/agent-runner.ts +++ b/packages/core/src/engine/agent-runner.ts @@ -487,6 +487,7 @@ async function prepareAgentDispatch( // Empty public text still crosses the required provenance boundary for low-level embeddings. const prompt = await resolvePrompt(node.prompt_template ?? '', ctx, deps); + if (ctx.signal.aborted) return failed('cancelled', 'run cancelled', false); if (!prompt.ok) return failed('validation', prompt.message, false); // Inline-vs-generative routing (1.AG Section C, ADR-0045 §1): a resolved model whose `media_surface` is @@ -1575,7 +1576,7 @@ function chainCapabilities(deps: AgentRunnerDeps): ChainCapabilities { async function resolvePrompt( template: string, - ctx: Pick, + ctx: Pick, deps: AgentRunnerDeps, ): Promise<{ ok: true; text: string } | { ok: false; message: string }> { // The resolved prompt may draw on untrusted run.outputs / read_file — it lands in a USER message @@ -1589,7 +1590,12 @@ async function resolvePrompt( outputs: Object.fromEntries(ctx.runOutputs), }; try { - const text = await resolvePublicTemplate(template, scope, deps.resolverCapabilities ?? {}); + const text = await resolvePublicTemplate( + template, + scope, + deps.resolverCapabilities ?? {}, + ctx.signal, + ); return { ok: true, text }; } catch (err) { return { diff --git a/packages/core/src/engine/agent-session.test.ts b/packages/core/src/engine/agent-session.test.ts index e6797680..82ed5de1 100644 --- a/packages/core/src/engine/agent-session.test.ts +++ b/packages/core/src/engine/agent-session.test.ts @@ -15,14 +15,14 @@ import { type Agent, type SessionContext, } from '@relavium/shared'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { authoredSystemPrompt } from './authored-system-prompt.js'; import { WorkflowGraphError } from '../errors.js'; import { ADMISSION_CEILINGS } from '../limits.js'; import { BUILTIN_TOOLS } from '../tools/builtins.js'; -import { ToolExecutionError } from '../tools/errors.js'; +import { ToolExecutionError, ToolPolicyError } from '../tools/errors.js'; import { createToolRegistry } from '../tools/registry.js'; import type { ProcessResult, @@ -269,6 +269,92 @@ const session = (deps: SessionDeps, agent: Agent = AGENT): AgentSession => const typesOf = (events: readonly SessionStreamEvent[]): readonly string[] => events.map((e) => e.type); +describe('git_commit requires a gate that chat cannot supply (#308)', () => { + it.each([false, true])( + 'refuses before confirm/journal/process with active approval=%s', + async (approval) => { + const spawn = vi.fn['spawn']>(() => + Promise.resolve({ exitCode: 0, stdout: 'committed', stderr: '', durationMs: 1 }), + ); + const actual = createToolRegistry({ tools: BUILTIN_TOOLS, host: { process: { spawn } } }); + let context: ToolDispatchContext | undefined; + let denial: unknown; + const registry: ToolRegistry = { + has: (id) => actual.has(id), + list: () => actual.list(), + dispatch: async (call, ctx) => { + context = ctx; + try { + return await actual.dispatch(call, ctx); + } catch (error) { + denial = error; + throw error; + } + }, + }; + const journal = createInMemoryEffectJournalStore(); + const prepareObserved = vi.fn(); + const confirm = vi.fn(() => Promise.resolve({ outcome: 'approve' as const })); + const p = scriptedProvider([ + [ + { type: 'tool_call_start', id: 'commit-call', name: 'git_commit' }, + { + type: 'tool_call_delta', + id: 'commit-call', + argsJsonDelta: '{"message":"reviewed changes"}', + }, + { type: 'tool_call_end', id: 'commit-call' }, + { + type: 'stop', + stopReason: 'tool_use', + usage: { kind: 'complete', usage: { inputTokens: 4, outputTokens: 2 } }, + }, + ], + ]); + const stream = vi.fn((...args) => p.stream(...args)); + const { deps, events } = harness( + [], + { + resolveProvider: () => ({ ...p, stream }), + tools: BUILTIN_TOOLS, + effects: (correlation) => { + const port = journal.for(correlation); + return { + ...port, + prepare: (...args) => { + prepareObserved(); + return port.prepare(...args); + }, + }; + }, + }, + registry, + ); + const s = session(deps, AgentSchema.parse({ ...AGENT, tools: ['git_commit'] })); + s.start(); + if (approval) s.setTurnPolicy({ confirm }); + await s.sendMessage('please commit'); + expect(stream).toHaveBeenCalledTimes(1); + expect(context?.gateApproved).toBe(false); + if (approval) expect(context?.approval?.confirm).toBe(confirm); + else expect(context).not.toHaveProperty('approval'); + expect(denial).toBeInstanceOf(ToolPolicyError); + if (!(denial instanceof ToolPolicyError)) throw new Error('missing actual gate denial'); + expect(denial.reason).toBe('gate_required'); + expect(denial.recoverable).toBe(false); + expect(denial.message).toMatch(/chat cannot provide.*review.*commit manually/i); + expect(confirm).not.toHaveBeenCalled(); + expect(prepareObserved).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); + expect(journal.rows()).toEqual([]); + expect(events.filter((event) => event.type === 'agent:approval_requested')).toEqual([]); + expect(events.filter((event) => event.type === 'session:turn_completed')).toMatchObject([ + { error: { code: 'tool_denied' } }, + ]); + }, + ); +}); + /** Drain a `SessionHandle` stream to completion — for the 1.W end-to-end wiring test below. */ async function drainSession( events: AsyncIterable, diff --git a/packages/core/src/engine/empty-template-provenance.test.ts b/packages/core/src/engine/empty-template-provenance.test.ts index e9170203..5c72f0db 100644 --- a/packages/core/src/engine/empty-template-provenance.test.ts +++ b/packages/core/src/engine/empty-template-provenance.test.ts @@ -279,29 +279,41 @@ describe('empty public templates retain the required provenance boundary (ADR-01 expect(run.keyReads()).toBe(0); }); - it('preserves pre-aborted agent execution after preparation without quoting or provider invocation', async () => { - const run = fixture(); - const ctx = context(vertex('agent')); - const abort = createAbortController(); - abort.abort(); - Reflect.set(ctx, 'signal', abort.signal); - const prepare = run.agent.prepareBudgetDispatch?.bind(run.agent); - if (prepare === undefined) throw new Error('agent preparation is required'); - // Preparation historically lowers a request even when already aborted. This correction - // preserves that contract; cancellation is enforced when executing, without calling quote. - const prepared = await prepare(ctx); - if (prepared.kind !== 'prepared') throw new Error('expected existing preparation behavior'); - expect(await prepared.preparation.execute(ctx)).toMatchObject({ - kind: 'failed', - error: { code: 'cancelled', retryable: false }, - }); - expect(await run.agent.execute(ctx)).toMatchObject({ - kind: 'failed', - error: { code: 'cancelled', retryable: false }, - }); - expect(run.requests).toEqual([]); - expect(run.keyReads()).toBe(0); - }); + it.each([false, true])( + 'cancels an empty agent before quoting or invoking, aborted before preparation=%s', + async (beforePreparation) => { + const run = fixture(); + const ctx = context(vertex('agent')); + const abort = createAbortController(); + if (beforePreparation) abort.abort(); + Reflect.set(ctx, 'signal', abort.signal); + const prepare = run.agent.prepareBudgetDispatch?.bind(run.agent); + if (prepare === undefined) throw new Error('agent preparation is required'); + // Step 5 now refuses a pre-aborted prompt at the final resolver boundary. A live preparation + // remains usable, and cancellation after it also refuses execution without calling quote. + const prepared = await prepare(ctx); + if (beforePreparation) + expect(prepared).toMatchObject({ + kind: 'failed', + error: { code: 'cancelled', retryable: false }, + }); + else { + expect(prepared.kind).toBe('prepared'); + if (prepared.kind !== 'prepared') throw new Error('live empty preparation refused'); + abort.abort(); + expect(await prepared.preparation.execute(ctx)).toMatchObject({ + kind: 'failed', + error: { code: 'cancelled', retryable: false }, + }); + } + expect(await run.agent.execute(ctx)).toMatchObject({ + kind: 'failed', + error: { code: 'cancelled', retryable: false }, + }); + expect(run.requests).toEqual([]); + expect(run.keyReads()).toBe(0); + }, + ); it('fails the real engine instead of publishing a template-free gate when an embedding drops ctx', async () => { const run = fixture(); diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index dfe59476..ab067114 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -2932,15 +2932,9 @@ class RunExecution { if (this.#settled) { return; // the run settled (e.g. a sibling failure/cancel) while we waited — drop this re-dispatch } - if (this.#cancelling || this.#abort.signal.aborted) { - // A cancel / sibling-abort landed on the same tick the timer fully elapsed (so sleptFully was true - // but the run is now ending) — settle this node's last failure rather than waste a re-dispatch. - await this.#onOutcome(vertex, outcome, startedAtMs, attempt); - return; - } - if (!sleptFully) { - // The run's AbortSignal fired during the backoff — a cancel OR a sibling node's failure (which - // aborts to stop other branches). Do not re-dispatch; settle this node's last failure. #settleFailed + if (this.#cancelling || this.#abort.signal.aborted || !sleptFully) { + // Cancellation/sibling abort can interrupt sleep or arrive on the tick it finishes. In either + // case, do not re-dispatch; settle this node's last failure. #settleFailed // honours precedence: it won't overwrite an already-set #failure (a sibling's root cause) nor set one // while cancelling — so the run closes as the sibling's run:failed, or run:cancelled, accordingly. await this.#onOutcome(vertex, outcome, startedAtMs, attempt); diff --git a/packages/core/src/engine/event-bus-validation-compatibility.test.ts b/packages/core/src/engine/event-bus-validation-compatibility.test.ts new file mode 100644 index 00000000..df44dc3d --- /dev/null +++ b/packages/core/src/engine/event-bus-validation-compatibility.test.ts @@ -0,0 +1,114 @@ +import type { RunOrSessionEvent } from '@relavium/shared'; +import { describe, expect, it, vi } from 'vitest'; + +import { + RunEventBus, + RunLoopInvariantError, + type RunEventDraft, + type RunEventBusOptions, +} from '../index.js'; + +const timestamp = '2026-10-16T00:00:00.000Z'; +const badCost: RunEventDraft = { + type: 'cost:updated', + runId: 'run-cost', + nodeId: 'node', + model: 'model', + inputTokens: 0, + outputTokens: 0, + costMicrocents: -1, + cumulativeCostMicrocents: 0, +}; +function token(key: { runId: string } | { sessionId: string }): RunEventDraft { + return { type: 'agent:token', ...key, nodeId: 'node', token: 'unchanged token', model: 'model' }; +} + +describe('exported event bus validation compatibility (#274)', () => { + it.each([undefined, true])( + 'default/true validation rejects actual invalid payload without consuming sequence: %s', + (validate) => { + const options: RunEventBusOptions = { + now: () => timestamp, + ...(validate === undefined ? {} : { validate }), + }; + const bus = new RunEventBus(options); + const listener = vi.fn(); + bus.subscribe(listener); + expect(() => bus.emit(badCost)).toThrow(); + expect(listener).not.toHaveBeenCalled(); + expect(bus.emit(token({ runId: 'run-cost' })).sequenceNumber).toBe(0); + }, + ); + + it('validate false accepts actual invalid payload while retaining exact stamping and delivery', () => { + const bus = new RunEventBus({ now: () => timestamp, validate: false }); + const delivered: RunOrSessionEvent[] = []; + bus.subscribe((event) => delivered.push(event)); + const event = bus.emit(badCost); + expect(event, 'validation opt-out accepts a negative runtime-invalid cost').toEqual({ + ...badCost, + timestamp, + sequenceNumber: 0, + }); + expect(delivered).toEqual([event]); + expect(bus.emit(token({ runId: 'run-cost' })).sequenceNumber).toBe(1); + expect(delivered).toHaveLength(2); + }); + + it.each([ + { + code: 'both_correlation_keys', + draft: { + type: 'agent:token', + runId: 'same-key', + sessionId: 'session-key', + nodeId: 'node', + token: 'x', + model: 'm', + }, + }, + { + code: 'no_correlation_key', + draft: { type: 'agent:token', nodeId: 'node', token: 'x', model: 'm' }, + }, + ] satisfies readonly { code: string; draft: RunEventDraft }[])( + 'validate false retains mandatory $code without clock, listener or counter effects', + ({ code, draft }) => { + const now = vi.fn(() => timestamp); + const listener = vi.fn(); + const bus = new RunEventBus({ now, validate: false }); + bus.subscribe(listener); + let caught: unknown; + try { + bus.emit(draft); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(RunLoopInvariantError); + if (!(caught instanceof RunLoopInvariantError)) + throw new Error('missing typed invariant failure'); + expect(caught.code, 'correlation ownership cannot be bypassed by validate false').toBe(code); + expect(now).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + expect(bus.emit(token({ runId: 'same-key' })).sequenceNumber).toBe(0); + expect(bus.emit(token({ sessionId: 'session-key' })).sequenceNumber).toBe(0); + }, + ); + + it('preserves exact dual-arm fields and independent run/session gap-free sequences', () => { + const bus = new RunEventBus({ now: () => timestamp, validate: false }); + const drafts = [ + token({ runId: 'r' }), + token({ sessionId: 's' }), + token({ runId: 'r' }), + token({ sessionId: 's' }), + ]; + expect(drafts.map((draft) => bus.emit(draft))).toEqual( + drafts.map((draft, index) => ({ + ...draft, + timestamp, + sequenceNumber: Math.floor(index / 2), + })), + ); + }); +}); diff --git a/packages/core/src/engine/human-gate-tail-race.test.ts b/packages/core/src/engine/human-gate-tail-race.test.ts new file mode 100644 index 00000000..c7e6b015 --- /dev/null +++ b/packages/core/src/engine/human-gate-tail-race.test.ts @@ -0,0 +1,148 @@ +import type { AbortSignalLike, RunEvent } from '@relavium/shared'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import * as resolver from '../interpolation/resolve.js'; +import { parseWorkflow } from '../parser.js'; +import { WorkflowEngine } from './engine.js'; +import { createInMemoryHost, InMemoryRunStore } from './execution-host.js'; +import type { NodeOutcome } from './node-executor.js'; +import { createDispatchingNodeExecutor } from './node-handlers/dispatcher.js'; +import { createHumanGateNodeExecutor } from './node-handlers/human-gate.js'; + +const originalResolve = resolver.resolvePublicTemplate; +afterEach(() => vi.restoreAllMocks()); + +function fixture(): { + engine: WorkflowEngine; + store: InMemoryRunStore; + outcomes: NodeOutcome[]; + signals: AbortSignalLike[]; +} { + const store = new InMemoryRunStore(); + const handler = createHumanGateNodeExecutor(); + const outcomes: NodeOutcome[] = []; + const signals: AbortSignalLike[] = []; + const executor = createDispatchingNodeExecutor({ + human_in_the_loop: { + execute: async (ctx) => { + signals.push(ctx.signal); + const outcome = await handler.execute(ctx); + outcomes.push(outcome); + return outcome; + }, + }, + }); + return { + engine: new WorkflowEngine({ host: createInMemoryHost({ store }), executor }), + store, + outcomes, + signals, + }; +} +function workflow() { + return parseWorkflow( + JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'gate-tail', + nodes: [ + { + id: 'gate', + type: 'human_gate', + gate_type: 'approval', + message_template: 'literal gate message', + assignee: 'literal reviewer', + }, + ], + edges: [], + }, + }), + ); +} +function assertOrder(events: readonly RunEvent[]): void { + const numbers = events.map((event) => event.sequenceNumber); + expect(numbers).toEqual([...numbers].sort((a, b) => a - b)); + expect(new Set(numbers).size).toBe(numbers.length); +} + +describe('real engine human gate final resolver race (G9)', () => { + it('cancels the real handler and preserves no durable or delivered gate pause', async () => { + const { engine, store, outcomes, signals } = fixture(); + const trace: string[] = []; + vi.spyOn(resolver, 'resolvePublicTemplate').mockImplementation(async (...args) => { + const text = await originalResolve(...args); + if (args[0] === 'literal reviewer') { + expect(args[3]).toBe(signals[0]); + trace.push('resolver-settled'); + handle.cancel(); + expect(args[3]?.aborted, 'real handle cancellation aborts actual handler signal').toBe( + true, + ); + trace.push('abort-requested'); + } + return text; + }); + const handle = engine.start({ workflow: workflow() }); + const events: RunEvent[] = []; + for await (const event of handle.events) events.push(event); + for (const rail of [events, store.eventsFor(handle.runId)]) { + expect(rail.filter((event) => event.type === 'run:cancelled')).toHaveLength(1); + expect + .soft( + rail.filter((event) => + [ + 'human_gate:paused', + 'run:paused', + 'human_gate:resumed', + 'run:completed', + 'node:retrying', + ].includes(event.type), + ), + ) + .toEqual([]); + assertOrder(rail); + } + expect(trace).toEqual(['resolver-settled', 'abort-requested']); + expect + .soft(outcomes, 'recorded real handler outcome is the causal final-tail control') + .toMatchObject([{ kind: 'failed', error: { code: 'cancelled', retryable: false } }]); + }); + + it('live real handler pauses durably and resumes to completion', async () => { + const { engine, store, outcomes } = fixture(); + const handle = engine.start({ workflow: workflow() }); + const events: RunEvent[] = []; + for await (const event of handle.events) { + events.push(event); + if (event.type === 'run:paused') { + const gateId = event.gateIds[0]; + if (gateId === undefined) throw new Error('real pause omitted gate id'); + await engine.resume(handle.runId, gateId, { decision: 'approved', decidedBy: 'tester' }); + } + } + expect(outcomes).toEqual([ + { + kind: 'paused', + gate: { + gateType: 'approval', + message: 'literal gate message', + assignee: 'literal reviewer', + }, + }, + ]); + for (const rail of [events, store.eventsFor(handle.runId)]) { + const pauses = rail.filter((event) => event.type === 'human_gate:paused'); + expect(pauses).toHaveLength(1); + expect(pauses[0]).toMatchObject({ + gateType: 'approval', + message: 'literal gate message', + assignee: 'literal reviewer', + }); + expect(typeof pauses[0]?.gateId).toBe('string'); + expect(pauses[0]?.gateId.length).toBeGreaterThan(0); + expect(rail.filter((event) => event.type === 'run:paused')).toHaveLength(1); + expect(rail.filter((event) => event.type === 'run:completed')).toHaveLength(1); + assertOrder(rail); + } + }); +}); diff --git a/packages/core/src/engine/node-handlers/human-gate-tail-cancellation.test.ts b/packages/core/src/engine/node-handlers/human-gate-tail-cancellation.test.ts new file mode 100644 index 00000000..15fa7025 --- /dev/null +++ b/packages/core/src/engine/node-handlers/human-gate-tail-cancellation.test.ts @@ -0,0 +1,165 @@ +import type { AbortSignalLike } from '@relavium/shared'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createWorkflowContextProjection, + ownSecretInputNames, +} from '../../interpolation/provenance.js'; +import * as resolver from '../../interpolation/resolve.js'; +import type { HumanGatePlanConfig, PlanVertex } from '../../run-plan.js'; +import type { NodeExecContext, NodeOutcome } from '../node-executor.js'; +import { createHumanGateNodeExecutor } from './human-gate.js'; + +const originalResolve = resolver.resolvePublicTemplate; +afterEach(() => vi.restoreAllMocks()); + +function gateVertex(node: Partial = {}): PlanVertex { + return { + id: 'gate', + type: 'human_in_the_loop', + dependencies: [], + dependents: [], + ancestors: [], + inputSites: [], + config: { + kind: 'human_in_the_loop', + node: { id: 'gate', type: 'human_gate', gate_type: 'approval', ...node }, + }, + }; +} +function contextFor(vertex: PlanVertex, signal: AbortSignalLike): NodeExecContext { + return { + vertex, + signal, + runOutputs: new Map(), + inputs: { path: 'review.txt', secret: 'trusted-input-canary' }, + ctx: createWorkflowContextProjection({ + values: { public: 'clean' }, + deniedKeys: ['private'], + orderedKeys: ['private', 'public'], + }), + secretInputNames: ownSecretInputNames(['secret']).secretInputNames, + toolPolicy: {}, + emit: () => undefined, + attemptNumber: 1, + }; +} +function assertCancelled(outcome: NodeOutcome): void { + expect(outcome.kind, 'final gate resolver cancellation owns the handler outcome').toBe('failed'); + if (outcome.kind !== 'failed') throw new Error('expected failed gate outcome'); + expect(outcome.error).toMatchObject({ code: 'cancelled', retryable: false }); +} + +const messages: readonly Partial[] = [ + { message_template: '{{inputs.path | read_file}}' }, + { message_template: 'literal message' }, + { message_template: '' }, + {}, +]; + +describe('human gate final resolver cancellation (G9)', () => { + it.each(messages)( + 'cancel after final message resolution returns failed cancelled rather than paused: %j', + async (node) => { + const controller = new AbortController(); + const trace: string[] = []; + const readFile = vi.fn(() => Promise.resolve('read message')); + vi.spyOn(resolver, 'resolvePublicTemplate').mockImplementation(async (...args) => { + const text = await originalResolve(...args); + expect(args[3]).toBe(controller.signal); + trace.push('resolver-settled'); + controller.abort(); + trace.push('abort-requested'); + return text; + }); + const outcome = await createHumanGateNodeExecutor({ + resolverCapabilities: { readFile }, + }).execute(contextFor(gateVertex(node), controller.signal)); + trace.push('handler-outcome'); + expect(trace).toEqual(['resolver-settled', 'abort-requested', 'handler-outcome']); + expect(readFile).toHaveBeenCalledTimes(node.message_template?.includes('read_file') ? 1 : 0); + assertCancelled(outcome); + }, + ); + + it.each(['literal reviewer', '{{inputs.path | read_file}}'])( + 'cancel after final assignee resolution never returns a gate: %s', + async (assignee) => { + const controller = new AbortController(); + const trace: string[] = []; + const readFile = vi.fn(() => Promise.resolve('read reviewer')); + vi.spyOn(resolver, 'resolvePublicTemplate').mockImplementation(async (...args) => { + const text = await originalResolve(...args); + trace.push(args[0] === assignee ? 'assignee-settled' : 'message-settled'); + if (args[0] === assignee) { + controller.abort(); + trace.push('abort-requested'); + } + return text; + }); + const outcome = await createHumanGateNodeExecutor({ + resolverCapabilities: { readFile }, + }).execute( + contextFor(gateVertex({ message_template: 'message first', assignee }), controller.signal), + ); + trace.push('handler-outcome'); + expect(trace).toEqual([ + 'message-settled', + 'assignee-settled', + 'abort-requested', + 'handler-outcome', + ]); + assertCancelled(outcome); + }, + ); + + it('live final resolution still returns exact message, assignee and deadline request', async () => { + const controller = new AbortController(); + const readFile = vi.fn((_path: string, signal?: AbortSignalLike) => { + expect(signal).toBe(controller.signal); + expect(_path).toBe('review.txt'); + return Promise.resolve('review text'); + }); + const result = await createHumanGateNodeExecutor({ + resolverCapabilities: { readFile }, + }).execute( + contextFor( + gateVertex({ + message_template: '{{inputs.path | read_file}}', + assignee: 'reviewer', + timeout_ms: 60000, + }), + controller.signal, + ), + ); + expect(result).toEqual({ + kind: 'paused', + gate: { + gateType: 'approval', + message: 'review text', + assignee: 'reviewer', + timeoutMs: 60000, + timeoutAction: 'reject', + }, + }); + expect(readFile).toHaveBeenCalledOnce(); + }); + + it.each([ + '{{inputs.secret | default("fallback") | read_file}}', + '{{ctx.private | default("fallback") | read_file}}', + ])( + 'live denied reference remains fatal validation before capabilities: %s', + async (message_template) => { + const readFile = vi.fn(() => Promise.resolve('unreachable')); + const outcome = await createHumanGateNodeExecutor({ + resolverCapabilities: { readFile }, + }).execute(contextFor(gateVertex({ message_template }), new AbortController().signal)); + expect(outcome).toMatchObject({ + kind: 'failed', + error: { code: 'validation', retryable: false }, + }); + expect(readFile).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/packages/core/src/engine/node-handlers/human-gate.ts b/packages/core/src/engine/node-handlers/human-gate.ts index 2d451733..59909427 100644 --- a/packages/core/src/engine/node-handlers/human-gate.ts +++ b/packages/core/src/engine/node-handlers/human-gate.ts @@ -65,6 +65,9 @@ async function runHumanGate( false, ); } + // Literal/empty resolution can settle without reading the signal; the final await is still a + // cancellation boundary, including when there is no assignee to resolve afterward. + if (ctx.signal.aborted) return cancelled(); const gate: GateRequest = { gateType: node.gate_type, message, diff --git a/packages/core/src/interpolation/references-quoted-escapes.test.ts b/packages/core/src/interpolation/references-quoted-escapes.test.ts new file mode 100644 index 00000000..e4e6f602 --- /dev/null +++ b/packages/core/src/interpolation/references-quoted-escapes.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import { parseTemplate, type InterpolationReference } from './references.js'; +import { resolveTemplate } from './resolve.js'; + +function referenceOf(text: string): InterpolationReference { + const first = parseTemplate(text)[0]; + if (first?.kind !== 'reference') throw new Error('expected reference'); + return first.reference; +} + +const quotedCases = [ + { name: 'double matching quote', argument: String.raw`"say \"hi\""`, expected: 'say "hi"' }, + { name: 'single matching quote', argument: String.raw`'it\'s fine'`, expected: "it's fine" }, + { name: 'backslash', argument: String.raw`"a\\b"`, expected: String.raw`a\b` }, + { name: 'other single quote', argument: String.raw`"a\'b"`, expected: String.raw`a\'b` }, + { name: 'other double quote', argument: String.raw`'a\"b'`, expected: String.raw`a\"b` }, + { + name: 'Windows single backslashes', + argument: String.raw`"C:\temp\name"`, + expected: String.raw`C:\temp\name`, + }, + { + name: 'Windows doubled backslashes', + argument: String.raw`"C:\\temp\\name"`, + expected: String.raw`C:\temp\name`, + }, + { + name: 'unknown escapes', + argument: String.raw`"\n\t\r\u0041\x41\q\/"`, + expected: String.raw`\n\t\r\u0041\x41\q\/`, + }, + { name: 'trailing backslash', argument: String.raw`"tail\\"`, expected: 'tail' + '\\' }, + { name: 'structural delimiters', argument: String.raw`"a|b,c}}d\"}}"`, expected: 'a|b,c}}d"}}' }, + { name: 'odd backslash parity', argument: String.raw`"a\\\"b"`, expected: 'a\\"b' }, +]; + +describe('quoted filter finite decoding (#82)', () => { + it.each(quotedCases)( + 'matching quoted escapes reach the runtime default value: $name', + async ({ argument, expected }) => { + const raw = `{{inputs.x | default(${argument})}}`; + const template = raw + ' suffix'; + expect( + referenceOf(template), + 'raw reference and delimiter structure survive decoding', + ).toEqual({ + kind: 'inputs', + identifier: 'x', + path: '', + raw, + filters: [{ name: 'default', args: [{ type: 'string', value: expected }] }], + }); + expect(parseTemplate(template)[1]).toEqual({ kind: 'literal', text: ' suffix' }); + expect( + await resolveTemplate(template, { inputs: {}, ctx: {}, outputs: {} }), + 'matching quoted escapes reach the runtime default value', + ).toBe(expected + ' suffix'); + expect( + await resolveTemplate(template, { inputs: { x: 'live value' }, ctx: {}, outputs: {} }), + ).toBe('live value suffix'); + }, + ); + + it('unknown escapes preserve Windows path bytes', async () => { + const text = String.raw`{{inputs.x | default("C:\temp\name\q\n")}}`; + expect(await resolveTemplate(text, { inputs: {}, ctx: {}, outputs: {} })).toBe( + String.raw`C:\temp\name\q\n`, + ); + }); + + it('preserves multi-filter and mixed argument boundaries without evaluating arbitrary filters', () => { + const text = String.raw`{{inputs.x | f("a\\b,|}}", true, -1.25, 0x10, 1e3, Infinity) | length}} tail`; + expect(referenceOf(text).filters).toEqual([ + { + name: 'f', + args: [ + { type: 'string', value: String.raw`a\b,|}}` }, + { type: 'boolean', value: true }, + { type: 'number', value: -1.25 }, + { type: 'string', value: '0x10' }, + { type: 'string', value: '1e3' }, + { type: 'string', value: 'Infinity' }, + ], + }, + { name: 'length', args: [] }, + ]); + }); + + it('keeps a truly unterminated quote literal', () => { + const text = String.raw`{{inputs.x | default("unterminated\")}} tail`; + expect(parseTemplate(text)).toEqual([{ kind: 'literal', text }]); + }); +}); diff --git a/packages/core/src/interpolation/references.test.ts b/packages/core/src/interpolation/references.test.ts index b1f1fe08..cbbee89d 100644 --- a/packages/core/src/interpolation/references.test.ts +++ b/packages/core/src/interpolation/references.test.ts @@ -196,14 +196,14 @@ describe('parseTemplate', () => { // `default("say \"hi\"")` — the `\"` must NOT close the string, so the argument is `say "hi"`. const ref = refOf(parseTemplate(String.raw`{{inputs.x | default("say \"hi\"")}}`)[0]); expect(ref.filters).toEqual([ - { name: 'default', args: [{ type: 'string', value: String.raw`say \"hi\"` }] }, + { name: 'default', args: [{ type: 'string', value: 'say "hi"' }] }, ]); }); it('handles an escaped quote inside a filter string argument (single-quote variant)', () => { const ref = refOf(parseTemplate(String.raw`{{inputs.x | default('it\'s fine')}}`)[0]); expect(ref.filters).toEqual([ - { name: 'default', args: [{ type: 'string', value: String.raw`it\'s fine` }] }, + { name: 'default', args: [{ type: 'string', value: "it's fine" }] }, ]); }); diff --git a/packages/core/src/interpolation/references.ts b/packages/core/src/interpolation/references.ts index 14d23108..692790fd 100644 --- a/packages/core/src/interpolation/references.ts +++ b/packages/core/src/interpolation/references.ts @@ -204,8 +204,8 @@ function parseArg(piece: string): FilterArg | undefined { return undefined; } const quoted = QUOTED.exec(trimmed); - if (quoted?.[2] !== undefined) { - return { type: 'string', value: quoted[2] }; + if (quoted?.[2] !== undefined && quoted[1] !== undefined) { + return { type: 'string', value: decodeQuotedArg(quoted[2], quoted[1]) }; } if (trimmed === 'true' || trimmed === 'false') { return { type: 'boolean', value: trimmed === 'true' }; @@ -218,6 +218,22 @@ function parseArg(piece: string): FilterArg | undefined { return { type: 'string', value: trimmed }; } +/** Decode only the matching delimiter and backslash, after all structural splitting is complete. */ +function decodeQuotedArg(text: string, quote: string): string { + const parts: string[] = []; + let start = 0; + for (let i = 0; i < text.length; i += 1) { + if (text[i] !== '\\') continue; + const next = text[i + 1]; + if (next !== quote && next !== '\\') continue; + parts.push(text.slice(start, i), next); + i += 1; + start = i + 1; + } + parts.push(text.slice(start)); + return parts.join(''); +} + /** Returns true for the two quote characters that delimit filter string arguments. */ function isQuoteChar(ch: string | undefined): boolean { return ch === '"' || ch === "'"; diff --git a/packages/core/src/tools/bounding-alias.test.ts b/packages/core/src/tools/bounding-alias.test.ts new file mode 100644 index 00000000..a79bf445 --- /dev/null +++ b/packages/core/src/tools/bounding-alias.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { boundForModel, redactInlineMedia, redactSecretShapedValue } from './bounding.js'; + +const WALKERS = [ + { name: 'media', walk: redactInlineMedia }, + { name: 'secret', walk: redactSecretShapedValue }, +]; + +describe.each(WALKERS)('W3S5 #94 $name path-local projection', ({ walk }) => { + it('ALIAS_OBJECT expands shared siblings and object-array-object joins', () => { + const shared = { label: 'alice' }; + const source = { left: shared, right: shared, nested: [shared] }; + expect(walk(source), 'ALIAS_OBJECT').toEqual({ + left: { label: 'alice' }, + right: { label: 'alice' }, + nested: [{ label: 'alice' }], + }); + expect(source.left).toBe(source.right); + expect(source.nested[0]).toBe(shared); + }); + + it('ALIAS_ARRAY expands shared arrays and array-object-array joins', () => { + const shared = ['first', { label: 'bob' }]; + const source = [shared, { nested: shared }, shared]; + expect(walk(source), 'ALIAS_ARRAY').toEqual([ + ['first', { label: 'bob' }], + { nested: ['first', { label: 'bob' }] }, + ['first', { label: 'bob' }], + ]); + expect(source[0]).toBe(shared); + expect(source[2]).toBe(shared); + }); + + it('ALIAS_NULL_PROTOTYPE expands clean null-prototype siblings', () => { + const shared = { label: 'null-prototype' }; + Object.setPrototypeOf(shared, null); + expect(walk({ left: shared, right: shared }), 'ALIAS_NULL_PROTOTYPE').toEqual({ + left: { label: 'null-prototype' }, + right: { label: 'null-prototype' }, + }); + expect(Object.getPrototypeOf(shared)).toBeNull(); + }); + + it('CYCLE_OBJECT marks only the self back-reference and re-expands the next sibling', () => { + const self: Record = { label: 'self' }; + self['back'] = self; + expect(walk({ first: self, second: self }), 'CYCLE_OBJECT').toEqual({ + first: { label: 'self', back: '[cyclic]' }, + second: { label: 'self', back: '[cyclic]' }, + }); + expect(self['back']).toBe(self); + }); + + it('CYCLE_ARRAY marks self-array ancestry while preserving later aliases', () => { + const self: unknown[] = ['self']; + self.push(self); + expect(walk([self, self]), 'CYCLE_ARRAY').toEqual([ + ['self', '[cyclic]'], + ['self', '[cyclic]'], + ]); + expect(self[1]).toBe(self); + }); + + it('CYCLE_MUTUAL marks mutual object-array ancestry at the actual back-reference', () => { + const object: Record = { label: 'mutual' }; + const array: unknown[] = [object]; + object['children'] = array; + expect(walk({ object, array }), 'CYCLE_MUTUAL').toEqual({ + object: { label: 'mutual', children: ['[cyclic]'] }, + array: [{ label: 'mutual', children: '[cyclic]' }], + }); + }); + + it('ALIAS_NATIVE unwinds Date and nonplain native early returns', () => { + const date = new Date('2026-10-16T00:00:00.000Z'); + const native = new Map([['label', 'map']]); + expect(walk({ dates: [date, date], natives: [native, native] }), 'ALIAS_NATIVE').toEqual({ + dates: [date, date], + natives: [native, native], + }); + }); +}); + +describe('W3S5 #94 specialized projection arms', () => { + it('ALIAS_MEDIA repeats byte-free canonical, data-URI and binary markers', () => { + const data = 'U1lOVEhFVElDX01FRElBX0NBTkFSWQ=='; + const canonical = { kind: 'base64', data }; + const uri = `data:image/png;base64,${data}`; + const bytes = new Uint8Array([11, 22, 33]); + const buffer = bytes.buffer; + const projected = redactInlineMedia({ + canonical: [canonical, canonical], + uris: [uri, uri], + bytes: [bytes, bytes], + buffers: [buffer, buffer], + }); + expect(projected, 'ALIAS_MEDIA').toEqual({ + canonical: [ + { kind: 'base64', base64Length: data.length }, + { kind: 'base64', base64Length: data.length }, + ], + uris: ['[base64 data URI omitted]', '[base64 data URI omitted]'], + bytes: ['[binary buffer omitted]', '[binary buffer omitted]'], + buffers: ['[binary buffer omitted]', '[binary buffer omitted]'], + }); + expect(JSON.stringify(projected)).not.toContain(data); + expect(canonical.data).toBe(data); + expect(bytes).toEqual(new Uint8Array([11, 22, 33])); + }); + + it('ALIAS_CREDENTIAL keeps key, scheme, embedded-token and argv redaction on every alias', () => { + const credential = { + api_key: 'synthetic-opaque-value', + pin: 7391, + Authorization: 'Bearer syntheticBearerValue', + mixed: 'xoxb-1234567890-glpat-abcdefghijklmnop', + author: 'Ada', + tokenizer: 'plain', + pinned: true, + }; + const argv = ['--token', 'synthetic-flag-value', '-p', '2222', '-p', '8080:80']; + const expected = { + api_key: '[redacted]', + pin: '[redacted]', + Authorization: 'Bearer [redacted]', + mixed: '[redacted]', + author: 'Ada', + tokenizer: 'plain', + pinned: true, + }; + const projected = redactSecretShapedValue({ + objects: [credential, credential], + argv: [argv, argv], + }); + expect(projected, 'ALIAS_CREDENTIAL').toEqual({ + objects: [expected, expected], + argv: [ + ['--token', '[redacted]', '-p', '2222', '-p', '8080:80'], + ['--token', '[redacted]', '-p', '2222', '-p', '8080:80'], + ], + }); + const text = JSON.stringify(projected); + for (const canary of [ + 'synthetic-opaque-value', + '7391', + 'syntheticBearerValue', + 'abcdefghijklmnop', + 'synthetic-flag-value', + ]) { + expect(text).not.toContain(canary); + } + expect(credential.api_key).toBe('synthetic-opaque-value'); + expect(argv[1]).toBe('synthetic-flag-value'); + }); + + it('ALIAS_SECRET_KEY_FALLBACK unwinds a hidden structured value before its clean sibling', () => { + const shared = { label: 'clean' }; + expect( + redactSecretShapedValue({ password: shared, public: shared }), + 'ALIAS_SECRET_KEY_FALLBACK', + ).toEqual({ password: '[redacted]', public: { label: 'clean' } }); + expect(shared).toEqual({ label: 'clean' }); + }); + + it('ALIAS_MODEL_IDENTITY preserves the original model value and expands clean summary branches', async () => { + const shared = { + label: 'alice', + note: 'Bearer SYNTHETIC_PRIVATE', + media: 'data:image/png;base64,U1lOVEhFVElD', + }; + const source = { left: shared, right: shared }; + const expectedText = JSON.stringify({ + left: { label: 'alice', note: 'Bearer [redacted]', media: '[base64 data URI omitted]' }, + right: { label: 'alice', note: 'Bearer [redacted]', media: '[base64 data URI omitted]' }, + }); + expect(expectedText.length).toBeLessThan(500); + const bounded = await boundForModel(source, { maxBytes: 10_000, maxLines: 20 }, {}); + expect(bounded.value, 'ALIAS_MODEL_IDENTITY').toBe(source); + expect(source.left).toBe(source.right); + expect(source.left.note).toBe('Bearer SYNTHETIC_PRIVATE'); + expect(bounded.truncated).toBe(false); + expect(bounded.summary, 'ALIAS_MODEL_SUMMARY').toBe(expectedText); + }); + + it('ALIAS_SPILL expands both clean branches in the actual sanitized spill', async () => { + const shared = { + label: 'bob', + media: 'data:image/png;base64,U1lOVEhFVElD', + }; + const source = { left: shared, right: shared }; + const spill = vi.fn((text: string) => + Promise.resolve({ ref: 'spill://alias', byteLength: text.length }), + ); + const bounded = await boundForModel( + source, + { maxBytes: 20, maxLines: 2 }, + { outputStore: { spill } }, + ); + expect(bounded.truncated).toBe(true); + expect(spill, 'ALIAS_SPILL').toHaveBeenCalledOnce(); + expect(spill.mock.calls[0]?.[0], 'ALIAS_SPILL').toBe( + JSON.stringify({ + left: { label: 'bob', media: '[base64 data URI omitted]' }, + right: { label: 'bob', media: '[base64 data URI omitted]' }, + }), + ); + expect(String(bounded.value)).toContain('spill://alias'); + expect(source.left).toBe(source.right); + expect(source.right.media).toBe('data:image/png;base64,U1lOVEhFVElD'); + }); +}); diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 1bd188da..6d76d1da 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -88,25 +88,30 @@ function redactInlineMediaForText(value: unknown, seen: WeakSet): unknow return '[cyclic]'; } seen.add(value); - if (Array.isArray(value)) { - return value.map((item) => redactInlineMediaForText(item, seen)); - } - if (isBinaryBuffer(value)) { - return '[binary buffer omitted]'; // a raw Uint8Array/ArrayBuffer — never JSON the decimal byte values - } - if (!isPlainObject(value)) { - return value; // Date/RegExp/Map/Set/class instance — leave for JSON.stringify's native handling - } - if (isCanonicalBase64Source(value)) { - const data = value['data']; - // Report the base64 CHARACTER length (≈1.33× the byte count), not "bytes" — observability only. - return { kind: 'base64', base64Length: typeof data === 'string' ? data.length : 0 }; - } - const out: Record = {}; - for (const [key, item] of Object.entries(value)) { - out[key] = redactInlineMediaForText(item, seen); + try { + if (Array.isArray(value)) { + return value.map((item) => redactInlineMediaForText(item, seen)); + } + if (isBinaryBuffer(value)) { + return '[binary buffer omitted]'; // a raw Uint8Array/ArrayBuffer — never JSON the decimal byte values + } + if (!isPlainObject(value)) { + return value; // Date/RegExp/Map/Set/class instance — leave for JSON.stringify's native handling + } + if (isCanonicalBase64Source(value)) { + const data = value['data']; + // Report the base64 CHARACTER length (≈1.33× the byte count), not "bytes" — observability only. + return { kind: 'base64', base64Length: typeof data === 'string' ? data.length : 0 }; + } + const out: Record = {}; + for (const [key, item] of Object.entries(value)) { + out[key] = redactInlineMediaForText(item, seen); + } + return out; + } finally { + // Only an ancestor is cyclic; a completed sibling can legitimately share this object. + seen.delete(value); } - return out; } /** @@ -346,19 +351,23 @@ function redactSecretShapedWalk(value: unknown, seen: WeakSet): unknown if (typeof value !== 'object' || value === null) return value; if (seen.has(value)) return '[cyclic]'; // break the cycle (mirrors redactInlineMedia) — never re-emit the ref seen.add(value); - if (Array.isArray(value)) return redactArgVector(value, seen); - if (!isPlainObject(value)) return value; // Date/RegExp/Map/… — leave for native handling - const out: Record = {}; - // Scrub the KEY too (a secret-shaped key is redacted; a normal name is unchanged — see the doc above). A - // display-only field, so a rare collision of two keys both redacting to `[redacted]` losing one is acceptable. - for (const [key, item] of Object.entries(value)) { - // **The key decides, whatever the value's shape.** Checked BEFORE the walk, because the walk only - // inspects strings by shape — so a numeric PIN, a nested credential object, or a high-entropy opaque - // token with no recognisable prefix all survive it. `null`/`undefined` are left alone so an absent - // field does not become a phantom `[redacted]` in a diagnostic. - out[redactSecretShapedText(key)] = redactUnderKey(key, item, seen); + try { + if (Array.isArray(value)) return redactArgVector(value, seen); + if (!isPlainObject(value)) return value; // Date/RegExp/Map/… — leave for native handling + const out: Record = {}; + // Scrub the KEY too (a secret-shaped key is redacted; a normal name is unchanged — see the doc above). A + // display-only field, so a rare collision of two keys both redacting to `[redacted]` losing one is acceptable. + for (const [key, item] of Object.entries(value)) { + // **The key decides, whatever the value's shape.** Checked BEFORE the walk, because the walk only + // inspects strings by shape — so a numeric PIN, a nested credential object, or a high-entropy opaque + // token with no recognisable prefix all survive it. `null`/`undefined` are left alone so an absent + // field does not become a phantom `[redacted]` in a diagnostic. + out[redactSecretShapedText(key)] = redactUnderKey(key, item, seen); + } + return out; + } finally { + seen.delete(value); } - return out; } /** Render any result to the text the model would see (a string is itself; else compact JSON). Inline diff --git a/packages/core/src/tools/builtin-input-admission.test.ts b/packages/core/src/tools/builtin-input-admission.test.ts new file mode 100644 index 00000000..b83a8edd --- /dev/null +++ b/packages/core/src/tools/builtin-input-admission.test.ts @@ -0,0 +1,373 @@ +import type { EffectDispatchPort } from '@relavium/shared'; +import { describe, expect, it, vi } from 'vitest'; + +import { BUILTIN_TOOLS } from './builtins.js'; +import { ToolArgsInvalidError, ToolPolicyError } from './errors.js'; +import { createToolRegistry } from './registry.js'; +import type { + ConfirmActionHook, + EgressCapability, + FsCapability, + ProcessCapability, + ToolDef, + ToolDispatchContext, + ToolHost, + ToolNodeConfig, +} from './types.js'; +import { createInMemoryEffectJournalStore } from '../engine/execution-host.js'; + +const CANARY = 'synthetic-input-private-canary'; + +interface Rail { + readonly id: string; + readonly field: string; + readonly base: Readonly>; + readonly small: unknown; + readonly exact: unknown; + readonly over: unknown; +} + +const RAILS: readonly Rail[] = [ + { + id: 'write_file', + field: 'content', + base: { path: 'out.txt', append: true }, + small: 'hello', + exact: 'a'.repeat(8 * 1024 * 1024), + over: CANARY + 'a'.repeat(8 * 1024 * 1024), + }, + { + id: 'http_request', + field: 'body', + base: { url: 'https://api.example/x', method: 'POST' }, + small: 'hello', + exact: 'a'.repeat(1024 * 1024), + over: CANARY + 'a'.repeat(1024 * 1024), + }, + { + id: 'git_commit', + field: 'message', + base: { files: ['out.txt'] }, + small: 'ordinary commit', + exact: 'a'.repeat(16 * 1024), + over: CANARY + 'a'.repeat(16 * 1024), + }, + { + id: 'run_command', + field: 'args', + base: { command: 'echo' }, + small: ['--mode', 'two words', ''], + exact: Array(4).fill('a'.repeat(16 * 1024)), + over: [CANARY + 'a'.repeat(16 * 1024)], + }, +]; + +function builtin(id: string): ToolDef { + const found = BUILTIN_TOOLS.find((candidate) => candidate.id === id); + if (found === undefined) throw new Error(`missing built-in fixture: ${id}`); + return found; +} + +function harness( + rail: Rail, + effective: Readonly>, + config: ToolNodeConfig = {}, +) { + const sequence: string[] = []; + const original = builtin(rail.id); + const parseArgs = vi.fn((raw) => { + sequence.push('parse'); + return original.parseArgs(raw); + }); + // git_commit has no production target; the {} probe observes the same registry timing without + // granting command/domain authority or replacing the actual target on the other three rails. + const policyTarget = vi.fn>((args) => { + sequence.push('target'); + return original.policyTarget?.(args) ?? {}; + }); + const effect = vi.fn>((args) => { + sequence.push('effect'); + return original.effect?.(args); + }); + const def: ToolDef = { ...original, parseArgs, policyTarget, effect }; + const store = createInMemoryEffectJournalStore(); + const port = store.for({ kind: 'run', runId: 'r-bounds', nodeId: 'n-bounds', attempt: 1 }); + const prepare = vi.fn((...args) => { + sequence.push('prepare'); + return port.prepare(...args); + }); + const settle = vi.fn((...args) => { + sequence.push('settle'); + return port.settle(...args); + }); + const discard = vi.fn((...args) => port.discard(...args)); + const writeFile = vi.fn((path, data) => { + sequence.push('host'); + return Promise.resolve({ path, bytesWritten: data.length }); + }); + const spawn = vi.fn(() => { + sequence.push('host'); + return Promise.resolve({ exitCode: 0, stdout: 'done', stderr: '', durationMs: 1 }); + }); + const fetch = vi.fn(() => { + sequence.push('host'); + return Promise.resolve({ status: 200, headers: {}, body: 'done' }); + }); + const host: ToolHost = { + fs: { + readFile: () => + Promise.resolve({ content: '', mimeType: 'text/plain', sizeBytes: 0, lastModified: 't' }), + listDirectory: () => Promise.resolve({ entries: [] }), + writeFile, + }, + process: { spawn }, + egress: { fetch }, + }; + const confirm = vi.fn(() => { + sequence.push('confirm'); + return Promise.resolve({ outcome: 'approve' }); + }); + const emitApprovalRequested = vi.fn(() => { + sequence.push('approval_event'); + }); + const beforeDispatch = vi.fn(() => { + sequence.push('before'); + }); + const args = effective['args']; + const command = effective['command']; + const vector = + Array.isArray(args) && args.every((value): value is string => typeof value === 'string') + ? args + : []; + const ctx: ToolDispatchContext = { + nodeId: 'n-bounds', + grantedToolIds: new Set([rail.id]), + config, + toolPolicy: { + allowedDomains: ['api.example'], + allowedCommands: [[typeof command === 'string' ? command : 'echo', ...vector].join(' ')], + }, + fsScope: 'sandboxed', + gateApproved: true, + effects: { prepare, settle, discard }, + effectSlot: 0, + approval: { confirm, emitApprovalRequested }, + beforeDispatch, + }; + const registry = createToolRegistry({ tools: [def], host }); + const dispatch = (modelArgs: unknown, overrides: Partial = {}) => + registry.dispatch( + { type: 'tool_call', id: 'c-bounds', name: rail.id, args: modelArgs }, + { ...ctx, ...overrides }, + ); + return { + dispatch, + sequence, + parseArgs, + policyTarget, + effect, + confirm, + emitApprovalRequested, + prepare, + settle, + discard, + beforeDispatch, + writeFile, + spawn, + fetch, + store, + }; +} + +type Harness = ReturnType; + +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return error; + } + return undefined; +} + +function expectRefusal(error: unknown, field: string, h: Harness): void { + expect(error, '[ADMISSION-TYPED] bound refusal is an argument validation failure').toBeInstanceOf( + ToolArgsInvalidError, + ); + if (!(error instanceof ToolArgsInvalidError)) return; + expect(error).toMatchObject({ + code: 'invalid_args', + runErrorCode: 'validation', + retryable: false, + }); + expect(error.fields, '[ADMISSION-FIELD] public diagnostic names the offending path').toContain( + field, + ); + expect( + error.message, + '[ADMISSION-PRIVACY] public message contains no input canary', + ).not.toContain(CANARY); + expect( + JSON.stringify(error), + '[ADMISSION-PRIVACY] enumerable public diagnostic contains no input canary', + ).not.toContain(CANARY); + // The native private cause is deliberately not a universal public secrecy claim. + expect( + h.sequence, + '[ADMISSION-ORDER] parse refusal precedes target, policy, approval and effects', + ).toEqual(['parse']); + for (const spy of [ + h.policyTarget, + h.effect, + h.confirm, + h.emitApprovalRequested, + h.prepare, + h.settle, + h.discard, + h.beforeDispatch, + h.writeFile, + h.spawn, + h.fetch, + ]) { + expect(spy, '[ADMISSION-ZERO] no downstream rail is touched on refusal').not.toHaveBeenCalled(); + } + expect(h.store.rows()).toEqual([]); +} + +function expectHostValue(rail: Rail, value: unknown, h: Harness): void { + if (rail.id === 'write_file') + expect(h.writeFile.mock.calls[0]?.slice(0, 3)).toEqual([ + 'out.txt', + value, + { append: true, createDirs: undefined }, + ]); + if (rail.id === 'http_request') + expect(h.fetch.mock.calls[0]?.[0]).toEqual({ + method: 'POST', + url: 'https://api.example/x', + headers: undefined, + body: value, + }); + if (rail.id === 'run_command') + expect(h.spawn.mock.calls[0]?.slice(0, 2)).toEqual(['echo', value]); + if (rail.id === 'git_commit') + expect(h.spawn.mock.calls[0]?.slice(0, 2)).toEqual([ + 'git', + ['commit', '-m', value, '--', 'out.txt'], + ]); +} + +function expectCommitted(rail: Rail, value: unknown, h: Harness): void { + const approval = rail.id === 'git_commit' ? [] : ['approval_event', 'confirm']; + expect(h.sequence, '[POSITIVE-RAIL] accepted effective args reach the real ordered rail').toEqual( + ['parse', 'target', 'effect', ...approval, 'prepare', 'before', 'host', 'settle'], + ); + expect(h.prepare).toHaveBeenCalledOnce(); + expect(h.settle).toHaveBeenCalledWith(0, rail.id, 'committed', expect.anything()); + expect(h.discard).not.toHaveBeenCalled(); + expect(h.store.rows()).toMatchObject([{ toolId: rail.id, state: 'committed', tier: 3 }]); + expectHostValue(rail, value, h); +} + +describe('built-in bounds at effective-argument admission before money/effects', () => { + for (const rail of RAILS) { + for (const [label, value] of [ + ['small', rail.small], + ['exact-cap', rail.exact], + ]) { + it(`[POSITIVE-RAIL] ${rail.id} ${String(label)} reaches approval/journal/host unchanged`, async () => { + const effective = { ...rail.base, [rail.field]: value }; + const h = harness(rail, effective); + await h.dispatch(effective); + expectCommitted(rail, value, h); + }); + } + + for (const source of ['model', 'mapping-over-model', 'mapping-without-model']) { + it(`[EFFECTIVE-REFUSAL] ${rail.id} refuses oversized ${source} before every downstream rail`, async () => { + const effective = { ...rail.base, [rail.field]: rail.over }; + const config: ToolNodeConfig = source === 'model' ? {} : { inputMapping: effective }; + const h = harness(rail, effective, config); + const model = + source === 'model' + ? effective + : source === 'mapping-over-model' + ? { ...rail.base, [rail.field]: rail.small } + : undefined; + const error = await rejection(h.dispatch(model)); + expectRefusal(error, rail.id === 'run_command' ? 'args.0' : rail.field, h); + }); + } + + it(`[EFFECTIVE-OVERRIDE] ${rail.id} valid mapping replaces oversized model value before admission`, async () => { + const effective = { ...rail.base, [rail.field]: rail.small }; + const h = harness(rail, effective, { inputMapping: effective }); + await h.dispatch({ ...rail.base, [rail.field]: rail.over }); + expectCommitted(rail, rail.small, h); + }); + + it(`[CONFIG-EXCLUSION] ${rail.id} parameters do not override model-visible bounded fields`, async () => { + const effective = { ...rail.base, [rail.field]: rail.small }; + const h = harness(rail, effective, { parameters: { [rail.field]: rail.over } }); + await h.dispatch(effective); + expectCommitted(rail, rail.small, h); + }); + } + + for (const [label, args] of [ + ['count', Array(257).fill('')], + ['aggregate', [...Array(4).fill('a'.repeat(16 * 1024)), 'a']], + ] satisfies Array<[string, string[]]>) { + it(`[ARG-VECTOR-ADMISSION] run_command refuses ${label} before target and preparation`, async () => { + const rail = RAILS.find((candidate) => candidate.id === 'run_command'); + if (rail === undefined) throw new Error('missing command rail'); + const effective = { command: 'echo', args }; + const h = harness(rail, effective); + expectRefusal(await rejection(h.dispatch(effective)), 'args', h); + }); + } + + it('[GIT-GATE-PRESERVATION] valid git args still require a gate and invalid args precede gate denial', async () => { + const rail = RAILS.find((candidate) => candidate.id === 'git_commit'); + if (rail === undefined) throw new Error('missing commit rail'); + const effective = { ...rail.base, message: rail.small }; + const h = harness(rail, effective); + const gateError = await rejection(h.dispatch(effective, { gateApproved: false })); + expect(gateError).toBeInstanceOf(ToolPolicyError); + expect(gateError).toMatchObject({ code: 'tool_denied', reason: 'gate_required' }); + expect(h.prepare).not.toHaveBeenCalled(); + const invalid = harness(rail, effective); + expectRefusal( + await rejection( + invalid.dispatch({ ...rail.base, message: rail.over }, { gateApproved: false }), + ), + 'message', + invalid, + ); + }); + + it('[UNJOURNALED-POSITIVES] overwrite and omitted-body GET reach host without effect preparation', async () => { + const write = RAILS[0]; + const http = RAILS[1]; + if (write === undefined || http === undefined) throw new Error('missing non-effect rails'); + const w = harness(write, { path: 'out.txt', content: '' }); + await w.dispatch({ path: 'out.txt', content: '' }); + expect(w.writeFile).toHaveBeenCalledOnce(); + expect(w.beforeDispatch).toHaveBeenCalledOnce(); + expect(w.confirm).toHaveBeenCalledOnce(); + expect(w.store.rows()).toEqual([]); + expect(w.prepare).not.toHaveBeenCalled(); + const h = harness(http, { url: 'https://api.example/x' }); + await h.dispatch({ url: 'https://api.example/x' }); + expect(h.fetch.mock.calls[0]?.[0]).toEqual({ + method: 'GET', + url: 'https://api.example/x', + headers: undefined, + body: undefined, + }); + expect(h.beforeDispatch).toHaveBeenCalledOnce(); + expect(h.confirm).toHaveBeenCalledOnce(); + expect(h.store.rows()).toEqual([]); + expect(h.prepare).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/tools/builtin-input-bounds.test.ts b/packages/core/src/tools/builtin-input-bounds.test.ts new file mode 100644 index 00000000..f995c4b7 --- /dev/null +++ b/packages/core/src/tools/builtin-input-bounds.test.ts @@ -0,0 +1,195 @@ +import { utf8ByteLength } from '@relavium/shared'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { BUILTIN_TOOLS } from './builtins.js'; +import type { ToolDef } from './types.js'; + +// Observe the existing platform-pure byte authority, retaining its actual implementation. No shipping +// injection point is needed, and direct parse avoids unrelated event/output byte measurements. +vi.mock('@relavium/shared', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, utf8ByteLength: vi.fn(actual.utf8ByteLength) }; +}); + +afterEach(() => vi.clearAllMocks()); + +function tool(id: string): ToolDef { + const found = BUILTIN_TOOLS.find((candidate) => candidate.id === id); + if (found === undefined) throw new Error(`missing built-in fixture: ${id}`); + return found; +} + +const STRING_FIELDS = [ + { id: 'write_file', field: 'content', cap: 8 * 1024 * 1024, base: { path: 'out.txt' } }, + { id: 'http_request', field: 'body', cap: 1024 * 1024, base: { url: 'https://api.example/x' } }, + { id: 'git_commit', field: 'message', cap: 16 * 1024, base: {} }, +] satisfies ReadonlyArray<{ + id: string; + field: string; + cap: number; + base: Record; +}>; + +const PATTERNS = [ + { label: 'ASCII', text: 'a', bytes: 1 }, + { label: 'two-byte', text: 'é', bytes: 2 }, + { label: 'three-byte', text: '€', bytes: 3 }, + { label: 'surrogate-pair', text: '😀', bytes: 4 }, + { label: 'lone-high-surrogate', text: '\ud800', bytes: 3 }, + { label: 'lone-low-surrogate', text: '\udc00', bytes: 3 }, + // The high surrogate must not consume the following non-low unit: 3 + 1 bytes per pattern. + { label: 'high-surrogate-before-ASCII', text: '\ud800b', bytes: 4 }, +]; + +function atBytes(cap: number, pattern: (typeof PATTERNS)[number]): string { + return pattern.text.repeat(Math.floor(cap / pattern.bytes)) + 'a'.repeat(cap % pattern.bytes); +} + +describe('built-in executable UTF-8 input admission', () => { + for (const { id, field, cap, base } of STRING_FIELDS) { + for (const pattern of PATTERNS) { + it(`[BYTE-CAP] ${id}.${field} accepts ${cap} and refuses ${cap + 1} bytes (${pattern.label})`, () => { + // Expected byte sizes come from the literal pattern arithmetic, not the production helper. + const exact = atBytes(cap, pattern); + const raw = { ...base, [field]: exact }; + expect(tool(id).parseArgs(raw), 'inclusive byte cap preserves the accepted value').toEqual( + raw, + ); + expect( + () => tool(id).parseArgs({ ...base, [field]: `${exact}a` }), + 'one byte over the cap must be refused', + ).toThrow(z.ZodError); + }); + } + + it(`[CHEAP-STRING] ${id}.${field} rejects impossible UTF-16 length before byte scanning`, () => { + const byteLength = vi.mocked(utf8ByteLength); + byteLength.mockClear(); + expect(() => tool(id).parseArgs({ ...base, [field]: 'a'.repeat(cap + 1) })).toThrow( + z.ZodError, + ); + expect( + byteLength, + 'cheap character lower bound must precede byte measurement', + ).not.toHaveBeenCalled(); + }); + + it(`[REAL-BYTES] ${id}.${field} measures plausible multibyte overflow and exact cap`, () => { + const byteLength = vi.mocked(utf8ByteLength); + const exact = 'é'.repeat(cap / 2); + byteLength.mockClear(); + expect(() => tool(id).parseArgs({ ...base, [field]: `${exact}a` })).toThrow(z.ZodError); + expect( + byteLength, + 'UTF-16-plausible overflow must use the shared byte authority', + ).toHaveBeenCalledWith(`${exact}a`); + byteLength.mockClear(); + expect(tool(id).parseArgs({ ...base, [field]: exact })).toEqual({ ...base, [field]: exact }); + expect(byteLength).toHaveBeenCalledWith(exact); + }); + } + + for (const pattern of PATTERNS) { + it(`[ARG-BYTE-CAP] run_command entry accepts 16384 and refuses 16385 bytes (${pattern.label})`, () => { + const exact = atBytes(16 * 1024, pattern); + expect(tool('run_command').parseArgs({ command: 'echo', args: [exact] })).toEqual({ + command: 'echo', + args: [exact], + }); + expect(() => tool('run_command').parseArgs({ command: 'echo', args: [`${exact}a`] })).toThrow( + z.ZodError, + ); + }); + } + + it('[ARG-COUNT] accepts 256 empty entries and refuses 257 before member byte scans', () => { + expect( + tool('run_command').parseArgs({ command: 'echo', args: Array(256).fill('') }), + ).toEqual({ + command: 'echo', + args: Array(256).fill(''), + }); + const byteLength = vi.mocked(utf8ByteLength); + byteLength.mockClear(); + expect(() => + tool('run_command').parseArgs({ command: 'echo', args: Array(257).fill('é') }), + ).toThrow(z.ZodError); + expect(byteLength, 'count refusal precedes all member byte scans').not.toHaveBeenCalled(); + }); + + it('[ARG-AGGREGATE] accepts exactly 65536 bytes and refuses an extra individually valid entry', () => { + const args = Array(4).fill('a'.repeat(16 * 1024)); + expect(tool('run_command').parseArgs({ command: 'echo', args })).toEqual({ + command: 'echo', + args, + }); + expect(() => tool('run_command').parseArgs({ command: 'echo', args: [...args, 'a'] })).toThrow( + z.ZodError, + ); + }); + + it('[ARG-DENSE-AGGREGATE] counts every multibyte member, not UTF-16 units or the largest member', () => { + const args = Array(256).fill('é'.repeat(128)); // 256 * 128 * 2 = 65536 bytes. + expect(tool('run_command').parseArgs({ command: 'echo', args })).toEqual({ + command: 'echo', + args, + }); + args[255] = `${args[255] ?? ''}a`; + expect(() => tool('run_command').parseArgs({ command: 'echo', args })).toThrow(z.ZodError); + }); + + it('[CHEAP-ARG-ENTRY] refuses impossible member length before byte measurement', () => { + expect(() => + tool('run_command').parseArgs({ command: 'echo', args: ['a'.repeat(16 * 1024 + 1)] }), + ).toThrow(z.ZodError); + expect(vi.mocked(utf8ByteLength)).not.toHaveBeenCalled(); + }); + + it('[CHEAP-ARG-TOTAL] refuses impossible aggregate UTF-16 length before byte measurement', () => { + expect(() => + tool('run_command').parseArgs({ + command: 'echo', + args: Array(5).fill('a'.repeat(16 * 1024)), + }), + ).toThrow(z.ZodError); + expect(vi.mocked(utf8ByteLength)).not.toHaveBeenCalled(); + }); + + it('[OPTIONAL-EMPTY] preserves empty content/body, omitted body/args and empty args', () => { + for (const raw of [ + { path: 'out.txt', content: '' }, + { path: 'out.txt', content: 'hello' }, + ]) { + expect(tool('write_file').parseArgs(raw)).toEqual(raw); + } + for (const raw of [ + { url: 'https://api.example/x' }, + { url: 'https://api.example/x', body: '' }, + ]) { + expect(tool('http_request').parseArgs(raw)).toEqual(raw); + } + for (const raw of [ + { command: 'echo' }, + { command: 'echo', args: [] }, + { command: 'echo', args: ['ordinary', 'two words', ''] }, + ]) { + expect(tool('run_command').parseArgs(raw)).toEqual(raw); + } + expect(() => tool('git_commit').parseArgs({ message: '' })).toThrow(z.ZodError); + expect(tool('git_commit').parseArgs({ message: 'ordinary commit' })).toEqual({ + message: 'ordinary commit', + }); + }); + + it('[STRICT-SHAPE] preserves type and unknown-key refusal on bounded fields', () => { + for (const { id, field, base } of STRING_FIELDS) { + expect(() => tool(id).parseArgs({ ...base, [field]: 1 })).toThrow(z.ZodError); + expect(() => tool(id).parseArgs({ ...base, [field]: 'ok', extra: true })).toThrow(z.ZodError); + } + expect(() => tool('run_command').parseArgs({ command: 'echo', args: [1] })).toThrow(z.ZodError); + expect(() => tool('run_command').parseArgs({ command: 'echo', args: [], extra: true })).toThrow( + z.ZodError, + ); + }); +}); diff --git a/packages/core/src/tools/builtin-model-schema.test.ts b/packages/core/src/tools/builtin-model-schema.test.ts new file mode 100644 index 00000000..597b7905 --- /dev/null +++ b/packages/core/src/tools/builtin-model-schema.test.ts @@ -0,0 +1,240 @@ +import { toWire, ToolDefSchema, type ProviderId, type ToolWire } from '@relavium/llm'; +import { describe, expect, it } from 'vitest'; + +import { BUILTIN_TOOLS } from './builtins.js'; +import type { ToolDef } from './types.js'; + +interface Shape { + readonly id: string; + readonly properties: readonly string[]; + readonly required: readonly string[]; + readonly configOnly?: readonly string[]; +} + +const SHAPES: readonly Shape[] = [ + { id: 'read_file', properties: ['path', 'glob'], required: ['path'] }, + { + id: 'write_file', + properties: ['path', 'content', 'append', 'createDirs'], + required: ['path', 'content'], + }, + { id: 'list_directory', properties: ['path', 'recursive', 'glob'], required: ['path'] }, + { + id: 'run_command', + properties: ['command', 'args'], + required: ['command'], + configOnly: ['cwd', 'env', 'timeoutMs'], + }, + { id: 'git_status', properties: ['command'], required: [], configOnly: ['args'] }, + { id: 'git_commit', properties: ['message', 'files'], required: ['message'] }, + { id: 'http_request', properties: ['method', 'url', 'headers', 'body'], required: ['url'] }, + { + id: 'web_search', + properties: ['query', 'maxResults'], + required: ['query'], + configOnly: ['endpoint', 'credentialRef'], + }, + { id: 'mcp_call', properties: ['server', 'tool', 'args'], required: ['server', 'tool'] }, + { id: 'read_clipboard', properties: [], required: [] }, + { id: 'notify', properties: ['title', 'body'], required: ['title', 'body'] }, + { id: 'read_media', properties: ['handle'], required: ['handle'] }, + { id: 'invoke_agent', properties: ['nodeId', 'input'], required: ['nodeId'] }, +]; + +function builtin(id: string): ToolDef { + const found = BUILTIN_TOOLS.find((candidate) => candidate.id === id); + if (found === undefined) throw new Error(`missing built-in fixture: ${id}`); + return found; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function record(value: unknown): Record { + if (!isRecord(value)) { + throw new Error('expected an object schema in actual built-in/lowered fixture'); + } + return value; +} + +function properties(schema: unknown): Record { + return record(record(schema)['properties']); +} + +function description(schema: unknown): string { + const text = record(schema)['description']; + expect( + typeof text, + '[MODEL-DESCRIPTION] every actual model-visible property describes its use', + ).toBe('string'); + if (typeof text !== 'string') return ''; + expect( + text.trim().length, + '[MODEL-DESCRIPTION] property guidance is more than a field-name label', + ).toBeGreaterThan(10); + return text; +} + +function wireSchema(wire: ToolWire): unknown { + if ('input_schema' in wire) return wire.input_schema; + if ('function' in wire) return wire.function.parameters; + const declaration = wire.functionDeclarations[0]; + if (declaration === undefined) throw new Error('missing actual Gemini declaration'); + return declaration.parameters; +} + +function wireDescription(wire: ToolWire): string | undefined { + if ('input_schema' in wire) return wire.description; + if ('function' in wire) return wire.function.description; + return wire.functionDeclarations[0]?.description; +} + +function expectShape(schema: unknown, shape: Shape): void { + expect( + Object.keys(properties(schema)).sort(), + '[MODEL-SHAPE] lowering retains exactly the advertised fields', + ).toEqual([...shape.properties].sort()); + expect( + record(schema)['required'] ?? [], + '[MODEL-REQUIRED] required and optional distinctions survive', + ).toEqual(shape.required); + for (const key of shape.configOnly ?? []) { + expect( + properties(schema), + '[MODEL-CONFIG-EXCLUSION] pinned author configuration is not advertised', + ).not.toHaveProperty(key); + } +} + +const BOUNDED_FIELDS = [ + { id: 'write_file', field: 'content', cap: 8 * 1024 * 1024, units: /(?:8\s*MiB|8,?388,?608)/i }, + { id: 'http_request', field: 'body', cap: 1024 * 1024, units: /(?:1\s*MiB|1,?048,?576)/i }, + { id: 'git_commit', field: 'message', cap: 16 * 1024, units: /(?:16\s*KiB|16,?384)/i }, +]; + +function expectBounds(id: string, schema: unknown): void { + for (const bound of BOUNDED_FIELDS.filter((candidate) => candidate.id === id)) { + const field = record(properties(schema)[bound.field]); + const text = description(field); + expect(text, '[MODEL-BYTE-UNITS] string budget uses UTF-8 bytes').toMatch(/UTF-?8/i); + expect(text, '[MODEL-BYTE-UNITS] exact approved byte ceiling is communicated').toMatch( + bound.units, + ); + // JSON Schema maxLength is only a necessary character bound. The description, not an invented + // custom keyword or a claim that maxLength counts bytes, communicates the executable byte cap. + expect( + field['maxLength'], + '[MODEL-CHAR-BOUND] character constraint remains a valid necessary bound', + ).toBe(bound.cap); + } + if (id === 'run_command') { + const args = record(properties(schema)['args']); + expect(args['maxItems'], '[MODEL-ARG-COUNT] maximum vector count survives lowering').toBe(256); + const text = description(args); + expect(text, '[MODEL-ARG-TOTAL] aggregate byte budget survives lowering').toMatch( + /(?:64\s*KiB|65,?536)/i, + ); + expect(text).toMatch(/UTF-?8/i); + expect(text).toMatch(/256/); + const item = record(args['items']); + const itemText = description(item); + expect(itemText, '[MODEL-ARG-ENTRY] per-entry UTF-8 byte budget survives lowering').toMatch( + /UTF-?8/i, + ); + expect(itemText).toMatch(/(?:16\s*KiB|16,?384)/i); + expect(item['maxLength'], '[MODEL-ARG-ENTRY] necessary character bound survives lowering').toBe( + 16 * 1024, + ); + } +} + +describe('actual built-in model metadata through the public LLM seam', () => { + it('[MODEL-CATALOG] expectation table covers every actual built-in', () => { + expect(SHAPES.map((shape) => shape.id).sort()).toEqual( + BUILTIN_TOOLS.map((tool) => tool.id).sort(), + ); + }); + + for (const shape of SHAPES) { + it(`[MODEL-AUTHORED] ${shape.id} describes every property/item and preserves its strict projection`, () => { + const tool = builtin(shape.id); + expectShape(tool.llmVisibleParams, shape); + expect(record(tool.llmVisibleParams)['additionalProperties']).toBe(false); + for (const field of Object.values(properties(tool.llmVisibleParams))) { + description(field); + if (record(field)['type'] === 'array') description(record(field)['items']); + } + expect( + tool.description, + '[MODEL-GUIDANCE] model instructions avoid host implementation vocabulary', + ).not.toMatch(/shell:false|allowedDomains|SSRF-guarded|exact-FQDN/); + expectBounds(shape.id, tool.llmVisibleParams); + }); + + for (const provider of ['anthropic', 'openai', 'deepseek', 'gemini'] satisfies ProviderId[]) { + it(`[MODEL-LOWERING] ${shape.id} keeps descriptions, bounds and configuration exclusions for ${provider}`, () => { + const tool = builtin(shape.id); + const parsed = ToolDefSchema.parse({ + name: tool.id, + description: tool.description, + parameters: tool.llmVisibleParams, + }); + const wire = toWire(parsed, provider); + const schema = wireSchema(wire); + expectShape(schema, shape); + expect(wireDescription(wire)).toBe(tool.description); + for (const [name, source] of Object.entries(properties(tool.llmVisibleParams))) { + const lowered = properties(schema)[name]; + description(lowered); + expect( + record(lowered)['description'], + '[MODEL-LOWERING-DESCRIPTION] actual property text survives the real normalizer', + ).toBe(record(source)['description']); + if (record(source)['type'] === 'array') { + description(record(lowered)['items']); + expect(record(record(lowered)['items'])['description']).toBe( + record(record(source)['items'])['description'], + ); + } + } + expectBounds(shape.id, schema); + if (provider === 'gemini') + expect(record(schema)).not.toHaveProperty('additionalProperties'); + else expect(record(schema)['additionalProperties']).toBe(false); + }); + } + } + + it('[MODEL-PRACTICAL-SEMANTICS] descriptions explain safe choices, defaults and availability', () => { + const write = properties(builtin('write_file').llmVisibleParams); + expect(description(write['append'])).toMatch(/append/i); + expect(description(write['append'])).toMatch(/overwrite|replace/i); + const command = builtin('run_command'); + expect( + `${command.description} ${description(properties(command.llmVisibleParams)['args'])}`, + ).toMatch( + /(?:without|no)\s+(?:a\s+)?shell|(?:not|never)\s+(?:a\s+)?shell|literal|directly|verbatim/i, + ); + const http = builtin('http_request'); + expect(`${http.description} ${description(properties(http.llmVisibleParams)['url'])}`).toMatch( + /HTTPS/i, + ); + expect(`${http.description} ${description(properties(http.llmVisibleParams)['url'])}`).toMatch( + /allow|configur/i, + ); + const status = properties(builtin('git_status').llmVisibleParams); + expect(record(status['command'])['enum']).toEqual(['status', 'log', 'diff']); + expect(description(status['command'])).toMatch(/default.*status|status.*default/i); + expect(description(properties(builtin('git_commit').llmVisibleParams)['files'])).toMatch( + /path|file/i, + ); + const media = builtin('read_media'); + expect( + `${media.description} ${description(properties(media.llmVisibleParams)['handle'])}`, + ).toMatch(/whole|entire/i); + const mcp = properties(builtin('mcp_call').llmVisibleParams); + expect(description(mcp['server'])).toMatch(/configur/i); + expect(description(mcp['tool'])).toMatch(/name|identif/i); + }); +}); diff --git a/packages/core/src/tools/builtins-identifier-order.test.ts b/packages/core/src/tools/builtins-identifier-order.test.ts new file mode 100644 index 00000000..6aed1b20 --- /dev/null +++ b/packages/core/src/tools/builtins-identifier-order.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from 'vitest'; + +const expectedBuiltinIds = [ + 'git_commit', + 'git_status', + 'http_request', + 'invoke_agent', + 'list_directory', + 'mcp_call', + 'notify', + 'read_clipboard', + 'read_file', + 'read_media', + 'run_command', + 'web_search', + 'write_file', +]; + +describe('built-in catalog identifier ordering (#259)', () => { + it('preserves the fixed thirteen-ID catalog through actual module evaluation', async () => { + const catalog = await import('./builtins.js'); + expect(catalog.BUILTIN_TOOL_IDS, 'fixed built-in catalog preservation').toEqual( + expectedBuiltinIds, + ); + expect(catalog.BUILTIN_TOOLS).toHaveLength(13); + expect(catalog.BUILTIN_TOOLS.every((tool) => tool.source === 'builtin')).toBe(true); + expect(new Set(catalog.BUILTIN_TOOLS.map((tool) => tool.id))).toEqual( + new Set(expectedBuiltinIds), + ); + }); + + it('fresh catalog import never calls ambient locale collation', async () => { + vi.resetModules(); + const ambient = vi.spyOn(String.prototype, 'localeCompare'); + try { + const catalog = await import('./builtins.js'); + expect( + catalog.BUILTIN_TOOL_IDS, + 'import-time builtin sort is independent of ambient collation', + ).toEqual(expectedBuiltinIds); + expect(ambient).not.toHaveBeenCalled(); + } finally { + ambient.mockRestore(); + vi.resetModules(); + } + }); +}); diff --git a/packages/core/src/tools/builtins.test.ts b/packages/core/src/tools/builtins.test.ts index 15ef1728..157c6cef 100644 --- a/packages/core/src/tools/builtins.test.ts +++ b/packages/core/src/tools/builtins.test.ts @@ -392,7 +392,13 @@ describe('read_media (1.AF/D12 + `CR-50` — scope-set authz, whole handle, deli // would let `start` back into the projection without a word. expect(t.llmVisibleParams).toEqual({ type: 'object', - properties: { handle: { type: 'string' } }, + properties: { + handle: { + type: 'string', + description: + 'Whole media://sha256-<64hex> handle authorized for this conversation; byte ranges are not supported.', + }, + }, required: ['handle'], additionalProperties: false, }); diff --git a/packages/core/src/tools/builtins.ts b/packages/core/src/tools/builtins.ts index 24a22876..97572b41 100644 --- a/packages/core/src/tools/builtins.ts +++ b/packages/core/src/tools/builtins.ts @@ -3,18 +3,19 @@ * ([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. + * performs side effects ONLY through the injected `ToolHost`. Egress availability depends on the + * surface's configured host capability; the CLI's network host enforces its shared SSRF boundary. */ import { type EffectTier, + byCodeUnit, INLINE_MEDIA_CEILING, MEDIA_HANDLE_PATTERN, MediaMimeTypeSchema, mediaModalityOf, scopeSetIncludes, + utf8ByteLength, } from '@relavium/shared'; import { z } from 'zod'; @@ -45,7 +46,7 @@ import { interface BuiltinSpec { readonly id: ToolId; readonly description: string; - readonly args: z.ZodType; + readonly args: z.ZodType; readonly llmVisibleParams: JsonSchema; readonly configOnlyParams?: readonly string[]; readonly policy: ToolPolicyClass; @@ -79,6 +80,67 @@ function defineBuiltin(spec: BuiltinSpec): ToolDef { return def; } +const WRITE_CONTENT_MAX_BYTES = 8 * 1024 * 1024; +const HTTP_BODY_MAX_BYTES = 1024 * 1024; +const COMMAND_ARG_MAX_BYTES = 16 * 1024; +const COMMAND_ARGS_MAX_BYTES = 64 * 1024; +const COMMAND_ARGS_MAX_COUNT = 256; +const COMMIT_MESSAGE_MAX_BYTES = 16 * 1024; + +/** UTF-16 length is a cheap lower bound; only plausible strings reach the byte measurement. */ +function utf8String(maxBytes: number, minLength = 0): z.ZodEffects { + return z + .string() + .min(minLength) + .refine((value) => value.length <= maxBytes && utf8ByteLength(value) <= maxBytes, { + message: `must contain at most ${maxBytes} UTF-8 bytes`, + }); +} + +// Refuse impossible vectors before parsing/measuring their members. A Zod array .max() alone still +// parses every member, and a per-member byte refine would scan before the aggregate cheap gate. +const commandArgs = z + .unknown() + .superRefine((raw, ctx) => { + if (!Array.isArray(raw)) return; // The typed array parser below owns the shape error. + const refuse = (message: string, path: number[] = []): void => + ctx.addIssue({ code: z.ZodIssueCode.custom, message, path, fatal: true }); + if (raw.length > COMMAND_ARGS_MAX_COUNT) { + refuse(`must contain at most ${COMMAND_ARGS_MAX_COUNT} arguments`); + return; + } + let characters = 0; + for (let i = 0; i < raw.length; i += 1) { + const value: unknown = raw[i]; + if (typeof value !== 'string') continue; + if (value.length > COMMAND_ARG_MAX_BYTES) { + refuse(`must contain at most ${COMMAND_ARG_MAX_BYTES} UTF-8 bytes`, [i]); + return; + } + characters += value.length; + if (characters > COMMAND_ARGS_MAX_BYTES) { + refuse(`arguments must total at most ${COMMAND_ARGS_MAX_BYTES} UTF-8 bytes`); + return; + } + } + let bytes = 0; + for (let i = 0; i < raw.length; i += 1) { + const value: unknown = raw[i]; + if (typeof value !== 'string') continue; + const size = utf8ByteLength(value); + if (size > COMMAND_ARG_MAX_BYTES) { + refuse(`must contain at most ${COMMAND_ARG_MAX_BYTES} UTF-8 bytes`, [i]); + return; + } + bytes += size; + if (bytes > COMMAND_ARGS_MAX_BYTES) { + refuse(`arguments must total at most ${COMMAND_ARGS_MAX_BYTES} UTF-8 bytes`); + return; + } + } + }) + .pipe(z.array(z.string())); + /** * 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 @@ -178,7 +240,16 @@ const readFileTool = defineBuiltin({ args: z.object({ path: z.string().min(1), glob: z.boolean().optional() }).strict(), llmVisibleParams: { type: 'object', - properties: { path: { type: 'string' }, glob: { type: 'boolean' } }, + properties: { + path: { + type: 'string', + description: 'File path within the host-authorized filesystem roots.', + }, + glob: { + type: 'boolean', + description: 'Treat the path as a glob pattern when true; defaults to false.', + }, + }, required: ['path'], additionalProperties: false, }, @@ -193,7 +264,7 @@ const writeFileTool = defineBuiltin({ args: z .object({ path: z.string().min(1), - content: z.string(), + content: utf8String(WRITE_CONTENT_MAX_BYTES), append: z.boolean().optional(), createDirs: z.boolean().optional(), }) @@ -201,10 +272,23 @@ const writeFileTool = defineBuiltin({ llmVisibleParams: { type: 'object', properties: { - path: { type: 'string' }, - content: { type: 'string' }, - append: { type: 'boolean' }, - createDirs: { type: 'boolean' }, + path: { + type: 'string', + description: 'Destination file within the host-authorized filesystem roots.', + }, + content: { + type: 'string', + maxLength: WRITE_CONTENT_MAX_BYTES, + description: 'Text to write, at most 8 MiB of UTF-8 bytes; empty text is allowed.', + }, + append: { + type: 'boolean', + description: 'Append when true; otherwise overwrite or replace the existing file content.', + }, + createDirs: { + type: 'boolean', + description: 'Create missing parent directories when true; defaults to false.', + }, }, required: ['path', 'content'], additionalProperties: false, @@ -240,9 +324,18 @@ const listDirectoryTool = defineBuiltin({ llmVisibleParams: { type: 'object', properties: { - path: { type: 'string' }, - recursive: { type: 'boolean' }, - glob: { type: 'string' }, + path: { + type: 'string', + description: 'Directory path within the host-authorized filesystem roots.', + }, + recursive: { + type: 'boolean', + description: 'Include nested directory entries when true; defaults to false.', + }, + glob: { + type: 'string', + description: 'Optional glob pattern used to filter the returned directory entries.', + }, }, required: ['path'], additionalProperties: false, @@ -263,11 +356,11 @@ const listDirectoryTool = defineBuiltin({ const runCommandTool = defineBuiltin({ id: 'run_command', description: - 'Spawn an allowlisted shell command (shell:false) and capture stdout/stderr/exit code.', + 'Run an authorized executable directly, without a shell; return its output and exit code.', args: z .object({ command: z.string().min(1), - args: z.array(z.string()).optional(), + args: commandArgs.optional(), cwd: z.string().optional(), timeoutMs: z.number().int().positive().optional(), env: z.record(z.string()).optional(), @@ -275,7 +368,24 @@ const runCommandTool = defineBuiltin({ .strict(), llmVisibleParams: { type: 'object', - properties: { command: { type: 'string' }, args: { type: 'array', items: { type: 'string' } } }, + properties: { + command: { + type: 'string', + description: 'Executable name or path permitted by the configured command policy.', + }, + args: { + type: 'array', + maxItems: COMMAND_ARGS_MAX_COUNT, + description: + 'Literal arguments passed without shell expansion: at most 256 entries, totaling at most 64 KiB of UTF-8 bytes.', + items: { + type: 'string', + maxLength: COMMAND_ARG_MAX_BYTES, + description: + 'One literal argument, at most 16 KiB of UTF-8 bytes; an empty argument is allowed.', + }, + }, + }, required: ['command'], additionalProperties: false, }, @@ -313,7 +423,14 @@ const gitStatusTool = defineBuiltin({ .strict(), llmVisibleParams: { type: 'object', - properties: { command: { type: 'string', enum: ['status', 'log', 'diff'] } }, + properties: { + command: { + type: 'string', + enum: ['status', 'log', 'diff'], + description: + 'Read-only Git operation; defaults to status. Extra flags are configured by the author.', + }, + }, additionalProperties: false, }, configOnlyParams: ['args'], @@ -332,10 +449,10 @@ const gitStatusTool = defineBuiltin({ const gitCommitTool = defineBuiltin({ id: 'git_commit', description: - 'Create a commit, optionally restricting it to the given pathspecs — requires a human-gate approval in automated workflows.', + 'Create a commit after a human-gate approval. Chat cannot provide that approval; review changes and commit manually instead.', args: z .object({ - message: z.string().min(1), + message: utf8String(COMMIT_MESSAGE_MAX_BYTES, 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). @@ -352,8 +469,19 @@ const gitCommitTool = defineBuiltin({ llmVisibleParams: { type: 'object', properties: { - message: { type: 'string' }, - files: { type: 'array', items: { type: 'string' } }, + message: { + type: 'string', + maxLength: COMMIT_MESSAGE_MAX_BYTES, + description: 'Non-empty commit message, at most 16 KiB of UTF-8 bytes.', + }, + files: { + type: 'array', + description: 'Optional file pathspecs to include in the commit.', + items: { + type: 'string', + description: 'A non-empty file pathspec that does not begin with a hyphen.', + }, + }, }, required: ['message'], additionalProperties: false, @@ -374,28 +502,43 @@ const gitCommitTool = defineBuiltin({ }); /* ------------------------------------------------------------------------------------------------ * - * Egress tools (gated until the shared SSRF primitive lands — 1.AE). + * Egress tools (available only when the surface supplies the corresponding network capability). * ------------------------------------------------------------------------------------------------ */ const httpRequestTool = defineBuiltin({ id: 'http_request', description: - 'Outbound HTTPS request to an allowedDomains host (HTTPS-only, exact-FQDN, SSRF-guarded).', + 'Make an HTTPS request to a configured allowed host, when network access is available.', 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(), + body: utf8String(HTTP_BODY_MAX_BYTES).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' }, + method: { + type: 'string', + enum: ['GET', 'POST', 'PUT', 'DELETE'], + description: 'HTTP method to send; defaults to GET.', + }, + url: { + type: 'string', + description: 'Absolute HTTPS URL whose host is allowed by the configured network policy.', + }, + headers: { + type: 'object', + description: 'Optional HTTP header names and string values for this request.', + additionalProperties: { type: 'string' }, + }, + body: { + type: 'string', + maxLength: HTTP_BODY_MAX_BYTES, + description: 'Optional request text, at most 1 MiB of UTF-8 bytes; empty text is allowed.', + }, }, required: ['url'], additionalProperties: false, @@ -433,7 +576,17 @@ const webSearchTool = defineBuiltin({ .strict(), llmVisibleParams: { type: 'object', - properties: { query: { type: 'string' }, maxResults: { type: 'integer', minimum: 1 } }, + properties: { + query: { + type: 'string', + description: 'Non-empty search query sent to the configured search provider.', + }, + maxResults: { + type: 'integer', + minimum: 1, + description: 'Optional positive maximum number of search results to request.', + }, + }, required: ['query'], additionalProperties: false, }, @@ -453,14 +606,22 @@ 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).', + description: 'Invoke a named tool on a configured MCP server, when that server is available.', 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: {} }, + properties: { + server: { type: 'string', description: 'Identifier of the configured MCP server to call.' }, + tool: { + type: 'string', + description: 'Name or identifier of the tool exposed by that server.', + }, + args: { + description: 'Optional tool arguments in the shape required by the selected server tool.', + }, + }, required: ['server', 'tool'], additionalProperties: false, }, @@ -496,7 +657,10 @@ const notifyTool = defineBuiltin({ args: z.object({ title: z.string().min(1), body: z.string() }).strict(), llmVisibleParams: { type: 'object', - properties: { title: { type: 'string' }, body: { type: 'string' } }, + properties: { + title: { type: 'string', description: 'Non-empty title shown in the desktop notification.' }, + body: { type: 'string', description: 'Notification body text; an empty body is allowed.' }, + }, required: ['title', 'body'], additionalProperties: false, }, @@ -530,7 +694,13 @@ const readMediaTool = defineBuiltin({ .strict(), llmVisibleParams: { type: 'object', - properties: { handle: { type: 'string' } }, + properties: { + handle: { + type: 'string', + description: + 'Whole media://sha256-<64hex> handle authorized for this conversation; byte ranges are not supported.', + }, + }, required: ['handle'], additionalProperties: false, }, @@ -623,7 +793,14 @@ const invokeAgentTool = defineBuiltin({ args: z.object({ nodeId: z.string().min(1), input: z.unknown().optional() }).strict(), llmVisibleParams: { type: 'object', - properties: { nodeId: { type: 'string' }, input: {} }, + properties: { + nodeId: { + type: 'string', + description: + 'Identifier of the agent node to invoke through the available delegation capability.', + }, + input: { description: 'Optional input value supplied to the invoked agent node.' }, + }, required: ['nodeId'], additionalProperties: false, }, @@ -673,5 +850,5 @@ export const BUILTIN_TOOLS: 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( - (a, b) => a.localeCompare(b), + byCodeUnit, ); diff --git a/packages/core/src/tools/errors.ts b/packages/core/src/tools/errors.ts index 6c8a8b05..da13e202 100644 --- a/packages/core/src/tools/errors.ts +++ b/packages/core/src/tools/errors.ts @@ -8,7 +8,7 @@ * [tool-registry.md](../../../../docs/reference/shared-core/tool-registry.md#error-taxonomy) and ADR-0037. */ -import { RelaviumError } from '@relavium/shared'; +import { byCodeUnit, RelaviumError } from '@relavium/shared'; import type { ErrorCode } from '@relavium/shared'; @@ -71,7 +71,7 @@ export class UnknownToolError extends ToolDispatchError { constructor(toolId: ToolId, availableIds: readonly ToolId[]) { super( - `unknown tool \`${toolId}\` — available tools: ${[...availableIds].sort((a, b) => a.localeCompare(b)).join(', ')}`, + `unknown tool \`${toolId}\` — available tools: ${[...availableIds].sort(byCodeUnit).join(', ')}`, toolId, undefined, ); diff --git a/packages/core/src/tools/registry-alias-projection.test.ts b/packages/core/src/tools/registry-alias-projection.test.ts new file mode 100644 index 00000000..cf0cabc4 --- /dev/null +++ b/packages/core/src/tools/registry-alias-projection.test.ts @@ -0,0 +1,249 @@ +import type { EffectDispatchPort } from '@relavium/shared'; +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { createInMemoryEffectJournalStore } from '../engine/execution-host.js'; +import { createToolRegistry } from './registry.js'; +import type { ToolCallPart, ToolDef, ToolDispatchContext } from './types.js'; + +interface Person { + readonly label: string; + readonly confidentialNote: string; +} + +function selection(which: 'alice' | 'bob') { + const alice: Person = { label: 'alice', confidentialNote: 'SYNTHETIC_ALICE_NOTE' }; + const bob: Person = { label: 'bob', confidentialNote: 'SYNTHETIC_BOB_NOTE' }; + return { catalog: [alice, bob], selected: which === 'alice' ? alice : bob }; +} + +function expectedSelection(which: 'alice' | 'bob', omitDeclared: boolean) { + const alice = { + label: 'alice', + ...(omitDeclared ? {} : { confidentialNote: 'SYNTHETIC_ALICE_NOTE' }), + }; + const bob = { label: 'bob', ...(omitDeclared ? {} : { confidentialNote: 'SYNTHETIC_BOB_NOTE' }) }; + return { payload: { catalog: [alice, bob], selected: which === 'alice' ? alice : bob } }; +} + +function harness() { + const store = createInMemoryEffectJournalStore(); + const inner = store.for({ kind: 'run', runId: 'r-alias', nodeId: 'n', attempt: 1 }); + const effects: EffectDispatchPort = { + prepare: vi.fn((...args: Parameters) => inner.prepare(...args)), + settle: vi.fn((...args: Parameters) => inner.settle(...args)), + discard: vi.fn((...args: Parameters) => inner.discard(...args)), + }; + const received: unknown[] = []; + // z.unknown retains the payload graph; a recursive schema clone could hide the alias defect. + const tool: ToolDef = { + id: 'alias_publish', + source: 'mcp', + description: 'Offline alias-projection seam fixture', + llmVisibleParams: { type: 'object' }, + parseArgs: (raw) => z.object({ payload: z.unknown() }).strict().parse(raw), + policy: { fsScoped: false, spawnsProcess: false, requiresGateApproval: false }, + effect: () => 3, + dispatch: (args) => { + const parsed = z.object({ payload: z.unknown() }).strict().parse(args); + received.push(parsed.payload); + return Promise.resolve({ receipt: 'published' }); + }, + }; + const context: ToolDispatchContext = { + nodeId: 'n', + grantedToolIds: new Set([tool.id]), + config: {}, + toolPolicy: {}, + fsScope: 'sandboxed', + gateApproved: false, + effects, + effectSlot: 0, + }; + return { + registry: createToolRegistry({ tools: [tool], host: {} }), + context, + received, + store, + effects, + }; +} + +function call(payload: unknown): ToolCallPart { + return { type: 'tool_call', id: 'alias-call', name: 'alias_publish', args: { payload } }; +} + +const METADATA = [ + { name: 'absent', keys: undefined, omitDeclared: false }, + { name: 'empty', keys: new Set(), omitDeclared: false }, + { name: 'nonempty', keys: new Set(['confidentialNote']), omitDeclared: true }, +]; + +describe.each(METADATA)( + 'W3S5 #94 actual registry joins with $name external metadata', + ({ keys, omitDeclared }) => { + it('REGISTRY_SELECTION preserves selected value in event and prepare copies without changing dispatch identity', async () => { + const h = harness(); + const context = { ...h.context, ...(keys === undefined ? {} : { secretArgKeys: keys }) }; + for (const [slot, which] of ['alice', 'bob'].entries()) { + if (which !== 'alice' && which !== 'bob') throw new TypeError('invalid fixture selection'); + const payload = selection(which); + const outcome = await h.registry.dispatch(call(payload), { ...context, effectSlot: slot }); + expect(h.received[slot], 'REGISTRY_DISPATCH_IDENTITY').toBe(payload); + expect(payload.selected).toBe(payload.catalog[slot]); + expect(outcome.events.call.toolInput, 'REGISTRY_SELECTION_EVENT').toEqual( + expectedSelection(which, omitDeclared), + ); + expect( + vi.mocked(h.effects.prepare).mock.calls[slot]?.[3], + 'REGISTRY_SELECTION_PREPARE', + ).toEqual(expectedSelection(which, omitDeclared)); + expect(payload.selected.confidentialNote).toBe( + which === 'alice' ? 'SYNTHETIC_ALICE_NOTE' : 'SYNTHETIC_BOB_NOTE', + ); + } + expect(h.store.rows()).toMatchObject([ + { state: 'committed', slot: 0 }, + { state: 'committed', slot: 1 }, + ]); + }); + + it('REGISTRY_REPLAY uses actual JSON argsKey equality and refuses changed selection at the same slot', async () => { + const h = harness(); + const context = { ...h.context, ...(keys === undefined ? {} : { secretArgKeys: keys }) }; + const first = await h.registry.dispatch(call(selection('alice')), context); + const replay = await h.registry.dispatch(call(selection('alice')), context); + expect(replay.output, 'REGISTRY_REPLAY').toEqual(first.output); + expect(h.received).toHaveLength(1); + await expect( + h.registry.dispatch(call(selection('bob')), context), + 'REGISTRY_CHANGED_SELECTION_CONFLICT', + ).rejects.toMatchObject({ + name: 'ToolEffectConflictError', + code: 'effect_conflict', + retryable: false, + }); + expect(h.received, 'REGISTRY_NO_DUPLICATE_DISPATCH').toHaveLength(1); + expect(h.store.rows()).toHaveLength(1); + await h.registry.dispatch(call(selection('bob')), { ...context, effectSlot: 1 }); + expect(h.received).toHaveLength(2); + expect(h.store.rows()).toMatchObject([ + { slot: 0, state: 'committed' }, + { slot: 1, state: 'committed' }, + ]); + expect( + vi.mocked(h.effects.prepare).mock.calls.map((args) => args[3]), + 'REGISTRY_REPLAY_PROJECTIONS', + ).toEqual([ + expectedSelection('alice', omitDeclared), + expectedSelection('alice', omitDeclared), + expectedSelection('bob', omitDeclared), + expectedSelection('bob', omitDeclared), + ]); + // This reference journal owns JSON.stringify argsKey, not a physical SHA-256 digest. + }); + + it('REGISTRY_CYCLE keeps a finite ancestor marker and expands repeated cycle-bearing siblings', async () => { + const h = harness(); + const shared: Record = { + label: 'cycle', + confidentialNote: 'SYNTHETIC_CYCLE_NOTE', + }; + const children: unknown[] = [shared]; + shared['children'] = children; + const payload = { first: shared, second: shared }; + const outcome = await h.registry.dispatch(call(payload), { + ...h.context, + ...(keys === undefined ? {} : { secretArgKeys: keys }), + }); + const branch = { + label: 'cycle', + ...(omitDeclared ? {} : { confidentialNote: 'SYNTHETIC_CYCLE_NOTE' }), + children: ['[cyclic]'], + }; + for (const projection of [ + outcome.events.call.toolInput, + vi.mocked(h.effects.prepare).mock.calls[0]?.[3], + ]) { + expect(projection, 'REGISTRY_CYCLE').toEqual({ + payload: { first: branch, second: branch }, + }); + } + expect(h.received[0]).toBe(payload); + expect(shared['children']).toBe(children); + expect(children[0]).toBe(shared); + }); + + it('REGISTRY_NATIVE unwinds repeated native early returns in both prewalk joins', async () => { + const h = harness(); + const date = new Date('2026-10-16T00:00:00.000Z'); + const native = new Map([['label', 'native']]); + const payload = { dates: [date, date], natives: [native, native] }; + const outcome = await h.registry.dispatch(call(payload), { + ...h.context, + ...(keys === undefined ? {} : { secretArgKeys: keys }), + }); + for (const projection of [ + outcome.events.call.toolInput, + vi.mocked(h.effects.prepare).mock.calls[0]?.[3], + ]) { + expect(projection, 'REGISTRY_NATIVE').toEqual({ + payload: { dates: [date, date], natives: [native, native] }, + }); + } + expect(h.received[0]).toBe(payload); + expect(payload.dates[0]).toBe(date); + expect(payload.natives[1]).toBe(native); + // The reference journal permits JSON-native rendering. Native objects are not a + // canonical SQLite digest positive; that boundary intentionally refuses them. + }); + + it('REGISTRY_REDACTION keeps credential and media bytes out of both actual projection sinks', async () => { + const h = harness(); + const shared = { + label: 'clean', + api_key: 'SYNTHETIC_OPAQUE_KEY', + pin: 7319, + Authorization: 'Bearer SYNTHETIC_BEARER_CANARY', + argv: ['--token', 'SYNTHETIC_FLAG_CANARY', '-p', '2222'], + image: { kind: 'base64', data: 'U1lOVEhFVElDX01FRElBX0NBTkFSWQ==' }, + confidentialNote: 'SYNTHETIC_DECLARED_CANARY', + }; + const payload = { left: shared, right: shared }; + const outcome = await h.registry.dispatch(call(payload), { + ...h.context, + ...(keys === undefined ? {} : { secretArgKeys: keys }), + }); + const branch = { + label: 'clean', + api_key: '[redacted]', + pin: '[redacted]', + Authorization: 'Bearer [redacted]', + argv: ['--token', '[redacted]', '-p', '2222'], + image: { kind: 'base64', base64Length: shared.image.data.length }, + ...(omitDeclared ? {} : { confidentialNote: 'SYNTHETIC_DECLARED_CANARY' }), + }; + for (const projection of [ + outcome.events.call.toolInput, + vi.mocked(h.effects.prepare).mock.calls[0]?.[3], + ]) { + expect(projection, 'REGISTRY_REDACTION').toEqual({ + payload: { left: branch, right: branch }, + }); + const text = JSON.stringify(projection); + for (const canary of [ + 'SYNTHETIC_OPAQUE_KEY', + '7319', + 'SYNTHETIC_BEARER_CANARY', + 'SYNTHETIC_FLAG_CANARY', + shared.image.data, + ]) + expect(text).not.toContain(canary); + if (omitDeclared) expect(text).not.toContain('SYNTHETIC_DECLARED_CANARY'); + } + expect(h.received[0]).toBe(payload); + expect(payload.right).toBe(payload.left); + expect(shared.api_key).toBe('SYNTHETIC_OPAQUE_KEY'); + }); + }, +); diff --git a/packages/core/src/tools/registry-identifier-order.test.ts b/packages/core/src/tools/registry-identifier-order.test.ts new file mode 100644 index 00000000..56a0f584 --- /dev/null +++ b/packages/core/src/tools/registry-identifier-order.test.ts @@ -0,0 +1,242 @@ +import { unwiredEffectJournal, type EffectDispatchPort } from '@relavium/shared'; +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { ToolArgsInvalidError, UnknownToolError } from './errors.js'; +import { createToolRegistry } from './registry.js'; +import type { + FsCapability, + ToolCallPart, + ToolDef, + ToolDispatchContext, + ToolHost, +} from './types.js'; +import { unwrapUntrusted } from './untrusted.js'; + +const ids = ['a_b', 'a-b', 'a', 'Z']; +const expected = ['Z', 'a', 'a-b', 'a_b']; +const canary = 'ARGUMENT-VALUE-CANARY'; + +function call(name: string, args: unknown = {}): ToolCallPart { + return { type: 'tool_call', id: 'ordering-call', name, args }; +} + +async function rejection(pending: Promise): Promise { + try { + await pending; + } catch (error) { + return error; + } + throw new Error('expected registry refusal'); +} + +function fixture( + parse: ToolDef['parseArgs'] = (raw) => raw, + configOnlyParams: readonly string[] = [], + includeProbe = true, +) { + const readFile = vi.fn(() => + Promise.resolve({ + content: 'clean probe output', + mimeType: 'text/plain', + sizeBytes: 18, + lastModified: 'fixed', + }), + ); + const host: ToolHost = { + fs: { + readFile, + writeFile: () => { + throw new Error('unexpected write'); + }, + listDirectory: () => { + throw new Error('unexpected directory read'); + }, + }, + }; + const parseArgs = vi.fn(parse); + const policyTarget = vi.fn>(() => ({ + path: 'public-probe.txt', + })); + const dispatch = vi.fn((_args, toolHost) => { + if (toolHost.fs === undefined) throw new Error('missing fixture filesystem'); + return toolHost.fs.readFile('public-probe.txt', {}); + }); + const beforeDispatch = vi.fn>(); + const port = unwiredEffectJournal(); + const prepare = vi.fn((...args) => port.prepare(...args)); + const settle = vi.fn((...args) => port.settle(...args)); + const discard = vi.fn((...args) => port.discard(...args)); + const probe: ToolDef = { + id: 'probe', + source: 'plugin', + description: 'Read public probe data.', + llmVisibleParams: { type: 'object' }, + policy: { fsScoped: true, spawnsProcess: false, requiresGateApproval: false }, + configOnlyParams, + parseArgs, + policyTarget, + dispatch, + }; + const context: ToolDispatchContext = { + nodeId: 'ordering-node', + grantedToolIds: new Set([...ids, 'probe']), + config: {}, + toolPolicy: {}, + fsScope: 'sandboxed', + gateApproved: false, + effects: { prepare, settle, discard }, + effectSlot: 0, + beforeDispatch, + }; + const registry = createToolRegistry({ + tools: [...(includeProbe ? [probe] : []), ...ids.map((id) => ({ ...probe, id }))], + host, + }); + function assertBeforeDispatch(): void { + for (const spy of [dispatch, readFile, beforeDispatch, prepare, settle, discard]) + expect(spy, 'identifier refusal precedes host admission and effects').not.toHaveBeenCalled(); + } + return { + registry, + context, + parseArgs, + policyTarget, + dispatch, + readFile, + beforeDispatch, + prepare, + settle, + discard, + assertBeforeDispatch, + }; +} + +function assertInvalid(error: unknown, fields: readonly string[], message: string): void { + expect(error).toBeInstanceOf(ToolArgsInvalidError); + if (!(error instanceof ToolArgsInvalidError)) throw new Error('expected typed invalid arguments'); + expect(error.code).toBe('invalid_args'); + expect(error.runErrorCode).toBe('validation'); + expect(error.retryable).toBe(false); + expect(error.fields, 'effective field identifiers use fixed ordinal order').toEqual(fields); + expect(error.message).toBe(message); + expect(error.message).not.toContain(canary); +} + +describe('real registry identifier consumers (#259)', () => { + it('lists actual plugin identifiers in fixed ordinal order with case-sensitive lookup', () => { + const { registry } = fixture((raw) => raw, [], false); + expect(registry.list(), 'registry list uses ordinal identifiers').toEqual(expected); + expect(registry.has('Z')).toBe(true); + expect(registry.has('z')).toBe(false); + }); + + it('actual unknown dispatch names available identifiers in fixed ordinal order', async () => { + const h = fixture((raw) => raw, [], false); + const error = await rejection(h.registry.dispatch(call('missing'), h.context)); + expect(error).toBeInstanceOf(UnknownToolError); + if (!(error instanceof UnknownToolError)) throw new Error('expected typed unknown tool'); + expect(error.code).toBe('unknown_tool'); + expect(error.runErrorCode).toBe('tool_failed'); + expect(error.message, 'actual unknown-tool consumer preserves ordinal available IDs').toBe( + 'unknown tool `missing` — available tools: Z, a, a-b, a_b', + ); + expect(h.parseArgs).not.toHaveBeenCalled(); + h.assertBeforeDispatch(); + }); + + it.each(['model', 'effective-mixed'] as const)( + 'effective secret fields are ordered before schema, targets, host or effects: %s', + async (source) => { + const h = fixture((raw) => raw, source === 'effective-mixed' ? ['Z'] : []); + const args = + source === 'model' + ? { a_b: canary, 'a-b': canary, a: canary, Z: canary } + : { a_b: canary, 'a-b': 'model-overridden', a: 'model-overridden', Z: 'model-dropped' }; + const context: ToolDispatchContext = { + ...h.context, + secretArgKeys: new Set(ids), + ...(source === 'model' + ? {} + : { + config: { + inputMapping: { 'a-b': canary, a: canary, Z: 'mapping-dropped' }, + parameters: { Z: canary }, + }, + }), + }; + const error = await rejection(h.registry.dispatch(call('probe', args), context)); + assertInvalid( + error, + expected, + 'tool `probe`: a secret-typed value cannot flow into tool arguments (Z, a, a-b, a_b) — use a credential reference (ADR-0029)', + ); + expect(h.parseArgs).not.toHaveBeenCalled(); + expect(h.policyTarget).not.toHaveBeenCalled(); + h.assertBeforeDispatch(); + }, + ); + + it('real Zod issue field paths use ordinal order and omit received argument values', async () => { + const schema = z.object({ a_b: z.string(), 'a-b': z.string(), a: z.string(), Z: z.string() }); + const h = fixture((raw) => schema.parse(raw)); + const error = await rejection( + h.registry.dispatch( + call('probe', { a_b: [canary], 'a-b': [canary], a: [canary], Z: [canary] }), + h.context, + ), + ); + assertInvalid(error, expected, 'tool `probe`: invalid arguments (Z, a, a-b, a_b)'); + expect(h.parseArgs).toHaveBeenCalledOnce(); + expect(h.policyTarget).not.toHaveBeenCalled(); + h.assertBeforeDispatch(); + }); + + it('real nested and array Zod paths retain dot joining and de-duplicate repeated issue paths', async () => { + const repeated = z.string().superRefine((_value, context) => { + context.addIssue({ code: 'custom', message: canary }); + context.addIssue({ code: 'custom', message: canary }); + }); + const schema = z.object({ + a_b: z.array(repeated), + 'a-b': z.object({ inner: z.string() }), + Z: z.object({ inner: z.string() }), + }); + const h = fixture((raw) => schema.parse(raw)); + const error = await rejection( + h.registry.dispatch(call('probe', { a_b: [canary], 'a-b': {}, Z: {} }), h.context), + ); + assertInvalid( + error, + ['Z.inner', 'a-b.inner', 'a_b.0'], + 'tool `probe`: invalid arguments (Z.inner, a-b.inner, a_b.0)', + ); + expect(h.parseArgs).toHaveBeenCalledOnce(); + h.assertBeforeDispatch(); + }); + + it('clean real dispatch reaches parser, target, admission and host with exact output', async () => { + const schema = z.object({ a_b: z.string(), 'a-b': z.string(), a: z.string(), Z: z.string() }); + const h = fixture((raw) => schema.parse(raw)); + const args = { a_b: 'four', 'a-b': 'three', a: 'two', Z: 'one' }; + const outcome = await h.registry.dispatch(call('probe', args), h.context); + expect(h.parseArgs).toHaveBeenCalledExactlyOnceWith(args); + expect(h.policyTarget).toHaveBeenCalledExactlyOnceWith(args); + expect(h.beforeDispatch).toHaveBeenCalledExactlyOnceWith('probe'); + expect(h.readFile).toHaveBeenCalledExactlyOnceWith('public-probe.txt', {}); + expect(h.dispatch).toHaveBeenCalledOnce(); + expect(outcome.output).toEqual({ + content: 'clean probe output', + mimeType: 'text/plain', + sizeBytes: 18, + lastModified: 'fixed', + }); + expect(unwrapUntrusted(outcome.toolResult)).toMatchObject({ + type: 'tool_result', + toolCallId: 'ordering-call', + result: outcome.output, + }); + expect(outcome.events.result).toMatchObject({ toolId: 'probe', success: true }); + for (const spy of [h.prepare, h.settle, h.discard]) expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/tools/registry.test.ts b/packages/core/src/tools/registry.test.ts index dcecea16..4671b574 100644 --- a/packages/core/src/tools/registry.test.ts +++ b/packages/core/src/tools/registry.test.ts @@ -190,7 +190,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((a, b) => a.localeCompare(b))); + expect(reg.list()).toEqual(BUILTIN_TOOL_IDS); expect(reg.list()).toHaveLength(13); }); @@ -386,18 +386,48 @@ describe('ToolRegistry — http_request egress policy', () => { 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 })), - ); + const spawn = vi.fn['spawn']>(() => + Promise.resolve({ exitCode: 0, stdout: '', stderr: '', durationMs: 0 }), + ); + const context = ctx({ gateApproved: false }); + const prepare = vi.fn(context.effects.prepare); + const reg = registry(stubHost({ process: { spawn } })); + const err: unknown = await reg + .dispatch(call('git_commit', { message: 'x' }), { + ...context, + effects: { ...context.effects, prepare }, + }) + .catch((error: unknown) => error); + expect(err).toBeInstanceOf(ToolPolicyError); + if (!(err instanceof ToolPolicyError)) throw new Error('missing gate refusal'); expect(err.reason).toBe('gate_required'); + expect(err.message).toMatch(/chat cannot provide.*review.*commit manually/i); + expect(prepare).not.toHaveBeenCalled(); + expect(spawn).not.toHaveBeenCalled(); }); it('allows git_commit once a gate approval is present', async () => { - const out = await registry().dispatch( - call('git_commit', { message: 'x' }), - ctx({ gateApproved: true }), + // Synthetic registry-level floor only: neither production context producer supplies true today. + const spawn = vi.fn['spawn']>(() => + Promise.resolve({ exitCode: 0, stdout: 'committed', stderr: '', durationMs: 1 }), + ); + const context = ctx({ gateApproved: true }); + const prepare = vi.fn(context.effects.prepare); + const settle = vi.fn(context.effects.settle); + const out = await registry(stubHost({ process: { spawn } })).dispatch( + call('git_commit', { message: 'x', files: ['src/a.ts'] }), + { ...context, effects: { ...context.effects, prepare, settle } }, ); expect(out.events.result.success).toBe(true); + expect(spawn).toHaveBeenCalledWith( + 'git', + ['commit', '-m', 'x', '--', 'src/a.ts'], + {}, + {}, + undefined, + ); + expect(prepare).toHaveBeenCalledTimes(1); + expect(settle).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 0263cc38..c88e1854 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -11,6 +11,7 @@ import { type DurableMediaPart, type EffectPrepareVerdict, type EffectTier, + byCodeUnit, extractHttpsHost, isEffectConflictError, type ToolActionClass, @@ -184,7 +185,7 @@ export function createToolRegistry(options: CreateToolRegistryOptions): { return { has: (id) => tools.has(id), - list: () => [...tools.keys()].sort((a, b) => a.localeCompare(b)), + list: () => [...tools.keys()].sort(byCodeUnit), dispatch: (toolCall, ctx) => dispatch(tools, host, toolCall, ctx), }; } @@ -688,7 +689,7 @@ 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)); + const sorted = tainted.toSorted(byCodeUnit); throw new ToolArgsInvalidError( toolId, sorted, @@ -728,7 +729,7 @@ function zodIssuePaths(cause: unknown): readonly string[] { } } } - return [...paths].sort((a, b) => a.localeCompare(b)); + return [...paths].sort(byCodeUnit); } /* ------------------------------------------------------------------------------------------------ * @@ -740,7 +741,7 @@ function enforcePolicy(def: ToolDef, target: PolicyTarget, ctx: ToolDispatchCont throw new ToolPolicyError( def.id, 'gate_required', - `tool \`${def.id}\` requires a human-gate approval in an automated workflow`, + `tool \`${def.id}\` requires a human-gate approval. Chat cannot provide that approval; review the changes and commit manually instead.`, ); } @@ -1120,12 +1121,16 @@ function boundArgsForScan(value: unknown, seen: WeakSet = new WeakSet boundArgsForScan(item, seen)); - const proto: unknown = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - const out: Record = {}; - for (const [key, item] of Object.entries(value)) out[key] = boundArgsForScan(item, seen); - return out; + try { + if (Array.isArray(value)) return value.map((item) => boundArgsForScan(item, seen)); + const proto: unknown = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) return value; + const out: Record = {}; + for (const [key, item] of Object.entries(value)) out[key] = boundArgsForScan(item, seen); + return out; + } finally { + seen.delete(value); + } } /** Narrow the walk's `unknown` result back to a record, without an unsafe cast. */ @@ -1145,19 +1150,23 @@ function dropSecretKeysDeep( if (typeof value !== 'object' || value === null) return value; if (seen.has(value)) return '[cyclic]'; seen.add(value); - if (Array.isArray(value)) { - return value.map((item) => dropSecretKeysDeep(item, secretArgKeys, seen)); - } - const proto: unknown = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) { - return value; // Date/RegExp/Map/… — left for the shape walk, exactly as it leaves them - } - const out: Record = {}; - for (const [key, item] of Object.entries(value)) { - if (secretArgKeys.has(key)) continue; - out[key] = dropSecretKeysDeep(item, secretArgKeys, seen); + try { + if (Array.isArray(value)) { + return value.map((item) => dropSecretKeysDeep(item, secretArgKeys, seen)); + } + const proto: unknown = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) { + return value; // Date/RegExp/Map/… — left for the shape walk, exactly as it leaves them + } + const out: Record = {}; + for (const [key, item] of Object.entries(value)) { + if (secretArgKeys.has(key)) continue; + out[key] = dropSecretKeysDeep(item, secretArgKeys, seen); + } + return out; + } finally { + seen.delete(value); } - return out; } /* ------------------------------------------------------------------------------------------------ * diff --git a/packages/core/src/tools/types.ts b/packages/core/src/tools/types.ts index 26210696..d52e50f8 100644 --- a/packages/core/src/tools/types.ts +++ b/packages/core/src/tools/types.ts @@ -381,7 +381,10 @@ export interface ToolDispatchContext { 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). */ + /** + * Describes the host's captured filesystem tier (built-in-tools.md), not a per-call grant. + * The host capability's immutable policy owns access; changing this metadata grants none. + */ readonly fsScope: FsScopeTier; /** A human-gate decision is present for this dispatch (1.Q) — required by `git_commit`. */ readonly gateApproved: boolean; From 6ee1556a4761603c7dbbb243d1cd73ff9f12f3c7 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 06:15:43 +0300 Subject: [PATCH 052/222] refactor(llm): name model lookup arguments as identifiers Clarify four lookup-local arguments without changing positional APIs, request fields, endpoint rules or cap behaviour. Refs: Wave 3 Step 5 #258; ADR-0101 Co-Authored-By: Codex --- packages/llm/src/adapters/shared.ts | 8 ++++---- packages/llm/src/output-cap.ts | 4 ++-- packages/llm/src/pricing.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/llm/src/adapters/shared.ts b/packages/llm/src/adapters/shared.ts index 96fba49c..9ffd557a 100644 --- a/packages/llm/src/adapters/shared.ts +++ b/packages/llm/src/adapters/shared.ts @@ -34,8 +34,8 @@ export function isAbortSignal(value: unknown): value is AbortSignal { * each adapter's `contextLimit`. `undefined` for a model the catalog does not carry (a custom base URL, or one * newer than the snapshot); the engine then skips auto-compaction rather than guess a window. */ -export function contextLimitFor(model: string): number | undefined { - return catalogModel(model)?.contextWindowTokens; +export function contextLimitFor(modelId: string): number | undefined { + return catalogModel(modelId)?.contextWindowTokens; } /** @@ -46,8 +46,8 @@ export function contextLimitFor(model: string): number | undefined { * `estimateTokens` on top (a same-key property AFTER the spread wins). */ export const CONTEXT_SEAM_DEFAULTS = { - contextLimit(model: string): number | undefined { - return contextLimitFor(model); + contextLimit(modelId: string): number | undefined { + return contextLimitFor(modelId); }, managesOwnContext(): boolean { return false; diff --git a/packages/llm/src/output-cap.ts b/packages/llm/src/output-cap.ts index 962553fb..706fdcaa 100644 --- a/packages/llm/src/output-cap.ts +++ b/packages/llm/src/output-cap.ts @@ -965,11 +965,11 @@ export function outputTokensReservation( */ export function cappedMaxTokens( requested: number | undefined, - model: string, + modelId: string, endpoint: EndpointKind = 'official', ): number | undefined { if (requested === undefined || endpoint === 'custom') return requested; - const ceiling = catalogModel(model)?.maxOutputTokens; + const ceiling = catalogModel(modelId)?.maxOutputTokens; if (ceiling === undefined) return requested; // not in the catalog — nothing to clamp against return Math.min(requested, ceiling); } diff --git a/packages/llm/src/pricing.ts b/packages/llm/src/pricing.ts index d25e7852..69478e94 100644 --- a/packages/llm/src/pricing.ts +++ b/packages/llm/src/pricing.ts @@ -111,6 +111,6 @@ export interface ModelPricing { * generated catalog said 1 050 000 — so the fullness percentage every user saw was computed against a window the * model does not have. */ -export function contextWindowForModel(model: string): number | undefined { - return catalogModel(model)?.contextWindowTokens; +export function contextWindowForModel(modelId: string): number | undefined { + return catalogModel(modelId)?.contextWindowTokens; } From 395b469bf4fb0e4be6a17fd0c9f63fdedff5b77e Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 06:15:43 +0300 Subject: [PATCH 053/222] fix(db): order session attribution identifiers by code unit Preserve money/coverage union and historical attribution while removing ambient collation. Verify real receipts, exact accounting and unchanged read snapshots. Refs: Wave 3 Step 5 #259; ADR-0104 Co-Authored-By: Codex --- .../session-usage-identifier-order.test.ts | 426 ++++++++++++++++++ packages/db/src/session-usage-read.ts | 9 +- 2 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 packages/db/src/session-usage-identifier-order.test.ts diff --git a/packages/db/src/session-usage-identifier-order.test.ts b/packages/db/src/session-usage-identifier-order.test.ts new file mode 100644 index 00000000..dd730afc --- /dev/null +++ b/packages/db/src/session-usage-identifier-order.test.ts @@ -0,0 +1,426 @@ +import { eq } from 'drizzle-orm'; +import { describe, expect, it, vi } from 'vitest'; + +import { createClient, runMigrations, type Db } from './client.js'; +import { + agentSessions, + sessionCosts, + sessionUsageCoverage, + sessionUsageInvocations, +} from './schema.js'; +import { + createSessionStore, + LEGACY_COST_SENTINEL, + type SessionCostEntry, +} from './session-store.js'; +import type { SessionUsageReceipt } from './session-usage-receipt.js'; +import { createSessionUsageStore } from './session-usage-store.js'; + +const SESSION_ID = 'identifier-order'; +const timestamp = '2026-10-16T00:00:00.000Z'; + +function fresh(db: Db) { + const usage = createSessionUsageStore(db); + usage.createFreshSession({ + id: SESSION_ID, + agentSlug: 'chatter', + context: { workingDir: '/work', fsScopeTier: 'sandboxed' }, + createdAt: timestamp, + }); + return { usage, costs: createSessionStore(db) }; +} + +function physicalSnapshot(db: Db) { + return { + owner: db.select().from(agentSessions).where(eq(agentSessions.id, SESSION_ID)).get(), + money: db + .select() + .from(sessionCosts) + .where(eq(sessionCosts.sessionId, SESSION_ID)) + .orderBy(sessionCosts.id) + .all(), + coverage: db + .select() + .from(sessionUsageCoverage) + .where(eq(sessionUsageCoverage.sessionId, SESSION_ID)) + .orderBy(sessionUsageCoverage.model) + .all(), + invocations: db + .select() + .from(sessionUsageInvocations) + .where(eq(sessionUsageInvocations.sessionId, SESSION_ID)) + .orderBy(sessionUsageInvocations.ordinal) + .all(), + }; +} + +function priced( + receipt: SessionUsageReceipt, + inputTokens: number, + outputTokens: number, + costMicrocents: number, +): SessionCostEntry { + return { + id: `actual-${receipt.model}`, + sessionId: receipt.sessionId, + model: receipt.model, + inputTokens, + outputTokens, + costMicrocents, + priced: true, + ts: 1, + usageReceipt: receipt, + }; +} + +describe('W3S5 #259 public session usage model identifier ordering', () => { + it.each([false, true])( + 'DB_MODEL_ORDER preserves real receipts, coverage and money with ambient comparator poisoned=%s', + (poisoned) => { + const client = createClient(':memory:'); + try { + runMigrations(client.db); + const { usage, costs } = fresh(client.db); + const begin = (model: string) => + usage.beginInvocation({ + sessionId: SESSION_ID, + model, + producerId: `producer-${model}`, + dispatchId: `dispatch-${model}`, + round: 0, + chainAttempt: 1, + }); + // Deliberately scrambled allocation, all through real receipt authority. + const underscore = begin('a_b'); + const hyphen = begin('a-b'); + const incomplete = begin('a'); + const pending = begin('Z'); + expect( + usage.disposition(incomplete, { + kind: 'incomplete', + evidence: { kind: 'missing', reason: 'not_reported' }, + }), + ).toBe(true); + expect(costs.recordSessionCost(priced(hyphen, 11, 13, 7))).toBe(true); + expect(costs.recordSessionCost(priced(underscore, 2, 3, 5))).toBe(true); + const before = physicalSnapshot(client.db); + expect(before.owner).toMatchObject({ + usageInvocationHighWater: 4, + usageUnverified: false, + usagePendingInvocations: 1, + usageUnknownCalls: 1, + totalCostMicrocents: 12, + totalConservativeMicrocents: 0, + }); + expect(before.owner?.usageAckRevision).toBe(before.owner?.usageMutationRevision); + expect(before.invocations).toMatchObject([ + { + model: 'a', + coverageState: 'incomplete', + producerId: 'producer-a', + dispatchId: 'dispatch-a', + ordinal: 3, + }, + { + model: 'Z', + coverageState: 'pending', + producerId: 'producer-Z', + dispatchId: 'dispatch-Z', + ordinal: 4, + }, + ]); + const receiptFields = [underscore, hyphen, incomplete, pending].map((receipt) => ({ + sessionId: receipt.sessionId, + model: receipt.model, + invocation: { ...receipt.invocation }, + })); + const readPair = () => ({ + coverage: usage.loadCoverage(SESSION_ID), + breakdown: costs.loadSessionCostBreakdown(SESSION_ID), + }); + const transaction = vi.spyOn(client.db, 'transaction'); + const ambient = poisoned + ? vi.spyOn(String.prototype, 'localeCompare').mockImplementation(() => { + throw new Error('DB_AMBIENT_COLLATION_FORBIDDEN'); + }) + : undefined; + let reads: ReturnType[]; + try { + reads = [readPair(), readPair()]; + } finally { + ambient?.mockRestore(); + } + expect(transaction, 'DB_PUBLIC_SINGLE_SNAPSHOTS').toHaveBeenCalledTimes(4); + if (ambient !== undefined) + expect(ambient, 'DB_NO_AMBIENT_COMPARATOR').not.toHaveBeenCalled(); + const expectedModels = [ + { model: 'Z', coverage: { unverified: false, pendingInvocations: 1, unknownCalls: 0 } }, + { model: 'a', coverage: { unverified: false, pendingInvocations: 0, unknownCalls: 1 } }, + { model: 'a-b', coverage: { unverified: false, pendingInvocations: 0, unknownCalls: 0 } }, + { model: 'a_b', coverage: { unverified: false, pendingInvocations: 0, unknownCalls: 0 } }, + ]; + const expectedCoverage = { unverified: false, pendingInvocations: 1, unknownCalls: 1 }; + for (const read of reads) { + expect( + read.coverage?.models.map(({ model }) => model), + 'DB_MODEL_ORDER_COVERAGE', + ).toEqual(['Z', 'a', 'a-b', 'a_b']); + expect( + read.breakdown.models.map(({ model }) => model), + 'DB_MODEL_ORDER_BREAKDOWN', + ).toEqual(['Z', 'a', 'a-b', 'a_b']); + expect(read.coverage).toEqual({ coverage: expectedCoverage, models: expectedModels }); + expect(read.breakdown).toEqual({ + totalCostMicrocents: 12, + coverage: expectedCoverage, + models: expectedModels, + rows: [ + { + model: 'a-b', + modelCatalogId: undefined, + inputTokens: 11, + outputTokens: 13, + costMicrocents: 7, + conservativeMicrocents: 0, + callCount: 1, + unpricedCalls: 0, + isLegacy: false, + }, + { + model: 'a_b', + modelCatalogId: undefined, + inputTokens: 2, + outputTokens: 3, + costMicrocents: 5, + conservativeMicrocents: 0, + callCount: 1, + unpricedCalls: 0, + isLegacy: false, + }, + ], + }); + } + expect(physicalSnapshot(client.db), 'DB_READS_DO_NOT_MUTATE').toEqual(before); + expect([underscore, hyphen, incomplete, pending]).toEqual(receiptFields); + for (const receipt of [underscore, hyphen, incomplete, pending]) { + expect(Object.isFrozen(receipt)).toBe(true); + expect(Object.isFrozen(receipt.invocation)).toBe(true); + } + // Reads neither retire a live capability nor revive a consumed one. + expect( + costs.recordSessionCost(priced(hyphen, 11, 13, 7)), + 'DB_COMPLETED_RECEIPT_STAYS_CONSUMED', + ).toBe(false); + expect( + costs.recordSessionCost(priced(incomplete, 17, 19, 23)), + 'DB_INCOMPLETE_RECEIPT_STAYS_LIVE', + ).toBe(true); + expect( + usage.disposition(pending, { kind: 'not_invoked', reason: 'cancelled' }), + 'DB_PENDING_RECEIPT_STAYS_LIVE', + ).toBe(true); + } finally { + vi.restoreAllMocks(); + client.sqlite.close(); + } + }, + ); + + it('DB_MONEY_MODEL_UNION keeps historical long names raw and excludes only the legacy aggregate identity', () => { + const client = createClient(':memory:'); + try { + runMigrations(client.db); + const { usage, costs } = fresh(client.db); + const longModel = `custom-${'m'.repeat(4090)}`; // 4097 units: valid historical money, beyond new invocation bound. + const pending = usage.beginInvocation({ + sessionId: SESSION_ID, + model: 'Z', + producerId: 'producer', + dispatchId: 'dispatch', + round: 0, + chainAttempt: 1, + }); + expect(pending.model).toBe('Z'); + for (const [id, model, charge] of [ + ['long-money', longModel, 17], + ['money-only', 'a_b', 11], + ['sentinel-real', LEGACY_COST_SENTINEL, 5], + ] as const) { + expect( + costs.recordSessionCost({ + id, + sessionId: SESSION_ID, + model, + inputTokens: 2, + outputTokens: 3, + costMicrocents: charge, + priced: true, + ts: 1, + }), + ).toBe(true); + } + // Historical aggregate fixture, using the existing table's flag rather than + // reserving a string that a real provider model is allowed to use. + client.db + .insert(sessionCosts) + .values({ + id: 'legacy-aggregate', + sessionId: SESSION_ID, + model: LEGACY_COST_SENTINEL, + inputTokens: 0, + outputTokens: 0, + costMicrocents: 31, + callCount: 0, + unpricedCalls: 0, + isLegacy: true, + createdAt: 0, + updatedAt: 0, + }) + .run(); + client.db + .update(agentSessions) + .set({ totalCostMicrocents: 64 }) + .where(eq(agentSessions.id, SESSION_ID)) + .run(); + const before = physicalSnapshot(client.db); + const expectedCoverage = { unverified: true, pendingInvocations: 1, unknownCalls: 0 }; + const cleanCoverage = { unverified: true, pendingInvocations: 0, unknownCalls: 0 }; + const expectedModels = [ + { model: LEGACY_COST_SENTINEL, coverage: cleanCoverage }, + { model: 'Z', coverage: expectedCoverage }, + { model: 'a_b', coverage: cleanCoverage }, + { model: longModel, coverage: cleanCoverage }, + ]; + const expectedRows = [ + { + model: LEGACY_COST_SENTINEL, + modelCatalogId: undefined, + inputTokens: 0, + outputTokens: 0, + costMicrocents: 31, + conservativeMicrocents: 0, + callCount: 0, + unpricedCalls: 0, + isLegacy: true, + }, + { + model: longModel, + modelCatalogId: undefined, + inputTokens: 2, + outputTokens: 3, + costMicrocents: 17, + conservativeMicrocents: 0, + callCount: 1, + unpricedCalls: 0, + isLegacy: false, + }, + { + model: 'a_b', + modelCatalogId: undefined, + inputTokens: 2, + outputTokens: 3, + costMicrocents: 11, + conservativeMicrocents: 0, + callCount: 1, + unpricedCalls: 0, + isLegacy: false, + }, + { + model: LEGACY_COST_SENTINEL, + modelCatalogId: undefined, + inputTokens: 2, + outputTokens: 3, + costMicrocents: 5, + conservativeMicrocents: 0, + callCount: 1, + unpricedCalls: 0, + isLegacy: false, + }, + ]; + for (let read = 0; read < 2; read++) { + const coverage = usage.loadCoverage(SESSION_ID); + const breakdown = costs.loadSessionCostBreakdown(SESSION_ID); + expect( + coverage?.models.map(({ model }) => model), + 'DB_HISTORICAL_UNION_ORDER', + ).toEqual([LEGACY_COST_SENTINEL, 'Z', 'a_b', longModel]); + expect(coverage).toEqual({ coverage: expectedCoverage, models: expectedModels }); + expect(breakdown).toEqual({ + totalCostMicrocents: 64, + rows: expectedRows, + coverage: expectedCoverage, + models: expectedModels, + }); + expect( + breakdown.rows.find((row) => row.model === longModel)?.model.length, + 'DB_HISTORICAL_MODEL_NOT_TRUNCATED', + ).toBe(4097); + } + expect(physicalSnapshot(client.db), 'DB_HISTORICAL_READS_DO_NOT_MUTATE').toEqual(before); + expect(before.owner?.usageMutationRevision).toBeGreaterThan( + before.owner?.usageAckRevision ?? 0, + ); + expect(before.owner?.usageUnverified).toBe(true); + } finally { + client.sqlite.close(); + } + }); + + it('DB_LEGACY_ONLY does not invent a model identity for an unattributed aggregate', () => { + const client = createClient(':memory:'); + try { + runMigrations(client.db); + const { usage, costs } = fresh(client.db); + client.db + .insert(sessionCosts) + .values({ + id: 'legacy-only', + sessionId: SESSION_ID, + model: LEGACY_COST_SENTINEL, + inputTokens: 0, + outputTokens: 0, + costMicrocents: 31, + callCount: 0, + unpricedCalls: 0, + isLegacy: true, + createdAt: 0, + updatedAt: 0, + }) + .run(); + client.db + .update(agentSessions) + .set({ totalCostMicrocents: 31 }) + .where(eq(agentSessions.id, SESSION_ID)) + .run(); + const before = physicalSnapshot(client.db); + const expectedCoverage = { unverified: true, pendingInvocations: 0, unknownCalls: 0 }; + for (let read = 0; read < 2; read++) { + expect(usage.loadCoverage(SESSION_ID), 'DB_LEGACY_ONLY_COVERAGE').toEqual({ + coverage: expectedCoverage, + models: [], + }); + expect(costs.loadSessionCostBreakdown(SESSION_ID), 'DB_LEGACY_ONLY_BREAKDOWN').toEqual({ + totalCostMicrocents: 31, + coverage: expectedCoverage, + models: [], + rows: [ + { + model: LEGACY_COST_SENTINEL, + modelCatalogId: undefined, + inputTokens: 0, + outputTokens: 0, + costMicrocents: 31, + conservativeMicrocents: 0, + callCount: 0, + unpricedCalls: 0, + isLegacy: true, + }, + ], + }); + } + expect(physicalSnapshot(client.db), 'DB_LEGACY_ONLY_READS_DO_NOT_MUTATE').toEqual(before); + } finally { + client.sqlite.close(); + } + }); +}); diff --git a/packages/db/src/session-usage-read.ts b/packages/db/src/session-usage-read.ts index 8550b060..9239e832 100644 --- a/packages/db/src/session-usage-read.ts +++ b/packages/db/src/session-usage-read.ts @@ -1,4 +1,9 @@ -import { UsageCoverageSchema, UsageModelSchema, type UsageCoverage } from '@relavium/shared'; +import { + byCodeUnit, + UsageCoverageSchema, + UsageModelSchema, + type UsageCoverage, +} from '@relavium/shared'; import { and, asc, eq } from 'drizzle-orm'; import type { TxDb } from './client.js'; @@ -64,7 +69,7 @@ export function readSessionCoverage(tx: TxDb, sessionId: string): SessionUsageSn return { coverage, models: [...models] - .sort(([a], [b]) => a.localeCompare(b)) + .sort(([a], [b]) => byCodeUnit(a, b)) .map(([model, modelCoverage]) => ({ model, coverage: modelCoverage })), }; } From 08be6af3b047e622e267a42641cf9cf75a28aa1f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 06:15:43 +0300 Subject: [PATCH 054/222] fix(cli): make catalog and runtime identity ordering deterministic Use the existing ordinal helper across refresh results, MCP dedup sets, catalog slugs, tag groups and folded mentions. Pin actual consumers and physical tool-journal selection digests. Refs: Wave 3 Step 5 #94/#259; ADR-0084 Co-Authored-By: Codex --- .../commands/list-identifier-order.test.ts | 68 +++++ apps/cli/src/commands/list.ts | 3 +- .../mcp-server-identifier-order.test.ts | 209 ++++++++++++++ apps/cli/src/engine/mcp-servers.ts | 7 +- .../model-refresh-identifier-order.test.ts | 171 ++++++++++++ apps/cli/src/engine/model-refresh.ts | 3 +- .../engine/tool-alias-journal-digest.test.ts | 260 ++++++++++++++++++ .../tui/mention-identifier-order.test.ts | 156 +++++++++++ apps/cli/src/render/tui/mention.ts | 3 +- .../catalog-identifier-order.test.ts | 58 ++++ apps/cli/src/workflows/catalog.ts | 3 +- 11 files changed, 933 insertions(+), 8 deletions(-) create mode 100644 apps/cli/src/commands/list-identifier-order.test.ts create mode 100644 apps/cli/src/engine/mcp-server-identifier-order.test.ts create mode 100644 apps/cli/src/engine/model-refresh-identifier-order.test.ts create mode 100644 apps/cli/src/engine/tool-alias-journal-digest.test.ts create mode 100644 apps/cli/src/render/tui/mention-identifier-order.test.ts create mode 100644 apps/cli/src/workflows/catalog-identifier-order.test.ts diff --git a/apps/cli/src/commands/list-identifier-order.test.ts b/apps/cli/src/commands/list-identifier-order.test.ts new file mode 100644 index 00000000..0ddcda71 --- /dev/null +++ b/apps/cli/src/commands/list-identifier-order.test.ts @@ -0,0 +1,68 @@ +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createClient, runMigrations } from '@relavium/db'; +import { describe, expect, it } from 'vitest'; + +import { captureIo, seedRun } from '../test-support.js'; +import type { CatalogEntry } from '../workflows/catalog.js'; +import { listCommand, type ListCommandDeps } from './list.js'; + +describe('actual list tag identifier order', () => { + it('[LIST-TAG-IDENTIFIERS] keeps exact ordinal headers, every bucket membership and untagged last', async () => { + const project = mkdtempSync(join(tmpdir(), 'relavium-list-order-')); + mkdirSync(join(project, '.relavium')); + const client = createClient(':memory:'); + runMigrations(client.db); + const { io, out, err } = captureIo(); + const catalog: CatalogEntry[] = [ + { + slug: 'untagged', + name: 'Untagged fixture', + tags: [], + path: '.relavium/workflows/untagged.yaml', + valid: true, + }, + { + slug: 'multi-tag', + name: 'Multi tag fixture', + tags: ['a_b', 'a-b', 'a', 'Z'], + path: '.relavium/workflows/multi-tag.yaml', + valid: true, + }, + ]; + const deps: ListCommandDeps = { + io, + global: { + json: false, + color: false, + cwd: project, + configPath: undefined, + verbosity: 'normal', + }, + openDb: () => ({ db: client.db, close: () => {} }), + discoverCatalog: () => catalog, + }; + try { + await seedRun(client.db, { slug: 'multi-tag', runId: 'r-order', state: 'completed' }); + expect(listCommand({ agents: false }, deps)).toBe(0); + const lines = out().split('\n'); + expect( + lines.filter((line) => /^ {2}(?:#|\(untagged\))/.test(line)), + '[LIST-TAG-HEADERS] complete header lines avoid #a prefix collisions', + ).toEqual([' #Z', ' #a', ' #a-b', ' #a_b', ' (untagged)']); + const tagged = lines.filter((line) => line.startsWith(' multi-tag ')); + expect(tagged).toHaveLength(4); + expect(tagged.every((line) => line.includes('[last: completed]'))).toBe(true); + const untagged = lines.filter((line) => line.startsWith(' untagged ')); + expect(untagged).toHaveLength(1); + expect(untagged[0]).toContain('[last: —]'); + expect(lines.filter((line) => line.startsWith(' ')).at(-1)).toBe(untagged[0]); + expect(err()).toBe(''); + } finally { + client.sqlite.close(); + rmSync(project, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts index 40853137..5064c1f8 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -1,4 +1,5 @@ import type { Db, RunRecord } from '@relavium/db'; +import { byCodeUnit } from '@relavium/shared'; import { loadResolvedConfig } from '../config/load.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; @@ -148,7 +149,7 @@ function renderHuman( // Untagged sinks to the bottom; named tags sort alphabetically. if (a === UNTAGGED) return 1; if (b === UNTAGGED) return -1; - return a.localeCompare(b); + return byCodeUnit(a, b); }); for (const tag of tags) { const header = tag === UNTAGGED ? UNTAGGED : `#${tag}`; diff --git a/apps/cli/src/engine/mcp-server-identifier-order.test.ts b/apps/cli/src/engine/mcp-server-identifier-order.test.ts new file mode 100644 index 00000000..b72b1550 --- /dev/null +++ b/apps/cli/src/engine/mcp-server-identifier-order.test.ts @@ -0,0 +1,209 @@ +import { parseWorkflow, type WorkflowDefinition } from '@relavium/core'; +import type { McpClient } from '@relavium/mcp'; +import type { McpServerRef } from '@relavium/shared'; +import { describe, expect, it, vi } from 'vitest'; + +import { isCliError } from '../process/errors.js'; +import { connectWorkflowMcp, type ConnectWorkflowMcpOptions } from './mcp-servers.js'; + +const VALUE_CANARY = 'synthetic-private-env-value'; +const REF_CANARY = '{{secrets.synthetic_private_ref}}'; + +function workflow(left: McpServerRef, right: McpServerRef): WorkflowDefinition { + return parseWorkflow( + `schema_version: '1.0'\nworkflow:\n id: ordering\n agents:\n - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [${JSON.stringify(left)}] }\n - { id: writer, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [${JSON.stringify(right)}] }\n nodes:\n - { id: s, type: input }\n - { id: a, type: agent, agent_ref: scanner, prompt_template: go }\n - { id: o, type: output }\n edges:\n - { from: s, to: a }\n - { from: a, to: o }\n`, + ); +} + +function server(overrides: Partial = {}): McpServerRef { + return { + id: 'fs', + transport: 'stdio', + command: 'fixture-server', + args: ['first', 'second'], + ...overrides, + }; +} + +function fixture() { + const close = vi.fn(() => Promise.resolve()); + const client: McpClient = { + capability: { call: () => Promise.resolve({ content: [], isError: false }) }, + toolDefs: [], + toolIdsByServer: new Map([['fs', ['mcp_fs_read']]]), + childPids: [], + skipped: [], + close, + }; + const consentGate = vi.fn>(() => + Promise.resolve(new Map()), + ); + const startMcpClient = vi.fn>(() => + Promise.resolve(client), + ); + return { + close, + consentGate, + startMcpClient, + options: { cwd: '/w', consentGate, startMcpClient } satisfies ConnectWorkflowMcpOptions, + }; +} + +function expectGrants(def: WorkflowDefinition): void { + for (const id of ['scanner', 'writer']) { + const agent = def.workflow.agents?.find((entry) => 'id' in entry && entry.id === id); + expect(agent, '[MCP-GRANT-POSITIVE] actual augmented inline agent is present').toBeDefined(); + if (agent !== undefined && 'id' in agent) expect(agent.tools).toEqual(['mcp_fs_read']); + } +} + +describe('actual workflow MCP private dedup identifier ordering', () => { + it('[MCP-SET-IDENTITY] same env and allowlist sets in different insertion orders share one real runtime', async () => { + const left = server({ + env: { Z_KEY: 'z', a_key: 'a', A_KEY: 'A' }, + tools_allowlist: ['a_b', 'a-b', 'a', 'Z'], + }); + const right = server({ + env: { A_KEY: 'A', Z_KEY: 'z', a_key: 'a' }, + tools_allowlist: ['Z', 'a', 'a-b', 'a_b'], + }); + const def = workflow(left, right); + const h = fixture(); + const runtime = await connectWorkflowMcp(def, h.options); + expect(runtime).toBeDefined(); + try { + expect(h.startMcpClient).toHaveBeenCalledOnce(); + expect(h.startMcpClient.mock.calls[0]?.[0]).toHaveLength(1); + expect(h.startMcpClient.mock.calls[0]?.[0][0]?.toolsAllowlist).toEqual([ + 'a_b', + 'a-b', + 'a', + 'Z', + ]); + expect(h.consentGate).toHaveBeenCalledOnce(); + expect(h.consentGate.mock.calls[0]?.[0]).toHaveLength(1); + if (runtime !== undefined) expectGrants(runtime.workflow); + } finally { + await runtime?.client.close(); + } + expect(h.close).toHaveBeenCalledOnce(); + }); + + for (const axis of ['env', 'allowlist']) { + it(`[MCP-NO-AMBIENT-${axis.toUpperCase()}] actual dedup shares without ambient collation on this arm`, async () => { + const left = + axis === 'env' + ? server({ env: { Z_KEY: 'z', a_key: 'a', A_KEY: 'A' }, tools_allowlist: ['read'] }) + : server({ env: {}, tools_allowlist: ['a_b', 'a-b', 'a', 'Z'] }); + const right = + axis === 'env' + ? server({ env: { A_KEY: 'A', Z_KEY: 'z', a_key: 'a' }, tools_allowlist: ['read'] }) + : server({ env: {}, tools_allowlist: ['Z', 'a', 'a-b', 'a_b'] }); + // Parse before observing the comparator: this isolates the production dedup join, not unrelated schema work. + const def = workflow(left, right); + const h = fixture(); + const collation = vi.spyOn(String.prototype, 'localeCompare'); + try { + const runtime = await connectWorkflowMcp(def, h.options); + expect( + runtime, + '[MCP-POSITIVE] sharing must reach a defined runtime, not an expected refusal', + ).toBeDefined(); + expect(h.startMcpClient).toHaveBeenCalledOnce(); + expect(h.startMcpClient.mock.calls[0]?.[0]).toHaveLength(1); + expect(h.consentGate).toHaveBeenCalledOnce(); + expect( + collation, + '[MCP-NO-AMBIENT] this independent fingerprint arm never invokes localeCompare', + ).not.toHaveBeenCalled(); + if (runtime !== undefined) { + expectGrants(runtime.workflow); + await runtime.client.close(); + } + } finally { + collation.mockRestore(); + } + }); + } + + const CONFLICTS: ReadonlyArray<{ label: string; left: McpServerRef; right: McpServerRef }> = [ + { + label: 'env-value', + left: server({ env: { A_KEY: VALUE_CANARY } }), + right: server({ env: { A_KEY: REF_CANARY } }), + }, + { + label: 'allowlist-member', + left: server({ tools_allowlist: ['Z', 'a'] }), + right: server({ tools_allowlist: ['Z', 'a_b'] }), + }, + { label: 'args-order', left: server(), right: server({ args: ['second', 'first'] }) }, + ]; + for (const conflict of CONFLICTS) { + it(`[MCP-CONFLICT-${conflict.label.toUpperCase()}] real changed identity refuses before consent/start without values`, async () => { + const def = workflow(conflict.left, conflict.right); + const h = fixture(); + let rejection: unknown; + try { + await connectWorkflowMcp(def, h.options); + } catch (error) { + rejection = error; + } + expect(isCliError(rejection)).toBe(true); + if (isCliError(rejection)) { + expect(rejection.code).toBe('invalid_invocation'); + expect(rejection.message).toMatch(/fs.*conflicting settings|conflicting settings.*fs/); + expect(rejection.message).not.toContain(VALUE_CANARY); + expect(rejection.message).not.toContain('synthetic_private_ref'); + } + expect( + h.consentGate, + '[MCP-CONFLICT-CUT] conflict precedes local-spawn consent', + ).not.toHaveBeenCalled(); + expect( + h.startMcpClient, + '[MCP-CONFLICT-CUT] conflict starts no transport', + ).not.toHaveBeenCalled(); + }); + } + + it('[MCP-EMPTY-PARSE-REFUSAL] authored empty allowlist is refused by the actual workflow parser', () => { + expect(() => workflow(server(), server({ tools_allowlist: [] }))).toThrow( + /writer.*tools_allowlist.*must name at least one tool/, + ); + // Omission remains a real parser positive; it is the authored spelling for admitting all tools. + expect(workflow(server(), server()).workflow.agents).toHaveLength(2); + }); + + it('[MCP-ALL-VERSUS-NONE-API] typed API-only empty sentinel conflicts with omitted allowlist before consent', async () => { + const parsed = workflow(server(), server()); + // The public connect API can receive a typed definition directly. Empty arrays are deliberately + // constructed here to preserve its private null-versus-[] fingerprint distinction; the separate + // parser control above proves this does not claim an authored workflow can admit an empty list. + const def: WorkflowDefinition = { + ...parsed, + workflow: { + ...parsed.workflow, + agents: (parsed.workflow.agents ?? []).map((entry) => + 'id' in entry && entry.id === 'writer' + ? { ...entry, mcp_servers: [server({ tools_allowlist: [] })] } + : entry, + ), + }, + }; + const h = fixture(); + let rejection: unknown; + try { + await connectWorkflowMcp(def, h.options); + } catch (error) { + rejection = error; + } + expect(isCliError(rejection)).toBe(true); + if (isCliError(rejection)) { + expect(rejection.code).toBe('invalid_invocation'); + expect(rejection.message).toMatch(/fs.*conflicting settings/); + } + expect(h.consentGate).not.toHaveBeenCalled(); + expect(h.startMcpClient).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index 58d2e4c4..3509d81a 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -24,6 +24,7 @@ import { } from '@relavium/mcp'; import { nodeEgressDeps, type LocalEndpoint } from '@relavium/db'; import { + byCodeUnit, extractEgressAuthority, isForbiddenDeclaredEnvKey, isMetadataOrLinkLocal, @@ -961,11 +962,9 @@ function withWorkflowMcpGrant( * collapse first-wins, silently granting (or denying) BOTH the SSRF relaxation — so it must fail loud. */ function serverFingerprint(ref: McpServerRef): string { - const env = Object.entries(ref.env ?? {}).sort(([a], [b]) => a.localeCompare(b)); + const env = Object.entries(ref.env ?? {}).sort(([a], [b]) => byCodeUnit(a, b)); const allowlist = - ref.tools_allowlist === undefined - ? null - : [...ref.tools_allowlist].sort((a, b) => a.localeCompare(b)); + ref.tools_allowlist === undefined ? null : [...ref.tools_allowlist].sort(byCodeUnit); return JSON.stringify({ t: ref.transport, c: ref.command ?? null, diff --git a/apps/cli/src/engine/model-refresh-identifier-order.test.ts b/apps/cli/src/engine/model-refresh-identifier-order.test.ts new file mode 100644 index 00000000..69d9f739 --- /dev/null +++ b/apps/cli/src/engine/model-refresh-identifier-order.test.ts @@ -0,0 +1,171 @@ +import { randomUUID } from 'node:crypto'; + +import { + createClient, + createModelCatalogStore, + createProviderStore, + runMigrations, +} from '@relavium/db'; +import type { LlmProvider, ProviderId } from '@relavium/llm'; +import { describe, expect, it, vi } from 'vitest'; + +import { modelsCommand, type ModelsCommandDeps } from '../commands/models.js'; +import { captureIo, CHAT_TEXT_CAPABILITY_FLAGS, parseNdjson } from '../test-support.js'; +import { createModelRefreshService } from './model-refresh.js'; +import { KNOWN_PROVIDERS } from './providers.js'; + +const KEY = 'synthetic-refresh-key-private'; + +function provider(id: ProviderId, canList: boolean): LlmProvider { + return { + id, + generate: () => Promise.reject(new Error('generate must not run during model discovery')), + stream: () => { + throw new Error('stream must not run during model discovery'); + }, + supports: CHAT_TEXT_CAPABILITY_FLAGS, + ...(canList + ? { + listModels: () => + Promise.resolve([{ id: `${id}-fixture`, displayName: `${id} fixture` }]), + } + : {}), + }; +} + +function fixture() { + const client = createClient(':memory:'); + runMigrations(client.db); + const stores = { uuid: () => randomUUID(), now: () => 1_700_000_000_000 }; + const adapters = { + anthropic: provider('anthropic', true), + deepseek: provider('deepseek', false), + gemini: provider('gemini', true), + openai: provider('openai', true), + } satisfies Record; + const svc = createModelRefreshService({ + resolveProvider: (id) => adapters[id], + keyFor: (id) => { + if (id === 'anthropic') throw new Error('synthetic key is absent'); + return KEY; + }, + providerStore: createProviderStore(client.db, stores), + catalogStore: createModelCatalogStore(client.db, stores), + knownProviderIds: ['openai', 'gemini', 'anthropic', 'deepseek'], + knownProviders: KNOWN_PROVIDERS, + now: stores.now, + }); + return { client, svc }; +} + +describe('provider identifiers through refresh and actual models JSON', () => { + it('[REFRESH-IDENTIFIERS] orders grouped/skipped and refreshed valid ProviderIds without ambient collation', async () => { + const { client, svc } = fixture(); + const collation = vi.spyOn(String.prototype, 'localeCompare'); + try { + const report = await svc.refresh(); + expect(report.providers).toEqual([ + { provider: 'anthropic', status: 'skipped-no-key' }, + { provider: 'deepseek', status: 'skipped-unsupported' }, + { provider: 'gemini', status: 'refreshed', added: 1, updated: 0, deactivated: 0 }, + { provider: 'openai', status: 'refreshed', added: 1, updated: 0, deactivated: 0 }, + ]); + expect( + collation, + '[REFRESH-NO-AMBIENT] actual refresh uses ordinal identifier ordering', + ).not.toHaveBeenCalled(); + expect(JSON.stringify(report)).not.toContain(KEY); + } finally { + collation.mockRestore(); + client.sqlite.close(); + } + }); + + it('[REFRESH-JSON-JOIN] actual service emits four sorted key-free provider records through modelsCommand', async () => { + const { client, svc } = fixture(); + const { io, out, err } = captureIo(); + const refreshCatalog = vi.fn(() => + Promise.reject(new Error('catalog egress must not run on the providers axis')), + ); + const deps: ModelsCommandDeps = { + io, + global: { + json: true, + color: false, + cwd: '/fixture', + configPath: undefined, + verbosity: 'normal', + }, + catalog: { listAll: () => [] }, + refreshService: svc, + refreshCatalog, + providerSlug: (id) => id, + }; + const collation = vi.spyOn(String.prototype, 'localeCompare'); + try { + expect(await modelsCommand({ refresh: true, axis: 'providers' }, deps)).toBe(0); + const records = parseNdjson<{ + provider: string; + status: string; + added: number | null; + updated: number | null; + deactivated: number | null; + error: string | null; + }>(out()); + expect( + records.map(({ provider: id }) => id), + '[REFRESH-JSON-ORDER] real NDJSON preserves refresh ordering', + ).toEqual(['anthropic', 'deepseek', 'gemini', 'openai']); + // Provider records retain the existing wire shape (no source field); the catalog axis would + // instead emit a separate source:'catalog' record. Pin actual command output, not a fake DTO. + expect(records).toEqual([ + { + provider: 'anthropic', + status: 'skipped-no-key', + added: null, + updated: null, + deactivated: null, + error: null, + }, + { + provider: 'deepseek', + status: 'skipped-unsupported', + added: null, + updated: null, + deactivated: null, + error: null, + }, + { + provider: 'gemini', + status: 'refreshed', + added: 1, + updated: 0, + deactivated: 0, + error: null, + }, + { + provider: 'openai', + status: 'refreshed', + added: 1, + updated: 0, + deactivated: 0, + error: null, + }, + ]); + expect(records.map(({ status }) => status)).toEqual([ + 'skipped-no-key', + 'skipped-unsupported', + 'refreshed', + 'refreshed', + ]); + expect(records.slice(2).map(({ added }) => added)).toEqual([1, 1]); + expect(refreshCatalog).not.toHaveBeenCalled(); + expect(collation).not.toHaveBeenCalled(); + expect(err()).toBe(''); + expect(out()).not.toContain(KEY); + } finally { + collation.mockRestore(); + client.sqlite.close(); + } + }); +}); diff --git a/apps/cli/src/engine/model-refresh.ts b/apps/cli/src/engine/model-refresh.ts index cfba3a27..6363c24b 100644 --- a/apps/cli/src/engine/model-refresh.ts +++ b/apps/cli/src/engine/model-refresh.ts @@ -1,4 +1,5 @@ import type { ModelCatalogLiveModel, ReplaceProviderModelsResult } from '@relavium/db'; +import { byCodeUnit } from '@relavium/shared'; import type { AbortSignalLike, LlmProvider, ModelListing, ProviderId } from '@relavium/llm'; import { keyHint } from './providers.js'; @@ -260,7 +261,7 @@ export function createModelRefreshService(deps: ModelRefreshDeps): ModelRefreshS }); // Deterministic order regardless of settle timing (a stable report for the human table + the --json stream). - results.sort((a, b) => a.provider.localeCompare(b.provider)); + results.sort((a, b) => byCodeUnit(a.provider, b.provider)); return { providers: results }; }; diff --git a/apps/cli/src/engine/tool-alias-journal-digest.test.ts b/apps/cli/src/engine/tool-alias-journal-digest.test.ts new file mode 100644 index 00000000..095db6de --- /dev/null +++ b/apps/cli/src/engine/tool-alias-journal-digest.test.ts @@ -0,0 +1,260 @@ +import { createHash } from 'node:crypto'; + +import { createToolRegistry, type ToolDef, type ToolDispatchContext } from '@relavium/core'; +import { + createClient, + createEffectJournalPort, + createEffectJournalStore, + runMigrations, +} from '@relavium/db'; +import type { EffectDispatchPort } from '@relavium/shared'; +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +function selection(which: 'alice' | 'bob') { + const alice = { label: 'alice' }; + const bob = { label: 'bob' }; + return { catalog: [alice, bob], selected: which === 'alice' ? alice : bob }; +} + +// Independently spelled canonical bytes: sorted object keys, ordered arrays, no whitespace. +// Do not use the production canonicalJson helper to derive the expected digest. +const EXPECTED_CANONICAL = { + alice: '{"payload":{"catalog":[{"label":"alice"},{"label":"bob"}],"selected":{"label":"alice"}}}', + bob: '{"payload":{"catalog":[{"label":"alice"},{"label":"bob"}],"selected":{"label":"bob"}}}', +}; + +function sha256(text: string): string { + return createHash('sha256').update(text).digest('hex'); +} + +function registryFixture(effects: EffectDispatchPort) { + const received: unknown[] = []; + const tool: ToolDef = { + id: 'alias_publish', + source: 'mcp', + description: 'Offline registry and native journal alias fixture', + llmVisibleParams: { type: 'object' }, + parseArgs: (raw) => z.object({ payload: z.unknown() }).strict().parse(raw), + policy: { fsScoped: false, spawnsProcess: false, requiresGateApproval: false }, + effect: () => 3, + dispatch: (args) => { + const parsed = z.object({ payload: z.unknown() }).strict().parse(args); + received.push(parsed.payload); + return Promise.resolve({ receipt: 'published' }); + }, + }; + const context: ToolDispatchContext = { + nodeId: 'n', + grantedToolIds: new Set([tool.id]), + config: {}, + toolPolicy: {}, + fsScope: 'sandboxed', + gateApproved: false, + effects, + effectSlot: 0, + }; + return { registry: createToolRegistry({ tools: [tool], host: {} }), received, context }; +} + +function call(payload: unknown) { + return { type: 'tool_call', id: 'alias-call', name: 'alias_publish', args: { payload } } as const; +} + +function recordingPort(inner: EffectDispatchPort) { + return { + prepare: vi.fn((...args: Parameters) => inner.prepare(...args)), + settle: vi.fn((...args: Parameters) => inner.settle(...args)), + discard: vi.fn((...args: Parameters) => inner.discard(...args)), + } satisfies EffectDispatchPort; +} + +const PhysicalRowsSchema = z.array( + z.object({ slot: z.number(), state: z.string(), args_digest: z.string() }).strict(), +); +const METADATA = [ + { name: 'absent', keys: undefined }, + { name: 'empty', keys: new Set() }, + { name: 'nonempty', keys: new Set(['confidentialNote']) }, +]; + +describe.each(METADATA)( + 'W3S5 #94 public registry and native SQLite with $name external metadata', + ({ keys }) => { + it('PHYSICAL_ALIAS_DIGEST commits distinct correct SHA-256 values for different selections', async () => { + const client = createClient(':memory:'); + try { + runMigrations(client.db); + let ids = 0; + const store = createEffectJournalStore(client.db, { + uuid: () => `alias-${++ids}`, + now: () => 6, + }); + const effects = recordingPort( + createEffectJournalPort( + store, + { kind: 'run', runId: 'r-alias', nodeId: 'n', attempt: 1 }, + { providerAttempt: 1, toolCallId: 'alias-call' }, + ), + ); + const h = registryFixture(effects); + const context = { ...h.context, ...(keys === undefined ? {} : { secretArgKeys: keys }) }; + const alice = selection('alice'); + const bob = selection('bob'); + const aliceOutcome = await h.registry.dispatch(call(alice), context); + const bobOutcome = await h.registry.dispatch(call(bob), { ...context, effectSlot: 1 }); + const rows = PhysicalRowsSchema.parse( + client.sqlite + .prepare('SELECT slot, state, args_digest FROM run_effects ORDER BY slot') + .all(), + ); + expect(rows).toHaveLength(2); + expect(rows.map((row) => row.state)).toEqual(['committed', 'committed']); + for (const row of rows) expect(row.args_digest).toMatch(/^[0-9a-f]{64}$/); + expect(rows[0]?.args_digest, 'PHYSICAL_ALIAS_DIGEST_DISTINCT').not.toBe( + rows[1]?.args_digest, + ); + expect(rows[0]?.args_digest, 'PHYSICAL_ALIAS_DIGEST_ALICE').toBe( + sha256(EXPECTED_CANONICAL.alice), + ); + expect(rows[1]?.args_digest, 'PHYSICAL_ALIAS_DIGEST_BOB').toBe( + sha256(EXPECTED_CANONICAL.bob), + ); + for (const [index, outcome] of [aliceOutcome, bobOutcome].entries()) { + const expected: unknown = JSON.parse( + index === 0 ? EXPECTED_CANONICAL.alice : EXPECTED_CANONICAL.bob, + ); + expect(outcome.events.call.toolInput, 'PHYSICAL_ALIAS_EVENT').toEqual(expected); + expect(effects.prepare.mock.calls[index]?.[3], 'PHYSICAL_ALIAS_PREPARE').toEqual( + expected, + ); + } + expect(h.received[0]).toBe(alice); + expect(h.received[1]).toBe(bob); + expect(alice.selected).toBe(alice.catalog[0]); + expect(bob.selected).toBe(bob.catalog[1]); + } finally { + client.sqlite.close(); + } + }); + + it('PHYSICAL_ALIAS_REPLAY replays equal selection and conflicts on changed same-slot selection', async () => { + const client = createClient(':memory:'); + try { + runMigrations(client.db); + let ids = 0; + const store = createEffectJournalStore(client.db, { + uuid: () => `replay-${++ids}`, + now: () => 6, + }); + const effects = recordingPort( + createEffectJournalPort( + store, + { kind: 'run', runId: 'r-alias-replay', nodeId: 'n', attempt: 1 }, + { providerAttempt: 1, toolCallId: 'alias-call' }, + ), + ); + const h = registryFixture(effects); + const context = { ...h.context, ...(keys === undefined ? {} : { secretArgKeys: keys }) }; + const first = await h.registry.dispatch(call(selection('alice')), context); + const replay = await h.registry.dispatch(call(selection('alice')), context); + expect(replay.output).toEqual(first.output); + expect(h.received).toHaveLength(1); + await expect( + h.registry.dispatch(call(selection('bob')), context), + 'PHYSICAL_ALIAS_CHANGED_SELECTION_CONFLICT', + ).rejects.toMatchObject({ + name: 'ToolEffectConflictError', + code: 'effect_conflict', + retryable: false, + }); + expect(h.received, 'PHYSICAL_ALIAS_NO_DUPLICATE_DISPATCH').toHaveLength(1); + const rows = PhysicalRowsSchema.parse( + client.sqlite + .prepare('SELECT slot, state, args_digest FROM run_effects ORDER BY slot') + .all(), + ); + expect(rows, 'PHYSICAL_ALIAS_REPLAY_ROW').toEqual([ + { slot: 0, state: 'committed', args_digest: sha256(EXPECTED_CANONICAL.alice) }, + ]); + } finally { + client.sqlite.close(); + } + }); + }, +); + +describe('W3S5 #94 physical journal deliberate secret-projection equality', () => { + it('PHYSICAL_SECRET_EQUALITY varies only credentials and keeps actual event and prepare copies scrubbed', async () => { + const client = createClient(':memory:'); + try { + runMigrations(client.db); + let ids = 0; + const store = createEffectJournalStore(client.db, { + uuid: () => `secret-${++ids}`, + now: () => 6, + }); + const effects = recordingPort( + createEffectJournalPort( + store, + { kind: 'run', runId: 'r-alias-secret', nodeId: 'n', attempt: 1 }, + { providerAttempt: 1, toolCallId: 'alias-call' }, + ), + ); + const h = registryFixture(effects); + const context = { ...h.context, secretArgKeys: new Set(['confidentialNote']) }; + const media = 'U1lOVEhFVElDX01FRElBX0NBTkFSWQ=='; + const projected = { + payload: { + left: { + label: 'clean', + password: '[redacted]', + media: { kind: 'base64', base64Length: media.length }, + }, + right: { + label: 'clean', + password: '[redacted]', + media: { kind: 'base64', base64Length: media.length }, + }, + }, + }; + const canonical = + '{"payload":{"left":{"label":"clean","media":{"base64Length":32,"kind":"base64"},"password":"[redacted]"},"right":{"label":"clean","media":{"base64Length":32,"kind":"base64"},"password":"[redacted]"}}}'; + expect(media.length, 'PHYSICAL_SECRET_FIXTURE_LENGTH').toBe(32); + for (const [slot, suffix] of ['ONE', 'TWO'].entries()) { + const shared = { + label: 'clean', + password: `SYNTHETIC_PASSWORD_${suffix}`, + media: { kind: 'base64', data: media }, + confidentialNote: `SYNTHETIC_NOTE_${suffix}`, + }; + const payload = { left: shared, right: shared }; + const outcome = await h.registry.dispatch(call(payload), { ...context, effectSlot: slot }); + for (const projection of [ + outcome.events.call.toolInput, + effects.prepare.mock.calls[slot]?.[3], + ]) { + expect(projection, 'PHYSICAL_SECRET_PROJECTION').toEqual(projected); + const text = JSON.stringify(projection); + for (const canary of ['SYNTHETIC_PASSWORD_', 'SYNTHETIC_NOTE_', media]) + expect(text).not.toContain(canary); + } + expect(h.received[slot]).toBe(payload); + expect(payload.left).toBe(payload.right); + expect(shared.password).toBe(`SYNTHETIC_PASSWORD_${suffix}`); + } + const rows = PhysicalRowsSchema.parse( + client.sqlite + .prepare('SELECT slot, state, args_digest FROM run_effects ORDER BY slot') + .all(), + ); + expect(rows, 'PHYSICAL_SECRET_EQUALITY').toEqual([ + { slot: 0, state: 'committed', args_digest: sha256(canonical) }, + { slot: 1, state: 'committed', args_digest: sha256(canonical) }, + ]); + expect(h.received).toHaveLength(2); + } finally { + client.sqlite.close(); + } + }); +}); diff --git a/apps/cli/src/render/tui/mention-identifier-order.test.ts b/apps/cli/src/render/tui/mention-identifier-order.test.ts new file mode 100644 index 00000000..1cc3cb31 --- /dev/null +++ b/apps/cli/src/render/tui/mention-identifier-order.test.ts @@ -0,0 +1,156 @@ +import type { FsCapability } from '@relavium/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { createMentionReader, foldMentionKey, type MentionState } from './mention.js'; + +const ENTRIES: ReadonlyArray<{ name: string; type: 'file' | 'directory' }> = [ + { name: 'Z.ts', type: 'file' }, + { name: 'Z', type: 'directory' }, + { name: 'tie', type: 'directory' }, + { name: 'tie.ts', type: 'file' }, + { name: 'aB.ts', type: 'file' }, + { name: 'a_b', type: 'directory' }, + { name: 'Tie', type: 'directory' }, + { name: 'Tie.ts', type: 'file' }, + { name: 'A-b.ts', type: 'file' }, + { name: 'aB', type: 'directory' }, + { name: 'a_b.ts', type: 'file' }, + { name: 'A-b', type: 'directory' }, + { name: 'ä.ts', type: 'file' }, + { name: 'ä', type: 'directory' }, + { name: 'node_modules', type: 'directory' }, + { name: 'dist', type: 'directory' }, + { name: 'ignored-dir', type: 'directory' }, + { name: 'ignored.txt', type: 'file' }, + { name: 'hidden.txt', type: 'file' }, +]; + +function fsFixture(): FsCapability { + return { + readFile: (path) => { + const content = + path === '.gitignore' ? 'ignored*\n' : path === '.relaviumignore' ? 'hidden*\n' : ''; + return Promise.resolve({ + content, + mimeType: 'text/plain', + sizeBytes: content.length, + lastModified: '', + }); + }, + writeFile: () => Promise.reject(new Error('mention fixture is read-only')), + listDirectory: () => + Promise.resolve({ + entries: ENTRIES.map((entry) => ({ ...entry, sizeBytes: 0, lastModified: '' })), + }), + }; +} + +describe('actual mention reader folded identifier ordering', () => { + for (const dir of ['', 'src/lib']) { + it(`[MENTION-IDENTIFIERS] ${dir || 'root'} preserves type groups, folded casing, stable ties and parent priority`, async () => { + const reader = createMentionReader(fsFixture()); + const collation = vi.spyOn(String.prototype, 'localeCompare'); + try { + const candidates = await reader.list(dir); + const ordinary = dir === '' ? candidates : candidates.slice(1); + expect( + ordinary.map(({ name }) => name), + '[MENTION-FOLDED-ORDINAL] actual reader retains raw names and incoming folded ties', + ).toEqual([ + 'A-b', + 'a_b', + 'aB', + 'tie', + 'Tie', + 'Z', + 'ä', + 'A-b.ts', + 'a_b.ts', + 'aB.ts', + 'tie.ts', + 'Tie.ts', + 'Z.ts', + 'ä.ts', + ]); + expect(ordinary.map(({ type }) => type)).toEqual([ + 'directory', + 'directory', + 'directory', + 'directory', + 'directory', + 'directory', + 'directory', + 'file', + 'file', + 'file', + 'file', + 'file', + 'file', + 'file', + ]); + expect(ordinary.map(({ path }) => path)).toEqual( + dir === '' + ? [ + 'A-b', + 'a_b', + 'aB', + 'tie', + 'Tie', + 'Z', + 'ä', + 'A-b.ts', + 'a_b.ts', + 'aB.ts', + 'tie.ts', + 'Tie.ts', + 'Z.ts', + 'ä.ts', + ] + : [ + 'src/lib/A-b', + 'src/lib/a_b', + 'src/lib/aB', + 'src/lib/tie', + 'src/lib/Tie', + 'src/lib/Z', + 'src/lib/ä', + 'src/lib/A-b.ts', + 'src/lib/a_b.ts', + 'src/lib/aB.ts', + 'src/lib/tie.ts', + 'src/lib/Tie.ts', + 'src/lib/Z.ts', + 'src/lib/ä.ts', + ], + ); + if (dir === '') expect(candidates.some(({ name }) => name === '..')).toBe(false); + else + expect( + candidates[0], + '[MENTION-PARENT] synthetic parent precedes every normal candidate', + ).toEqual({ name: '..', type: 'directory', path: 'src' }); + expect( + collation, + '[MENTION-NO-AMBIENT] case folding does not reintroduce ambient collation', + ).not.toHaveBeenCalled(); + const state: MentionState = { + dir, + filter: '', + candidates: ordinary, + selected: 0, + loading: false, + }; + expect(foldMentionKey('', { return: true }, state)).toEqual({ + kind: 'descend', + dir: dir === '' ? 'A-b' : 'src/lib/A-b', + }); + expect(foldMentionKey('', { tab: true }, { ...state, selected: 7 })).toEqual({ + kind: 'accept', + path: dir === '' ? 'A-b.ts' : 'src/lib/A-b.ts', + }); + } finally { + collation.mockRestore(); + } + }); + } +}); diff --git a/apps/cli/src/render/tui/mention.ts b/apps/cli/src/render/tui/mention.ts index fedc3cf6..81e82f17 100644 --- a/apps/cli/src/render/tui/mention.ts +++ b/apps/cli/src/render/tui/mention.ts @@ -1,4 +1,5 @@ import type { FsCapability } from '@relavium/core'; +import { byCodeUnit } from '@relavium/shared'; import { dropLastCodePoint } from './chat-input.js'; import { compileIgnore, type IgnoreMatcher } from './gitignore.js'; @@ -268,7 +269,7 @@ export function createMentionReader(fs: FsCapability): MentionReader { // Directories first, then case-insensitive by name — a stable, glanceable order. const sorted = [...candidates].sort((a, b) => { if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; - return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + return byCodeUnit(a.name.toLowerCase(), b.name.toLowerCase()); }); // Below the workspace root, offer a synthetic `..` ascend row at the very top (dir-navigation is first-class, // ADR-0061) — it descends to the parent (which only ever climbs toward `''`, never above the jailed root). diff --git a/apps/cli/src/workflows/catalog-identifier-order.test.ts b/apps/cli/src/workflows/catalog-identifier-order.test.ts new file mode 100644 index 00000000..d71a2c2d --- /dev/null +++ b/apps/cli/src/workflows/catalog-identifier-order.test.ts @@ -0,0 +1,58 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { discoverCatalog, type CatalogKind } from './catalog.js'; + +function valid(kind: CatalogKind, id: string): string { + return kind === 'workflows' + ? `schema_version: '1.0'\nworkflow:\n id: ${id}\n name: Fixture ${id}\n tags: [greeting]\n nodes:\n - { id: a, type: input }\n - { id: b, type: output }\n edges:\n - { from: a, to: b }\n` + : `id: ${id}\nname: Fixture ${id}\nmodel: claude-sonnet-4-6\nprovider: anthropic\nsystem_prompt: Summarize the input.\n`; +} + +describe('disk catalog identifier ordering', () => { + for (const kind of ['workflows', 'agents'] satisfies CatalogKind[]) { + it(`[CATALOG-IDENTIFIERS] ${kind} orders invalid mixed-case fallback stems alongside valid kebab IDs`, () => { + const project = mkdtempSync(join(tmpdir(), 'relavium-catalog-order-')); + const configDir = join(project, '.relavium'); + const dir = join(configDir, kind); + mkdirSync(dir, { recursive: true }); + try { + for (const id of ['a_b', 'a-b', 'a', 'Z']) { + // Mixed-case/underscore values are filenames of deliberately INVALID documents, never fake + // valid authored identifiers. Their fallback stems are real catalog-visible identifiers. + const body = + id === 'Z' || id === 'a_b' ? 'id: synthetic-invalid-private-value\n' : valid(kind, id); + writeFileSync(join(dir, `${id}.yaml`), body, 'utf8'); + } + const entries = discoverCatalog({ projectConfigDir: configDir, cwd: project, kind }); + expect( + entries.map(({ slug }) => slug), + '[CATALOG-ORDINAL] actual disk discovery sorts fallback and authored identifiers together', + ).toEqual(['Z', 'a', 'a-b', 'a_b']); + expect(entries.map(({ path }) => path)).toEqual([ + join('.relavium', kind, 'Z.yaml'), + join('.relavium', kind, 'a.yaml'), + join('.relavium', kind, 'a-b.yaml'), + join('.relavium', kind, 'a_b.yaml'), + ]); + expect(entries.map(({ valid: isValid }) => isValid)).toEqual([false, true, true, false]); + for (const entry of entries.filter(({ valid: isValid }) => isValid)) { + expect(entry.name).toBe(`Fixture ${entry.slug}`); + expect(entry.tags).toEqual(kind === 'workflows' ? ['greeting'] : []); + expect(entry.error).toBeUndefined(); + } + for (const entry of entries.filter(({ valid: isValid }) => !isValid)) { + expect(entry.name).toBeUndefined(); + expect(entry.tags).toEqual([]); + expect(entry.error).toMatch(kind === 'workflows' ? /^schema_version:/ : /model: missing/); + expect(entry.error).not.toContain('synthetic-invalid-private-value'); + } + } finally { + rmSync(project, { recursive: true, force: true }); + } + }); + } +}); diff --git a/apps/cli/src/workflows/catalog.ts b/apps/cli/src/workflows/catalog.ts index 146d71dc..fa57e325 100644 --- a/apps/cli/src/workflows/catalog.ts +++ b/apps/cli/src/workflows/catalog.ts @@ -9,6 +9,7 @@ import { statSync, } from 'node:fs'; import { basename, join } from 'node:path'; +import { byCodeUnit } from '@relavium/shared'; import { AgentParseError, @@ -102,7 +103,7 @@ export function discoverCatalog(opts: { } const entries = names.map((name) => readEntry(join(dir, name), opts.cwd, opts.kind, root)); - return entries.sort((a, b) => a.slug.localeCompare(b.slug)); + return entries.sort((a, b) => byCodeUnit(a.slug, b.slug)); } /** A `valid: false` catalog entry whose slug falls back to the filename stem — the secret-free, path-free shape. */ From 25cac33a822927d566448dc5bf08a93af4ba1128 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 06:15:43 +0300 Subject: [PATCH 055/222] docs(docs): record wave 3 tool and engine tail contracts Land canonical input, scope, literal-template, cancellation and bus compatibility contracts, with precise author evidence and pending independent acceptance. Refs: Wave 3 Step 5; ADR-0037; ADR-0113 Co-Authored-By: Codex --- docs/reference/contracts/sse-event-schema.md | 6 ++ .../reference/contracts/workflow-yaml-spec.md | 10 ++-- docs/reference/shared-core/agent-runner.md | 7 +++ docs/reference/shared-core/built-in-tools.md | 30 +++++++++- docs/reference/shared-core/tool-registry.md | 21 +++++-- .../phase-2.5.5-hardening-and-remediation.md | 60 +++++++++++++++++++ 6 files changed, 123 insertions(+), 11 deletions(-) diff --git a/docs/reference/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index ee820fee..24115ab5 100644 --- a/docs/reference/contracts/sse-event-schema.md +++ b/docs/reference/contracts/sse-event-schema.md @@ -49,6 +49,12 @@ cross-field guards retain their stricter rules. The stored run reader still drop an unknown nonempty discriminator independently of its body, while refusing a known damaged event; envelope validation does not precede discriminator selection. +`RunEventBusOptions.validate` remains an exported compatibility option. Omitted or `true` validates +the full payload before consuming a sequence number; `false` skips that payload validation and +retains supplied fields. Both modes enforce the raw exactly-one-correlation rule before reading the +clock, consuming a number or notifying listeners. This opt-out does not weaken persisted-store +validation or turn an invalid payload into a valid contract event. + `sequenceNumber` is monotonic per run and is the basis for **gap detection**: if a consumer sees a jump in `sequenceNumber`, it triggers a full state resync (re-read the durable run state) rather than trusting a partial view. This is what makes reconnection lossless. The **envelope** fields (`sessionId` / `runId`, `sequenceNumber`, `timestamp`) are stamped by the bus, not the producer: `WorkflowEngine` emits through the `RunEventBus`, and `AgentSession` (1.V) emits *envelope-free payload drafts* through an injected `SessionEventSink` — wiring that sink onto the bus, where the per-session `sequenceNumber` (and its same gap/resync rule) is assigned, is **1.W**. So a session's monotonic numbering is the bus's responsibility, not the session core's. ## The `RunEvent` union diff --git a/docs/reference/contracts/workflow-yaml-spec.md b/docs/reference/contracts/workflow-yaml-spec.md index c2f16814..157619ed 100644 --- a/docs/reference/contracts/workflow-yaml-spec.md +++ b/docs/reference/contracts/workflow-yaml-spec.md @@ -25,7 +25,7 @@ The same file parses and runs **identically** on every surface — desktop canva schema_version: '1.0' # required — validated for forward-compat parsing workflow: id: string # required, unique, kebab-case - version: semver # e.g. '1.2.0' + version: string # optional; Semver recommended, e.g. '1.2.0' name: string description: string tags: string[] @@ -48,7 +48,7 @@ workflow: | --- | --- | --- | | `schema_version` | yes | Top-level, outside `workflow:`. Currently `'1.0'`. | | `workflow.id` | yes | Unique, kebab-case. | -| `workflow.version` | recommended | Semver of the workflow itself, for human change tracking. | +| `workflow.version` | no | Optional string for human change tracking; Semver is recommended, not format-validated. | | `workflow.name` / `description` / `tags` | no | Display + filtering metadata. | | `workflow.metadata` | no | Free-form provenance map — a real schema field, so it **survives parse → serialize round-trips** (unlike comments). Used by session export to carry the source transcript ([ADR-0026](../../decisions/0026-session-export-to-workflow.md)). | | `trigger` | no | Defaults to `manual`. | @@ -194,7 +194,9 @@ receives, rejecting `"3"` for a `number`. That split is what makes two surfaces ## Context and interpolation -`context` declares named values available throughout the workflow as `{{ctx.key}}`. `{{ ... }}` interpolation is for **template fields only** — `context` values, agent `prompt_template` / `system_prompt_append`, and human-gate `assignee` / `message_template`. An **`inputs` default is NOT a template field** ([ADR-0083](../../decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md) §3): it is resolved at admission, before a run exists, so `inputs`, `ctx` and `secrets` are all unavailable to it — a `{{ }}` there is an authoring error. It never resolved before either; the engine applied no defaults at all, so what changes is that the mistake is now loud. The **expression fields** are **bare sandboxed JavaScript** ([ADR-0027](../../decisions/0027-expression-sandbox.md)), *not* interpolation: they read the same run scope directly as `run.outputs["x"]` / `inputs.y` / `ctx.z` with no `{{ }}` wrapper. The three sandboxed kinds (the 1.AB `ExpressionKind`) are a `condition` node's `expression`, a `transform`, and a `merge_fn` — each `js` (a `condition`/`transform` carries `expression_type: js`; `jmespath`/`jsonlogic` are reserved and rejected at parse; a `merge_fn` is always `js`). An **edge `condition` is REJECTED at parse in v1.0** ([ADR-0093](../../decisions/0093-an-expression-sees-only-what-it-is-ordered-after.md)): the field was accepted by the schema, described as gating its edge here (twice) and in [node-types.md](../shared-core/node-types.md#edges), and **read by nothing** — so an author could write it and it silently did nothing. Branch on a `condition` NODE instead. *(This sentence described the field as "evaluated by the run loop (1.N)" until 2026-09-02; it never was.)* A context `key`, like an input `name`, must be a referenceable identifier (`[A-Za-z0-9_-]+`). +`system_prompt_append` is literal authored system text, appended after the agent system prompt with a blank line. Braces remain literal; this does not activate Proposed ADR-0060. Existing reference/dependency and secret-taint scans remain in force. + +`context` declares named values available throughout the workflow as `{{ctx.key}}`. `{{ ... }}` interpolation is for **template fields only** — `context` values, agent `prompt_template`, and human-gate `assignee` / `message_template`. An **`inputs` default is NOT a template field** ([ADR-0083](../../decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md) §3): it is resolved at admission, before a run exists, so `inputs`, `ctx` and `secrets` are all unavailable to it — a `{{ }}` there is an authoring error. It never resolved before either; the engine applied no defaults at all, so what changes is that the mistake is now loud. The **expression fields** are **bare sandboxed JavaScript** ([ADR-0027](../../decisions/0027-expression-sandbox.md)), *not* interpolation: they read the same run scope directly as `run.outputs["x"]` / `inputs.y` / `ctx.z` with no `{{ }}` wrapper. The three sandboxed kinds (the 1.AB `ExpressionKind`) are a `condition` node's `expression`, a `transform`, and a `merge_fn` — each `js` (a `condition`/`transform` carries `expression_type: js`; `jmespath`/`jsonlogic` are reserved and rejected at parse; a `merge_fn` is always `js`). An **edge `condition` is REJECTED at parse in v1.0** ([ADR-0093](../../decisions/0093-an-expression-sees-only-what-it-is-ordered-after.md)): the field was accepted by the schema, described as gating its edge here (twice) and in [node-types.md](../shared-core/node-types.md#edges), and **read by nothing** — so an author could write it and it silently did nothing. Branch on a `condition` NODE instead. *(This sentence described the field as "evaluated by the run loop (1.N)" until 2026-09-02; it never was.)* A context `key`, like an input `name`, must be a referenceable identifier (`[A-Za-z0-9_-]+`). ```yaml context: @@ -216,7 +218,7 @@ Common interpolation namespaces: - `{{ctx.}}` — context entries. - `{{run.outputs[""]}}` — a completed node's output, in a template field; the same data is read as bare `run.outputs[""]` (no `{{ }}`) inside a `condition` / `transform` / `merge_fn` expression. - `{{run.id}}` — the run's id (1.AF) — for an output node's `save_to` path template (e.g. `out/{{ run.id }}/image.png`). The interpolation engine (1.L2) recognizes the `run.id` namespace alongside `run.outputs`; it resolves only where the run id is in scope (today: `save_to`), so a `{{ run.id }}` elsewhere surfaces a typed `unresolved_reference` rather than an empty string. -- Pipe filters: `| read_file`, `| json`, `| length`, `| default("…")`. +- Pipe filters: `| read_file`, `| json`, `| length`, `| default("…")`. Quoted filter arguments decode only an escaped matching quote or backslash, after structural splitting. Other escapes remain literal, including Windows path segments; they do not become control characters. > **Evaluation timing.** `context` entries are evaluated **eagerly, exactly once, before any node runs**, and the resolved values are immutable and cached for the whole run. A pipe-filter failure (e.g. `read_file` on a missing path) is a **validation error** that fails the run before it starts (CLI exit code 2), never a mid-run surprise. A `context` value may reference `{{inputs.*}}` but **not** `{{run.outputs[...]}}` (no node has run yet) — doing so is a parse error. diff --git a/docs/reference/shared-core/agent-runner.md b/docs/reference/shared-core/agent-runner.md index 4fe67426..efe8cbef 100644 --- a/docs/reference/shared-core/agent-runner.md +++ b/docs/reference/shared-core/agent-runner.md @@ -64,6 +64,13 @@ The runner owns the **cost path** itself — one `CostTracker` per node executio ## The turn loop + the failure ladder +Ordinary execution and budget preparation forward the identical node signal to public prompt +resolution and recheck cancellation after its final await, before prompt-error mapping or further +admission. Literal/empty prompts share that boundary. A cancelled resolver settlement returns the +existing non-retryable `cancelled` failure without provider/key/effect work; pure plan lookup may have +already occurred. Human-gate message/assignee resolution also rechecks after its final await, so +cancellation there cannot become a newly published pause. + The core streams one turn (emitting `agent:token` per text delta, accumulating text / tool-call / reasoning parts), and on a `tool_use` stop appends the assistant turn (**including its reasoning `ContentPart`** for the same-provider replay, [ADR-0039](../../decisions/0039-same-provider-reasoning-replay.md)), dispatches each tool call, appends the results, and continues — bounded by a **runner-default max-tool-turns cap** (a DoS guard; the authored hard cap + the loud `turn_limit` surfacing is the 1.V session knob). The error mapping to the closed `ErrorCode` ([error-handling.md](../../standards/error-handling.md)) — **cancel wins** over all others: diff --git a/docs/reference/shared-core/built-in-tools.md b/docs/reference/shared-core/built-in-tools.md index b65b8495..561a7021 100644 --- a/docs/reference/shared-core/built-in-tools.md +++ b/docs/reference/shared-core/built-in-tools.md @@ -26,14 +26,14 @@ A tool declaration in YAML has a `type` discriminator: | `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 (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. | +| `git_commit` | Create a commit with a message, optionally restricted to given pathspecs (does **not** `git add`/stage). | commit result | **Requires `gateApproved`** at the registry boundary. Both production agent-context producers currently supply `false`, including workflows; chat confirmation cannot provide it. Review changes and commit manually. Pathspecs are `--`-separated so they cannot become Git options. | | `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). | | `invoke_agent` | Call another agent node in the same workflow by node id, with explicit input. | that agent's output | The dynamic-dispatch mechanism used by orchestrator agents to delegate. | | `read_media` | Read a produced/received media asset by its durable `media://sha256-…` handle (**whole handle** — no byte range), so a media-capable model can look at it. | a short **text descriptor** (mime type, byte length, handle) as the `tool_result`; the media itself arrives as a top-level `media` part on a **marked, engine-synthesized `user` message** appended right after it ([ADR-0089](../../decisions/0089-media-correctness-four-boundaries.md) §1) | **Scope-set authz** — the requesting `session`/`workspace` must be in the handle's `allowedScopes` ([ADR-0044](../../decisions/0044-media-access-governance-read-media-save-to-cost.md) §1); knowing a sha256 is **not** authorization; read-only (bypasses the action gate), secret-free `media_scope_denied`. The tool never touches bytes: it authorizes a handle and describes it, and the chain's egress re-materialization resolves the handle per attempt (ADR-0043 §1), so a failover or a resume re-resolves from the canonical handle. The synthesized message carries a fixed engine-authored preamble naming the tool — it must never present as the user's own words. Because the media rides the ordinary media-INPUT rail it is bounded by it: `read_media` **refuses** a video, a PDF (never inline at any size) or an image/audio handle over `INLINE_MEDIA_CEILING`, with a correctable `invalid_args` naming the limit — so the model picks another handle instead of the next request dying on a schema error after the tool has already run. A response's tool calls may deliver at most `ADMISSION_CEILINGS.mediaAttachmentsPerResponse` attachments, refused by the engine before the seam's own per-message cap. All of a response's attachments arrive on **one** synthesized message appended after the FINAL tool result — after every one of them, never interleaved between two — a tool result must stay contiguous with its siblings or the providers reject the turn. And a model that does not accept image input refuses the turn rather than being handed a descriptor for something it never received. *(The delivery path ships as of `W5`/`CR-50`. The host `MediaReadAccess` delegate and the `session`/`workspace` scope population remain unwired in production — see [deferred-tasks.md](../../roadmap/deferred-tasks.md); absent the delegate the tool fails closed with `tool_unavailable`.)* | -> **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** allowlisted 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). +> **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)). `git_commit` retains its registry gate floor and is refused by current production agent producers, as the table records. All are enforced by the engine, not by convention. `run_command` is the **only** allowlisted 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, `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. @@ -45,6 +45,30 @@ A `tool` node (direct tool execution without an LLM) and an agent's tool call bo 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 input bounds + +The executable validator checks the full effective arguments before policy-target construction, +confirmation, effect preparation, dispatch hooks or host work. Limits are inclusive **UTF-8 byte** +limits, independent of operating-system command-line capacity. + +| Argument | Maximum | +| --- | --- | +| `write_file.content` | 8 MiB (8,388,608 bytes) | +| `http_request.body` | 1 MiB (1,048,576 bytes), when supplied | +| `git_commit.message` | 16 KiB (16,384 bytes); still non-empty | +| `run_command.args` | 256 entries, each at most 16 KiB, totaling at most 64 KiB (65,536 bytes) | + +Empty file content, HTTP body and argument entries remain valid; omitted body/arguments and an empty +argument vector remain valid. Impossible UTF-16 lengths and argument counts are refused before byte +scanning; the vector's cheap aggregate check precedes all member byte scans. The existing shared +byte counter measures plausible values, including multibyte text and unmatched surrogates. + +The model-visible properties and array items describe their use, defaults and these limits through +all supported dialect normalizers. JSON Schema `maxLength` is a necessary character bound, **not** +the byte authority; descriptions communicate the UTF-8 constraint and aggregate argument limit. +Config-only parameters remain excluded. These limits apply only to the listed arguments, without +imposing matching-name limits on unrelated fields such as `notify.body` or `git_status` configuration. + ## 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** (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). @@ -64,7 +88,7 @@ A tool that spawns a process (`run_command`, `git_*`) runs with **`shell: false` ## Filesystem permission tiers -File scope is host-enforced. The desktop uses its scoped filesystem layer and Tauri v2 capabilities ([tauri-plugins.md](../desktop/tauri-plugins.md)); its native permission-dialog/capability-grant mechanism is surface-specific. The Node CLI uses the host-owned policy below ([ADR-0105](../../decisions/0105-filesystem-roots-are-invocation-grants-not-project-authority.md)). A committed path declaration alone never authorizes CLI access. +File scope is host-enforced. `ToolDispatchContext.fsScope` describes the tier captured by the host; it does not grant per-call roots or widen an already-created filesystem capability. The immutable host policy is the authority. The desktop uses its scoped filesystem layer and Tauri v2 capabilities ([tauri-plugins.md](../desktop/tauri-plugins.md)); its native permission-dialog/capability-grant mechanism is surface-specific. The Node CLI uses the host-owned policy below ([ADR-0105](../../decisions/0105-filesystem-roots-are-invocation-grants-not-project-authority.md)). A committed path declaration alone never authorizes CLI access. | CLI tier/surface | Effective roots | | --- | --- | diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index b360ccd7..fd36a07f 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -273,8 +273,8 @@ 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' | 'full' (the @relavium/shared source of truth) - readonly gateApproved: boolean; // a human-gate decision is present for this dispatch (1.Q) + readonly fsScope: FsScopeTier; // descriptive host-captured tier; cannot widen the host's immutable policy + readonly gateApproved: boolean; // registry gate floor; both production agent producers currently pass false // Per-tool approval regime (ADR-0057 EA3). PRESENT ⇒ the interactive-approval (chat) path: a governed-class // dispatch requires `approval.confirm`'s decision, and an absent confirm is fail-closed → denied. ABSENT ⇒ // the workflow author-trust path (unchanged). `confirm` is the host-injected ConfirmActionHook (the engine @@ -390,10 +390,15 @@ The canonical guardrail home is [security-review.md §Sandbox-and-tool-policy](. | `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` | +| `git_commit` | refused unless `ctx.gateApproved`; production agent contexts currently pass false, and chat confirmation cannot substitute | `process.spawn` | | node `tools:` narrowing | enforced at **plan build** when the host marks the grant complete (`BuildRunPlanOptions.toolGrantsFinal`), with `resolveGrant` as the dispatch floor for a directly-constructed executor ([ADR-0094](../../decisions/0094-a-tool-grant-is-checked-when-the-plan-is-built.md)); narrow-only, 0029(b)/[ADR-0023](../../decisions/0023-strict-authored-yaml-validation.md) | — | | secret args | Parser refusal covers declared secret references in text (transitive taint, 0029(c)); externally supplied `secretArgKeys` is checked on **top-level** effective-arg keys before approvals/effects (step 3). There is no live authored mapping producer; absent metadata is unverified. See [secret argument provenance](#secret-argument-provenance) for the future mapped-path obligation | host resolves an opaque `credentialRef` inside its trust boundary | -| FS scope tier | tool flagged `fsScoped` | `fs.*` jails to the tier + rejects path traversal | +| FS scope tier | descriptive `ctx.fsScope` does not grant access | `fs.*` enforces its host-captured immutable policy + rejects path traversal | + +Built-in effective-argument bounds are canonical in +[built-in tools](built-in-tools.md#tool-input-bounds). Validation precedes policy targets, confirmation, +effect preparation and host dispatch. Registry IDs and field-only refusal lists use shared UTF-16 +code-unit ordering, independent of ambient locale. ## Result bounding and spill-to-file @@ -408,6 +413,14 @@ distinct from the *event* `outputSummary`, which is truncated separately at the 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`. +Observability and journal-argument projections distinguish shared siblings from ancestor cycles. +Each walk removes a visited object when its branch finishes, including native/binary/media early +returns. A genuine ancestor back-reference becomes `[cyclic]`; repeated non-cyclic objects are +projected at every occurrence. The model-facing value and existing secret/media redaction policies +are unchanged. Sanitized effective arguments also feed the existing journal identity; preserving a +clean sibling avoids incorrectly treating distinct selections as a replay. This does not make the +projection a lossless canonical encoding or claim detection of arbitrary sensitive text. + ## 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 diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 735f4945..b9f0e688 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4685,3 +4685,63 @@ only test designs are preparation, not acceptance. Necessary same-defect registr redaction prewalks and remaining identifier-sort consumers stay in that increment. No new ADR, user decision, provider capture, credential or external operation is pending. Step 19 retains final whole-wave validation and publication. + +### Wave 3 Step 5 implementation checkpoint — 2026-10-16 + +Step 5 is implemented from accepted Step 4 base +`e60b10478f5f5200f8ef55d01a6976c2dcb9fb87`; independent security and two NEW complete +cumulative correctness rounds are still required. This is author evidence, not +acceptance of Step 5 or the whole wave. + +| Queue | Implemented or certified disposition | +| --- | --- | +| #95 | Executable effective-argument UTF-8 admission with cheap count, member-length and aggregate-length prechecks before byte scanning. Inclusive limits and model metadata have their [canonical home](../../reference/shared-core/built-in-tools.md#tool-input-bounds). Actual refusals precede target, confirmation, journal preparation, dispatch hook and host; small/exact-cap/mapping/config-exclusion positives reach the real journal and host. | +| #96/#98 | All thirteen built-ins describe their 28 immediate model-visible properties and array items. Actual ToolDef parsing and all four wire normalizers retain bounds/descriptions, required distinctions and configuration exclusions; Gemini keeps its existing dialect treatment of additional properties. | +| #94 | Both bounding walks and both registry prewalks use ancestor-path cycle guards with retirement in `finally`, including early returns. Shared siblings survive; true back-references remain finite. Real events, reference replay/conflict and native SQLite digest controls distinguish clean selections while retaining deliberate secret-projection equality. | +| #76/G9 | Ordinary/budget prompt resolution forwards the identical signal and checks after its final await; human-gate final resolution does likewise. The actual engine race produced a pause on both delivered and stored rails before the fix, contrary to the preparation note's fence-preservation prediction. The corrected real handler returns cancelled and publishes no pause. Live execution and gate pause/resume remain positive controls. | +| #82 | Quoted filter arguments decode only matching quote/backslash escapes after structural splitting. Exact runtime default values, structural delimiters, unknown escapes and Windows path text are pinned. Two old assertions contradicted their own intended decoded values and are corrected. | +| #275 | A private min-heap preserves Kahn's minimum authored index across the entire ready set. An independent full-sort oracle compares complete plans/maps over generated and named graphs, derived edges and cycles. Empty workflow admission remains refused. | +| #258/#259 | Four lookup-local parameters become `modelId`, without renaming request/payload fields. Twelve live unpinned comparator expressions use the existing UTF-16 code-unit helper, including the verified DB money/coverage model union. The private preparation note overcounted these as thirteen. Case folding, groups, ties, raw historical model names and MCP set/ordered-vector distinctions remain. The two explicitly English LLM display comparisons are unchanged. | +| #93/#97/#214/#87 | Descriptive dispatch scope cannot widen immutable host filesystem authority; stale egress comments describe conditional host availability. Workflow version is an optional string with Semver recommended, not validated. System append remains literal authored text with existing reference/dependency/secret scans; Proposed ADR-0060 is not activated. | +| #308 | Actual default/approval-enabled sessions and workflow-agent execution all retain `gateApproved:false`, refusing before confirm, journal prepare and process work. The message offers manual review/commit. A true-gate registry positive is explicitly synthetic, not an invented shipping workflow approval route. | +| #79/#75/#78/#216/#274 | Equivalent abort/backoff outcomes are combined. Existing timeout/scheduling and tiered journal behaviour are certified by their owning suites, not rewritten. Public `success` remains unchanged. Exported bus `validate:false` remains compatible, with actually invalid payloads and mandatory raw correlation/counter/clock/listener controls. | + +Twenty-one new test files contribute 393 cases; actual git-denial controls and the +split pre/post-preparation cancellation case add four cases in existing owners. +Author baselines record 115 expected bound/metadata failures, alias/replay/digest +failures, eight quoted-decoding failures, six direct gate failures, the actual +engine pause race and forty prompt-cancellation failures. Fixture/API mistakes +were corrected separately; they are not attributed to production. DAG, literal +append and bus controls certify existing behaviour rather than claim old defects. + +The broad workspace command passes: **629 files / 14,281 passing tests / 20 +established skips**, plus all workspace lint/typecheck tasks. Standard Turbo cache +reuse for unchanged inputs is retained; this is not a claim that every test was +freshly executed. The two initial broad failures were stale metadata and the +superseded pre-aborted preparation expectation; both are corrected without +weakening the actual range/provenance/cancellation controls. + +Thirty-one selected local causal cohorts retain **93 zero-unhandled-error runner +receipts**: fixed passes, a named expected assertion fails on mechanism removal, +and exact-byte restoration passes. Each source/mutant hash and expected collected +count is checked. Seven completed cohorts and the remaining twenty-four are +retained separately: an initial eighth-cohort witness-label mismatch stopped the +first capture after restoring source; that incomplete attempt is not accepted. +Four secondary test-local errors are retained: three replay expectations whose promises +incorrectly resolve, plus the DB poisoned-comparator error. They are distinct from zero +runner-unhandled errors. The priority mutation checks authored-minimum semantics, +not a benchmark; schema/guard removals do not certify unrelated mechanisms. +The existing source-owned reporter is pinned; these selected probes do not claim +whole-workspace provenance or remote/native-platform acceptance. + +Six forced builds pass without cache reuse before native author tests and again after +source restoration. Formatting covers all 46 changed TypeScript files; seam/dependency +fences, six-workspace test isolation and relative-link/append-only checks pass. +The document scan checks 74 Markdown files / 3,516 relative links and 31 pre-existing +ADRs. These gates are separate from the review rounds below. + +Canonical changes land only in the owning tool, YAML, runner and event contracts. +Detailed baseline, causal and review records remain local and Git-ignored. There +is no new dependency, engine platform import, error taxonomy, grant feature, ADR, +provider call, credential/billing action or external publication. Final full CI, +ordinary/enforced coverage, platform and whole-wave publication remain Step 19. From fd54e22a776f13f92b520b42737928543cfe9532 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 06:29:11 +0300 Subject: [PATCH 056/222] docs(docs): correct registry boundary and evidence attribution Reconcile the actual upstream summary producer and profile-specific egress availability, preserve the string-seam limitation, and record honest baseline retention and first nonclean review scope. Refs: Wave 3 Step 5; ADR-0037; ADR-0057 Co-Authored-By: Codex --- docs/reference/shared-core/tool-registry.md | 6 ++-- .../phase-2.5.5-hardening-and-remediation.md | 32 +++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index fd36a07f..18668798 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -403,11 +403,11 @@ code-unit ordering, independent of ambient locale. ## 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 +distinct from the *event* `outputSummary`, which the registry bounding stage produces before bus stamping/delivery ([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 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). +- **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. A **stream-and-incrementally-spill-at-the-boundary** guarantee (the engine never holding the full bytes) requires a **host-capability contract**; it is not provided by this string-returning seam. Hosts own their source limits, and network availability depends on the surface/profile wiring. The shipping CLI wires egress in `chat-read-write`; its `chat-read-only` and `workflow-read-write` profiles omit that arm. This does not claim streamed engine results or shipping workflow HTTP. 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 @@ -444,7 +444,7 @@ per-call-site discipline ([security-review.md §Prompt-injection](../../standard | `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) | +| `http_request` | `host.egress` (`http`) | HTTPS-only + exact-FQDN `allowedDomains` + SSRF at the wired host; availability is profile-dependent | | `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) | diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index b9f0e688..b9b6e38d 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4708,11 +4708,15 @@ acceptance of Step 5 or the whole wave. Twenty-one new test files contribute 393 cases; actual git-denial controls and the split pre/post-preparation cancellation case add four cases in existing owners. -Author baselines record 115 expected bound/metadata failures, alias/replay/digest +Author-reported baselines describe 115 expected bound/metadata failures, alias/replay/digest failures, eight quoted-decoding failures, six direct gate failures, the actual engine pause race and forty prompt-cancellation failures. Fixture/API mistakes were corrected separately; they are not attributed to production. DAG, literal -append and bus controls certify existing behaviour rather than claim old defects. +append and bus controls certify existing behaviour rather than claim old defects. Original raw baseline +outputs were tool-response observations, not retained physical runner reports. The +retrospective ignored author note discloses that limit; fresh broad/causal records +below are independently retained. Later comparator-observer test refinements are +attributed to final source rather than earlier author hashes. The broad workspace command passes: **629 files / 14,281 passing tests / 20 established skips**, plus all workspace lint/typecheck tasks. Standard Turbo cache @@ -4745,3 +4749,27 @@ Detailed baseline, causal and review records remain local and Git-ignored. There is no new dependency, engine platform import, error taxonomy, grant feature, ADR, provider call, credential/billing action or external publication. Final full CI, ordinary/enforced coverage, platform and whole-wave publication remain Step 19. + + +### Wave 3 Step 5 first review correction — 2026-10-16 + +The first complete cumulative correctness review at `25cac33a` covers all 52 paths / +124 hunks and freshly passes 35 files / 942 tests, direct four-package typechecks +and all 46 changed TypeScript lint targets. Its result is **nonclean**: one verified +Medium canonical-documentation finding, with no production defect. It is not either +of the required clean rounds. + +The parent reads the full report and confirms all three stale statements: summaries +are produced by registry bounding, not truncated in the bus, and network availability +is profile-dependent rather than waiting for historical 1.AE. The canonical registry +reference now describes the actual chat-read-write wiring, retains the read-only and +workflow omissions, and states the string-seam/spill limitation accurately. No runtime +capability or workflow network feature is added. The author checkpoint also makes raw +baseline-retention and later test-observer attribution explicit. + +The separate independent security sitting at `25cac33a` passes 18 files / 680 tests, +zero skips, with no verified finding. Its evidence is source-pinned; this correction +changes only two documents. All production/test bytes and the 31 causal source hashes +remain unchanged. Existing broad/build evidence keeps its original source and cache +scope. Relative-link/append-only and diff checks are rerun for this document correction; +two NEW independent complete cumulative clean rounds remain required after this fix. From 952115d3506b0078faefa10dee8dfb4236e19c1c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 06:55:13 +0300 Subject: [PATCH 057/222] docs(docs): accept wave 3 tool and engine tail increment Join both new clean complete cumulative correctness rounds after the registry prose correction with the separate security sitting. Preserve source, cache, baseline and physical-causal evidence attribution. Record Step 5 acceptance and advance the approved queue to Step 6. Refs: Wave 3 Step 5, ADR-0029 Co-Authored-By: Codex --- AGENTS.md | 6 ++- CLAUDE.md | 6 ++- docs/roadmap/current.md | 7 ++- .../phase-2.5.5-hardening-and-remediation.md | 50 +++++++++++++++++++ docs/standards/security-review.md | 21 ++++++++ 5 files changed, 84 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8a3a0926..11a78d63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,8 +84,10 @@ Its serialized multi-file Step 1 is independently accepted on `development` following its historical-reader corrections. Step 3 (ADR-0113 secret provenance) is independently accepted after two NEW complete cumulative clean rounds and a separate clean security sitting. Step 4 (error/envelope substrate) is independently -accepted after two NEW complete cumulative clean rounds; Step 5 (tools and engine -tails) is active. +accepted after two NEW complete cumulative clean rounds. Step 5 (tools and engine +tails) is independently accepted after two NEW complete cumulative clean rounds +following its registry-documentation correction and a separate clean security +sitting. Step 6 (pure process/discovery substrate) is active. Later implementation and whole-wave acceptance remain staged in the canonical roadmap. diff --git a/CLAUDE.md b/CLAUDE.md index b01a23ff..6edc0e3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,8 +173,10 @@ Its serialized multi-file Step 1 is independently accepted on `development` following its historical-reader corrections. Step 3 (ADR-0113 secret provenance) is independently accepted after two NEW complete cumulative clean rounds and a separate clean security sitting. Step 4 (error/envelope substrate) is independently -accepted after two NEW complete cumulative clean rounds; Step 5 (tools and engine -tails) is active. +accepted after two NEW complete cumulative clean rounds. Step 5 (tools and engine +tails) is independently accepted after two NEW complete cumulative clean rounds +following its registry-documentation correction and a separate clean security +sitting. Step 6 (pure process/discovery substrate) is active. Later implementation and whole-wave acceptance remain staged in the canonical roadmap. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index e99bb02c..0a75b6fc 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1117,8 +1117,11 @@ with two NEW complete clean cumulative rounds after its historical-reader correc [Step 3 is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-3-acceptance--2026-10-16) after two NEW complete cumulative clean rounds and a separate clean security sitting. [Step 4 is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-4-acceptance--2026-10-16) -after two NEW complete cumulative clean rounds. Step 5 (tools and engine tails) -is active. The +after two NEW complete cumulative clean rounds. +[Step 5 is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-5-acceptance--2026-10-16) +after two NEW complete cumulative clean rounds following its registry-documentation +correction and a separate clean security sitting. Step 6 (pure process/discovery +substrate) is active. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index b9b6e38d..82344379 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4773,3 +4773,53 @@ changes only two documents. All production/test bytes and the 31 causal source h remain unchanged. Existing broad/build evidence keeps its original source and cache scope. Relative-link/append-only and diff checks are rerun for this document correction; two NEW independent complete cumulative clean rounds remain required after this fix. + + +### Wave 3 Step 5 acceptance — 2026-10-16 + +Step 5 is independently accepted at +`fd54e22a776f13f92b520b42737928543cfe9532`. Two NEW complete cumulative clean +reviews after the final documentation correction cover all **52 paths / 127 +hunks**, including all 21 new files, nineteen production paths, six old-test +owners and six documents. Reviewers are gpt-6-astra/high (**35 files / 942 passing +tests**) and gpt-6.1-sol/xhigh (**37 files / 987 tests**), both with zero skips. +The parent reads both full reports. The original one-Medium sitting remains +nonclean history; its three stale registry statements are corrected, with no +verified scoped finding left open. + +The separate clean security sitting at `25cac33a` passes **18 files / 680 tests**. +Its subsequent documentation-only revalidation through the accepted head is +clean: all 46 production/test TypeScript paths and 31 causal source hashes are +unchanged, with no new test execution inferred. The [security register](../../standards/security-review.md#sitting-wave-3-tool-admission-and-alias-safe-projections-2026-10-16) +records its scope and limits. Both correctness reviewers independently audit +all **31 accepted causal cohorts / 93 zero-unhandled runner receipts**, source +and reconstructed mutant hashes, expected assertion witnesses and exact-byte +restoration. The incomplete initial eighth capture, four secondary test-local +errors and retrospective author baseline limits remain explicitly attributed. + +All Step 5 queues in the implementation table above now have independent scoped +acceptance. Actual effective-input bounds, alias-safe event/journal projections, +ordinal identifier consumers, final-await cancellation and authored-minimum DAG +semantics are pinned. Existing timeout, journal, literal-append and bus compatibility +controls are certified without inventing old defects. Actual sessions and workflow +agents retain Git gate refusal; the approved registry positive is synthetic. +Canonical contracts preserve conditional host availability and intentional +projection/trust limits rather than claim new authority or lossless secrecy. + +Broad author evidence remains **629 files / 14,281 passing tests / 20 established +skips**, all workspace lint/typechecks, six forced builds before native consumers +and again after restoration, source formatting, isolation, fences and document +checks. Normal test-cache reuse and original source attribution remain explicit. +Reviewer selections overlap and are not added to author totals. This metadata +landing claims no new source execution, full CI, ordinary/enforced coverage, +native-platform acceptance, whole-wave acceptance or merge. Detailed reports +remain local and Git-ignored. + +Step 6 proceeds automatically under approved ADR-0110/0111: pure early flags, +closed code/origin and observation contracts, one descriptor authority, compatible +legacy/model and separate CLI discovery projections, source-verified classification +inventory and creation-specific invocation ownership. Its read-only designs are +preparation, not implementation acceptance. Real fatal/native integration and +JSON-help behaviour retain their later steps. No new ADR, user decision, provider +capture, credential, billing or external operation is pending; Step 19 retains +whole-wave validation and publication. diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index df883ed2..74ecdcc5 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -541,6 +541,27 @@ content retain their documented limits; no universal content scrubbing, history rewrite or attempted-access detector is claimed. Exact contracts remain in the [expression specification](../reference/shared-core/expression-sandbox-spec.md#secret-context-boundary). +### Sitting: Wave 3 tool admission and alias-safe projections (2026-10-16) + +A NEW independent cumulative sitting is **clean** at `25cac33a`. Fresh execution +passes **18 files / 680 tests**, zero skips. The parent reads the full report and +its subsequent two-document revalidation through `fd54e22a`; all 46 production/test +TypeScript paths, including nineteen production files, retain identical bytes. +The revalidation executes no new tests. The [Step 5 acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-5-acceptance--2026-10-16) +joins this sitting with the two separate new complete correctness rounds. + +The sitting traces effective UTF-8 argument admission before approval/effect/host +work, all four ancestor-path guards, secret/media projections, actual reference +replay and native SQLite digest distinctions, immutable filesystem authority, +conditional HTTP host wiring, actual Git gate denial and cancellation tails. +It independently inspects all 31 retained local causal source hashes; it does not +claim new mutation execution. MCP private deduplication remains distinct from +persisted consent authority. Deliberate sanitized-digest equality, string-result +in-memory limits, optional unproduced tool provenance and arbitrary content retain +their documented qualifications. No universal secret scrubbing, OS isolation, +new network authority, provider/keychain operation, paid request or platform +recertification is claimed. Full-wave publication remains Step 19. + ## Sandbox and tool policy (`run_command`, node tools, secret inputs) This is the **binding home** for the tool-policy rules; the rationale is From af80dca2a880129bae6e9f9936c30bbaa32d3b1c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 07:50:15 +0300 Subject: [PATCH 058/222] refactor(shared): expose pure error and filesystem limit leaves Preserve existing constructor/constant identity while allowing early CLI contracts to avoid the schema barrel. Add direct compatibility controls. Refs: ADR-0110, ADR-0111; Wave 3 Step 6 Co-Authored-By: Codex --- packages/shared/package.json | 10 ++++++++ packages/shared/src/config.ts | 5 ++-- packages/shared/src/fs-root-limits.test.ts | 27 ++++++++++++++++++++++ packages/shared/src/fs-root-limits.ts | 2 ++ 4 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/fs-root-limits.test.ts create mode 100644 packages/shared/src/fs-root-limits.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index c1bb8efe..3fa2f561 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -9,6 +9,16 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" + }, + "./relavium-error": { + "types": "./dist/relavium-error.d.ts", + "import": "./dist/relavium-error.js", + "default": "./dist/relavium-error.js" + }, + "./fs-root-limits": { + "types": "./dist/fs-root-limits.d.ts", + "import": "./dist/fs-root-limits.js", + "default": "./dist/fs-root-limits.js" } }, "main": "./dist/index.js", diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 4cd96cf9..33bcaaa0 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -3,6 +3,8 @@ import { z } from 'zod'; import { McpConnectTimeoutSchema } from './agent.js'; import { URL_HAS_CREDENTIALS, nonEmptyString, nonNegativeInt, positiveInt } from './common.js'; import { validateDeclaredEnv } from './declared-env.js'; +import { FS_ROOT_LIMITS } from './fs-root-limits.js'; +export { FS_ROOT_LIMITS } from './fs-root-limits.js'; import { FS_SCOPE_TIERS, LLM_PROVIDERS, @@ -21,9 +23,6 @@ export const UpdateChannelSchema = z.enum(['stable', 'beta']); /** Filesystem permission tier (built-in-tools.md) — derived from the shared tier vocabulary. */ export const FsScopeSchema = z.enum(FS_SCOPE_TIERS); -/** Shared admission/selector bounds (ADR-0105); these limits never grant a path. */ -export const FS_ROOT_LIMITS = Object.freeze({ count: 16, characters: 4096 }); - /** A registered network MCP server must use http(s) (`http`) or ws(s) (`websocket`) — never file:/javascript:/etc. */ const SAFE_HTTP_URL = /^https?:\/\//i; const SAFE_WS_URL = /^wss?:\/\//i; diff --git a/packages/shared/src/fs-root-limits.test.ts b/packages/shared/src/fs-root-limits.test.ts new file mode 100644 index 00000000..885c14b0 --- /dev/null +++ b/packages/shared/src/fs-root-limits.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { FS_ROOT_LIMITS as compatibilityLimits, ProjectConfigSchema } from './config.js'; +import { FS_ROOT_LIMITS } from './fs-root-limits.js'; + +describe('pure filesystem limit leaf compatibility', () => { + it('preserves the exact frozen config re-export identity', () => { + expect(compatibilityLimits).toBe(FS_ROOT_LIMITS); + expect(Object.isFrozen(FS_ROOT_LIMITS)).toBe(true); + }); + + it('preserves existing selector admission at the real schema boundary', () => { + const selectors = Array.from({ length: 16 }, (_, index) => `root-${index}`); + expect( + ProjectConfigSchema.parse({ chat: { extra_roots: selectors } }).chat?.extra_roots, + ).toEqual(selectors); + expect( + ProjectConfigSchema.safeParse({ chat: { extra_roots: [...selectors, 'overflow'] } }).success, + ).toBe(false); + expect( + ProjectConfigSchema.safeParse({ chat: { extra_roots: ['x'.repeat(4096)] } }).success, + ).toBe(true); + expect( + ProjectConfigSchema.safeParse({ chat: { extra_roots: ['x'.repeat(4097)] } }).success, + ).toBe(false); + }); +}); diff --git a/packages/shared/src/fs-root-limits.ts b/packages/shared/src/fs-root-limits.ts new file mode 100644 index 00000000..01d1cc0e --- /dev/null +++ b/packages/shared/src/fs-root-limits.ts @@ -0,0 +1,2 @@ +/** Shared admission/selector bounds (ADR-0105). Syntax limits never grant a path. */ +export const FS_ROOT_LIMITS = Object.freeze({ count: 16, characters: 4096 }); From e0289de509d132eb33949d3af1f10518b7b51332 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 07:50:16 +0300 Subject: [PATCH 059/222] refactor(cli): add pure process and command descriptor substrate Derive current Commander grammar and compatible model discovery from one enriched authority. Add closed code/origin and sticky exit contracts plus creation-owned lifetime coordination without claiming native fatal or JSON-help integration. Refs: ADR-0110, ADR-0111; Wave 3 Step 6 Co-Authored-By: Codex --- .../src/commands/descriptor-actions.test.ts | 482 ++++++ apps/cli/src/commands/descriptors.test.ts | 198 +++ apps/cli/src/commands/descriptors.ts | 1308 +++++++++++++++++ apps/cli/src/commands/discovery.test.ts | 240 +++ apps/cli/src/commands/discovery.ts | 103 ++ apps/cli/src/commands/dispatch.ts | 43 +- .../fixtures/legacy-command-manifest.json | 487 ++++++ apps/cli/src/commands/input.ts | 22 + apps/cli/src/commands/manifest.test.ts | 2 +- apps/cli/src/commands/manifest.ts | 471 +----- .../cli/src/commands/specs-forwarding.test.ts | 5 +- apps/cli/src/commands/specs.ts | 905 ++---------- apps/cli/src/process/cli-error.test.ts | 144 ++ apps/cli/src/process/cli-error.ts | 99 ++ .../cli/src/process/diagnostic-policy.test.ts | 99 ++ apps/cli/src/process/diagnostic-policy.ts | 51 + apps/cli/src/process/errors.test.ts | 27 + apps/cli/src/process/errors.ts | 83 +- apps/cli/src/process/exit-codes.ts | 2 + .../cli/src/process/exit-observations.test.ts | 197 +++ apps/cli/src/process/exit-observations.ts | 98 ++ .../src/process/invocation-registry.test.ts | 891 +++++++++++ apps/cli/src/process/invocation-registry.ts | 498 +++++++ apps/cli/src/process/options-lexical.test.ts | 233 +++ apps/cli/src/process/options.ts | 178 ++- .../src/process/pure-substrate-graph.test.ts | 230 +++ apps/cli/src/program.ts | 24 +- apps/cli/src/run.ts | 2 + 28 files changed, 5686 insertions(+), 1436 deletions(-) create mode 100644 apps/cli/src/commands/descriptor-actions.test.ts create mode 100644 apps/cli/src/commands/descriptors.test.ts create mode 100644 apps/cli/src/commands/descriptors.ts create mode 100644 apps/cli/src/commands/discovery.test.ts create mode 100644 apps/cli/src/commands/discovery.ts create mode 100644 apps/cli/src/commands/fixtures/legacy-command-manifest.json create mode 100644 apps/cli/src/commands/input.ts create mode 100644 apps/cli/src/process/cli-error.test.ts create mode 100644 apps/cli/src/process/cli-error.ts create mode 100644 apps/cli/src/process/diagnostic-policy.test.ts create mode 100644 apps/cli/src/process/diagnostic-policy.ts create mode 100644 apps/cli/src/process/exit-observations.test.ts create mode 100644 apps/cli/src/process/exit-observations.ts create mode 100644 apps/cli/src/process/invocation-registry.test.ts create mode 100644 apps/cli/src/process/invocation-registry.ts create mode 100644 apps/cli/src/process/options-lexical.test.ts create mode 100644 apps/cli/src/process/pure-substrate-graph.test.ts diff --git a/apps/cli/src/commands/descriptor-actions.test.ts b/apps/cli/src/commands/descriptor-actions.test.ts new file mode 100644 index 00000000..fff1ad72 --- /dev/null +++ b/apps/cli/src/commands/descriptor-actions.test.ts @@ -0,0 +1,482 @@ +import { Readable } from 'node:stream'; +import type { Command } from 'commander'; +import { describe, expect, it, vi } from 'vitest'; + +import { buildProgram } from '../program.js'; +import type { ExitCode } from '../process/exit-codes.js'; +import type { CliIo } from '../process/io.js'; +import type { CommandInput } from './dispatch.js'; + +function capture(): { io: CliIo; out: string[]; err: string[] } { + const out: string[] = []; + const err: string[] = []; + return { + out, + err, + io: { + writeOut: (text) => { + out.push(text); + }, + writeErr: (text) => { + err.push(text); + }, + writeErrAcknowledged: (text) => { + err.push(text); + return Promise.resolve(); + }, + env: {}, + stdoutIsTty: false, + stdinIsTty: false, + stdin: Readable.from([]), + }, + }; +} + +type Options = Readonly>; +interface Case { + readonly id: string; + readonly argv: readonly string[]; + readonly positionals: readonly string[]; + readonly options: Options; +} + +/** Independent argv/payload examples, not generated from the registry under test. */ +const cases: readonly Case[] = [ + { + id: 'run', + argv: [ + 'run', + 'wf', + '--input', + 'n=-1', + 'text=a=b', + '--input', + 'third=c', + '--secret-inputs-stdin', + '--allow-mcp-stdio', + 'd1', + 'd2', + '--allow-mcp-stdio', + 'd3', + ], + positionals: ['wf'], + options: { + input: ['n=-1', 'text=a=b', 'third=c'], + secretInputsStdin: true, + allowMcpStdio: ['d1', 'd2', 'd3'], + }, + }, + { + id: 'chat', + argv: ['chat', '--agent', 'helper'], + positionals: [], + options: { agent: 'helper' }, + }, + { id: 'chat-resume', argv: ['chat-resume', 's1'], positionals: ['s1'], options: {} }, + { id: 'chat-list', argv: ['chat-list'], positionals: [], options: {} }, + { + id: 'chat-export', + argv: ['chat-export', 's1', '--out', 'export.yaml', '--force'], + positionals: ['s1'], + options: { out: 'export.yaml', force: true }, + }, + { id: 'create', argv: ['create', '--force'], positionals: [], options: { force: true } }, + { + id: 'export', + argv: ['export', 'wf', '--out', 'copy.yaml', '--force'], + positionals: ['wf'], + options: { out: 'copy.yaml', force: true }, + }, + { + id: 'import', + argv: ['import', 'source.yaml', '--force'], + positionals: ['source.yaml'], + options: { force: true }, + }, + { + id: 'agent.run', + argv: [ + 'agent', + 'run', + 'helper', + '--input', + 'x=1', + 'y=a=b', + '--fixture', + 'cassette.json', + '--allow-mcp-stdio', + 'd1', + 'd2', + ], + positionals: ['helper'], + options: { input: ['x=1', 'y=a=b'], fixture: 'cassette.json', allowMcpStdio: ['d1', 'd2'] }, + }, + { + id: 'gate', + argv: [ + 'gate', + 'r1', + '--approve', + '--reject', + '--comment', + 'reviewed', + '--input', + 'payload', + '--gate', + 'g1', + '--secret-stdin', + '--allow-mcp-stdio', + 'd1', + 'd2', + ], + positionals: ['r1'], + options: { + approve: true, + reject: true, + comment: 'reviewed', + input: 'payload', + gate: 'g1', + secretStdin: true, + allowMcpStdio: ['d1', 'd2'], + }, + }, + { + id: 'budget.resume', + argv: [ + 'budget', + 'resume', + 'r1', + '--approve-amount', + '0', + '--abort', + '--gate', + 'b1', + '--secret-stdin', + '--allow-mcp-stdio', + 'd1', + 'd2', + ], + positionals: ['r1'], + options: { + approveAmount: '0', + abort: true, + gate: 'b1', + secretStdin: true, + allowMcpStdio: ['d1', 'd2'], + }, + }, + { id: 'gate.list', argv: ['gate', 'list', 'r1'], positionals: ['r1'], options: {} }, + { id: 'list', argv: ['list', '--agents'], positionals: [], options: { agents: true } }, + { id: 'logs', argv: ['logs', 'r1'], positionals: ['r1'], options: {} }, + { id: 'status', argv: ['status'], positionals: [], options: {} }, + { id: 'models', argv: ['models'], positionals: [], options: {} }, + { + id: 'models.refresh', + argv: ['models', 'refresh', '--providers', '--catalog'], + positionals: [], + options: { providers: true, catalog: true }, + }, + { + id: 'models.pricing', + argv: [ + 'models', + 'pricing', + 'm1', + '--provider', + 'openai', + '--input', + '1.25', + '--output', + '2.5', + '--cached', + '0.25', + '--image', + '0.1', + '--audio', + '0.2', + '--video', + '0.3', + '--clear', + ], + positionals: ['m1'], + options: { + provider: 'openai', + input: '1.25', + output: '2.5', + cached: '0.25', + image: '0.1', + audio: '0.2', + video: '0.3', + clear: true, + }, + }, + { + id: 'provider.list', + argv: ['provider', 'list', '--verify'], + positionals: [], + options: { verify: true }, + }, + { + id: 'provider.add', + argv: [ + 'provider', + 'add', + 'openai', + '--base-url', + 'https://proxy.example/v1', + '--pricing-url', + 'https://prices.example/', + ], + positionals: ['openai'], + options: { baseUrl: 'https://proxy.example/v1', pricingUrl: 'https://prices.example/' }, + }, + { + id: 'provider.set-key', + argv: ['provider', 'set-key', 'openai'], + positionals: ['openai'], + options: {}, + }, + { + id: 'media.prune-temporaries', + argv: ['media', 'prune-temporaries', '--legacy-offline', '--force'], + positionals: [], + options: { legacyOffline: true, force: true }, + }, + { + id: 'provider.remove-key', + argv: ['provider', 'remove-key', 'openai'], + positionals: ['openai'], + options: {}, + }, + { + id: 'provider.test', + argv: ['provider', 'test', 'openai', '--model', 'cheap'], + positionals: ['openai'], + options: { model: 'cheap' }, + }, +]; + +function walk(command: Command, parent = ''): { path: string; command: Command }[] { + return command.commands.flatMap((child) => { + const path = parent === '' ? child.name() : `${parent}.${child.name()}`; + return [{ path, command: child }, ...walk(child, path)]; + }); +} + +describe('descriptor-derived real Commander actions (compatibility grammar only)', () => { + it.each(cases)( + '$id parses and forwards every present binding and awaits its exit', + async (testCase) => { + const { io } = capture(); + const result: { exitCode?: ExitCode } = {}; + const global = { + json: false, + color: false, + cwd: '/test', + configPath: undefined, + verbosity: 'normal' as const, + }; + const execute = vi.fn<(id: string, input: CommandInput, ctx: unknown) => Promise>( + () => Promise.resolve(3), + ); + const context = { io, global, result }; + const program = buildProgram(io, { context, execute }); + await program.parseAsync(['node', 'relavium', ...testCase.argv]); + expect(execute).toHaveBeenCalledTimes(1); + const call = execute.mock.calls[0]; + if (call === undefined) throw new Error('missing action callback'); + expect(call[0]).toBe(testCase.id); + expect(call[1].positionals).toEqual(testCase.positionals); + expect( + Object.fromEntries( + Object.entries(call[1].options).filter(([, value]) => value !== undefined), + ), + ).toEqual(testCase.options); + expect(call[2]).toBe(context); + expect(result.exitCode).toBe(3); + }, + ); + + it('retains all actual command paths and mandatory/variadic grammar independently of model schema', () => { + const nodes = walk(buildProgram(capture().io)); + expect(nodes.map((node) => node.path).sort()).toEqual( + [ + ...cases.map((testCase) => testCase.id), + 'agent', + 'budget', + 'provider', + 'media', + 'init', + ].sort(), + ); + for (const testCase of cases) { + const command = nodes.find((node) => node.path === testCase.id)?.command; + if (command === undefined) throw new Error(`missing command ${testCase.id}`); + expect(command.options.map((option) => option.long).sort()).toEqual( + [...new Set(testCase.argv.filter((token) => token.startsWith('--')))].sort(), + ); + expect( + command.options.map((option) => [option.attributeName(), option.required]).sort(), + ).toEqual( + Object.entries(testCase.options) + .map(([key, value]) => [key, typeof value !== 'boolean']) + .sort(), + ); + expect( + command.registeredArguments.map((arg) => [arg.name(), arg.required, arg.variadic]), + ).toEqual( + testCase.positionals.map((_value, index) => { + const names: Readonly> = { + run: 'workflow', + 'chat-resume': 'sessionId', + 'chat-export': 'sessionId', + export: 'id', + import: 'path', + 'agent.run': 'agent', + gate: 'runId', + 'gate.list': 'runId', + 'budget.resume': 'runId', + logs: 'runId', + 'models.pricing': 'model', + 'provider.add': 'name', + 'provider.set-key': 'name', + 'provider.remove-key': 'name', + 'provider.test': 'name', + }; + expect(index).toBe(0); + return [names[testCase.id], !['gate', 'gate.list'].includes(testCase.id), false]; + }), + ); + } + const options = nodes.flatMap(({ path, command }) => + command.options.map((option) => ({ + path, + binding: option.attributeName(), + required: option.required, + optional: option.optional, + mandatory: option.mandatory, + variadic: option.variadic, + negate: option.negate, + hasDefault: option.defaultValue !== undefined, + })), + ); + expect( + options.filter((option) => option.mandatory).map((option) => [option.path, option.binding]), + ).toEqual([['models.pricing', 'provider']]); + expect( + options.filter((option) => option.variadic).map((option) => [option.path, option.binding]), + ).toEqual([ + ['run', 'input'], + ['run', 'allowMcpStdio'], + ['agent.run', 'input'], + ['agent.run', 'allowMcpStdio'], + ['gate', 'allowMcpStdio'], + ['budget.resume', 'allowMcpStdio'], + ]); + expect( + options.filter((option) => option.optional || option.negate || option.hasDefault), + ).toEqual([]); + expect( + nodes + .find((node) => node.path === 'gate') + ?.command.registeredArguments.map((arg) => [arg.name(), arg.required, arg.variadic]), + ).toEqual([['runId', false, false]]); + expect( + nodes + .find((node) => node.path === 'gate.list') + ?.command.registeredArguments.map((arg) => [arg.name(), arg.required, arg.variadic]), + ).toEqual([['runId', false, false]]); + }); + + it.each( + [ + ['models', 'pricing', 'm1', '--input', '1'], + ['run'], + ['logs'], + ['agent', 'run'], + ['budget', 'resume'], + ['run', 'wf', 'extra'], + ['media', 'prune-temporaries', 'extra'], + ].map((argv) => ({ argv })), + )( + 'refuses missing required grammar/excess arguments before dispatch: $argv', + async ({ argv }) => { + const execute = vi.fn(() => Promise.resolve(0 as const)); + const { io } = capture(); + const program = buildProgram(io, { + context: { + io, + global: { + json: false, + color: false, + cwd: '/test', + configPath: undefined, + verbosity: 'normal', + }, + result: {}, + }, + execute, + }); + await expect(program.parseAsync(['node', 'relavium', ...argv])).rejects.toBeDefined(); + expect(execute).not.toHaveBeenCalled(); + }, + ); + + it('keeps the init stub as an explicit unavailable action', async () => { + const execute = vi.fn(() => Promise.resolve(0 as const)); + await expect( + buildProgram(capture().io, { execute }).parseAsync(['node', 'relavium', 'init']), + ).rejects.toMatchObject({ code: 'not_implemented', exitCode: 2 }); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each( + [ + ['gate', '--approve'], + ['gate', 'list'], + ].map((argv) => ({ argv })), + )('omits absent optional runId bindings: $argv', async ({ argv }) => { + const execute = vi.fn<(id: string, input: CommandInput, ctx: unknown) => Promise>( + () => Promise.resolve(0), + ); + const { io } = capture(); + await buildProgram(io, { + context: { + io, + global: { + json: false, + color: false, + cwd: '/test', + configPath: undefined, + verbosity: 'normal', + }, + result: {}, + }, + execute, + }).parseAsync(['node', 'relavium', ...argv]); + expect(execute).toHaveBeenCalledTimes(1); + expect(execute.mock.calls[0]?.[1].positionals).toEqual([]); + expect(execute.mock.calls[0]?.[0]).toBe(argv[1] === 'list' ? 'gate.list' : 'gate'); + }); + + it('refuses a missing executor wiring with fixed infrastructure classification', async () => { + const { io } = capture(); + const program = buildProgram(io, { + context: { + io, + global: { + json: false, + color: false, + cwd: '/test', + configPath: undefined, + verbosity: 'normal', + }, + result: {}, + }, + }); + await expect(program.parseAsync(['node', 'relavium', 'status'])).rejects.toMatchObject({ + code: 'cli_internal', + exitCode: 9, + }); + }); +}); diff --git a/apps/cli/src/commands/descriptors.test.ts b/apps/cli/src/commands/descriptors.test.ts new file mode 100644 index 00000000..cf4d4da0 --- /dev/null +++ b/apps/cli/src/commands/descriptors.test.ts @@ -0,0 +1,198 @@ +import { RelaviumError } from '@relavium/shared'; +import { RelaviumError as LeafRelaviumError } from '@relavium/shared/relavium-error'; +import { describe, expect, it } from 'vitest'; + +import { CliError } from '../process/cli-error.js'; +import { + assertDescriptorRegistry, + COMMAND_DESCRIPTORS, + compatibilityOptions, + compatibilityPath, + GLOBAL_OPTION_DESCRIPTORS, + legacyManifestEntries, + type CommandDescriptor, +} from './descriptors.js'; +import { DISPATCHABLE_COMMAND_IDS } from './dispatch.js'; +import legacyFixture from './fixtures/legacy-command-manifest.json' with { type: 'json' }; +import { COMMAND_MANIFEST, CommandManifestEntrySchema } from './manifest.js'; + +function descriptor(id: string): CommandDescriptor { + const found = COMMAND_DESCRIPTORS.find((entry) => entry.executorId === id); + if (found === undefined) throw new Error(`missing test descriptor: ${id}`); + return found; +} + +function expectDeepFrozen(value: unknown): void { + if (typeof value !== 'object' || value === null) return; + expect(Object.isFrozen(value)).toBe(true); + for (const child of Object.values(value)) expectDeepFrozen(child); +} + +describe('descriptor authority and legacy compatibility (ADR-0110 Step 6)', () => { + it('retains all 24 literal pre-refactor model entries, order, annotations and strict schemas', () => { + expect(legacyFixture).toHaveLength(24); + expect(legacyManifestEntries()).toEqual(legacyFixture); + expect(COMMAND_MANIFEST).toEqual(legacyFixture); + for (const entry of COMMAND_MANIFEST) { + expect(CommandManifestEntrySchema.parse(entry)).toEqual(entry); + expect(CommandManifestEntrySchema.safeParse({ ...entry, path: ['leaked'] }).success).toBe( + false, + ); + } + expect(COMMAND_MANIFEST.find((entry) => entry.id === 'gate')?.args?.[0]).not.toHaveProperty( + 'required', + ); + expect( + COMMAND_MANIFEST.find((entry) => entry.id === 'models.pricing')?.args?.[1], + ).toMatchObject({ name: 'provider', required: true }); + expect(COMMAND_MANIFEST.find((entry) => entry.id === 'provider.remove-key')?.args).toHaveLength( + 1, + ); + expect(COMMAND_MANIFEST.filter((entry) => entry.modeScope !== undefined)).toEqual([]); + expectDeepFrozen(COMMAND_MANIFEST); + }); + + it('checks descriptor executor bindings against the actual dispatch table in both directions', () => { + const ids = COMMAND_DESCRIPTORS.flatMap((entry) => + entry.executorId === undefined ? [] : [entry.executorId], + ); + expect(ids).toHaveLength(24); + expect(new Set(ids).size).toBe(ids.length); + expect([...ids].sort()).toEqual([...DISPATCHABLE_COMMAND_IDS].sort()); + expect( + COMMAND_DESCRIPTORS.filter((entry) => entry.executorId === undefined).map( + (entry) => entry.path, + ), + ).toEqual([['agent'], ['budget'], ['provider'], ['media'], ['init']]); + expectDeepFrozen(COMMAND_DESCRIPTORS); + expectDeepFrozen(GLOBAL_OPTION_DESCRIPTORS); + }); + + it('stages canonical chat paths, pricing spellings and key force without changing legacy bindings', () => { + for (const verb of ['resume', 'list', 'export']) { + const entry = descriptor(`chat-${verb}`); + expect(entry.path).toEqual(['chat', verb]); + expect(entry.aliases).toEqual([{ path: [`chat-${verb}`], deprecated: true }]); + expect(compatibilityPath(entry)).toEqual([`chat-${verb}`]); + } + const pricing = descriptor('models.pricing'); + expect( + pricing.options + .filter((option) => ['input', 'output'].includes(option.binding)) + .map((option) => ({ + binding: option.binding, + spellings: option.spellings, + deprecated: option.deprecatedSpellings, + })), + ).toEqual([ + { binding: 'input', spellings: ['--input-price'], deprecated: ['--input'] }, + { binding: 'output', spellings: ['--output-price'], deprecated: ['--output'] }, + ]); + expect( + compatibilityOptions(pricing) + .filter((option) => ['input', 'output'].includes(option.binding)) + .map((option) => option.spellings), + ).toEqual([['--input'], ['--output']]); + expect(descriptor('provider.remove-key').options).toMatchObject([ + { binding: 'force', spellings: ['--force'], compatibility: false, model: false }, + ]); + expect(compatibilityOptions(descriptor('provider.remove-key'))).toEqual([]); + }); + + it('retains the same shared error constructor through the supported pure subpath', () => { + expect(LeafRelaviumError).toBe(RelaviumError); + expect(new CliError('invalid_invocation', 'fixed')).toBeInstanceOf(RelaviumError); + }); + + it.each([ + [ + 'canonical path', + (entry: CommandDescriptor): CommandDescriptor => ({ + ...entry, + executorId: 'other', + legacyModelOrder: 100, + }), + ], + [ + 'alias collision', + (entry: CommandDescriptor): CommandDescriptor => ({ + ...entry, + path: ['other'], + aliases: [{ path: entry.path, deprecated: true }], + executorId: 'other', + legacyModelOrder: 100, + }), + ], + [ + 'executor id', + (entry: CommandDescriptor): CommandDescriptor => ({ + ...entry, + path: ['other'], + legacyModelOrder: 100, + }), + ], + [ + 'model order', + (entry: CommandDescriptor): CommandDescriptor => ({ + ...entry, + path: ['other'], + executorId: 'other', + }), + ], + [ + 'positional binding', + (entry: CommandDescriptor): CommandDescriptor => ({ + ...entry, + positionals: [...entry.positionals, ...entry.positionals], + }), + ], + [ + 'option binding', + (entry: CommandDescriptor): CommandDescriptor => ({ + ...entry, + options: [...entry.options, { ...entry.options[0]!, spellings: ['--another'] }], + }), + ], + [ + 'option spelling', + (entry: CommandDescriptor): CommandDescriptor => ({ + ...entry, + options: [...entry.options, { ...entry.options[0]!, binding: 'another' }], + }), + ], + [ + 'deprecated spelling', + (entry: CommandDescriptor): CommandDescriptor => ({ + ...entry, + options: entry.options.map((option, index) => + index === 0 ? { ...option, deprecatedSpellings: option.spellings } : option, + ), + }), + ], + ])('rejects duplicate %s with the closed infrastructure classification', (_name, mutate) => { + const first = descriptor('run'); + const changed = mutate(first); + const pair = changed.path[0] === 'other' ? [first, changed] : [changed]; + // A same-path replacement needs the original present for the path collision case only. + const entries = _name === 'canonical path' ? [first, changed] : pair; + expect(() => assertDescriptorRegistry(entries)).toThrowError(CliError); + try { + assertDescriptorRegistry(entries); + } catch (error) { + expect(error).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + } + }); + + it('rejects empty paths, missing model order and executors bound to unavailable stubs', () => { + const first = descriptor('run'); + for (const changed of [ + { ...first, path: [] }, + { ...first, path: [''] }, + { ...first, availability: 'not_implemented' as const }, + ]) + expect(() => assertDescriptorRegistry([changed])).toThrowError(CliError); + const { legacyModelOrder: _order, ...missingOrder } = first; + expect(_order).toBe(0); + expect(() => assertDescriptorRegistry([missingOrder])).toThrowError(CliError); + }); +}); diff --git a/apps/cli/src/commands/descriptors.ts b/apps/cli/src/commands/descriptors.ts new file mode 100644 index 00000000..9ca7bbf2 --- /dev/null +++ b/apps/cli/src/commands/descriptors.ts @@ -0,0 +1,1308 @@ +import { FS_ROOT_LIMITS } from '@relavium/shared/fs-root-limits'; + +import { CliError } from '../process/cli-error.js'; +import type { CommandManifestEntry } from './manifest.js'; + +export type DescriptorValueType = 'string' | 'number' | 'boolean'; +export interface PositionalDescriptor { + readonly name: string; + readonly binding: string; + readonly type: DescriptorValueType; + readonly required: boolean; + readonly variadic: boolean; + readonly description: string; +} +export interface OptionDescriptor { + readonly binding: string; + readonly spellings: readonly string[]; + readonly deprecatedSpellings: readonly string[]; + readonly arity: 'none' | 'required' | 'optional'; + readonly placeholder?: string; + readonly type: DescriptorValueType; + readonly repetition: 'single' | 'repeatable' | 'variadic'; + readonly negated: boolean; + readonly required: boolean; + readonly description: string; + readonly default?: string | boolean | readonly string[]; + readonly conflicts: readonly string[]; +} +interface AuthoredOption extends OptionDescriptor { + /** Internal projections only; neither field is serialized in CLI discovery. */ + readonly compatibility?: boolean; + readonly model?: boolean; +} +export interface CommandDescriptor { + readonly path: readonly string[]; + readonly label: string; + readonly description: string; + readonly effect: 'read' | 'write' | 'destructive'; + readonly availability: 'implemented' | 'not_implemented'; + readonly aliases: readonly { readonly path: readonly string[]; readonly deprecated: boolean }[]; + readonly positionals: readonly PositionalDescriptor[]; + readonly options: readonly AuthoredOption[]; + readonly executorId?: string; + /** Existing model order is compatible even where current CLI help orders groups differently. */ + readonly legacyModelOrder?: number; + readonly modeScope?: readonly ('ask' | 'plan' | 'accept-edits' | 'auto')[]; + readonly allowExcessArguments?: boolean; + readonly opaqueParserHint?: string; + readonly requiredSubcommand?: string; + readonly landsIn?: string; +} +export interface GlobalOptionDescriptor extends OptionDescriptor { + readonly scope: 'position-independent'; + readonly owner: 'extractor' | 'commander'; +} + +/** Trusted authored JSON data only, not a normalizer of untrusted request/result graphs. */ +function freezeData(value: T): T { + if (typeof value === 'object' && value !== null) { + for (const child of Object.values(value)) freezeData(child); + Object.freeze(value); + } + return value; +} + +/** Single authored authority. Future canonical aliases/options are staged until Step 10 integration. */ +export const COMMAND_DESCRIPTORS: readonly CommandDescriptor[] = freezeData([ + { + path: ['run'], + label: 'Run workflow', + description: 'Execute a workflow (path or id), streaming progress.', + effect: 'write', + availability: 'implemented', + aliases: [], + positionals: [ + { + name: 'workflow', + binding: 'workflow', + type: 'string', + required: true, + variadic: false, + description: 'workflow path or id', + }, + ], + options: [ + { + binding: 'input', + spellings: ['--input'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'variadic', + negated: false, + required: false, + description: 'a workflow input (repeatable)', + conflicts: [], + placeholder: 'key=value', + }, + { + binding: 'secretInputsStdin', + spellings: ['--secret-inputs-stdin'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: 'read declared secret inputs from non-TTY stdin', + conflicts: [], + }, + { + binding: 'allowMcpStdio', + spellings: ['--allow-mcp-stdio'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'variadic', + negated: false, + required: false, + description: + 'authorize a stdio MCP server by its consent digest for this invocation (repeatable)', + conflicts: [], + placeholder: 'digest', + }, + ], + executorId: 'run', + legacyModelOrder: 0, + opaqueParserHint: 'error: invalid run arguments; use relavium run --help.', + allowExcessArguments: false, + }, + { + path: ['chat'], + label: 'Start chat', + description: 'Start an interactive agent chat session (REPL).', + effect: 'write', + availability: 'implemented', + aliases: [], + positionals: [], + options: [ + { + binding: 'agent', + spellings: ['--agent'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: 'bind a specific agent (.agent.yaml path or .relavium/ id)', + conflicts: [], + placeholder: 'ref', + }, + ], + executorId: 'chat', + legacyModelOrder: 1, + }, + { + path: ['chat', 'resume'], + label: 'Resume chat', + description: 'Reload a persisted session from history.db and continue the conversation.', + effect: 'write', + availability: 'implemented', + aliases: [ + { + path: ['chat-resume'], + deprecated: true, + }, + ], + positionals: [ + { + name: 'sessionId', + binding: 'sessionId', + type: 'string', + required: true, + variadic: false, + description: 'the session to resume', + }, + ], + options: [], + executorId: 'chat-resume', + legacyModelOrder: 2, + }, + { + path: ['chat', 'list'], + label: 'List sessions', + description: 'List past agent sessions (id, agent, title, last activity).', + effect: 'read', + availability: 'implemented', + aliases: [ + { + path: ['chat-list'], + deprecated: true, + }, + ], + positionals: [], + options: [], + executorId: 'chat-list', + legacyModelOrder: 3, + }, + { + path: ['chat', 'export'], + label: 'Export session', + description: 'Export a session to a .relavium.yaml scaffold for review (ADR-0026).', + effect: 'write', + availability: 'implemented', + aliases: [ + { + path: ['chat-export'], + deprecated: true, + }, + ], + positionals: [ + { + name: 'sessionId', + binding: 'sessionId', + type: 'string', + required: true, + variadic: false, + description: 'the session to export', + }, + ], + options: [ + { + binding: 'out', + spellings: ['--out'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: 'write the scaffold here instead of .relavium.yaml', + conflicts: [], + placeholder: 'path', + }, + { + binding: 'force', + spellings: ['--force'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: 'overwrite an existing file at the target path', + conflicts: [], + }, + ], + executorId: 'chat-export', + legacyModelOrder: 4, + }, + { + path: ['create'], + label: 'Create (scaffold)', + description: 'Scaffold a new agent or workflow via an interactive wizard.', + effect: 'write', + availability: 'implemented', + aliases: [], + positionals: [], + options: [ + { + binding: 'force', + spellings: ['--force'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: 'overwrite an existing project entry with the same id', + conflicts: [], + }, + ], + executorId: 'create', + legacyModelOrder: 5, + }, + { + path: ['export'], + label: 'Export YAML', + description: + 'Export a workflow/agent to a portable YAML (secret references stay placeholdered).', + effect: 'write', + availability: 'implemented', + aliases: [], + positionals: [ + { + name: 'id', + binding: 'id', + type: 'string', + required: true, + variadic: false, + description: 'workflow or agent id to export', + }, + ], + options: [ + { + binding: 'out', + spellings: ['--out'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: 'write the copy here instead of . in cwd', + conflicts: [], + placeholder: 'path', + }, + { + binding: 'force', + spellings: ['--force'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: 'overwrite an existing file at the target path', + conflicts: [], + }, + ], + executorId: 'export', + legacyModelOrder: 6, + }, + { + path: ['import'], + label: 'Import YAML', + description: + 'Import an external workflow/agent YAML into the project (validated, deduplicated).', + effect: 'write', + availability: 'implemented', + aliases: [], + positionals: [ + { + name: 'path', + binding: 'path', + type: 'string', + required: true, + variadic: false, + description: 'path to the .relavium.yaml / .agent.yaml file', + }, + ], + options: [ + { + binding: 'force', + spellings: ['--force'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: 'overwrite an existing project entry with the same id', + conflicts: [], + }, + ], + executorId: 'import', + legacyModelOrder: 7, + }, + { + path: ['agent'], + label: 'Agent', + description: 'Manage and run agents.', + effect: 'read', + availability: 'implemented', + aliases: [], + positionals: [], + options: [], + requiredSubcommand: 'run', + }, + { + path: ['agent', 'run'], + label: 'Run agent (one-shot)', + description: + 'Run a single agent one-shot (prompt on stdin); --fixture replays a recorded cassette.', + effect: 'write', + availability: 'implemented', + aliases: [], + positionals: [ + { + name: 'agent', + binding: 'agent', + type: 'string', + required: true, + variadic: false, + description: 'agent .agent.yaml path or .relavium/ id', + }, + ], + options: [ + { + binding: 'input', + spellings: ['--input'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'variadic', + negated: false, + required: false, + description: 'a session {{ctx.*}} variable (repeatable)', + conflicts: [], + placeholder: 'key=value', + }, + { + binding: 'fixture', + spellings: ['--fixture'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: 'replay a recorded LLM cassette (deterministic, offline)', + conflicts: [], + placeholder: 'path', + }, + { + binding: 'allowMcpStdio', + spellings: ['--allow-mcp-stdio'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'variadic', + negated: false, + required: false, + description: + 'authorize a stdio MCP server by its consent digest for this invocation (repeatable)', + conflicts: [], + placeholder: 'digest', + }, + ], + executorId: 'agent.run', + legacyModelOrder: 8, + }, + { + path: ['gate'], + label: 'Resolve gate', + description: 'Resolve a pending human gate (approve / reject / input).', + effect: 'write', + availability: 'implemented', + aliases: [], + positionals: [ + { + name: 'runId', + binding: 'runId', + type: 'string', + required: false, + variadic: false, + description: 'the paused run (omit only with the list subcommand)', + }, + ], + options: [ + { + binding: 'approve', + spellings: ['--approve'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: 'approve the gate', + conflicts: [], + }, + { + binding: 'reject', + spellings: ['--reject'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: 'reject the gate', + conflicts: [], + }, + { + binding: 'comment', + spellings: ['--comment'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: 'a decision comment (with --approve / --reject)', + conflicts: [], + placeholder: 'text', + }, + { + binding: 'input', + spellings: ['--input'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: 'provide input for a gate_type=input gate (JSON, else a raw string)', + conflicts: [], + placeholder: 'value', + }, + { + binding: 'gate', + spellings: ['--gate'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: 'which pending gate to resolve (required when more than one is pending)', + conflicts: [], + placeholder: 'gateId', + }, + { + binding: 'secretStdin', + spellings: ['--secret-stdin'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: + "read the run's secret inputs from stdin as name=value lines (never passed as arguments)", + conflicts: [], + }, + { + binding: 'allowMcpStdio', + spellings: ['--allow-mcp-stdio'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'variadic', + negated: false, + required: false, + description: + 'authorize a stdio MCP server by its consent digest for this invocation (repeatable)', + conflicts: [], + placeholder: 'digest', + }, + ], + executorId: 'gate', + legacyModelOrder: 9, + }, + { + path: ['gate', 'list'], + label: 'List gates', + description: 'List pending human gates (all paused runs, or one run).', + effect: 'read', + availability: 'implemented', + aliases: [], + positionals: [ + { + name: 'runId', + binding: 'runId', + type: 'string', + required: false, + variadic: false, + description: 'a single run to inspect (omit ⇒ all paused runs)', + }, + ], + options: [], + executorId: 'gate.list', + legacyModelOrder: 11, + }, + { + path: ['budget'], + label: 'Budget', + description: 'Resolve recorded budget authorization.', + effect: 'read', + availability: 'implemented', + aliases: [], + positionals: [], + options: [], + }, + { + path: ['budget', 'resume'], + label: 'Resolve budget gate', + description: 'Approve the exact frozen microcents or reject a budget gate.', + effect: 'write', + availability: 'implemented', + aliases: [], + positionals: [ + { + name: 'runId', + binding: 'runId', + type: 'string', + required: true, + variadic: false, + description: 'the budget-paused run', + }, + ], + options: [ + { + binding: 'approveAmount', + spellings: ['--approve-amount'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: 'approve exactly the recorded frozen amount (including zero)', + conflicts: [], + placeholder: 'microcents', + }, + { + binding: 'abort', + spellings: ['--abort'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: 'reject the budget gate without provider or tool execution', + conflicts: [], + }, + { + binding: 'gate', + spellings: ['--gate'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: 'which budget gate to resolve when multiple are pending', + conflicts: [], + placeholder: 'gateId', + }, + { + binding: 'secretStdin', + spellings: ['--secret-stdin'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: + "read the run's secret inputs from stdin as name=value lines (never passed as arguments)", + conflicts: [], + }, + { + binding: 'allowMcpStdio', + spellings: ['--allow-mcp-stdio'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'variadic', + negated: false, + required: false, + description: + 'authorize a stdio MCP server by its consent digest for this invocation (repeatable)', + conflicts: [], + placeholder: 'digest', + }, + ], + executorId: 'budget.resume', + legacyModelOrder: 10, + }, + { + path: ['models'], + label: 'List models', + description: 'List the cached model catalog (refreshes on first run if empty).', + effect: 'read', + availability: 'implemented', + aliases: [], + positionals: [], + options: [], + executorId: 'models', + legacyModelOrder: 15, + }, + { + path: ['models', 'refresh'], + label: 'Refresh models', + description: + "Re-fetch what we know about models: each connected provider's live list, and the models.dev catalog.", + effect: 'write', + availability: 'implemented', + aliases: [], + positionals: [], + options: [ + { + binding: 'providers', + spellings: ['--providers'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: "availability only — each connected provider's live model list", + conflicts: [], + }, + { + binding: 'catalog', + spellings: ['--catalog'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: 'metadata only — prices, ceilings and reasoning tiers from models.dev', + conflicts: [], + }, + ], + executorId: 'models.refresh', + legacyModelOrder: 16, + }, + { + path: ['models', 'pricing'], + label: 'Set model pricing', + description: + 'Set your own price for a model — it overrides the catalog (you hold the invoice). --clear removes it.', + effect: 'write', + availability: 'implemented', + aliases: [], + positionals: [ + { + name: 'model', + binding: 'model', + type: 'string', + required: true, + variadic: false, + description: 'the model id to price', + }, + ], + options: [ + { + binding: 'provider', + spellings: ['--provider'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: true, + description: 'the provider that serves the model (must be registered)', + conflicts: [], + placeholder: 'slug', + }, + { + binding: 'input', + spellings: ['--input-price'], + deprecatedSpellings: ['--input'], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: + 'input (prompt) price, USD per million tokens — required WITH --output when either is given; omit both to price only media', + conflicts: [], + placeholder: 'usd-per-mtok', + }, + { + binding: 'output', + spellings: ['--output-price'], + deprecatedSpellings: ['--output'], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: + 'output (completion) price, USD per million tokens — required WITH --input when either is given; omit both to price only media', + conflicts: [], + placeholder: 'usd-per-mtok', + }, + { + binding: 'cached', + spellings: ['--cached'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: + "cache-read price, USD per million tokens; omitted ⇒ the catalog's cache discount, applied to your input rate", + conflicts: [], + placeholder: 'usd-per-mtok', + }, + { + binding: 'image', + spellings: ['--image'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: 'image-output price, USD per image', + conflicts: [], + placeholder: 'usd-per-image', + }, + { + binding: 'audio', + spellings: ['--audio'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: 'audio-output price, USD per second', + conflicts: [], + placeholder: 'usd-per-second', + }, + { + binding: 'video', + spellings: ['--video'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: 'video-output price, USD per second', + conflicts: [], + placeholder: 'usd-per-second', + }, + { + binding: 'clear', + spellings: ['--clear'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: "remove your price for this model — it falls back to the catalog's", + conflicts: [], + }, + ], + executorId: 'models.pricing', + legacyModelOrder: 17, + }, + { + path: ['provider'], + label: 'Provider', + description: 'Manage providers and API keys in the OS keychain.', + effect: 'read', + availability: 'implemented', + aliases: [], + positionals: [], + options: [], + }, + { + path: ['provider', 'list'], + label: 'List providers', + description: 'List registered providers and whether a key is set.', + effect: 'read', + availability: 'implemented', + aliases: [], + positionals: [], + options: [ + { + binding: 'verify', + spellings: ['--verify'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: 'additionally run a live key-verification probe per provider', + conflicts: [], + }, + ], + executorId: 'provider.list', + legacyModelOrder: 18, + }, + { + path: ['provider', 'add'], + label: 'Add provider', + description: 'Register a provider.', + effect: 'write', + availability: 'implemented', + aliases: [], + positionals: [ + { + name: 'name', + binding: 'name', + type: 'string', + required: true, + variadic: false, + description: 'provider name (e.g. anthropic)', + }, + ], + options: [ + { + binding: 'baseUrl', + spellings: ['--base-url'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: 'override the provider base URL', + conflicts: [], + placeholder: 'url', + }, + { + binding: 'pricingUrl', + spellings: ['--pricing-url'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: 'override the pricing reference page (where you find model prices)', + conflicts: [], + placeholder: 'url', + }, + ], + executorId: 'provider.add', + legacyModelOrder: 19, + }, + { + path: ['provider', 'set-key'], + label: 'Set provider key', + description: 'Store a provider API key in the OS keychain (the key is read from stdin).', + effect: 'write', + availability: 'implemented', + aliases: [], + positionals: [ + { + name: 'name', + binding: 'name', + type: 'string', + required: true, + variadic: false, + description: 'provider name (e.g. anthropic)', + }, + ], + options: [], + executorId: 'provider.set-key', + legacyModelOrder: 20, + }, + { + path: ['provider', 'remove-key'], + label: 'Remove provider key', + description: 'Remove a provider API key from the OS keychain.', + effect: 'destructive', + availability: 'implemented', + aliases: [], + positionals: [ + { + name: 'name', + binding: 'name', + type: 'string', + required: true, + variadic: false, + description: 'provider name (e.g. anthropic)', + }, + ], + options: [ + { + binding: 'force', + spellings: ['--force'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: 'remove the key without an interactive confirmation', + conflicts: [], + compatibility: false, + model: false, + }, + ], + executorId: 'provider.remove-key', + legacyModelOrder: 22, + }, + { + path: ['provider', 'test'], + label: 'Test provider key', + description: 'Verify a provider key with a minimal live request.', + effect: 'read', + availability: 'implemented', + aliases: [], + positionals: [ + { + name: 'name', + binding: 'name', + type: 'string', + required: true, + variadic: false, + description: 'provider name (e.g. anthropic)', + }, + ], + options: [ + { + binding: 'model', + spellings: ['--model'], + deprecatedSpellings: [], + arity: 'required', + type: 'string', + repetition: 'single', + negated: false, + required: false, + description: 'model to test with (defaults to a cheap known model)', + conflicts: [], + placeholder: 'id', + }, + ], + executorId: 'provider.test', + legacyModelOrder: 23, + }, + { + path: ['media'], + label: 'Media', + description: 'Manage local media storage.', + effect: 'read', + availability: 'implemented', + aliases: [], + positionals: [], + options: [], + }, + { + path: ['media', 'prune-temporaries'], + label: 'Prune legacy media temporaries', + description: 'Remove legacy temporary media after all old producers are stopped.', + effect: 'destructive', + availability: 'implemented', + aliases: [], + positionals: [], + options: [ + { + binding: 'legacyOffline', + spellings: ['--legacy-offline'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: 'acknowledge that all old media producers are stopped', + conflicts: [], + }, + { + binding: 'force', + spellings: ['--force'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: 'authorize legacy temporary media deletion', + conflicts: [], + }, + ], + executorId: 'media.prune-temporaries', + legacyModelOrder: 21, + opaqueParserHint: 'error: invalid media maintenance arguments.', + allowExcessArguments: false, + }, + { + path: ['list'], + label: 'List workflows', + description: 'List discovered workflows (or, with --agents, agents) in the current project.', + effect: 'read', + availability: 'implemented', + aliases: [], + positionals: [], + options: [ + { + binding: 'agents', + spellings: ['--agents'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: 'list agents instead of workflows', + conflicts: [], + }, + ], + executorId: 'list', + legacyModelOrder: 12, + }, + { + path: ['logs'], + label: 'Show run logs', + description: 'Print the persisted event stream for a past run.', + effect: 'read', + availability: 'implemented', + aliases: [], + positionals: [ + { + name: 'runId', + binding: 'runId', + type: 'string', + required: true, + variadic: false, + description: 'the run to print', + }, + ], + options: [], + executorId: 'logs', + legacyModelOrder: 13, + }, + { + path: ['status'], + label: 'Show status', + description: 'Show active/paused runs and their per-node status.', + effect: 'read', + availability: 'implemented', + aliases: [], + positionals: [], + options: [], + executorId: 'status', + legacyModelOrder: 14, + }, + { + path: ['init'], + label: 'Init', + description: 'Initialize a .relavium/ directory in the current project.', + effect: 'read', + availability: 'not_implemented', + aliases: [], + positionals: [], + options: [], + landsIn: 'a later workstream', + }, +] satisfies readonly CommandDescriptor[]); + +function globalOption( + binding: string, + spellings: readonly string[], + description: string, + details: Partial = {}, +): GlobalOptionDescriptor { + return { + binding, + spellings, + description, + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + conflicts: [], + scope: 'position-independent', + owner: 'extractor', + ...details, + }; +} + +export const GLOBAL_OPTION_DESCRIPTORS: readonly GlobalOptionDescriptor[] = freezeData([ + globalOption('json', ['--json'], 'emit machine-readable NDJSON output (disables the TUI)'), + globalOption('color', ['--color'], 'force colored output on'), + globalOption('color', ['--no-color'], 'disable colored output', { negated: true }), + globalOption('cwd', ['--cwd'], 'run as if started in ', { + arity: 'required', + placeholder: 'dir', + type: 'string', + }), + globalOption('config', ['--config'], 'use an explicit config file', { + arity: 'required', + placeholder: 'path', + type: 'string', + }), + globalOption( + 'allowFsRoots', + ['--allow-fs-root'], + `grant a chat/Home/agent-run root (repeatable, max ${FS_ROOT_LIMITS.count})`, + { + arity: 'required', + placeholder: 'absolute-directory', + type: 'string', + repetition: 'repeatable', + }, + ), + globalOption( + 'noAltScreen', + ['--no-alt-screen'], + 'keep the inline renderer (no full-screen alt screen)', + ), + globalOption( + 'noMouse', + ['--no-mouse'], + 'disable mouse reporting (restores native click-drag selection)', + ), + globalOption('verbose', ['-v', '--verbose'], 'print verbose diagnostics to stderr', { + conflicts: ['quiet'], + }), + globalOption('quiet', ['-q', '--quiet'], 'suppress non-essential output', { + conflicts: ['verbose'], + }), + globalOption('help', ['-h', '--help'], 'display help for command', { owner: 'commander' }), + globalOption('version', ['-V', '--version'], 'output the version number', { owner: 'commander' }), +]); + +const globalsBySpelling = new Map( + GLOBAL_OPTION_DESCRIPTORS.flatMap((option) => + option.spellings.map((spelling) => [spelling, option] as const), + ), +); +export function globalOptionForSpelling(spelling: string): GlobalOptionDescriptor | undefined { + return globalsBySpelling.get(spelling); +} + +export function compatibilityPath(descriptor: CommandDescriptor): readonly string[] { + if (descriptor.executorId === undefined) return descriptor.path; + const previous = descriptor.executorId.split('.'); + if (samePath(previous, descriptor.path)) return descriptor.path; + const alias = descriptor.aliases.find((candidate) => samePath(previous, candidate.path)); + if (alias === undefined) throw new CliError('cli_internal', ''); + return alias.path; +} + +export function compatibilityOptions(descriptor: CommandDescriptor): readonly OptionDescriptor[] { + return descriptor.options + .filter((option) => option.compatibility !== false) + .map((option) => ({ + ...option, + spellings: + option.deprecatedSpellings.length > 0 ? option.deprecatedSpellings : option.spellings, + })); +} + +export function legacyManifestEntries(): CommandManifestEntry[] { + return COMMAND_DESCRIPTORS.filter((entry) => entry.executorId !== undefined) + .toSorted((a, b) => (a.legacyModelOrder ?? 0) - (b.legacyModelOrder ?? 0)) + .map((entry) => { + if (entry.executorId === undefined) throw new CliError('cli_internal', ''); + const args = [ + ...entry.positionals.map((arg) => ({ + name: arg.binding, + type: arg.type, + ...(arg.required ? { required: true } : {}), + description: arg.description, + })), + ...entry.options + .filter((arg) => arg.model !== false) + .map((arg) => ({ + name: arg.binding, + type: arg.type, + ...(arg.required ? { required: true } : {}), + description: arg.description, + })), + ]; + return { + id: entry.executorId, + label: entry.label, + description: entry.description, + ...(args.length > 0 ? { args } : {}), + effect: entry.effect, + ...(entry.modeScope === undefined ? {} : { modeScope: [...entry.modeScope] }), + }; + }); +} + +function samePath(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((part, index) => part === b[index]); +} + +export function assertDescriptorRegistry(entries: readonly CommandDescriptor[]): void { + const paths = new Set(); + const executors = new Set(); + const modelOrder = new Set(); + for (const entry of entries) { + for (const path of [entry.path, ...entry.aliases.map((alias) => alias.path)]) { + const key = JSON.stringify(path); + if (path.length === 0 || path.some((part) => part.length === 0) || paths.has(key)) + throw new CliError('cli_internal', ''); + paths.add(key); + } + if (entry.executorId !== undefined) { + if ( + entry.availability !== 'implemented' || + executors.has(entry.executorId) || + entry.legacyModelOrder === undefined || + modelOrder.has(entry.legacyModelOrder) + ) + throw new CliError('cli_internal', ''); + executors.add(entry.executorId); + modelOrder.add(entry.legacyModelOrder); + } + const bindings = new Set(); + const spellings = new Set(); + for (const positional of entry.positionals) { + if (bindings.has(positional.binding)) throw new CliError('cli_internal', ''); + bindings.add(positional.binding); + } + for (const option of entry.options) { + if (bindings.has(option.binding)) throw new CliError('cli_internal', ''); + bindings.add(option.binding); + for (const spelling of [...option.spellings, ...option.deprecatedSpellings]) { + if (spellings.has(spelling)) throw new CliError('cli_internal', ''); + spellings.add(spelling); + } + } + } +} +assertDescriptorRegistry(COMMAND_DESCRIPTORS); diff --git a/apps/cli/src/commands/discovery.test.ts b/apps/cli/src/commands/discovery.test.ts new file mode 100644 index 00000000..0b12b017 --- /dev/null +++ b/apps/cli/src/commands/discovery.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from 'vitest'; + +import { CliError } from '../process/cli-error.js'; +import { discoverCommands } from './discovery.js'; + +function expectFrozenTree(value: unknown): void { + if (typeof value !== 'object' || value === null) return; + expect(Object.isFrozen(value)).toBe(true); + for (const child of Object.values(value)) expectFrozenTree(child); +} + +describe('pure CLI discovery DTO (unit projection, not production JSON help)', () => { + it('has the approved envelope, stable authored order, executable containers, groups and unavailable stub', () => { + const dto = discoverCommands(); + expect(Object.keys(dto).sort()).toEqual([ + 'commands', + 'globalOptions', + 'path', + 'type', + 'version', + ]); + expect(dto).toMatchObject({ type: 'command_manifest', version: 1, path: [] }); + expect(dto.commands.map((entry) => entry.path.join(' '))).toEqual([ + 'run', + 'chat', + 'chat resume', + 'chat list', + 'chat export', + 'create', + 'export', + 'import', + 'agent', + 'agent run', + 'gate', + 'gate list', + 'budget', + 'budget resume', + 'models', + 'models refresh', + 'models pricing', + 'provider', + 'provider list', + 'provider add', + 'provider set-key', + 'provider remove-key', + 'provider test', + 'media', + 'media prune-temporaries', + 'list', + 'logs', + 'status', + 'init', + ]); + expect( + dto.commands + .filter((entry) => entry.availability === 'not_implemented') + .map((entry) => entry.path), + ).toEqual([['init']]); + expect(dto.commands.filter((entry) => entry.executorId !== undefined)).toHaveLength(24); + for (const path of ['chat', 'gate', 'models']) + expect(dto.commands.find((entry) => entry.path.join(' ') === path)?.executorId).toBe(path); + expect(dto.commands.find((entry) => entry.path.join(' ') === 'agent')).not.toHaveProperty( + 'executorId', + ); + expectFrozenTree(dto); + expect(JSON.parse(JSON.stringify(dto))).toEqual(dto); + }); + + it.each([ + [['chat'], ['chat', 'chat resume', 'chat list', 'chat export']], + [['models'], ['models', 'models refresh', 'models pricing']], + [ + ['provider'], + [ + 'provider', + 'provider list', + 'provider add', + 'provider set-key', + 'provider remove-key', + 'provider test', + ], + ], + [['agent'], ['agent', 'agent run']], + [['gate'], ['gate', 'gate list']], + [['init'], ['init']], + ])('selects canonical subtree %j including its own root', (path, expected) => { + const dto = discoverCommands(path); + expect(dto.path).toEqual(path); + expect(dto.commands.map((entry) => entry.path.join(' '))).toEqual(expected); + expect(dto.globalOptions).toEqual(discoverCommands().globalOptions); + }); + + it.each(['resume', 'list', 'export'])( + 'resolves flat chat-%s alias to the exact canonical subtree', + (verb) => { + const aliased = discoverCommands([`chat-${verb}`]); + expect(aliased).toEqual(discoverCommands(['chat', verb])); + expect(aliased.path).toEqual(['chat', verb]); + expect(aliased.commands).toHaveLength(1); + expect(aliased.commands[0]?.executorId).toBe(`chat-${verb}`); + expect(aliased.commands[0]?.aliases).toEqual([{ path: [`chat-${verb}`], deprecated: true }]); + }, + ); + + it('serializes only public fields and retains exact positional/option grammar', () => { + const dto = discoverCommands(); + const commandFields = [ + 'aliases', + 'allowExcessArguments', + 'availability', + 'description', + 'effect', + 'label', + 'options', + 'path', + 'positionals', + ]; + for (const command of dto.commands) { + expect(Object.keys(command).sort()).toEqual( + [...commandFields, ...(command.executorId === undefined ? [] : ['executorId'])].sort(), + ); + for (const positional of command.positionals) + expect(Object.keys(positional).sort()).toEqual([ + 'binding', + 'description', + 'name', + 'required', + 'type', + 'variadic', + ]); + for (const option of command.options) + expect(Object.keys(option).sort()).toEqual( + [ + 'arity', + 'binding', + 'conflicts', + 'deprecatedSpellings', + 'description', + 'negated', + 'repetition', + 'required', + 'spellings', + 'type', + ...(option.placeholder === undefined ? [] : ['placeholder']), + ...(option.default === undefined ? [] : ['default']), + ].sort(), + ); + } + expect( + dto.commands.filter((entry) => !entry.allowExcessArguments).map((entry) => entry.path), + ).toEqual([['run'], ['media', 'prune-temporaries']]); + const pricing = discoverCommands(['models', 'pricing']).commands[0]; + expect( + pricing?.options.filter((option) => ['provider', 'input', 'output'].includes(option.binding)), + ).toMatchObject([ + { binding: 'provider', required: true, arity: 'required', spellings: ['--provider'] }, + { + binding: 'input', + required: false, + spellings: ['--input-price'], + deprecatedSpellings: ['--input'], + }, + { + binding: 'output', + required: false, + spellings: ['--output-price'], + deprecatedSpellings: ['--output'], + }, + ]); + expect(discoverCommands(['gate']).commands[0]?.positionals).toMatchObject([ + { name: 'runId', required: false, variadic: false }, + ]); + expect(discoverCommands(['provider', 'remove-key']).commands[0]?.options).toMatchObject([ + { binding: 'force', spellings: ['--force'] }, + ]); + }); + + it('describes every global spelling, value arity, repetition, conflict and negation without owner state', () => { + const options = discoverCommands().globalOptions; + expect(options.flatMap((option) => option.spellings)).toEqual([ + '--json', + '--color', + '--no-color', + '--cwd', + '--config', + '--allow-fs-root', + '--no-alt-screen', + '--no-mouse', + '-v', + '--verbose', + '-q', + '--quiet', + '-h', + '--help', + '-V', + '--version', + ]); + expect( + options + .filter((option) => option.arity !== 'none') + .map((option) => [option.binding, option.arity, option.repetition]), + ).toEqual([ + ['cwd', 'required', 'single'], + ['config', 'required', 'single'], + ['allowFsRoots', 'required', 'repeatable'], + ]); + expect(options.filter((option) => option.negated).map((option) => option.spellings)).toEqual([ + ['--no-color'], + ]); + expect( + options + .filter((option) => option.conflicts.length > 0) + .map((option) => [option.binding, option.conflicts]), + ).toEqual([ + ['verbose', ['quiet']], + ['quiet', ['verbose']], + ]); + expect(options.every((option) => option.scope === 'position-independent')).toBe(true); + expect(options.every((option) => !Object.hasOwn(option, 'owner'))).toBe(true); + }); + + it.each([ + ['missing-canary-SECRET'], + ['chat', 'resume', 'extra'], + ['chat-resume', 'extra'], + [''], + ['Chat'], + ])('refuses invalid subtree %j with fixed non-echoing text', (...path) => { + expect(() => discoverCommands(path)).toThrowError(CliError); + try { + discoverCommands(path); + } catch (error) { + expect(error).toMatchObject({ + code: 'invalid_invocation', + exitCode: 2, + message: 'Unknown command help path. Use relavium --help.', + }); + } + }); +}); diff --git a/apps/cli/src/commands/discovery.ts b/apps/cli/src/commands/discovery.ts new file mode 100644 index 00000000..5544a109 --- /dev/null +++ b/apps/cli/src/commands/discovery.ts @@ -0,0 +1,103 @@ +import { CliError } from '../process/cli-error.js'; +import { + COMMAND_DESCRIPTORS, + GLOBAL_OPTION_DESCRIPTORS, + type CommandDescriptor, + type OptionDescriptor, + type PositionalDescriptor, +} from './descriptors.js'; + +export interface CliCommandDescriptor { + readonly path: readonly string[]; + readonly label: string; + readonly description: string; + readonly effect: CommandDescriptor['effect']; + readonly availability: CommandDescriptor['availability']; + readonly aliases: CommandDescriptor['aliases']; + readonly positionals: readonly PositionalDescriptor[]; + readonly options: readonly OptionDescriptor[]; + readonly allowExcessArguments: boolean; + readonly executorId?: string; + readonly modeScope?: CommandDescriptor['modeScope']; +} + +export interface CliCommandManifest { + readonly type: 'command_manifest'; + readonly version: 1; + readonly path: readonly string[]; + readonly globalOptions: readonly (OptionDescriptor & { + readonly scope: 'position-independent'; + })[]; + readonly commands: readonly CliCommandDescriptor[]; +} + +/** Explicit projection prevents internal compatibility, parser or future loader state from leaking. */ +function publicOption(option: OptionDescriptor): OptionDescriptor { + return Object.freeze({ + binding: option.binding, + spellings: option.spellings, + deprecatedSpellings: option.deprecatedSpellings, + arity: option.arity, + ...(option.placeholder === undefined ? {} : { placeholder: option.placeholder }), + type: option.type, + repetition: option.repetition, + negated: option.negated, + required: option.required, + description: option.description, + ...(option.default === undefined ? {} : { default: option.default }), + conflicts: option.conflicts, + }); +} + +function projectCommand(entry: CommandDescriptor): CliCommandDescriptor { + return Object.freeze({ + path: entry.path, + label: entry.label, + description: entry.description, + effect: entry.effect, + availability: entry.availability, + aliases: entry.aliases, + positionals: entry.positionals, + options: Object.freeze(entry.options.map(publicOption)), + allowExcessArguments: entry.allowExcessArguments !== false, + ...(entry.executorId === undefined ? {} : { executorId: entry.executorId }), + ...(entry.modeScope === undefined ? {} : { modeScope: entry.modeScope }), + }); +} + +function samePath(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((part, index) => part === b[index]); +} + +/** Pure approved DTO only. Production --help --json integration remains Step 10. */ +export function discoverCommands(path: readonly string[] = []): CliCommandManifest { + const selected = + path.length === 0 + ? undefined + : COMMAND_DESCRIPTORS.find( + (entry) => + samePath(path, entry.path) || entry.aliases.some((alias) => samePath(path, alias.path)), + ); + if (path.length > 0 && selected === undefined) { + throw new CliError('invalid_invocation', 'Unknown command help path. Use relavium --help.'); + } + const canonical = selected?.path ?? Object.freeze([]); + return Object.freeze({ + type: 'command_manifest', + version: 1, + path: canonical, + globalOptions: Object.freeze( + GLOBAL_OPTION_DESCRIPTORS.map((option) => + Object.freeze({ + ...publicOption(option), + scope: option.scope, + }), + ), + ), + commands: Object.freeze( + COMMAND_DESCRIPTORS.filter((entry) => + canonical.every((part, index) => entry.path[index] === part), + ).map(projectCommand), + ), + }); +} diff --git a/apps/cli/src/commands/dispatch.ts b/apps/cli/src/commands/dispatch.ts index 1a533f12..2046acf1 100644 --- a/apps/cli/src/commands/dispatch.ts +++ b/apps/cli/src/commands/dispatch.ts @@ -25,7 +25,6 @@ import { persistCatalogToDb, syncCatalogFromDb } from '../engine/catalog-metadat import { CliError } from '../process/errors.js'; import { type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; -import type { GlobalOptions } from '../process/options.js'; import { selectChatDriver } from '../render/tui/chat-ink.js'; import type { KeychainStore } from '../secrets/keychain.js'; import { createMcpSecretResolver } from '../secrets/mcp-secret.js'; @@ -61,39 +60,27 @@ import { pruneTemporariesCommand, type MediaPruneArgs } from './media.js'; /** * The shared **command dispatch table** ([ADR-0056](../../../../docs/decisions/0056-cli-in-app-slash-command-system-and-manifest.md), - * 2.5.C). Every surface — the `commander` actions ([specs.ts](specs.ts)), and (later in 2.5.C) the `/` palette - * and the in-REPL slash commands — calls {@link executeCommand} keyed by the manifest id, so the per-command - * dependency wiring (keychain, provider resolver, the durable stores) lives in exactly ONE place and the surfaces - * can never diverge. Each entry is a {@link CommandExecutor} that turns a uniform {@link CommandInput} into the + * 2.5.C). Commander actions ([specs.ts](specs.ts)) call {@link executeCommand} with stable descriptor executor + * ids. Per-command dependency wiring (keychain, provider resolver, durable stores) lives here; the in-REPL + * registry remains separate. Each entry is a {@link CommandExecutor} that turns a uniform {@link CommandInput} into the * command core's typed args (the pure `build*Args` functions — unit-tested) and assembles its production deps. * * The `build*Args` extraction is the only behaviour-sensitive change vs the old inline `register*` bodies, so it * is kept pure and exhaustively tested; the dep assembly below is copied verbatim from those bodies. */ -/** A parsed option value as a surface hands it in — a string, a boolean flag, a repeatable list, or absent. */ -export type CommandOptionValue = string | boolean | readonly string[] | undefined; - -/** - * The uniform, surface-agnostic parsed input a command executor consumes (commander argv, or a slash line). It is - * deliberately LOW-LEVEL: an option value is just a string / boolean / repeatable list, with no per-command - * schema. Each command's pure `build*Args` extractor below owns the interpretation; a slash/palette surface - * (2.5.C) builds a `CommandInput` against the manifest's arg shapes, so option arity (a repeatable `--input` vs a - * single `--out`) is resolved from the manifest, never guessed from this shape. - */ -export interface CommandInput { - readonly positionals: readonly string[]; - readonly options: Readonly>; -} - -/** The runtime channels a command needs; the boundary reads the returned {@link ExitCode}. */ -export interface DispatchContext { - readonly io: CliIo; - readonly global: GlobalOptions; -} - -/** One command's executor — uniform input + context in, an exit code out. */ -export type CommandExecutor = (input: CommandInput, ctx: DispatchContext) => Promise; +import type { + CommandExecutor, + CommandInput, + CommandOptionValue, + DispatchContext, +} from './input.js'; +export type { + CommandExecutor, + CommandInput, + CommandOptionValue, + DispatchContext, +} from './input.js'; // ── input extractors (pure) ───────────────────────────────────────────────── diff --git a/apps/cli/src/commands/fixtures/legacy-command-manifest.json b/apps/cli/src/commands/fixtures/legacy-command-manifest.json new file mode 100644 index 00000000..2bee3d9a --- /dev/null +++ b/apps/cli/src/commands/fixtures/legacy-command-manifest.json @@ -0,0 +1,487 @@ +[ + { + "id": "run", + "label": "Run workflow", + "description": "Execute a workflow (path or id), streaming progress.", + "args": [ + { + "name": "workflow", + "type": "string", + "required": true, + "description": "workflow path or id" + }, + { + "name": "input", + "type": "string", + "description": "a workflow input (repeatable)" + }, + { + "name": "secretInputsStdin", + "type": "boolean", + "description": "read declared secret inputs from non-TTY stdin" + }, + { + "name": "allowMcpStdio", + "type": "string", + "description": "authorize a stdio MCP server by its consent digest for this invocation (repeatable)" + } + ], + "effect": "write" + }, + { + "id": "chat", + "label": "Start chat", + "description": "Start an interactive agent chat session (REPL).", + "args": [ + { + "name": "agent", + "type": "string", + "description": "bind a specific agent (.agent.yaml path or .relavium/ id)" + } + ], + "effect": "write" + }, + { + "id": "chat-resume", + "label": "Resume chat", + "description": "Reload a persisted session from history.db and continue the conversation.", + "args": [ + { + "name": "sessionId", + "type": "string", + "required": true, + "description": "the session to resume" + } + ], + "effect": "write" + }, + { + "id": "chat-list", + "label": "List sessions", + "description": "List past agent sessions (id, agent, title, last activity).", + "effect": "read" + }, + { + "id": "chat-export", + "label": "Export session", + "description": "Export a session to a .relavium.yaml scaffold for review (ADR-0026).", + "args": [ + { + "name": "sessionId", + "type": "string", + "required": true, + "description": "the session to export" + }, + { + "name": "out", + "type": "string", + "description": "write the scaffold here instead of .relavium.yaml" + }, + { + "name": "force", + "type": "boolean", + "description": "overwrite an existing file at the target path" + } + ], + "effect": "write" + }, + { + "id": "create", + "label": "Create (scaffold)", + "description": "Scaffold a new agent or workflow via an interactive wizard.", + "args": [ + { + "name": "force", + "type": "boolean", + "description": "overwrite an existing project entry with the same id" + } + ], + "effect": "write" + }, + { + "id": "export", + "label": "Export YAML", + "description": "Export a workflow/agent to a portable YAML (secret references stay placeholdered).", + "args": [ + { + "name": "id", + "type": "string", + "required": true, + "description": "workflow or agent id to export" + }, + { + "name": "out", + "type": "string", + "description": "write the copy here instead of . in cwd" + }, + { + "name": "force", + "type": "boolean", + "description": "overwrite an existing file at the target path" + } + ], + "effect": "write" + }, + { + "id": "import", + "label": "Import YAML", + "description": "Import an external workflow/agent YAML into the project (validated, deduplicated).", + "args": [ + { + "name": "path", + "type": "string", + "required": true, + "description": "path to the .relavium.yaml / .agent.yaml file" + }, + { + "name": "force", + "type": "boolean", + "description": "overwrite an existing project entry with the same id" + } + ], + "effect": "write" + }, + { + "id": "agent.run", + "label": "Run agent (one-shot)", + "description": "Run a single agent one-shot (prompt on stdin); --fixture replays a recorded cassette.", + "args": [ + { + "name": "agent", + "type": "string", + "required": true, + "description": "agent .agent.yaml path or .relavium/ id" + }, + { + "name": "input", + "type": "string", + "description": "a session {{ctx.*}} variable (repeatable)" + }, + { + "name": "fixture", + "type": "string", + "description": "replay a recorded LLM cassette (deterministic, offline)" + }, + { + "name": "allowMcpStdio", + "type": "string", + "description": "authorize a stdio MCP server by its consent digest for this invocation (repeatable)" + } + ], + "effect": "write" + }, + { + "id": "gate", + "label": "Resolve gate", + "description": "Resolve a pending human gate (approve / reject / input).", + "args": [ + { + "name": "runId", + "type": "string", + "description": "the paused run (omit only with the list subcommand)" + }, + { + "name": "approve", + "type": "boolean", + "description": "approve the gate" + }, + { + "name": "reject", + "type": "boolean", + "description": "reject the gate" + }, + { + "name": "comment", + "type": "string", + "description": "a decision comment (with --approve / --reject)" + }, + { + "name": "input", + "type": "string", + "description": "provide input for a gate_type=input gate (JSON, else a raw string)" + }, + { + "name": "gate", + "type": "string", + "description": "which pending gate to resolve (required when more than one is pending)" + }, + { + "name": "secretStdin", + "type": "boolean", + "description": "read the run's secret inputs from stdin as name=value lines (never passed as arguments)" + }, + { + "name": "allowMcpStdio", + "type": "string", + "description": "authorize a stdio MCP server by its consent digest for this invocation (repeatable)" + } + ], + "effect": "write" + }, + { + "id": "budget.resume", + "label": "Resolve budget gate", + "description": "Approve the exact frozen microcents or reject a budget gate.", + "args": [ + { + "name": "runId", + "type": "string", + "required": true, + "description": "the budget-paused run" + }, + { + "name": "approveAmount", + "type": "string", + "description": "approve exactly the recorded frozen amount (including zero)" + }, + { + "name": "abort", + "type": "boolean", + "description": "reject the budget gate without provider or tool execution" + }, + { + "name": "gate", + "type": "string", + "description": "which budget gate to resolve when multiple are pending" + }, + { + "name": "secretStdin", + "type": "boolean", + "description": "read the run's secret inputs from stdin as name=value lines (never passed as arguments)" + }, + { + "name": "allowMcpStdio", + "type": "string", + "description": "authorize a stdio MCP server by its consent digest for this invocation (repeatable)" + } + ], + "effect": "write" + }, + { + "id": "gate.list", + "label": "List gates", + "description": "List pending human gates (all paused runs, or one run).", + "args": [ + { + "name": "runId", + "type": "string", + "description": "a single run to inspect (omit ⇒ all paused runs)" + } + ], + "effect": "read" + }, + { + "id": "list", + "label": "List workflows", + "description": "List discovered workflows (or, with --agents, agents) in the current project.", + "args": [ + { + "name": "agents", + "type": "boolean", + "description": "list agents instead of workflows" + } + ], + "effect": "read" + }, + { + "id": "logs", + "label": "Show run logs", + "description": "Print the persisted event stream for a past run.", + "args": [ + { + "name": "runId", + "type": "string", + "required": true, + "description": "the run to print" + } + ], + "effect": "read" + }, + { + "id": "status", + "label": "Show status", + "description": "Show active/paused runs and their per-node status.", + "effect": "read" + }, + { + "id": "models", + "label": "List models", + "description": "List the cached model catalog (refreshes on first run if empty).", + "effect": "read" + }, + { + "id": "models.refresh", + "label": "Refresh models", + "description": "Re-fetch what we know about models: each connected provider's live list, and the models.dev catalog.", + "args": [ + { + "name": "providers", + "type": "boolean", + "description": "availability only — each connected provider's live model list" + }, + { + "name": "catalog", + "type": "boolean", + "description": "metadata only — prices, ceilings and reasoning tiers from models.dev" + } + ], + "effect": "write" + }, + { + "id": "models.pricing", + "label": "Set model pricing", + "description": "Set your own price for a model — it overrides the catalog (you hold the invoice). --clear removes it.", + "args": [ + { + "name": "model", + "type": "string", + "required": true, + "description": "the model id to price" + }, + { + "name": "provider", + "type": "string", + "required": true, + "description": "the provider that serves the model (must be registered)" + }, + { + "name": "input", + "type": "string", + "description": "input (prompt) price, USD per million tokens — required WITH --output when either is given; omit both to price only media" + }, + { + "name": "output", + "type": "string", + "description": "output (completion) price, USD per million tokens — required WITH --input when either is given; omit both to price only media" + }, + { + "name": "cached", + "type": "string", + "description": "cache-read price, USD per million tokens; omitted ⇒ the catalog's cache discount, applied to your input rate" + }, + { + "name": "image", + "type": "string", + "description": "image-output price, USD per image" + }, + { + "name": "audio", + "type": "string", + "description": "audio-output price, USD per second" + }, + { + "name": "video", + "type": "string", + "description": "video-output price, USD per second" + }, + { + "name": "clear", + "type": "boolean", + "description": "remove your price for this model — it falls back to the catalog's" + } + ], + "effect": "write" + }, + { + "id": "provider.list", + "label": "List providers", + "description": "List registered providers and whether a key is set.", + "args": [ + { + "name": "verify", + "type": "boolean", + "description": "additionally run a live key-verification probe per provider" + } + ], + "effect": "read" + }, + { + "id": "provider.add", + "label": "Add provider", + "description": "Register a provider.", + "args": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "provider name (e.g. anthropic)" + }, + { + "name": "baseUrl", + "type": "string", + "description": "override the provider base URL" + }, + { + "name": "pricingUrl", + "type": "string", + "description": "override the pricing reference page (where you find model prices)" + } + ], + "effect": "write" + }, + { + "id": "provider.set-key", + "label": "Set provider key", + "description": "Store a provider API key in the OS keychain (the key is read from stdin).", + "args": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "provider name (e.g. anthropic)" + } + ], + "effect": "write" + }, + { + "id": "media.prune-temporaries", + "label": "Prune legacy media temporaries", + "description": "Remove legacy temporary media after all old producers are stopped.", + "args": [ + { + "name": "legacyOffline", + "type": "boolean", + "description": "acknowledge that all old media producers are stopped" + }, + { + "name": "force", + "type": "boolean", + "description": "authorize legacy temporary media deletion" + } + ], + "effect": "destructive" + }, + { + "id": "provider.remove-key", + "label": "Remove provider key", + "description": "Remove a provider API key from the OS keychain.", + "args": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "provider name (e.g. anthropic)" + } + ], + "effect": "destructive" + }, + { + "id": "provider.test", + "label": "Test provider key", + "description": "Verify a provider key with a minimal live request.", + "args": [ + { + "name": "name", + "type": "string", + "required": true, + "description": "provider name (e.g. anthropic)" + }, + { + "name": "model", + "type": "string", + "description": "model to test with (defaults to a cheap known model)" + } + ], + "effect": "read" + } +] diff --git a/apps/cli/src/commands/input.ts b/apps/cli/src/commands/input.ts new file mode 100644 index 00000000..157ddba3 --- /dev/null +++ b/apps/cli/src/commands/input.ts @@ -0,0 +1,22 @@ +import type { ExitCode } from '../process/exit-codes.js'; +import type { CliIo } from '../process/io.js'; +import type { GlobalOptions } from '../process/options.js'; + +export type CommandOptionValue = string | boolean | readonly string[] | undefined; + +export interface CommandInput { + readonly positionals: readonly string[]; + readonly options: Readonly>; +} + +export interface DispatchContext { + readonly io: CliIo; + readonly global: GlobalOptions; +} + +export type CommandExecutor = (input: CommandInput, ctx: DispatchContext) => Promise; +export type DispatchCommand = ( + id: string, + input: CommandInput, + ctx: DispatchContext, +) => Promise; diff --git a/apps/cli/src/commands/manifest.test.ts b/apps/cli/src/commands/manifest.test.ts index a4943da7..0b8fee16 100644 --- a/apps/cli/src/commands/manifest.test.ts +++ b/apps/cli/src/commands/manifest.test.ts @@ -140,7 +140,7 @@ describe('command manifest (ADR-0056)', () => { } }); - it('the destructive annotation marks credential removal (the one destructive entry today)', () => { + it('the destructive annotation marks credential removal and legacy media maintenance', () => { const destructive = COMMAND_MANIFEST.filter((entry) => entry.effect === 'destructive'); expect(destructive.map((entry) => entry.id)).toEqual([ 'media.prune-temporaries', diff --git a/apps/cli/src/commands/manifest.ts b/apps/cli/src/commands/manifest.ts index 39fb52a5..8501986c 100644 --- a/apps/cli/src/commands/manifest.ts +++ b/apps/cli/src/commands/manifest.ts @@ -1,12 +1,15 @@ import { z } from 'zod'; +import { legacyManifestEntries } from './descriptors.js'; + /** * The **command manifest** ([ADR-0056](../../../../docs/decisions/0056-cli-in-app-slash-command-system-and-manifest.md), - * 2.5.C) — one canonical, **alias-free** description of every **shell** command, the single source the shell - * surfaces derive from: the `commander` parser, `relavium --help --json`, and the `executeCommand` dispatch - * table ([dispatch.ts](dispatch.ts)). Because they read this one list, they can never disagree. Canonically - * homed in [commands.md](../../../../docs/reference/cli/commands.md); this module is its runtime form (a CLI-only - * contract — apps/cli, not `@relavium/shared`). + * 2.5.C) — the compatible, strict, **alias-free** model projection of the enriched descriptor authority + * ([descriptors.ts](descriptors.ts)). That authority also derives the current Commander grammar; the separate + * implementation bindings in [dispatch.ts](dispatch.ts) are checked in both directions. The new CLI discovery + * DTO lives in [discovery.ts](discovery.ts); production JSON-help integration remains Wave 3 Step 10. + * Canonically homed in [commands.md](../../../../docs/reference/cli/commands.md), this contract belongs to + * apps/cli, not `@relavium/shared`. * * The **in-REPL** `/` palette + slash commands are a SEPARATE, curated registry ([repl-commands.ts](repl-commands.ts), * `REPL_COMMANDS` — see the ADR-0056 amendment): a REPL command runs over the live session's lifecycle, not a @@ -20,7 +23,7 @@ export const CommandArgSchema = z /** * The argument / option **key as it appears in `CommandInput.options`** — the camelCase form commander * derives via `option.attributeName()` (`workflow`, `agent`, `baseUrl`), never the kebab CLI flag - * (`--base-url`) and never with leading dashes. A slash/palette surface builds `CommandInput` against this key. + * (`--base-url`) and never with leading dashes. Model command calls use these stable binding keys. */ name: z.string().min(1), type: z.enum(['string', 'number', 'boolean']), @@ -54,7 +57,7 @@ export const CommandManifestEntrySchema = z id: z.string().min(1), /** A short human label for the palette (`Run workflow`, `Set provider key`). */ label: z.string().min(1), - /** The one-line help text — must match the `commander` `.description()` so `--help --json` stays consistent. */ + /** The one-line help text shared with the descriptor-derived Commander description. */ description: z.string().min(1), args: z.array(CommandArgSchema).optional(), effect: CommandEffectSchema, @@ -82,456 +85,8 @@ const CommandManifestSchema = z.array(CommandManifestEntrySchema).superRefine((e } }); -/** - * The authored entries (pre-validation). Descriptions are copied verbatim from the `commander` registrations in - * [specs.ts](specs.ts) so the drift guard ties them together. Credential removal and legacy media maintenance are `destructive` - * entries: they remove stored credentials or legacy temporary files. - */ -const ENTRIES: readonly CommandManifestEntry[] = [ - { - id: 'run', - label: 'Run workflow', - description: 'Execute a workflow (path or id), streaming progress.', - args: [ - { name: 'workflow', type: 'string', required: true, description: 'workflow path or id' }, - { name: 'input', type: 'string', description: 'a workflow input (repeatable)' }, - { - name: 'secretInputsStdin', - type: 'boolean', - description: 'read declared secret inputs from non-TTY stdin', - }, - { - name: 'allowMcpStdio', - type: 'string', - description: - 'authorize a stdio MCP server by its consent digest for this invocation (repeatable)', - }, - ], - effect: 'write', - }, - { - id: 'chat', - label: 'Start chat', - description: 'Start an interactive agent chat session (REPL).', - args: [ - { - name: 'agent', - type: 'string', - description: 'bind a specific agent (.agent.yaml path or .relavium/ id)', - }, - ], - effect: 'write', - }, - { - id: 'chat-resume', - label: 'Resume chat', - description: 'Reload a persisted session from history.db and continue the conversation.', - args: [ - { name: 'sessionId', type: 'string', required: true, description: 'the session to resume' }, - ], - effect: 'write', - }, - { - id: 'chat-list', - label: 'List sessions', - description: 'List past agent sessions (id, agent, title, last activity).', - effect: 'read', - }, - { - id: 'chat-export', - label: 'Export session', - description: 'Export a session to a .relavium.yaml scaffold for review (ADR-0026).', - args: [ - { name: 'sessionId', type: 'string', required: true, description: 'the session to export' }, - { - name: 'out', - type: 'string', - description: 'write the scaffold here instead of .relavium.yaml', - }, - { - name: 'force', - type: 'boolean', - description: 'overwrite an existing file at the target path', - }, - ], - effect: 'write', - }, - { - id: 'create', - label: 'Create (scaffold)', - description: 'Scaffold a new agent or workflow via an interactive wizard.', - args: [ - { - name: 'force', - type: 'boolean', - description: 'overwrite an existing project entry with the same id', - }, - ], - effect: 'write', - }, - { - id: 'export', - label: 'Export YAML', - description: - 'Export a workflow/agent to a portable YAML (secret references stay placeholdered).', - args: [ - { name: 'id', type: 'string', required: true, description: 'workflow or agent id to export' }, - { - name: 'out', - type: 'string', - description: 'write the copy here instead of . in cwd', - }, - { - name: 'force', - type: 'boolean', - description: 'overwrite an existing file at the target path', - }, - ], - effect: 'write', - }, - { - id: 'import', - label: 'Import YAML', - description: - 'Import an external workflow/agent YAML into the project (validated, deduplicated).', - args: [ - { - name: 'path', - type: 'string', - required: true, - description: 'path to the .relavium.yaml / .agent.yaml file', - }, - { - name: 'force', - type: 'boolean', - description: 'overwrite an existing project entry with the same id', - }, - ], - effect: 'write', - }, - { - id: 'agent.run', - label: 'Run agent (one-shot)', - description: - 'Run a single agent one-shot (prompt on stdin); --fixture replays a recorded cassette.', - args: [ - { - name: 'agent', - type: 'string', - required: true, - description: 'agent .agent.yaml path or .relavium/ id', - }, - { name: 'input', type: 'string', description: 'a session {{ctx.*}} variable (repeatable)' }, - { - name: 'fixture', - type: 'string', - description: 'replay a recorded LLM cassette (deterministic, offline)', - }, - { - name: 'allowMcpStdio', - type: 'string', - description: - 'authorize a stdio MCP server by its consent digest for this invocation (repeatable)', - }, - ], - effect: 'write', - }, - { - id: 'gate', - label: 'Resolve gate', - description: 'Resolve a pending human gate (approve / reject / input).', - args: [ - { - name: 'runId', - type: 'string', - description: 'the paused run (omit only with the list subcommand)', - }, - { name: 'approve', type: 'boolean', description: 'approve the gate' }, - { name: 'reject', type: 'boolean', description: 'reject the gate' }, - { - name: 'comment', - type: 'string', - description: 'a decision comment (with --approve / --reject)', - }, - { - name: 'input', - type: 'string', - description: 'provide input for a gate_type=input gate (JSON, else a raw string)', - }, - { - name: 'gate', - type: 'string', - description: 'which pending gate to resolve (required when more than one is pending)', - }, - { - name: 'secretStdin', - type: 'boolean', - description: - "read the run's secret inputs from stdin as name=value lines (never passed as arguments)", - }, - { - name: 'allowMcpStdio', - type: 'string', - description: - 'authorize a stdio MCP server by its consent digest for this invocation (repeatable)', - }, - ], - effect: 'write', - }, - { - id: 'budget.resume', - label: 'Resolve budget gate', - description: 'Approve the exact frozen microcents or reject a budget gate.', - args: [ - { - name: 'runId', - type: 'string', - required: true, - description: 'the budget-paused run', - }, - { - name: 'approveAmount', - type: 'string', - description: 'approve exactly the recorded frozen amount (including zero)', - }, - { - name: 'abort', - type: 'boolean', - description: 'reject the budget gate without provider or tool execution', - }, - { - name: 'gate', - type: 'string', - description: 'which budget gate to resolve when multiple are pending', - }, - { - name: 'secretStdin', - type: 'boolean', - description: - "read the run's secret inputs from stdin as name=value lines (never passed as arguments)", - }, - { - name: 'allowMcpStdio', - type: 'string', - description: - 'authorize a stdio MCP server by its consent digest for this invocation (repeatable)', - }, - ], - effect: 'write', - }, - { - id: 'gate.list', - label: 'List gates', - description: 'List pending human gates (all paused runs, or one run).', - args: [ - { - name: 'runId', - type: 'string', - description: 'a single run to inspect (omit ⇒ all paused runs)', - }, - ], - effect: 'read', - }, - { - id: 'list', - label: 'List workflows', - description: 'List discovered workflows (or, with --agents, agents) in the current project.', - args: [{ name: 'agents', type: 'boolean', description: 'list agents instead of workflows' }], - effect: 'read', - }, - { - id: 'logs', - label: 'Show run logs', - description: 'Print the persisted event stream for a past run.', - args: [{ name: 'runId', type: 'string', required: true, description: 'the run to print' }], - effect: 'read', - }, - { - id: 'status', - label: 'Show status', - description: 'Show active/paused runs and their per-node status.', - effect: 'read', - }, - { - id: 'models', - label: 'List models', - description: 'List the cached model catalog (refreshes on first run if empty).', - effect: 'read', - }, - { - id: 'models.refresh', - label: 'Refresh models', - description: - "Re-fetch what we know about models: each connected provider's live list, and the models.dev catalog.", - args: [ - { - name: 'providers', - type: 'boolean', - description: "availability only — each connected provider's live model list", - }, - { - name: 'catalog', - type: 'boolean', - description: 'metadata only — prices, ceilings and reasoning tiers from models.dev', - }, - ], - effect: 'write', - }, - { - id: 'models.pricing', - label: 'Set model pricing', - description: - 'Set your own price for a model — it overrides the catalog (you hold the invoice). --clear removes it.', - args: [ - { name: 'model', type: 'string', required: true, description: 'the model id to price' }, - { - name: 'provider', - type: 'string', - required: true, - description: 'the provider that serves the model (must be registered)', - }, - { - // Not `required` at the manifest level, because `--clear` takes none of the price flags and a - // media-only invocation takes neither token flag. The command's arg builder enforces the real rule. - name: 'input', - type: 'string', - description: - 'input (prompt) price, USD per million tokens — required WITH --output when either is given; omit both to price only media', - }, - { - name: 'output', - type: 'string', - description: - 'output (completion) price, USD per million tokens — required WITH --input when either is given; omit both to price only media', - }, - { - name: 'cached', - type: 'string', - description: - "cache-read price, USD per million tokens; omitted ⇒ the catalog's cache discount, applied to your input rate", - }, - { - // Per BILLED UNIT, never per Mtok (ADR-0044 §3's canonical units; ADR-0089 §4). No shipped catalog row - // carries a media rate, so these are currently the ONLY way to price one — and under `strict_cost_cap` - // an unpriced modality is refused, which makes them the remedy that refusal points at. - name: 'image', - type: 'string', - description: 'image-output price, USD per image', - }, - { - name: 'audio', - type: 'string', - description: 'audio-output price, USD per second', - }, - { - name: 'video', - type: 'string', - description: 'video-output price, USD per second', - }, - { - name: 'clear', - type: 'boolean', - description: "remove your price for this model — it falls back to the catalog's", - }, - ], - effect: 'write', - }, - { - id: 'provider.list', - label: 'List providers', - description: 'List registered providers and whether a key is set.', - args: [ - { - name: 'verify', - type: 'boolean', - description: 'additionally run a live key-verification probe per provider', - }, - ], - effect: 'read', - }, - { - id: 'provider.add', - label: 'Add provider', - description: 'Register a provider.', - args: [ - { - name: 'name', - type: 'string', - required: true, - description: 'provider name (e.g. anthropic)', - }, - { name: 'baseUrl', type: 'string', description: 'override the provider base URL' }, - { - name: 'pricingUrl', - type: 'string', - description: 'override the pricing reference page (where you find model prices)', - }, - ], - effect: 'write', - }, - { - id: 'provider.set-key', - label: 'Set provider key', - description: 'Store a provider API key in the OS keychain (the key is read from stdin).', - args: [ - { - name: 'name', - type: 'string', - required: true, - description: 'provider name (e.g. anthropic)', - }, - ], - effect: 'write', - }, - { - id: 'media.prune-temporaries', - label: 'Prune legacy media temporaries', - description: 'Remove legacy temporary media after all old producers are stopped.', - args: [ - { - name: 'legacyOffline', - type: 'boolean', - description: 'acknowledge that all old media producers are stopped', - }, - { name: 'force', type: 'boolean', description: 'authorize legacy temporary media deletion' }, - ], - effect: 'destructive', - }, - { - id: 'provider.remove-key', - label: 'Remove provider key', - description: 'Remove a provider API key from the OS keychain.', - args: [ - { - name: 'name', - type: 'string', - required: true, - description: 'provider name (e.g. anthropic)', - }, - ], - // destructive: irreversibly removes a stored credential (approval enforcement is 2.5.E/ADR-0057). - effect: 'destructive', - }, - { - id: 'provider.test', - label: 'Test provider key', - description: 'Verify a provider key with a minimal live request.', - args: [ - { - name: 'name', - type: 'string', - required: true, - description: 'provider name (e.g. anthropic)', - }, - { - name: 'model', - type: 'string', - description: 'model to test with (defaults to a cheap known model)', - }, - ], - effect: 'read', - }, -]; +/** The strict model projection derives from the single descriptor authority; CLI-only metadata stays out. */ +const ENTRIES: readonly CommandManifestEntry[] = legacyManifestEntries(); /** Freeze an entry and its nested `args` (items + array) so the manifest is immutable at runtime, not just in the type system. */ function freezeEntry(entry: CommandManifestEntry): CommandManifestEntry { @@ -542,7 +97,7 @@ function freezeEntry(entry: CommandManifestEntry): CommandManifestEntry { return Object.freeze(entry); } -/** The validated, deep-frozen command manifest — the single source for `commander` + the `executeCommand` table + `--help --json`. */ +/** The validated, deep-frozen command manifest — the compatible alias-free model projection. Enriched CLI discovery has its own DTO. */ export const COMMAND_MANIFEST: readonly CommandManifestEntry[] = Object.freeze( CommandManifestSchema.parse(ENTRIES).map(freezeEntry), ); diff --git a/apps/cli/src/commands/specs-forwarding.test.ts b/apps/cli/src/commands/specs-forwarding.test.ts index 8fdf8108..4bf87d12 100644 --- a/apps/cli/src/commands/specs-forwarding.test.ts +++ b/apps/cli/src/commands/specs-forwarding.test.ts @@ -15,11 +15,10 @@ import { captureIo } from '../test-support.js'; // `vi.hoisted` runs before the hoisted `vi.mock` + the imports, so the mock factory can close over this fn. The // generic signature (not named params) types `mock.calls` as the (id, input, ctx) tuple with no unused bindings. const { executeCommand } = vi.hoisted(() => ({ - executeCommand: vi.fn<(id: string, input: unknown, ctx: unknown) => Promise>(() => + executeCommand: vi.fn<(id: string, input: unknown, ctx: unknown) => Promise<0>>(() => Promise.resolve(0), ), })); -vi.mock('./dispatch.js', () => ({ executeCommand })); /** Parse `argv` through the real commander program (with a live context) and return the dispatched (id, input). */ function drive(argv: readonly string[]): { id: unknown; input: unknown } { @@ -36,7 +35,7 @@ function drive(argv: readonly string[]): { id: unknown; input: unknown } { }, result: {}, }; - const program = buildProgram(io, { context }); + const program = buildProgram(io, { context, execute: executeCommand }); program.exitOverride(); // `.parse` (sync) invokes the action, which calls executeCommand synchronously BEFORE its first await — so the // mock has recorded the call by the time parse returns, even though the action's promise is not awaited here. diff --git a/apps/cli/src/commands/specs.ts b/apps/cli/src/commands/specs.ts index 8beaf7cd..c0e13ca8 100644 --- a/apps/cli/src/commands/specs.ts +++ b/apps/cli/src/commands/specs.ts @@ -1,802 +1,159 @@ -import { CommanderError, type Command } from 'commander'; - -import { CliError } from '../process/errors.js'; -import { type ExitCode } from '../process/exit-codes.js'; -import type { CliIo } from '../process/io.js'; -import type { GlobalOptions } from '../process/options.js'; -import { executeCommand } from './dispatch.js'; - -/** - * The documented command surface (canonical home: - * [commands.md](../../../../docs/reference/cli/commands.md)). Every real command's registration here is a thin - * `commander` adapter: it parses argv into a uniform {@link import('./dispatch.js').CommandInput} and calls the - * shared {@link executeCommand} table ([dispatch.ts](dispatch.ts), [ADR-0056](../../../../docs/decisions/0056-cli-in-app-slash-command-system-and-manifest.md)), - * so the per-command dependency wiring lives once and the `commander` / palette / slash surfaces can never - * diverge. The remaining `STUB_COMMANDS` contains `init` (a later workstream) — - * each shows the documented "not available yet (lands in …)" message until it lands. - */ - -/** The runtime context the real commands need; the boundary reads `result.exitCode` after parse. */ -export interface CommandContext { - readonly io: CliIo; - readonly global: GlobalOptions; +import { CommanderError, Option, type Command } from 'commander'; + +import { CliError } from '../process/cli-error.js'; +import type { ExitCode } from '../process/exit-codes.js'; +import { + COMMAND_DESCRIPTORS, + compatibilityOptions, + compatibilityPath, + type CommandDescriptor, + type OptionDescriptor, +} from './descriptors.js'; +import type { CommandOptionValue, DispatchCommand, DispatchContext } from './input.js'; + +export interface CommandContext extends DispatchContext { readonly result: { exitCode?: ExitCode }; } -interface StubSpec { - readonly name: string; - readonly summary: string; - readonly landsIn: string; -} - -export const STUB_COMMANDS: readonly StubSpec[] = [ - { - name: 'init', - summary: 'Initialize a .relavium/ directory in the current project.', - landsIn: 'a later workstream', - }, -]; - -export function registerCommands(program: Command, ctx?: CommandContext): void { - registerRun(program, ctx); - registerChat(program, ctx); - registerChatResume(program, ctx); - registerChatList(program, ctx); - registerChatExport(program, ctx); - registerCreate(program, ctx); - registerExport(program, ctx); - registerImport(program, ctx); - registerAgent(program, ctx); - registerGate(program, ctx); - registerBudget(program, ctx); - registerModels(program, ctx); - registerProvider(program, ctx); - registerMedia(program, ctx); - registerList(program, ctx); - registerLogs(program, ctx); - registerStatus(program, ctx); - for (const spec of STUB_COMMANDS) { - program - .command(spec.name) - .description(spec.summary) - .action(() => { - throw new CliError( - 'not_implemented', - `\`relavium ${commandWord(spec.name)}\` is not available yet (lands in ${spec.landsIn}).`, - ); +/** Availability derives from the descriptor authority, not a separately authored stub list. */ +export const STUB_COMMANDS = Object.freeze( + COMMAND_DESCRIPTORS.filter((entry) => entry.availability === 'not_implemented').map((entry) => + Object.freeze({ + name: entry.path.join(' '), + summary: entry.description, + landsIn: entry.landsIn ?? 'a later workstream', + }), + ), +); + +function flags(option: OptionDescriptor): string { + const base = option.spellings.join(', '); + if (option.arity === 'none') return base; + const value = `${option.placeholder ?? 'value'}${option.repetition === 'variadic' ? '...' : ''}`; + return `${base} ${option.arity === 'required' ? `<${value}>` : `[${value}]`}`; +} + +/** Current compatibility grammar only; canonical aliases and consent integrate in Step 10. */ +export function registerCommands( + program: Command, + context?: CommandContext, + execute?: DispatchCommand, +): void { + const registered = new Map(); + registered.set('[]', program); + for (const descriptor of COMMAND_DESCRIPTORS) { + const path = compatibilityPath(descriptor); + const parent = registered.get(JSON.stringify(path.slice(0, -1))); + const word = path.at(-1); + if (parent === undefined || word === undefined) throw new CliError('cli_internal', ''); + const command = + descriptor.opaqueParserHint === undefined ? parent.command(word) : parent.createCommand(word); + if (descriptor.opaqueParserHint !== undefined) { + parent.addCommand(command); + const hint = descriptor.opaqueParserHint; + command.configureOutput({ + ...parent.configureOutput(), + outputError: (_message, write) => write(`${hint}\n`), }); - } -} - -function registerRun(program: Command, ctx?: CommandContext): void { - // `command()` shares the parent's output configuration object. A prepared subcommand gets a - // separate copy, so its parser faults can be made opaque without changing root/other commands. - const run = program - .createCommand('run') - .argument('') - .description('Execute a workflow (path or id), streaming progress.') - .option('--input ', 'a workflow input (repeatable)') - .option('--secret-inputs-stdin', 'read declared secret inputs from non-TTY stdin') - .option( - '--allow-mcp-stdio ', - 'authorize a stdio MCP server by its consent digest for this invocation (repeatable)', - ) - .allowExcessArguments(false) - .configureOutput({ - ...program.configureOutput(), - outputError: (_message, write) => - write('error: invalid run arguments; use relavium run --help.\n'), - }) - .exitOverride((error) => { - if (error.exitCode === 0) throw error; - throw new CommanderError( - error.exitCode, - error.code, - 'error: invalid run arguments; use relavium run --help.', - ); - }); - program.addCommand(run); - - if (ctx === undefined) { - // No runtime context (e.g. a bare buildProgram for help rendering) — keep it a clean stub. - run.action(() => { - throw new CliError('not_implemented', '`relavium run` requires the CLI runtime context.'); - }); - return; - } - - run.action( - async ( - workflow: string, - opts: { - input?: readonly string[]; - secretInputsStdin?: boolean; - allowMcpStdio?: readonly string[]; - }, - ) => { - ctx.result.exitCode = await executeCommand( - 'run', - // Every registered option must be FORWARDED, not merely registered: `opts` is destructured by name - // here, so an option missing from this type is parsed by commander and then silently dropped before - // `buildRunArgs` can see it. `--allow-mcp-stdio` was — which made ADR-0084 §6's CI escape hatch - // inoperative end to end while its unit tests passed, because they call the gate directly. - { - positionals: [workflow], - options: { - input: opts.input, - secretInputsStdin: opts.secretInputsStdin, - allowMcpStdio: opts.allowMcpStdio, - }, - }, - ctx, - ); - }, - ); -} - -/** - * Register `relavium chat [--agent ]` (2.M — the agent-first interactive REPL over `AgentSession`). The - * dispatch wires the keychain-backed key resolver (2.C), durable session persistence (2.H/ADR-0050), and the - * TTY-aware driver. `/exit` ends the session with exit code 4. - */ -function registerChat(program: Command, ctx?: CommandContext): void { - const chat = program - .command('chat') - .description('Start an interactive agent chat session (REPL).') - .option('--agent ', 'bind a specific agent (.agent.yaml path or .relavium/ id)'); - - if (ctx === undefined) { - chat.action(() => { - throw new CliError('not_implemented', '`relavium chat` requires the CLI runtime context.'); - }); - return; - } - - chat.action(async (opts: { agent?: string }) => { - ctx.result.exitCode = await executeCommand( - 'chat', - { positionals: [], options: { agent: opts.agent } }, - ctx, - ); - }); -} - -/** - * Register `relavium chat-resume ` (2.N) — reload a persisted session from `history.db` and continue - * it in the same REPL. An unknown session id is a clean exit-2 invocation fault; `/exit` ends with exit code 4. - */ -function registerChatResume(program: Command, ctx?: CommandContext): void { - const chatResume = program - .command('chat-resume ') - .description('Reload a persisted session from history.db and continue the conversation.'); - - if (ctx === undefined) { - chatResume.action(() => { - throw new CliError( - 'not_implemented', - '`relavium chat-resume` requires the CLI runtime context.', - ); - }); - return; - } - - chatResume.action(async (sessionId: string) => { - ctx.result.exitCode = await executeCommand( - 'chat-resume', - { positionals: [sessionId], options: {} }, - ctx, - ); - }); -} - -/** Register `relavium chat-list` (2.O) — list past agent sessions from durable `history.db` (id, agent, last activity). */ -function registerChatList(program: Command, ctx?: CommandContext): void { - const chatList = program - .command('chat-list') - .description('List past agent sessions (id, agent, title, last activity).'); - if (ctx === undefined) { - chatList.action(() => { - throw new CliError( - 'not_implemented', - '`relavium chat-list` requires the CLI runtime context.', - ); - }); - return; - } - chatList.action(async () => { - ctx.result.exitCode = await executeCommand('chat-list', { positionals: [], options: {} }, ctx); - }); -} - -/** - * Register `relavium chat-export ` (2.P) — export a persisted session to a `.relavium.yaml` - * scaffold for review ([ADR-0026](../../../../docs/decisions/0026-session-export-to-workflow.md)). Writes - * `.relavium.yaml` in cwd by default; `--out ` overrides, `--force` overwrites an existing file. - */ -function registerChatExport(program: Command, ctx?: CommandContext): void { - const chatExport = program - .command('chat-export ') - .description('Export a session to a .relavium.yaml scaffold for review (ADR-0026).') - .option('--out ', 'write the scaffold here instead of .relavium.yaml') - .option('--force', 'overwrite an existing file at the target path'); - if (ctx === undefined) { - chatExport.action(() => { - throw new CliError( - 'not_implemented', - '`relavium chat-export` requires the CLI runtime context.', - ); - }); - return; - } - chatExport.action(async (sessionId: string, opts: { out?: string; force?: boolean }) => { - ctx.result.exitCode = await executeCommand( - 'chat-export', - { positionals: [sessionId], options: { out: opts.out, force: opts.force } }, - ctx, - ); - }); -} - -/** - * Register `relavium create` (2.J) — an interactive `@clack/prompts` wizard that scaffolds a new agent or a - * minimal single-agent workflow as schema-validated, git-ready YAML under `.relavium/`. Needs an interactive - * terminal (fails loud under `--json` / non-TTY); a name collision is exit 2 unless `--force`. No keychain/db. - */ -function registerCreate(program: Command, ctx?: CommandContext): void { - const create = program - .command('create') - .description('Scaffold a new agent or workflow via an interactive wizard.') - .option('--force', 'overwrite an existing project entry with the same id'); - if (ctx === undefined) { - create.action(() => { - throw new CliError('not_implemented', '`relavium create` requires the CLI runtime context.'); - }); - return; - } - create.action(async (opts: { force?: boolean }) => { - ctx.result.exitCode = await executeCommand( - 'create', - { positionals: [], options: { force: opts.force } }, - ctx, - ); - }); -} - -/** - * Register `relavium export ` (2.J) — write a portable, share-safe copy of a project workflow/agent - * (re-serialized from the validated AST; `{{secrets.*}}` placeholders preserved, no resolved secret). Default - * target is `.` in cwd; `--out` overrides, `--force` overwrites. Pure YAML I/O — no keychain/db. - */ -function registerExport(program: Command, ctx?: CommandContext): void { - const exportCmd = program - .command('export ') - .description( - 'Export a workflow/agent to a portable YAML (secret references stay placeholdered).', - ) - .option('--out ', 'write the copy here instead of . in cwd') - .option('--force', 'overwrite an existing file at the target path'); - if (ctx === undefined) { - exportCmd.action(() => { - throw new CliError('not_implemented', '`relavium export` requires the CLI runtime context.'); - }); - return; - } - exportCmd.action(async (id: string, opts: { out?: string; force?: boolean }) => { - ctx.result.exitCode = await executeCommand( - 'export', - { positionals: [id], options: { out: opts.out, force: opts.force } }, - ctx, - ); - }); -} - -/** - * Register `relavium import ` (2.J) — copy an external workflow/agent YAML into the project `.relavium/`, - * validating schema + slug uniqueness, writing the re-serialized doc to `.relavium//.`. A - * collision is exit 2 unless `--force`. Pure YAML I/O — no keychain/db. - */ -function registerImport(program: Command, ctx?: CommandContext): void { - const importCmd = program - .command('import ') - .description( - 'Import an external workflow/agent YAML into the project (validated, deduplicated).', - ) - .option('--force', 'overwrite an existing project entry with the same id'); - if (ctx === undefined) { - importCmd.action(() => { - throw new CliError('not_implemented', '`relavium import` requires the CLI runtime context.'); - }); - return; - } - importCmd.action(async (path: string, opts: { force?: boolean }) => { - ctx.result.exitCode = await executeCommand( - 'import', - { positionals: [path], options: { force: opts.force } }, - ctx, - ); - }); -} - -/** - * Register `relavium agent run ` (2.Q) — a one-shot, non-interactive agent invocation over the same - * `AgentSession` infra. The prompt is piped on stdin; `--input k=v` adds `{{ctx.*}}` variables; `--fixture` - * replays a recorded cassette (offline). A bare `relavium agent` (no subcommand) is a clean exit-2 fault. - */ -function registerAgent(program: Command, ctx?: CommandContext): void { - const agent = program.command('agent').description('Manage and run agents.'); - const run = agent - .command('run ') - .description( - 'Run a single agent one-shot (prompt on stdin); --fixture replays a recorded cassette.', - ) - .option('--input ', 'a session {{ctx.*}} variable (repeatable)') - .option('--fixture ', 'replay a recorded LLM cassette (deterministic, offline)') - .option( - '--allow-mcp-stdio ', - 'authorize a stdio MCP server by its consent digest for this invocation (repeatable)', - ); - - if (ctx === undefined) { - run.action(() => { - throw new CliError( - 'not_implemented', - '`relavium agent run` requires the CLI runtime context.', - ); - }); - agent.action(() => { - throw new CliError('not_implemented', '`relavium agent` requires the CLI runtime context.'); - }); - return; - } - - run.action( - async ( - agentRef: string, - opts: { - input?: readonly string[]; - fixture?: string; - allowMcpStdio?: readonly string[]; - }, - ) => { - ctx.result.exitCode = await executeCommand( - 'agent.run', - { - positionals: [agentRef], - options: { - input: opts.input, - fixture: opts.fixture, - allowMcpStdio: opts.allowMcpStdio, - }, - }, - ctx, - ); - }, - ); - // A bare `relavium agent` (no `run`) is a clean invocation fault, not a thrown stack. - agent.action(() => { - throw new CliError('invalid_invocation', '`relavium agent` requires a subcommand (run).'); - }); -} - -/** - * Register `relavium gate [runId]` (2.G — resolve a pending human gate over the durable resume substrate) plus - * its `gate list [runId]` subcommand (2.I — list the pending gates so an operator picks a `gateId`). The - * positional is OPTIONAL so commander routes `gate list …` to the subcommand; a bare `gate` (no runId, no - * subcommand) falls through to the parent action, which reports the missing runId as a clean exit-2 invocation. - */ -function registerGate(program: Command, ctx?: CommandContext): void { - const gate = program - .command('gate [runId]') - .description('Resolve a pending human gate (approve / reject / input).') - .option('--approve', 'approve the gate') - .option('--reject', 'reject the gate') - .option('--comment ', 'a decision comment (with --approve / --reject)') - .option('--input ', 'provide input for a gate_type=input gate (JSON, else a raw string)') - .option( - '--gate ', - 'which pending gate to resolve (required when more than one is pending)', - ) - .option( - '--secret-stdin', - "read the run's secret inputs from stdin as name=value lines (never passed as arguments)", - ) - .option( - '--allow-mcp-stdio ', - 'authorize a stdio MCP server by its consent digest for this invocation (repeatable)', - ); - const gateList = gate - .command('list [runId]') - .description('List pending human gates (all paused runs, or one run).'); - - if (ctx === undefined) { - gate.action(() => { - throw new CliError('not_implemented', '`relavium gate` requires the CLI runtime context.'); - }); - gateList.action(() => { - throw new CliError( - 'not_implemented', - '`relavium gate list` requires the CLI runtime context.', + command.exitOverride((error) => { + if (error.exitCode === 0) throw error; + throw new CommanderError(error.exitCode, error.code, hint); + }); + } + command.description(descriptor.description); + if (descriptor.allowExcessArguments !== undefined) + command.allowExcessArguments(descriptor.allowExcessArguments); + for (const positional of descriptor.positionals) { + const name = `${positional.name}${positional.variadic ? '...' : ''}`; + // Existing human argument-help layout is preserved; discovery separately carries descriptions. + command.argument(positional.required ? `<${name}>` : `[${name}]`); + } + for (const option of compatibilityOptions(descriptor)) { + const parsed = new Option(flags(option), option.description).makeOptionMandatory( + option.required, ); - }); - return; + if (option.default !== undefined) parsed.default(option.default); + if (option.conflicts.length > 0) parsed.conflicts([...option.conflicts]); + command.addOption(parsed); + } + registerAction(command, descriptor, path, context, execute); + registered.set(JSON.stringify(path), command); } - - gate.action( - async ( - runId: string | undefined, - opts: { - approve?: boolean; - reject?: boolean; - comment?: string; - input?: string; - gate?: string; - secretStdin?: boolean; - allowMcpStdio?: readonly string[]; - }, - ) => { - ctx.result.exitCode = await executeCommand( - 'gate', - { - positionals: runId === undefined ? [] : [runId], - options: { - approve: opts.approve, - reject: opts.reject, - comment: opts.comment, - input: opts.input, - gate: opts.gate, - secretStdin: opts.secretStdin, - allowMcpStdio: opts.allowMcpStdio, - }, - }, - ctx, - ); - }, - ); - - gateList.action(async (runId: string | undefined) => { - ctx.result.exitCode = await executeCommand( - 'gate.list', - { positionals: runId === undefined ? [] : [runId], options: {} }, - ctx, - ); - }); } -function registerBudget(program: Command, ctx?: CommandContext): void { - const budget = program.command('budget').description('Resolve recorded budget authorization.'); - const resume = budget - .command('resume ') - .description('Approve the exact frozen microcents or reject a budget gate.') - .option( - '--approve-amount ', - 'approve exactly the recorded frozen amount (including zero)', - ) - .option('--abort', 'reject the budget gate without provider or tool execution') - .option('--gate ', 'which budget gate to resolve when multiple are pending') - .option( - '--secret-stdin', - "read the run's secret inputs from stdin as name=value lines (never passed as arguments)", - ) - .option( - '--allow-mcp-stdio ', - 'authorize a stdio MCP server by its consent digest for this invocation (repeatable)', - ); - if (ctx === undefined) { - resume.action(() => { +function registerAction( + command: Command, + descriptor: CommandDescriptor, + path: readonly string[], + context?: CommandContext, + execute?: DispatchCommand, +): void { + const name = path.join(' '); + if (descriptor.availability === 'not_implemented') { + command.action(() => { throw new CliError( 'not_implemented', - '`relavium budget resume` requires the CLI runtime context.', + `\`relavium ${name}\` is not available yet (lands in ${descriptor.landsIn ?? 'a later workstream'}).`, ); }); return; } - resume.action( - async ( - runId: string, - opts: { - approveAmount?: string; - abort?: boolean; - gate?: string; - secretStdin?: boolean; - allowMcpStdio?: readonly string[]; - }, - ) => { - ctx.result.exitCode = await executeCommand( - 'budget.resume', - { - positionals: [runId], - options: { - approveAmount: opts.approveAmount, - abort: opts.abort, - gate: opts.gate, - secretStdin: opts.secretStdin, - allowMcpStdio: opts.allowMcpStdio, - }, - }, - ctx, - ); - }, - ); -} - -/** Register `relavium list [--agents]` (2.I) — the disk catalog + last-run overlay from durable history. */ -function registerList(program: Command, ctx?: CommandContext): void { - const list = program - .command('list') - .description('List discovered workflows (or, with --agents, agents) in the current project.') - .option('--agents', 'list agents instead of workflows'); - if (ctx === undefined) { - list.action(() => { - throw new CliError('not_implemented', '`relavium list` requires the CLI runtime context.'); - }); - return; - } - list.action(async (opts: { agents?: boolean }) => { - ctx.result.exitCode = await executeCommand( - 'list', - { positionals: [], options: { agents: opts.agents } }, - ctx, - ); - }); -} - -/** Register `relavium logs ` (2.I) — replay a past run's persisted event stream. */ -function registerLogs(program: Command, ctx?: CommandContext): void { - const logs = program - .command('logs ') - .description('Print the persisted event stream for a past run.'); - if (ctx === undefined) { - logs.action(() => { - throw new CliError('not_implemented', '`relavium logs` requires the CLI runtime context.'); - }); + if (descriptor.executorId === undefined) { + if (descriptor.requiredSubcommand !== undefined) { + command.action(() => { + if (context === undefined) throw missingContext(name); + throw new CliError( + 'invalid_invocation', + `\`relavium ${name}\` requires a subcommand (${descriptor.requiredSubcommand}).`, + ); + }); + } return; } - logs.action(async (runId: string) => { - ctx.result.exitCode = await executeCommand('logs', { positionals: [runId], options: {} }, ctx); - }); -} - -/** Register `relavium status` (2.I) — the active/paused runs + their per-node status. */ -function registerStatus(program: Command, ctx?: CommandContext): void { - const status = program - .command('status') - .description('Show active/paused runs and their per-node status.'); - if (ctx === undefined) { - status.action(() => { - throw new CliError('not_implemented', '`relavium status` requires the CLI runtime context.'); + if (context === undefined) { + command.action(() => { + throw missingContext(name); }); return; } - status.action(async () => { - ctx.result.exitCode = await executeCommand('status', { positionals: [], options: {} }, ctx); + const executorId = descriptor.executorId; + command.action(async (...values: unknown[]) => { + if (execute === undefined) throw new CliError('cli_internal', ''); + const positionals: string[] = []; + for (let index = 0; index < descriptor.positionals.length; index += 1) { + const value = values[index]; + const positional = descriptor.positionals[index]; + if (value === undefined && positional?.required !== true) continue; + if (typeof value === 'string') positionals.push(value); + else if (positional?.variadic === true) positionals.push(...stringVector(value)); + else throw new CliError('cli_internal', ''); + } + const raw: unknown = command.opts(); + const options: Record = {}; + for (const option of compatibilityOptions(descriptor)) + options[option.binding] = readOption(raw, option.binding); + context.result.exitCode = await execute(executorId, { positionals, options }, context); }); } -/** - * Register `relavium models` (list the cached catalog) + `relavium models refresh` (force a live re-fetch) - * (2.5.G S5, [ADR-0064](../../../../docs/decisions/0064-live-model-catalog.md)). The parent `models` action - * lists the cache (refreshing on first run if empty); the `refresh` subcommand blocks on a live re-fetch and - * reports per-provider outcomes. Both honor `--json`. Each dispatch opens the local db + keychain per invocation. - */ -function registerModels(program: Command, ctx?: CommandContext): void { - const models = program - .command('models') - .description('List the cached model catalog (refreshes on first run if empty).'); - const refresh = models - .command('refresh') - .description( - "Re-fetch what we know about models: each connected provider's live list, and the models.dev catalog.", - ) - .option('--providers', "availability only — each connected provider's live model list") - .option('--catalog', 'metadata only — prices, ceilings and reasoning tiers from models.dev'); - const pricing = models - .command('pricing ') - .description( - 'Set your own price for a model — it overrides the catalog (you hold the invoice). --clear removes it.', - ) - .requiredOption('--provider ', 'the provider that serves the model (must be registered)') - // NOT `requiredOption`: `--clear` takes none of the three price flags, and commander would reject the invocation - // before the command ever sees it. The real rule — exactly one of "set a price" or "--clear" — is enforced in - // `buildModelsPricingArgs`, which can express it; commander's required-flag check cannot. - .option( - '--input ', - 'input (prompt) price, USD per million tokens — required WITH --output when either is given; omit both to price only media', - ) - .option( - '--output ', - 'output (completion) price, USD per million tokens — required WITH --input when either is given; omit both to price only media', - ) - .option( - '--cached ', - "cache-read price, USD per million tokens; omitted ⇒ the catalog's cache discount, applied to your input rate", - ) - .option('--image ', 'image-output price, USD per image') - .option('--audio ', 'audio-output price, USD per second') - .option('--video ', 'video-output price, USD per second') - .option('--clear', "remove your price for this model — it falls back to the catalog's"); - - if (ctx === undefined) { - models.action(() => { - throw new CliError('not_implemented', '`relavium models` requires the CLI runtime context.'); - }); - refresh.action(() => { - throw new CliError( - 'not_implemented', - '`relavium models refresh` requires the CLI runtime context.', - ); - }); - pricing.action(() => { - throw new CliError( - 'not_implemented', - '`relavium models pricing` requires the CLI runtime context.', - ); - }); - return; - } - - models.action(async () => { - ctx.result.exitCode = await executeCommand('models', { positionals: [], options: {} }, ctx); - }); - refresh.action(async (opts: { providers?: boolean; catalog?: boolean }) => { - ctx.result.exitCode = await executeCommand( - 'models.refresh', - { positionals: [], options: { providers: opts.providers, catalog: opts.catalog } }, - ctx, - ); - }); - pricing.action( - async ( - model: string, - opts: { - provider?: string; - input?: string; - output?: string; - cached?: string; - // The media rates are DECLARED as options above and must be forwarded here. They were not, so - // `relavium models pricing --provider p --image 0.04` reached the dispatch with no `image` - // and failed "missing required option --input" — the media-only invocation `CR-55` exists to make - // legal, and the exact command a `strict_cost_cap` refusal tells the user to run. - image?: string; - audio?: string; - video?: string; - clear?: boolean; - }, - ) => { - ctx.result.exitCode = await executeCommand( - 'models.pricing', - { - positionals: [model], - options: { - provider: opts.provider, - input: opts.input, - output: opts.output, - cached: opts.cached, - image: opts.image, - audio: opts.audio, - video: opts.video, - clear: opts.clear, - }, - }, - ctx, - ); - }, - ); +function missingContext(name: string): CliError { + return new CliError('not_implemented', `\`relavium ${name}\` requires the CLI runtime context.`); } -/** Register `relavium provider` and its subcommands (2.C). Each dispatch opens the local db + keychain per invocation. */ -function registerProvider(program: Command, ctx?: CommandContext): void { - const provider = program - .command('provider') - .description('Manage providers and API keys in the OS keychain.'); - const list = provider - .command('list') - .description('List registered providers and whether a key is set.') - .option('--verify', 'additionally run a live key-verification probe per provider'); - const add = provider - .command('add ') - .description('Register a provider.') - .option('--base-url ', 'override the provider base URL') - .option( - '--pricing-url ', - 'override the pricing reference page (where you find model prices)', - ); - const setKey = provider - .command('set-key ') - .description('Store a provider API key in the OS keychain (the key is read from stdin).'); - const removeKey = provider - .command('remove-key ') - .description('Remove a provider API key from the OS keychain.'); - const test = provider - .command('test ') - .description('Verify a provider key with a minimal live request.') - .option('--model ', 'model to test with (defaults to a cheap known model)'); - - if (ctx === undefined) { - // No runtime context (bare buildProgram for help) — keep each subcommand a clean stub. - for (const sub of [list, add, setKey, removeKey, test]) { - sub.action(() => { - throw new CliError( - 'not_implemented', - '`relavium provider` requires the CLI runtime context.', - ); - }); - } - return; +function stringVector(value: unknown): readonly string[] { + if (!Array.isArray(value)) throw new CliError('cli_internal', ''); + const values: readonly unknown[] = value; + const strings: string[] = []; + for (const item of values) { + if (typeof item !== 'string') throw new CliError('cli_internal', ''); + strings.push(item); } - - list.action(async (opts: { verify?: boolean }) => { - ctx.result.exitCode = await executeCommand( - 'provider.list', - { positionals: [], options: { verify: opts.verify } }, - ctx, - ); - }); - add.action(async (name: string, opts: { baseUrl?: string; pricingUrl?: string }) => { - ctx.result.exitCode = await executeCommand( - 'provider.add', - { positionals: [name], options: { baseUrl: opts.baseUrl, pricingUrl: opts.pricingUrl } }, - ctx, - ); - }); - setKey.action(async (name: string) => { - ctx.result.exitCode = await executeCommand( - 'provider.set-key', - { positionals: [name], options: {} }, - ctx, - ); - }); - removeKey.action(async (name: string) => { - ctx.result.exitCode = await executeCommand( - 'provider.remove-key', - { positionals: [name], options: {} }, - ctx, - ); - }); - test.action(async (name: string, opts: { model?: string }) => { - ctx.result.exitCode = await executeCommand( - 'provider.test', - { positionals: [name], options: { model: opts.model } }, - ctx, - ); - }); + return strings; } -/** First whitespace-delimited token of a command name — `logs ` → `logs`. */ -function commandWord(name: string): string { - return name.split(' ', 1)[0] ?? name; -} - -function registerMedia(program: Command, ctx?: CommandContext): void { - const media = program.command('media').description('Manage local media storage.'); - const prune = media.createCommand('prune-temporaries'); - media.addCommand(prune); - prune - .configureOutput({ - ...program.configureOutput(), - outputError: (_message, write) => write('error: invalid media maintenance arguments.\n'), - }) - .exitOverride((error) => { - if (error.exitCode === 0) throw error; - throw new CommanderError( - error.exitCode, - error.code, - 'error: invalid media maintenance arguments.', - ); - }) - .description('Remove legacy temporary media after all old producers are stopped.') - .option('--legacy-offline', 'acknowledge that all old media producers are stopped') - .option('--force', 'authorize legacy temporary media deletion') - .allowExcessArguments(false) - .action(async (opts: { legacyOffline?: boolean; force?: boolean }) => { - if (ctx === undefined) - throw new CliError( - 'not_implemented', - 'Media maintenance requires the CLI runtime context.', - ); - ctx.result.exitCode = await executeCommand( - 'media.prune-temporaries', - { positionals: [], options: { legacyOffline: opts.legacyOffline, force: opts.force } }, - ctx, - ); - }); +function readOption(raw: unknown, binding: string): CommandOptionValue { + if (typeof raw !== 'object' || raw === null) throw new CliError('cli_internal', ''); + const value: unknown = Object.getOwnPropertyDescriptor(raw, binding)?.value; + if (value === undefined || typeof value === 'string' || typeof value === 'boolean') return value; + return stringVector(value); } diff --git a/apps/cli/src/process/cli-error.test.ts b/apps/cli/src/process/cli-error.test.ts new file mode 100644 index 00000000..b783c80b --- /dev/null +++ b/apps/cli/src/process/cli-error.test.ts @@ -0,0 +1,144 @@ +import { RelaviumError } from '@relavium/shared/relavium-error'; +import { describe, expect, it } from 'vitest'; + +import { + CliError, + CLI_ERROR_POLICY, + cliErrorExitCode, + isCliError, + type CliErrorCode, + type CliFailureOrigin, + type UserFacingError, +} from './cli-error.js'; + +describe('closed CLI diagnostic authority', () => { + const codes: readonly CliErrorCode[] = [ + 'invalid_invocation', + 'config_error', + 'not_implemented', + 'run_owned_elsewhere', + 'internal', + 'cli_internal', + 'output_unavailable', + 'storage_unavailable', + 'config_write_refused', + ]; + + it('contains exactly the approved CLI codes in one immutable table', () => { + expect(Object.keys(CLI_ERROR_POLICY).sort()).toEqual([...codes].sort()); + expect(Object.isFrozen(CLI_ERROR_POLICY)).toBe(true); + for (const code of codes) expect(Object.isFrozen(CLI_ERROR_POLICY[code])).toBe(true); + }); + + it.each([ + ['invalid_invocation', 'invocation'], + ['config_error', 'invocation'], + ['not_implemented', 'invocation'], + ['run_owned_elsewhere', 'ownership'], + ['internal', 'operational'], + ['cli_internal', 'infrastructure'], + ['output_unavailable', 'output'], + ['storage_unavailable', 'storage'], + ['config_write_refused', 'invocation'], + ] satisfies readonly (readonly [CliErrorCode, CliFailureOrigin])[])( + 'assigns %s the explicit %s origin', + (code, origin) => { + expect(CLI_ERROR_POLICY[code].origin).toBe(origin); + }, + ); + + it.each([ + ['invalid_invocation', 2], + ['config_error', 2], + ['not_implemented', 2], + ['run_owned_elsewhere', 6], + ['internal', 1], + ['cli_internal', 9], + ['output_unavailable', 9], + ['storage_unavailable', 1], + ['config_write_refused', 2], + ] satisfies readonly (readonly [CliErrorCode, number])[])( + 'maps %s to explicit exit %s', + (code, exitCode) => { + expect(cliErrorExitCode(code)).toBe(exitCode); + expect(CLI_ERROR_POLICY[code].exitCode).toBe(exitCode); + expect(new CliError(code, 'constructor-message-canary').exitCode).toBe(exitCode); + }, + ); + + it.each([ + 'invalid_invocation', + 'config_error', + 'not_implemented', + 'run_owned_elsewhere', + 'internal', + ] satisfies readonly CliErrorCode[])( + 'preserves the historical constructor contract for %s', + (code) => { + const cause = { source: 'cause-identity-canary' }; + const error = new CliError(code, 'owned historical message', { cause }); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(RelaviumError); + expect(error.name).toBe('CliError'); + expect(error.code).toBe(code); + expect(error.message).toBe('owned historical message'); + expect(error.cause).toBe(cause); + expect(isCliError(error)).toBe(true); + expect(new CliError(code, 'without cause').cause).toBeUndefined(); + }, + ); + + it.each([ + 'cli_internal', + 'output_unavailable', + 'storage_unavailable', + 'config_write_refused', + ] satisfies readonly CliErrorCode[])( + 'fixes the %s message independently of arbitrary constructor text', + (code) => { + const cause = new Error('arbitrary-cause-secret-canary'); + const first = new CliError(code, 'first-arbitrary-secret-canary', { cause }); + const second = new CliError(code, 'second-arbitrary-secret-canary'); + const fixed = CLI_ERROR_POLICY[code].fixedMessage; + expect(fixed).toEqual(expect.any(String)); + expect(fixed?.length).toBeGreaterThan(0); + expect(first.message).toBe(fixed); + expect(second.message).toBe(fixed); + expect(first.cause).toBe(cause); + expect(first.name).toBe('CliError'); + expect(first.message).not.toContain('canary'); + const projection: UserFacingError = { + code: first.code, + message: first.message, + exitCode: first.exitCode, + }; + expect(JSON.stringify(projection)).not.toContain('canary'); + }, + ); + + it('keeps known operational internal/1 distinct from unexpected CLI infrastructure/9', () => { + expect(new CliError('internal', 'known history refusal').exitCode).toBe(1); + expect(new CliError('internal', 'known history refusal').message).toBe('known history refusal'); + expect(new CliError('cli_internal', 'private detail').exitCode).toBe(9); + expect(new CliError('cli_internal', 'private detail').message).toContain('--verbose'); + }); + + it('retains an inspection remedy before repeating potentially effectful infrastructure failures', () => { + for (const code of ['cli_internal', 'output_unavailable'] satisfies readonly CliErrorCode[]) { + expect(CLI_ERROR_POLICY[code].fixedMessage).toMatch(/inspect/i); + expect(CLI_ERROR_POLICY[code].fixedMessage).toMatch(/repeat|retry/i); + } + expect(CLI_ERROR_POLICY.storage_unavailable.fixedMessage).toMatch(/permission|capacity/i); + }); + + it.each([ + undefined, + null, + false, + 'cli_internal', + new Error('plain'), + { code: 'internal', name: 'CliError', exitCode: 1 }, + ])('does not promote arbitrary lookalike %j to the local class identity', (value) => + expect(isCliError(value)).toBe(false), + ); +}); diff --git a/apps/cli/src/process/cli-error.ts b/apps/cli/src/process/cli-error.ts new file mode 100644 index 00000000..27e5643d --- /dev/null +++ b/apps/cli/src/process/cli-error.ts @@ -0,0 +1,99 @@ +import { RelaviumError } from '@relavium/shared/relavium-error'; + +import { EXIT_CODES, type ExitCode } from './exit-codes.js'; + +/** CLI classifications only; engine ErrorCode and actual run outcomes remain separate. */ +export type CliErrorCode = + | 'invalid_invocation' + | 'config_error' + | 'not_implemented' + | 'run_owned_elsewhere' + /** Historical known operational refusals. Do not globally reinterpret this as infrastructure. */ + | 'internal' + | 'cli_internal' + | 'output_unavailable' + | 'storage_unavailable' + | 'config_write_refused'; + +export type CliFailureOrigin = + | 'invocation' + | 'operational' + | 'ownership' + | 'storage' + | 'output' + | 'infrastructure'; + +interface CliErrorPolicy { + readonly origin: CliFailureOrigin; + readonly exitCode: ExitCode; + readonly fixedMessage?: string; +} + +/** One lightweight code/origin/exit authority, shared by early and runtime boundaries. */ +export const CLI_ERROR_POLICY: Readonly> = Object.freeze({ + invalid_invocation: Object.freeze({ + origin: 'invocation', + exitCode: EXIT_CODES.invalidInvocation, + }), + config_error: Object.freeze({ origin: 'invocation', exitCode: EXIT_CODES.invalidInvocation }), + not_implemented: Object.freeze({ origin: 'invocation', exitCode: EXIT_CODES.invalidInvocation }), + run_owned_elsewhere: Object.freeze({ + origin: 'ownership', + exitCode: EXIT_CODES.runOwnedElsewhere, + }), + internal: Object.freeze({ origin: 'operational', exitCode: EXIT_CODES.workflowFailed }), + cli_internal: Object.freeze({ + origin: 'infrastructure', + exitCode: EXIT_CODES.infrastructureFailed, + fixedMessage: + 'An unexpected CLI failure occurred. Use --verbose for safe diagnostics. Inspect existing work before repeating paid or effectful operations.', + }), + output_unavailable: Object.freeze({ + origin: 'output', + exitCode: EXIT_CODES.infrastructureFailed, + fixedMessage: + 'CLI output could not be delivered. Inspect existing work before repeating paid or effectful operations.', + }), + storage_unavailable: Object.freeze({ + origin: 'storage', + exitCode: EXIT_CODES.workflowFailed, + fixedMessage: + 'Local storage is unavailable. Check permissions and free space. Inspect existing work before repeating paid or effectful operations.', + }), + config_write_refused: Object.freeze({ + origin: 'invocation', + exitCode: EXIT_CODES.invalidInvocation, + fixedMessage: + 'The settings file could not be safely updated. Use a regular writable file and retry the settings change.', + }), +}); + +export function cliErrorExitCode(code: CliErrorCode): ExitCode { + return CLI_ERROR_POLICY[code].exitCode; +} + +/** Existing family identity/name/cause semantics; new fixed diagnostics never promote incoming text. */ +export class CliError extends RelaviumError { + readonly code: CliErrorCode; + readonly exitCode: ExitCode; + + constructor(code: CliErrorCode, message: string, opts?: { readonly cause?: unknown }) { + super( + CLI_ERROR_POLICY[code].fixedMessage ?? message, + opts?.cause === undefined ? undefined : { cause: opts.cause }, + ); + this.name = 'CliError'; + this.code = code; + this.exitCode = cliErrorExitCode(code); + } +} + +export function isCliError(value: unknown): value is CliError { + return value instanceof CliError; +} + +export interface UserFacingError { + readonly code: CliErrorCode; + readonly message: string; + readonly exitCode: ExitCode; +} diff --git a/apps/cli/src/process/diagnostic-policy.test.ts b/apps/cli/src/process/diagnostic-policy.test.ts new file mode 100644 index 00000000..eb8611cf --- /dev/null +++ b/apps/cli/src/process/diagnostic-policy.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { + resolveDiagnosticPolicy, + shouldEmitCategory, + type DiagnosticPolicy, + type OutputCategory, +} from './diagnostic-policy.js'; + +describe('diagnostic policy resolution', () => { + it.each([undefined, '', '0', 'false', 'TRUE', '01', '1'])( + 'enables the debug alias only for exact RELAVIUM_DEBUG=%s', + (value) => { + expect(resolveDiagnosticPolicy({}, { RELAVIUM_DEBUG: value })).toEqual({ + json: false, + verbosity: value === '1' ? 'verbose' : 'normal', + }); + }, + ); + + it.each([undefined, '', '0', '1'])( + 'preserves explicit quiet and verbose precedence over environment %s', + (value) => { + const env = { RELAVIUM_DEBUG: value }; + expect(resolveDiagnosticPolicy({ quiet: true }, env)).toEqual({ + json: false, + verbosity: 'quiet', + }); + expect(resolveDiagnosticPolicy({ verbose: true }, env)).toEqual({ + json: false, + verbosity: 'verbose', + }); + }, + ); + + it.each([false, true])('refuses quiet plus verbose with JSON=%s', (json) => { + let failure: unknown; + try { + resolveDiagnosticPolicy({ quiet: true, verbose: true, json }, { RELAVIUM_DEBUG: '1' }); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ code: 'invalid_invocation', exitCode: 2 }); + }); + + it.each([{}, { CI: 'true' }, { TERM: 'dumb' }, { NO_COLOR: '1' }, { FORCE_COLOR: '1' }])( + 'never infers JSON or verbosity from unrelated environment %j', + (env) => { + expect(resolveDiagnosticPolicy({}, env)).toEqual({ json: false, verbosity: 'normal' }); + expect(resolveDiagnosticPolicy({ json: true }, env)).toEqual({ + json: true, + verbosity: 'normal', + }); + }, + ); + + it('does not mutate supplied flags or environment', () => { + const flags = Object.freeze({ quiet: true, json: true }); + const env = Object.freeze({ RELAVIUM_DEBUG: '1' }); + expect(resolveDiagnosticPolicy(flags, env)).toEqual({ json: true, verbosity: 'quiet' }); + expect(flags).toEqual({ quiet: true, json: true }); + expect(env).toEqual({ RELAVIUM_DEBUG: '1' }); + }); +}); + +describe('diagnostic output categories', () => { + const cases: readonly { + readonly policy: DiagnosticPolicy; + readonly decoration: boolean; + readonly notice: boolean; + readonly detail: boolean; + }[] = [ + { + policy: { json: false, verbosity: 'quiet' }, + decoration: false, + notice: false, + detail: false, + }, + { policy: { json: false, verbosity: 'normal' }, decoration: true, notice: true, detail: false }, + { policy: { json: false, verbosity: 'verbose' }, decoration: true, notice: true, detail: true }, + { policy: { json: true, verbosity: 'quiet' }, decoration: false, notice: false, detail: false }, + { policy: { json: true, verbosity: 'normal' }, decoration: false, notice: true, detail: false }, + { policy: { json: true, verbosity: 'verbose' }, decoration: false, notice: true, detail: true }, + ]; + + it.each(cases)('keeps requested/safety/compatibility output under $policy', ({ policy }) => { + const preserved: readonly OutputCategory[] = ['requested', 'safety', 'compatibility']; + for (const category of preserved) expect(shouldEmitCategory(policy, category)).toBe(true); + }); + + it.each(cases)( + 'applies decoration/notice/detail policy under $policy', + ({ policy, decoration, notice, detail }) => { + expect(shouldEmitCategory(policy, 'decoration')).toBe(decoration); + expect(shouldEmitCategory(policy, 'notice')).toBe(notice); + expect(shouldEmitCategory(policy, 'detail')).toBe(detail); + }, + ); +}); diff --git a/apps/cli/src/process/diagnostic-policy.ts b/apps/cli/src/process/diagnostic-policy.ts new file mode 100644 index 00000000..a36f8c6a --- /dev/null +++ b/apps/cli/src/process/diagnostic-policy.ts @@ -0,0 +1,51 @@ +import { CliError } from './cli-error.js'; + +export type Verbosity = 'quiet' | 'normal' | 'verbose'; + +export interface DiagnosticPolicy { + readonly json: boolean; + readonly verbosity: Verbosity; +} + +export type OutputCategory = + | 'requested' + | 'safety' + | 'decoration' + | 'notice' + | 'compatibility' + | 'detail'; + +/** Representation and verbosity are independent of TTY/CI. No service or ambient environment access. */ +export function resolveDiagnosticPolicy( + flags: { readonly json?: boolean; readonly quiet?: boolean; readonly verbose?: boolean }, + env: Readonly>, +): DiagnosticPolicy { + if (flags.quiet === true && flags.verbose === true) { + throw new CliError('invalid_invocation', '`--verbose` and `--quiet` cannot be combined.'); + } + return Object.freeze({ + json: flags.json === true, + verbosity: + flags.quiet === true + ? 'quiet' + : flags.verbose === true || env['RELAVIUM_DEBUG'] === '1' + ? 'verbose' + : 'normal', + }); +} + +/** Apply at semantic output owners, never as a blanket IO filter or implicit approval. */ +export function shouldEmitCategory(policy: DiagnosticPolicy, category: OutputCategory): boolean { + switch (category) { + case 'requested': + case 'safety': + case 'compatibility': + return true; + case 'detail': + return policy.verbosity === 'verbose'; + case 'decoration': + return !policy.json && policy.verbosity !== 'quiet'; + case 'notice': + return policy.verbosity !== 'quiet'; + } +} diff --git a/apps/cli/src/process/errors.test.ts b/apps/cli/src/process/errors.test.ts index 8a433963..14ba876a 100644 --- a/apps/cli/src/process/errors.test.ts +++ b/apps/cli/src/process/errors.test.ts @@ -15,6 +15,10 @@ import { import { describe, expect, it } from 'vitest'; import { CliError, isCliError, toUserFacing } from './errors.js'; +import { + CliError as LightweightCliError, + isCliError as isLightweightCliError, +} from './cli-error.js'; import { EXIT_CODES } from './exit-codes.js'; describe('EXIT_CODES', () => { @@ -34,11 +38,34 @@ describe('EXIT_CODES', () => { effectNeedsAttention: 7, // ADR-0103 — required money receipts are not confirmed, independently of terminal durability. moneyDurabilityUncertain: 8, + infrastructureFailed: 9, }); }); }); describe('CliError', () => { + it('keeps the runtime compatibility re-exports on the exact lightweight class and guard identity', () => { + expect(CliError).toBe(LightweightCliError); + expect(isCliError).toBe(isLightweightCliError); + expect(isCliError(new LightweightCliError('internal', 'known refusal'))).toBe(true); + }); + + it.each([ + 'cli_internal', + 'output_unavailable', + 'storage_unavailable', + 'config_write_refused', + ] as const)( + 'retains the fixed %s diagnostic even if the native Error message is mutated', + (code) => { + const value = new CliError(code, 'PRIVATE_CONSTRUCTOR_TEXT'); + const expected = value.message; + value.message = 'PRIVATE_MUTATED_TEXT'; + expect(toUserFacing(value)).toEqual({ code, message: expected, exitCode: value.exitCode }); + expect(JSON.stringify(toUserFacing(value))).not.toContain('PRIVATE_'); + }, + ); + it('maps invalid_invocation and not_implemented to exit 2', () => { expect(new CliError('invalid_invocation', 'bad').exitCode).toBe(EXIT_CODES.invalidInvocation); expect(new CliError('not_implemented', 'soon').exitCode).toBe(EXIT_CODES.invalidInvocation); diff --git a/apps/cli/src/process/errors.ts b/apps/cli/src/process/errors.ts index 44d5fee4..de086a08 100644 --- a/apps/cli/src/process/errors.ts +++ b/apps/cli/src/process/errors.ts @@ -1,5 +1,3 @@ -import { RelaviumError } from '@relavium/shared'; - import { AgentParseError, EngineStateError, @@ -14,66 +12,17 @@ import { SessionMessageBoundaryError, } from '@relavium/db'; -import { EXIT_CODES, type ExitCode } from './exit-codes.js'; - -/** - * The closed set of CLI-level error codes — callers and tests narrow on `.code`, never on - * `.message` (error-handling.md). Run-level outcomes (a workflow failing, a gate pausing) - * are NOT modelled here: they come from the engine's terminal `RunEvent` and are mapped to - * exit codes by `relavium run` (2.D). These codes are the CLI's own faults. - */ -export type CliErrorCode = - /** Bad args / unknown command|option / missing arg / not found / schema error → exit 2. */ - | 'invalid_invocation' - /** A malformed/invalid configuration layer (TOML syntax or schema) → exit 2. */ - | 'config_error' - /** A documented command whose implementing workstream has not landed yet → exit 2. */ - | 'not_implemented' - /** - * Another process holds a live lease on the run — this invocation refused rather than becoming a second - * side-effect producer (ADR-0079 §7) → exit 6. The only TRANSIENT code here: it is worth retrying - * unchanged, which is exactly what distinguishes it from `invalid_invocation`. - */ - | 'run_owned_elsewhere' - /** An unexpected CLI fault → exit 1 (the user-facing message stays generic). */ - | 'internal'; - -const EXIT_CODE_BY_ERROR: Readonly> = { - invalid_invocation: EXIT_CODES.invalidInvocation, - config_error: EXIT_CODES.invalidInvocation, - not_implemented: EXIT_CODES.invalidInvocation, - run_owned_elsewhere: EXIT_CODES.runOwnedElsewhere, - internal: EXIT_CODES.workflowFailed, -}; +import { EXIT_CODES } from './exit-codes.js'; -/** - * A typed, discriminated CLI error (error-handling.md) — mirrors the engine's - * `EngineStateError` shape. Carries a stable `code` and the `exitCode` it maps to; the - * `message` is **user-facing** and must never contain a secret, raw payload, stack trace, - * or internal path. - */ -export class CliError extends RelaviumError { - readonly code: CliErrorCode; - readonly exitCode: ExitCode; - - constructor(code: CliErrorCode, message: string, opts?: { readonly cause?: unknown }) { - super(message, opts?.cause === undefined ? undefined : { cause: opts.cause }); - this.name = 'CliError'; - this.code = code; - this.exitCode = EXIT_CODE_BY_ERROR[code]; - } -} - -export function isCliError(value: unknown): value is CliError { - return value instanceof CliError; -} - -/** A user-safe projection of any thrown value — what the renderer prints. */ -export interface UserFacingError { - readonly code: CliErrorCode; - readonly message: string; - readonly exitCode: ExitCode; -} +import { + CLI_ERROR_POLICY, + cliErrorExitCode, + isCliError, + type CliErrorCode, + type UserFacingError, +} from './cli-error.js'; +export { CliError, isCliError } from './cli-error.js'; +export type { CliErrorCode, UserFacingError } from './cli-error.js'; /** * Map any thrown value to a user-safe projection. A `CliError` passes through; anything else @@ -82,7 +31,11 @@ export interface UserFacingError { */ export function toUserFacing(value: unknown): UserFacingError { if (isCliError(value)) { - return { code: value.code, message: value.message, exitCode: value.exitCode }; + return { + code: value.code, + message: CLI_ERROR_POLICY[value.code].fixedMessage ?? value.message, + exitCode: value.exitCode, + }; } // ADR-0108: the disabled header field is an invocation refusal. Keep this projection fixed rather than // promoting the incoming message: structurally compatible errors can originate from another bundle. @@ -151,7 +104,7 @@ export function toUserFacing(value: unknown): UserFacingError { // The engine's message is already user-safe by its own contract ("secret-free… never carries run // inputs"), and for an admission refusal it carries the echo-safe half of each issue — so it is // promoted as-is rather than re-flattened from `value.issues` here. - return { code, message: value.message, exitCode: EXIT_CODE_BY_ERROR[code] }; + return { code, message: value.message, exitCode: cliErrorExitCode(code) }; } if (value instanceof AgentParseError) { // **A sibling of the arm below, and it needed its own.** `AgentParseError extends Error`, not @@ -166,7 +119,7 @@ export function toUserFacing(value: unknown): UserFacingError { return { code: 'invalid_invocation', message: value.message, - exitCode: EXIT_CODE_BY_ERROR['invalid_invocation'], + exitCode: cliErrorExitCode('invalid_invocation'), }; } if (value instanceof WorkflowParseError) { @@ -183,7 +136,7 @@ export function toUserFacing(value: unknown): UserFacingError { return { code: 'invalid_invocation', message: value.message, - exitCode: EXIT_CODE_BY_ERROR['invalid_invocation'], + exitCode: cliErrorExitCode('invalid_invocation'), }; } return { diff --git a/apps/cli/src/process/exit-codes.ts b/apps/cli/src/process/exit-codes.ts index 889eb76a..34921188 100644 --- a/apps/cli/src/process/exit-codes.ts +++ b/apps/cli/src/process/exit-codes.ts @@ -44,6 +44,8 @@ export const EXIT_CODES = { effectNeedsAttention: 7, /** Required money receipts were not durably acknowledged; never automatically repeat paid work. */ moneyDurabilityUncertain: 8, + /** Unexpected CLI infrastructure or failed output; inspect admitted work before repeating it. */ + infrastructureFailed: 9, } as const; export type ExitCode = (typeof EXIT_CODES)[keyof typeof EXIT_CODES]; diff --git a/apps/cli/src/process/exit-observations.test.ts b/apps/cli/src/process/exit-observations.test.ts new file mode 100644 index 00000000..ec857841 --- /dev/null +++ b/apps/cli/src/process/exit-observations.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + ExitObservations, + type OrdinaryExit, + type WorkflowSafetyExit, +} from './exit-observations.js'; + +function permutations(values: readonly T[]): T[][] { + if (values.length === 0) return [[]]; + return values.flatMap((value, index) => + permutations(values.filter((_, other) => other !== index)).map((tail) => [value, ...tail]), + ); +} + +const safetyPriority: readonly WorkflowSafetyExit[] = [8, 7, 5, 6]; +const ordinaryOutcomes: readonly OrdinaryExit[] = [0, 1, 2, 3, 4, 6]; + +describe('ExitObservations actual-fact priority', () => { + for (let offset = 0; offset < safetyPriority.length; offset++) { + const cohort = safetyPriority.slice(offset); + it(`keeps safety ${cohort[0]} above all weaker facts in every arrival order`, () => { + const observations: ((target: ExitObservations) => void)[] = [ + ...cohort.map((value) => (target: ExitObservations) => target.observeWorkflowSafety(value)), + (target) => target.observeFailure('output_unavailable'), + (target) => target.observeTerminatingSignal(15), + (target) => target.observeOutcome(4), + ]; + for (const order of permutations(observations)) { + const facts = new ExitObservations(); + for (const observe of order) observe(facts); + facts.observeWorkflowSafety(undefined); + facts.observeOutcome(0); + expect(facts.exitCode()).toBe(cohort[0]); + } + }); + } + + it.each(['cli_internal', 'output_unavailable'] as const)( + '%s outranks signal and session end in either order', + (code) => { + for (const order of permutations<(facts: ExitObservations) => void>([ + (facts) => facts.observeFailure(code), + (facts) => facts.observeTerminatingSignal(15), + (facts) => facts.observeOutcome(4), + ])) { + const facts = new ExitObservations(); + for (const observe of order) observe(facts); + expect(facts.exitCode()).toBe(9); + expect(facts.infrastructureCode()).toBe(code); + } + }, + ); + + it('keeps the first actual terminating signal while stronger faults can still arrive', () => { + const facts = new ExitObservations(); + facts.observeTerminatingSignal(1); + facts.observeTerminatingSignal(15); + facts.observeOutcome(1); + expect(facts.exitCode()).toBe(129); + facts.observeFailure('cli_internal'); + expect(facts.exitCode()).toBe(9); + }); + + it.each(ordinaryOutcomes)( + 'background failure only upgrades otherwise-zero outcome %s', + (outcome) => { + for (const backgroundFirst of [false, true]) { + const facts = new ExitObservations(); + if (backgroundFirst) facts.observeBackgroundFailure(); + facts.observeOutcome(outcome); + if (!backgroundFirst) facts.observeBackgroundFailure(); + expect(facts.exitCode()).toBe(outcome === 0 ? 9 : outcome); + } + }, + ); + + it('background keepalive preserves signal and actual safety', () => { + const facts = new ExitObservations(); + facts.observeBackgroundFailure(); + facts.observeTerminatingSignal(2); + expect(facts.exitCode()).toBe(130); + facts.observeWorkflowSafety(6); + expect(facts.exitCode()).toBe(6); + }); + + it.each(ordinaryOutcomes.filter((value) => value !== 0))( + 'keeps established ordinary %s through subsequent success and other outcomes', + (outcome) => { + const facts = new ExitObservations(); + facts.observeOutcome(outcome); + for (const other of ordinaryOutcomes) facts.observeOutcome(other); + expect(facts.exitCode()).toBe(outcome); + }, + ); + + it('distinguishes operational ownership refusal from actual workflow fencing', () => { + const refusal = new ExitObservations(); + refusal.observeFailure('run_owned_elsewhere'); + expect(refusal.exitCode()).toBe(6); + refusal.observeFailure('cli_internal'); + expect(refusal.exitCode()).toBe(9); + refusal.observeWorkflowSafety(6); + expect(refusal.exitCode()).toBe(6); + }); + + it.each([ + ['internal', 1], + ['storage_unavailable', 1], + ['invalid_invocation', 2], + ['config_error', 2], + ['config_write_refused', 2], + ['not_implemented', 2], + ] as const)('preserves explicit %s classification', (code, exit) => { + const facts = new ExitObservations(); + facts.observeFailure(code); + expect(facts.exitCode()).toBe(exit); + expect(facts.infrastructureCode()).toBeUndefined(); + }); + + it('keeps output diagnosis distinct when unexpected failure also occurs', () => { + const facts = new ExitObservations(); + facts.observeFailure('output_unavailable'); + facts.observeFailure('cli_internal'); + expect(facts.infrastructureCode()).toBe('output_unavailable'); + }); + + it.each([0, -1, 128, Number.NaN, Number.POSITIVE_INFINITY, 1.5])( + 'refuses invalid external signal %s without changing facts', + (signal) => { + const facts = new ExitObservations(); + expect(() => facts.observeTerminatingSignal(signal)).toThrow(); + expect(facts.exitCode()).toBe(0); + facts.observeTerminatingSignal(15); + expect(facts.exitCode()).toBe(143); + }, + ); + it.each(['safety', 'outcome', 'failure'] as const)( + 'refuses invalid runtime %s observations without changing existing facts or coercing values', + (channel) => { + const touched = vi.fn(() => { + throw new Error('must not inspect or coerce private observation'); + }); + const canary = Object.defineProperties( + {}, + { + code: { get: touched }, + origin: { get: touched }, + valueOf: { get: touched }, + toString: { get: touched }, + [Symbol.toPrimitive]: { get: touched }, + }, + ); + const invalid: readonly unknown[] = [ + null, + true, + false, + Number.NaN, + Number.POSITIVE_INFINITY, + -1, + 1.5, + 9, + 143, + '8', + '__proto__', + 'constructor', + 'toString', + Symbol('invalid observation'), + canary, + new String('internal'), + ...(channel === 'safety' + ? [0, 1, 2, 3, 4] + : channel === 'outcome' + ? [5, 7, 8, undefined] + : [0, 1, undefined]), + ]; + for (const initial of [0, 1] as const) { + const facts = new ExitObservations(); + facts.observeOutcome(initial); + const observe = + channel === 'safety' + ? facts.observeWorkflowSafety.bind(facts) + : channel === 'outcome' + ? facts.observeOutcome.bind(facts) + : facts.observeFailure.bind(facts); + for (const value of invalid) { + expect(() => { + Reflect.apply(observe, facts, [value]); + }).toThrow(); + expect(facts.exitCode()).toBe(initial); + expect(facts.infrastructureCode()).toBeUndefined(); + } + } + expect(touched).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/apps/cli/src/process/exit-observations.ts b/apps/cli/src/process/exit-observations.ts new file mode 100644 index 00000000..1909a658 --- /dev/null +++ b/apps/cli/src/process/exit-observations.ts @@ -0,0 +1,98 @@ +import { CLI_ERROR_POLICY, CliError, type CliErrorCode } from './cli-error.js'; +import { EXIT_CODES } from './exit-codes.js'; + +/** Only runtime workflow adapters may supply actual RunHandle safety facts. */ +export type WorkflowSafetyExit = 8 | 7 | 5 | 6; +export type OrdinaryExit = 0 | 1 | 2 | 3 | 4 | 6; + +export function assertTerminatingSignalNumber(value: number): void { + if (!Number.isSafeInteger(value) || value <= 0 || value > 127) + throw new CliError('cli_internal', ''); +} + +const SAFETY_RANK: Readonly> = Object.freeze({ + 8: 4, + 7: 3, + 5: 2, + 6: 1, +}); + +/** Finite sticky invocation facts, not a history of completed owners or raw failures. */ +export class ExitObservations { + #safety: WorkflowSafetyExit | undefined; + #infrastructure = false; + #output = false; + #signal: number | undefined; + #ordinary: OrdinaryExit = EXIT_CODES.success; + #background = false; + + observeWorkflowSafety(value: WorkflowSafetyExit | undefined): void { + if (value !== undefined && value !== 8 && value !== 7 && value !== 5 && value !== 6) { + throw new CliError('cli_internal', ''); + } + if ( + value !== undefined && + (this.#safety === undefined || SAFETY_RANK[value] > SAFETY_RANK[this.#safety]) + ) { + this.#safety = value; + } + } + + /** Publish a surface outcome, not each failed chat turn or nested chat-end consumed by Home. */ + observeOutcome(value: OrdinaryExit): void { + if (value !== 0 && value !== 1 && value !== 2 && value !== 3 && value !== 4 && value !== 6) { + throw new CliError('cli_internal', ''); + } + // One authoritative surface supplies this slot. An established nonzero cannot be erased by later success. + if (this.#ordinary === EXIT_CODES.success) this.#ordinary = value; + } + + observeFailure(code: CliErrorCode): void { + if (typeof code !== 'string' || !Object.hasOwn(CLI_ERROR_POLICY, code)) + throw new CliError('cli_internal', ''); + const policy = CLI_ERROR_POLICY[code]; + if (policy.origin === 'infrastructure') this.#infrastructure = true; + else if (policy.origin === 'output') this.#output = true; + else { + switch (policy.exitCode) { + case 0: + case 1: + case 2: + case 3: + case 4: + case 6: + this.observeOutcome(policy.exitCode); + break; + default: + // A code policy is not authority to invent workflow-only receipt health. + this.#infrastructure = true; + } + } + } + + /** An actually terminating external signal; cooperative cancellation/raw Ctrl-C are ordinary outcomes. */ + observeTerminatingSignal(signalNumber: number): void { + assertTerminatingSignalNumber(signalNumber); + this.#signal ??= signalNumber; + } + + /** Keepalive backstop only. This neither stops admission nor overrides an established nonzero. */ + observeBackgroundFailure(): void { + this.#background = true; + } + + exitCode(): number { + if (this.#safety !== undefined) return this.#safety; + if (this.#infrastructure || this.#output) return EXIT_CODES.infrastructureFailed; + if (this.#signal !== undefined) return 128 + this.#signal; + if (this.#ordinary === EXIT_CODES.success && this.#background) + return EXIT_CODES.infrastructureFailed; + return this.#ordinary; + } + + infrastructureCode(): 'output_unavailable' | 'cli_internal' | undefined { + if (this.#output) return 'output_unavailable'; + if (this.#infrastructure || this.#background) return 'cli_internal'; + return undefined; + } +} diff --git a/apps/cli/src/process/invocation-registry.test.ts b/apps/cli/src/process/invocation-registry.test.ts new file mode 100644 index 00000000..f12ac7f3 --- /dev/null +++ b/apps/cli/src/process/invocation-registry.test.ts @@ -0,0 +1,891 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + InvocationRegistry, + type InvocationClock, + type InvocationCloseResult, + type OwnerRetirement, + type RemainingBudget, + type SessionOwner, + type WorkflowOwner, +} from './invocation-registry.js'; + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (cause: unknown) => void; +} { + let resolve: (value: T) => void = () => undefined; + let reject: (cause: unknown) => void = () => undefined; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +class ManualClock implements InvocationClock { + time = 0; + readonly timers = new Set<{ at: number; fire: () => void }>(); + readonly now = (): number => this.time; + readonly schedule = (milliseconds: number, fire: () => void): (() => void) => { + const timer = { at: this.time + milliseconds, fire }; + this.timers.add(timer); + return () => { + this.timers.delete(timer); + }; + }; + advanceTo(time: number): void { + expect(time).toBeGreaterThanOrEqual(this.time); + this.time = time; + for (const timer of [...this.timers]) { + if (timer.at <= time && this.timers.delete(timer)) timer.fire(); + } + } +} + +function session( + retired: Promise, + overrides: Partial = {}, +): SessionOwner { + return { + kind: 'session', + surface: 'chat', + retired, + stop: () => undefined, + join: () => undefined, + classifyFailure: () => 'cli_internal', + ...overrides, + }; +} + +const settled: OwnerRetirement = Object.freeze({ kind: 'settled' }); + +// Awaiting the exact witness is an explicit observer-order barrier: registry registration +// attached its native Promise reaction first. No timer or guessed microtask count is used. +async function retire( + witness: ReturnType>, + value = settled, +): Promise { + witness.resolve(value); + await witness.promise; +} + +describe('InvocationRegistry ownership', () => { + it('registers before admission without joining an idle session, and closes only that instance', async () => { + const registry = new InvocationRegistry(new ManualClock()); + const first = deferred(); + const second = deferred(); + const join = vi.fn(); + const lease = registry.register(session(first.promise, { join })); + const sibling = registry.register(session(second.promise)); + expect(registry.activeOwners).toBe(2); + expect(join).not.toHaveBeenCalled(); + lease.assertAdmitting(); + const closing = lease.close(); + expect(lease.close()).toBe(closing); + expect(join).toHaveBeenCalledOnce(); + expect(() => lease.assertAdmitting()).toThrow(); + sibling.assertAdmitting(); + registry.assertAdmitting(); + expect(registry.activeOwners).toBe(2); + await retire(first); + expect(await closing).toEqual({ kind: 'retired' }); + expect(registry.activeOwners).toBe(1); + await retire(second); + }); + + it('latches stop before a reentrant factory can admit work', async () => { + const registry = new InvocationRegistry(new ManualClock()); + const life = deferred(); + let blocked = false; + registry.register( + session(life.promise, { + stop: () => { + try { + registry.register(session(life.promise)); + } catch { + blocked = true; + } + }, + }), + ); + const closing = registry.terminate({ kind: 'normal' }); + expect(blocked).toBe(true); + expect(() => registry.assertAdmitting()).toThrow(); + await retire(life); + expect(await closing).toEqual({ kind: 'settled', unacknowledgedOwners: 0 }); + }); + + it('enters all stops before tails despite throw, immediate rejection and held stop', async () => { + const registry = new InvocationRegistry(new ManualClock()); + const trace: string[] = []; + const lifetimes = Array.from({ length: 3 }, () => deferred()); + const held = deferred(); + for (const [index, life] of lifetimes.entries()) { + registry.register( + session(life.promise, { + stop: () => { + trace.push(`stop${index}`); + if (index === 0) throw new Error('stop failed'); + if (index === 1) return Promise.reject(new Error('rejected stop')); + return held.promise; + }, + join: () => { + trace.push(`join${index}`); + }, + }), + ); + } + const closing = registry.terminate({ kind: 'normal' }); + expect(trace).toEqual(['stop0', 'stop1', 'stop2', 'join0', 'join1', 'join2']); + for (const life of lifetimes) await retire(life); + held.resolve(); + expect((await closing).kind).toBe('settled'); + expect(registry.observations.exitCode()).toBe(9); + expect(registry.failureKinds).toBe(1); + }); + + it('does not confuse settled cleanup attempts with actual lifetime retirement', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const lease = registry.register( + session(life.promise, { join: () => Promise.reject(new Error('join refused')) }), + ); + const ownerClose = lease.close(); + const closing = registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + expect(registry.activeOwners).toBe(1); + clock.advanceTo(3000); + expect(await ownerClose).toEqual({ kind: 'unacknowledged' }); + expect(await closing).toEqual({ kind: 'deadline', unacknowledgedOwners: 1 }); + expect(registry.activeOwners).toBe(1); + await retire(life); + expect(registry.activeOwners).toBe(0); + }); + + it('keeps rejected retirement unacknowledged and retains settled publication failure', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const rejected = deferred(); + const published = deferred(); + registry.register(session(rejected.promise, { classifyFailure: () => 'storage_unavailable' })); + registry.register(session(published.promise)); + rejected.reject(new Error('private publication details')); + await rejected.promise.catch(() => undefined); + await retire(published, { kind: 'settled', failure: 'storage_unavailable' }); + expect(registry.activeOwners).toBe(1); + expect(registry.observations.exitCode()).toBe(1); + const closing = registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + clock.advanceTo(3000); + expect((await closing).unacknowledgedOwners).toBe(1); + }); + + it('restores terminal custody while an unrelated host join is held', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const host = deferred(); + const terminal = deferred(); + const hostJoin = deferred(); + const trace: string[] = []; + registry.register(session(host.promise, { join: () => hostJoin.promise })); + registry.register({ + ...session(terminal.promise), + kind: 'terminal', + restore: () => { + trace.push('restore'); + }, + }); + const closing = registry.terminate({ kind: 'infrastructure', code: 'output_unavailable' }); + expect(trace).toEqual(['restore']); + await retire(terminal); + expect(registry.activeOwners).toBe(1); + clock.advanceTo(3000); + expect((await closing).kind).toBe('deadline'); + hostJoin.resolve(); + await retire(host); + }); + + it('attempts sibling restore and native reap after stop or restore failure', async () => { + const registry = new InvocationRegistry(new ManualClock()); + const life = deferred(); + const trace: string[] = []; + registry.register({ + ...session(life.promise), + kind: 'terminal', + stop: () => { + throw new Error('stop'); + }, + restore: () => { + trace.push('restore1'); + throw new Error('restore'); + }, + }); + registry.register({ + ...session(life.promise), + kind: 'terminal', + restore: () => { + trace.push('restore2'); + }, + }); + registry.register({ + ...session(life.promise), + kind: 'native-pid', + mayReap: () => true, + reap: () => { + trace.push('reap'); + }, + }); + const closing = registry.terminate({ kind: 'normal' }); + expect(trace).toEqual(['restore1', 'restore2', 'reap']); + await retire(life); + await closing; + expect(registry.observations.exitCode()).toBe(9); + }); + + it('shares exact cleanup identity across stop and health reentry', async () => { + const registry = new InvocationRegistry(new ManualClock()); + const life = deferred(); + const nested: Promise[] = []; + const stop = vi.fn(() => { + nested.push(registry.terminate({ kind: 'normal' })); + }); + registry.register({ + ...session(life.promise), + kind: 'workflow', + stop, + readSafetyExit: () => { + nested.push(registry.terminate({ kind: 'normal' })); + return 7; + }, + }); + const closing = registry.terminate({ kind: 'normal' }); + expect(nested.length).toBeGreaterThan(0); + for (const promise of nested) expect(promise).toBe(closing); + expect(stop).toHaveBeenCalledOnce(); + await retire(life); + await closing; + expect(registry.observations.exitCode()).toBe(7); + }); + + it('escalates normal cleanup under one live shortened deadline without restarting callbacks', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const budgets: RemainingBudget[] = []; + const stop = vi.fn((budget: RemainingBudget) => { + budgets.push(budget); + }); + registry.register( + session(life.promise, { + stop, + join: (budget) => { + budgets.push(budget); + }, + }), + ); + const closing = registry.terminate({ kind: 'normal' }); + expect(budgets.map((budget) => budget.remainingMs())).toEqual([undefined, undefined]); + clock.advanceTo(100); + expect(registry.terminate({ kind: 'infrastructure', code: 'cli_internal' })).toBe(closing); + expect(budgets.map((budget) => budget.deadline())).toEqual([3100, 3100]); + clock.advanceTo(200); + expect(registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 500 })).toBe(closing); + expect(registry.terminate({ kind: 'infrastructure', code: 'output_unavailable' })).toBe( + closing, + ); + expect(budgets.map((budget) => budget.remainingMs())).toEqual([300, 300]); + expect(stop).toHaveBeenCalledOnce(); + clock.advanceTo(500); + expect(await closing).toEqual({ kind: 'deadline', unacknowledgedOwners: 1 }); + expect(registry.observations.exitCode()).toBe(9); + await retire(life); + }); + + it('never starts a tail/helper after the common budget expires during an earlier callback', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const join = vi.fn(); + const reap = vi.fn(); + registry.register({ + ...session(life.promise), + kind: 'windows-live-root', + stop: () => { + clock.advanceTo(3000); + }, + join, + mayReap: () => true, + reap, + }); + const closing = registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + expect((await closing).kind).toBe('deadline'); + expect(join).not.toHaveBeenCalled(); + expect(reap).not.toHaveBeenCalled(); + expect(registry.activeOwners).toBe(1); + await retire(life); + }); + + it('uses creation-kind eligibility: closed leader retains group authority but closed PID/root does not', async () => { + const registry = new InvocationRegistry(new ManualClock()); + const life = deferred(); + const trace: string[] = []; + const rootClosed = true; + for (const kind of ['native-group', 'native-pid', 'windows-live-root'] as const) { + registry.register({ + ...session(life.promise), + kind, + mayReap: () => kind === 'native-group' || !rootClosed, + reap: () => { + trace.push(kind); + }, + }); + } + const closing = registry.terminate({ kind: 'normal' }); + expect(trace).toEqual(['native-group']); + expect(registry.activeOwners).toBe(3); // A reap request does not acknowledge any target. + await retire(life); + await closing; + expect(registry.activeOwners).toBe(0); + }); + + it.each([false, true])( + 'preserves actual workflow safety on returned/thrown join and after retirement (throw=%s)', + async (throws) => { + const registry = new InvocationRegistry(new ManualClock()); + const life = deferred(); + let safety: ReturnType = 5; + registry.register({ + ...session(life.promise), + kind: 'workflow', + readSafetyExit: () => safety, + join: () => { + if (throws) throw new Error('drive fault'); + }, + }); + const closing = registry.terminate({ kind: 'normal' }); + expect(registry.observations.exitCode()).toBe(5); + safety = 8; + await retire(life); + safety = undefined; + await closing; + registry.observations.observeFailure('output_unavailable'); + registry.sampleWorkflowHealth(); + expect(registry.observations.exitCode()).toBe(8); + }, + ); + + it('retains a finite failure vocabulary after many retired instances without claiming GC', async () => { + const registry = new InvocationRegistry(new ManualClock()); + for (let index = 0; index < 1000; index++) { + const life = deferred(); + registry.register(session(life.promise)); + await retire(life, { kind: 'settled', failure: 'storage_unavailable' }); + } + expect(registry.activeOwners).toBe(0); + expect(registry.failureKinds).toBe(1); + expect(registry.observations.exitCode()).toBe(1); + }); + + it('snapshots caller-owned callback properties at registration', async () => { + const registry = new InvocationRegistry(new ManualClock()); + const life = deferred(); + const original = vi.fn(); + const replacement = vi.fn(); + const owner = { ...session(life.promise), stop: original }; + registry.register(owner); + owner.stop = replacement; + const closing = registry.terminate({ kind: 'normal' }); + expect(original).toHaveBeenCalledOnce(); + expect(replacement).not.toHaveBeenCalled(); + await retire(life); + await closing; + }); + + it('uses fixed infrastructure failure when the owned classifier itself throws', async () => { + const registry = new InvocationRegistry(new ManualClock()); + const life = deferred(); + registry.register( + session(life.promise, { + stop: () => { + throw new Error('private'); + }, + classifyFailure: () => { + throw new Error('classifier private'); + }, + }), + ); + const closing = registry.terminate({ kind: 'normal' }); + await retire(life); + await closing; + expect(registry.observations.exitCode()).toBe(9); + expect(registry.observations.infrastructureCode()).toBe('cli_internal'); + }); + + it('background observation leaves admission and live owners running', async () => { + const registry = new InvocationRegistry(new ManualClock()); + const life = deferred(); + const stop = vi.fn(); + const lease = registry.register(session(life.promise, { stop })); + registry.observations.observeBackgroundFailure(); + registry.assertAdmitting(); + lease.assertAdmitting(); + expect(stop).not.toHaveBeenCalled(); + expect(registry.observations.exitCode()).toBe(9); + await retire(life); + }); + + it('bypasses an overridden native Promise.then without accepting a forged settlement', async () => { + const registry = new InvocationRegistry(new ManualClock()); + const life = deferred(); + const forgedThen = vi.fn(() => Promise.resolve(settled)); + void Object.defineProperty(life.promise, 'then', { value: forgedThen }); + registry.register(session(life.promise)); + expect(forgedThen).not.toHaveBeenCalled(); + expect(registry.activeOwners).toBe(1); + life.resolve(settled); + await life.promise; // Native await ignores the own .then for a genuine same-realm Promise. + expect(registry.activeOwners).toBe(0); + }); + + it('does not accept a foreign thenable as native lifetime evidence', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const forged = { + then: vi.fn((resolve: (value: OwnerRetirement) => void) => { + resolve(settled); + }), + }; + // @ts-expect-error Deliberately exercise the runtime boundary with a non-Promise adapter. + registry.register(session(forged)); + expect(forged.then).not.toHaveBeenCalled(); + expect(registry.activeOwners).toBe(1); + const closing = registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + clock.advanceTo(3000); + expect((await closing).unacknowledgedOwners).toBe(1); + }); + + it('does not invent retirement when native Promise species observation fails', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + void Object.defineProperty(life.promise, 'constructor', { + get: () => { + throw new Error('species unavailable'); + }, + }); + registry.register(session(life.promise)); + life.resolve(settled); + const closing = registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + clock.advanceTo(3000); + expect(await closing).toEqual({ kind: 'deadline', unacknowledgedOwners: 1 }); + expect(registry.failureKinds).toBe(1); + }); + + it('does not invoke hostile retirement payload getters or accept them as an ACK', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const getter = vi.fn(() => 'settled'); + const payload: OwnerRetirement = { kind: 'settled' }; + Object.defineProperty(payload, 'kind', { get: getter }); + registry.register(session(life.promise)); + await retire(life, payload); + expect(getter).not.toHaveBeenCalled(); + expect(registry.activeOwners).toBe(1); + const closing = registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + clock.advanceTo(3000); + await closing; + }); + + it('removes completed fatal timer and does not rearm it on a later observation', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const closing = registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + await closing; + expect(clock.timers.size).toBe(0); + expect(registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 10 })).toBe(closing); + expect(clock.timers.size).toBe(0); + }); + + it('rejects invalid termination input before poisoning the admission/cleanup latch', async () => { + const registry = new InvocationRegistry(new ManualClock()); + expect(() => registry.terminate({ kind: 'signal', signalNumber: 0 })).toThrow(); + registry.assertAdmitting(); + expect(await registry.terminate({ kind: 'normal' })).toEqual({ + kind: 'settled', + unacknowledgedOwners: 0, + }); + }); + + it('does not read a session-shaped object as workflow health at runtime', async () => { + const registry = new InvocationRegistry(new ManualClock()); + const life = deferred(); + const readSafetyExit = vi.fn(() => 8); + const owner = { ...session(life.promise), readSafetyExit }; + registry.register(owner); + const closing = registry.terminate({ kind: 'normal' }); + await retire(life, { kind: 'settled', failure: 'storage_unavailable' }); + await closing; + expect(readSafetyExit).not.toHaveBeenCalled(); + expect(registry.observations.exitCode()).toBe(1); + }); + + it('shares one decreasing budget across sequential callback entry', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const remaining: (number | undefined)[] = []; + registry.register( + session(life.promise, { + stop: (budget) => { + remaining.push(budget.remainingMs()); + clock.advanceTo(700); + }, + join: (budget) => { + remaining.push(budget.remainingMs()); + }, + }), + ); + registry.register({ + ...session(life.promise), + kind: 'native-group', + stop: (budget) => { + remaining.push(budget.remainingMs()); + clock.advanceTo(1100); + }, + mayReap: () => true, + reap: (budget) => { + remaining.push(budget.remainingMs()); + }, + }); + const closing = registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + expect(remaining).toEqual([3000, 2300, 1900, 1900]); + await retire(life); + await closing; + }); + + it('keeps an unobserved stop attempt pending despite an independently retired resource', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const attempt = deferred(); + void Object.defineProperty(attempt.promise, 'constructor', { + get: () => { + throw new Error('cannot observe cleanup'); + }, + }); + registry.register(session(life.promise, { stop: () => attempt.promise })); + const closing = registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + await retire(life); + attempt.resolve(); + clock.advanceTo(3000); + expect(await closing).toEqual({ kind: 'deadline', unacknowledgedOwners: 0 }); + expect(registry.observations.exitCode()).toBe(9); + }); + + it('does not reap a native owner which already actually retired', async () => { + const registry = new InvocationRegistry(new ManualClock()); + const life = deferred(); + const reap = vi.fn(); + const lease = registry.register({ + ...session(life.promise), + kind: 'native-group', + mayReap: () => true, + reap, + }); + await retire(life); + expect(await lease.close()).toEqual({ kind: 'retired' }); + expect(reap).not.toHaveBeenCalled(); + expect(await registry.terminate({ kind: 'normal' })).toEqual({ + kind: 'settled', + unacknowledgedOwners: 0, + }); + }); + + it('rejects a nonfinite signal deadline without poisoning admission', async () => { + const registry = new InvocationRegistry(new ManualClock()); + expect(() => + registry.terminate({ kind: 'signal', signalNumber: 15, deadline: Number.NaN }), + ).toThrow(); + registry.assertAdmitting(); + expect(registry.observations.exitCode()).toBe(0); + await registry.terminate({ kind: 'normal' }); + }); + + it('starts the fatal budget when normal cleanup first observes an infrastructure callback fault', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + registry.register( + session(life.promise, { + stop: () => { + throw new Error('unexpected owner failure'); + }, + }), + ); + const closing = registry.terminate({ kind: 'normal' }); + expect(registry.observations.exitCode()).toBe(9); + expect([...clock.timers].map((timer) => timer.at)).toEqual([3000]); + clock.advanceTo(3000); + expect(await closing).toEqual({ kind: 'deadline', unacknowledgedOwners: 1 }); + await retire(life); + }); + + it('automatic fatal escalation from an individual close stops all siblings before starting tails', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const trace: string[] = []; + const first = registry.register( + session(life.promise, { + stop: () => { + trace.push('stop1'); + throw new Error('fatal stop'); + }, + join: () => { + trace.push('join1'); + }, + }), + ); + registry.register( + session(life.promise, { + stop: () => { + trace.push('stop2'); + throw new Error('nested fatal stop'); + }, + join: () => { + trace.push('join2'); + }, + }), + ); + registry.register({ + ...session(life.promise), + kind: 'terminal', + stop: () => { + trace.push('stop3'); + }, + join: () => { + trace.push('join3'); + }, + restore: () => { + trace.push('restore3'); + }, + }); + const closing = first.close(); + expect(trace).toEqual(['stop1', 'stop2', 'stop3', 'join1', 'join2', 'join3', 'restore3']); + expect(() => registry.assertAdmitting()).toThrow(); + expect([...clock.timers].map((timer) => timer.at)).toEqual([3000]); + clock.advanceTo(3000); + expect(await closing).toEqual({ kind: 'unacknowledged' }); + expect((await registry.terminate({ kind: 'normal' })).kind).toBe('deadline'); + await retire(life); + }); + + it('does not let a reentrant owner close start tails before the all-stop pass finishes', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const trace: string[] = []; + const second = registry.register( + session(life.promise, { + stop: () => { + trace.push('stop2'); + }, + join: () => { + trace.push('join2'); + }, + }), + ); + registry.register( + session(life.promise, { + stop: () => { + trace.push('stop1'); + void second.close(); + }, + join: () => { + trace.push('join1'); + }, + }), + ); + registry.register( + session(life.promise, { + stop: () => { + trace.push('stop3'); + }, + join: () => { + trace.push('join3'); + }, + }), + ); + const closing = registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + expect(trace).toEqual(['stop2', 'stop1', 'stop3', 'join2', 'join1', 'join3']); + await retire(life); + await closing; + }); + + it.each(['clock', 'scheduler'] as const)( + 'a failed %s cannot leave fatal termination without a backstop or acknowledge an owner', + async (fault) => { + const clock: InvocationClock = { + now: () => (fault === 'clock' ? Number.NaN : 0), + schedule: () => { + throw new Error('timer unavailable'); + }, + }; + const registry = new InvocationRegistry(clock); + const life = deferred(); + const join = vi.fn(); + registry.register(session(life.promise, { join })); + const closing = registry.terminate({ kind: 'infrastructure', code: 'output_unavailable' }); + expect(await closing).toEqual({ kind: 'deadline', unacknowledgedOwners: 1 }); + expect(join).not.toHaveBeenCalled(); + expect(registry.activeOwners).toBe(1); + expect(registry.observations.exitCode()).toBe(9); + await retire(life); + }, + ); + + it('does not grant sessions a workflow safety callback in the type contract', () => { + const owner: SessionOwner = { + ...session(new Promise(() => undefined)), + // @ts-expect-error Sessions have no RunDeparture/5–8 health authority. + readSafetyExit: () => 8, + }; + expect(owner.kind).toBe('session'); + }); + it('classifies invalid runtime workflow health as infrastructure without acknowledging retirement', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const touched = vi.fn(() => { + throw new Error('private value must not be inspected'); + }); + const invalidHealth = Object.defineProperties( + {}, + { + valueOf: { get: touched }, + toString: { get: touched }, + [Symbol.toPrimitive]: { get: touched }, + }, + ); + const owner: WorkflowOwner = { + ...session(life.promise), + kind: 'workflow', + readSafetyExit: () => undefined, + }; + expect(Reflect.defineProperty(owner, 'readSafetyExit', { value: () => invalidHealth })).toBe( + true, + ); + registry.register(owner); + registry.sampleWorkflowHealth(); + expect(touched).not.toHaveBeenCalled(); + expect(registry.observations.exitCode()).toBe(9); + expect(registry.observations.infrastructureCode()).toBe('cli_internal'); + expect(registry.activeOwners).toBe(1); + expect(() => registry.assertAdmitting()).toThrow(); + expect([...clock.timers].map((timer) => timer.at)).toEqual([3000]); + const closing = registry.terminate({ kind: 'normal' }); + clock.advanceTo(3000); + expect(await closing).toEqual({ kind: 'deadline', unacknowledgedOwners: 1 }); + await retire(life); + expect(registry.activeOwners).toBe(0); + }); + + it('counts owned failure-classification time inside the first-fault fatal deadline', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + registry.register( + session(life.promise, { + stop: () => { + throw new Error('first observed fault at time zero'); + }, + classifyFailure: () => { + // Deterministic elapsed time, not a claim that the registry can preempt blocked JS. + clock.advanceTo(700); + return 'cli_internal'; + }, + }), + ); + const closing = registry.terminate({ kind: 'normal' }); + expect([...clock.timers].map((timer) => timer.at)).toEqual([3000]); + clock.advanceTo(3000); + expect(await closing).toEqual({ kind: 'deadline', unacknowledgedOwners: 1 }); + expect(registry.activeOwners).toBe(1); + await retire(life); + }); + it('does not make an owned operational refusal fatal merely because classification consumed time', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const lease = registry.register( + session(life.promise, { + stop: () => { + throw new Error('owned storage refusal'); + }, + classifyFailure: () => { + clock.advanceTo(700); + return 'storage_unavailable'; + }, + }), + ); + const closing = lease.close(); + expect(clock.timers.size).toBe(0); + registry.assertAdmitting(); + expect(registry.observations.exitCode()).toBe(1); + expect(registry.activeOwners).toBe(1); + await retire(life); + expect(await closing).toEqual({ kind: 'retired' }); + }); + it('requests finite sibling cancellation after fatal expiry without starting joins or native helpers', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const existingStopTail = deferred(); + const trace: string[] = []; + const join = vi.fn(); + const mayReap = vi.fn(() => true); + const reap = vi.fn(); + registry.register( + session(life.promise, { + stop: () => { + trace.push('first-stop'); + clock.advanceTo(3000); + throw new Error('first stop failed after consuming the budget'); + }, + join, + }), + ); + registry.register( + session(life.promise, { + stop: (budget) => { + trace.push(`sibling-cancel:${budget.remainingMs()}`); + // This observes work already created above; it starts no helper at zero budget. + return existingStopTail.promise; + }, + join, + }), + ); + registry.register({ + ...session(life.promise), + kind: 'native-pid', + stop: (budget) => { + trace.push(`native-cancel:${budget.remainingMs()}`); + }, + join, + mayReap, + reap, + }); + const closing = registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + expect(trace).toEqual(['first-stop', 'sibling-cancel:0', 'native-cancel:0']); + expect(join).not.toHaveBeenCalled(); + expect(mayReap).not.toHaveBeenCalled(); + expect(reap).not.toHaveBeenCalled(); + expect(await closing).toEqual({ kind: 'deadline', unacknowledgedOwners: 3 }); + expect(registry.activeOwners).toBe(3); + existingStopTail.resolve(); + await retire(life); + expect(registry.activeOwners).toBe(0); + }); +}); diff --git a/apps/cli/src/process/invocation-registry.ts b/apps/cli/src/process/invocation-registry.ts new file mode 100644 index 00000000..46f01cd8 --- /dev/null +++ b/apps/cli/src/process/invocation-registry.ts @@ -0,0 +1,498 @@ +import { CliError } from './cli-error.js'; +import { + ExitObservations, + assertTerminatingSignalNumber, + type WorkflowSafetyExit, +} from './exit-observations.js'; + +/** Process termination backstop, never a receipt deadline or host-safe-close acknowledgement. */ +export const FATAL_TERMINATION_MS = 3_000; + +export interface InvocationClock { + readonly now: () => number; + readonly schedule: (milliseconds: number, fire: () => void) => () => void; +} + +export interface RemainingBudget { + readonly deadline: () => number | undefined; + readonly remainingMs: () => number | undefined; +} + +export type OwnerFailureCode = + | 'internal' + | 'storage_unavailable' + | 'cli_internal' + | 'output_unavailable'; + +/** Supplied by the creation owner after actual lifetime settlement, separately from cleanup attempts. */ +export interface OwnerRetirement { + readonly kind: 'settled'; + readonly failure?: OwnerFailureCode; +} + +interface OwnerBase { + readonly retired: Promise; + /** + * Synchronously seal admission/request finite cancellation, including at zero remaining budget. + * At expiry, never launch an async helper; a returned promise may only observe already-started work. + */ + readonly stop: (budget: RemainingBudget) => void | Promise; + readonly join: (budget: RemainingBudget) => void | Promise; + /** Classify only facts at this owned boundary; the registry never probes a private cause tree. */ + readonly classifyFailure: (stage: OwnerStage, cause: unknown) => OwnerFailureCode; +} + +export interface WorkflowOwner extends OwnerBase { + readonly kind: 'workflow'; + readonly readSafetyExit: () => WorkflowSafetyExit | undefined; +} + +export interface SessionOwner extends OwnerBase { + readonly kind: 'session'; + readonly surface: 'chat' | 'home' | 'agent'; +} + +export interface TerminalOwner extends OwnerBase { + readonly kind: 'terminal'; + readonly restore: (budget: RemainingBudget) => void | Promise; +} + +export interface NativeOwner extends OwnerBase { + readonly kind: 'native-group' | 'native-pid' | 'windows-live-root'; + /** Current creation-owned eligibility. Group authority can outlive its leader; PID/root authority cannot. */ + readonly mayReap: () => boolean; + /** A request/attempt, never target close ACK. Concrete PID/PGID/facility stays in the native adapter. */ + readonly reap: (budget: RemainingBudget) => void | Promise; +} + +export type InvocationOwner = WorkflowOwner | SessionOwner | TerminalOwner | NativeOwner; +export type OwnerStage = 'stop' | 'join' | 'restore' | 'reap' | 'retirement' | 'health'; + +export interface OwnerLease { + readonly assertAdmitting: () => void; + /** Close this owner only. Completion reports actual retirement, not receipt success. */ + readonly close: () => Promise; +} + +export interface OwnerCloseResult { + readonly kind: 'retired' | 'unacknowledged'; +} + +export type TerminationRequest = + | { readonly kind: 'normal' } + | { readonly kind: 'infrastructure'; readonly code: 'cli_internal' | 'output_unavailable' } + | { readonly kind: 'signal'; readonly signalNumber: number; readonly deadline?: number }; + +export interface InvocationCloseResult { + readonly kind: 'settled' | 'deadline'; + readonly unacknowledgedOwners: number; +} + +interface Entry { + readonly owner: InvocationOwner; + readonly retirement: Promise; + readonly resolveRetirement: () => void; + readonly stages: Map>; + closing: boolean; + retired: boolean; + closeResult: Promise | undefined; + sampling: boolean; +} + +// Observe actual native Promise settlement, bypassing overridden then/foreign thenable protocols. +const nativeThen: unknown = Object.getOwnPropertyDescriptor(Promise.prototype, 'then')?.value; + +/** + * Pure invocation lifetime coordinator. Runtime adapters own physical facts and DB-close permission. + * Successful retirement releases closures; finite sticky diagnoses survive. No process or service imports. + */ +export class InvocationRegistry { + readonly observations: ExitObservations; + readonly #clock: InvocationClock; + readonly #entries = new Set(); + readonly #failures = new Set(); + readonly #budget: RemainingBudget; + readonly #waiters = new Set<() => void>(); + readonly #deferredCloses = new Set<() => void>(); + #admitting = true; + #deadline: number | undefined; + #expired = false; + #disarm: (() => void) | undefined; + #termination: Promise | undefined; + #sampling = false; + #finished = false; + #stopping = false; + + constructor(clock: InvocationClock, observations = new ExitObservations()) { + this.#clock = clock; + this.observations = observations; + this.#budget = Object.freeze({ + deadline: () => this.#deadline, + remainingMs: () => + this.#deadline === undefined ? undefined : Math.max(0, this.#deadline - this.#now()), + }); + } + + get activeOwners(): number { + return this.#entries.size; + } + /** Bounded by the fixed owner-kind × stage × code vocabulary, not completed attempt count. */ + get failureKinds(): number { + return this.#failures.size; + } + + assertAdmitting(): void { + if (!this.#admitting) throw new CliError('cli_internal', ''); + } + + register(owner: InvocationOwner): OwnerLease { + this.assertAdmitting(); + // Creation-specific capabilities cannot change after registration through the caller's object. + owner = Object.freeze({ ...owner }); + let resolveRetirement = (): void => undefined; + const retirement = new Promise((resolve) => { + resolveRetirement = resolve; + }); + const entry: Entry = { + owner, + retirement, + resolveRetirement, + stages: new Map(), + closing: false, + retired: false, + closeResult: undefined, + sampling: false, + }; + this.#entries.add(entry); + // Registration observes lifetime only: joining an idle session here would falsely retire future turns. + if ( + !this.#observe( + owner.retired, + (result) => this.#retire(entry, result), + (cause) => this.#failed(entry, 'retirement', cause), + ) + ) { + this.#record(entry, 'retirement', 'cli_internal'); + } + return Object.freeze({ + assertAdmitting: () => { + this.assertAdmitting(); + if (entry.closing || entry.retired) throw new CliError('cli_internal', ''); + }, + close: () => this.#closeEntry(entry), + }); + } + + /** Publish the shared promise BEFORE entering callbacks, including reentrant failures. */ + terminate(request: TerminationRequest): Promise { + if (request.kind === 'signal') { + // Pure validation precedes sealing; a malformed internal request must not leave a pending coordinator. + assertTerminatingSignalNumber(request.signalNumber); + if (request.deadline !== undefined && !Number.isFinite(request.deadline)) + throw new CliError('cli_internal', ''); + } + if (this.#termination !== undefined) { + this.#observeRequest(request); + return this.#termination; + } + this.#admitting = false; + let finish: (value: InvocationCloseResult) => void = () => undefined; + this.#termination = new Promise((resolve) => { + finish = resolve; + }); + const entries = [...this.#entries]; + this.#stopping = true; + try { + this.#observeRequest(request); + for (const entry of entries) { + entry.closing = true; + this.#attempt(entry, 'stop', entry.owner.stop); + } + } finally { + this.#stopping = false; + } + // Every stop has entered before any join/reap/restore; none is serially awaited. + for (const entry of entries) this.#startTails(entry); + const deferred = [...this.#deferredCloses]; + this.#deferredCloses.clear(); + for (const close of deferred) close(); + const joins = Promise.all(entries.map((entry) => this.#entryFinished(entry))).then( + () => undefined, + ); + void this.#wait(joins).then((settled) => { + this.#sampleHealth(); + this.#finished = true; + if (settled) this.#disarmTimer(); + finish( + Object.freeze({ + kind: settled ? 'settled' : 'deadline', + unacknowledgedOwners: this.#entries.size, + }), + ); + }); + return this.#termination; + } + + /** Actual workflow facts may strengthen disposition while another owner remains pending. */ + sampleWorkflowHealth(): void { + this.#sampleHealth(); + } + + #closeEntry(entry: Entry): Promise { + if (entry.closeResult !== undefined) return entry.closeResult; + let finish: (value: OwnerCloseResult) => void = () => undefined; + entry.closeResult = new Promise((resolve) => { + finish = resolve; + }); + entry.closing = true; + this.#attempt(entry, 'stop', entry.owner.stop); + const join = (): void => { + this.#startTails(entry); + void this.#wait(this.#entryFinished(entry)).then((settled) => { + finish(Object.freeze({ kind: settled ? 'retired' : 'unacknowledged' })); + }); + }; + if (this.#stopping) this.#deferredCloses.add(join); + else join(); + return entry.closeResult; + } + + #startTails(entry: Entry): void { + this.#attempt(entry, 'join', entry.owner.join); + const owner = entry.owner; + if (owner.kind === 'terminal') this.#attempt(entry, 'restore', owner.restore); + else if ( + owner.kind === 'native-group' || + owner.kind === 'native-pid' || + owner.kind === 'windows-live-root' + ) { + if (!this.#canStart() || entry.retired) return; + try { + if (owner.mayReap()) this.#attempt(entry, 'reap', owner.reap); + } catch (cause) { + this.#failed(entry, 'reap', cause); + } + } + } + + #entryFinished(entry: Entry): Promise { + return Promise.all([entry.retirement, ...entry.stages.values()]).then(() => undefined); + } + + #attempt( + entry: Entry, + stage: OwnerStage, + operation: (budget: RemainingBudget) => void | Promise, + ): void { + if (entry.stages.has(stage) || entry.retired) return; + // Expiry cannot excuse another owner's finite cancellation request. Runtime stop adapters + // must respect the zero-budget contract; joins/restore/reap helpers remain deadline-gated. + if (stage !== 'stop' && !this.#canStart()) return; + let finish = (): void => undefined; + const observed = new Promise((resolve) => { + finish = resolve; + }); + entry.stages.set(stage, observed); + try { + const raw = operation(this.#budget); + if (raw === undefined) finish(); + else if ( + !this.#observe(raw, finish, (cause) => { + this.#failed(entry, stage, cause); + finish(); + }) + ) { + this.#record(entry, stage, 'cli_internal'); + // Observation setup failure is not raw settlement. Keep the attempt and lifetime unacknowledged. + } + } catch (cause) { + this.#failed(entry, stage, cause); + finish(); + } + } + + #observe( + raw: Promise, + resolved: (value: T) => void, + rejected: (cause: unknown) => void, + ): boolean { + try { + if (typeof nativeThen !== 'function') return false; + Reflect.apply(nativeThen, raw, [resolved, rejected]); + return true; + } catch { + return false; + } + } + + #retire(entry: Entry, result: OwnerRetirement): void { + try { + // A hostile getter or malformed adapter payload is not a lifetime witness. + const kind: unknown = Object.getOwnPropertyDescriptor(result, 'kind')?.value; + const descriptor = Object.getOwnPropertyDescriptor(result, 'failure'); + const failure: unknown = descriptor?.value; + if ( + kind !== 'settled' || + (descriptor !== undefined && (!('value' in descriptor) || !isOwnerFailureCode(failure))) + ) { + this.#record(entry, 'retirement', 'cli_internal'); + return; + } + entry.retired = true; + this.#health(entry); + if (isOwnerFailureCode(failure)) this.#record(entry, 'retirement', failure); + this.#entries.delete(entry); + entry.resolveRetirement(); + } catch (cause) { + this.#failed(entry, 'retirement', cause); + } + } + + #health(entry: Entry): void { + if (entry.owner.kind !== 'workflow' || entry.sampling) return; + entry.sampling = true; + try { + this.observations.observeWorkflowSafety(entry.owner.readSafetyExit()); + } catch (cause) { + this.#failed(entry, 'health', cause); + } finally { + entry.sampling = false; + } + } + + #sampleHealth(): void { + if (this.#sampling) return; + this.#sampling = true; + try { + for (const entry of this.#entries) this.#health(entry); + } finally { + this.#sampling = false; + } + } + + #failed(entry: Entry, stage: OwnerStage, cause: unknown): void { + let observedAt: number; + try { + observedAt = this.#now(); + } catch { + this.#record(entry, stage, 'cli_internal'); + return; + } + try { + this.#record(entry, stage, entry.owner.classifyFailure(stage, cause), observedAt); + } catch { + this.#record(entry, stage, 'cli_internal', observedAt); + } + } + + #record(entry: Entry, stage: OwnerStage, code: OwnerFailureCode, observedAt?: number): void { + if (!isOwnerFailureCode(code)) code = 'cli_internal'; + this.#failures.add(`${entry.owner.kind}:${stage}:${code}`); + this.observations.observeFailure(code); + if ((code === 'cli_internal' || code === 'output_unavailable') && !this.#finished) { + // A callback's first infrastructure failure has the same absolute budget as an external fatal request. + // Recoverable background rejection is deliberately a separate observation API. + try { + if (observedAt !== undefined) this.#tighten(observedAt + FATAL_TERMINATION_MS); + void this.terminate({ kind: 'infrastructure', code }); + } catch { + this.observations.observeFailure('cli_internal'); + this.#expire(); + } + } + } + + #observeRequest(request: TerminationRequest): void { + if (request.kind === 'infrastructure') { + this.observations.observeFailure(request.code); + if (!this.#finished) { + try { + this.#tighten(this.#now() + FATAL_TERMINATION_MS); + } catch { + this.observations.observeFailure('cli_internal'); + this.#expire(); + } + } + } else if (request.kind === 'signal') { + this.observations.observeTerminatingSignal(request.signalNumber); + if (!this.#finished && request.deadline !== undefined) { + try { + this.#tighten(request.deadline); + } catch { + this.observations.observeFailure('cli_internal'); + this.#expire(); + } + } + } + this.#sampleHealth(); + } + + #now(): number { + const value = this.#clock.now(); + if (!Number.isFinite(value)) throw new CliError('cli_internal', ''); + return value; + } + + #tighten(deadline: number): void { + if (!Number.isFinite(deadline)) throw new CliError('cli_internal', ''); + if (this.#deadline !== undefined && this.#deadline <= deadline) return; + this.#deadline = deadline; + this.#disarmTimer(); + const remaining = deadline - this.#now(); + if (remaining <= 0) this.#expire(); + else this.#disarm = this.#clock.schedule(remaining, () => this.#expire()); + } + + #disarmTimer(): void { + const disarm = this.#disarm; + this.#disarm = undefined; + try { + disarm?.(); + } catch { + this.observations.observeFailure('cli_internal'); + } + } + + #expire(): void { + this.#expired = true; + const waiters = [...this.#waiters]; + this.#waiters.clear(); + for (const resolve of waiters) resolve(); + } + + #canStart(): boolean { + try { + if (!this.#expired && this.#deadline !== undefined && this.#now() >= this.#deadline) + this.#expire(); + } catch { + this.observations.observeFailure('cli_internal'); + this.#expire(); + } + return !this.#expired; + } + + #wait(work: Promise): Promise { + return new Promise((resolve) => { + const expired = (): void => resolve(false); + if (this.#expired) { + resolve(false); + return; + } + this.#waiters.add(expired); + // Retire each waiter on actual completion; an immortal expiry Promise would retain one per closed owner. + void work.then(() => { + this.#waiters.delete(expired); + resolve(true); + }); + }); + } +} + +function isOwnerFailureCode(value: unknown): value is OwnerFailureCode { + return ( + value === 'internal' || + value === 'storage_unavailable' || + value === 'cli_internal' || + value === 'output_unavailable' + ); +} diff --git a/apps/cli/src/process/options-lexical.test.ts b/apps/cli/src/process/options-lexical.test.ts new file mode 100644 index 00000000..5d678f96 --- /dev/null +++ b/apps/cli/src/process/options-lexical.test.ts @@ -0,0 +1,233 @@ +import { isAbsolute, join, parse } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { extractGlobalOptions, resolveGlobalOptions } from './options.js'; + +const prefix = ['node-canary', 'entry-canary']; +const argv = (...tokens: string[]): string[] => [...prefix, ...tokens]; +const nativeRoot = parse(process.cwd()).root; +const absentAbsoluteRoot = join(nativeRoot, 'relavium-step6-uncreated-root-canary'); + +describe('EARLY_DEBUG_EXACT_VALUE', () => { + it.each([undefined, '', '0', 'false', 'TRUE', '01', '1'])( + 'uses only exact RELAVIUM_DEBUG=%s as the absent-flag verbose alias', + (debug) => { + expect(resolveGlobalOptions({}, '/work', { RELAVIUM_DEBUG: debug }).verbosity).toBe( + debug === '1' ? 'verbose' : 'normal', + ); + }, + ); + + it('lets explicit quiet override the debug alias and JSON remain independent', () => { + expect( + resolveGlobalOptions({ quiet: true, json: true }, '/work', { RELAVIUM_DEBUG: '1' }), + ).toMatchObject({ verbosity: 'quiet', json: true }); + }); + + it('lets explicit verbose override a non-enabling debug value', () => { + expect( + resolveGlobalOptions({ verbose: true }, '/work', { RELAVIUM_DEBUG: '0' }).verbosity, + ).toBe('verbose'); + }); + + it.each([{}, { RELAVIUM_DEBUG: '1' }])('refuses both explicit flags with env %j', (env) => { + let failure: unknown; + try { + resolveGlobalOptions({ verbose: true, quiet: true, json: true }, '/work', env); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ code: 'invalid_invocation', exitCode: 2 }); + }); +}); + +describe('EARLY_GLOBAL_POSITION_AND_TERMINATOR', () => { + // Independent shipping grammar fixture: importing descriptors here would hide missing paths. + const commandPaths = [ + ['run', 'workflow.yaml'], + ['chat'], + ['chat-resume', 'session'], + ['chat-list'], + ['chat-export', 'session'], + ['create'], + ['export', 'workflow'], + ['import', 'workflow.yaml'], + ['agent', 'run', 'agent.yaml'], + ['gate', 'run-id'], + ['budget', 'resume', 'run-id'], + ['gate', 'list'], + ['list'], + ['logs', 'run-id'], + ['status'], + ['models'], + ['models', 'refresh'], + ['models', 'pricing', 'model'], + ['provider', 'list'], + ['provider', 'add', 'provider'], + ['provider', 'set-key', 'provider'], + ['media', 'prune-temporaries'], + ['provider', 'remove-key', 'provider'], + ['provider', 'test', 'provider'], + ]; + + it.each(commandPaths.map((path) => ({ path })))( + 'preserves command path $path with globals before and after it', + ({ path }) => { + const result = extractGlobalOptions(argv('--json', ...path, '-q')); + expect(result.error).toBeUndefined(); + expect(result.raw).toEqual({ json: true, quiet: true }); + expect(result.rest).toEqual(argv(...path)); + }, + ); + + it('preserves both node-style prefix tokens even when they resemble global flags', () => { + expect(extractGlobalOptions(['--json', '-q', 'status'])).toEqual({ + raw: {}, + rest: ['--json', '-q', 'status'], + }); + }); + + it.each(['constructor', 'toString', '__proto__'])( + 'leaves prototype-named unknown command token %s for Commander', + (token) => { + const result = extractGlobalOptions(argv('--json', token, '-q')); + expect(result.error).toBeUndefined(); + expect(result.raw).toEqual({ json: true, quiet: true }); + expect(result.rest).toEqual(argv(token)); + }, + ); + + it('retains help/version spellings while extracting lowercase -v', () => { + const result = extractGlobalOptions(argv('-v', '-V', '-h', '--help', '--version', 'status')); + expect(result.raw).toEqual({ verbose: true }); + expect(result.rest).toEqual(argv('-V', '-h', '--help', '--version', 'status')); + }); + + it('stops at -- and preserves every later global-looking token verbatim', () => { + const result = extractGlobalOptions(argv('--json', 'run', '--', '-q', '--cwd', 'x', '--json')); + expect(result.raw).toEqual({ json: true }); + expect(result.rest).toEqual(argv('run', '--', '-q', '--cwd', 'x', '--json')); + }); + + it('preserves repeated command inputs and their additional equals signs', () => { + const input = ['run', 'wf', '--input', 'first=a=b', '--input', 'second=', '--input=third=c=d']; + const result = extractGlobalOptions(argv(...input, '--json')); + expect(result.error).toBeUndefined(); + expect(result.raw).toEqual({ json: true }); + expect(result.rest).toEqual(argv(...input)); + }); + + it('accepts spaced/equal valued globals without normalizing their lexical content', () => { + const result = extractGlobalOptions( + argv('--cwd', 'relative/a=b', 'list', '--config=config=c.toml'), + ); + expect(result.error).toBeUndefined(); + expect(result.raw).toEqual({ cwd: 'relative/a=b', config: 'config=c.toml' }); + expect(result.rest).toEqual(argv('list')); + }); + + it('retains last explicit color/cwd/config occurrence', () => { + const result = extractGlobalOptions( + argv('--color', '--cwd=a', '--config=a', '--no-color', '--cwd', 'b', '--config', 'b'), + ); + expect(result.error).toBeUndefined(); + expect(result.raw).toEqual({ color: false, cwd: 'b', config: 'b' }); + }); +}); + +describe('EARLY_MALFORMED_GLOBAL_HAS_POLICY', () => { + it.each(['--cwd', '--config', '--allow-fs-root'])( + 'returns a fixed typed refusal for missing/blank/next-flag values of %s', + (flag) => { + const cases = [ + [flag], + [flag, ''], + [flag, ' '], + [flag, '--unknown-secret-canary'], + [`${flag}=`], + [`${flag}= `], + ]; + for (const tokens of cases) { + const result = extractGlobalOptions(argv('--json', '-q', 'status', ...tokens, '--verbose')); + expect(result.error).toMatchObject({ + code: 'invalid_invocation', + exitCode: 2, + message: `\`${flag}\` requires a non-empty argument.`, + }); + expect(result.raw).toEqual({ json: true, quiet: true }); + expect(result.rest).toEqual(argv('status')); + expect(resolveGlobalOptions(result.raw, '/work', { RELAVIUM_DEBUG: '1' })).toMatchObject({ + json: true, + verbosity: 'quiet', + }); + expect(result.error?.message).not.toContain('canary'); + } + }, + ); +}); + +describe('EARLY_FS_SYNTAX_ONLY', () => { + it('accepts a syntactically native absolute root without filesystem admission', () => { + expect(isAbsolute(absentAbsoluteRoot)).toBe(true); + const result = extractGlobalOptions(argv('chat', '--allow-fs-root', absentAbsoluteRoot)); + expect(result.error).toBeUndefined(); + expect(result.raw.allowFsRoots).toEqual([absentAbsoluteRoot]); + const global = resolveGlobalOptions(result.raw, '/work'); + expect(global.allowFsRoots).toEqual([absentAbsoluteRoot]); + expect(global.fsGrants).toBeUndefined(); + expect(Object.isFrozen(global.allowFsRoots)).toBe(true); + }); + + it.each([ + 'relative-secret-canary', + './relative-secret-canary', + '../relative-secret-canary', + '~secret-canary', + ])('refuses nonabsolute root syntax without echoing %s', (root) => { + const result = extractGlobalOptions(argv('--json', '--allow-fs-root', root)); + expect(result.error).toMatchObject({ code: 'invalid_invocation', exitCode: 2 }); + expect(result.error?.message).toBe('Filesystem root grants must use absolute paths.'); + expect(result.raw).toEqual({ json: true }); + expect(result.error?.message).not.toContain(root); + }); + + it('uses the native path syntax rather than accepting a foreign drive by spelling', () => { + const root = 'C:\\relavium-secret-canary'; + const result = extractGlobalOptions(argv('--allow-fs-root', root)); + if (isAbsolute(root)) { + expect(result.error).toBeUndefined(); + expect(result.raw.allowFsRoots).toEqual([root]); + } else { + expect(result.error).toMatchObject({ code: 'invalid_invocation', exitCode: 2 }); + expect(result.error?.message).toBe('Filesystem root grants must use absolute paths.'); + } + }); + + it('accepts sixteen roots and refuses the seventeenth without losing the first sixteen', () => { + const roots = Array.from({ length: 17 }, (_, index) => join(nativeRoot, `root-${index}`)); + const tokens = roots.flatMap((root) => ['--allow-fs-root', root]); + const atLimit = extractGlobalOptions(argv(...tokens.slice(0, 32))); + expect(atLimit.error).toBeUndefined(); + expect(atLimit.raw.allowFsRoots).toEqual(roots.slice(0, 16)); + const overLimit = extractGlobalOptions(argv(...tokens)); + expect(overLimit.error).toMatchObject({ + code: 'invalid_invocation', + exitCode: 2, + message: 'Filesystem root grants exceed the supported bounds.', + }); + expect(overLimit.raw.allowFsRoots).toEqual(roots.slice(0, 16)); + }); + + it('accepts 4096 lexical characters and refuses 4097 without echoing the root', () => { + const root = nativeRoot + 'a'.repeat(4096 - nativeRoot.length); + expect(extractGlobalOptions(argv(`--allow-fs-root=${root}`)).error).toBeUndefined(); + expect(extractGlobalOptions(argv(`--allow-fs-root=${root}`)).raw.allowFsRoots).toEqual([root]); + const result = extractGlobalOptions(argv(`--allow-fs-root=${root}b`)); + expect(result.error).toMatchObject({ + code: 'invalid_invocation', + exitCode: 2, + message: 'Filesystem root grants exceed the supported bounds.', + }); + expect(result.raw.allowFsRoots).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/process/options.ts b/apps/cli/src/process/options.ts index 95159af3..b6e897b9 100644 --- a/apps/cli/src/process/options.ts +++ b/apps/cli/src/process/options.ts @@ -1,5 +1,10 @@ -import { CliError } from './errors.js'; -import { FS_ROOT_LIMITS } from '@relavium/shared'; +import { isAbsolute } from 'node:path'; + +import { CliError } from './cli-error.js'; +import { FS_ROOT_LIMITS } from '@relavium/shared/fs-root-limits'; +import { resolveDiagnosticPolicy, type Verbosity } from './diagnostic-policy.js'; +export type { Verbosity } from './diagnostic-policy.js'; +import { globalOptionForSpelling, type GlobalOptionDescriptor } from '../commands/descriptors.js'; import type { InvocationFsGrants } from '../engine/tool-host/fs-policy.js'; /** @@ -8,7 +13,6 @@ import type { InvocationFsGrants } from '../engine/tool-host/fs-policy.js'; * equivalent), so they are owned here — extracted from argv before `commander` parses the * rest — rather than declared as `commander` options (which bind to the command they follow). */ -export type Verbosity = 'quiet' | 'normal' | 'verbose'; export interface GlobalOptions { readonly json: boolean; @@ -62,36 +66,31 @@ export interface ExtractedArgv { readonly error?: CliError; } -/** Boolean global flags (no value) → the mutation each applies to the raw options. */ -const BOOLEAN_FLAGS: Readonly void>> = { - '--json': (raw) => { - raw.json = true; - }, - '--no-color': (raw) => { - raw.color = false; - }, - '--color': (raw) => { - raw.color = true; - }, - '--verbose': (raw) => { - raw.verbose = true; - }, - '-v': (raw) => { - raw.verbose = true; - }, - '--quiet': (raw) => { - raw.quiet = true; - }, - '-q': (raw) => { - raw.quiet = true; - }, - '--no-alt-screen': (raw) => { - raw.noAltScreen = true; - }, - '--no-mouse': (raw) => { - raw.noMouse = true; - }, -}; +/** Descriptor bindings are a closed internal grammar; no prototype lookup consumes unknown argv. */ +function applyBoolean(raw: RawGlobalOptions, option: GlobalOptionDescriptor): void { + switch (option.binding) { + case 'json': + raw.json = true; + break; + case 'color': + raw.color = !option.negated; + break; + case 'verbose': + raw.verbose = true; + break; + case 'quiet': + raw.quiet = true; + break; + case 'noAltScreen': + raw.noAltScreen = true; + break; + case 'noMouse': + raw.noMouse = true; + break; + default: + throw new CliError('cli_internal', ''); + } +} type ValueFlagResult = | { readonly kind: 'consumed'; readonly advance: number } @@ -122,9 +121,9 @@ export function extractGlobalOptions(argv: readonly string[]): ExtractedArgv { rest.push(...tokens.slice(i)); break; } - const applyBoolean = BOOLEAN_FLAGS[token]; - if (applyBoolean !== undefined) { - applyBoolean(raw); + const option = globalOptionForSpelling(token); + if (option?.owner === 'extractor' && option.arity === 'none') { + applyBoolean(raw, option); continue; } const valued = takeValueFlag(raw, token, tokens[i + 1]); @@ -141,79 +140,66 @@ export function extractGlobalOptions(argv: readonly string[]): ExtractedArgv { return { raw, rest: [...prefix, ...rest] }; } -/** Handle the value-bearing flags (`--cwd` / `--config`), spaced or `=`-form. */ +/** Recognize value arity from the same descriptor authority used by help/discovery. */ function takeValueFlag( raw: RawGlobalOptions, token: string, next: string | undefined, ): ValueFlagResult { - if ( - token.startsWith('--cwd=') || - token.startsWith('--config=') || - token.startsWith('--allow-fs-root=') - ) { - const eq = token.indexOf('='); - const flag = token.slice(0, eq); - const value = token.slice(eq + 1); - if (value.trim() === '') { - return { kind: 'error', error: requiresArgument(flag) }; - } - const error = assignValueFlag(raw, flag, value); - if (error !== undefined) return { kind: 'error', error }; - return { kind: 'consumed', advance: 0 }; + const eq = token.indexOf('='); + const spelling = eq < 0 ? token : token.slice(0, eq); + const option = globalOptionForSpelling(spelling); + if (option?.owner !== 'extractor' || option.arity === 'none') return { kind: 'skip' }; + const value = eq < 0 ? next : token.slice(eq + 1); + if (value === undefined || value.trim() === '' || (eq < 0 && value.startsWith('-'))) { + return { kind: 'error', error: requiresArgument(spelling) }; } - if (token === '--cwd' || token === '--config' || token === '--allow-fs-root') { - if (next === undefined || next.startsWith('-') || next.trim() === '') { - return { kind: 'error', error: requiresArgument(token) }; - } - const error = assignValueFlag(raw, token, next); - if (error !== undefined) return { kind: 'error', error }; - return { kind: 'consumed', advance: 1 }; - } - return { kind: 'skip' }; + const error = assignValueFlag(raw, option, value); + if (error !== undefined) return { kind: 'error', error }; + return { kind: 'consumed', advance: eq < 0 ? 1 : 0 }; } -function assignValueFlag(raw: RawGlobalOptions, flag: string, value: string): CliError | undefined { - if (flag === '--allow-fs-root') { - if ( - value.length > FS_ROOT_LIMITS.characters || - (raw.allowFsRoots?.length ?? 0) >= FS_ROOT_LIMITS.count - ) { - return new CliError( - 'invalid_invocation', - 'Filesystem root grants exceed the supported bounds.', - ); - } - (raw.allowFsRoots ??= []).push(value); - return undefined; - } - if (flag === '--cwd') { - raw.cwd = value; - } else { - raw.config = value; +function assignValueFlag( + raw: RawGlobalOptions, + option: GlobalOptionDescriptor, + value: string, +): CliError | undefined { + switch (option.binding) { + case 'allowFsRoots': + if ( + value.length > FS_ROOT_LIMITS.characters || + (raw.allowFsRoots?.length ?? 0) >= FS_ROOT_LIMITS.count + ) { + return new CliError( + 'invalid_invocation', + 'Filesystem root grants exceed the supported bounds.', + ); + } + if (!isAbsolute(value)) + return new CliError( + 'invalid_invocation', + 'Filesystem root grants must use absolute paths.', + ); + (raw.allowFsRoots ??= []).push(value); + return undefined; + case 'cwd': + raw.cwd = value; + return undefined; + case 'config': + raw.config = value; + return undefined; + default: + throw new CliError('cli_internal', ''); } - return undefined; } function requiresArgument(flag: string): CliError { return new CliError('invalid_invocation', `\`${flag}\` requires a non-empty argument.`); } -/** The one cross-flag rule: `--verbose` and `--quiet` are mutually exclusive. */ +/** Preserve the existing conflict API through the single diagnostic policy. */ export function assertNoGlobalOptionConflicts(raw: RawGlobalOptions): void { - if (raw.verbose === true && raw.quiet === true) { - throw new CliError('invalid_invocation', '`--verbose` and `--quiet` cannot be combined.'); - } -} - -function resolveVerbosity(raw: RawGlobalOptions): Verbosity { - if (raw.quiet === true) { - return 'quiet'; - } - if (raw.verbose === true) { - return 'verbose'; - } - return 'normal'; + resolveDiagnosticPolicy(raw, {}); } /** @@ -244,13 +230,13 @@ export function resolveGlobalOptions( defaultCwd: string, env: Readonly> = {}, ): GlobalOptions { - assertNoGlobalOptionConflicts(raw); + const policy = resolveDiagnosticPolicy(raw, env); return { - json: raw.json === true, + json: policy.json, color: resolveColor(raw, env), cwd: raw.cwd ?? defaultCwd, configPath: raw.config, - verbosity: resolveVerbosity(raw), + verbosity: policy.verbosity, noAltScreen: raw.noAltScreen === true, noMouse: raw.noMouse === true, ...(raw.allowFsRoots === undefined diff --git a/apps/cli/src/process/pure-substrate-graph.test.ts b/apps/cli/src/process/pure-substrate-graph.test.ts new file mode 100644 index 00000000..52ec6ccc --- /dev/null +++ b/apps/cli/src/process/pure-substrate-graph.test.ts @@ -0,0 +1,230 @@ +import { readFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +const repository = fileURLToPath(new URL('../../../../', import.meta.url)); +const compilerOptions: ts.CompilerOptions = { + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + target: ts.ScriptTarget.ES2022, + verbatimModuleSyntax: true, +}; + +/** Inspect emitted JS, so import/export type edges are erased by the real compiler. */ +function runtimeSpecifiers(source: string, filename: string): string[] { + const output = ts.transpileModule(source, { fileName: filename, compilerOptions }).outputText; + const parsed = ts.createSourceFile( + filename.replace(/\.tsx?$/, '.js'), + output, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.JS, + ); + const found: string[] = []; + const visit = (node: ts.Node): void => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier !== undefined && + ts.isStringLiteral(node.moduleSpecifier) + ) + found.push(node.moduleSpecifier.text); + if ( + ts.isCallExpression(node) && + node.arguments[0] !== undefined && + ts.isStringLiteral(node.arguments[0]) + ) { + if ( + node.expression.kind === ts.SyntaxKind.ImportKeyword || + (ts.isIdentifier(node.expression) && node.expression.text === 'require') + ) + found.push(node.arguments[0].text); + } + ts.forEachChild(node, visit); + }; + visit(parsed); + return found; +} + +interface Closure { + readonly files: Set; + readonly packages: Set; + readonly edges: string[]; +} + +interface ClosureOptions { + readonly opaquePackages?: ReadonlySet; + readonly permittedPackages?: ReadonlySet; + readonly sourceOverrides?: ReadonlyMap; +} + +function runtimeClosure(roots: readonly string[], options: ClosureOptions = {}): Closure { + const files = new Set(); + const packages = new Set(); + const edges: string[] = []; + const pending = roots.map((root) => resolve(repository, root)); + while (pending.length > 0) { + const file = pending.pop(); + if (file === undefined || files.has(file)) continue; + files.add(file); + const source = options.sourceOverrides?.get(file) ?? readFileSync(file, 'utf8'); + for (const specifier of runtimeSpecifiers(source, file)) { + edges.push(`${relative(repository, file)} -> ${specifier}`); + if (!specifier.startsWith('.')) { + packages.add(specifier); + if (options.permittedPackages !== undefined && !options.permittedPackages.has(specifier)) + throw new Error( + `forbidden runtime import: ${specifier} from ${relative(repository, file)}`, + ); + } + if (options.opaquePackages?.has(specifier) === true) continue; + if (specifier.startsWith('node:')) continue; + const resolved = ts.resolveModuleName( + specifier, + file, + compilerOptions, + ts.sys, + ).resolvedModule; + if (resolved === undefined) + throw new Error(`unresolved runtime edge: ${specifier} from ${file}`); + // Supported package exports resolve to declarations for tsc. Follow their actual paired JS. + const target = resolved.resolvedFileName.replace(/\.d\.ts$/, '.js'); + pending.push(resolve(dirname(file), target)); + } + } + return { files, packages, edges }; +} + +describe('pure Step 6 substrate runtime import closure', () => { + it('uses compiler-erased edges, including mixed imports, exports, dynamic imports and require', () => { + expect( + runtimeSpecifiers( + ` + import type { Heavy } from 'heavy-types'; + export type { Heavy } from 'heavy-export-types'; + import { type Erased, kept } from 'mixed'; + export { type Erased, kept } from 'mixed-export'; + export * from 'value-reexport'; + void import('dynamic-value'); + const value = require('required-value'); + `, + 'witness.ts', + ), + ).toEqual(['mixed', 'mixed-export', 'value-reexport', 'dynamic-value', 'required-value']); + }); + + it('resolves every pure root transitively without loading service packages or the runtime classifier', () => { + const graph = runtimeClosure([ + 'apps/cli/src/process/cli-error.ts', + 'apps/cli/src/process/diagnostic-policy.ts', + 'apps/cli/src/process/options.ts', + 'apps/cli/src/process/exit-observations.ts', + 'apps/cli/src/process/invocation-registry.ts', + 'apps/cli/src/process/output-mode.ts', + 'apps/cli/src/process/source-label.ts', + 'apps/cli/src/commands/input.ts', + 'apps/cli/src/commands/descriptors.ts', + 'apps/cli/src/commands/discovery.ts', + ]); + expect([...graph.packages].sort(), graph.edges.join('\n')).toEqual([ + '@relavium/shared/fs-root-limits', + '@relavium/shared/relavium-error', + 'node:path', + ]); + const filenames = [...graph.files].map((file) => relative(repository, file)); + expect(filenames.some((file) => file.endsWith('shared/dist/relavium-error.js'))).toBe(true); + expect(filenames.some((file) => file.endsWith('shared/dist/fs-root-limits.js'))).toBe(true); + expect( + filenames.filter( + (file) => + file.includes('/node_modules/') && + !file.endsWith('shared/dist/relavium-error.js') && + !file.endsWith('shared/dist/fs-root-limits.js'), + ), + ).toEqual([]); + expect( + filenames.filter((file) => + /(?:process\/errors|commands\/(?:dispatch|manifest|specs)|test-support|engine\/|db\/|render\/|config\/)/.test( + file, + ), + ), + ).toEqual([]); + expect( + filenames.every( + (file) => + file.startsWith('apps/cli/src/') || + /packages\/shared\/dist\/(?:relavium-error|fs-root-limits)\.js$/.test(file), + ), + ).toBe(true); + }); +}); + +describe('lightweight Commander adapter runtime import closure', () => { + const roots = ['apps/cli/src/program.ts', 'apps/cli/src/commands/specs.ts']; + const permittedPackages = new Set([ + 'commander', + '@relavium/shared/fs-root-limits', + '@relavium/shared/relavium-error', + 'node:path', + ]); + const opaquePackages = new Set(['commander']); + + function adapterClosure(sourceOverrides?: ReadonlyMap): Closure { + return runtimeClosure(roots, { + permittedPackages, + opaquePackages, + ...(sourceOverrides === undefined ? {} : { sourceOverrides }), + }); + } + + function expectLightweight(graph: Closure): void { + expect([...graph.packages].sort(), graph.edges.join('\n')).toEqual([ + '@relavium/shared/fs-root-limits', + '@relavium/shared/relavium-error', + 'commander', + ]); + const filenames = [...graph.files].map((file) => relative(repository, file)); + expect(filenames).toContain('apps/cli/src/program.ts'); + expect(filenames).toContain('apps/cli/src/commands/specs.ts'); + expect(filenames).toContain('apps/cli/src/commands/descriptors.ts'); + expect( + filenames.filter((file) => + /(?:process\/errors|commands\/(?:dispatch|manifest)|test-support|engine\/|db\/|render\/|config\/)/.test( + file, + ), + ), + ).toEqual([]); + expect( + filenames.every( + (file) => + file.startsWith('apps/cli/src/') || + /packages\/shared\/dist\/(?:relavium-error|fs-root-limits)\.js$/.test(file), + ), + ).toBe(true); + } + + it('follows local and supported shared values, stopping only at the reviewed Commander framework boundary', () => { + // This source guard deliberately does not certify Commander's installed transitive graph, + // packed/native reachability, or Step 7 bootstrap behavior. + expectLightweight(adapterClosure()); + }); + + it('rejects a compiler-emitted heavy value edge while erasing the same type-only fixture edge', () => { + const filename = resolve(repository, 'apps/cli/src/commands/specs.ts'); + const original = readFileSync(filename, 'utf8'); + const typeOnly = new Map([ + [filename, `import type { WorkflowEngine } from '@relavium/core';\n${original}`], + ]); + expectLightweight(adapterClosure(typeOnly)); + const value = new Map([ + [ + filename, + `import { WorkflowEngine } from '@relavium/core';\nvoid WorkflowEngine;\n${original}`, + ], + ]); + expect(() => adapterClosure(value)).toThrowError( + 'forbidden runtime import: @relavium/core from apps/cli/src/commands/specs.ts', + ); + }); +}); diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 2f99e84b..29a4ad40 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -1,6 +1,8 @@ import { Command } from 'commander'; import { registerCommands, type CommandContext } from './commands/specs.js'; +import { GLOBAL_OPTION_DESCRIPTORS } from './commands/descriptors.js'; +import type { DispatchCommand } from './commands/input.js'; import type { CliIo } from './process/io.js'; export interface BuildProgramOptions { @@ -8,6 +10,8 @@ export interface BuildProgramOptions { readonly suppressErrorOutput?: boolean; /** The runtime context the real commands need; absent ⇒ commands stay help-only stubs. */ readonly context?: CommandContext; + /** Inject runtime dispatch; constructing grammar never imports or starts services. */ + readonly execute?: DispatchCommand; } /** @@ -25,16 +29,14 @@ export const CLI_VERSION = */ const GLOBAL_OPTIONS_HELP = ` Global options (usable anywhere on the command line): - --json emit machine-readable NDJSON output (disables the TUI) - --color force colored output on - --no-color disable colored output - --cwd run as if started in - --config use an explicit config file - --allow-fs-root grant a chat/Home/agent-run root (repeatable, max 16) - --no-alt-screen keep the inline renderer (no full-screen alt screen) - --no-mouse disable mouse reporting (restores native click-drag selection) - -v, --verbose print verbose diagnostics to stderr - -q, --quiet suppress non-essential output +${GLOBAL_OPTION_DESCRIPTORS.filter((option) => option.owner === 'extractor') + .map((option) => { + const name = + option.spellings.join(', ') + + (option.arity === 'none' ? '' : ` <${option.placeholder ?? 'value'}>`); + return ` ${name.padEnd(Math.max(18, name.length + 2))}${option.description}`; + }) + .join('\n')} Color precedence: --color/--no-color > NO_COLOR (any value ⇒ off) > FORCE_COLOR (0/false ⇒ off) > on.`; @@ -72,6 +74,6 @@ export function buildProgram(io: CliIo, options?: BuildProgramOptions): Command // NB: no `program.action()` — a default action would make `commander` treat an unknown // subcommand as a positional argument (swallowing the unknown-command error). The bare // invocation (no subcommand) is handled in run.ts, which prints help and exits 0. - registerCommands(program, options?.context); + registerCommands(program, options?.context, options?.execute); return program; } diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index 8ee43250..bb7c5b38 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -1,3 +1,4 @@ +import { executeCommand } from './commands/dispatch.js'; import { CommanderError } from 'commander'; import { resolveHomeDir } from './config/load.js'; @@ -65,6 +66,7 @@ export async function run( const program = buildProgram(io, { suppressErrorOutput: renderCtx.json, context: { io, global, result }, + execute: executeCommand, }); // Bare invocation — no subcommand after extraction (only `[node, script]`, optionally a lone `--`). From be603f012fe3acadc573fc83e2ba054fc47ed3e7 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 07:50:16 +0300 Subject: [PATCH 060/222] test(repo): preserve causal controls across descriptor extraction Repoint the two existing secret forwarding/parser mutations to their actual generic call path. Admit only the supported shared leaves in the source fence and pin the exported alias set. Refs: ADR-0110, ADR-0111; Wave 3 Step 6 Co-Authored-By: Codex --- tools/secrets-controls/mutations.mjs | 27 ++++++++----------- .../usage-coverage-controls/source-graph.mjs | 2 ++ .../usage-coverage-controls/workload.test.mjs | 26 +++++++++++++++++- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index 0bebce7b..920482ec 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -309,8 +309,8 @@ export const mutations = [ control( 'run-action-stdin-forwarding', 'apps/cli/src/commands/specs.ts', - ' secretInputsStdin: opts.secretInputsStdin,\n', - '', + ' options[option.binding] = readOption(raw, option.binding);', + " options[option.binding] = executorId === 'run' && option.binding === 'secretInputsStdin' ? undefined : readOption(raw, option.binding);", ['apps/cli/src/commands/specs-forwarding.test.ts'], 14, 'SECRETS_CAUSAL_RUN_STDIN_FLAG_FORWARDED: run forwards the boolean through its real action', @@ -404,20 +404,15 @@ export const mutations = [ control( 'run-commander-diagnostic-projection', 'apps/cli/src/commands/specs.ts', - ` .configureOutput({ - ...program.configureOutput(), - outputError: (_message, write) => - write('error: invalid run arguments; use relavium run --help.\\n'), - }) - .exitOverride((error) => { - if (error.exitCode === 0) throw error; - throw new CommanderError( - error.exitCode, - error.code, - 'error: invalid run arguments; use relavium run --help.', - ); - });`, - ' .exitOverride();', + ` command.configureOutput({ + ...parent.configureOutput(), + outputError: (_message, write) => write(\`\${hint}\\n\`), + }); + command.exitOverride((error) => { + if (error.exitCode === 0) throw error; + throw new CommanderError(error.exitCode, error.code, hint); + });`, + ' command.exitOverride();', ['apps/cli/src/run.test.ts'], 28, 'SECRETS_CAUSAL_RUN_COMMANDER_OPACITY: human keeps parser values opaque', diff --git a/tools/usage-coverage-controls/source-graph.mjs b/tools/usage-coverage-controls/source-graph.mjs index 43d77b7c..9820e2f3 100644 --- a/tools/usage-coverage-controls/source-graph.mjs +++ b/tools/usage-coverage-controls/source-graph.mjs @@ -21,6 +21,8 @@ import { systemTool } from '../budget-replay-compat/system-tools.mjs'; export const aliases = Object.freeze({ '@relavium/shared': 'packages/shared/src/index.ts', + '@relavium/shared/relavium-error': 'packages/shared/src/relavium-error.ts', + '@relavium/shared/fs-root-limits': 'packages/shared/src/fs-root-limits.ts', '@relavium/llm': 'packages/llm/src/index.ts', '@relavium/llm/adapters': 'packages/llm/src/adapters/index.ts', '@relavium/core': 'packages/core/src/index.ts', diff --git a/tools/usage-coverage-controls/workload.test.mjs b/tools/usage-coverage-controls/workload.test.mjs index c876750b..425fe2c5 100644 --- a/tools/usage-coverage-controls/workload.test.mjs +++ b/tools/usage-coverage-controls/workload.test.mjs @@ -13,7 +13,13 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; -import { digest, instrument } from './source-graph.mjs'; +import { + aliases, + digest, + instrument, + rejectUnknownAlias, + relativeSource, +} from './source-graph.mjs'; import { assertNativeReadiness, assertPermanentWorkload, @@ -21,6 +27,24 @@ import { permanentPhaseTimeoutMs, } from './workload.mjs'; +test('supported shared exports map to their exact owned source leaves, with unknown aliases refused', () => { + const repository = fileURLToPath(new URL('../../', import.meta.url)); + const shared = JSON.parse(readFileSync(join(repository, 'packages/shared/package.json'), 'utf8')); + for (const [subpath, target] of Object.entries(shared.exports)) { + const specifier = subpath === '.' ? '@relavium/shared' : `@relavium/shared/${subpath.slice(2)}`; + const source = `packages/shared/src/${target.import.slice('./dist/'.length).replace(/\.js$/, '.ts')}`; + assert.equal(aliases[specifier], source); + assert.equal(relativeSource({ sourceRoot: repository }, specifier), join(repository, source)); + assert.doesNotThrow(() => rejectUnknownAlias(specifier)); + } + for (const specifier of [ + '@relavium/shared/private', + '@relavium/shared/../core', + '@relavium/unknown', + ]) + assert.throws(() => rejectUnknownAlias(specifier), /USAGE_UNKNOWN_WORKSPACE_ALIAS/); +}); + test('the complete native/scale workload has its own calculated bound and exact inventory', () => { assert.equal(ordinaryPhaseTimeoutMs, 300_000); assert.equal(permanentPhaseTimeoutMs, 300_000 + 71 * 20_000 + 600_000); From f3cab0db993714006e8c318f37618e2cf2683333 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 07:50:16 +0300 Subject: [PATCH 061/222] docs(docs): record wave 3 process substrate implementation Document exact process/discovery contracts, origin inventories and staged integration limits. Attribute author, causal and selected physical evidence separately; independent increment reviews remain required. Refs: ADR-0110, ADR-0111; Wave 3 Step 6 Co-Authored-By: Codex --- docs/reference/cli/commands.md | 64 ++++++++++++-- .../phase-2.5.5-hardening-and-remediation.md | 87 +++++++++++++++++++ docs/standards/error-handling.md | 4 + 3 files changed, 147 insertions(+), 8 deletions(-) diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 6d3ca004..3c201409 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -656,9 +656,14 @@ Numeric cost totals are known realized sums. Human totals qualify unverified his ## Proposed Wave 3 CLI compatibility -**Approved design, unshipped — 2026-10-16.** [ADR-0110](../../decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md) -was approved by the maintainer. This section owns the approved exact contract; -earlier sections describe current behaviour until implementation landing. +**Approved design, partially implemented on `development` — 2026-10-16.** +[ADR-0110](../../decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md) +was approved by the maintainer. Step 6 implements the pure flag/error/observation +and ownership substrate, descriptor-derived current Commander grammar and the +compatible model projection. Its discovery DTO is available as a pure function; +production JSON help, aliases, pricing spellings, output categories and consent +remain Step 10. Lazy entry and actual fatal/native/terminal adapters remain +Steps 7–8. Earlier sections describe current behaviour unless stated here. ### Diagnostic policy @@ -709,7 +714,7 @@ These are CLI dispositions, separate from engine ErrorCode. One closed | Standalone agent: actual turn outcome / terminating external signal | Existing 0/1; actual signal 128+signo absent stronger observed disposition | | Management/meta success / known operational failure | Existing 0 or classified 1; invalid invocation/schema remains 2 | | Typed corrupt/unreadable history or known departure-unacknowledged | Existing known operational 1/remedy, unless actual workflow health overrides | -| Owned storage boundary: typed DbOpenError or filesystem EACCES/EPERM/ENOSPC/EROFS | Fixed `storage_unavailable`, operational 1; correct permissions/capacity before retry. No arbitrary cause text | +| Owned storage boundary: a DbOpenError with owned storage provenance, or filesystem EACCES/EPERM/ENOSPC/EROFS | Fixed `storage_unavailable`, operational 1; correct permissions/capacity before retry. Native import/ABI failures retain infrastructure 9, including constructor-time failures wrapped as open_failed. No arbitrary cause text | | Failed-output/EPIPE/write acknowledgement on any surface | Fixed `output_unavailable`, exit 9 unless actual stronger workflow health overrides | | Unexpected CLI throw, invariant, native import/ABI or wiring failure on any surface | Fixed `cli_internal`, exit 9 unless actual stronger workflow health overrides; never chat-end 4/Home success | | Recoverable unhandled background rejection | Keepalive; upgrade only otherwise clean 0 to 9, preserve established nonzero outcomes | @@ -728,10 +733,36 @@ admitted work (including session publication/join failure) does not prove absenc of paid/effectful work: retain inspect-before-repeat advice before correcting the storage issue. No healthy session receipt is inferred from its numeric exit. +The Step 6 source inventory distinguishes the twelve existing literal +`CliError('internal', ...)` constructors before runtime integration: + +| Existing owner | Count | Step 8 disposition | +|---|---|---| +| [Acknowledged stderr writer](../../../apps/cli/src/process/io.ts) | 1 | Output 9; a failed sink cannot acknowledge itself | +| [Run departure](../../../apps/cli/src/commands/run.ts) and [gate departure](../../../apps/cli/src/commands/gate.ts) | 2 | Known operational 1, subject to actual workflow health | +| [Media maintenance](../../../apps/cli/src/commands/media.ts) | 1 | Split actual storage 1, output 9 and unexpected 9 at their originating boundaries | +| [Session export writer](../../../apps/cli/src/chat/export.ts) | 1 | Owned storage 1; preserve target-shape/existence refusal 2; unexpected 9 | +| [Missing MCP consent wiring](../../../apps/cli/src/engine/mcp-servers.ts) | 1 | Infrastructure 9 before spawn | +| [Job-control setup/disposal](../../../apps/cli/src/render/run-job-control.ts) | 2 | Infrastructure 9, with attempt-all cleanup and primary/health priority | +| [Terminal suspension invariants](../../../apps/cli/src/render/suspend.ts) | 4 | Infrastructure 9; existing busy/no-renderer recoveries remain separate | + +The same inventory tracks workflow/gate/driver catches, every AgentSession/Home +construction, submit and replacement catch, history/forced-exit and storage +owners. The nine current CLI native-DB open invocations are four dispatch sites, +three history helpers, gate resume and model pricing; the shared opener definition +is not a tenth invocation. Minimal origin adapters land in Step 8 and the sole +opener refactor remains last in Step 18. Best-effort catalog/overlay/outbox reads, +ordinary doctor results and explicit benign engine races retain their recovery +policies. Caught unknown faults and forced-success fallbacks require explicit +coordinator routing; a blanket catch or broad internal-code rewrite is insufficient. + Classify native storage errno only at the actual owned I/O boundary, not by examining arbitrary provider/plugin objects or private cause trees. Recognize actual typed DbOpenError without importing DB through the lightweight entry. -The lazy runtime adapter supplies its classification. Existing CLI open wrappers +The lazy runtime adapter supplies its classification. `DbOpenError.open_failed` +alone cannot distinguish storage failure from an addon/ABI load inside the native +database constructor; the actual creation boundary must supply that provenance. +Existing CLI open wrappers that label runtime permission/capacity failures invalid invocation/2 deliberately move to operational 1. Step 6 defines this mapping and Step 8 installs minimal boundary adapters; Step 18 centralizes them after all callers have moved. @@ -764,7 +795,7 @@ numeric value alone ([Node exit codes](https://nodejs.org/docs/latest-v22.x/api/ ### Invocation owners and fatal ordering One invocation-owned lightweight registry is installed before dynamic imports. -Owners register before admitting work and unregister only after actual settlement. +Owners register before admitting work and retire only after actual settlement. Callbacks expose facts/capabilities specific to their owner, not a generic false health object: @@ -790,6 +821,23 @@ external watchdog. Normal receipt joins retain their actual protocol. A timer never acknowledges an owner. After expiry no unbounded async helper starts; finite nonawaited exit cleanup cannot certify native closure. +Step 6's registry observes a creation-owned retirement promise separately from +stop/join/reap attempts. Rejected or malformed retirement retains the owner; +attempt completion and budget expiry never remove it. Successful retirement may +carry a fixed failure classification, releases retained owner closures and keeps +only bounded sticky classifications. Registering an idle session does not call +its host-safe-close barrier: the runtime adapter first seals future admission, +then joins actual lifetime work. Reentrant close requests share one operation; +all stops enter before any join. Terminal restoration can progress while another +owner's join is pending. Failure classification consumes the same absolute fatal +budget. These are pure protocol controls, not native-close or store-close proof. + +A stop adapter synchronously seals its owner and makes a finite cancellation +request even if an earlier owner consumed the budget. At zero remaining budget +it cannot launch a new async helper; a returned promise may only observe work +already started. Joins, restoration and reap helpers do not start after expiry. +This attempt-all cancellation rule grants no retirement or safe-close ACK. + ### Native crash teardown scope | Creation owner / platform | Permitted finite teardown | @@ -831,10 +879,10 @@ order is stable. No internal executor loader paths or functions are serialized. | Descriptor | Required contract | |---|---| -| Command | Canonical `path: string[]`, description, existing effect/label metadata, `availability: 'implemented' \| 'not_implemented'`, aliases, ordered positionals/options, and optional stable `executorId` | +| Command | Canonical `path: string[]`, description, existing effect/label metadata, `availability: 'implemented' \| 'not_implemented'`, aliases, ordered positionals/options, `allowExcessArguments: boolean`, and optional stable `executorId`/existing `modeScope` | | Alias | Explicit token `path`, `deprecated: boolean`; canonical and flat paths register separately | | Positional | Name/binding key, scalar value type, required, variadic, description | -| Option | Binding key, canonical flag spelling(s), deprecated spellings, arity (none/required/optional), placeholder/type where applicable, repetition (single/repeatable/variadic), negation, description, existing default where applicable and conflict bindings | +| Option | Binding key, canonical flag spelling(s), deprecated spellings, value arity (none/required/optional), mandatory-presence `required: boolean` (distinct from value arity), placeholder/type where applicable, repetition (single/repeatable/variadic), negation, description, existing default where applicable and conflict bindings | | Global option | Same grammar plus position-independent scope; include help/version and extraction-owned flags | A container can also execute, as chat/gate/models do. Optional executor binding diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 82344379..4c2ae7df 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4823,3 +4823,90 @@ preparation, not implementation acceptance. Real fatal/native integration and JSON-help behaviour retain their later steps. No new ADR, user decision, provider capture, credential, billing or external operation is pending; Step 19 retains whole-wave validation and publication. + +### Wave 3 Step 6 implementation checkpoint — 2026-10-16 + +**Implemented on `development`, not independently accepted.** The increment starts +at Step 5 acceptance metadata `952115d3`. Approved ADR-0110/0111 now have a pure +process/discovery substrate before their runtime consumers. The +[CLI contract](../../reference/cli/commands.md#proposed-wave-3-cli-compatibility) +owns exact classifications, descriptor fields and staged integration boundaries. + +One closed CLI code/origin/exit authority preserves the five existing codes for +refusal, including operational `internal`/1, and adds distinct fixed infrastructure, +output, storage and settings-write diagnostics. The heavy runtime classifier +re-exports the same constructor/guard; generic unknown handling remains unchanged +until Step 8. The shared error constructor and filesystem limits have supported +pure package leaves without loading the schema barrel; config/root exports retain +identity. Early flags share descriptor grammar, preserve command tokens and the +terminator, reject nonabsolute root syntax before filesystem work, and apply exact +DEBUG=1 with explicit quiet/verbose conflict and override rules. Semantic category +selection is pure; production output owners remain Step 10. + +The one enriched descriptor authority derives the current Commander grammar, +global help and all **24 unchanged strict alias-free model entries**, compared +against a literal pre-refactor fixture. Twenty-nine descriptors include executable +containers, four nonexecuting groups and the unavailable init stub. Actual actions +receive injected dispatch, preserve every binding and await its result; finite +implementation bindings are checked both ways. The separate explicit public DTO +supports canonical subtrees, aliases and complete option grammar without exposing +internal compatibility/loader state. Canonical chat aliases, pricing flags and +key-removal force metadata are staged: production JSON help and those behaviours +remain Step 10. Compiler-emitted import controls prove the pure local leaves; +the separate Commander adapter guard stops at the external framework boundary, +not at an imagined dependency-free or packed/native proof. + +The creation-kind registry keeps actual retirement separate from stop/join/reap +attempts. It starts no idle session join at registration, removes settled owner +closures, retains unacknowledged owners and bounded classifications, samples only +actual workflow health and applies the total sticky exit order. Reentrant cleanup +shares one operation. Classification, terminal restoration and helpers consume +one live absolute fatal budget; expiry never grants retirement or store-close ACK. +Every owner still receives finite synchronous cancellation at zero remaining +budget, while new joins/restoration/reap helpers are refused. Actual native, +RunHandle, AgentSession, sink and terminal adapters remain Step 8. + +The source-verified inventory covers twelve literal internal constructors, caught +and forced-exit owners, and storage/open boundaries. It preserves supported inline +recovery and records the constructor-time addon/ABI ambiguity inside DbOpenError: +a blanket typed-error or arbitrary cause-tree classification is insufficient. +Minimal origin adapters land in Step 8; central openLocalDb remains last in Step 18. + +Author evidence distinguishes genuine original defects from new-substrate controls. +The original lexical baseline is **52 cases / 46 passing / six failing**; an +intermediate lookup exposes three prototype-token failures before the private Map +fix. New owner regressions reproduce signal-state poisoning, reentrant join order, +classification-time renewal and skipped sibling cancellation after expiry. Test +fixture fixes preserve exact context identity and complete unique case names; +they are not product regressions. Initial lint failures were unused imports/fixture +typing, corrected before the final gates. + +All **14 final causal cohorts / 42 structured zero-unhandled runner receipts** +pass fixed/removal/restored checks, exact named assertion witnesses, complete +unchanged inventories, source/test/reporter pins and byte-exact restoration. +Earlier incomplete runs stopped on duplicate case labels or a quoted witness +mismatch; neither is counted. Two existing secret controls were repointed to the +real generic action/parser path without removing IDs or cases. Their retained +isolated captures each contain one selected control / three verified receipts, +1,223 physical source/tool files and 457 installed dependency packages; forwarding +is 14/13/14 passing and parser projection 28/22/28. These selected captures are +explicitly not a complete 41-control family. These captures precede the final finite-cancellation correction; their target +source and selected test bytes are unchanged. The parent separately verified +retained stdio, runtime receipts, evaluations and manifests. Filesystem's 110 anchors +remain unique; no filesystem control was changed. + +Final author gates pass: **639 files / 14,554 passing tests / 20 established skips**, +all workspace lint/typechecks and all six forced builds after causal restoration. +The final CLI suite runs fresh; other workspaces reuse valid normal test caches. +Earlier broad execution at the pre-final registry revision records 14,553 passes; +its counts are not added to the final total. All **322 acceptance-tool tests** pass, +alongside tooling lint/typecheck, seam/engine dependency fences, DB synchronization, +639-file test isolation, formatting of all 35 changed code/data paths, bundle closure, +compiled CLI smoke and document/diff checks. Native consumers use rebuilt dist, +not ordinary-test source aliases. Two NEW complete cumulative reviews and the +scoped security sitting remain pending; author checks do not replace them. +No full CI/coverage, native-platform or whole-wave acceptance is inferred from +these unit/source checks. No new dependency, ADR, decision, provider capture, +credential, billing or external action is introduced. Steps 7–19 remain open; +commit and two NEW independent complete cumulative clean rounds plus a separate +scoped security sitting are required before Step 7 implementation proceeds. diff --git a/docs/standards/error-handling.md b/docs/standards/error-handling.md index 5f840ce4..70b0098c 100644 --- a/docs/standards/error-handling.md +++ b/docs/standards/error-handling.md @@ -20,6 +20,10 @@ neither proof of a safe diagnostic nor a substitute for an existing structural boundary guard: independently bundled class identities can differ. Existing local identity and private-authority guards also retain their own contracts. +The supported `@relavium/shared/relavium-error` leaf exports that same constructor +without evaluating the schema barrel. Early process contracts use this leaf; +runtime classifiers that recognize engine/DB families stay behind runtime loading. + - Errors are **typed and discriminated**, never bare `Error` and never thrown strings (see [code-style-typescript.md](code-style-typescript.md)). Each error type carries a stable discriminant (a `kind` / `code`) callers narrow on — not `error.message`, which From b09008e76b3c31e4cf0c51e93b3788e07ea117bb Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 08:16:01 +0300 Subject: [PATCH 062/222] docs(docs): accept wave 3 process and discovery substrate Record both new complete clean cumulative reviews and the separate security sitting at f3cab0db, with scoped author and physical evidence attribution. Split the approved lazy graph increment into compiled and installed-package acceptance gates without changing its obligations. Refs: ADR-0110, ADR-0111, Wave 3 Step 6 Co-Authored-By: Codex --- AGENTS.md | 5 +- CLAUDE.md | 5 +- docs/roadmap/current.md | 7 ++- .../phase-2.5.5-hardening-and-remediation.md | 46 +++++++++++++++++++ docs/standards/security-review.md | 19 ++++++++ 5 files changed, 78 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 11a78d63..366d2725 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,10 @@ separate clean security sitting. Step 4 (error/envelope substrate) is independen accepted after two NEW complete cumulative clean rounds. Step 5 (tools and engine tails) is independently accepted after two NEW complete cumulative clean rounds following its registry-documentation correction and a separate clean security -sitting. Step 6 (pure process/discovery substrate) is active. +sitting. Step 6 (pure process/discovery substrate) is independently accepted after +two NEW complete cumulative clean rounds and a separate clean security sitting. +Step 7a (lazy runtime and finite compiled graph) is active; whole Step 7 also requires +Step 7b's installed-tarball and required platform controls. Later implementation and whole-wave acceptance remain staged in the canonical roadmap. diff --git a/CLAUDE.md b/CLAUDE.md index 6edc0e3d..52780ddc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,7 +176,10 @@ separate clean security sitting. Step 4 (error/envelope substrate) is independen accepted after two NEW complete cumulative clean rounds. Step 5 (tools and engine tails) is independently accepted after two NEW complete cumulative clean rounds following its registry-documentation correction and a separate clean security -sitting. Step 6 (pure process/discovery substrate) is active. +sitting. Step 6 (pure process/discovery substrate) is independently accepted after +two NEW complete cumulative clean rounds and a separate clean security sitting. +Step 7a (lazy runtime and finite compiled graph) is active; whole Step 7 also requires +Step 7b's installed-tarball and required platform controls. Later implementation and whole-wave acceptance remain staged in the canonical roadmap. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 0a75b6fc..c1fe379b 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1120,8 +1120,11 @@ after two NEW complete cumulative clean rounds and a separate clean security sit after two NEW complete cumulative clean rounds. [Step 5 is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-5-acceptance--2026-10-16) after two NEW complete cumulative clean rounds following its registry-documentation -correction and a separate clean security sitting. Step 6 (pure process/discovery -substrate) is active. The +correction and a separate clean security sitting. +[Step 6 is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-6-acceptance--2026-10-16) +after two NEW complete cumulative clean rounds and a separate clean security sitting. +Step 7a (lazy runtime and finite compiled graph) is active; Step 7b must also prove +the installed tarball and required platform controls before whole Step 7 closes. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 4c2ae7df..91de45c4 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -3664,6 +3664,8 @@ No credential values or request captures belong in tracked evidence. | 5 | **Tools and engine tails:** #93/#95/#96/#98/#97/#214/#221/#258/#259/#308, #94/#82/#87/#76/G9/#79/#274/#275. Bounds before effects, model-visible semantics, existing ordinal helper, path-local cycle guards, cancellation race and deterministic plan equivalence; certify #75/#78/#216 instead of rewriting them. | | 6 | **Pure process/discovery substrate:** ADR-0110 categories, shared early flag grammar, explicit exit/site inventory, enriched descriptor registry with legacy/model and new CLI projections, one closed CLI code/origin authority, storage-boundary classification inventory and kind-specific invocation owner registry. Include caught/forced-exit owners and total observation priority. Contracts precede consumers. Unit/source checks do not claim fatal/native or JSON-help integration complete. | | 7 | **Lazy runtime and package graph:** #43/#44/#271/ADR-0111, consuming Step 6. Guard dynamic imports from a lightweight entry; defer classifier/sanitizer/service barrels and MCP wiring; preserve filesystem → one seedFileCatalogOnce owner → DB catalog → model/cost ordering and media exception; explicitly test meta-only admission refinement. Finite flat chunks, all-output closure, real packed existing meta/run/chat/migration/identity and minimum-Node/platform controls. New JSON help waits for Step 10. | +| 7a | **Recorded split, 2026-10-16 — source/router and finite compiled graph:** exact sanitizer leaves, guarded entry/runtime imports, untouched compiled human meta and grammar controls, actual execution admission ordering, one invocation file seed and eleven current DB-owner/quote/media/fixture controls. One split ESM build, flat layout, entry-only shebang and every-output file/import/dependency closure. Preserve affected compiled workflow/media smokes. Two NEW complete cumulative clean rounds and a scoped security sitting are required. This accepts only the compiled graph, not installed packaging or whole Step 7. | +| 7b | **Recorded split, 2026-10-16 — exact tarball and required consumers:** independently inspect actual tar bytes/modes/public inventory; install the same digest outside the workspace; prove complete real chat/resume, workflow/migrations/native/CAS/MCP/class identity and invocation-context continuity. Required exact Node 22.0.0 and current-runtime Linux/macOS/Windows consumers aggregate the same artifact. Final cumulative reviews include all 7a changes from the Step 6 boundary. Whole Step 7 and ADR-0111's graph obligations close only after both increments; actual registered native-owner cleanup remains Step 8. | | 8 | **Native process integration:** #40/#41/#42/#224/#269/G35 under ADR-0110, after the early graph can catch import failures. Wire kind-specific owners, output_unavailable/cli_internal 9, known storage/history/departure classification, run-only health precedence and session join uncertainty. Preserve recoverable #228/signals; MCP PID versus tool PGID/Windows, terminal restore before stderr, total remaining fatal budget, competing faults and both sinks. Minimal I/O adapters land here; central DB refactor stays last. No false host-safe-close ACK. | | 9 | **Release map/custody substrate:** #46/ADR-0111, independently reviewed after the finite graph. Upstream TS maps, sole prepack final build, pre-hash trailer removal/staging, exact tarball/CLI+engine symbolication, encrypt-once candidate with permissioned prebuild discovery/reservation → pack/reuse → smoke → archive → npm → finalization. Required artifact/platform aggregation, all rerun modes, lost-staging refusal and permission-enforcing offline API controls. Dedicated archive/crypto security sitting. Fixture proof never claims production recipient/custody configuration. | | 10 | **CLI behaviour/discovery integration:** #45/#264/#265/#268/#13/#17/#18/#310/#309/#19 under ADR-0110, consuming Steps 6–9. Production owner category matrix, real root/subtree JSON help, pricing/chat aliases, non-echoing Commander refusal and signal-safe deletion consent. G15 is refuted and receives real engine/store/log preservation evidence, with no invented token coalescing. Repeat affected packed gates. | @@ -4910,3 +4912,47 @@ these unit/source checks. No new dependency, ADR, decision, provider capture, credential, billing or external action is introduced. Steps 7–19 remain open; commit and two NEW independent complete cumulative clean rounds plus a separate scoped security sitting are required before Step 7 implementation proceeds. + + +### Wave 3 Step 6 acceptance — 2026-10-16 + +Step 6 is independently accepted at +`f3cab0db993714006e8c318f37618e2cf2683333`, from Step 5 metadata +`952115d3506b0078faefa10dee8dfb4236e19c1c`. Two NEW independent complete +cumulative clean reviews cover all **38 paths / 95 zero-context hunks**, including +all nineteen new files. Reviewers are gpt-6-astra/high (**26 files / 512 tests**, plus +322 acceptance-tool tests) and gpt-6.1-sol/xhigh (**16 files / 395 tests**, plus +119 related tooling tests), with zero skips and no verified finding. The parent +reads both full reports, independently checks the evidence and records no source +change after these rounds. Differential controls preserve all 24 original model +entries, 29 Commander nodes and 48 old/new action payload comparisons. + +The separate NEW clean security sitting passes **14 files / 340 tests**, plus one +acceptance-tool test, with zero skips. Its scope is the pure process/discovery +substrate, not the later native fatal path. All fourteen final causal cohorts / +42 structured zero-unhandled receipts retain named assertion witnesses, unchanged +case inventories, exact direct target/test/reporter pins and restoration. These +local cohorts do not claim a complete transitive dependency snapshot. Two selected +existing secret controls each retain three physical receipts, 1,223 source/tool +files and 457 dependency packages; they precede the last registry fix, with their +target/test bytes unchanged, and are not a complete 41-control capture. Both full +reviewers and the security reviewer independently audit these records. + +Final author evidence remains **639 files / 14,554 passing tests / 20 established +skips**, all workspace lint/typechecks, six forced builds after causal restoration, +322 acceptance-tool tests, source fences, DB synchronization, test isolation, +formatting, compiled CLI smoke, bundle closure and document/diff checks. The final +CLI test suite runs fresh; other workspaces use valid test caches. Review executions +are independent scoped checks and are not added to the author total. This metadata +landing does not imply new source execution, full CI/coverage, native-platform, +installed-tarball or whole-wave acceptance. + +Step 7 proceeds automatically through the recorded **7a → 7b** split. The split +preserves every original finding and acceptance obligation. Step 7a proves the +actual finite compiled graph and catalog ordering; Step 7b adds the independently +inspected installed artifact and required platform/minimum-runtime controls. Whole +Step 7 remains open until both pass; Step 8 does not start after 7a alone. Registry +identity before Step 8 means one canonical module, one bootstrap invocation object +and preserved context identity across lazy routes. Actual registered native-owner +stop/join evidence belongs to Step 8. No new ADR, dependency, deferral, provider +request, credential, billing or user decision is introduced. diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index 74ecdcc5..d8c61e78 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -562,6 +562,25 @@ their documented qualifications. No universal secret scrubbing, OS isolation, new network authority, provider/keychain operation, paid request or platform recertification is claimed. Full-wave publication remains Step 19. +### Sitting: Wave 3 pure process and discovery boundaries (2026-10-16) + +A NEW independent cumulative sitting is **clean** at `f3cab0db`. Fresh execution +passes **14 files / 340 tests**, plus one acceptance-tool test, with zero skips. +The parent reads the full report. The [Step 6 acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-6-acceptance--2026-10-16) +joins this sitting with the two separate new complete correctness rounds. + +The sitting covers pure flag and filesystem-root syntax, exact secret forwarding, +fixed CLI diagnostics, public descriptor projection, kind-specific owner retirement, +reentrant cancellation and the one absolute fatal budget. Expiry does not grant +retirement or host-safe-close acknowledgement; finite cancellation still reaches +every live owner at zero remaining budget. Fourteen causal cohorts / 42 structured +runner receipts and two selected existing secret controls are independently audited, +including restoration and physical manifests. This audit is not new mutation +execution or a complete secret-control family capture. Actual native process, +terminal, sink and RunHandle/AgentSession adapters remain Step 8; installed package +and whole-wave claims remain their later gates. No provider, keychain, paid request, +new network authority or native-platform recertification is claimed. + ## Sandbox and tool policy (`run_command`, node tools, secret inputs) This is the **binding home** for the tool-policy rules; the rationale is From 88329e47442ea211ec2a8e170dd29c1a37ec06f6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 08:40:42 +0300 Subject: [PATCH 063/222] refactor(shared): expose the pure terminal sanitizer leaf Preserve the existing root function identity while allowing the guarded CLI entry to load terminal stripping without the schema barrel. Refs: ADR-0111, Wave 3 Step 7a Co-Authored-By: Codex --- packages/shared/package.json | 5 +++ .../shared/src/terminal-safe-leaf.test.ts | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 packages/shared/src/terminal-safe-leaf.test.ts diff --git a/packages/shared/package.json b/packages/shared/package.json index 3fa2f561..de29219e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -19,6 +19,11 @@ "types": "./dist/fs-root-limits.d.ts", "import": "./dist/fs-root-limits.js", "default": "./dist/fs-root-limits.js" + }, + "./terminal-safe": { + "types": "./dist/terminal-safe.d.ts", + "import": "./dist/terminal-safe.js", + "default": "./dist/terminal-safe.js" } }, "main": "./dist/index.js", diff --git a/packages/shared/src/terminal-safe-leaf.test.ts b/packages/shared/src/terminal-safe-leaf.test.ts new file mode 100644 index 00000000..5bdddc49 --- /dev/null +++ b/packages/shared/src/terminal-safe-leaf.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { stripTerminalControls as rootStripTerminalControls } from './index.js'; +import { stripTerminalControls } from './terminal-safe.js'; + +describe('supported terminal-safe leaf', () => { + it('retains the root function identity in source and actual compiled exports', async () => { + expect(rootStripTerminalControls).toBe(stripTerminalControls); + const root = await import('@relavium/shared'); + const leaf = await import('@relavium/shared/terminal-safe'); + expect(root.stripTerminalControls).toBe(leaf.stripTerminalControls); + }); + + it('removes finite escape/control families while preserving text, tabs, newlines and shaping', () => { + const text = 'plain\ttext\nemoji 👩\u200d💻 and shaping \u200c'; + const hostile = [ + '\u001b[31m', + '\u001b]52;c;YWJj\u0007', + '\u001bPpayload\u001b\\', + '\u001b^payload\u0007', + '\u001b_payload\u001b\\', + '\u001bXpayload\u001b\\', + '\u001bM', + '\u0001', + '\r', + '\u007f', + '\u009b', + '\u061c', + '\u200e', + '\u200f', + '\u202e', + '\u2066', + '\u2069', + ].join(''); + expect(stripTerminalControls(hostile + text)).toBe(text); + }); + + it('keeps unterminated string-sequence bodies visible', () => { + for (const introducer of [']', 'P', '^', '_', 'X']) { + expect(stripTerminalControls(`before\u001b${introducer}visible remainder`)).toBe( + 'beforevisible remainder', + ); + } + }); +}); From f4e2251ae47a75b76acc4ca4764e1840e6d80551 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 08:40:42 +0300 Subject: [PATCH 064/222] refactor(llm): expose the existing secret scrubber as a pure leaf Move the exact finite scrubber implementation behind a supported pure export. Keep the root and error mapper on the same function with compatibility tests. Refs: ADR-0111, Wave 3 Step 7a Co-Authored-By: Codex --- packages/llm/package.json | 5 ++ packages/llm/src/llm-error.ts | 72 +---------------- packages/llm/src/scrub-secrets.test.ts | 107 +++++++++++++++++++++++++ packages/llm/src/scrub-secrets.ts | 68 ++++++++++++++++ 4 files changed, 184 insertions(+), 68 deletions(-) create mode 100644 packages/llm/src/scrub-secrets.test.ts create mode 100644 packages/llm/src/scrub-secrets.ts diff --git a/packages/llm/package.json b/packages/llm/package.json index 744c9d11..8ce30162 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -14,6 +14,11 @@ "types": "./dist/adapters/index.d.ts", "import": "./dist/adapters/index.js", "default": "./dist/adapters/index.js" + }, + "./scrub-secrets": { + "types": "./dist/scrub-secrets.d.ts", + "import": "./dist/scrub-secrets.js", + "default": "./dist/scrub-secrets.js" } }, "main": "./dist/index.js", diff --git a/packages/llm/src/llm-error.ts b/packages/llm/src/llm-error.ts index f0b2b372..79f13f8d 100644 --- a/packages/llm/src/llm-error.ts +++ b/packages/llm/src/llm-error.ts @@ -2,11 +2,14 @@ import { RelaviumError, ownUsageEvidence, InvalidUsageEvidenceError, - stripTerminalControls, type UsageEvidence, } from '@relavium/shared'; +import { scrubSecrets } from './scrub-secrets.js'; import { LlmErrorSchema, type LlmError, type LlmErrorKind, type ProviderId } from './types.js'; +// Preserve the existing internal and root export paths with the same function identity. +export { scrubSecrets }; + const DiagnosticSchema = LlmErrorSchema.omit({ usage: true }); /** Own evidence before parsing diagnostics; neither usage getters nor nested traps execute. */ @@ -103,73 +106,6 @@ interface MakeLlmErrorArgs { readonly retryAfterMs?: number; } -/** - * Defense-in-depth secret scrub applied at the one `makeLlmError` choke point. The CALLER (adapter) is - * still responsible for passing an already-redacted message; this is the **structural backstop** — like - * the engine's `deInlineMedia` pass, leak-freedom here is an active transform at one boundary, not - * per-adapter discipline. It masks finite common secret **shapes**: prefixed keys, literal secret query - * names, URL userinfo and Bearer/Basic headers. Each non-query rule probes the original diagnostic - * independently, including terminal-normalized and whitespace-joined residual views, before any - * other rule can erase its identifying context. A match revealed by normalization masks the whole - * diagnostic. The finite patterns retain their word boundaries and already-masked-span limits; - * this is not universal fragment discovery. Query names are excluded from the residual probe. - * Pure and total for string input: regex replacements and a - * finite residual check, with no parsing or external calls on the error path. - * Asserted by `llm-error.test.ts` (the backstop) and each adapter's - * secret-safety test (a planted secret → a secret-free surfaced `LlmError`); see security-review.md. - */ -export function scrubSecrets(text: string): string { - return scrubQuerySecrets(scrubNonQuerySecrets(text)); -} - -interface SecretRule { - readonly pattern: RegExp; - readonly replacement: string; -} - -/** One private authority per existing credential family, shared by probes and output masking. */ -const NON_QUERY_SECRET_RULES: readonly SecretRule[] = [ - // OpenAI sk-/sk-proj-/sk-svcacct- and Anthropic sk-ant- key prefixes. - { pattern: /\bsk-(?:ant-)?[A-Za-z0-9\-_]{16,}/g, replacement: '[REDACTED]' }, - { pattern: /\bAIza[0-9A-Za-z\-_]{20,}/g, replacement: '[REDACTED]' }, - // URL userinfo: user@ or user:pass@. - { pattern: /(https?:\/\/)[^/\s:@]+(?::[^/\s@]+)?@/gi, replacement: '$1[REDACTED]@' }, - { pattern: /\bBearer\s+\S+/gi, replacement: 'Bearer [REDACTED]' }, - { pattern: /\bBasic\s+\S+/gi, replacement: 'Basic [REDACTED]' }, -]; - -/** Reuse the display boundary's platform-free normalization without changing benign output. */ -function normalizeSecretProbe(text: string): string { - return stripTerminalControls(text); -} - -function scrubNonQuerySecrets(text: string): string { - const normalized = normalizeSecretProbe(text); - for (const rule of NON_QUERY_SECRET_RULES) { - const rawMasked = text.replace(rule.pattern, rule.replacement); - const normalizedMasked = normalized.replace(rule.pattern, rule.replacement); - // Keep required header whitespace: joining first would hide a newly revealed Bearer/Basic label. - if (normalizeSecretProbe(rawMasked) !== normalizedMasked) return '[REDACTED]'; - // Other families must not destroy this rule's prefix; only its own complete masks delimit spans. - for (const remaining of normalizedMasked.split('[REDACTED]')) { - const joined = remaining.replace(/\s+/g, ''); - if (joined.replace(rule.pattern, rule.replacement) !== joined) return '[REDACTED]'; - } - } - return NON_QUERY_SECRET_RULES.reduce( - (masked, rule) => masked.replace(rule.pattern, rule.replacement), - text, - ); -} - -/** Exact literal query names, applied last so a value cannot erase another family's evidence. */ -function scrubQuerySecrets(text: string): string { - return text.replace( - /([?&](?:(?:x[-_])?api[-_]?key|api[-_]?secret|(?:access|secret)[-_]?key|(?:access|refresh|id|auth|session)[-_]?token|client[-_]?secret|key|token|auth|password|secret)=)[^&\s#]+/gi, - '$1[REDACTED]', - ); // Finite literal names; exact boundaries preserve benign suffixes. No URL-name decoding. -} - /** * Build a normalized `LlmError`, deriving `retryable` from `kind` so a miswired adapter can't * produce an inconsistent pair. Constructs directly (no Zod parse) so it never throws on the error diff --git a/packages/llm/src/scrub-secrets.test.ts b/packages/llm/src/scrub-secrets.test.ts new file mode 100644 index 00000000..bb468936 --- /dev/null +++ b/packages/llm/src/scrub-secrets.test.ts @@ -0,0 +1,107 @@ +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { scrubSecrets as rootScrubSecrets } from './index.js'; +import { makeLlmError, scrubSecrets as errorScrubSecrets } from './llm-error.js'; +import { scrubSecrets } from './scrub-secrets.js'; + +// Synthetic credentials assembled from fragments, never real provider material. +const key = ['sk-', 'ant-', 'api03-', 'AbCdEf0123456789xyz'].join(''); +const googleKey = ['AI', 'za', 'SyA1234567890abcdefghijklmnopqrstuv'].join(''); + +const compatibleDiagnostics = [ + { input: `invalid ${key}; retry later`, expected: 'invalid [REDACTED]; retry later' }, + { input: `invalid ${googleKey}; retry later`, expected: 'invalid [REDACTED]; retry later' }, + { + input: 'https://user:opaque@provider.invalid/x', + expected: 'https://[REDACTED]@provider.invalid/x', + }, + { input: 'https://opaque@provider.invalid/x', expected: 'https://[REDACTED]@provider.invalid/x' }, + { + input: 'Authorization: Bearer opaque-value rejected', + expected: 'Authorization: Bearer [REDACTED] rejected', + }, + { + input: 'Authorization: Basic dXNlcjpwYXNz rejected', + expected: 'Authorization: Basic [REDACTED] rejected', + }, + { + input: '?client_secret=opaque&ordinary=kept#visible', + expected: '?client_secret=[REDACTED]&ordinary=kept#visible', + }, + { input: key.slice(0, 12) + '\u0001' + key.slice(12), expected: '[REDACTED]' }, + { input: key.slice(0, 12) + '\n\t' + key.slice(12), expected: '[REDACTED]' }, + { input: `?api_secret=sk-ant-api03-\nAbCdEf0123456789xyz`, expected: '[REDACTED]' }, + { input: `?token=opaque; https://u\nser:opaque@provider.invalid/x`, expected: '[REDACTED]' }, + { input: `?token=opaque; Bear\u202eer opaque-value`, expected: '[REDACTED]' }, + // Literal query names, word boundaries and masked spans retain their finite scope. + { input: '?api%5fkey=opaque', expected: '?api%5fkey=opaque' }, + { input: '?client_secret_hint=ordinary', expected: '?client_secret_hint=ordinary' }, + { input: '?api_\nkey=opaque', expected: '?api_\nkey=opaque' }, + { input: 'sk-short', expected: 'sk-short' }, + { + input: 'ordinary prompt prose with an opaque value', + expected: 'ordinary prompt prose with an opaque value', + }, + { + input: '\u001b[31mrate limited\u001b[0m\nretry after 30s', + expected: '\u001b[31mrate limited\u001b[0m\nretry after 30s', + }, + { input: '', expected: '' }, +]; + +describe('pure scrub-secrets leaf compatibility', () => { + it('preserves the source root and llm-error function identities', () => { + expect(errorScrubSecrets).toBe(scrubSecrets); + expect(rootScrubSecrets).toBe(scrubSecrets); + }); + + it.each(compatibleDiagnostics)('preserves exact redaction for $input', ({ input, expected }) => { + expect(scrubSecrets(input)).toBe(expected); + const error = makeLlmError({ provider: 'openai', kind: 'auth', message: input, code: input }); + expect(error.message).toBe(expected); + expect(error.code).toBe(expected); + }); + + it('retains one function through the supported compiled root and leaf exports', async () => { + const root = await import('@relavium/llm'); + const leaf = await import('@relavium/llm/scrub-secrets'); + expect(root.scrubSecrets).toBe(leaf.scrubSecrets); + for (const { input, expected } of compatibleDiagnostics) { + expect(leaf.scrubSecrets(input)).toBe(expected); + } + }); + + it('loads the actual supported compiled leaf with only terminal-safe reachable', () => { + const require = createRequire(import.meta.url); + const allowed = [ + pathToFileURL(require.resolve('@relavium/llm/scrub-secrets')).href, + pathToFileURL(require.resolve('@relavium/shared/terminal-safe')).href, + ]; + // An isolated ESM loader refuses every other module, including schema/provider barrels. + // This tests evaluation without relying on bundler tree shaking or warm module caches. + const loader = `export async function load(url, context, nextLoad) { + if (!url.startsWith('data:') && !${JSON.stringify(allowed)}.includes(url)) { + throw new Error('Unexpected sanitizer dependency: ' + url); + } + return nextLoad(url, context); + }`; + const run = spawnSync( + process.execPath, + [ + '--no-warnings', + `--experimental-loader=data:text/javascript,${encodeURIComponent(loader)}`, + '--input-type=module', + '--eval', + `import { scrubSecrets } from ${JSON.stringify(allowed[0])}; + if (scrubSecrets('?api_key=opaque') !== '?api_key=[REDACTED]') process.exit(1);`, + ], + { encoding: 'utf8', timeout: 10_000 }, + ); + expect(run.error).toBeUndefined(); + expect(run.stderr).toBe(''); + expect(run.status).toBe(0); + }); +}); diff --git a/packages/llm/src/scrub-secrets.ts b/packages/llm/src/scrub-secrets.ts new file mode 100644 index 00000000..50f4bccc --- /dev/null +++ b/packages/llm/src/scrub-secrets.ts @@ -0,0 +1,68 @@ +import { stripTerminalControls } from '@relavium/shared/terminal-safe'; + +/** + * Defense-in-depth secret scrub applied at the one `makeLlmError` choke point. The CALLER (adapter) is + * still responsible for passing an already-redacted message; this is the **structural backstop** — like + * the engine's `deInlineMedia` pass, leak-freedom here is an active transform at one boundary, not + * per-adapter discipline. It masks finite common secret **shapes**: prefixed keys, literal secret query + * names, URL userinfo and Bearer/Basic headers. Each non-query rule probes the original diagnostic + * independently, including terminal-normalized and whitespace-joined residual views, before any + * other rule can erase its identifying context. A match revealed by normalization masks the whole + * diagnostic. The finite patterns retain their word boundaries and already-masked-span limits; + * this is not universal fragment discovery. Query names are excluded from the residual probe. + * Pure and total for string input: regex replacements and a + * finite residual check, with no parsing or external calls on the error path. + * Asserted by `llm-error.test.ts` (the backstop) and each adapter's + * secret-safety test (a planted secret → a secret-free surfaced `LlmError`); see security-review.md. + */ +export function scrubSecrets(text: string): string { + return scrubQuerySecrets(scrubNonQuerySecrets(text)); +} + +interface SecretRule { + readonly pattern: RegExp; + readonly replacement: string; +} + +/** One private authority per existing credential family, shared by probes and output masking. */ +const NON_QUERY_SECRET_RULES: readonly SecretRule[] = [ + // OpenAI sk-/sk-proj-/sk-svcacct- and Anthropic sk-ant- key prefixes. + { pattern: /\bsk-(?:ant-)?[A-Za-z0-9\-_]{16,}/g, replacement: '[REDACTED]' }, + { pattern: /\bAIza[0-9A-Za-z\-_]{20,}/g, replacement: '[REDACTED]' }, + // URL userinfo: user@ or user:pass@. + { pattern: /(https?:\/\/)[^/\s:@]+(?::[^/\s@]+)?@/gi, replacement: '$1[REDACTED]@' }, + { pattern: /\bBearer\s+\S+/gi, replacement: 'Bearer [REDACTED]' }, + { pattern: /\bBasic\s+\S+/gi, replacement: 'Basic [REDACTED]' }, +]; + +/** Reuse the display boundary's platform-free normalization without changing benign output. */ +function normalizeSecretProbe(text: string): string { + return stripTerminalControls(text); +} + +function scrubNonQuerySecrets(text: string): string { + const normalized = normalizeSecretProbe(text); + for (const rule of NON_QUERY_SECRET_RULES) { + const rawMasked = text.replace(rule.pattern, rule.replacement); + const normalizedMasked = normalized.replace(rule.pattern, rule.replacement); + // Keep required header whitespace: joining first would hide a newly revealed Bearer/Basic label. + if (normalizeSecretProbe(rawMasked) !== normalizedMasked) return '[REDACTED]'; + // Other families must not destroy this rule's prefix; only its own complete masks delimit spans. + for (const remaining of normalizedMasked.split('[REDACTED]')) { + const joined = remaining.replace(/\s+/g, ''); + if (joined.replace(rule.pattern, rule.replacement) !== joined) return '[REDACTED]'; + } + } + return NON_QUERY_SECRET_RULES.reduce( + (masked, rule) => masked.replace(rule.pattern, rule.replacement), + text, + ); +} + +/** Exact literal query names, applied last so a value cannot erase another family's evidence. */ +function scrubQuerySecrets(text: string): string { + return text.replace( + /([?&](?:(?:x[-_])?api[-_]?key|api[-_]?secret|(?:access|secret)[-_]?key|(?:access|refresh|id|auth|session)[-_]?token|client[-_]?secret|key|token|auth|password|secret)=)[^&\s#]+/gi, + '$1[REDACTED]', + ); // Finite literal names; exact boundaries preserve benign suffixes. No URL-name decoding. +} From c99b9a50aa2af7e4137da822445e1b20a2880641 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 08:40:42 +0300 Subject: [PATCH 065/222] refactor(core): expose the pure tool dispatch error base Keep existing error-family identity while letting filesystem grant admission load its denial base before any engine or provider service barrel. Refs: ADR-0111, Wave 3 Step 7a Co-Authored-By: Codex --- packages/core/package.json | 5 ++ .../core/src/tools/dispatch-error.test.ts | 39 ++++++++++++++ packages/core/src/tools/dispatch-error.ts | 52 +++++++++++++++++++ packages/core/src/tools/errors.ts | 52 ++----------------- 4 files changed, 100 insertions(+), 48 deletions(-) create mode 100644 packages/core/src/tools/dispatch-error.test.ts create mode 100644 packages/core/src/tools/dispatch-error.ts diff --git a/packages/core/package.json b/packages/core/package.json index 9d19a815..6fbcefec 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,6 +9,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" + }, + "./tool-dispatch-error": { + "types": "./dist/tools/dispatch-error.d.ts", + "import": "./dist/tools/dispatch-error.js", + "default": "./dist/tools/dispatch-error.js" } }, "main": "./dist/index.js", diff --git a/packages/core/src/tools/dispatch-error.test.ts b/packages/core/src/tools/dispatch-error.test.ts new file mode 100644 index 00000000..b5cc5e7a --- /dev/null +++ b/packages/core/src/tools/dispatch-error.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { ToolDispatchError as RootBase, ToolPolicyError } from '../index.js'; +import { RelaviumError } from '@relavium/shared/relavium-error'; +import { ToolDispatchError } from './dispatch-error.js'; + +class LeafProbe extends ToolDispatchError { + readonly code = 'tool_denied'; + readonly runErrorCode = 'tool_denied'; + readonly retryable = false; + constructor(cause?: unknown) { + super('safe reason', 'write_file', cause, true); + } +} + +describe('pure tool dispatch error base compatibility', () => { + it('retains the same exported constructor and concrete-family instanceof relationship', () => { + expect(ToolDispatchError).toBe(RootBase); + const concrete = new ToolPolicyError('write_file', 'not_granted', 'safe denial'); + expect(concrete).toBeInstanceOf(ToolDispatchError); + expect(concrete).toBeInstanceOf(RelaviumError); + expect(concrete).toMatchObject({ + code: 'tool_denied', + runErrorCode: 'tool_denied', + retryable: false, + }); + }); + it('preserves inherited tool id, recoverability and opaque cause identity', () => { + const cause = Object.freeze({ private: 'synthetic cause' }); + const value = new LeafProbe(cause); + expect(value).toBeInstanceOf(RootBase); + expect(value).toMatchObject({ + message: 'safe reason', + toolId: 'write_file', + recoverable: true, + }); + expect(value.cause).toBe(cause); + expect(new LeafProbe()).not.toHaveProperty('cause'); + }); +}); diff --git a/packages/core/src/tools/dispatch-error.ts b/packages/core/src/tools/dispatch-error.ts new file mode 100644 index 00000000..bb5a9f39 --- /dev/null +++ b/packages/core/src/tools/dispatch-error.ts @@ -0,0 +1,52 @@ +/** Pure dispatch-error base; concrete registry errors keep their existing public home. */ +import { RelaviumError } from '@relavium/shared/relavium-error'; +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) + | 'effect_conflict' // another attempt already holds this effect's journal identity (ADR-0080) — a refusal + | 'effect_unrecorded' // the effect LANDED and its journal record could not be completed (ADR-0080 §7) + | '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 RelaviumError { + /** 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; + /** + * Whether this failure is safe to FEED BACK to the model as an `isError` tool result for in-turn CONVERSATIONAL + * recovery (the ADR-0057 `recoverToolFailures` surfaces — `relavium chat` / the Home / one-shot `agent run`; + * a WORKFLOW node never sets the flag) instead of ending the turn. + * Default **false** (fatal — the safe direction; a forgotten opt-in just stays fatal). Set true by the specific + * throwing class ONLY when re-attempting is safe: an IDEMPOTENT host execution failure (a read — no side-effect + * hazard), or a SCOPE denial refused BEFORE any side effect (a Step-14 fs scope-tier escape / a media scope + * denial) so the model can adapt to an in-bounds path. A user / guardrail / SSRF / **confidentiality** / + * side-effecting denial stays false — re-issuing re-denies, would risk a re-execution, or would leak a + * probe oracle. Distinct from {@link retryable} (a fresh whole-node retry); this is the stricter in-turn re-attempt. + */ + readonly recoverable: boolean; + + protected constructor( + message: string, + toolId: ToolId | undefined, + cause: unknown, + recoverable = false, + ) { + super(message, cause === undefined ? undefined : { cause }); + if (toolId !== undefined) { + this.toolId = toolId; + } + this.recoverable = recoverable; + } +} diff --git a/packages/core/src/tools/errors.ts b/packages/core/src/tools/errors.ts index da13e202..d8058942 100644 --- a/packages/core/src/tools/errors.ts +++ b/packages/core/src/tools/errors.ts @@ -8,60 +8,16 @@ * [tool-registry.md](../../../../docs/reference/shared-core/tool-registry.md#error-taxonomy) and ADR-0037. */ -import { byCodeUnit, RelaviumError } from '@relavium/shared'; +import { byCodeUnit } from '@relavium/shared'; import type { ErrorCode } from '@relavium/shared'; import type { ToolId } from './types.js'; import { markEffectAttentionError } from './effect-attention-marker.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) - | 'effect_conflict' // another attempt already holds this effect's journal identity (ADR-0080) — a refusal - | 'effect_unrecorded' // the effect LANDED and its journal record could not be completed (ADR-0080 §7) - | '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 RelaviumError { - /** 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; - /** - * Whether this failure is safe to FEED BACK to the model as an `isError` tool result for in-turn CONVERSATIONAL - * recovery (the ADR-0057 `recoverToolFailures` surfaces — `relavium chat` / the Home / one-shot `agent run`; - * a WORKFLOW node never sets the flag) instead of ending the turn. - * Default **false** (fatal — the safe direction; a forgotten opt-in just stays fatal). Set true by the specific - * throwing class ONLY when re-attempting is safe: an IDEMPOTENT host execution failure (a read — no side-effect - * hazard), or a SCOPE denial refused BEFORE any side effect (a Step-14 fs scope-tier escape / a media scope - * denial) so the model can adapt to an in-bounds path. A user / guardrail / SSRF / **confidentiality** / - * side-effecting denial stays false — re-issuing re-denies, would risk a re-execution, or would leak a - * probe oracle. Distinct from {@link retryable} (a fresh whole-node retry); this is the stricter in-turn re-attempt. - */ - readonly recoverable: boolean; - - protected constructor( - message: string, - toolId: ToolId | undefined, - cause: unknown, - recoverable = false, - ) { - super(message, cause === undefined ? undefined : { cause }); - if (toolId !== undefined) { - this.toolId = toolId; - } - this.recoverable = recoverable; - } -} +export { ToolDispatchError } from './dispatch-error.js'; +export type { ToolErrorCode } from './dispatch-error.js'; +import { ToolDispatchError } from './dispatch-error.js'; /** The id was not an exact registry match. The message lists the available ids (names, not secrets). */ export class UnknownToolError extends ToolDispatchError { From 54e0226cd088cf0612e0477fc155334ccd2c1a6a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 08:40:42 +0300 Subject: [PATCH 066/222] refactor(cli): guard lazy runtime and invocation catalog loading Use one grammar for meta and execution, admit filesystem grants before the lazy runtime, and retain one invocation-owned offline catalog seed. Emit a finite flat ESM graph while staging native fatal integration and packaging. Refs: ADR-0111, Wave 3 Step 7a Co-Authored-By: Codex --- apps/cli/src/commands/dispatch.ts | 17 +- apps/cli/src/commands/input.ts | 3 + apps/cli/src/commands/lazy-dispatch.test.ts | 226 ++++++++++++++++++ apps/cli/src/commands/lazy-dispatch.ts | 87 +++++++ apps/cli/src/commands/media-input.ts | 17 ++ apps/cli/src/engine/catalog-refresh.ts | 2 +- apps/cli/src/engine/catalog-seed.ts | 7 + .../src/engine/file-catalog-cutover.test.ts | 220 +++++++++++++++++ apps/cli/src/engine/mcp-servers.ts | 4 + apps/cli/src/engine/tool-host/errors.ts | 4 +- apps/cli/src/engine/tool-host/fs-admission.ts | 14 +- .../tool-host/fs-invocation-admission.ts | 15 ++ apps/cli/src/engine/tool-host/fs-policy.ts | 2 +- apps/cli/src/home/drive-home.tsx | 3 + apps/cli/src/index.ts | 40 +--- .../cli/src/process/file-catalog-seed.test.ts | 91 +++++++ apps/cli/src/process/file-catalog-seed.ts | 12 + apps/cli/src/process/invocation.ts | 24 ++ apps/cli/src/process/io.ts | 2 +- apps/cli/src/process/render-error.ts | 21 +- apps/cli/src/process/render-user-facing.ts | 34 +++ apps/cli/src/program.ts | 10 +- apps/cli/src/render/sanitize.ts | 4 +- apps/cli/src/run-lazy-boundary.test.ts | 136 +++++++++++ apps/cli/src/run.ts | 210 ++++++++-------- apps/cli/src/version.ts | 8 + apps/cli/tsup.config.ts | 16 +- 27 files changed, 1024 insertions(+), 205 deletions(-) create mode 100644 apps/cli/src/commands/lazy-dispatch.test.ts create mode 100644 apps/cli/src/commands/lazy-dispatch.ts create mode 100644 apps/cli/src/commands/media-input.ts create mode 100644 apps/cli/src/engine/catalog-seed.ts create mode 100644 apps/cli/src/engine/file-catalog-cutover.test.ts create mode 100644 apps/cli/src/engine/tool-host/fs-invocation-admission.ts create mode 100644 apps/cli/src/process/file-catalog-seed.test.ts create mode 100644 apps/cli/src/process/file-catalog-seed.ts create mode 100644 apps/cli/src/process/invocation.ts create mode 100644 apps/cli/src/process/render-user-facing.ts create mode 100644 apps/cli/src/run-lazy-boundary.test.ts create mode 100644 apps/cli/src/version.ts diff --git a/apps/cli/src/commands/dispatch.ts b/apps/cli/src/commands/dispatch.ts index 2046acf1..28862db6 100644 --- a/apps/cli/src/commands/dispatch.ts +++ b/apps/cli/src/commands/dispatch.ts @@ -56,7 +56,8 @@ import { } from './provider.js'; import { runCommand, type RunCommandArgs } from './run.js'; import { statusCommand } from './status.js'; -import { pruneTemporariesCommand, type MediaPruneArgs } from './media.js'; +import { pruneTemporariesCommand } from './media.js'; +import { buildMediaPruneArgs } from './media-input.js'; /** * The shared **command dispatch table** ([ADR-0056](../../../../docs/decisions/0056-cli-in-app-slash-command-system-and-manifest.md), @@ -689,19 +690,7 @@ function providerExecutor(build: (input: CommandInput) => ProviderCommandArgs): }; } -export function buildMediaPruneArgs(input: CommandInput): MediaPruneArgs { - if ( - input.positionals.length !== 0 || - input.options['legacyOffline'] !== true || - input.options['force'] !== true - ) { - throw new CliError( - 'invalid_invocation', - 'Temporary maintenance requires --legacy-offline and --force.', - ); - } - return { legacyOffline: true, force: true }; -} +export { buildMediaPruneArgs } from './media-input.js'; // ── the dispatch table ─────────────────────────────────────────────────────── diff --git a/apps/cli/src/commands/input.ts b/apps/cli/src/commands/input.ts index 157ddba3..923968bc 100644 --- a/apps/cli/src/commands/input.ts +++ b/apps/cli/src/commands/input.ts @@ -1,3 +1,4 @@ +import type { CliInvocation } from '../process/invocation.js'; import type { ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; @@ -10,6 +11,8 @@ export interface CommandInput { } export interface DispatchContext { + /** One bootstrap owner, retained through lazy routes; native owner adapters land separately. */ + readonly invocation?: CliInvocation; readonly io: CliIo; readonly global: GlobalOptions; } diff --git a/apps/cli/src/commands/lazy-dispatch.test.ts b/apps/cli/src/commands/lazy-dispatch.test.ts new file mode 100644 index 00000000..78c30dca --- /dev/null +++ b/apps/cli/src/commands/lazy-dispatch.test.ts @@ -0,0 +1,226 @@ +import { Readable } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createFileCatalogSeed } from '../process/file-catalog-seed.js'; +import { createCliInvocation } from '../process/invocation.js'; +import { COMMAND_DESCRIPTORS } from './descriptors.js'; +import type { CommandInput, DispatchCommand, DispatchContext } from './input.js'; + +// These are route-boundary tests: executors are doubles, not eleven production DB-owner journeys. +const PRICED = [ + 'run', + 'chat', + 'chat-resume', + 'agent.run', + 'gate', + 'budget.resume', + 'models', + 'models.refresh', + 'models.pricing', +]; +const UNPRICED = [ + 'chat-list', + 'chat-export', + 'create', + 'export', + 'import', + 'gate.list', + 'list', + 'logs', + 'status', + 'provider.list', + 'provider.add', + 'provider.set-key', + 'provider.remove-key', + 'provider.test', +]; +const INPUT: CommandInput = { positionals: ['literal-model-or-file'], options: {} }; +let events: string[]; +let execute: ReturnType>; +let media: ReturnType; + +function context(seed: () => Promise): DispatchContext { + return { + invocation: { ...createCliInvocation(), seedFileCatalogOnce: seed }, + io: { + writeOut: () => undefined, + writeErr: () => undefined, + writeErrAcknowledged: () => Promise.resolve(), + env: {}, + stdoutIsTty: false, + stdinIsTty: false, + stdin: Readable.from([]), + }, + global: { + json: false, + color: false, + cwd: '/unused', + configPath: undefined, + verbosity: 'normal', + }, + }; +} + +beforeEach(() => { + vi.resetModules(); + events = []; + execute = vi.fn(() => { + events.push('execute'); + return Promise.resolve(0); + }); + media = vi.fn(() => Promise.resolve(0)); + vi.doMock('./dispatch.js', () => { + events.push('import runtime'); + return { executeCommand: execute }; + }); + vi.doMock('./media.js', () => { + events.push('import media'); + return { pruneTemporariesCommand: media }; + }); +}); + +afterEach(() => { + vi.doUnmock('./dispatch.js'); + vi.doUnmock('./media.js'); +}); + +describe('finite lazy route boundary (ADR-0111)', () => { + it('binds every advertised executor exactly once without importing its runtime', async () => { + const { LAZY_COMMAND_IDS } = await import('./lazy-dispatch.js'); + const advertised = COMMAND_DESCRIPTORS.flatMap((descriptor) => + descriptor.executorId === undefined ? [] : [descriptor.executorId], + ); + expect(LAZY_COMMAND_IDS).toHaveLength(24); + expect(new Set(LAZY_COMMAND_IDS).size).toBe(24); + expect([...LAZY_COMMAND_IDS].sort()).toEqual([...advertised].sort()); + expect([...LAZY_COMMAND_IDS].sort()).toEqual( + [...PRICED, ...UNPRICED, 'media.prune-temporaries'].sort(), + ); + expect(events).toEqual([]); + }); + + it.each(PRICED)('%s awaits the seed before importing or executing runtime', async (id) => { + const { executeLazyCommand } = await import('./lazy-dispatch.js'); + let release: () => void = () => undefined; + const seed = vi.fn( + () => + new Promise((resolve) => { + events.push('seed'); + release = resolve; + }), + ); + const ctx = context(seed); + const result = executeLazyCommand(id, INPUT, ctx); + expect(events).toEqual(['seed']); + expect(execute).not.toHaveBeenCalled(); + release(); + await expect(result).resolves.toBe(0); + expect(events).toEqual(['seed', 'import runtime', 'execute']); + expect(execute).toHaveBeenCalledExactlyOnceWith(id, INPUT, ctx); + }); + + it.each(UNPRICED)('%s forwards input and invocation without seeding', async (id) => { + const { executeLazyCommand } = await import('./lazy-dispatch.js'); + const seed = vi.fn(() => Promise.resolve()); + const ctx = context(seed); + await expect(executeLazyCommand(id, INPUT, ctx)).resolves.toBe(0); + expect(seed).not.toHaveBeenCalled(); + expect(execute).toHaveBeenCalledExactlyOnceWith(id, INPUT, ctx); + }); + + it.each(['cassette.json', ''])( + 'agent fixture string %j skips the external file seed', + async (fixture) => { + const { executeLazyCommand } = await import('./lazy-dispatch.js'); + const seed = vi.fn(() => Promise.resolve()); + await executeLazyCommand('agent.run', { ...INPUT, options: { fixture } }, context(seed)); + expect(seed).not.toHaveBeenCalled(); + expect(execute).toHaveBeenCalledTimes(1); + }, + ); + + it.each([true, false, ['cassette.json'], undefined])( + 'non-string fixture %j does not bypass seeding', + async (fixture) => { + const { executeLazyCommand } = await import('./lazy-dispatch.js'); + const seed = vi.fn(() => Promise.resolve()); + await executeLazyCommand('agent.run', { ...INPUT, options: { fixture } }, context(seed)); + expect(seed).toHaveBeenCalledTimes(1); + }, + ); + + it('two priced routes sharing an invocation cannot reseed after the first runtime', async () => { + const { executeLazyCommand } = await import('./lazy-dispatch.js'); + const load = vi.fn(() => { + events.push('file'); + }); + const ctx = context(createFileCatalogSeed(load)); + await executeLazyCommand('chat', INPUT, ctx); + await executeLazyCommand('chat-resume', INPUT, ctx); + expect(load).toHaveBeenCalledTimes(1); + expect(events).toEqual(['file', 'import runtime', 'execute', 'execute']); + expect(execute.mock.calls.every((call) => call[2].invocation === ctx.invocation)).toBe(true); + }); + + it.each(['../dispatch.js', 'constructor', 'toString', 'unknown'])( + 'rejects unknown id %s before seed or import', + async (id) => { + const { executeLazyCommand } = await import('./lazy-dispatch.js'); + const seed = vi.fn(() => Promise.resolve()); + await expect(executeLazyCommand(id, INPUT, context(seed))).rejects.toMatchObject({ + code: 'invalid_invocation', + }); + expect(seed).not.toHaveBeenCalled(); + expect(events).toEqual([]); + }, + ); + + it.each(['run', 'models', 'provider.list', 'media.prune-temporaries'])( + '%s refuses unsupported FS grants before seed or import', + async (id) => { + const { executeLazyCommand } = await import('./lazy-dispatch.js'); + const seed = vi.fn(() => Promise.resolve()); + const ctx = context(seed); + await expect( + executeLazyCommand(id, INPUT, { + ...ctx, + global: { ...ctx.global, allowFsRoots: ['/admitted-but-unsupported'] }, + }), + ).rejects.toMatchObject({ code: 'invalid_invocation' }); + expect(seed).not.toHaveBeenCalled(); + expect(events).toEqual([]); + }, + ); + + it('valid media maintenance imports only media and never seeds', async () => { + const { executeLazyCommand } = await import('./lazy-dispatch.js'); + const seed = vi.fn(() => Promise.resolve()); + const ctx = context(seed); + await executeLazyCommand( + 'media.prune-temporaries', + { + positionals: [], + options: { legacyOffline: true, force: true }, + }, + ctx, + ); + expect(events).toEqual(['import media']); + expect(media).toHaveBeenCalledExactlyOnceWith({ legacyOffline: true, force: true }, ctx); + expect(seed).not.toHaveBeenCalled(); + }); + + it.each([ + { positionals: [], options: {} }, + { positionals: [], options: { legacyOffline: true } }, + { positionals: [], options: { legacyOffline: 'true', force: true } }, + { positionals: ['unexpected'], options: { legacyOffline: true, force: true } }, + ])('invalid media maintenance refuses before any runtime import: %j', async (input) => { + const { executeLazyCommand } = await import('./lazy-dispatch.js'); + const seed = vi.fn(() => Promise.resolve()); + await expect( + executeLazyCommand('media.prune-temporaries', input, context(seed)), + ).rejects.toMatchObject({ code: 'invalid_invocation' }); + expect(events).toEqual([]); + expect(seed).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/cli/src/commands/lazy-dispatch.ts b/apps/cli/src/commands/lazy-dispatch.ts new file mode 100644 index 00000000..2f379eca --- /dev/null +++ b/apps/cli/src/commands/lazy-dispatch.ts @@ -0,0 +1,87 @@ +import { COMMAND_DESCRIPTORS } from './descriptors.js'; +import { buildMediaPruneArgs } from './media-input.js'; +import type { DispatchCommand } from './input.js'; +import { CliError } from '../process/cli-error.js'; + +/** Fixed module edges only; command/model/user strings never become import paths. */ +const loadRuntime = () => import('./dispatch.js'); +const RUNTIME_IDS = Object.freeze([ + 'run', + 'chat', + 'chat-resume', + 'chat-list', + 'chat-export', + 'create', + 'export', + 'import', + 'agent.run', + 'gate', + 'budget.resume', + 'gate.list', + 'list', + 'logs', + 'status', + 'models', + 'models.refresh', + 'models.pricing', + 'provider.list', + 'provider.add', + 'provider.set-key', + 'provider.remove-key', + 'provider.test', +]); + +const routes = new Map(); +for (const id of RUNTIME_IDS) { + routes.set(id, async (_id, input, ctx) => { + let runtime: Awaited>; + try { + runtime = await loadRuntime(); + } catch (cause) { + throw new CliError('internal', 'The CLI runtime could not be loaded.', { cause }); + } + return runtime.executeCommand(id, input, ctx); + }); +} +routes.set('media.prune-temporaries', async (_id, input, ctx) => { + const args = buildMediaPruneArgs(input); + const { pruneTemporariesCommand } = await import('./media.js'); + return pruneTemporariesCommand(args, ctx); +}); + +export const LAZY_COMMAND_IDS: readonly string[] = Object.freeze([...routes.keys()]); +const advertised = COMMAND_DESCRIPTORS.flatMap((entry) => + entry.executorId === undefined ? [] : [entry.executorId], +); +if (advertised.length !== routes.size || advertised.some((id) => !routes.has(id))) + throw new CliError('cli_internal', ''); + +const PRICED_IDS = new Set([ + 'run', + 'chat', + 'chat-resume', + 'agent.run', + 'gate', + 'budget.resume', + 'models', + 'models.refresh', + 'models.pricing', +]); + +/** Read the offline cache before the runtime's first metadata consumer; fixtures/media never seed it. */ +export const executeLazyCommand: DispatchCommand = async (id, input, ctx) => { + const route = routes.get(id); + if (route === undefined) throw new CliError('invalid_invocation', 'unknown command.'); + if ( + (ctx.global.allowFsRoots?.length ?? 0) > 0 && + !['chat', 'chat-resume', 'agent.run'].includes(id) + ) { + throw new CliError( + 'invalid_invocation', + '--allow-fs-root applies only to Home, chat, chat-resume and agent run.', + ); + } + if (PRICED_IDS.has(id) && !(id === 'agent.run' && typeof input.options['fixture'] === 'string')) + await ctx.invocation?.seedFileCatalogOnce(); + return route(id, input, ctx); +}; diff --git a/apps/cli/src/commands/media-input.ts b/apps/cli/src/commands/media-input.ts new file mode 100644 index 00000000..5b552e25 --- /dev/null +++ b/apps/cli/src/commands/media-input.ts @@ -0,0 +1,17 @@ +import { CliError } from '../process/cli-error.js'; +import type { CommandInput } from './input.js'; +import type { MediaPruneArgs } from './media.js'; + +export function buildMediaPruneArgs(input: CommandInput): MediaPruneArgs { + if ( + input.positionals.length !== 0 || + input.options['legacyOffline'] !== true || + input.options['force'] !== true + ) { + throw new CliError( + 'invalid_invocation', + 'Temporary maintenance requires --legacy-offline and --force.', + ); + } + return { legacyOffline: true, force: true }; +} diff --git a/apps/cli/src/engine/catalog-refresh.ts b/apps/cli/src/engine/catalog-refresh.ts index 02ace4a7..6c969e92 100644 --- a/apps/cli/src/engine/catalog-refresh.ts +++ b/apps/cli/src/engine/catalog-refresh.ts @@ -260,7 +260,7 @@ function install( * Cache the payload, filtered to the FOUR providers we have an adapter for. * * models.dev ships 166 providers and ~5,600 models — the 2–3 MB blob ADR-0071 §3 explicitly declined to vendor. We - * consume four of them, and `seedCatalog` re-reads + re-parses this file on EVERY invocation, including `--help`. + * consume four of them, and a priced invocation seeds this file at most once, after filesystem admission. Meta operations never read it. * Caching the whole thing taxes the cheapest, hottest commands with a parse we throw almost all of away. Filtering * to the mapped providers keeps the file small AND keeps it RAW-per-provider, so a future release that fixes a * normalization bug still repairs the cache on the next load. diff --git a/apps/cli/src/engine/catalog-seed.ts b/apps/cli/src/engine/catalog-seed.ts new file mode 100644 index 00000000..3846a0e6 --- /dev/null +++ b/apps/cli/src/engine/catalog-seed.ts @@ -0,0 +1,7 @@ +import { resolveHomeDir } from '../config/load.js'; +import { loadCachedCatalog } from './catalog-refresh.js'; + +/** Read only the existing offline file cache; never fetch or write. Invocation ownership prevents late reseeds. */ +export function seedFileCatalog(): void { + loadCachedCatalog(resolveHomeDir({})); +} diff --git a/apps/cli/src/engine/file-catalog-cutover.test.ts b/apps/cli/src/engine/file-catalog-cutover.test.ts new file mode 100644 index 00000000..c6198013 --- /dev/null +++ b/apps/cli/src/engine/file-catalog-cutover.test.ts @@ -0,0 +1,220 @@ +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { createClient, createModelMetadataStore, runMigrations, type DbClient } from '@relavium/db'; +import { + catalogModel, + CATALOG_SCHEMA_VERSION, + clearCatalogRefresh, + cost, + priceModel, + type CatalogModel, +} from '@relavium/llm'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createFileCatalogSeed } from '../process/file-catalog-seed.js'; +import { catalogModelToRow } from './catalog-metadata.js'; +import { catalogCachePath, loadCachedCatalog } from './catalog-refresh.js'; +import { + loadUserPricingOverlay, + readBudgetPricingOverlay, + readUserPricingOverlay, +} from './pricing-overlay.js'; + +const MODEL = 'step7-cutover-long-tail'; +const FILE_PRICE = 100_000_000; +const DB_PRICE = 700_000_000; +const TS = 1_800_000_000_000; + +function dbModel(price = DB_PRICE): CatalogModel { + return { + modelId: MODEL, + provider: 'openai', + displayName: 'DB long tail', + contextWindowTokens: 123_456, + maxOutputTokens: 4_321, + inputPerMtokMicrocents: price, + outputPerMtokMicrocents: price * 2, + requestCapabilities: { toolCall: false }, + }; +} + +function cachedPayload(price = 1): string { + return JSON.stringify({ + openai: { + models: { + [MODEL]: { + id: MODEL, + name: 'File long tail', + limit: { context: 100_000, output: 10_000 }, + cost: { input: price, output: price * 2 }, + tool_call: true, + }, + }, + }, + }); +} + +// Actual cache parsing, SQLite rows and production overlay helpers; these do not simulate +// full Home/clear/reseat/resume journeys or claim coverage of every production caller. +describe('file-to-DB catalog cutover (ADR-0071/0072/0111)', () => { + let home: string; + let client: DbClient; + + beforeEach(() => { + clearCatalogRefresh(); + expect(catalogModel(MODEL)).toBeUndefined(); + home = mkdtempSync(join(tmpdir(), 'relavium-file-catalog-')); + client = createClient(':memory:'); + const path = catalogCachePath(home); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, cachedPayload()); + }); + + afterEach(() => { + if (client.sqlite.open) client.sqlite.close(); + clearCatalogRefresh(); + rmSync(home, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + function seedOwner() { + const load = vi.fn(() => { + loadCachedCatalog(home); + }); + return { load, seed: createFileCatalogSeed(load) }; + } + + function persist(price = DB_PRICE): void { + const store = createModelMetadataStore(client.db, { now: () => TS }); + store.upsert([catalogModelToRow(dbModel(price), 'refreshed', TS)]); + } + + it.each(['unmigrated', 'empty', 'torn', 'stale'] as const)( + '%s DB retains file pricing at the first actual cost lookup', + async (state) => { + if (state !== 'unmigrated') runMigrations(client.db); + if (state === 'torn') client.sqlite.exec('DROP TABLE model_metadata'); + if (state === 'stale') { + const store = createModelMetadataStore(client.db, { now: () => TS }); + store.upsert([ + { + ...catalogModelToRow(dbModel(), 'refreshed', TS), + catalogSchemaVersion: CATALOG_SCHEMA_VERSION - 1, + }, + ]); + } + const { seed, load } = seedOwner(); + await seed(); + const overlay = readUserPricingOverlay(client.db); + expect(priceModel(MODEL, overlay).inputPerMtokMicrocents).toBe(FILE_PRICE); + expect( + cost(MODEL, { inputTokens: 1_000_000, outputTokens: 1_000_000 }, overlay).microcents, + ).toBe(FILE_PRICE * 3); + expect(load).toHaveBeenCalledTimes(1); + }, + ); + + it('a current DB row wins before the first capability and cost consumers', async () => { + runMigrations(client.db); + persist(); + const { seed } = seedOwner(); + await seed(); + expect(priceModel(MODEL).inputPerMtokMicrocents).toBe(FILE_PRICE); + const overlay = readUserPricingOverlay(client.db); + expect(catalogModel(MODEL)?.requestCapabilities?.toolCall).toBe(false); + expect(priceModel(MODEL, overlay).maxOutputTokens).toBe(4_321); + expect( + cost(MODEL, { inputTokens: 1_000_000, outputTokens: 1_000_000 }, overlay).microcents, + ).toBe(DB_PRICE * 3); + }); + + it('repeated owner-level rebuild reads see the newest DB rows without a late file reinstall', async () => { + runMigrations(client.db); + const { seed, load } = seedOwner(); + await seed(); + // The production clear/reseat/resume sites call this same fresh-read helper. This + // loop tests that helper with one seed owner, not those UI/session lifecycles. + for (const price of [DB_PRICE, DB_PRICE + 100_000_000, DB_PRICE + 200_000_000]) { + persist(price); + writeFileSync(catalogCachePath(home), cachedPayload(99)); + await seed(); + const overlay = readUserPricingOverlay(client.db); + await seed(); + expect(priceModel(MODEL, overlay).inputPerMtokMicrocents).toBe(price); + expect(cost(MODEL, { inputTokens: 1_000_000, outputTokens: 0 }, overlay).microcents).toBe( + price, + ); + } + expect(load).toHaveBeenCalledTimes(1); + }); + + it('a transient loader cuts over from the file to the persisted SQLite price', async () => { + client.sqlite.close(); + mkdirSync(join(home, '.relavium')); + client = createClient(join(home, '.relavium', 'history.db')); + runMigrations(client.db); + persist(); + const { seed } = seedOwner(); + await seed(); + const errors: string[] = []; + const overlay = loadUserPricingOverlay(home, { + writeErr: (text) => { + errors.push(text); + }, + }); + expect(overlay).toBeDefined(); + expect(errors).toEqual([]); + expect(cost(MODEL, { inputTokens: 1_000_000, outputTokens: 0 }, overlay).microcents).toBe( + DB_PRICE, + ); + }); + + it('quote-only reading installs the DB price without seeding or changing a single database byte', async () => { + runMigrations(client.db); + persist(); + const store = createModelMetadataStore(client.db, { now: () => TS }); + expect(store.readMeta()).toBeUndefined(); + expect(store.readAll()).toHaveLength(1); + const before = client.sqlite.serialize(); + const { seed } = seedOwner(); + await seed(); + const overlay = readBudgetPricingOverlay(client.db); + expect(cost(MODEL, { inputTokens: 1_000_000, outputTokens: 0 }, overlay).microcents).toBe( + DB_PRICE, + ); + expect(client.sqlite.serialize()).toEqual(before); + expect(store.readMeta()).toBeUndefined(); + expect(store.readAll()).toHaveLength(1); + }); + + it.each(['missing', 'malformed'] as const)( + 'a %s cache is attempted once even if a valid file appears later', + async (state) => { + if (state === 'missing') rmSync(catalogCachePath(home)); + else writeFileSync(catalogCachePath(home), '{"openai":'); + const { seed, load } = seedOwner(); + await seed(); + expect(catalogModel(MODEL)).toBeUndefined(); + writeFileSync(catalogCachePath(home), cachedPayload()); + await seed(); + expect(load).toHaveBeenCalledTimes(1); + expect(catalogModel(MODEL)).toBeUndefined(); + // A distinct invocation can consume the newly available cache. + await seedOwner().seed(); + expect(priceModel(MODEL).inputPerMtokMicrocents).toBe(FILE_PRICE); + }, + ); + + it('reading a valid cache changes neither its bytes nor the directory contents', async () => { + const path = catalogCachePath(home); + const before = readFileSync(path); + const files = readdirSync(dirname(path)); + const { seed } = seedOwner(); + await seed(); + expect(readFileSync(path)).toEqual(before); + expect(readdirSync(dirname(path))).toEqual(files); + expect(priceModel(MODEL).inputPerMtokMicrocents).toBe(FILE_PRICE); + }); +}); diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index 3509d81a..53a96275 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -13,6 +13,7 @@ import { openStdioConnection, openWebSocketConnection, startMcpClient as defaultStartMcpClient, + setMcpClientVersion, type HttpServerSpec, type ManagerSkippedTool, type McpClient, @@ -38,6 +39,7 @@ import { type McpServerRegistration, } from '@relavium/shared'; +import { CLI_VERSION } from '../version.js'; import { EgressWorkScope } from './egress-work.js'; import { CliError } from '../process/errors.js'; import { createMcpFetch, type McpFetch, type McpFetchConfig } from './mcp-fetch.js'; @@ -692,6 +694,8 @@ async function startMcpClientFailLoud( connectSignal?: AbortSignalLike, work?: ToolHostCallOptions, ): Promise { + // The shared build token is installed only at the actual MCP construction choke. + setMcpClientVersion(CLI_VERSION); const start = custom ?? defaultStartMcpClient; const owned = work === undefined ? new EgressWorkScope() : undefined; try { diff --git a/apps/cli/src/engine/tool-host/errors.ts b/apps/cli/src/engine/tool-host/errors.ts index 6094633b..eecadd50 100644 --- a/apps/cli/src/engine/tool-host/errors.ts +++ b/apps/cli/src/engine/tool-host/errors.ts @@ -1,6 +1,6 @@ -import { RelaviumError } from '@relavium/shared'; +import { RelaviumError } from '@relavium/shared/relavium-error'; -import { ToolDispatchError } from '@relavium/core'; +import { ToolDispatchError } from '@relavium/core/tool-dispatch-error'; import type { AbortSignalLike, ErrorCode } from '@relavium/shared'; /** diff --git a/apps/cli/src/engine/tool-host/fs-admission.ts b/apps/cli/src/engine/tool-host/fs-admission.ts index 7bafc33e..b2294632 100644 --- a/apps/cli/src/engine/tool-host/fs-admission.ts +++ b/apps/cli/src/engine/tool-host/fs-admission.ts @@ -1,9 +1,7 @@ import { resolve } from 'node:path'; -import type { GlobalOptions } from '../../process/options.js'; import { CliError } from '../../process/errors.js'; import { - admitInvocationFsGrants, selectInvocationFsPolicy, fsPolicyFloorPaths, fsPolicyWorkspace, @@ -26,17 +24,7 @@ export function assertChatWorkspaceAdmission(workspace: string, policy?: FsPathP } } -/** Direct command embeddings use the same pre-composition admission as the argv boundary. */ -export function admitFsInvocation(global: GlobalOptions): InvocationFsGrants { - try { - return global.fsGrants ?? admitInvocationFsGrants(global.allowFsRoots ?? []); - } catch { - throw new CliError( - 'invalid_invocation', - 'Filesystem root grants must name existing absolute directories that can be admitted.', - ); - } -} +export { admitFsInvocation } from './fs-invocation-admission.js'; /** A committed selector can narrow authority; it cannot create a grant in any tier. */ export function admitChatFsPolicy( diff --git a/apps/cli/src/engine/tool-host/fs-invocation-admission.ts b/apps/cli/src/engine/tool-host/fs-invocation-admission.ts new file mode 100644 index 00000000..e5e09ede --- /dev/null +++ b/apps/cli/src/engine/tool-host/fs-invocation-admission.ts @@ -0,0 +1,15 @@ +import type { GlobalOptions } from '../../process/options.js'; +import { CliError } from '../../process/cli-error.js'; +import { admitInvocationFsGrants, type InvocationFsGrants } from './fs-policy.js'; + +/** Direct command embeddings use the same pre-composition admission as the argv boundary. */ +export function admitFsInvocation(global: GlobalOptions): InvocationFsGrants { + try { + return global.fsGrants ?? admitInvocationFsGrants(global.allowFsRoots ?? []); + } catch { + throw new CliError( + 'invalid_invocation', + 'Filesystem root grants must name existing absolute directories that can be admitted.', + ); + } +} diff --git a/apps/cli/src/engine/tool-host/fs-policy.ts b/apps/cli/src/engine/tool-host/fs-policy.ts index 91711a30..0269c0a2 100644 --- a/apps/cli/src/engine/tool-host/fs-policy.ts +++ b/apps/cli/src/engine/tool-host/fs-policy.ts @@ -2,7 +2,7 @@ import { lstatSync, realpathSync, statSync, type Stats } from 'node:fs'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import type { FsScopeTier } from '@relavium/core'; -import { FS_ROOT_LIMITS } from '@relavium/shared'; +import { FS_ROOT_LIMITS } from '@relavium/shared/fs-root-limits'; import { HostDeniedError } from './errors.js'; diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index 0a024f89..99797169 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -1,3 +1,4 @@ +import type { CliInvocation } from '../process/invocation.js'; import { closeQuietly, warnTeardown } from '../process/close-quietly.js'; import { COMPACTION_UNKNOWN_WINDOW_NOTICE } from '../chat/repl-info.js'; import { randomUUID } from 'node:crypto'; @@ -104,6 +105,8 @@ import { defaultSubscribeSignals } from '../process/signals.js'; * block is bracketed literal text (no embedded newline submits early); the controller strips the markers. */ export interface HomeDeps { + /** The same bootstrap invocation used by the guarded lazy route. */ + readonly invocation?: CliInvocation; readonly io: CliIo; readonly global: GlobalOptions; readonly providers?: ProviderResolver; diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index f0f6756a..2cb2f223 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,43 +1,21 @@ +#!/usr/bin/env node import { escalateExitCode, installBackgroundFailureNet } from './process/background-failure.js'; import { EXIT_CODES } from './process/exit-codes.js'; import { processIo } from './process/io.js'; -import { setMcpClientVersion } from '@relavium/mcp'; +import { createCliInvocation } from './process/invocation.js'; -import { CLI_VERSION } from './program.js'; -import { run } from './run.js'; -import { sanitizeUntrusted } from './render/sanitize.js'; - -// The `bin` entry: wire the real-process IO seam, install the background-failure net, run, and set the -// deterministic exit code. +// Pure bootstrap owners exist before any runtime import can fail. Native adapters land in Step 8. const io = processIo(); - -// Tell every MCP server the version we actually ship (`#208`, ADR-0088 §9). `packages/mcp` has no release -// version of its own, and its previous hand-maintained literal had already drifted past the CLI's — a server -// that logs or version-gates on it saw a number no maintainer would remember to bump. Sourced from the SAME -// build-time token `--version` uses, so the two cannot disagree. -setMcpClientVersion(CLI_VERSION); - -// #228 — installed BEFORE `run()`, so a rejection during startup is caught too. The module doc explains why -// the process survives one rather than dying on it, and why the net is bounded and sanitized. +const invocation = createCliInvocation(); const net = installBackgroundFailureNet(io); try { - process.exitCode = await run(process.argv, io); -} catch (err) { - // `run()` is designed never to reject; this is a last-resort guard that fails loudly - // without leaking a stack as primary output. Set `RELAVIUM_DEBUG` to see the stack. + const { run } = await import('./run.js'); + process.exitCode = await run(process.argv, io, undefined, invocation); +} catch { + // Import/evaluation failures must reach a fixed early sink even when runtime classification cannot load. io.writeErr('relavium: a fatal internal error occurred.\n'); - if (io.env['RELAVIUM_DEBUG'] !== undefined && err instanceof Error && err.stack !== undefined) { - // A stack embeds the message and often argument values with it, and at this point the error can be - // anything a provider, an MCP server or a tool produced — so redact secret shapes before the control-byte - // strip, not after (a credential split by an escape byte would otherwise rejoin once the byte is removed). - io.writeErr(`${sanitizeUntrusted(err.stack)}\n`); - } process.exitCode = EXIT_CODES.workflowFailed; } -// The mirror of the in-handler upgrade, for a rejection that fired BEFORE `run()` assigned the code and would -// otherwise have been overwritten by it. Same rule: only a clean `0` is escalated. -if (net.occurred()) { - escalateExitCode(); -} +if (net.occurred()) escalateExitCode(); diff --git a/apps/cli/src/process/file-catalog-seed.test.ts b/apps/cli/src/process/file-catalog-seed.test.ts new file mode 100644 index 00000000..88bf2d7a --- /dev/null +++ b/apps/cli/src/process/file-catalog-seed.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createFileCatalogSeed } from './file-catalog-seed.js'; + +describe('invocation-owned file catalog seed (ADR-0111)', () => { + it('does no work at construction and shares completion across concurrent and later callers', async () => { + let release: () => void = () => undefined; + const pending = new Promise((resolve) => { + release = resolve; + }); + const load = vi.fn(() => pending); + const seed = createFileCatalogSeed(load); + expect(load).not.toHaveBeenCalled(); + + const first = seed(); + const concurrent = seed(); + expect(concurrent).toBe(first); + await Promise.resolve(); + expect(load).toHaveBeenCalledTimes(1); + let completed = false; + void concurrent.then(() => { + completed = true; + }); + await Promise.resolve(); + expect(completed).toBe(false); + release(); + await first; + expect(completed).toBe(true); + expect(seed()).toBe(first); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('latches before a loader synchronously reenters the same owner', async () => { + let reentered: Promise | undefined; + const load = vi.fn(() => { + reentered = seed(); + }); + const seed = createFileCatalogSeed(load); + const first = seed(); + await first; + expect(reentered).toBe(first); + expect(load).toHaveBeenCalledTimes(1); + }); + + it.each(['throw', 'reject'] as const)('a %s is total and remains attempted', async (failure) => { + const load = vi.fn(() => { + if (failure === 'throw') throw new Error('optional cache unavailable'); + return Promise.reject(new Error('optional cache import unavailable')); + }); + const seed = createFileCatalogSeed(load); + const first = seed(); + await expect(first).resolves.toBeUndefined(); + await expect(seed()).resolves.toBeUndefined(); + expect(seed()).toBe(first); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('independent invocation owners each attempt the same loader once', async () => { + const load = vi.fn(() => undefined); + const first = createFileCatalogSeed(load); + const second = createFileCatalogSeed(load); + await Promise.all([first(), first(), second(), second()]); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('createCliInvocation binds one lazy loader and registry per invocation', async () => { + vi.resetModules(); + const load = vi.fn(() => undefined); + const imported = vi.fn(() => ({ seedFileCatalog: load })); + vi.doMock('../engine/catalog-seed.js', imported); + try { + const { createCliInvocation } = await import('./invocation.js'); + const first = createCliInvocation(); + const second = createCliInvocation(); + expect(first.registry).not.toBe(second.registry); + expect(imported).not.toHaveBeenCalled(); + expect(load).not.toHaveBeenCalled(); + const firstAttempt = first.seedFileCatalogOnce(); + expect(first.seedFileCatalogOnce()).toBe(firstAttempt); + await firstAttempt; + await first.seedFileCatalogOnce(); + expect(imported).toHaveBeenCalledTimes(1); + expect(load).toHaveBeenCalledTimes(1); + await second.seedFileCatalogOnce(); + expect(load).toHaveBeenCalledTimes(2); + } finally { + vi.doUnmock('../engine/catalog-seed.js'); + vi.resetModules(); + } + }); +}); diff --git a/apps/cli/src/process/file-catalog-seed.ts b/apps/cli/src/process/file-catalog-seed.ts new file mode 100644 index 00000000..db4a8322 --- /dev/null +++ b/apps/cli/src/process/file-catalog-seed.ts @@ -0,0 +1,12 @@ +/** One invocation attempt, including concurrent/reentrant callers and failed or missing cache reads. */ +export function createFileCatalogSeed(load: () => void | Promise): () => Promise { + let attempted: Promise | undefined; + return () => { + attempted ??= Promise.resolve() + .then(load) + .catch(() => { + // Optional offline cache seeding is total. The shipped floor remains authoritative on failure. + }); + return attempted; + }; +} diff --git a/apps/cli/src/process/invocation.ts b/apps/cli/src/process/invocation.ts new file mode 100644 index 00000000..01a8c0e0 --- /dev/null +++ b/apps/cli/src/process/invocation.ts @@ -0,0 +1,24 @@ +import { createFileCatalogSeed } from './file-catalog-seed.js'; +import { InvocationRegistry } from './invocation-registry.js'; + +export interface CliInvocation { + readonly registry: InvocationRegistry; + readonly seedFileCatalogOnce: () => Promise; +} + +/** Created once before guarded imports; authority is explicitly threaded, never module-global. */ +export function createCliInvocation(): CliInvocation { + return Object.freeze({ + registry: new InvocationRegistry({ + now: () => performance.now(), + schedule: (milliseconds, fire) => { + const timer = setTimeout(fire, milliseconds); + return () => clearTimeout(timer); + }, + }), + seedFileCatalogOnce: createFileCatalogSeed(async () => { + const { seedFileCatalog } = await import('../engine/catalog-seed.js'); + seedFileCatalog(); + }), + }); +} diff --git a/apps/cli/src/process/io.ts b/apps/cli/src/process/io.ts index 8cb2df28..5b423bd6 100644 --- a/apps/cli/src/process/io.ts +++ b/apps/cli/src/process/io.ts @@ -1,4 +1,4 @@ -import { CliError } from './errors.js'; +import { CliError } from './cli-error.js'; /** * The CLI's IO seam — injected so the command and process logic is testable with no real diff --git a/apps/cli/src/process/render-error.ts b/apps/cli/src/process/render-error.ts index 887b08e8..a7619f05 100644 --- a/apps/cli/src/process/render-error.ts +++ b/apps/cli/src/process/render-error.ts @@ -1,6 +1,6 @@ import { toUserFacing } from './errors.js'; import type { CliIo } from './io.js'; -import { sanitizeInline, sanitizeUntrusted, sanitizeUntrustedInline } from '../render/sanitize.js'; +import { renderUserFacing } from './render-user-facing.js'; /** * Render a fatal error once, at the top-level boundary (error-handling.md: the internal → @@ -17,22 +17,5 @@ export function renderError( opts: { readonly json: boolean; readonly verbose: boolean }, io: CliIo, ): void { - const userFacing = toUserFacing(value); - // The error boundary is shared by every command, including JSON diagnostics and verbose stacks. Keep the - // structured envelope valid while also removing terminal/bidi controls before it reaches stderr. - const code = sanitizeInline(userFacing.code); - // The message can carry a provider/MCP/tool string, so it is redacted as well as control-stripped. - const message = sanitizeUntrustedInline(userFacing.message); - if (opts.json) { - io.writeErr(JSON.stringify({ type: 'error', code, message }) + '\n'); - } else { - io.writeErr(`relavium: ${message}\n`); - } - // The raw stack is a human-mode debugging affordance only — never under `--json`, where it would - // mix non-JSON text into the structured stderr diagnostic (error-handling.md: a stack is not machine - // output). Under `--json` the `{ type: 'error', … }` envelope is the whole stderr diagnostic. - if (opts.verbose && !opts.json && value instanceof Error && value.stack !== undefined) { - // A stack embeds the message, and often argument values with it — this is the likeliest leak of the four. - io.writeErr(sanitizeUntrusted(value.stack) + '\n'); - } + renderUserFacing(toUserFacing(value), value, opts, io); } diff --git a/apps/cli/src/process/render-user-facing.ts b/apps/cli/src/process/render-user-facing.ts new file mode 100644 index 00000000..20da30e7 --- /dev/null +++ b/apps/cli/src/process/render-user-facing.ts @@ -0,0 +1,34 @@ +import type { UserFacingError } from './cli-error.js'; +import type { CliIo } from './io.js'; +import { + sanitizeInline, + sanitizeUntrusted, + sanitizeUntrustedInline, + stringifyJsonLine, +} from '../render/sanitize.js'; + +/** Pure diagnostic rendering: classification belongs to the owning boundary. */ +export function renderUserFacing( + userFacing: UserFacingError, + value: unknown, + opts: { readonly json: boolean; readonly verbose: boolean }, + io: CliIo, +): void { + // The error boundary is shared by every command, including JSON diagnostics and verbose stacks. Keep the + // structured envelope valid while also removing terminal/bidi controls before it reaches stderr. + const code = sanitizeInline(userFacing.code); + // The message can carry a provider/MCP/tool string, so it is redacted as well as control-stripped. + const message = sanitizeUntrustedInline(userFacing.message); + if (opts.json) { + io.writeErr(stringifyJsonLine({ type: 'error', code, message }) + '\n'); + } else { + io.writeErr(`relavium: ${message}\n`); + } + // The raw stack is a human-mode debugging affordance only — never under `--json`, where it would + // mix non-JSON text into the structured stderr diagnostic (error-handling.md: a stack is not machine + // output). Under `--json` the `{ type: 'error', … }` envelope is the whole stderr diagnostic. + if (opts.verbose && !opts.json && value instanceof Error && value.stack !== undefined) { + // A stack embeds the message, and often argument values with it — this is the likeliest leak of the four. + io.writeErr(sanitizeUntrusted(value.stack) + '\n'); + } +} diff --git a/apps/cli/src/program.ts b/apps/cli/src/program.ts index 29a4ad40..4e2b7cc1 100644 --- a/apps/cli/src/program.ts +++ b/apps/cli/src/program.ts @@ -14,14 +14,8 @@ export interface BuildProgramOptions { readonly execute?: DispatchCommand; } -/** - * CLI version. `tsup` replaces `__RELAVIUM_CLI_VERSION__` with the `package.json` version at build time - * (see tsup.config.ts `define`); a source run (tsx/vitest — no define) falls back to a dev sentinel, since an - * unbundled process has no published version. `typeof` keeps the source path from a ReferenceError. (2.L, ADR-0051.) - */ -declare const __RELAVIUM_CLI_VERSION__: string | undefined; -export const CLI_VERSION = - typeof __RELAVIUM_CLI_VERSION__ === 'string' ? __RELAVIUM_CLI_VERSION__ : '0.0.0-dev'; +import { CLI_VERSION } from './version.js'; +export { CLI_VERSION } from './version.js'; /** * The position-independent global flags are owned by `extractGlobalOptions` (options.ts), not diff --git a/apps/cli/src/render/sanitize.ts b/apps/cli/src/render/sanitize.ts index b770989d..61de430b 100644 --- a/apps/cli/src/render/sanitize.ts +++ b/apps/cli/src/render/sanitize.ts @@ -1,5 +1,5 @@ -import { scrubSecrets } from '@relavium/llm'; -import { stripTerminalControls } from '@relavium/shared'; +import { scrubSecrets } from '@relavium/llm/scrub-secrets'; +import { stripTerminalControls } from '@relavium/shared/terminal-safe'; // Re-exported so every existing consumer keeps this import path while the IMPLEMENTATION lives in // `@relavium/shared` — see its header for why it moved (ADR-0088 §7.1). diff --git a/apps/cli/src/run-lazy-boundary.test.ts b/apps/cli/src/run-lazy-boundary.test.ts new file mode 100644 index 00000000..7fec0b09 --- /dev/null +++ b/apps/cli/src/run-lazy-boundary.test.ts @@ -0,0 +1,136 @@ +import { Readable } from 'node:stream'; +import { resolve } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CliError } from './process/cli-error.js'; +import { createCliInvocation, type CliInvocation } from './process/invocation.js'; +import type { CliIo } from './process/io.js'; +import type { ExitCode } from './process/exit-codes.js'; +import { run } from './run.js'; + +const state = vi.hoisted(() => { + const trace: string[] = []; + const invocation: unknown = undefined; + return { trace, refuse: false, invocation }; +}); +vi.mock('./engine/tool-host/fs-invocation-admission.js', () => ({ + admitFsInvocation: () => { + state.trace.push('admission'); + if (state.refuse) + throw new CliError('invalid_invocation', 'Synthetic directory admission refused.'); + return Object.freeze({ kind: 'invocation-fs-grants' }); + }, +})); +vi.mock('./commands/dispatch.js', () => ({ + executeCommand: (_id: string, _input: unknown, ctx: { invocation?: CliInvocation }) => { + state.trace.push('dispatch'); + state.invocation = ctx.invocation; + return Promise.resolve(0); + }, +})); + +function fixture(interactive = false) { + let out = ''; + let err = ''; + const io: CliIo = { + writeOut: (text) => { + out += text; + }, + writeErr: (text) => { + err += text; + }, + writeErrAcknowledged: (text) => { + err += text; + return Promise.resolve(); + }, + env: {}, + stdoutIsTty: interactive, + stdinIsTty: interactive, + stdin: Readable.from([]), + }; + const base = createCliInvocation(); + const invocation: CliInvocation = Object.freeze({ + registry: base.registry, + seedFileCatalogOnce: () => { + state.trace.push('seed'); + return Promise.resolve(); + }, + }); + return { io, invocation, out: () => out, err: () => err }; +} +const argv = (...tokens: string[]) => ['node', 'relavium', ...tokens]; + +beforeEach(() => { + state.trace.length = 0; + state.refuse = false; + state.invocation = undefined; +}); + +describe('lazy run boundary with injected admission/runtime observers', () => { + it.each( + [ + [], + ['--help'], + ['--version'], + ['run', '--help'], + ['agent', '--help'], + ['provider', '--help'], + ['chat', '--help'], + ['--json', '--help'], + ['--allow-fs-root', resolve('/synthetic-missing-directory'), '--help'], + ['run', '--allow-fs-root', resolve('/synthetic-missing-directory'), '--help'], + ].map((tokens) => ({ tokens })), + )( + 'keeps meta grammar $tokens before directory admission, catalog or dispatch', + async ({ tokens }) => { + const f = fixture(); + expect(await run(argv(...tokens), f.io, undefined, f.invocation)).toBe(0); + expect(f.out().length).toBeGreaterThan(0); + expect(f.err()).toBe(''); + expect(state.trace).toEqual([]); + }, + ); + + it.each( + [ + ['--allow-fs-root', 'relative-root', '--help'], + ['--allow-fs-root', '--help'], + ['--verbose', '--quiet', '--help'], + ['run'], + ].map((tokens) => ({ tokens })), + )('refuses invalid grammar $tokens before services', async ({ tokens }) => { + const f = fixture(); + expect(await run(argv(...tokens), f.io, undefined, f.invocation)).toBe(2); + expect(f.err().length).toBeGreaterThan(0); + expect(state.trace).toEqual([]); + }); + + it('preserves one invocation object through admission, seed and the real lazy route', async () => { + const f = fixture(); + expect(await run(argv('run', 'synthetic.workflow.yaml'), f.io, undefined, f.invocation)).toBe( + 0, + ); + expect(state.trace).toEqual(['admission', 'seed', 'dispatch']); + expect(state.invocation).toBe(f.invocation); + expect(f.err()).toBe(''); + }); + + it('refuses a real-action admission before even the optional cache or runtime action', async () => { + const f = fixture(); + state.refuse = true; + expect(await run(argv('chat'), f.io, undefined, f.invocation)).toBe(2); + expect(state.trace).toEqual(['admission']); + expect(f.err()).toContain('Synthetic directory admission refused.'); + }); + + it('threads the same invocation into the injected interactive Home boundary after admission and seed', async () => { + const f = fixture(true); + const open = vi.fn((deps: { invocation?: CliInvocation }): Promise => { + state.trace.push('home'); + expect(deps.invocation).toBe(f.invocation); + return Promise.resolve(4); + }); + expect(await run(argv(), f.io, open, f.invocation)).toBe(4); + expect(state.trace).toEqual(['admission', 'seed', 'home']); + expect(open).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index bb7c5b38..43fc77fa 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -1,162 +1,158 @@ -import { executeCommand } from './commands/dispatch.js'; import { CommanderError } from 'commander'; -import { resolveHomeDir } from './config/load.js'; -import { loadCachedCatalog } from './engine/catalog-refresh.js'; -import { admitFsInvocation } from './engine/tool-host/fs-admission.js'; -import { driveHome, type HomeDeps } from './home/drive-home.js'; +import { executeLazyCommand } from './commands/lazy-dispatch.js'; +import type { HomeDeps } from './home/drive-home.js'; import { shouldOpenHome } from './home/should-open-home.js'; -import { CliError, toUserFacing } from './process/errors.js'; +import { + CLI_ERROR_POLICY, + CliError, + isCliError, + type UserFacingError, +} from './process/cli-error.js'; import { EXIT_CODES, type ExitCode } from './process/exit-codes.js'; import type { CliIo } from './process/io.js'; +import { createCliInvocation, type CliInvocation } from './process/invocation.js'; import { extractGlobalOptions, resolveGlobalOptions, type GlobalOptions, } from './process/options.js'; -import { renderError } from './process/render-error.js'; +import { renderUserFacing } from './process/render-user-facing.js'; import { buildProgram } from './program.js'; -/** The bare-invocation Home opener — injectable so a test can assert the TTY gate routes here (default {@link driveHome}). */ +/** Injectable Home boundary; the production implementation is imported only after admission. */ export type OpenHome = (deps: HomeDeps) => Promise; +const openLazyHome: OpenHome = async (deps) => { + let runtime: typeof import('./home/drive-home.js'); + try { + runtime = await import('./home/drive-home.js'); + } catch (cause) { + throw new CliError('internal', 'The CLI runtime could not be loaded.', { cause }); + } + return runtime.driveHome(deps); +}; + /** - * The CLI's top-level boundary: extract the position-independent global flags, resolve them, build the - * program with the command runtime context, parse the remaining argv (node-style), and translate every - * outcome to a single deterministic exit code ([commands.md](../../../docs/reference/cli/commands.md#exit-codes)). - * A command's exit code is set on the shared `result` holder; pre-parse and parse faults are rendered - * here. Designed to **never reject**. + * One grammar for meta and real execution. Only an actual action or interactive Home + * admits directories, seeds a needed offline catalog and enters the lazy runtime. */ export async function run( argv: readonly string[], io: CliIo, - openHome: OpenHome = driveHome, + openHome: OpenHome = openLazyHome, + invocation: CliInvocation = createCliInvocation(), ): Promise { const { raw, rest, error: extractError } = extractGlobalOptions(argv); - // `raw` is fully populated by extraction (it never throws), so the render honors any - // `--json`/`--verbose` even when a later global flag is the thing that failed. const renderCtx = { json: raw.json === true, verbose: raw.verbose === true }; - if (extractError !== undefined) { - renderError(extractError, renderCtx, io); + renderKnownError(extractError, renderCtx, io); return extractError.exitCode; } let global: GlobalOptions; try { - global = resolveGlobalOptions(raw, process.cwd(), io.env); // env feeds NO_COLOR/FORCE_COLOR; also enforces --verbose/--quiet - global = Object.freeze({ ...global, fsGrants: admitFsInvocation(global) }); + global = resolveGlobalOptions(raw, process.cwd(), io.env); } catch (err) { - renderError(err, renderCtx, io); - return toUserFacing(err).exitCode; + return renderFailure(err, renderCtx, io); } - - // THE CATALOG, BEFORE ANYTHING READS A PRICE (ADR-0071 §4). - // - // A previous `models refresh --catalog` cached models.dev to disk; load it so this process starts current. Absent, - // unreadable, or corrupt ⇒ silently nothing, and the SHIPPED SNAPSHOT answers — it is the floor, not a fallback, - // and a refresh that cannot be read must never leave the product knowing less than it shipped knowing. - // - // No fetch here, ever. `[catalog] auto_refresh` (default `false`) governs whether a background refresh may run at - // all, and even when it is on, the fetch belongs on a path the user can see — not in the boot of every `--help`. - // Offline media maintenance needs only its canonical CAS root, even for malformed flags. - if (rest[2] !== 'media') seedCatalog(); - const result: { exitCode?: ExitCode } = {}; - const program = buildProgram(io, { - suppressErrorOutput: renderCtx.json, - context: { io, global, result }, - execute: executeCommand, - }); - - // Bare invocation — no subcommand after extraction (only `[node, script]`, optionally a lone `--`). - if (isBareInvocation(rest)) { - return await handleBareInvocation({ io, global, program, openHome, renderCtx }); - } - try { + const program = buildProgram(io, { + suppressErrorOutput: renderCtx.json, + context: { io, global, result, invocation }, + execute: async (id, input, ctx) => { + const admitted = await admitGlobal(ctx.global); + return executeLazyCommand(id, input, { ...ctx, global: admitted }); + }, + }); + if (isBareInvocation(rest)) { + if ( + !shouldOpenHome({ + stdoutIsTty: io.stdoutIsTty, + stdinIsTty: io.stdinIsTty, + json: global.json, + env: io.env, + }) + ) { + io.writeOut(program.helpInformation()); + return EXIT_CODES.success; + } + const admitted = await admitGlobal(global); + await invocation.seedFileCatalogOnce(); + return await openHome({ io, global: admitted, invocation }); + } await program.parseAsync([...rest]); return result.exitCode ?? EXIT_CODES.success; } catch (err) { if (err instanceof CommanderError) { - // commander wrote help/version to stdout; under --json its own stderr was suppressed, so we emit - // the structured fault envelope ourselves — to stderr, keeping stdout a pure event stream - // (ADR-0049). `exitCode` is 0 for help/version, non-zero for a parse fault → 2. - // Only re-render under --json (commander's own stderr was suppressed). Without --json, commander - // already wrote its human message to stderr (writeErr un-suppressed), so we deliberately add nothing. - if (err.exitCode !== 0 && renderCtx.json) { - renderError( + if (err.exitCode !== 0 && renderCtx.json) + renderKnownError( new CliError('invalid_invocation', stripErrorPrefix(err.message)), renderCtx, io, ); - } return err.exitCode === 0 ? EXIT_CODES.success : EXIT_CODES.invalidInvocation; } - // A CliError thrown by a command action, or an unexpected throw. - renderError(err, renderCtx, io); - return toUserFacing(err).exitCode; + return renderFailure(err, renderCtx, io); } } -/** A bare invocation: nothing after `[node, script]` survives extraction except an optional lone `--`. */ -function isBareInvocation(rest: readonly string[]): boolean { - return !rest.slice(2).some((token) => token !== '--'); -} - -/** - * The bare-invocation outcome: open the interactive Home in a genuine TTY (2.5.B / ADR-0054), else keep the - * byte-for-byte help + exit-0 meta-op (ADR-0049). A Home build/config fault renders like a command fault. - */ -async function handleBareInvocation(ctx: { - io: CliIo; - global: GlobalOptions; - program: ReturnType; - openHome: OpenHome; - renderCtx: { json: boolean; verbose: boolean }; -}): Promise { - const { io, global, program, openHome, renderCtx } = ctx; - if ( - !shouldOpenHome({ - stdoutIsTty: io.stdoutIsTty, - stdinIsTty: io.stdinIsTty, - json: global.json, - env: io.env, - }) - ) { - io.writeOut(program.helpInformation()); - return EXIT_CODES.success; - } +async function admitGlobal(global: GlobalOptions): Promise { + let runtime: typeof import('./engine/tool-host/fs-invocation-admission.js'); try { - return await openHome({ io, global }); - } catch (err) { - renderError(err, renderCtx, io); - return toUserFacing(err).exitCode; + runtime = await import('./engine/tool-host/fs-invocation-admission.js'); + } catch (cause) { + throw new CliError('internal', 'The CLI runtime could not be loaded.', { cause }); } + return Object.freeze({ ...global, fsGrants: runtime.admitFsInvocation(global) }); } -/** Drop commander's redundant leading `error:` prefix (the JSON envelope already says type:error). */ -function stripErrorPrefix(message: string): string { - const prefix = 'error:'; - if (!message.startsWith(prefix)) { - return message; - } - return message.slice(prefix.length).trimStart(); +function renderKnownError( + error: CliError, + opts: { json: boolean; verbose: boolean }, + io: CliIo, +): void { + renderUserFacing( + { + code: error.code, + message: CLI_ERROR_POLICY[error.code].fixedMessage ?? error.message, + exitCode: error.exitCode, + }, + error, + opts, + io, + ); } -/** - * Install a previously-refreshed models.dev catalog, if one is cached (ADR-0071 §4). - * - * TOTAL: it cannot fail the CLI. A missing cache, an unreadable one, a corrupt one, an unreadable home — every path - * lands on the shipped snapshot, which is a complete answer on its own. That is the point of embedding it: with no - * refresh ever run (the DEFAULT — `[catalog] auto_refresh = false`), Relavium prices every model it ships, offline, - * and contacts nobody. - */ -function seedCatalog(): void { +async function renderFailure( + value: unknown, + opts: { json: boolean; verbose: boolean }, + io: CliIo, +): Promise { + if (isCliError(value)) { + renderKnownError(value, opts, io); + return value.exitCode; + } + let facing: UserFacingError; try { - loadCachedCatalog(resolveHomeDir({})); + const { toUserFacing } = await import('./process/errors.js'); + facing = toUserFacing(value); } catch { - // An unreadable home, a torn cache. Not this seed's job to report — a cached catalog is not a reason to refuse - // to run `relavium --help`, and the shipped snapshot answers regardless. + // Classification may itself be unavailable after a dependency import failure. + const fixed = new CliError('internal', 'An unexpected internal error occurred.'); + renderKnownError(fixed, opts, io); + return fixed.exitCode; } + renderUserFacing(facing, value, opts, io); + return facing.exitCode; +} + +function isBareInvocation(rest: readonly string[]): boolean { + return !rest.slice(2).some((token) => token !== '--'); +} + +function stripErrorPrefix(message: string): string { + const prefix = 'error:'; + return message.startsWith(prefix) ? message.slice(prefix.length).trimStart() : message; } diff --git a/apps/cli/src/version.ts b/apps/cli/src/version.ts new file mode 100644 index 00000000..1ea9877b --- /dev/null +++ b/apps/cli/src/version.ts @@ -0,0 +1,8 @@ +/** + * CLI version. `tsup` replaces `__RELAVIUM_CLI_VERSION__` with the `package.json` version at build time + * (see tsup.config.ts `define`); a source run (tsx/vitest — no define) falls back to a dev sentinel, since an + * unbundled process has no published version. `typeof` keeps the source path from a ReferenceError. (2.L, ADR-0051.) + */ +declare const __RELAVIUM_CLI_VERSION__: string | undefined; +export const CLI_VERSION = + typeof __RELAVIUM_CLI_VERSION__ === 'string' ? __RELAVIUM_CLI_VERSION__ : '0.0.0-dev'; diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts index 6476d3a2..92e89c71 100644 --- a/apps/cli/tsup.config.ts +++ b/apps/cli/tsup.config.ts @@ -1,4 +1,4 @@ -import { cpSync, readFileSync, rmSync } from 'node:fs'; +import { chmodSync, cpSync, readdirSync, readFileSync, rmSync } from 'node:fs'; import { defineConfig } from 'tsup'; @@ -11,15 +11,15 @@ const { version } = JSON.parse( /** * The CLI distribution bundle (2.L, [ADR-0051](../../docs/decisions/0051-cli-distribution-thin-bundle-private-engine.md)). * - * INLINE only the proprietary `@relavium/*` engine packages (`noExternal`); EXTERNALIZE every third-party + * INLINE only the `@relavium/*` engine packages (`noExternal`); EXTERNALIZE every third-party * dependency — they are declared in `package.json` and installed normally by npm (the prebuilt native addons * `better-sqlite3` + `@napi-rs/keyring` cannot be bundled; the quickjs WASM + the vendor SDKs + `ink`/`react` * are bundler-hostile). `tools/bundle-closure/check.mjs` guards that this `external` list equals the declared * runtime `dependencies`, so the two can never drift. * - * `sourcemap: false` + `minify: true`: the published `dist/` must ship **no** original engine source — a source - * map would embed the inlined engine's TypeScript into the tarball and defeat keeping it unpublished (ADR-0051). - * The shebang banner makes `dist/index.js` directly executable as the `relavium` bin. + * `sourcemap: false` + `minify: true`: the published `dist/` must ship no source tree or maps. This is the npm + * distribution boundary, not a claim that the public repository's sources are secret (ADR-0111). + * The entry-source shebang makes `dist/index.js` directly executable as the `relavium` bin. */ const THIRD_PARTY_EXTERNAL = [ '@anthropic-ai/sdk', @@ -53,6 +53,7 @@ export default defineConfig({ clean: true, sourcemap: false, minify: true, + splitting: true, // Emit esbuild's metafile (`dist/metafile-esm.json`) — the authoritative record of every externalized import, // read by `tools/bundle-closure/check.mjs` to guard that the `external` list ≡ the declared `dependencies`. It // is EXCLUDED from the published tarball (the `files` negation in package.json): its `inputs` would reveal the @@ -65,11 +66,14 @@ export default defineConfig({ // bundled `relavium --version` reports the real version with no runtime file read. Source runs (tsx/vitest) // have no define and fall back to a dev sentinel. define: { __RELAVIUM_CLI_VERSION__: JSON.stringify(version) }, - banner: { js: '#!/usr/bin/env node' }, // The inlined `@relavium/db` resolves its drizzle migrations via `new URL('../drizzle', import.meta.url)`, // which — once bundled — points beside THIS bundle, not the db package. So ship the migration set alongside // `dist/` (`files: ["dist","drizzle"]`); `/drizzle` is then what the bundled db code finds at runtime. onSuccess: async () => { + // Only the executable entry owns a hashbang; lazy chunks are regular flat ESM files. + for (const file of readdirSync('./dist')) { + if (file.endsWith('.js')) chmodSync(`./dist/${file}`, file === 'index.js' ? 0o755 : 0o644); + } // Clean first: cpSync merges (recursive) and never deletes, so a migration removed/renamed in the source // would otherwise linger here and ship stale. Recreate from the source of truth each build. rmSync('./drizzle', { recursive: true, force: true }); From ef61eebcc0fb30398676e02e8dbfa6488daca9cd Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 08:40:42 +0300 Subject: [PATCH 067/222] test(repo): enforce compiled cli isolation and emitted graph closure Check every emitted JavaScript loading edge and run native throw witnesses against the untouched compiled entry. Preserve existing causal controls at their extracted targets and require the new checks in local and GitHub CI. Refs: ADR-0111, Wave 3 Step 7a Co-Authored-By: Codex --- .github/workflows/ci.yml | 3 + package.json | 7 +- tools/bundle-closure/check.mjs | 155 ++---- tools/bundle-closure/closure.mjs | 384 ++++++++++++++ tools/bundle-closure/closure.test.mjs | 492 ++++++++++++++++++ tools/cli-bootstrap/README.md | 43 ++ tools/cli-bootstrap/check.mjs | 172 ++++++ tools/cli-bootstrap/child.mjs | 119 +++++ tools/cli-bootstrap/inventory.mjs | 68 +++ tools/cli-bootstrap/loader.mjs | 29 ++ tools/cli-bootstrap/policy.mjs | 37 ++ tools/cli-bootstrap/preload.mjs | 146 ++++++ tools/cli-bootstrap/process.mjs | 65 +++ tools/cli-bootstrap/witness.test.mjs | 187 +++++++ tools/fs-policy-controls/mutations.mjs | 4 +- tools/secrets-controls/mutations.mjs | 10 +- .../usage-coverage-controls/source-graph.mjs | 3 + .../usage-coverage-controls/workload.test.mjs | 24 +- 18 files changed, 1825 insertions(+), 123 deletions(-) create mode 100644 tools/bundle-closure/closure.mjs create mode 100644 tools/bundle-closure/closure.test.mjs create mode 100644 tools/cli-bootstrap/README.md create mode 100644 tools/cli-bootstrap/check.mjs create mode 100644 tools/cli-bootstrap/child.mjs create mode 100644 tools/cli-bootstrap/inventory.mjs create mode 100644 tools/cli-bootstrap/loader.mjs create mode 100644 tools/cli-bootstrap/policy.mjs create mode 100644 tools/cli-bootstrap/preload.mjs create mode 100644 tools/cli-bootstrap/process.mjs create mode 100644 tools/cli-bootstrap/witness.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 341fa2cf..55bd222e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,6 +169,9 @@ jobs: - name: CLI bundle closure matches its declared dependencies run: pnpm lint:bundle-closure + - name: Compiled CLI meta paths stay outside runtime services + run: pnpm smoke:cli-bootstrap + # Drift gate: the committed migration must match src/schema.ts. If a schema change # (or an upstream @relavium/shared enum change the CHECKs derive from) wasn't # regenerated via `pnpm --filter @relavium/db db:generate`, regenerating here produces diff --git a/package.json b/package.json index 0c20e6f4..9cdbad9c 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "coverage": "vitest run --coverage", "coverage:enforced": "node tools/coverage-gate/run.mjs", "ci:workspaces": "turbo run lint typecheck && turbo run test --concurrency=1", - "ci": "pnpm lint:test-isolation && pnpm ci:workspaces && pnpm db:sync-check && pnpm typecheck:tools && pnpm lint:tools && turbo run build format:check && pnpm lint:fence-check && pnpm lint:engine-deps && pnpm lint:bundle-closure && pnpm smoke:cli && pnpm smoke:media-packaging && pnpm smoke:overflow-capture && pnpm smoke:budget-replay && pnpm test:acceptance-tools && pnpm check:wire-mutations && pnpm check:usage-mutations && pnpm check:fs-mutations && pnpm check:secrets-mutations", + "ci": "pnpm lint:test-isolation && pnpm ci:workspaces && pnpm db:sync-check && pnpm typecheck:tools && pnpm lint:tools && turbo run build format:check && pnpm lint:fence-check && pnpm lint:engine-deps && pnpm lint:bundle-closure && pnpm smoke:cli-bootstrap && pnpm smoke:cli && pnpm smoke:media-packaging && pnpm smoke:overflow-capture && pnpm smoke:budget-replay && pnpm test:acceptance-tools && pnpm check:wire-mutations && pnpm check:usage-mutations && pnpm check:fs-mutations && pnpm check:secrets-mutations", "lint:test-isolation": "node tools/test-isolation/check.mjs && node tools/test-isolation/check-ownership.mjs", "lint:fence-check": "node tools/lint-fixtures/assert-fence.mjs", "lint:engine-deps": "node tools/engine-deps/check.mjs", @@ -36,9 +36,10 @@ "smoke:budget-replay": "node tools/budget-replay-compat/check.mjs", "check:wire-mutations": "node tools/wire-conformance/check.mjs", "check:usage-mutations": "node tools/usage-coverage-controls/check.mjs", - "test:acceptance-tools": "node --test tools/ci/windows-headless-smoke.test.mjs tools/ci/workspace-scheduling.test.mjs tools/ci/require-native-db-diagnostics.test.mjs tools/usage-coverage-controls/outcome.test.mjs tools/usage-coverage-controls/workload.test.mjs tools/ci/require-w8-acceptance.test.mjs tools/acceptance-evidence/export.test.mjs tools/wire-conformance/provenance.test.mjs tools/causal-controls/outcome.test.mjs tools/causal-controls/process.test.mjs tools/causal-controls/provenance.test.mjs tools/causal-controls/retention.test.mjs tools/causal-controls/run.test.mjs tools/causal-controls/timeout-policy.test.mjs", + "test:acceptance-tools": "node --test tools/ci/windows-headless-smoke.test.mjs tools/ci/workspace-scheduling.test.mjs tools/ci/require-native-db-diagnostics.test.mjs tools/usage-coverage-controls/outcome.test.mjs tools/usage-coverage-controls/workload.test.mjs tools/ci/require-w8-acceptance.test.mjs tools/acceptance-evidence/export.test.mjs tools/wire-conformance/provenance.test.mjs tools/causal-controls/outcome.test.mjs tools/causal-controls/process.test.mjs tools/causal-controls/provenance.test.mjs tools/causal-controls/retention.test.mjs tools/causal-controls/run.test.mjs tools/causal-controls/timeout-policy.test.mjs tools/bundle-closure/closure.test.mjs tools/cli-bootstrap/witness.test.mjs", "check:fs-mutations": "node tools/fs-policy-controls/check.mjs", - "check:secrets-mutations": "node tools/secrets-controls/check.mjs" + "check:secrets-mutations": "node tools/secrets-controls/check.mjs", + "smoke:cli-bootstrap": "node tools/cli-bootstrap/check.mjs" }, "devDependencies": { "@eslint/js": "catalog:", diff --git a/tools/bundle-closure/check.mjs b/tools/bundle-closure/check.mjs index 65bae7e4..2aa9bb81 100644 --- a/tools/bundle-closure/check.mjs +++ b/tools/bundle-closure/check.mjs @@ -1,109 +1,54 @@ -/** - * CLI bundle-closure guard (2.L, [ADR-0051](../../docs/decisions/0051-cli-distribution-thin-bundle-private-engine.md)). - * - * The published `relavium` bundle inlines ONLY the proprietary `@relavium/*` engine and externalizes every - * third-party dependency, which must be DECLARED in `apps/cli/package.json` `dependencies` so a global install - * resolves them. The danger is drift: an engine package adds a runtime dep, the bundle imports it, but the CLI - * manifest is not updated — and a user's `npm i -g relavium` fails with `Cannot find module …`. - * - * This guard reads esbuild's BUILD METAFILE (`dist/metafile-esm.json`, emitted by tsup with `metafile: true`) — - * the bundler's OWN authoritative record of every import the output carries — and asserts the external set equals - * the declared runtime `dependencies` exactly: - * - a bundle import NOT declared → ERROR (a broken install — the dangerous direction); - * - a declared dep NOT imported → ERROR (dead dependency / stale manifest). - * Reading the metafile (not regex-scanning the minified bundle) means import-like text inside a string literal / - * comment / regex can never be mistaken for a dependency, and a real (e.g. dynamic `import()`) import can never be - * missed. `@relavium/*` must NOT appear (they are inlined); node builtins and relative imports are ignored. - * - * Build the CLI first (the build writes the metafile), then run from the repo root: - * pnpm --filter relavium build && node tools/bundle-closure/check.mjs - */ -import { existsSync, readFileSync } from 'node:fs'; -import { builtinModules } from 'node:module'; -import { join } from 'node:path'; - -const METAFILE = 'apps/cli/dist/metafile-esm.json'; -const MANIFEST = 'apps/cli/package.json'; - -const BUILTINS = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)]); - -/** Collapse a specifier to its package name: `@scope/n/sub` → `@scope/n`; `name/sub` → `name`. */ -function toPackageName(spec) { - const parts = spec.split('/'); - return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; -} - -if (!existsSync(METAFILE)) { - console.error(`✗ ${METAFILE} not found — build the CLI first (pnpm --filter relavium build).`); - process.exit(1); -} - -const meta = JSON.parse(readFileSync(METAFILE, 'utf8')); -const outputKey = Object.keys(meta.outputs ?? {}).find((k) => k.endsWith('index.js')); -if (outputKey === undefined) { - console.error( - `✗ ${METAFILE} has no index.js output — the build did not produce the expected bundle.`, +/** Thin filesystem/exit adapter for the ADR-0111 emitted ESM graph guard. */ +import { lstatSync, readFileSync, readdirSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { BundleClosureError, checkBundleClosure } from './closure.mjs'; + +const modeEvidence = process.platform === 'win32' ? 'not-observable-windows' : 'posix'; +const packageDirectory = fileURLToPath(new URL('../../apps/cli/', import.meta.url)); + +function inventory(directory, files = {}) { + if (!lstatSync(directory).isDirectory()) { + throw new BundleClosureError('OUTPUT_KIND', 'dist inventory must use ordinary directories'); + } + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) inventory(path, files); + else if (/\.(?:js|mjs|cjs)$/iu.test(entry.name)) { + const key = relative(packageDirectory, path).split(sep).join('/'); + const stat = lstatSync(path); + files[key] = { + contents: entry.isFile() ? readFileSync(path, 'utf8') : '', + ...(modeEvidence === 'posix' ? { mode: stat.mode & 0o7777 } : {}), + kind: entry.isFile() ? 'file' : 'other', + }; + } else if (entry.isSymbolicLink()) { + // Never follow a directory symlink that could hide runtime chunks. + throw new BundleClosureError('OUTPUT_KIND', 'dist contains a symbolic link'); + } + } + return files; +} + +try { + const report = checkBundleClosure({ + metafile: JSON.parse(readFileSync(join(packageDirectory, 'dist/metafile-esm.json'), 'utf8')), + manifest: JSON.parse(readFileSync(join(packageDirectory, 'package.json'), 'utf8')), + files: inventory(join(packageDirectory, 'dist')), + modeEvidence, + }); + console.log( + `✓ CLI finite ESM closure: ${report.outputs.length} output(s), ${report.inputs.length} input(s), ` + + `${report.dependencies.length} declared external dependencies; engine inlined; ` + + (report.modeEvidence === 'posix' + ? 'POSIX modes verified.' + : 'POSIX modes not observable on Windows (graph evidence only).'), ); - process.exit(1); -} - -// This guard reads ONE output chunk. That is correct only while the build emits exactly one, which holds -// today (no dynamic `import()` in `apps/cli/src`). If a future dynamic import makes esbuild split a chunk, -// that chunk's external imports would never be inspected and the closure claim would silently narrow — so -// assert the precondition instead of trusting it. Deferring `driveHome` (2.5.5.E, #43/#44) will trip this -// deliberately: the fix then is to iterate every `.js` output, not to delete the assert. -const jsOutputs = Object.keys(meta.outputs ?? {}).filter((k) => k.endsWith('.js')); -if (jsOutputs.length !== 1) { +} catch (error) { console.error( - `✗ ${METAFILE} has ${jsOutputs.length} .js outputs (${jsOutputs.join(', ')}), expected exactly 1.\n` + - ' The bundle was split. Iterate every chunk below before trusting the closure result.', + error instanceof BundleClosureError + ? `✗ CLI bundle closure: ${error.message}` + : '✗ CLI bundle closure: cannot read valid build inventory — build the CLI first.', ); - process.exit(1); + process.exitCode = 1; } - -const imported = new Set(); -for (const imp of meta.outputs[outputKey].imports ?? []) { - const spec = imp.path; - // Relative (`./…`) / absolute (`/…`) specifiers are internal (the bundler inlined them); node builtins (listed - // by esbuild with or without the `node:` prefix, incl. subpaths like `fs/promises`) resolve without a package. - if (spec.startsWith('.') || spec.startsWith('/')) continue; - const pkg = toPackageName(spec); - if (BUILTINS.has(spec) || BUILTINS.has(pkg)) continue; - imported.add(pkg); -} - -const declared = new Set( - Object.keys(JSON.parse(readFileSync(join(MANIFEST), 'utf8')).dependencies ?? {}), -); - -const leakedEngine = [...imported].filter((p) => p.startsWith('@relavium/')); -const missing = [...imported].filter((p) => !p.startsWith('@relavium/') && !declared.has(p)); -const dead = [...declared].filter((p) => !imported.has(p)); - -let failed = false; -if (leakedEngine.length > 0) { - failed = true; - console.error( - `✗ engine package(s) NOT inlined (imported at runtime, but must be bundled): ${leakedEngine.join(', ')}`, - ); -} -if (missing.length > 0) { - failed = true; - console.error( - `✗ bundle imports undeclared dependenc(ies) — a published install would fail: ${missing.join(', ')}\n` + - ` Add them to ${MANIFEST} "dependencies" (catalog:), per ADR-0051.`, - ); -} -if (dead.length > 0) { - failed = true; - console.error( - `✗ declared dependenc(ies) the bundle never imports — stale manifest: ${dead.join(', ')}\n` + - ` Remove them from ${MANIFEST} "dependencies" (or confirm they are runtime-required).`, - ); -} - -if (failed) process.exit(1); -console.log( - `✓ CLI bundle closure matches the declared dependencies (${declared.size} third-party dep(s); ` + - `the @relavium/* engine is inlined).`, -); diff --git a/tools/bundle-closure/closure.mjs b/tools/bundle-closure/closure.mjs new file mode 100644 index 00000000..95e1ccf6 --- /dev/null +++ b/tools/bundle-closure/closure.mjs @@ -0,0 +1,384 @@ +/** + * Finite emitted ESM graph guard (ADR-0111). No filesystem reads or process state: + * the CLI supplies an inventory, and controls can remove/change any independent + * witness. This checks emitted files, not a tarball or Step 9 map custody. + */ +import { Buffer } from 'node:buffer'; +import { isBuiltin } from 'node:module'; +import { posix } from 'node:path'; +import ts from 'typescript'; + +export class BundleClosureError extends Error { + constructor(code, detail) { + super(`${code}: ${detail}`); + this.name = 'BundleClosureError'; + this.code = code; + } +} + +function refuse(code, detail) { + throw new BundleClosureError(code, detail); +} + +function record(value, label) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + refuse('METADATA_SHAPE', `${label} must be an object`); + } + return value; +} + +function strings(value, label) { + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + refuse('METADATA_SHAPE', `${label} must be a string array`); + } + return value; +} + +function bytes(value, label) { + if (!Number.isSafeInteger(value) || value < 0) { + refuse('METADATA_SHAPE', `${label} must be nonnegative integral bytes`); + } +} + +function sourcePath(value, label) { + if ( + typeof value !== 'string' || + value.length === 0 || + /[\\\s:?#%]/u.test(value) || + [...value].some((character) => character.codePointAt(0) < 32) || + value.startsWith('/') || + posix.normalize(value) !== value + ) { + refuse('INPUT_PATH', `${label} is not a canonical relative source path`); + } +} + +function outputPath(value, directory) { + if ( + typeof value !== 'string' || + !value.startsWith(`${directory}/`) || + !/^[a-zA-Z0-9_-]+\.js$/u.test(value.slice(directory.length + 1)) + ) { + refuse('OUTPUT_PATH', `expected a flat ${directory}/*.js output: ${value}`); + } +} + +/** Exact builtin identity matters: fs/not-a-builtin must not disappear as "fs". */ +function externalPackage(specifier) { + if (isBuiltin(specifier)) return undefined; + if ( + typeof specifier !== 'string' || + !/^(?:@[a-zA-Z0-9_.-]+\/)?[a-zA-Z0-9_-][a-zA-Z0-9_.-]*(?:\/[a-zA-Z0-9_.-]+)*$/u.test( + specifier, + ) || + specifier.split('/').some((part) => part === '.' || part === '..') + ) { + refuse('EXTERNAL_PATH', `unsupported external specifier: ${specifier}`); + } + const parts = specifier.split('/'); + const packageName = specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; + if (isBuiltin(packageName)) { + refuse('EXTERNAL_PATH', `unknown builtin subpath: ${specifier}`); + } + if (packageName.startsWith('@relavium/')) { + refuse('PRIVATE_EXTERNAL', `engine package must be inlined: ${specifier}`); + } + return packageName; +} + +function imports(value, label) { + if (!Array.isArray(value)) refuse('METADATA_SHAPE', `${label}.imports must be an array`); + for (const edge of value) { + record(edge, `${label} import`); + if ( + typeof edge.path !== 'string' || + !['import-statement', 'dynamic-import'].includes(edge.kind) || + (edge.external !== undefined && typeof edge.external !== 'boolean') || + (edge.original !== undefined && typeof edge.original !== 'string') + ) { + refuse('LOADER_KIND', `${label} has unsupported import metadata`); + } + } + return value; +} + +// Only syntax with an independently enumerable specifier is supported. Refuse +// acquisition/aliasing of loader or code-generation capabilities, not just calls +// spelled `require(...)`: imported createRequire is routinely minified/renamed. +const LOADER_NAMES = new Set([ + 'require', + '__require', + 'createRequire', + 'eval', + 'Function', + 'AsyncFunction', + 'GeneratorFunction', + 'AsyncGeneratorFunction', + 'getBuiltinModule', + '_load', + '_compile', + 'dlopen', + 'Worker', + 'importScripts', +]); +const MODULE_SAFE_EXPORTS = new Set([ + 'builtinModules', + 'isBuiltin', + 'syncBuiltinESMExports', + 'findSourceMap', + 'SourceMap', +]); + +function actualImports(contents, path) { + const source = ts.createSourceFile( + path, + contents, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.JS, + ); + if (source.parseDiagnostics.length > 0) refuse('JS_SYNTAX', `cannot parse emitted JS: ${path}`); + const edges = []; + function edge(specifier, kind) { + if (!specifier || !ts.isStringLiteral(specifier)) { + refuse('NONLITERAL_IMPORT', `${path} has a nonliteral runtime import`); + } + edges.push({ path: specifier.text, kind }); + } + function visit(node) { + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + if (node.moduleSpecifier) { + edge(node.moduleSpecifier, 'import-statement'); + const specifier = node.moduleSpecifier.text; + if (['vm', 'node:vm', 'worker_threads', 'node:worker_threads'].includes(specifier)) { + refuse('UNKNOWN_LOADER', `${path} imports a code-generation capability`); + } + if (specifier === 'module' || specifier === 'node:module') { + const bindings = ts.isImportDeclaration(node) + ? node.importClause?.namedBindings + : node.exportClause; + if ( + !bindings || + !(ts.isNamedImports(bindings) || ts.isNamedExports(bindings)) || + (ts.isImportDeclaration(node) && node.importClause?.name) || + bindings.elements.some( + (item) => !MODULE_SAFE_EXPORTS.has((item.propertyName ?? item.name).text), + ) + ) { + refuse('UNKNOWN_LOADER', `${path} imports an unreviewed module loader capability`); + } + } + } + } + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + if (node.arguments.length !== 1) { + refuse('UNKNOWN_LOADER', `${path} uses unsupported import options`); + } + edge(node.arguments[0], 'dynamic-import'); + // import('node:module') exposes every loader even when renamed/destructured. + if ( + [ + 'module', + 'node:module', + 'vm', + 'node:vm', + 'worker_threads', + 'node:worker_threads', + ].includes(node.arguments[0].text) + ) { + refuse('UNKNOWN_LOADER', `${path} dynamically acquires a module loader capability`); + } + } + if ( + (ts.isIdentifier(node) && LOADER_NAMES.has(node.text)) || + (ts.isElementAccessExpression(node) && + ts.isStringLiteral(node.argumentExpression) && + LOADER_NAMES.has(node.argumentExpression.text)) + ) { + refuse('UNKNOWN_LOADER', `${path} contains an unreviewed runtime loader`); + } + ts.forEachChild(node, visit); + } + visit(source); + return edges; +} + +/** + * @param {{metafile: unknown, manifest: unknown, + * files: Record, + * outputDirectory?: string, entryPoint?: string, + * modeEvidence?: "posix" | "not-observable-windows"}} options + * @returns {{outputs: string[], dependencies: string[], inputs: string[], modeEvidence: string}} + * @throws {BundleClosureError} when an emitted graph invariant fails. + */ +export function checkBundleClosure({ + metafile, + manifest, + files, + outputDirectory = 'dist', + entryPoint = 'src/index.ts', + modeEvidence = 'posix', +}) { + if (!/^[a-zA-Z0-9_-]+$/u.test(outputDirectory)) { + refuse('OUTPUT_PATH', 'outputDirectory must be one plain directory name'); + } + if (!['posix', 'not-observable-windows'].includes(modeEvidence)) { + refuse('MODE_EVIDENCE', 'mode evidence must name its observable platform scope'); + } + sourcePath(entryPoint, 'entryPoint'); + const meta = record(metafile, 'metafile'); + const inputs = record(meta.inputs, 'metafile.inputs'); + const outputs = record(meta.outputs, 'metafile.outputs'); + const dependencies = record(record(manifest, 'manifest').dependencies, 'manifest.dependencies'); + record(files, 'files'); + const declared = new Set(Object.keys(dependencies)); + for (const name of declared) { + if (externalPackage(name) !== name || typeof dependencies[name] !== 'string') { + refuse('DEPENDENCY_DECLARATION', `invalid runtime dependency declaration: ${name}`); + } + } + function assertExternal(specifier) { + const name = externalPackage(specifier); + if (name !== undefined && !declared.has(name)) { + refuse('UNDECLARED_EXTERNAL', `runtime dependency is not declared: ${specifier}`); + } + return name; + } + for (const [path, input] of Object.entries(inputs)) { + sourcePath(path, 'metafile input'); + record(input, `input ${path}`); + bytes(input.bytes, `input ${path}.bytes`); + if (input.format !== 'esm') refuse('INPUT_FORMAT', `expected an ESM input: ${path}`); + // No third-party implementation may silently become a second bundled copy. + if (path.split('/').includes('node_modules')) { + refuse('BUNDLED_DEPENDENCY', `third-party input must remain external: ${path}`); + } + for (const imported of imports(input.imports, `input ${path}`)) { + if (imported.external === true) assertExternal(imported.path); + else if (!Object.hasOwn(inputs, imported.path)) { + refuse('MISSING_INPUT', `input ${path} references absent input ${imported.path}`); + } + } + } + if (!Object.hasOwn(inputs, entryPoint)) refuse('ENTRY_POINT', 'entry input is absent'); + const outputNames = Object.keys(outputs); + if (!outputNames.includes(`${outputDirectory}/index.js`)) { + refuse('ENTRY_POINT', 'index.js output is absent'); + } + const inputOwners = new Map(); + const importedPackages = new Set(); + const graph = new Map(); + let roots = 0; + for (const [path, output] of Object.entries(outputs)) { + outputPath(path, outputDirectory); + record(output, `output ${path}`); + bytes(output.bytes, `output ${path}.bytes`); + strings(output.exports, `output ${path}.exports`); + const contributions = record(output.inputs, `output ${path}.inputs`); + const isEntry = path === `${outputDirectory}/index.js`; + if (output.entryPoint !== undefined) { + sourcePath(output.entryPoint, `output ${path}.entryPoint`); + if (!Object.hasOwn(inputs, output.entryPoint)) { + refuse('ENTRY_POINT', `output ${path} names absent entry input`); + } + if (output.entryPoint === entryPoint) roots += 1; + } + if (isEntry && output.entryPoint !== entryPoint) { + refuse('ENTRY_POINT', 'index.js is not emitted from the configured entry'); + } + for (const [input, contribution] of Object.entries(contributions)) { + if (!Object.hasOwn(inputs, input)) + refuse('MISSING_INPUT', `${path} names absent input ${input}`); + record(contribution, `${path} input ${input}`); + bytes(contribution.bytesInOutput, `${path} input ${input}.bytesInOutput`); + if (contribution.bytesInOutput > 0) { + if (inputOwners.has(input)) { + refuse('DUPLICATE_INPUT', `${input} is independently inlined in more than one output`); + } + inputOwners.set(input, path); + } + } + if (!Object.hasOwn(files, path)) refuse('MISSING_OUTPUT', `emitted output is absent: ${path}`); + const file = record(files[path], `file ${path}`); + if (file.kind !== undefined && file.kind !== 'file') { + refuse('OUTPUT_KIND', `runtime output is not an ordinary file: ${path}`); + } + if (typeof file.contents !== 'string') refuse('OUTPUT_KIND', `cannot read JS output: ${path}`); + if (modeEvidence === 'posix') { + if (file.mode !== (isEntry ? 0o755 : 0o644)) + refuse('OUTPUT_MODE', `unexpected or missing POSIX mode: ${path}`); + } else if (file.mode !== undefined) { + refuse('MODE_EVIDENCE', 'Windows graph evidence cannot assert unobservable POSIX modes'); + } + if (Buffer.byteLength(file.contents, 'utf8') !== output.bytes) { + refuse('OUTPUT_BYTES', `output byte inventory disagrees: ${path}`); + } + if ( + isEntry ? !file.contents.startsWith('#!/usr/bin/env node\n') : file.contents.startsWith('#!') + ) { + refuse('HASHBANG', `only index.js may carry its expected hashbang: ${path}`); + } + const neighbours = new Set(); + function normalizeEdge(imported, metadata) { + let target; + if (metadata && imported.external !== true) { + outputPath(imported.path, outputDirectory); + target = imported.path; + } else if (imported.path.startsWith('./')) { + const name = imported.path.slice(2); + target = `${outputDirectory}/${name}`; + outputPath(target, outputDirectory); + if (metadata) refuse('EXTERNAL_CHUNK', `${path} declares a relative chunk external`); + } else { + const name = assertExternal(imported.path); + if (name !== undefined) importedPackages.add(name); + return `${imported.kind}:external:${imported.path}`; + } + if (!Object.hasOwn(outputs, target)) { + refuse('MISSING_CHUNK', `${path} loads absent chunk ${target}`); + } + neighbours.add(target); + return `${imported.kind}:chunk:${target}`; + } + const declaredEdges = new Set( + imports(output.imports, `output ${path}`).map((item) => normalizeEdge(item, true)), + ); + const actualEdges = new Set( + actualImports(file.contents, path).map((item) => normalizeEdge(item, false)), + ); + if ( + declaredEdges.size !== actualEdges.size || + [...declaredEdges].some((item) => !actualEdges.has(item)) + ) { + refuse('IMPORT_DRIFT', `actual JS imports disagree with metadata: ${path}`); + } + graph.set(path, neighbours); + } + if (roots !== 1) refuse('ENTRY_POINT', 'expected one configured entry output'); + for (const path of Object.keys(files)) { + if (/\.(?:js|mjs|cjs)$/iu.test(path) && !Object.hasOwn(outputs, path)) { + refuse('EXTRA_OUTPUT', `JS file is absent from build metadata: ${path}`); + } + } + const reached = new Set(); + const pending = [`${outputDirectory}/index.js`]; + while (pending.length > 0) { + const path = pending.pop(); + if (reached.has(path)) continue; + reached.add(path); + pending.push(...graph.get(path)); + } + if (reached.size !== outputNames.length) { + refuse('UNREFERENCED_OUTPUT', 'build inventory contains unreachable runtime chunks'); + } + for (const name of declared) { + if (!importedPackages.has(name)) + refuse('DEAD_DEPENDENCY', `declared dependency is not loaded: ${name}`); + } + return { + modeEvidence, + outputs: outputNames.sort(), + dependencies: [...importedPackages].sort(), + inputs: Object.keys(inputs).sort(), + }; +} diff --git a/tools/bundle-closure/closure.test.mjs b/tools/bundle-closure/closure.test.mjs new file mode 100644 index 00000000..9c96179e --- /dev/null +++ b/tools/bundle-closure/closure.test.mjs @@ -0,0 +1,492 @@ +import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + copyFileSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join, relative, sep } from 'node:path'; +import { after, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { BundleClosureError, checkBundleClosure } from './closure.mjs'; + +// Use the exact esbuild installed behind the existing CLI tsup tool, not a new +// dependency, a copied metadata generator, or the product build. Everything is +// emitted in memory from tiny temporary source fixtures; no native/service load. +const cliRequire = createRequire(new URL('../../apps/cli/package.json', import.meta.url)); +const { build } = createRequire(cliRequire.resolve('tsup/package.json'))('esbuild'); +const scratch = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-emitted-closure-'))); +after(() => rmSync(scratch, { recursive: true, force: true })); +mkdirSync(join(scratch, 'src')); +writeFileSync( + join(scratch, 'src/index.ts'), + '#!/usr/bin/env node\nimport {program} from "commander";\n' + + 'import {authority} from "./authority.js";\n' + + 'console.log(program, authority, "é", "import(\\"fake\\")");\n' + + 'export const run = () => import("./runtime.js");\n', +); +writeFileSync(join(scratch, 'src/authority.ts'), 'export const authority = new WeakMap();\n'); +writeFileSync( + join(scratch, 'src/runtime.ts'), + 'import {authority} from "./authority.js";\n' + + 'import {readFile} from "node:fs/promises";\n' + + 'export {Client} from "@modelcontextprotocol/sdk/client/index.js";\n' + + 'export const execute = () => [authority, readFile, import("./tail.js")];\n', +); +writeFileSync( + join(scratch, 'src/tail.ts'), + 'export const sdk = () => import("@modelcontextprotocol/sdk/client/stdio.js");\n', +); + +async function fixture(splitting = true) { + const built = await build({ + absWorkingDir: scratch, + entryPoints: { index: 'src/index.ts' }, + outdir: 'dist', + format: 'esm', + platform: 'node', + target: 'node22', + bundle: true, + splitting, + minify: true, + metafile: true, + write: false, + external: ['commander', '@modelcontextprotocol/sdk', '@modelcontextprotocol/sdk/*'], + }); + const files = {}; + for (const output of built.outputFiles) { + const path = relative(scratch, output.path).split(sep).join('/'); + files[path] = { + contents: output.text, + mode: path === 'dist/index.js' ? 0o755 : 0o644, + kind: 'file', + }; + } + return { + metafile: built.metafile, + manifest: { dependencies: { commander: '1', '@modelcontextprotocol/sdk': '1' } }, + files, + }; +} +const original = await fixture(); +function candidate() { + return JSON.parse(JSON.stringify(original)); +} +function lazyFile(value) { + return Object.keys(value.metafile.outputs).find((path) => + value.metafile.outputs[path].entryPoint?.endsWith('/runtime.ts'), + ); +} +function setContents(value, path, contents) { + value.files[path].contents = contents; + value.metafile.outputs[path].bytes = Buffer.byteLength(contents); +} +function append(value, contents, path = lazyFile(value)) { + setContents(value, path, `${value.files[path].contents}\n${contents}\n`); +} +function refuses(value, code) { + assert.throws( + () => checkBundleClosure(value), + (error) => error instanceof BundleClosureError && error.code === code, + `expected ${code}`, + ); +} + +test('actual minified split esbuild graph retains shared authority and SDK subpaths', () => { + const report = checkBundleClosure(candidate()); + assert.ok(report.outputs.length >= 4, 'must exercise entry, lazy runtime/tail, shared chunk'); + assert.equal(report.inputs.length, 4); + assert.deepEqual(report.dependencies, ['@modelcontextprotocol/sdk', 'commander']); + const owners = Object.values(original.metafile.outputs).filter( + (output) => output.inputs['src/authority.ts']?.bytesInOutput > 0, + ); + assert.equal(owners.length, 1, 'shared private state is hoisted exactly once by actual esbuild'); +}); + +test('also accepts one-output ESM build during the migration', async () => { + const value = await fixture(false); + assert.equal(checkBundleClosure(value).outputs.length, 1); +}); + +test('literal/comment/regex/template import-looking text and hashbang text are data', () => { + const value = candidate(); + append( + value, + 'const data = ["require(\\"nope\\")", "#!", /import("nope")/, `import("nope")`];\n' + + '/* import("nope"); require("nope"); */ console.log(data);', + ); + checkBundleClosure(value); +}); + +test('removing an emitted lazy chunk refuses even with complete build metadata', () => { + const value = candidate(); + delete value.files[lazyFile(value)]; + refuses(value, 'MISSING_OUTPUT'); +}); + +test('removing a chunk from both inventories still refuses its loading edge', () => { + const value = candidate(); + const path = lazyFile(value); + delete value.files[path]; + delete value.metafile.outputs[path]; + refuses(value, 'MISSING_CHUNK'); +}); + +for (const path of ['dist/stale.js', 'dist/nested/hidden.js', 'dist/extra.mjs', 'dist/extra.cjs']) { + test(`extra on-disk JavaScript is refused: ${path}`, () => { + const value = candidate(); + value.files[path] = { contents: 'export {};', mode: 0o644 }; + refuses(value, 'EXTRA_OUTPUT'); + }); +} + +test('an unreferenced output present in both inventories is refused', () => { + const value = candidate(); + value.files['dist/orphan.js'] = { contents: 'export {};', mode: 0o644 }; + value.metafile.outputs['dist/orphan.js'] = { + imports: [], + exports: [], + inputs: {}, + bytes: Buffer.byteLength('export {};'), + }; + refuses(value, 'UNREFERENCED_OUTPUT'); +}); + +for (const path of [ + 'dist/nested/a.js', + '../evil.js', + '/tmp/evil.js', + 'dist/../evil.js', + 'dist/a.mjs', +]) { + test(`metadata output must be flat, relative JS: ${path}`, () => { + const value = candidate(); + value.metafile.outputs[path] = { imports: [], exports: [], inputs: {}, bytes: 0 }; + refuses(value, 'OUTPUT_PATH'); + }); +} + +for (const specifier of [ + '../outside.js', + '/tmp/outside.js', + 'file:///tmp/a.js', + './nested/a.js', + './a.js?x', + './%2e%2e/a.js', +]) { + test(`actual lazy loading rejects escaping/unsupported path ${specifier}`, () => { + const value = candidate(); + append(value, `void import(${JSON.stringify(specifier)});`); + assert.throws(() => checkBundleClosure(value), BundleClosureError); + }); +} + +test('a relative external cannot disguise an unbundled chunk', () => { + const value = candidate(); + value.metafile.outputs['dist/index.js'].imports.push({ + path: './outside.js', + kind: 'dynamic-import', + external: true, + }); + refuses(value, 'EXTERNAL_CHUNK'); +}); + +for (const specifier of [ + 'unlisted', + '@other/sdk/client.js', + '@relavium/core', + '@relavium/db/subpath', +]) { + test(`actual lazy imports independently enforce declarations/private engine: ${specifier}`, () => { + const value = candidate(); + append(value, `void import(${JSON.stringify(specifier)});`); + refuses(value, specifier.startsWith('@relavium/') ? 'PRIVATE_EXTERNAL' : 'UNDECLARED_EXTERNAL'); + }); +} + +test('metadata-only undeclared lazy external cannot hide behind changed JS', () => { + const value = candidate(); + value.metafile.outputs[lazyFile(value)].imports.push({ + path: 'unlisted', + kind: 'dynamic-import', + external: true, + }); + refuses(value, 'UNDECLARED_EXTERNAL'); +}); + +test('a dead manifest declaration is refused even if a tree-shaken input mentions it', () => { + const value = candidate(); + value.manifest.dependencies.stale = '1'; + value.metafile.inputs['src/tail.ts'].imports.push({ + path: 'stale', + kind: 'dynamic-import', + external: true, + }); + refuses(value, 'DEAD_DEPENDENCY'); +}); + +for (const specifier of ['node:not-a-builtin', 'fs/not-a-builtin', 'node:fs/not-a-builtin']) { + test(`builtin authority uses the full specifier: ${specifier}`, () => { + const value = candidate(); + append(value, `void import(${JSON.stringify(specifier)});`); + refuses(value, 'EXTERNAL_PATH'); + }); +} + +test('actual reexports are loading edges, independently of metadata', () => { + const value = candidate(); + append(value, 'export * from "node:crypto";'); + refuses(value, 'IMPORT_DRIFT'); +}); + +test('a metadata import absent from JavaScript is refused', () => { + const value = candidate(); + value.metafile.outputs[lazyFile(value)].imports.push({ + path: 'node:os', + kind: 'import-statement', + external: true, + }); + refuses(value, 'IMPORT_DRIFT'); +}); + +test('removing the metadata edge alone also refuses', () => { + const value = candidate(); + value.metafile.outputs['dist/index.js'].imports.pop(); + refuses(value, 'IMPORT_DRIFT'); +}); + +for (const expression of [ + 'process.argv[2]', + '"./" + name', + 'new URL("./tail.js", import.meta.url)', + '`./tail.js`', +]) { + test(`nonliteral dynamic import refuses: ${expression}`, () => { + const value = candidate(); + append(value, `void import(${expression});`); + refuses(value, 'NONLITERAL_IMPORT'); + }); +} + +test('import options require an explicit future review', () => { + const value = candidate(); + append(value, 'void import("commander", {with: {type: "json"}});'); + refuses(value, 'UNKNOWN_LOADER'); +}); + +for (const loader of [ + 'require("commander")', + '__require("commander")', + 'module.require("commander")', + 'globalThis["require"]("commander")', + '(0, eval)("import(\\"hidden\\")")', + 'new Function("return import(\\"hidden\\")")', + 'process.getBuiltinModule("module")', + 'process.dlopen({}, "/tmp/addon.node")', + 'import {Worker as W} from "node:worker_threads"; new W("./hidden.js")', + 'new Worker("./hidden.js")', + 'import {createRequire as r} from "node:module"; r(import.meta.url)("hidden")', + 'import Module from "module"; Module._load("hidden")', + 'import * as loader from "node:module"', + 'import {register as r} from "node:module"', + 'void import("node:module")', + 'import {runInNewContext as r} from "node:vm"', +]) { + test(`unreviewed loading capability refuses: ${loader}`, () => { + const value = candidate(); + append(value, `${loader};`); + refuses(value, 'UNKNOWN_LOADER'); + }); +} + +for (const kind of ['require-call', 'require-resolve', 'file-loader', 'new-kind']) { + test(`unknown metadata loader kind refuses in every input and output: ${kind}`, () => { + for (const metadata of [true, false]) { + const value = candidate(); + const imports = metadata + ? value.metafile.inputs['src/tail.ts'].imports + : value.metafile.outputs[lazyFile(value)].imports; + imports.push({ path: 'commander', kind, external: true }); + refuses(value, 'LOADER_KIND'); + } + }); +} + +test('malformed JavaScript cannot be certified by valid metadata', () => { + const value = candidate(); + append(value, 'export const broken = ;'); + refuses(value, 'JS_SYNTAX'); +}); + +test('missing input references and absent contributed input refuse', () => { + const value = candidate(); + value.metafile.inputs['src/tail.ts'].imports.push({ + path: 'src/absent.ts', + kind: 'import-statement', + }); + refuses(value, 'MISSING_INPUT'); + const other = candidate(); + other.metafile.outputs[lazyFile(other)].inputs['src/absent.ts'] = { bytesInOutput: 1 }; + refuses(other, 'MISSING_INPUT'); +}); + +test('one authority input cannot be independently copied into another output', () => { + const value = candidate(); + value.metafile.outputs['dist/index.js'].inputs['src/authority.ts'] = { bytesInOutput: 1 }; + refuses(value, 'DUPLICATE_INPUT'); +}); + +test('source path aliases cannot claim an independently bundled authority', () => { + const value = candidate(); + value.metafile.inputs['src/other/../authority.ts'] = JSON.parse( + JSON.stringify(value.metafile.inputs['src/authority.ts']), + ); + refuses(value, 'INPUT_PATH'); +}); + +test('third-party implementations and CommonJS inputs are refused', () => { + const value = candidate(); + value.metafile.inputs['node_modules/commander/index.js'] = { + format: 'esm', + bytes: 1, + imports: [], + }; + refuses(value, 'BUNDLED_DEPENDENCY'); + const other = candidate(); + other.metafile.inputs['src/authority.ts'].format = 'cjs'; + refuses(other, 'INPUT_FORMAT'); +}); + +test('entry inventory requires exactly the configured entry', () => { + const value = candidate(); + value.metafile.outputs['dist/index.js'].entryPoint = 'src/runtime.ts'; + refuses(value, 'ENTRY_POINT'); + const other = candidate(); + other.metafile.outputs[lazyFile(other)].entryPoint = 'src/index.ts'; + refuses(other, 'ENTRY_POINT'); +}); + +test('JS byte drift, unexpected permissions and hashbang drift each refuse', () => { + const byteDrift = candidate(); + byteDrift.files[lazyFile(byteDrift)].contents += ' '; + refuses(byteDrift, 'OUTPUT_BYTES'); + for (const entry of [true, false]) { + const value = candidate(); + const path = entry ? 'dist/index.js' : lazyFile(value); + value.files[path].mode = entry ? 0o644 : 0o755; + refuses(value, 'OUTPUT_MODE'); + const other = candidate(); + setContents( + other, + path, + entry + ? other.files[path].contents.replace('#!', '//') + : '#!/usr/bin/env node\n' + other.files[path].contents, + ); + refuses(other, 'HASHBANG'); + } +}); + +test('a symbolic link cannot satisfy an emitted runtime file', () => { + const value = candidate(); + value.files[lazyFile(value)].kind = 'symlink'; + refuses(value, 'OUTPUT_KIND'); +}); + +for (const mutate of [ + (value) => { + value.metafile.inputs = []; + }, + (value) => { + value.metafile.outputs = null; + }, + (value) => { + value.manifest.dependencies = null; + }, + (value) => { + value.metafile.inputs['src/index.ts'].bytes = -1; + }, + (value) => { + value.metafile.inputs['src/index.ts'].imports = null; + }, + (value) => { + value.metafile.outputs['dist/index.js'].exports = [7]; + }, + (value) => { + value.metafile.outputs['dist/index.js'].inputs = null; + }, +]) { + test('malformed independent inventory shape refuses', () => { + const value = candidate(); + mutate(value); + refuses(value, 'METADATA_SHAPE'); + }); +} + +test('thin CLI inventories actual files from its package root and refuses missing/extra/symlink chunks', () => { + const root = mkdtempSync(join(scratch, 'adapter-')); + const tooling = join(root, 'tools/bundle-closure'); + const pkg = join(root, 'apps/cli'); + mkdirSync(tooling, { recursive: true }); + mkdirSync(join(pkg, 'dist'), { recursive: true }); + symlinkSync( + fileURLToPath(new URL('../../node_modules', import.meta.url)), + join(root, 'node_modules'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + for (const name of ['check.mjs', 'closure.mjs']) { + copyFileSync(fileURLToPath(new URL(name, import.meta.url)), join(tooling, name)); + } + writeFileSync(join(pkg, 'package.json'), JSON.stringify(original.manifest)); + writeFileSync(join(pkg, 'dist/metafile-esm.json'), JSON.stringify(original.metafile)); + for (const [path, file] of Object.entries(original.files)) { + writeFileSync(join(pkg, path), file.contents); + chmodSync(join(pkg, path), file.mode); + } + function run() { + const result = spawnSync(process.execPath, [join(tooling, 'check.mjs')], { + cwd: tmpdir(), + encoding: 'utf8', + timeout: 10_000, + }); + assert.equal(result.error, undefined); + return result; + } + assert.equal(run().status, 0); + writeFileSync(join(pkg, 'dist/extra.js'), 'export {};'); + assert.match(run().stderr, /EXTRA_OUTPUT/u); + rmSync(join(pkg, 'dist/extra.js')); + const lazy = join(pkg, lazyFile(original)); + rmSync(lazy); + assert.match(run().stderr, /MISSING_OUTPUT/u); + symlinkSync(join(pkg, 'dist/index.js'), lazy); + assert.match(run().stderr, /OUTPUT_KIND/u); + rmSync(lazy); + writeFileSync(lazy, original.files[lazyFile(original)].contents); + chmodSync(lazy, 0o644); + const linkedDirectory = join(pkg, 'dist/linked'); + symlinkSync(dirname(lazy), linkedDirectory, process.platform === 'win32' ? 'junction' : 'dir'); + assert.match(run().stderr, /OUTPUT_KIND/u); +}); + +test('POSIX modes require actual evidence; Windows graph evidence is explicitly narrower', () => { + const value = candidate(); + delete value.files['dist/index.js'].mode; + refuses(value, 'OUTPUT_MODE'); + const windows = candidate(); + windows.modeEvidence = 'not-observable-windows'; + refuses(windows, 'MODE_EVIDENCE'); + for (const file of Object.values(windows.files)) delete file.mode; + assert.equal(checkBundleClosure(windows).modeEvidence, 'not-observable-windows'); + const missing = candidate(); + missing.modeEvidence = 'unverified'; + refuses(missing, 'MODE_EVIDENCE'); +}); diff --git a/tools/cli-bootstrap/README.md b/tools/cli-bootstrap/README.md new file mode 100644 index 00000000..594bb689 --- /dev/null +++ b/tools/cli-bootstrap/README.md @@ -0,0 +1,43 @@ +# Compiled CLI bootstrap controls + +`node tools/cli-bootstrap/check.mjs` consumes the existing untouched +`apps/cli/dist/index.js` and its actual `run-*` / `dispatch-*` runtime chunks. +Build ownership remains with the caller; this control never builds or rewrites +the artifact. Its JSON result includes the entry digest, runtime/platform, +scenario results and deduplicated native import/path/filesystem observations. +Temporary application paths are represented as ``. + +`node --test tools/cli-bootstrap/witness.test.mjs` proves the witnesses reject +swallowed ESM/CommonJS import failures, application FS access, native service +dispatch, missing observations and genuine dynamic chunk loading. It also checks +timeout/output enforcement and joining the owned child. + +The inventory covers existing human help/version/bare/subtree grammar, with and +without `--json`, and syntactically valid absent absolute filesystem roots. +Explicit cwd/config selectors accompany the grant cases. Syntax failures retain +exit 2 and bounded-root values remain absent from diagnostics. Unknown invocation +compatibility is retained. The current artifact supplies the human-output byte +comparison; this is not a measurement of a previous revision. + +The native `--import` preload installs Node 22.0-compatible `module.register` +hooks before importing the entry. It records and refuses native/provider/MCP/TUI/ +schema/config-parser imports, protects poisoned temporary HOME/workspace/grant +paths, and refuses network/subprocess dispatch. Node's module/package loader FS +reads outside those application roots remain allowed and recorded. The main +witness writer retains an open descriptor and primitive write function, avoiding +the patched FS wrappers. Native `realpath[Sync].native` is preserved and guarded. + +Positive controls execute the real compiled chat grant-admission path and refuse +real deferred entry/dispatcher chunk loads. Import failures must reach the fixed +diagnostics without the arbitrary synthetic error canary or stack. Their current +fallback exit 1 is explicit; Step 8 owns native ABI classification and exit 9. + +Each scenario uses a fresh sanitized child environment and a finite deadline, +with a combined UTF-8 output-byte cap. POSIX children own a process group which +is killed and joined; product subprocess dispatch is forbidden on every OS. +Fixture snapshots prove poisoned application files/directories remain unchanged, +including on passing controls. Cleanup removes only the exact owned mkdtemp root. + +These controls establish repository-compiled behavior on the runtime/OS that +actually ran them. They do not certify installed tarballs, Node's minimum floor, +other platforms, complete Step 7, native owner integration or archival custody. diff --git a/tools/cli-bootstrap/check.mjs b/tools/cli-bootstrap/check.mjs new file mode 100644 index 00000000..99edf0ba --- /dev/null +++ b/tools/cli-bootstrap/check.mjs @@ -0,0 +1,172 @@ +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { performance } from 'node:perf_hooks'; +import { createHash } from 'node:crypto'; +import { withFixture, toEntryUrl } from './child.mjs'; +import { metaCases, grammarCases } from './inventory.mjs'; +import { assertWitness, importFailureCanary } from './policy.mjs'; + +const repository = fileURLToPath(new URL('../../', import.meta.url)); +const dist = join(repository, 'apps/cli/dist'); +const entry = join(dist, 'index.js'); +function artifactInventory() { + return readdirSync(dist) + .filter((name) => name.endsWith('.js')) + .sort() + .map((name) => ({ + name, + sha256: createHash('sha256') + .update(readFileSync(join(dist, name))) + .digest('hex'), + })); +} +const artifact = artifactInventory(); +const version = JSON.parse(readFileSync(join(repository, 'apps/cli/package.json'), 'utf8')).version; +function runtimeChunk(prefix) { + const files = readdirSync(dist).filter( + (name) => name.startsWith(`${prefix}-`) && name.endsWith('.js'), + ); + assert.equal(files.length, 1, `one real ${prefix} runtime chunk is required`); + return toEntryUrl(resolve(dist, files[0])); +} +const runtimeUrl = runtimeChunk('run'); +const dispatchUrl = runtimeChunk('dispatch'); +const records = []; +const observed = new Map(); +function retainEvents(events, fixture) { + for (const event of events) { + const normalized = JSON.parse( + JSON.stringify(event).replaceAll(fixture.root, ''), + ); + observed.set(JSON.stringify(normalized), normalized); + } +} + +await withFixture(async (fixture) => { + for (const [name, args] of metaCases) { + let referenceOutput; + for (const json of [false, true]) + for (const rootGrant of [false, true]) { + const globals = [ + ...(json ? ['--json'] : []), + ...(rootGrant + ? [ + '--allow-fs-root', + fixture.absentRoot, + '--cwd', + fixture.workspace, + '--config', + join(fixture.home, '.relavium', 'config.toml'), + ] + : []), + ]; + const started = performance.now(); + const result = await fixture.child([entry, ...globals, ...args]); + retainEvents(result.events, fixture); + assertWitness(result.events, toEntryUrl(entry)); + assert.equal(result.code, 0, `${name}: ${result.stderr}`); + assert.equal(result.stderr, '', name); + if (name.includes('version')) assert.equal(result.stdout, `${version}\n`, name); + else assert.match(result.stdout, /^Usage: relavium/, name); + // Same current artifact/runtime supplies the byte comparison; no historical timing threshold. + referenceOutput ??= result.stdout; + assert.equal( + result.stdout, + referenceOutput, + `${name}: global flags changed existing human meta bytes`, + ); + records.push({ + name, + json, + absentAbsoluteRoot: rootGrant, + exitCode: result.code, + imports: result.events.filter((event) => event.kind === 'load').length, + filesystemCalls: result.events.filter((event) => event.kind === 'fs').length, + milliseconds: Math.round(performance.now() - started), + }); + } + } + for (const [name, args, nonEcho] of grammarCases(fixture.absentRoot)) { + for (const json of [false, true]) { + const result = await fixture.child([entry, ...(json ? ['--json'] : []), ...args]); + retainEvents(result.events, fixture); + assertWitness(result.events, toEntryUrl(entry)); + assert.equal(result.code, 2, `${name}: ${result.stderr}`); + assert.equal(result.stdout, '', name); + assert.notEqual(result.stderr, '', name); + if (nonEcho !== undefined) + assert.ok(!result.stderr.includes(nonEcho), `${name}: unsafe argument echoed`); + records.push({ name, json, exitCode: result.code }); + } + } + + // An actual execution reaches native grant admission. A swallowed refusal still leaves + // the witness, and a service import ahead of that native check fails this ordering proof. + const admission = await fixture.child([entry, 'chat', '--allow-fs-root', fixture.workspace]); + retainEvents(admission.events, fixture); + assert.notEqual(admission.code, 0); + const firstDenied = admission.events.find((event) => event.denied); + assert.equal( + firstDenied?.kind, + 'fs', + 'real execution did not reach filesystem admission before services', + ); + assert.ok(firstDenied.paths.some((path) => path === fixture.workspace)); + records.push({ + name: 'real-execution-fs-before-service', + exitCode: admission.code, + witness: firstDenied.method, + }); + + // These refuse real emitted chunks, without rewriting the compiled entry or implementation. + for (const [name, failRuntimeUrl, args, diagnostic] of [ + [ + 'entry-runtime-load-failure', + runtimeUrl, + ['run', 'synthetic-absent.yaml'], + 'relavium: a fatal internal error occurred.\n', + ], + [ + 'dispatcher-load-failure', + dispatchUrl, + ['provider', 'list'], + 'The CLI runtime could not be loaded.', + ], + ]) { + const result = await fixture.child([entry, ...args], { failRuntimeUrl }); + retainEvents(result.events, fixture); + assert.ok( + result.events.some( + (event) => event.kind === 'load' && event.url === failRuntimeUrl && event.runtimeFailure, + ), + ); + assert.equal(result.code, 1, name); // Native ABI classification/exit 9 is Step 8. + assert.equal(result.stdout, '', name); + assert.ok(result.stderr.includes(diagnostic), name); + assert.ok(!result.stderr.includes(importFailureCanary), name); + assert.doesNotMatch(result.stderr, /\n\s+at |file:\/\/|Error: synthetic/, name); + assert.equal( + result.events.filter((event) => event.denied && !event.runtimeFailure).length, + 0, + name, + ); + records.push({ name, exitCode: result.code, refusedUrl: failRuntimeUrl }); + } +}); +assert.deepEqual(artifactInventory(), artifact, 'compiled graph changed during bootstrap controls'); +console.log( + JSON.stringify({ + kind: 'compiled-cli-bootstrap', + node: process.version, + platform: process.platform, + entrySha256: createHash('sha256').update(readFileSync(entry)).digest('hex'), + artifact, + scope: + 'untouched repository dist on current runtime/OS; no installed-tarball or minimum-floor claim', + controls: records.length, + records, + observed: [...observed.values()], + }), +); diff --git a/tools/cli-bootstrap/child.mjs b/tools/cli-bootstrap/child.mjs new file mode 100644 index 00000000..5f8874d0 --- /dev/null +++ b/tools/cli-bootstrap/child.mjs @@ -0,0 +1,119 @@ +import assert from 'node:assert/strict'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + readdirSync, + lstatSync, + realpathSync, + rmSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, relative } from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; +import { spawnBounded } from './process.mjs'; +import { maxWitnessBytes } from './policy.mjs'; + +function snapshot(root) { + const entries = []; + function visit(directory) { + for (const name of readdirSync(directory).sort()) { + const path = join(directory, name); + const info = lstatSync(path); + entries.push([ + relative(root, path), + info.mode, + info.isFile() ? createHash('sha256').update(readFileSync(path)).digest('hex') : 'directory', + ]); + if (info.isDirectory()) visit(path); + } + } + visit(root); + return entries; +} + +/** Each child receives a synthetic HOME/cwd, no ambient credentials or Node preload. */ +export async function withFixture(run) { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-bootstrap-'))); + try { + const home = join(root, 'home'), + workspace = join(root, 'workspace'); + for (const dir of [join(home, '.relavium', 'catalog'), join(workspace, '.relavium')]) + mkdirSync(dir, { recursive: true }); + const poison = 'synthetic-bootstrap-file-canary-5631\n[[invalid TOML JSON YAML'; + for (const path of [ + join(home, '.relavium', 'config.toml'), + join(home, '.relavium', 'catalog', 'models-dev.json'), + join(workspace, '.relavium', 'config.toml'), + join(workspace, 'relavium.toml'), + join(workspace, '.relavium.yaml'), + ]) + writeFileSync(path, poison); + const absentRoot = join(root, 'absent-grant'); + const protectedRoots = [home, workspace, absentRoot]; + const before = protectedRoots.slice(0, 2).map(snapshot); + const fixture = { + root, + home, + workspace, + absentRoot, + async child(args, { failRuntimeUrl, debug = false, bounds } = {}) { + const witness = join(root, `witness-${fixture.count++}.ndjson`); + writeFileSync(witness, ''); + const env = { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: home, + XDG_CACHE_HOME: home, + NO_COLOR: '1', + RELAVIUM_BOOTSTRAP_WITNESS_OPTIONS: JSON.stringify({ + witness, + protectedRoots, + failRuntimeUrl, + }), + }; + for (const key of ['PATH', 'SystemRoot', 'WINDIR', 'TEMP', 'TMP']) + if (process.env[key] !== undefined) env[key] = process.env[key]; + if (debug) env.RELAVIUM_DEBUG = '1'; + const result = await spawnBounded( + ['--import', fileURLToPath(new URL('./preload.mjs', import.meta.url)), ...args], + { cwd: workspace, env }, + bounds, + ); + assert.equal(result.joined, true); + assert.equal(result.timedOut, false, 'bootstrap child timed out'); + assert.equal(result.outputExceeded, false, 'bootstrap child exceeded output bound'); + assert.equal(result.spawnError, undefined); + const body = readFileSync(witness); + assert.ok(body.length <= maxWitnessBytes, 'bootstrap witness exceeded bound'); + const events = body + .toString('utf8') + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); + return { ...result, events }; + }, + count: 0, + }; + const result = await run(fixture); + assert.deepEqual( + protectedRoots.slice(0, 2).map(snapshot), + before, + 'application files or directories changed', + ); + assert.equal( + readdirSync(root).includes('absent-grant'), + false, + 'meta path minted a grant directory', + ); + return result; + } finally { + // This exact mkdtemp-owned directory is the sole cleanup target, including failing controls. + rmSync(root, { recursive: true, force: true }); + } +} + +export const toEntryUrl = (entry) => pathToFileURL(entry).href; diff --git a/tools/cli-bootstrap/inventory.mjs b/tools/cli-bootstrap/inventory.mjs new file mode 100644 index 00000000..31c912f5 --- /dev/null +++ b/tools/cli-bootstrap/inventory.mjs @@ -0,0 +1,68 @@ +/** Existing human meta grammar only. JSON help remains human until Wave 3 Step 10. */ +export const metaCases = Object.freeze([ + ['root-help', ['--help']], + ['root-short-help', ['-h']], + ['root-version', ['--version']], + ['root-short-version', ['-V']], + ['bare-pipe', []], + ['bare-terminator', ['--']], + ...[ + ['run'], + ['chat'], + ['chat', 'resume'], + ['chat', 'list'], + ['chat', 'export'], + ['chat-resume'], + ['chat-list'], + ['chat-export'], + ['create'], + ['export'], + ['import'], + ['agent'], + ['agent', 'run'], + ['gate'], + ['gate', 'list'], + ['budget'], + ['budget', 'resume'], + ['models'], + ['models', 'refresh'], + ['models', 'pricing'], + ['provider'], + ['provider', 'list'], + ['provider', 'add'], + ['provider', 'set-key'], + ['provider', 'remove-key'], + ['provider', 'test'], + ['media'], + ['media', 'prune-temporaries'], + ['list'], + ['logs'], + ['status'], + ['init'], + ].map((path) => [`${path.join('-')}-help`, [...path, '--help']]), + ['help-command-subtree', ['help', 'provider']], + ['help-command-root', ['help']], +]); + +export function grammarCases(absentRoot) { + return [ + [ + 'relative-root', + ['--help', '--allow-fs-root', 'synthetic-relative-canary-1157'], + 'synthetic-relative-canary-1157', + ], + [ + 'oversize-root', + ['--help', '--allow-fs-root', `${absentRoot}/${'s'.repeat(4097)}`], + 's'.repeat(100), + ], + ['missing-root-value', ['--help', '--allow-fs-root'], undefined], + [ + 'root-count', + ['--help', ...Array.from({ length: 17 }, () => ['--allow-fs-root', absentRoot]).flat()], + absentRoot, + ], + ['unknown-option', ['--synthetic-unknown-bootstrap-option'], undefined], + ['unknown-command', ['synthetic-unknown-bootstrap-command'], undefined], + ]; +} diff --git a/tools/cli-bootstrap/loader.mjs b/tools/cli-bootstrap/loader.mjs new file mode 100644 index 00000000..43fd4631 --- /dev/null +++ b/tools/cli-bootstrap/loader.mjs @@ -0,0 +1,29 @@ +import { appendFileSync } from 'node:fs'; +import { Buffer } from 'node:buffer'; +import { isServiceSpecifier, importFailureCanary } from './policy.mjs'; + +let options; +let bytes = 0; +function record(event) { + const line = `${JSON.stringify(event)}\n`; + bytes += Buffer.byteLength(line); + if (bytes > 1024 * 1024) throw new Error('bootstrap loader witness exceeded limit'); + appendFileSync(options.witness, line); +} +export function initialize(data) { + options = data; +} + +export async function resolve(specifier, context, nextResolve) { + const denied = isServiceSpecifier(specifier); + record({ kind: 'resolve', specifier, parent: context.parentURL ?? null, denied }); + if (denied) throw new Error('synthetic bootstrap service import refused'); + return await nextResolve(specifier, context); +} + +export async function load(url, context, nextLoad) { + const denied = options.failRuntimeUrl === url; + record({ kind: 'load', url, denied, runtimeFailure: denied }); + if (denied) throw new Error(importFailureCanary); + return await nextLoad(url, context); +} diff --git a/tools/cli-bootstrap/policy.mjs b/tools/cli-bootstrap/policy.mjs new file mode 100644 index 00000000..9eb661af --- /dev/null +++ b/tools/cli-bootstrap/policy.mjs @@ -0,0 +1,37 @@ +/** External packages whose evaluation is outside the compiled meta-operation graph. */ +export const servicePackages = Object.freeze([ + 'better-sqlite3', + '@napi-rs/keyring', + '@anthropic-ai/sdk', + 'openai', + '@google/genai', + '@modelcontextprotocol/sdk', + 'drizzle-orm', + 'ink', + 'react', + '@clack/prompts', + 'yaml', + 'smol-toml', + 'zod', + 'quickjs-emscripten-core', + '@jitl/quickjs-singlefile-mjs-release-sync', +]); + +export function isServiceSpecifier(specifier) { + return servicePackages.some((name) => specifier === name || specifier.startsWith(`${name}/`)); +} + +export const importFailureCanary = 'synthetic-bootstrap-import-canary-9173'; +export const maxWitnessBytes = 2 * 1024 * 1024; + +export function assertWitness(witness, entryUrl) { + if (!witness.some((event) => event.kind === 'preload-ready')) { + throw new Error('bootstrap preload did not initialize'); + } + if (!witness.some((event) => event.kind === 'load' && event.url === entryUrl)) { + throw new Error('untouched compiled entry was not loaded'); + } + const forbidden = witness.filter((event) => event.denied === true); + if (forbidden.length !== 0) + throw new Error(`bootstrap crossed ${forbidden.length} guarded boundaries`); +} diff --git a/tools/cli-bootstrap/preload.mjs b/tools/cli-bootstrap/preload.mjs new file mode 100644 index 00000000..d6c67cf9 --- /dev/null +++ b/tools/cli-bootstrap/preload.mjs @@ -0,0 +1,146 @@ +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import Module, { register, syncBuiltinESMExports } from 'node:module'; +import { resolve, relative, isAbsolute, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Buffer } from 'node:buffer'; +import net from 'node:net'; +import tls from 'node:tls'; +import http from 'node:http'; +import https from 'node:https'; +import http2 from 'node:http2'; +import dns from 'node:dns'; +import dnsPromises from 'node:dns/promises'; +import dgram from 'node:dgram'; +import childProcess from 'node:child_process'; +import { isServiceSpecifier } from './policy.mjs'; + +const options = JSON.parse(process.env.RELAVIUM_BOOTSTRAP_WITNESS_OPTIONS); +// appendFileSync internally calls mutable fs.writeFileSync, so retain an already-open +// descriptor and the primitive writer instead of recursing through a patched API. +const witnessFd = fs.openSync(options.witness, 'a'); +const savedWrite = fs.writeSync; +const savedClose = fs.closeSync; +process.once('exit', () => savedClose(witnessFd)); +let bytes = 0; +function record(event) { + const line = `${JSON.stringify(event)}\n`; + bytes += Buffer.byteLength(line); + if (bytes > 1024 * 1024) throw new Error('bootstrap filesystem witness exceeded limit'); + savedWrite(witnessFd, line); +} +// Node 22.0 supports this async loader API; registerHooks is deliberately not used. +register(new URL('./loader.mjs', import.meta.url), { data: options }); + +function pathValue(value) { + if (value instanceof URL && value.protocol === 'file:') return fileURLToPath(value); + if (Buffer.isBuffer(value)) return value.toString(); + return typeof value === 'string' ? resolve(value) : null; +} +function inside(root, candidate) { + const suffix = relative(root, candidate); + return ( + suffix === '' || (suffix !== '..' && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix)) + ); +} +function guard(method, args) { + const paths = args + .slice(0, method.startsWith('rename') || method.startsWith('copyFile') ? 2 : 1) + .map(pathValue) + .filter((path) => path !== null); + const denied = paths.some((path) => options.protectedRoots.some((root) => inside(root, path))); + record({ kind: 'fs', method, paths, denied }); + if (denied) throw new Error('synthetic bootstrap application filesystem access refused'); +} +// Only protected application paths are refused. Loader/module/package reads elsewhere are +// recorded and allowed; this does not mistake Node's own module reading for product config IO. +const operations = [ + 'access', + 'appendFile', + 'chmod', + 'chown', + 'copyFile', + 'cp', + 'exists', + 'lstat', + 'mkdir', + 'mkdtemp', + 'open', + 'opendir', + 'readFile', + 'readdir', + 'readlink', + 'realpath', + 'rename', + 'rm', + 'rmdir', + 'stat', + 'truncate', + 'unlink', + 'watch', + 'writeFile', +]; +for (const base of operations) { + for (const method of [base, `${base}Sync`]) { + const original = fs[method]; + if (typeof original === 'function') { + fs[method] = function (...args) { + guard(method, args); + return Reflect.apply(original, this, args); + }; + if (typeof original.native === 'function') { + fs[method].native = function (...args) { + guard(`${method}.native`, args); + return Reflect.apply(original.native, this, args); + }; + } + } + } + const original = fsPromises[base]; + if (typeof original === 'function') + fsPromises[base] = async function (...args) { + guard(base, args); + return await Reflect.apply(original, this, args); + }; +} +for (const method of ['createReadStream', 'createWriteStream']) { + const original = fs[method]; + fs[method] = function (...args) { + guard(method, args); + return Reflect.apply(original, this, args); + }; +} +const originalLoad = Module._load; +Module._load = function (specifier, ...args) { + const denied = isServiceSpecifier(specifier); + record({ kind: 'require', specifier, denied }); + if (denied) throw new Error('synthetic bootstrap CommonJS service import refused'); + return Reflect.apply(originalLoad, this, [specifier, ...args]); +}; +globalThis.fetch = async () => { + record({ kind: 'fetch', denied: true }); + throw new Error('synthetic bootstrap fetch refused'); +}; +for (const [owner, names, category] of [ + [net, ['connect', 'createConnection', 'createServer'], 'net'], + [net.Socket.prototype, ['connect'], 'socket'], + [tls, ['connect', 'createServer'], 'tls'], + [http, ['request', 'get', 'createServer'], 'http'], + [https, ['request', 'get', 'createServer'], 'https'], + [http2, ['connect', 'createServer', 'createSecureServer'], 'http2'], + [dns, ['lookup', 'resolve', 'resolve4', 'resolve6', 'reverse'], 'dns'], + [dnsPromises, ['lookup', 'resolve', 'resolve4', 'resolve6', 'reverse'], 'dns-promises'], + [dgram, ['createSocket'], 'dgram'], + [ + childProcess, + ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork'], + 'child-process', + ], +]) + for (const method of names) + owner[method] = function () { + record({ kind: 'service', category, method, denied: true }); + throw new Error('synthetic bootstrap service dispatch refused'); + }; +syncBuiltinESMExports(); +record({ kind: 'preload-ready' }); diff --git a/tools/cli-bootstrap/process.mjs b/tools/cli-bootstrap/process.mjs new file mode 100644 index 00000000..4e6e23fa --- /dev/null +++ b/tools/cli-bootstrap/process.mjs @@ -0,0 +1,65 @@ +import { spawn } from 'node:child_process'; +import { Buffer } from 'node:buffer'; +import { setTimeout, clearTimeout } from 'node:timers'; + +export const maxOutputBytes = 256 * 1024; +/** Bound bytes and wall time, kill only this owned process group, and wait for close. */ +export function spawnBounded( + args, + options, + { timeoutMs = 10000, outputBytes = maxOutputBytes } = {}, +) { + return new Promise((resolve) => { + const grouped = process.platform !== 'win32'; + const child = spawn(process.execPath, args, { + ...options, + detached: grouped, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const output = { stdout: [], stderr: [] }; + let bytes = 0, + timedOut = false, + outputExceeded = false, + spawnError; + function kill() { + if (grouped && child.pid !== undefined) { + try { + process.kill(-child.pid, 'SIGKILL'); + return; + } catch (error) { + if (error.code !== 'ESRCH') spawnError ??= error.message; + } + } + child.kill('SIGKILL'); + } + const timer = setTimeout(() => { + timedOut = true; + kill(); + }, timeoutMs); + for (const name of ['stdout', 'stderr']) + child[name].on('data', (chunk) => { + bytes += chunk.length; + if (bytes > outputBytes) { + outputExceeded = true; + kill(); + } else output[name].push(chunk); + }); + child.once('error', (error) => { + spawnError = error.message; + }); + child.once('close', (code, signal) => { + clearTimeout(timer); + kill(); + resolve({ + code, + signal, + timedOut, + outputExceeded, + spawnError, + joined: true, + stdout: Buffer.concat(output.stdout).toString('utf8'), + stderr: Buffer.concat(output.stderr).toString('utf8'), + }); + }); + }); +} diff --git a/tools/cli-bootstrap/witness.test.mjs b/tools/cli-bootstrap/witness.test.mjs new file mode 100644 index 00000000..956d4617 --- /dev/null +++ b/tools/cli-bootstrap/witness.test.mjs @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { withFixture, toEntryUrl } from './child.mjs'; +import { assertWitness, importFailureCanary, servicePackages } from './policy.mjs'; +import { spawnBounded } from './process.mjs'; + +function fixtureModule(fixture, body, name = 'fixture.mjs') { + const path = join(fixture.root, name); + writeFileSync(path, body); + return path; +} +test('native loader observes untouched ESM entry and tolerates Node loader reads', async () => { + await withFixture(async (fixture) => { + const entry = fixtureModule( + fixture, + "import { readFileSync } from 'node:fs'; console.log(typeof readFileSync);", + ); + const result = await fixture.child([entry]); + assert.equal(result.code, 0, result.stderr); + assertWitness(result.events, toEntryUrl(entry)); + assert.equal(result.stdout, 'function\n'); + }); +}); +test('each denied ESM package/subpath is witnessed even when the application swallows it', async () => { + await withFixture(async (fixture) => { + const specifiers = servicePackages.flatMap((name) => [name, `${name}/synthetic-subpath`]); + const entry = fixtureModule( + fixture, + `for (const specifier of ${JSON.stringify(specifiers)}) { try { await import(specifier); } catch {} }`, + ); + const result = await fixture.child([entry]); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual( + result.events.filter((event) => event.denied).map((event) => event.specifier), + specifiers, + ); + assert.throws(() => assertWitness(result.events, toEntryUrl(entry)), /guarded boundaries/); + }); +}); +test('CommonJS require cannot bypass the native ESM service witness', async () => { + await withFixture(async (fixture) => { + const entry = fixtureModule( + fixture, + "import { createRequire } from 'node:module'; try { createRequire(import.meta.url)('better-sqlite3'); } catch {}", + ); + const result = await fixture.child([entry]); + assert.equal(result.code, 0, result.stderr); + assert.ok( + result.events.some( + (event) => event.kind === 'require' && event.specifier === 'better-sqlite3' && event.denied, + ), + ); + assert.throws(() => assertWitness(result.events, toEntryUrl(entry)), /guarded boundaries/); + }); +}); +test('sync, promises, named builtin imports, streams and root-stat calls remain observable after swallowed failure', async () => { + await withFixture(async (fixture) => { + const config = join(fixture.home, '.relavium', 'config.toml'); + const entry = fixtureModule( + fixture, + ` + import fs, { readFileSync, createReadStream, realpathSync } from 'node:fs'; + import { stat } from 'node:fs/promises'; + const calls = [() => readFileSync(${JSON.stringify(config)}), + () => fs.existsSync(${JSON.stringify(config)}), + () => stat(${JSON.stringify(fixture.absentRoot)}), + () => realpathSync(${JSON.stringify(fixture.workspace)}), + () => realpathSync.native(${JSON.stringify(fixture.workspace)}), + () => createReadStream(${JSON.stringify(config)})]; + for (const call of calls) { try { await call(); } catch {} } + `, + ); + const result = await fixture.child([entry]); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual( + result.events + .filter((event) => event.kind === 'fs' && event.denied) + .map((event) => event.method), + [ + 'readFileSync', + 'existsSync', + 'stat', + 'realpathSync', + 'realpathSync.native', + 'createReadStream', + ], + ); + assert.throws(() => assertWitness(result.events, toEntryUrl(entry)), /guarded boundaries/); + }); +}); +test('native network and subprocess methods throw before dispatch and remain witnessed if swallowed', async () => { + await withFixture(async (fixture) => { + const entry = fixtureModule( + fixture, + ` + import { connect } from 'node:net'; + import { request } from 'node:https'; + import { lookup } from 'node:dns/promises'; + import { spawn } from 'node:child_process'; + for (const call of [() => connect(1), () => request('https://synthetic.invalid'), + () => lookup('synthetic.invalid'), () => spawn('synthetic-not-a-command')]) { + try { await call(); } catch {} + } + `, + ); + const result = await fixture.child([entry]); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual( + result.events.filter((event) => event.kind === 'service').map((event) => event.category), + ['net', 'https', 'dns-promises', 'child-process'], + ); + assert.throws(() => assertWitness(result.events, toEntryUrl(entry)), /guarded boundaries/); + }); +}); +test('protected descendants beginning with two dots are denied while similarly named siblings remain allowed', async () => { + await withFixture(async (fixture) => { + const protectedPath = join(fixture.home, '..cache', 'config.toml'); + const siblingPath = join(fixture.root, 'home-sibling-config.toml'); + writeFileSync(siblingPath, 'synthetic-sibling'); + const entry = fixtureModule( + fixture, + ` + import { readFileSync } from 'node:fs'; + try { readFileSync(${JSON.stringify(protectedPath)}); } catch {} + console.log(readFileSync(${JSON.stringify(siblingPath)}, 'utf8')); + `, + ); + const result = await fixture.child([entry]); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stdout, 'synthetic-sibling\n'); + assert.deepEqual( + result.events + .filter((event) => event.kind === 'fs' && event.denied) + .map((event) => event.paths), + [[protectedPath]], + ); + assert.ok( + result.events.some( + (event) => event.kind === 'fs' && !event.denied && event.paths.includes(siblingPath), + ), + ); + }); +}); +test('dynamic chunk refusal is real native load failure and carries the synthetic raw canary', async () => { + await withFixture(async (fixture) => { + const target = fixtureModule( + fixture, + "throw new Error('this source must never evaluate');", + 'runtime.mjs', + ); + const entry = fixtureModule( + fixture, + `try { await import(${JSON.stringify(toEntryUrl(target))}); } catch (error) { console.log(error.message); }`, + ); + const result = await fixture.child([entry], { failRuntimeUrl: toEntryUrl(target) }); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stdout, `${importFailureCanary}\n`); + assert.ok( + result.events.some( + (event) => + event.kind === 'load' && event.url === toEntryUrl(target) && event.runtimeFailure, + ), + ); + }); +}); +test('missing preload or missing actual entry observations cannot pass', () => { + assert.throws(() => assertWitness([], 'file:///entry'), /preload/); + assert.throws(() => assertWitness([{ kind: 'preload-ready' }], 'file:///entry'), /entry/); +}); +test('native child is killed and joined at finite timeout', async () => { + const result = await spawnBounded(['-e', 'setInterval(() => {}, 1000)'], {}, { timeoutMs: 100 }); + assert.equal(result.timedOut, true); + assert.equal(result.joined, true); + assert.equal(result.signal, 'SIGKILL'); +}); +test('output cap counts UTF-8 bytes and joins the killed child', async () => { + const result = await spawnBounded( + ['-e', "process.stdout.write('😀'.repeat(100000)); setInterval(() => {}, 1000)"], + {}, + { outputBytes: 4096 }, + ); + assert.equal(result.outputExceeded, true); + assert.equal(result.joined, true); + assert.equal(result.signal, 'SIGKILL'); +}); diff --git a/tools/fs-policy-controls/mutations.mjs b/tools/fs-policy-controls/mutations.mjs index 2853dc72..7ccd82eb 100644 --- a/tools/fs-policy-controls/mutations.mjs +++ b/tools/fs-policy-controls/mutations.mjs @@ -111,8 +111,8 @@ mutations.push( control( 'INVOCATION_ADMISSION', 'apps/cli/src/run.ts', - 'fsGrants: admitFsInvocation(global)', - 'fsGrants: admitFsInvocation({ ...global, allowFsRoots: [] })', + 'fsGrants: runtime.admitFsInvocation(global)', + 'fsGrants: runtime.admitFsInvocation({ ...global, allowFsRoots: [] })', 'FS_CAUSAL_INVOCATION_ADMISSION', [invocation], 'invalid grants refuse', diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index 920482ec..8522ef5d 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -1,7 +1,7 @@ /** Steps 3–5 and 7 independent production mechanisms and required named semantic witnesses. */ const reader = 'apps/cli/src/secrets/read-secret.ts'; const readerCases = ['apps/cli/src/secrets/read-secret.test.ts']; -const llmError = 'packages/llm/src/llm-error.ts'; +const scrubSecretsLeaf = 'packages/llm/src/scrub-secrets.ts'; const overlapFiles = [ 'packages/llm/src/llm-error.test.ts', 'apps/cli/src/render/sanitize.test.ts', @@ -148,7 +148,7 @@ export const mutations = [ ), control( 'query-names', - 'packages/llm/src/llm-error.ts', + scrubSecretsLeaf, String.raw`/([?&](?:(?:x[-_])?api[-_]?key|api[-_]?secret|(?:access|secret)[-_]?key|(?:access|refresh|id|auth|session)[-_]?token|client[-_]?secret|key|token|auth|password|secret)=)[^&\s#]+/gi`, String.raw`/([?&](?:(?:x[-_])?api[-_]?key|key|token|access[-_]?token|auth|password|secret)=)[^&\s#]+/gi`, ['packages/llm/src/llm-error.test.ts'], @@ -197,7 +197,7 @@ export const mutations = [ ), control( 'prefix-before-redaction', - 'packages/llm/src/llm-error.ts', + scrubSecretsLeaf, " const joined = remaining.replace(/\\s+/g, '');", ' const joined = remaining;', [ @@ -250,7 +250,7 @@ export const mutations = [ ), control( 'normalized-secret-probe', - llmError, + scrubSecretsLeaf, ' return stripTerminalControls(text);', ' return text;', overlapFiles, @@ -265,7 +265,7 @@ export const mutations = [ ), control( 'non-query-before-query', - llmError, + scrubSecretsLeaf, ' return scrubQuerySecrets(scrubNonQuerySecrets(text));', ' return scrubNonQuerySecrets(scrubQuerySecrets(text));', overlapFiles, diff --git a/tools/usage-coverage-controls/source-graph.mjs b/tools/usage-coverage-controls/source-graph.mjs index 9820e2f3..7b7cd320 100644 --- a/tools/usage-coverage-controls/source-graph.mjs +++ b/tools/usage-coverage-controls/source-graph.mjs @@ -23,9 +23,12 @@ export const aliases = Object.freeze({ '@relavium/shared': 'packages/shared/src/index.ts', '@relavium/shared/relavium-error': 'packages/shared/src/relavium-error.ts', '@relavium/shared/fs-root-limits': 'packages/shared/src/fs-root-limits.ts', + '@relavium/shared/terminal-safe': 'packages/shared/src/terminal-safe.ts', '@relavium/llm': 'packages/llm/src/index.ts', '@relavium/llm/adapters': 'packages/llm/src/adapters/index.ts', + '@relavium/llm/scrub-secrets': 'packages/llm/src/scrub-secrets.ts', '@relavium/core': 'packages/core/src/index.ts', + '@relavium/core/tool-dispatch-error': 'packages/core/src/tools/dispatch-error.ts', '@relavium/db': 'packages/db/src/index.ts', '@relavium/mcp': 'packages/mcp/src/index.ts', }); diff --git a/tools/usage-coverage-controls/workload.test.mjs b/tools/usage-coverage-controls/workload.test.mjs index 425fe2c5..96aee853 100644 --- a/tools/usage-coverage-controls/workload.test.mjs +++ b/tools/usage-coverage-controls/workload.test.mjs @@ -27,19 +27,27 @@ import { permanentPhaseTimeoutMs, } from './workload.mjs'; -test('supported shared exports map to their exact owned source leaves, with unknown aliases refused', () => { +test('supported shared, llm and core exports map to exact owned sources, with unknown aliases refused', () => { const repository = fileURLToPath(new URL('../../', import.meta.url)); - const shared = JSON.parse(readFileSync(join(repository, 'packages/shared/package.json'), 'utf8')); - for (const [subpath, target] of Object.entries(shared.exports)) { - const specifier = subpath === '.' ? '@relavium/shared' : `@relavium/shared/${subpath.slice(2)}`; - const source = `packages/shared/src/${target.import.slice('./dist/'.length).replace(/\.js$/, '.ts')}`; - assert.equal(aliases[specifier], source); - assert.equal(relativeSource({ sourceRoot: repository }, specifier), join(repository, source)); - assert.doesNotThrow(() => rejectUnknownAlias(specifier)); + for (const name of ['shared', 'llm', 'core']) { + const info = JSON.parse( + readFileSync(join(repository, `packages/${name}/package.json`), 'utf8'), + ); + for (const [subpath, target] of Object.entries(info.exports)) { + const specifier = + subpath === '.' ? `@relavium/${name}` : `@relavium/${name}/${subpath.slice(2)}`; + const source = `packages/${name}/src/${target.import.slice('./dist/'.length).replace(/\.js$/, '.ts')}`; + assert.equal(aliases[specifier], source); + assert.equal(relativeSource({ sourceRoot: repository }, specifier), join(repository, source)); + assert.doesNotThrow(() => rejectUnknownAlias(specifier)); + } } for (const specifier of [ '@relavium/shared/private', '@relavium/shared/../core', + '@relavium/llm/private', + '@relavium/llm/scrub-secrets/private', + '@relavium/core/tool-dispatch-error/private', '@relavium/unknown', ]) assert.throws(() => rejectUnknownAlias(specifier), /USAGE_UNKNOWN_WORKSPACE_ALIAS/); From 6ffdd63ef1eb621efa5b7cbe2749011e367fcf04 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 08:40:42 +0300 Subject: [PATCH 068/222] docs(docs): record wave 3 compiled graph implementation Record the implemented 7a boundary and attributed author/causal evidence. Independent reviews and installed-package/platform acceptance remain open; no native fatal, minimum-runtime or release custody claim is implied. Refs: ADR-0111, Wave 3 Step 7a Co-Authored-By: Codex --- ...-operations-use-a-lazy-packaged-runtime.md | 13 +++ docs/reference/cli/commands.md | 2 +- docs/reference/cli/packaging.md | 50 +++++++++++- .../phase-2.5.5-hardening-and-remediation.md | 79 +++++++++++++++++++ docs/standards/error-handling.md | 6 ++ 5 files changed, 146 insertions(+), 4 deletions(-) diff --git a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md index eae7b282..e228b68d 100644 --- a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md +++ b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md @@ -198,3 +198,16 @@ contracts and the nineteen-increment Wave 3 plan. Earlier proposal wording above records the review stage; approval is complete. Implementation, independent review gates and canonical promotion remain staged in that plan. Acceptance does not claim shipping behaviour, an actual release or production custody setup. + +## Compiled-graph implementation staging — 2026-10-16 + +The approved Step 7 is split into 7a (guarded lazy runtime, offline catalog order +and every-output emitted closure) and 7b (independent installed-tarball and required +platform/minimum-runtime controls), preserving every original obligation. Step 7a +implementation is present on `development`; independent reviews remain required. +The [canonical packaging contract](../reference/cli/packaging.md#compiled-graph-implementation-boundary--step-7a) +records the implemented boundary and remaining gates. Existing error and sanitizer +identities have supported pure leaves; no new runtime dependency or security policy +is introduced. Context/module continuity does not claim the native registered-owner +integration assigned to Step 8. Original-TS maps and custody remain Step 9. Neither +this note nor local compiled controls close whole Step 7 or claim a new release. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 3c201409..96dd6569 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -12,7 +12,7 @@ The `relavium` CLI is the terminal surface of the platform and the fastest way t ## Install & distribution - **Package**: published to npm as `relavium`, installed globally. The artifact is an **engine-inlined ESM bundle** — the proprietary `@relavium/*` engine is bundled in; every third-party dependency (including the prebuilt native addons) installs normally ([ADR-0051](../../decisions/0051-cli-distribution-thin-bundle-private-engine.md)). A global install needs no compiler toolchain. -- **Build**: TypeScript bundled with `tsup` to a single ESM `bin`; released via the `Release CLI` workflow (pack → cross-OS install-smoke on macOS/Linux/Windows → npm publish with provenance), see [release-a-surface.md](../../runbooks/release-a-surface.md). +- **Build**: TypeScript bundled with `tsup` to one executable ESM entry. Wave 3 Step 7a implements finite lazy chunks on `development`; installed-package/platform acceptance and release custody remain staged in the [packaging contract](packaging.md). The published single-bundle release uses the `Release CLI` workflow (pack → cross-OS install-smoke on macOS/Linux/Windows → npm publish with provenance), see [release-a-surface.md](../../runbooks/release-a-surface.md). - **Stack**: `commander.js` for argument parsing, `ink` (React for terminals) for the interactive TUI, `@clack/prompts` for setup wizards. - **API keys**: stored in the OS keychain via `@napi-rs/keyring` (macOS Keychain / Windows Credential Manager / Linux libsecret) — never plaintext, and never the archived `keytar` (see [ADR-0019](../../decisions/0019-cli-node-keychain-library.md) and [add-a-provider-key.md](../../runbooks/add-a-provider-key.md)). - **Workflow discovery**: reads workflows from the `.relavium/` directory in the project root, or from an explicit path argument. diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index c2db93e5..2beca375 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -1,11 +1,14 @@ # CLI Packaging Contract -- **Status**: Approved design — unshipped; implementation staged in Wave 3 +- **Status**: Approved design — compiled graph implemented on `development`; independent acceptance, installed-package and release gates staged - **Date**: 2026-10-15 - **Related**: [ADR-0111](../../decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md), [commands.md](commands.md), [release runbook](../../runbooks/release-a-surface.md) -This is the maintainer-approved canonical design contract (2026-10-16). Current -single-bundle packaging remains shipping until the implementing increments land. +This is the maintainer-approved canonical design contract (2026-10-16). Wave 3 +Step 7a implements the lazy compiled graph on `development`; its independent +reviews remain a gate. The published release retains its historical single bundle. +Step 7b owns actual tarball/install/platform acceptance, Step 8 owns native fatal +integration, and Step 9 owns maps and archival custody. No release, production recipient setup or asset upload is performed by this document. ## Finite runtime package @@ -45,6 +48,47 @@ tail wins, offline agent fixture and media no-cache exceptions remain. One registry instance passes through the chunk graph; no global-symbol bridge silently replaces existing private-module provenance. +### Compiled graph implementation boundary — Step 7a + +The bootstrap creates one invocation object before guarded imports. That object +contains the registry and one total, latched offline file-cache attempt. Actions +receive it through their context, and interactive Home receives the same object. +The native registered-owner adapters are not yet integrated; this is context and +module identity, not proof of physical owner shutdown. + +Current human help/version/bare/subtree paths use the same Commander grammar as +execution. They do not load runtime services or admit filesystem directories. +Actual actions admit grants before optional catalog seeding and command runtime +loading. A finite executor map is checked against the descriptor registry; +import paths are literal, independent of model, command argument or user content. +Media maintenance has its own route. Offline agent fixtures skip cache seeding; +history, provider management and authoring paths do not seed an unused catalog. +The existing eleven DB-overlay consumers remain responsible for installing current +DB metadata; no rebuild can repeat the invocation's file-cache attempt over it. + +Pure supported leaves retain their existing constructor/function identities: +shared terminal stripping and the error base, LLM secret scrubbing, and core's +tool-dispatch error base. Runtime classification remains separate from diagnostic +rendering. Dependency/evaluation failures reach fixed early diagnostics even if +the heavy classifier cannot load; their historical fallback exit 1 is retained +until Step 8 integrates the approved origin and exit-9 policy. + +The emitted graph checker parses actual JavaScript and compares every loading edge +with the build inventory. It rejects missing/extra/unreachable/escaping chunks, +computed or unreviewed loaders, private external imports, undeclared dependencies +and independently duplicated input contributions. Flat chunks preserve migration +resolution. Entry-only hashbangs and actual POSIX modes are checked; Windows graph +checks explicitly do not claim unobservable POSIX mode bits. Step 7b independently +checks actual tar headers on every consumer platform. + +`pnpm smoke:cli-bootstrap` runs the untouched compiled entry with native import, +application-file, network and subprocess throw witnesses in a synthetic HOME. +It covers current human meta output, pure grammar, absent absolute root refinement, +real execution admission ordering and deferred import failure. Its own negative +controls pin the witnesses, child deadline and UTF-8 output bound. The source and +compiled controls do not certify installed dependencies, complete chat turns, +minimum Node, other platforms, original-TS symbolication or release custody. + ## Build, map and candidate identity 1. Build inlined dependencies fresh in an isolated candidate job with external diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 91de45c4..dccc3007 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4956,3 +4956,82 @@ identity before Step 8 means one canonical module, one bootstrap invocation obje and preserved context identity across lazy routes. Actual registered native-owner stop/join evidence belongs to Step 8. No new ADR, dependency, deferral, provider request, credential, billing or user decision is introduced. + + +### Wave 3 Step 7a implementation checkpoint — 2026-10-16 + +**Implemented on `development`, not independently accepted.** The recorded 7a → 7b +split starts at Step 6 acceptance metadata `b09008e7`. This increment implements +only the guarded lazy source/runtime and finite compiled graph under ADR-0111. +[Packaging](../../reference/cli/packaging.md#compiled-graph-implementation-boundary--step-7a) +owns its current boundary; installed packaging, native fatal integration, maps/ +custody and new JSON help retain Steps 7b, 8, 9 and 10 respectively. + +The real bootstrap constructs one invocation before guarded imports and threads +that exact object through actions and interactive Home. Existing error and +sanitizer implementations have pure supported leaves, retaining root identities. +An initial grant-admission graph reached the core/provider barrel through its error +base; the exact existing ToolDispatchError base is extracted without changing the +family or private filesystem authority. Directory admission now precedes optional +file-cache seeding and the lazy runtime. Meta uses the same grammar but does not +stat an absent absolute grant. Pure syntax/size/count checks remain; no ad-hoc +help-token parser or model/user-derived import path is introduced. + +One invocation-owned total file-cache attempt is lazy, concurrent/reentrant safe +and remains attempted after missing/torn/import failure. Nine priced executor +routes and Home use it before first runtime metadata access; current eleven DB +consumers remain unchanged and install the latest admitted DB tail. History, +provider management, authoring and media do not load an unused file cache; an +actual string agent fixture skips it. New tests distinguish injected action/ +context observers from actual SQLite/cache/cost helper integration. Existing +agent fixture tests also pass; no eleven-surface end-to-end claim is made. +MCP installs the same build version token only at its actual construction choke. + +One actual tsup invocation emits **24 flat ESM files / 375 inputs**, with all +seventeen third-party runtime dependencies external. The entry alone has a +hashbang and executable POSIX mode. The new emitted checker parses every JS +loading edge and compares independent file/import inventories, dependency parity, +reachability and single input contribution. Seventy-six negative/positive tool +controls include actual esbuild split fixtures. Windows explicitly reports graph +scope without fabricating POSIX mode observations; actual tar headers belong 7b. + +The untouched final compiled entry passes **175 native controls** on Node 22.23.1 / +macOS: forty current human meta forms × JSON/plain × absent-root/no-root, twelve +grammar refusals, real grant admission before services and two guarded deferred +import failures. Native witnesses forbid service/config/cache/grant/network/ +subprocess activity and retain all 24 unchanged JS digests. Ten self-controls pin +these witnesses, descendant boundaries, UTF-8 output bounds and child termination. +This is repository-dist evidence, not an installed artifact, minimum Node or other +platform certification. Existing compiled workflow/budget/MCP-signal and native +media ownership smokes also pass; their deliberate failure controls are not +claimed as successful generation tests. + +All **six final local causal cohorts / eighteen structured zero-unhandled receipts** +pass named assertions, complete unique inventories, direct target/test/reporter +pins and byte-exact restoration. Earlier partial execution stops on ambiguous +new test labels and is not counted; labels are made unique without changing +assertions. Four existing secret controls and one filesystem invocation control +retain IDs, mutation bodies and workloads after target moves. Their selected +physical captures each have three verified receipts and 457 dependency packages; +source inventories are 1,239, 1,239, 1,240, 1,240 and 1,248 files respectively. +These precommit snapshots name base `b09008e7` and their physical source pins, +not a future committed head or complete 41/110-control family. The parent verifies +stdio, runtime receipts, evaluations, manifests and restoration independently. + +Final author gates pass **646 files / 14,659 tests / 20 established skips**, all +workspace lint/typechecks and six forced builds after causal restoration. The +final CLI suite runs fresh; ten of eleven test/build tasks reuse valid normal +caches in the workspace gate. All **408 acceptance-tool tests**, tools lint/ +typecheck, seam/engine fences, DB synchronization, 646-file isolation, changed +code/data formatting, bundle closure, compiled smokes and document/diff checks pass. +Initial author failures are corrected test return typing, await-free async doubles, +the moved JSON-renderer lint requirement and one stale mutation anchor. A later +tooling run overlaps separately invoked coordinator captures and misattributes +their global temporary directories as a leak; serialized final execution passes. +No source cleanliness or gate is inferred from either failed run. + +Two NEW independent complete cumulative clean reviews and a separate sanitizer/ +bootstrap/package-boundary security sitting remain required before accepting 7a. +Step 7b, whole Step 7 and Steps 8–19 remain open. No new ADR, runtime dependency, +provider capture, credential, billing action, external release or user decision +is introduced. No full CI/coverage or native-platform publication is claimed. diff --git a/docs/standards/error-handling.md b/docs/standards/error-handling.md index 70b0098c..9a4372d1 100644 --- a/docs/standards/error-handling.md +++ b/docs/standards/error-handling.md @@ -23,6 +23,12 @@ identity and private-authority guards also retain their own contracts. The supported `@relavium/shared/relavium-error` leaf exports that same constructor without evaluating the schema barrel. Early process contracts use this leaf; runtime classifiers that recognize engine/DB families stay behind runtime loading. +The supported `@relavium/core/tool-dispatch-error` leaf likewise exports the +existing `ToolDispatchError` base without loading the engine/provider barrel. +Concrete tool errors and the root export extend/re-export that exact constructor; +filesystem grant admission can classify its own denial before service evaluation. +CLI diagnostic rendering accepts an already-classified projection independently +of the heavy runtime-family classifier. - Errors are **typed and discriminated**, never bare `Error` and never thrown strings (see [code-style-typescript.md](code-style-typescript.md)). Each error type carries a From bc79793dfa53f40456f92d4d4b7769beb8799d96 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 09:02:12 +0300 Subject: [PATCH 069/222] docs(docs): accept the wave 3 compiled runtime graph Record both new complete clean cumulative reviews and the separate security sitting at 6ffdd63e, retaining exact author, causal and compiled evidence scopes. Start installed-tarball and required platform work without closing whole Step 7. Refs: ADR-0111, Wave 3 Step 7a Co-Authored-By: Codex --- AGENTS.md | 6 ++- CLAUDE.md | 6 ++- docs/reference/cli/packaging.md | 7 +-- docs/roadmap/current.md | 6 ++- .../phase-2.5.5-hardening-and-remediation.md | 50 +++++++++++++++++++ docs/standards/security-review.md | 24 +++++++++ 6 files changed, 90 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 366d2725..eeca1e42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,8 +89,10 @@ tails) is independently accepted after two NEW complete cumulative clean rounds following its registry-documentation correction and a separate clean security sitting. Step 6 (pure process/discovery substrate) is independently accepted after two NEW complete cumulative clean rounds and a separate clean security sitting. -Step 7a (lazy runtime and finite compiled graph) is active; whole Step 7 also requires -Step 7b's installed-tarball and required platform controls. +Step 7a (lazy runtime and finite compiled graph) is independently accepted after +two NEW complete cumulative clean rounds and a separate clean security sitting. +Step 7b's installed-tarball and required platform controls are active; whole Step 7 +remains open until they pass. Later implementation and whole-wave acceptance remain staged in the canonical roadmap. diff --git a/CLAUDE.md b/CLAUDE.md index 52780ddc..4a4c2f78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,8 +178,10 @@ tails) is independently accepted after two NEW complete cumulative clean rounds following its registry-documentation correction and a separate clean security sitting. Step 6 (pure process/discovery substrate) is independently accepted after two NEW complete cumulative clean rounds and a separate clean security sitting. -Step 7a (lazy runtime and finite compiled graph) is active; whole Step 7 also requires -Step 7b's installed-tarball and required platform controls. +Step 7a (lazy runtime and finite compiled graph) is independently accepted after +two NEW complete cumulative clean rounds and a separate clean security sitting. +Step 7b's installed-tarball and required platform controls are active; whole Step 7 +remains open until they pass. Later implementation and whole-wave acceptance remain staged in the canonical roadmap. diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index 2beca375..87535dab 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -1,12 +1,13 @@ # CLI Packaging Contract -- **Status**: Approved design — compiled graph implemented on `development`; independent acceptance, installed-package and release gates staged +- **Status**: Compiled graph independently accepted on `development`; installed-package and release gates staged - **Date**: 2026-10-15 - **Related**: [ADR-0111](../../decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md), [commands.md](commands.md), [release runbook](../../runbooks/release-a-surface.md) This is the maintainer-approved canonical design contract (2026-10-16). Wave 3 -Step 7a implements the lazy compiled graph on `development`; its independent -reviews remain a gate. The published release retains its historical single bundle. +Step 7a's lazy compiled graph is independently accepted on `development` after two +new complete cumulative clean reviews and a separate security sitting. The +published release retains its historical single bundle. Step 7b owns actual tarball/install/platform acceptance, Step 8 owns native fatal integration, and Step 9 owns maps and archival custody. No release, production recipient setup or asset upload is performed by this document. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index c1fe379b..5b989eaa 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1123,8 +1123,10 @@ after two NEW complete cumulative clean rounds following its registry-documentat correction and a separate clean security sitting. [Step 6 is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-6-acceptance--2026-10-16) after two NEW complete cumulative clean rounds and a separate clean security sitting. -Step 7a (lazy runtime and finite compiled graph) is active; Step 7b must also prove -the installed tarball and required platform controls before whole Step 7 closes. The +[Step 7a is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-7a-acceptance--2026-10-16) +after two NEW complete cumulative clean rounds and a separate clean security sitting. +Step 7b's installed-tarball and required platform controls are active; whole Step 7 +remains open until they pass. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index dccc3007..97c7a1fb 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -5035,3 +5035,53 @@ bootstrap/package-boundary security sitting remain required before accepting 7a. Step 7b, whole Step 7 and Steps 8–19 remain open. No new ADR, runtime dependency, provider capture, credential, billing action, external release or user decision is introduced. No full CI/coverage or native-platform publication is claimed. + + +### Wave 3 Step 7a acceptance — 2026-10-16 + +Step 7a is independently accepted at +`6ffdd63ef1eb621efa5b7cbe2749011e367fcf04`, from Step 6 metadata +`b09008e76b3c31e4cf0c51e93b3788e07ea117bb`. Two NEW independent complete +cumulative clean reviews cover every one of **60 paths / 127 zero-context hunks**, +including all 28 full new files. Reviewers are gpt-6-astra/high (**18 files / +547 passing tests**) and gpt-6.1-sol/xhigh (**16 files / 538 passing tests**), both +with zero skips, plus ninety tooling tests each. Both independently execute all +175 compiled controls and the 24-output / 375-input / seventeen-dependency closure. +The parent reads both complete reports. Neither round finds an actionable defect; +no material source change follows them. + +The separate clean security sitting executes **10 files / 391 tests**, with two +established Windows-only skips, 86 tool tests, 175 compiled controls and four extra +debug/JSON failure controls. It checks exact extraction, loader/admission/privacy/ +catalog boundaries and all retained scoped evidence. Both complete reviewers and +the security reviewer independently audit all eighteen direct causal receipts and +all fifteen selected physical phases. Direct target/test/reporter pins remain +narrower than the five isolated physical captures; the latter retain their +precommit base, differing broader source inventories and 457 dependency packages. +No whole-family or all-final-source equivalence is inferred. The second reviewer +also verifies the dependency inventory's 21,013 actual files. + +Final author gates retain **646 files / 14,659 passes / 20 established skips**, +all workspace lint/typechecks, six forced builds, 408 acceptance-tool tests, +formatting/isolation/fences/DB synchronization, compiled smokes and document/diff +checks. Scoped reviewer totals are separate and not added to author counts. +Every final compiled JS digest remains unchanged throughout both reviews and the +security sitting. A separate unhooked local three-spawn-per-case comparison records +56–59 ms medians against the fresh 427–433 ms Step 6 baseline on the same Node +22.23.1 host; all six existing human-output forms retain exact stdout bytes. +The seventh case is the approved absent-root help refinement, exit 2 → 0. These +measurements are not a portable threshold or installed-package benchmark. + +This accepts the **compiled graph only**. Whole Step 7 remains open. Step 7b starts +automatically with actual tar inspection, external same-digest installation, +complete chat/resume, migration/native/MCP/CAS/class/context controls and required +exact Node 22.0.0 Linux plus current `.nvmrc` (24) Linux/macOS/Windows consumers. +A read-only synthetic transport feasibility probe against unchanged repository +dist already observes two real SDK dispatches, two refused keyring constructors, +four durable transcript rows, same-session resume and exit 4 twice. The parent +reads its full ignored report. That probe is preparation, not installed-tarball, +minimum-runtime or platform acceptance; no real credential/service is used. +Actual registered native-owner cleanup remains Step 8, maps/custody Step 9 and +new CLI discovery Step 10. This metadata landing does not imply new source +execution, full CI/coverage, a release or whole-wave acceptance. No new ADR, +dependency, deferral, billing action or user decision is introduced. diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index d8c61e78..60e414c5 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -581,6 +581,30 @@ terminal, sink and RunHandle/AgentSession adapters remain Step 8; installed pack and whole-wave claims remain their later gates. No provider, keychain, paid request, new network authority or native-platform recertification is claimed. +### Sitting: Wave 3 guarded runtime and emitted package boundaries (2026-10-16) + +A NEW independent cumulative sitting is **clean** at `6ffdd63e`. Fresh execution +passes **10 files / 391 tests**, with two established Windows-only skips, and +**86 tooling tests**, zero skips. The untouched compiled entry passes 175 native +controls plus four additional debug/JSON import-failure controls on macOS / +Node 22.23.1. All 24 emitted JS digests match the accepted artifact; all seventeen +external dependencies remain declared. The parent reads the complete report. +The [Step 7a acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-7a-acceptance--2026-10-16) +joins this sitting with both separate complete correctness rounds. + +The sitting verifies exact scrubber/error-base extraction, root/leaf identities, +unchanged filesystem authority, admission before service composition, one offline +catalog attempt and current DB cutover, fixed literal loader edges and all-output +closure. Native witnesses remain observable after swallowed refusal and constrain +synthetic paths, network, subprocesses, output bytes and owned cleanup. It audits +eighteen direct causal receipts and fifteen selected physical phases, preserving +their different source/dependency and precommit scopes. It does not claim fresh +mutation execution or complete family capture. Existing finite redaction and +cooperating-host limits remain explicit. Installed tarballs, minimum/current +platform consumers, native owner/fatal integration, maps/custody and whole-wave +publication retain Steps 7b, 8, 9 and 19. No real provider, keychain, credential, +billing or release operation is performed. + ## Sandbox and tool policy (`run_command`, node tools, secret inputs) This is the **binding home** for the tool-policy rules; the rationale is From 97e9700259f0487d38033e944282025aa59aa865 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 09:39:34 +0300 Subject: [PATCH 070/222] build(repo): verify and exercise exact installed cli tarballs Inspect bounded public archive bytes independently of package files rules, install the chosen digest outside the workspace and drive real bundled workflow, chat, native, MCP and media paths with synthetic transport guards. Require all four same-artifact platform consumers through strict aggregation; reuse that consumer in release smoke and keep maps/custody staged for Step 9. Author validation: 473 acceptance-tool tests, valid workspace gates, fresh dependency/prepack builds, installed bootstrap and six behaviour cases, tooling checks, default artifact smokes, isolation and DB synchronization. Local runtime evidence is Node 22.23.1/macOS only; required platform jobs and two new complete cumulative source reviews remain before acceptance. Refs: ADR-0111, Wave 3 Step 7b Co-Authored-By: Codex --- .github/workflows/ci.yml | 142 ++++- .github/workflows/release.yml | 152 ++++-- package.json | 9 +- tools/ci/require-packed-cli.test.mjs | 247 +++++++++ tools/ci/require-w8-acceptance.test.mjs | 9 + tools/cli-bootstrap/README.md | 13 +- tools/cli-bootstrap/check.mjs | 296 ++++++----- tools/cli-package/README.md | 67 +++ tools/cli-package/aggregate.mjs | 123 +++++ tools/cli-package/archive.mjs | 282 ++++++++++ tools/cli-package/archive.test.mjs | 384 ++++++++++++++ tools/cli-package/binding.mjs | 406 ++++++++++++++ tools/cli-package/binding.test.mjs | 496 ++++++++++++++++++ tools/cli-package/check.mjs | 33 ++ tools/cli-package/consume.mjs | 184 +++++++ tools/cli-package/files.mjs | 87 +++ tools/cli-package/files.test.mjs | 64 +++ tools/cli-package/pack.mjs | 40 ++ tools/cli-package/pipeline.mjs | 25 + tools/cli-package/pipeline.test.mjs | 89 ++++ tools/cli-package/process.mjs | 143 +++++ tools/cli-package/process.test.mjs | 84 +++ tools/cli-package/seal.mjs | 119 +++++ tools/cli-package/smoke/README.md | 60 +++ tools/cli-package/smoke/cases.mjs | 9 + tools/cli-package/smoke/chat.mjs | 163 ++++++ tools/cli-package/smoke/guard-log.mjs | 18 + tools/cli-package/smoke/index.mjs | 261 +++++++++ tools/cli-package/smoke/keyring-denied.mjs | 7 + tools/cli-package/smoke/mcp-server.mjs | 44 ++ tools/cli-package/smoke/module-guard.mjs | 13 + tools/cli-package/smoke/native-load.mjs | 33 ++ tools/cli-package/smoke/preload.mjs | 119 +++++ tools/cli-package/smoke/process.mjs | 107 ++++ tools/cli-package/smoke/process.test.mjs | 64 +++ tools/cli-package/smoke/workflows.mjs | 404 ++++++++++++++ .../fixtures/media-native-packaging.mjs | 10 +- 37 files changed, 4597 insertions(+), 209 deletions(-) create mode 100644 tools/ci/require-packed-cli.test.mjs create mode 100644 tools/cli-package/README.md create mode 100644 tools/cli-package/aggregate.mjs create mode 100644 tools/cli-package/archive.mjs create mode 100644 tools/cli-package/archive.test.mjs create mode 100644 tools/cli-package/binding.mjs create mode 100644 tools/cli-package/binding.test.mjs create mode 100644 tools/cli-package/check.mjs create mode 100644 tools/cli-package/consume.mjs create mode 100644 tools/cli-package/files.mjs create mode 100644 tools/cli-package/files.test.mjs create mode 100644 tools/cli-package/pack.mjs create mode 100644 tools/cli-package/pipeline.mjs create mode 100644 tools/cli-package/pipeline.test.mjs create mode 100644 tools/cli-package/process.mjs create mode 100644 tools/cli-package/process.test.mjs create mode 100644 tools/cli-package/seal.mjs create mode 100644 tools/cli-package/smoke/README.md create mode 100644 tools/cli-package/smoke/cases.mjs create mode 100644 tools/cli-package/smoke/chat.mjs create mode 100644 tools/cli-package/smoke/guard-log.mjs create mode 100644 tools/cli-package/smoke/index.mjs create mode 100644 tools/cli-package/smoke/keyring-denied.mjs create mode 100644 tools/cli-package/smoke/mcp-server.mjs create mode 100644 tools/cli-package/smoke/module-guard.mjs create mode 100644 tools/cli-package/smoke/native-load.mjs create mode 100644 tools/cli-package/smoke/preload.mjs create mode 100644 tools/cli-package/smoke/process.mjs create mode 100644 tools/cli-package/smoke/process.test.mjs create mode 100644 tools/cli-package/smoke/workflows.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55bd222e..a12c6b0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ # Turborepo cache, plus the formatting check and the no-vendor-type-across-the-seam fence. # # Branch protection (set in the GitHub repo settings, not here): `ci` and `coverage` are the REQUIRED -# status checks to merge into `main`. Wire, usage, filesystem and secrets mutation jobs feed `ci` through an explicit +# status checks to merge into `main`. Wire, usage, filesystem, secrets and packed CLI jobs feed `ci` through an explicit # success-only aggregation; they cannot silently skip or fail while `ci` stays green. The other jobs # self-label `(advisory)` — `floor-check`, `peer-dep-gate` and `windows-concurrency`. # @@ -57,7 +57,7 @@ env: jobs: ci: - needs: [wire-mutations, usage-mutations, fs-mutations, secrets-mutations] + needs: [wire-mutations, usage-mutations, fs-mutations, secrets-mutations, packed-cli] if: ${{ always() }} name: lint · typecheck · test runs-on: ubuntu-latest @@ -94,6 +94,11 @@ jobs: SECRETS_CONTROLS_RESULT: ${{ needs.secrets-mutations.result }} run: node -e "require('node:assert/strict').equal(process.env.SECRETS_CONTROLS_RESULT, 'success')" + - name: Require complete packed CLI controls + env: + PACKED_CLI_RESULT: ${{ needs.packed-cli.result }} + run: node -e "require('node:assert/strict').equal(process.env.PACKED_CLI_RESULT, 'success')" + # A squash/main-based checkout need not contain development's immutable predecessor object. # Fetch only this canonical pin during CI setup; the acceptance harness itself stays offline. - name: Ensure immutable replay predecessor is available @@ -577,3 +582,136 @@ jobs: echo "::error::chat entered raw mode without a TTY (the driver-selection gate regressed)"; exit 1 fi echo "✓ windows headless no-TTY smoke passed" + + # Required exact-artifact controls are independent of the cached workspace build above. + pack-cli: + outputs: + artifact-name: relavium-tarball-${{ github.run_id }}-${{ github.run_attempt }} + name: build once and inspect the packed CLI + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + env: + TURBO_TOKEN: '' + TURBO_TEAM: '' + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - name: Set up pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + - name: Install (frozen lockfile) + run: pnpm install --frozen-lockfile + # The runner builds inlined dependencies fresh, then prepack performs the sole final CLI build. + - name: Pack once and independently inspect the public package + env: + RELAVIUM_PACKAGE_SOURCE_SHA: ${{ github.sha }} + run: node tools/cli-package/pack.mjs "${{ runner.temp }}/packed-cli-artifact" + - name: Upload the public tarball and package-test receipt + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: relavium-tarball-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/packed-cli-artifact/*.tgz + ${{ runner.temp }}/packed-cli-artifact/package-test-receipt.json + retention-days: 7 + if-no-files-found: error + + packed-cli-consumer: + name: installed CLI (${{ matrix.id }}) + needs: pack-cli + strategy: + fail-fast: false + matrix: + include: + - id: current-linux + os: ubuntu-latest + node: '24' + - id: current-macos + os: macos-latest + node: '24' + - id: current-windows + os: windows-latest + node: '24' + - id: minimum-linux + os: ubuntu-latest + node: '22.0.0' + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + permissions: + contents: read + steps: + # Only the test runner and fixtures use this checkout. No workspace dependencies or build. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + # An explicit version avoids evaluating the workspace's >=22.13 install floor on Node 22.0.0. + - name: Set up pnpm for the external consumer + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + with: + version: 9.12.3 + run_install: false + - name: Set up consumer Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ matrix.node == '22.0.0' && matrix.node || '' }} + node-version-file: ${{ matrix.node != '22.0.0' && '.nvmrc' || '' }} + - name: Download the exact packed CLI + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ needs.pack-cli.outputs.artifact-name }} + path: ${{ runner.temp }}/packed-cli-artifact + - name: Install outside the workspace and exercise the actual package + env: + RELAVIUM_PACKAGE_LEG: ${{ matrix.id }} + RELAVIUM_PACKAGE_SOURCE_SHA: ${{ github.sha }} + run: node tools/cli-package/consume.mjs "${{ runner.temp }}/packed-cli-artifact" "${{ runner.temp }}/packed-cli-report" + - name: Upload successful installed-package receipt + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ needs.pack-cli.outputs.artifact-name }}-report-${{ matrix.id }} + # A retried consumer replaces only its smoke receipt for the same immutable tarball. + overwrite: true + path: ${{ runner.temp }}/packed-cli-report/installed-consumer.json + retention-days: 7 + if-no-files-found: error + + packed-cli: + outputs: + artifact-name: ${{ needs.pack-cli.outputs.artifact-name }} + name: require all installed CLI consumers + needs: [pack-cli, packed-cli-consumer] + if: ${{ always() }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + - name: Download the exact packed CLI + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ needs.pack-cli.outputs.artifact-name }} + path: ${{ runner.temp }}/packed-cli-artifact + - name: Download all installed-package receipts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: ${{ needs.pack-cli.outputs.artifact-name }}-report-* + path: ${{ runner.temp }}/packed-cli-reports + merge-multiple: false + - name: Require pack and every consumer outcome and matching receipt + env: + RELAVIUM_PACKAGE_JOB_RESULTS: ${{ toJSON(needs) }} + RELAVIUM_PACKAGE_SOURCE_SHA: ${{ github.sha }} + run: node tools/cli-package/aggregate.mjs "${{ runner.temp }}/packed-cli-artifact" "${{ runner.temp }}/packed-cli-reports" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf309b86..83c0ea3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,12 @@ # Relavium CLI release — pack once, smoke the exact artifact on every OS, then publish it (2.L · ADR-0051). # +# Step 7b reuses the required installed-package runner, including the exact Node 22.0.0 runtime floor. +# Step 9 retains ownership of original-TS maps, release custody and their permission/discovery redesign. +# # Flow: `pack` builds the engine-inlined bundle and `pnpm pack`s it (resolving catalog:/workspace: to -# concrete versions) into a tarball artifact → `smoke` installs THAT SAME tarball globally on ubuntu / -# macOS / Windows and asserts the binary works (prebuilt native addons load, the bundled engine runs) → -# `publish` (only on a `v*` tag, gated on green smoke) publishes the very artifact that was smoked, with +# concrete versions) into a tarball artifact → `smoke` installs THAT SAME tarball outside the workspace on ubuntu / +# macOS / Windows and the minimum Node runtime and asserts the binary works (prebuilt native addons load, the bundled engine runs) → +# `publish` (only on a `v*` tag, gated on the strict packed aggregate) publishes the very artifact that was smoked, with # npm provenance. The actual publish needs the maintainer's `NPM_TOKEN` secret — it is maintainer-gated, # like branch protection. Verifying cross-OS BEFORE publish is the Phase-3 go/no-go #7 gate. # @@ -38,6 +41,8 @@ concurrency: jobs: pack: + outputs: + artifact-name: relavium-tarball-${{ github.run_id }}-${{ github.run_attempt }} name: build + pack the tarball runs-on: ubuntu-latest timeout-minutes: 15 @@ -87,37 +92,92 @@ jobs: cache: pnpm - name: Install (frozen lockfile) run: pnpm install --frozen-lockfile - # Build the engine packages first (turbo resolves the dependency order) so `pnpm pack`'s prepack can - # inline them into the bundle. - - name: Build - run: pnpm turbo run build --filter=relavium... - # `pnpm pack` (not `npm pack`) resolves catalog:/workspace: in the published manifest; its `prepack` - # rebuilds the bundle, copies the drizzle migrations beside it, AND runs the bundle-closure guard — so - # the guard validates exactly the dist that gets tarballed. The tarball is platform-independent (native - # addons resolve per-OS at install), so one tarball is smoked on, and published to, every target. - - name: Pack - working-directory: apps/cli - run: pnpm pack --pack-destination "${{ runner.temp }}/artifact" - - name: Upload tarball + # The same required package runner owns fresh dependencies and the sole prepack CLI build. + # Original-TS maps and encrypted release custody are staged separately in Wave 3 Step 9. + - name: Pack once and independently inspect the public package + env: + RELAVIUM_PACKAGE_SOURCE_SHA: ${{ github.sha }} + run: node tools/cli-package/pack.mjs "${{ runner.temp }}/packed-cli-artifact" + - name: Upload the public tarball and package-test receipt uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: relavium-tarball - path: ${{ runner.temp }}/artifact/*.tgz + name: relavium-tarball-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/packed-cli-artifact/*.tgz + ${{ runner.temp }}/packed-cli-artifact/package-test-receipt.json + retention-days: 7 if-no-files-found: error smoke: - name: install-smoke (${{ matrix.os }}) + name: installed CLI (${{ matrix.id }}) needs: pack strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + include: + - id: current-linux + os: ubuntu-latest + node: '24' + - id: current-macos + os: macos-latest + node: '24' + - id: current-windows + os: windows-latest + node: '24' + - id: minimum-linux + os: ubuntu-latest + node: '22.0.0' runs-on: ${{ matrix.os }} - timeout-minutes: 15 + timeout-minutes: 20 + permissions: + contents: read + steps: + # Only the test runner and fixtures use this checkout. No workspace dependencies or build. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + # An explicit version avoids evaluating the workspace's >=22.13 install floor on Node 22.0.0. + - name: Set up pnpm for the external consumer + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + with: + version: 9.12.3 + run_install: false + - name: Set up consumer Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ matrix.node == '22.0.0' && matrix.node || '' }} + node-version-file: ${{ matrix.node != '22.0.0' && '.nvmrc' || '' }} + - name: Download the exact packed CLI + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: ${{ needs.pack.outputs.artifact-name }} + path: ${{ runner.temp }}/packed-cli-artifact + - name: Install outside the workspace and exercise the actual package + env: + RELAVIUM_PACKAGE_LEG: ${{ matrix.id }} + RELAVIUM_PACKAGE_SOURCE_SHA: ${{ github.sha }} + run: node tools/cli-package/consume.mjs "${{ runner.temp }}/packed-cli-artifact" "${{ runner.temp }}/packed-cli-report" + - name: Upload successful installed-package receipt + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ needs.pack.outputs.artifact-name }}-report-${{ matrix.id }} + # A retried consumer replaces only its smoke receipt for the same immutable tarball. + overwrite: true + path: ${{ runner.temp }}/packed-cli-report/installed-consumer.json + retention-days: 7 + if-no-files-found: error + + packed-cli: + outputs: + artifact-name: ${{ needs.pack.outputs.artifact-name }} + name: require all installed CLI consumers + needs: [pack, smoke] + if: ${{ always() }} + runs-on: ubuntu-latest + timeout-minutes: 5 permissions: contents: read steps: - # Checkout only for the fixture workflows the smoke runs; no build here — we install the published tarball. - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: persist-credentials: false @@ -125,44 +185,26 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc - - name: Download tarball + - name: Download the exact packed CLI uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: relavium-tarball - path: artifact - # One bash script across all three OSes (GitHub provides Git Bash on Windows) so the asserts are uniform. - # These commands exercise the bundle + BOTH prebuilt native addons (better-sqlite3 opens history.db with - # the shipped migrations; @napi-rs/keyring's accessor loads) WITHOUT touching the OS credential store, so - # the Windows leg is headless-safe. - - name: Install globally + smoke - shell: bash - run: | - set -euo pipefail - # The `./` prefix is load-bearing: npm reads a slash-bearing arg without it - # (`artifact/relavium-X.tgz`) as a GitHub `owner/repo` shorthand and tries to git-clone it. - # `./artifact/…` forces the local-file spec (verified via npm-package-arg); the glob expands to - # the single packed tarball, so no `ls`/subshell is needed. - npm install -g ./artifact/*.tgz - echo "## relavium --help" && relavium --help >/dev/null - echo "## relavium --version matches the published package version" - VER="$(relavium --version)" - PKG="$(node -p "require('./apps/cli/package.json').version")" - test "$VER" = "$PKG" || { echo "relavium --version=$VER != package.json $PKG"; exit 1; } - echo "## relavium provider list" && relavium provider list - echo "## relavium run (sequential) --json (exit 0)" - relavium run apps/cli/src/harness/fixtures/sequential.relavium.yaml --input n=21 --json - echo "## relavium run (human-gate) --json (exit 3 = gate-paused)" - set +e; relavium run apps/cli/src/harness/fixtures/human-gate.relavium.yaml --json >/dev/null; ec=$?; set -e - test "$ec" -eq 3 || { echo "expected exit 3, got $ec"; exit 1; } - echo "## relavium gate list" && relavium gate list - echo "## unknown runId → exit 2" - set +e; relavium logs nope >/dev/null 2>&1; ec=$?; set -e - test "$ec" -eq 2 || { echo "expected exit 2, got $ec"; exit 1; } - echo "✓ smoke passed on ${{ matrix.os }}" + name: ${{ needs.pack.outputs.artifact-name }} + path: ${{ runner.temp }}/packed-cli-artifact + - name: Download all installed-package receipts + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: ${{ needs.pack.outputs.artifact-name }}-report-* + path: ${{ runner.temp }}/packed-cli-reports + merge-multiple: false + - name: Require pack and every consumer outcome and matching receipt + env: + RELAVIUM_PACKAGE_JOB_RESULTS: ${{ toJSON(needs) }} + RELAVIUM_PACKAGE_SOURCE_SHA: ${{ github.sha }} + run: node tools/cli-package/aggregate.mjs "${{ runner.temp }}/packed-cli-artifact" "${{ runner.temp }}/packed-cli-reports" publish: name: publish to npm (provenance) - needs: smoke + needs: packed-cli # Only a real pushed release tag publishes. `github.event_name == 'push'` is load-bearing, not # belt-and-suspenders: a `workflow_dispatch` can target a `v*` tag, so the ref check alone would let a # manually dispatched run publish. Every manual run now stops after the cross-OS smoke. @@ -189,7 +231,7 @@ jobs: - name: Download the smoked tarball uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: relavium-tarball + name: ${{ needs.packed-cli.outputs.artifact-name }} path: artifact # Publish the EXACT artifact the smoke matrix proved — not a fresh build. catalog:/workspace: are already # resolved in the packed manifest, so `npm publish ` is correct here (pnpm-pack did the resolving). diff --git a/package.json b/package.json index 9cdbad9c..31768dc6 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "coverage": "vitest run --coverage", "coverage:enforced": "node tools/coverage-gate/run.mjs", "ci:workspaces": "turbo run lint typecheck && turbo run test --concurrency=1", - "ci": "pnpm lint:test-isolation && pnpm ci:workspaces && pnpm db:sync-check && pnpm typecheck:tools && pnpm lint:tools && turbo run build format:check && pnpm lint:fence-check && pnpm lint:engine-deps && pnpm lint:bundle-closure && pnpm smoke:cli-bootstrap && pnpm smoke:cli && pnpm smoke:media-packaging && pnpm smoke:overflow-capture && pnpm smoke:budget-replay && pnpm test:acceptance-tools && pnpm check:wire-mutations && pnpm check:usage-mutations && pnpm check:fs-mutations && pnpm check:secrets-mutations", + "ci": "pnpm lint:test-isolation && pnpm ci:workspaces && pnpm db:sync-check && pnpm typecheck:tools && pnpm lint:tools && turbo run build format:check && pnpm lint:fence-check && pnpm lint:engine-deps && pnpm lint:bundle-closure && pnpm smoke:cli-package && pnpm smoke:cli-bootstrap && pnpm smoke:cli && pnpm smoke:media-packaging && pnpm smoke:overflow-capture && pnpm smoke:budget-replay && pnpm test:acceptance-tools && pnpm check:wire-mutations && pnpm check:usage-mutations && pnpm check:fs-mutations && pnpm check:secrets-mutations", "lint:test-isolation": "node tools/test-isolation/check.mjs && node tools/test-isolation/check-ownership.mjs", "lint:fence-check": "node tools/lint-fixtures/assert-fence.mjs", "lint:engine-deps": "node tools/engine-deps/check.mjs", @@ -36,10 +36,13 @@ "smoke:budget-replay": "node tools/budget-replay-compat/check.mjs", "check:wire-mutations": "node tools/wire-conformance/check.mjs", "check:usage-mutations": "node tools/usage-coverage-controls/check.mjs", - "test:acceptance-tools": "node --test tools/ci/windows-headless-smoke.test.mjs tools/ci/workspace-scheduling.test.mjs tools/ci/require-native-db-diagnostics.test.mjs tools/usage-coverage-controls/outcome.test.mjs tools/usage-coverage-controls/workload.test.mjs tools/ci/require-w8-acceptance.test.mjs tools/acceptance-evidence/export.test.mjs tools/wire-conformance/provenance.test.mjs tools/causal-controls/outcome.test.mjs tools/causal-controls/process.test.mjs tools/causal-controls/provenance.test.mjs tools/causal-controls/retention.test.mjs tools/causal-controls/run.test.mjs tools/causal-controls/timeout-policy.test.mjs tools/bundle-closure/closure.test.mjs tools/cli-bootstrap/witness.test.mjs", + "test:acceptance-tools": "node --test tools/ci/windows-headless-smoke.test.mjs tools/ci/workspace-scheduling.test.mjs tools/ci/require-native-db-diagnostics.test.mjs tools/usage-coverage-controls/outcome.test.mjs tools/usage-coverage-controls/workload.test.mjs tools/ci/require-w8-acceptance.test.mjs tools/acceptance-evidence/export.test.mjs tools/wire-conformance/provenance.test.mjs tools/causal-controls/outcome.test.mjs tools/causal-controls/process.test.mjs tools/causal-controls/provenance.test.mjs tools/causal-controls/retention.test.mjs tools/causal-controls/run.test.mjs tools/causal-controls/timeout-policy.test.mjs tools/bundle-closure/closure.test.mjs tools/cli-bootstrap/witness.test.mjs tools/ci/require-packed-cli.test.mjs tools/cli-package/archive.test.mjs tools/cli-package/binding.test.mjs tools/cli-package/process.test.mjs tools/cli-package/files.test.mjs tools/cli-package/smoke/process.test.mjs tools/cli-package/pipeline.test.mjs", "check:fs-mutations": "node tools/fs-policy-controls/check.mjs", "check:secrets-mutations": "node tools/secrets-controls/check.mjs", - "smoke:cli-bootstrap": "node tools/cli-bootstrap/check.mjs" + "smoke:cli-bootstrap": "node tools/cli-bootstrap/check.mjs", + "package:cli:pack": "node tools/cli-package/pack.mjs", + "package:cli:consume": "node tools/cli-package/consume.mjs", + "smoke:cli-package": "node tools/cli-package/check.mjs" }, "devDependencies": { "@eslint/js": "catalog:", diff --git a/tools/ci/require-packed-cli.test.mjs b/tools/ci/require-packed-cli.test.mjs new file mode 100644 index 00000000..778db191 --- /dev/null +++ b/tools/ci/require-packed-cli.test.mjs @@ -0,0 +1,247 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { test } from 'node:test'; + +const root = new URL('../../', import.meta.url); +const require = createRequire(new URL('apps/cli/package.json', root)); +const { parse } = require('yaml'); +const currentNode = readFileSync(new URL('.nvmrc', root), 'utf8').trim(); +const artifactDirectory = '${{ runner.temp }}/packed-cli-artifact'; +const reportDirectory = '${{ runner.temp }}/packed-cli-report'; +const reportsDirectory = '${{ runner.temp }}/packed-cli-reports'; +const packCommand = `node tools/cli-package/pack.mjs "${artifactDirectory}"`; +const consumeCommand = `node tools/cli-package/consume.mjs "${artifactDirectory}" "${reportDirectory}"`; +const aggregateCommand = `node tools/cli-package/aggregate.mjs "${artifactDirectory}" "${reportsDirectory}"`; +const actions = { + checkout: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5', + pnpm: 'pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320', + node: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020', + upload: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02', + download: 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093', +}; + +function readWorkflow(name) { + return parse(readFileSync(new URL(`.github/workflows/${name}.yml`, root), 'utf8')); +} + +function requiredJob(job, always = false) { + assert.ok(job); + assert.equal(job.if, always ? '${{ always() }}' : undefined); + assert.equal(job['continue-on-error'], undefined); + assert.ok(job['timeout-minutes'] > 0 && job['timeout-minutes'] <= 20); + assert.deepEqual(job.permissions, { contents: 'read' }); + for (const step of job.steps) { + assert.equal(step['continue-on-error'], undefined); + assert.equal(step.if, undefined); + if (step.uses) assert.ok(Object.values(actions).includes(step.uses), step.uses); + } +} + +function checkConsumer(job, packId) { + requiredJob(job); + assert.equal(job.needs, packId); + assert.equal(job.strategy['fail-fast'], false); + assert.deepEqual(job.strategy.matrix, { + include: [ + { id: 'current-linux', os: 'ubuntu-latest', node: currentNode }, + { id: 'current-macos', os: 'macos-latest', node: currentNode }, + { id: 'current-windows', os: 'windows-latest', node: currentNode }, + { id: 'minimum-linux', os: 'ubuntu-latest', node: '22.0.0' }, + ], + }); + assert.equal(job['runs-on'], '${{ matrix.os }}'); + const setupPnpm = job.steps.filter((step) => step.uses === actions.pnpm); + assert.equal(setupPnpm.length, 1); + assert.deepEqual(setupPnpm[0].with, { version: '9.12.3', run_install: false }); + const setupNode = job.steps.filter((step) => step.uses === actions.node); + assert.equal(setupNode.length, 1); + assert.deepEqual(setupNode[0].with, { + 'node-version': "${{ matrix.node == '22.0.0' && matrix.node || '' }}", + 'node-version-file': "${{ matrix.node != '22.0.0' && '.nvmrc' || '' }}", + }); + const download = job.steps.filter((step) => step.uses === actions.download); + assert.equal(download.length, 1); + assert.deepEqual(download[0].with, { + name: '${{ needs.' + packId + '.outputs.artifact-name }}', + path: artifactDirectory, + }); + // One runner owns the outside-workspace install and actual installed bootstrap/run/chat/native smokes. + // An extra shell, workspace install or build step cannot silently substitute for this consumer. + assert.deepEqual( + job.steps.filter((step) => step.run).map((step) => step.run), + [consumeCommand], + ); + const consumer = job.steps.find((step) => step.run === consumeCommand); + assert.deepEqual(consumer.env, { + RELAVIUM_PACKAGE_LEG: '${{ matrix.id }}', + RELAVIUM_PACKAGE_SOURCE_SHA: '${{ github.sha }}', + }); + const receipts = job.steps.filter((step) => step.uses === actions.upload); + assert.equal(receipts.length, 1); + assert.deepEqual(receipts[0].with, { + name: '${{ needs.' + packId + '.outputs.artifact-name }}-report-${{ matrix.id }}', + overwrite: true, + path: `${reportDirectory}/installed-consumer.json`, + 'retention-days': 7, + 'if-no-files-found': 'error', + }); + assert.ok(job.steps.indexOf(receipts[0]) > job.steps.indexOf(consumer)); +} + +function checkAggregate(job, packId, consumerId) { + requiredJob(job, true); + assert.deepEqual(job.needs, [packId, consumerId]); + assert.deepEqual(job.outputs, { + 'artifact-name': '${{ needs.' + packId + '.outputs.artifact-name }}', + }); + assert.deepEqual( + job.steps.filter((step) => step.run).map((step) => step.run), + [aggregateCommand], + ); + const validator = job.steps.find((step) => step.run === aggregateCommand); + assert.deepEqual(validator.env, { + RELAVIUM_PACKAGE_JOB_RESULTS: '${{ toJSON(needs) }}', + RELAVIUM_PACKAGE_SOURCE_SHA: '${{ github.sha }}', + }); + const downloads = job.steps.filter((step) => step.uses === actions.download); + assert.deepEqual( + downloads.map((step) => step.with), + [ + { name: '${{ needs.' + packId + '.outputs.artifact-name }}', path: artifactDirectory }, + { + pattern: '${{ needs.' + packId + '.outputs.artifact-name }}-report-*', + path: reportsDirectory, + 'merge-multiple': false, + }, + ], + ); + assert.ok(downloads.every((step) => job.steps.indexOf(step) < job.steps.indexOf(validator))); +} + +for (const [name, packId, consumerId] of [ + ['ci', 'pack-cli', 'packed-cli-consumer'], + ['release', 'pack', 'smoke'], +]) { + test(`${name} packs once without a duplicate CLI build or build cache`, () => { + const workflow = readWorkflow(name); + const job = workflow.jobs[packId]; + assert.deepEqual(job.outputs, { + 'artifact-name': 'relavium-tarball-${{ github.run_id }}-${{ github.run_attempt }}', + }); + // Release has existing explicit tag/main admission steps, which are preserved below. + assert.equal(job['continue-on-error'], undefined); + assert.equal(job.if, undefined); + assert.deepEqual(job.permissions, { contents: 'read' }); + assert.ok(job.steps.every((step) => step['continue-on-error'] === undefined)); + assert.ok(job.steps.every((step) => !step.uses || Object.values(actions).includes(step.uses))); + const commands = job.steps.filter((step) => step.run).map((step) => step.run); + assert.equal(commands.filter((command) => command === packCommand).length, 1); + assert.equal( + commands.filter((command) => command === 'pnpm install --frozen-lockfile').length, + 1, + ); + assert.ok(commands.indexOf('pnpm install --frozen-lockfile') < commands.indexOf(packCommand)); + assert.ok( + commands.every( + (command) => !/\b(?:turbo|prepack|pnpm\s+pack|pnpm\s+[^\n]*\bbuild)\b/.test(command), + ), + ); + assert.equal(job.steps.find((step) => step.run === packCommand).if, undefined); + const uploads = job.steps.filter((step) => step.uses === actions.upload); + assert.equal(uploads.length, 1); + assert.equal(uploads[0].if, undefined); + assert.deepEqual(uploads[0].with, { + name: 'relavium-tarball-${{ github.run_id }}-${{ github.run_attempt }}', + path: `${artifactDirectory}/*.tgz\n${artifactDirectory}/package-test-receipt.json\n`, + 'retention-days': 7, + 'if-no-files-found': 'error', + }); + assert.ok( + job.steps.indexOf(uploads[0]) > job.steps.findIndex((step) => step.run === packCommand), + ); + }); + + test(`${name} requires three current platforms and the separate exact public Node floor`, () => { + checkConsumer(readWorkflow(name).jobs[consumerId], packId); + }); + + test(`${name} always aggregates actual pack/matrix outcomes and all same-artifact receipts`, () => { + checkAggregate(readWorkflow(name).jobs['packed-cli'], packId, consumerId); + }); +} + +test('required CI joins packed acceptance and explicitly refuses every result except success', () => { + const job = readWorkflow('ci').jobs.ci; + assert.ok(job.needs.includes('packed-cli')); + assert.equal(job.if, '${{ always() }}'); + const guard = job.steps.find((step) => step.name === 'Require complete packed CLI controls'); + assert.deepEqual(guard.env, { PACKED_CLI_RESULT: '${{ needs.packed-cli.result }}' }); + assert.equal( + guard.run, + "node -e \"require('node:assert/strict').equal(process.env.PACKED_CLI_RESULT, 'success')\"", + ); + assert.equal(guard.if, undefined); + assert.equal(guard['continue-on-error'], undefined); +}); + +test('release keeps main ancestry, version, real-push publication and npm provenance on the same tarball', () => { + const jobs = readWorkflow('release').jobs; + const ancestry = jobs.pack.steps.find( + (step) => step.name === 'Release ref is reachable from `main`', + ); + assert.equal( + ancestry.if, + "${{ startsWith(github.ref, 'refs/tags/v') || !inputs.allow_off_main }}", + ); + assert.match(ancestry.run, /git merge-base --is-ancestor "\$GITHUB_SHA" origin\/main/); + const version = jobs.pack.steps.find( + (step) => step.name === 'Tag matches the CLI package version', + ); + assert.equal(version.if, "startsWith(github.ref, 'refs/tags/v')"); + assert.match(version.run, /test "\$TAG" = "\$PKG"/); + assert.equal(jobs.publish.needs, 'packed-cli'); + assert.equal( + jobs.publish.if, + "${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}", + ); + assert.deepEqual(jobs.publish.permissions, { contents: 'read', 'id-token': 'write' }); + assert.equal( + jobs.publish.steps.find((step) => step.uses === actions.download).with.name, + '${{ needs.packed-cli.outputs.artifact-name }}', + ); + assert.deepEqual( + jobs.publish.steps.filter((step) => step.run).map((step) => step.run), + ['npm publish ./artifact/*.tgz --provenance --access public'], + ); +}); + +for (const [label, alter] of [ + ['missing minimum-runtime leg', (job) => job.strategy.matrix.include.pop()], + ['advisory platform job', (job) => (job['continue-on-error'] = true)], + ['hidden workspace install', (job) => job.steps.push({ run: 'pnpm install --frozen-lockfile' })], + ['hidden CLI build', (job) => job.steps.push({ run: 'pnpm --filter relavium build' })], + [ + 'pnpm implicit install', + (job) => (job.steps.find((step) => step.uses === actions.pnpm).with.run_install = true), + ], + [ + 'always-upload receipt', + (job) => (job.steps.find((step) => step.uses === actions.upload).if = '${{ always() }}'), + ], + [ + 'private report directory upload', + (job) => (job.steps.find((step) => step.uses === actions.upload).with.path = reportDirectory), + ], + [ + 'different tarball', + (job) => + (job.steps.find((step) => step.uses === actions.download).with.name = 'rebuilt-tarball'), + ], +]) { + test(`workflow structure refuses ${label}`, () => { + const job = readWorkflow('ci').jobs['packed-cli-consumer']; + alter(job); + assert.throws(() => checkConsumer(job, 'pack-cli')); + }); +} diff --git a/tools/ci/require-w8-acceptance.test.mjs b/tools/ci/require-w8-acceptance.test.mjs index 364fe5b7..e2bbd452 100644 --- a/tools/ci/require-w8-acceptance.test.mjs +++ b/tools/ci/require-w8-acceptance.test.mjs @@ -41,7 +41,16 @@ test('the actual required workflow joins both full offline controls and checks t 'usage-mutations', 'fs-mutations', 'secrets-mutations', + 'packed-cli', ]); + const packedGuard = ci.steps.find((step) => step.name === 'Require complete packed CLI controls'); + assert.equal(packedGuard.env.PACKED_CLI_RESULT, '${{ needs.packed-cli.result }}'); + assert.equal( + packedGuard.run, + "node -e \"require('node:assert/strict').equal(process.env.PACKED_CLI_RESULT, 'success')\"", + ); + assert.equal(packedGuard.if, undefined); + assert.equal(packedGuard['continue-on-error'], undefined); const fsGuard = ci.steps.find( (step) => step.name === 'Require complete filesystem causal controls', ); diff --git a/tools/cli-bootstrap/README.md b/tools/cli-bootstrap/README.md index 594bb689..dc04b5a9 100644 --- a/tools/cli-bootstrap/README.md +++ b/tools/cli-bootstrap/README.md @@ -7,6 +7,11 @@ the artifact. Its JSON result includes the entry digest, runtime/platform, scenario results and deduplicated native import/path/filesystem observations. Temporary application paths are represented as ``. +The tooling export `runBootstrap(absolutePackageRoot)` runs those same controls on +an explicitly selected installed package. The package consumer supplies its +verified tarball/install identity; no selector or test hook is added to the CLI. +The default command continues to use repository `dist`. + `node --test tools/cli-bootstrap/witness.test.mjs` proves the witnesses reject swallowed ESM/CommonJS import failures, application FS access, native service dispatch, missing observations and genuine dynamic chunk loading. It also checks @@ -38,6 +43,8 @@ is killed and joined; product subprocess dispatch is forbidden on every OS. Fixture snapshots prove poisoned application files/directories remain unchanged, including on passing controls. Cleanup removes only the exact owned mkdtemp root. -These controls establish repository-compiled behavior on the runtime/OS that -actually ran them. They do not certify installed tarballs, Node's minimum floor, -other platforms, complete Step 7, native owner integration or archival custody. +These controls establish selected-artifact behavior on the runtime/OS that +actually ran them. Alone they do not certify tarball content, dependency install, +Node's minimum floor, other platforms, complete Step 7, native owner integration +or archival custody. Step 7b's package consumer independently supplies the +tarball/install binding and records each actual platform execution. diff --git a/tools/cli-bootstrap/check.mjs b/tools/cli-bootstrap/check.mjs index 99edf0ba..1bb284d6 100644 --- a/tools/cli-bootstrap/check.mjs +++ b/tools/cli-bootstrap/check.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { readFileSync, readdirSync } from 'node:fs'; import { resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { performance } from 'node:perf_hooks'; import { createHash } from 'node:crypto'; import { withFixture, toEntryUrl } from './child.mjs'; @@ -9,164 +9,172 @@ import { metaCases, grammarCases } from './inventory.mjs'; import { assertWitness, importFailureCanary } from './policy.mjs'; const repository = fileURLToPath(new URL('../../', import.meta.url)); -const dist = join(repository, 'apps/cli/dist'); -const entry = join(dist, 'index.js'); -function artifactInventory() { - return readdirSync(dist) - .filter((name) => name.endsWith('.js')) - .sort() - .map((name) => ({ - name, - sha256: createHash('sha256') - .update(readFileSync(join(dist, name))) - .digest('hex'), - })); -} -const artifact = artifactInventory(); -const version = JSON.parse(readFileSync(join(repository, 'apps/cli/package.json'), 'utf8')).version; -function runtimeChunk(prefix) { - const files = readdirSync(dist).filter( - (name) => name.startsWith(`${prefix}-`) && name.endsWith('.js'), - ); - assert.equal(files.length, 1, `one real ${prefix} runtime chunk is required`); - return toEntryUrl(resolve(dist, files[0])); -} -const runtimeUrl = runtimeChunk('run'); -const dispatchUrl = runtimeChunk('dispatch'); -const records = []; -const observed = new Map(); -function retainEvents(events, fixture) { - for (const event of events) { - const normalized = JSON.parse( - JSON.stringify(event).replaceAll(fixture.root, ''), +export async function runBootstrap(packageRoot = join(repository, 'apps/cli')) { + const dist = join(packageRoot, 'dist'); + const entry = join(dist, 'index.js'); + function artifactInventory() { + return readdirSync(dist) + .filter((name) => name.endsWith('.js')) + .sort() + .map((name) => ({ + name, + sha256: createHash('sha256') + .update(readFileSync(join(dist, name))) + .digest('hex'), + })); + } + const artifact = artifactInventory(); + const version = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version; + function runtimeChunk(prefix) { + const files = readdirSync(dist).filter( + (name) => name.startsWith(`${prefix}-`) && name.endsWith('.js'), ); - observed.set(JSON.stringify(normalized), normalized); + assert.equal(files.length, 1, `one real ${prefix} runtime chunk is required`); + return toEntryUrl(resolve(dist, files[0])); + } + const runtimeUrl = runtimeChunk('run'); + const dispatchUrl = runtimeChunk('dispatch'); + const records = []; + const observed = new Map(); + function retainEvents(events, fixture) { + for (const event of events) { + const normalized = JSON.parse( + JSON.stringify(event).replaceAll(fixture.root, ''), + ); + observed.set(JSON.stringify(normalized), normalized); + } } -} -await withFixture(async (fixture) => { - for (const [name, args] of metaCases) { - let referenceOutput; - for (const json of [false, true]) - for (const rootGrant of [false, true]) { - const globals = [ - ...(json ? ['--json'] : []), - ...(rootGrant - ? [ - '--allow-fs-root', - fixture.absentRoot, - '--cwd', - fixture.workspace, - '--config', - join(fixture.home, '.relavium', 'config.toml'), - ] - : []), - ]; - const started = performance.now(); - const result = await fixture.child([entry, ...globals, ...args]); + await withFixture(async (fixture) => { + for (const [name, args] of metaCases) { + let referenceOutput; + for (const json of [false, true]) + for (const rootGrant of [false, true]) { + const globals = [ + ...(json ? ['--json'] : []), + ...(rootGrant + ? [ + '--allow-fs-root', + fixture.absentRoot, + '--cwd', + fixture.workspace, + '--config', + join(fixture.home, '.relavium', 'config.toml'), + ] + : []), + ]; + const started = performance.now(); + const result = await fixture.child([entry, ...globals, ...args]); + retainEvents(result.events, fixture); + assertWitness(result.events, toEntryUrl(entry)); + assert.equal(result.code, 0, `${name}: ${result.stderr}`); + assert.equal(result.stderr, '', name); + if (name.includes('version')) assert.equal(result.stdout, `${version}\n`, name); + else assert.match(result.stdout, /^Usage: relavium/, name); + // Same current artifact/runtime supplies the byte comparison; no historical timing threshold. + referenceOutput ??= result.stdout; + assert.equal( + result.stdout, + referenceOutput, + `${name}: global flags changed existing human meta bytes`, + ); + records.push({ + name, + json, + absentAbsoluteRoot: rootGrant, + exitCode: result.code, + imports: result.events.filter((event) => event.kind === 'load').length, + filesystemCalls: result.events.filter((event) => event.kind === 'fs').length, + milliseconds: Math.round(performance.now() - started), + }); + } + } + for (const [name, args, nonEcho] of grammarCases(fixture.absentRoot)) { + for (const json of [false, true]) { + const result = await fixture.child([entry, ...(json ? ['--json'] : []), ...args]); retainEvents(result.events, fixture); assertWitness(result.events, toEntryUrl(entry)); - assert.equal(result.code, 0, `${name}: ${result.stderr}`); - assert.equal(result.stderr, '', name); - if (name.includes('version')) assert.equal(result.stdout, `${version}\n`, name); - else assert.match(result.stdout, /^Usage: relavium/, name); - // Same current artifact/runtime supplies the byte comparison; no historical timing threshold. - referenceOutput ??= result.stdout; - assert.equal( - result.stdout, - referenceOutput, - `${name}: global flags changed existing human meta bytes`, - ); - records.push({ - name, - json, - absentAbsoluteRoot: rootGrant, - exitCode: result.code, - imports: result.events.filter((event) => event.kind === 'load').length, - filesystemCalls: result.events.filter((event) => event.kind === 'fs').length, - milliseconds: Math.round(performance.now() - started), - }); + assert.equal(result.code, 2, `${name}: ${result.stderr}`); + assert.equal(result.stdout, '', name); + assert.notEqual(result.stderr, '', name); + if (nonEcho !== undefined) + assert.ok(!result.stderr.includes(nonEcho), `${name}: unsafe argument echoed`); + records.push({ name, json, exitCode: result.code }); } - } - for (const [name, args, nonEcho] of grammarCases(fixture.absentRoot)) { - for (const json of [false, true]) { - const result = await fixture.child([entry, ...(json ? ['--json'] : []), ...args]); - retainEvents(result.events, fixture); - assertWitness(result.events, toEntryUrl(entry)); - assert.equal(result.code, 2, `${name}: ${result.stderr}`); - assert.equal(result.stdout, '', name); - assert.notEqual(result.stderr, '', name); - if (nonEcho !== undefined) - assert.ok(!result.stderr.includes(nonEcho), `${name}: unsafe argument echoed`); - records.push({ name, json, exitCode: result.code }); } - } - // An actual execution reaches native grant admission. A swallowed refusal still leaves - // the witness, and a service import ahead of that native check fails this ordering proof. - const admission = await fixture.child([entry, 'chat', '--allow-fs-root', fixture.workspace]); - retainEvents(admission.events, fixture); - assert.notEqual(admission.code, 0); - const firstDenied = admission.events.find((event) => event.denied); - assert.equal( - firstDenied?.kind, - 'fs', - 'real execution did not reach filesystem admission before services', - ); - assert.ok(firstDenied.paths.some((path) => path === fixture.workspace)); - records.push({ - name: 'real-execution-fs-before-service', - exitCode: admission.code, - witness: firstDenied.method, - }); - - // These refuse real emitted chunks, without rewriting the compiled entry or implementation. - for (const [name, failRuntimeUrl, args, diagnostic] of [ - [ - 'entry-runtime-load-failure', - runtimeUrl, - ['run', 'synthetic-absent.yaml'], - 'relavium: a fatal internal error occurred.\n', - ], - [ - 'dispatcher-load-failure', - dispatchUrl, - ['provider', 'list'], - 'The CLI runtime could not be loaded.', - ], - ]) { - const result = await fixture.child([entry, ...args], { failRuntimeUrl }); - retainEvents(result.events, fixture); - assert.ok( - result.events.some( - (event) => event.kind === 'load' && event.url === failRuntimeUrl && event.runtimeFailure, - ), - ); - assert.equal(result.code, 1, name); // Native ABI classification/exit 9 is Step 8. - assert.equal(result.stdout, '', name); - assert.ok(result.stderr.includes(diagnostic), name); - assert.ok(!result.stderr.includes(importFailureCanary), name); - assert.doesNotMatch(result.stderr, /\n\s+at |file:\/\/|Error: synthetic/, name); + // An actual execution reaches native grant admission. A swallowed refusal still leaves + // the witness, and a service import ahead of that native check fails this ordering proof. + const admission = await fixture.child([entry, 'chat', '--allow-fs-root', fixture.workspace]); + retainEvents(admission.events, fixture); + assert.notEqual(admission.code, 0); + const firstDenied = admission.events.find((event) => event.denied); assert.equal( - result.events.filter((event) => event.denied && !event.runtimeFailure).length, - 0, - name, + firstDenied?.kind, + 'fs', + 'real execution did not reach filesystem admission before services', ); - records.push({ name, exitCode: result.code, refusedUrl: failRuntimeUrl }); - } -}); -assert.deepEqual(artifactInventory(), artifact, 'compiled graph changed during bootstrap controls'); -console.log( - JSON.stringify({ + assert.ok(firstDenied.paths.some((path) => path === fixture.workspace)); + records.push({ + name: 'real-execution-fs-before-service', + exitCode: admission.code, + witness: firstDenied.method, + }); + + // These refuse real emitted chunks, without rewriting the compiled entry or implementation. + for (const [name, failRuntimeUrl, args, diagnostic] of [ + [ + 'entry-runtime-load-failure', + runtimeUrl, + ['run', 'synthetic-absent.yaml'], + 'relavium: a fatal internal error occurred.\n', + ], + [ + 'dispatcher-load-failure', + dispatchUrl, + ['provider', 'list'], + 'The CLI runtime could not be loaded.', + ], + ]) { + const result = await fixture.child([entry, ...args], { failRuntimeUrl }); + retainEvents(result.events, fixture); + assert.ok( + result.events.some( + (event) => event.kind === 'load' && event.url === failRuntimeUrl && event.runtimeFailure, + ), + ); + assert.equal(result.code, 1, name); // Native ABI classification/exit 9 is Step 8. + assert.equal(result.stdout, '', name); + assert.ok(result.stderr.includes(diagnostic), name); + assert.ok(!result.stderr.includes(importFailureCanary), name); + assert.doesNotMatch(result.stderr, /\n\s+at |file:\/\/|Error: synthetic/, name); + assert.equal( + result.events.filter((event) => event.denied && !event.runtimeFailure).length, + 0, + name, + ); + records.push({ name, exitCode: result.code, refusedUrl: failRuntimeUrl }); + } + }); + assert.deepEqual( + artifactInventory(), + artifact, + 'compiled graph changed during bootstrap controls', + ); + return { kind: 'compiled-cli-bootstrap', node: process.version, platform: process.platform, entrySha256: createHash('sha256').update(readFileSync(entry)).digest('hex'), artifact, - scope: - 'untouched repository dist on current runtime/OS; no installed-tarball or minimum-floor claim', + scope: 'untouched selected CLI artifact on the actual reported runtime and OS', controls: records.length, records, observed: [...observed.values()], - }), -); + }; +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + assert.equal(process.argv.length, 2, 'use the exported tooling runner for installed artifacts'); + console.log(JSON.stringify(await runBootstrap())); +} diff --git a/tools/cli-package/README.md b/tools/cli-package/README.md new file mode 100644 index 00000000..205a5d27 --- /dev/null +++ b/tools/cli-package/README.md @@ -0,0 +1,67 @@ +# Exact installed CLI package controls + +These tooling runners implement Wave 3 Step 7b under +[ADR-0111](../../docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md). +The canonical package and release contract lives in +[packaging.md](../../docs/reference/cli/packaging.md). + +```sh +pnpm package:cli:pack /absolute/empty/artifact-directory +pnpm package:cli:consume /absolute/artifact-directory /absolute/fresh/report-directory +pnpm smoke:cli-package +``` + +The producer emits the five inlined dependency packages in order with the installed +compiler and no build cache. Prepack performs the sole final CLI build. The runner +observes one actual prepack build-start record, checks the emitted graph, and +independently inspects the produced gzip/USTAR bytes. The inspector never extracts +the archive. Every JS and migration byte must match the finalized build and +canonical migrations; package metadata must match pnpm's publication transform. +An exact public allowlist, checksums, modes, hashbangs, byte ceilings and negative +controls exclude links, traversal, duplicates, extra files, maps and private build +inventories. This deliberately supports the pinned packer's dialect, not general tar. + +The resulting public `package-test-receipt.json` records `compiled_package_test`. +It does **not** certify original-TypeScript maps, symbolication, encrypted custody +or a production release candidate. Those are Step 9 obligations. No private map, +metafile or per-source inventory enters the public artifact. + +The consumer verifies the chosen bytes before installation, installs a copy outside +the workspace, and compares every installed public file with the archive receipt. +Its temporary project also pins pnpm 9.12.3 and permits lifecycle scripts only for +the existing better-sqlite3/esbuild allowlist. Dependency installation downloads +public registry packages; runtime provider responses are synthetic and keychain API +access is refused. No workspace install, product build, repack, provider request or +credential is used by a consumer. Actual installed dependency versions and metadata +digests are recorded; a workspace resolution is never substituted. + +Each consumer repeats the 175 untouched-entry bootstrap controls and the six +[installed behavior cases](smoke/README.md). Detailed local reports remain in the +support directory. CI uploads **only** the bounded public `installed-consumer.json`: +source/artifact/origin/runtime identities, public file/dependency digests, and exact +successful case inventory. It does not upload transport bodies, host paths or logs. + +Required PR/push consumers use current `.nvmrc` Linux/macOS/Windows and exact Node +22.0.0 Linux. The aggregate reinspects the tarball and requires all four unique +matching receipts plus successful pack/matrix jobs. Missing, failed, skipped, +cancelled, duplicate, wrong-runtime or mismatched evidence refuses. The required +`ci` job also checks the aggregate's success under `always()`. + +Artifacts are named by originating run and pack attempt. A failed-consumer retry +reuses that original tarball and preserves successful same-artifact receipts; it +may replace only its own latest smoke receipt. Consumer attempt identities must +fall between the original pack and current aggregate attempts. The workflow's +same-run artifact handoff and job dependencies supply provenance; JSON claims +alone do not authenticate an arbitrary external artifact. Release smoke uses the +same consumer, while release reservation/archive/retry custody remains Step 9. + +Local runs retain their actual runtime/platform and a local origin. They cannot +be promoted to one of the four required CI legs. The test suites include native +child timeout/output/cleanup controls, corrupted archives, receipt omissions, +origin/runtime mismatches and workflow mutations. Neither a local pass nor a +synthetic aggregate fixture is remote platform execution evidence. + +`smoke:cli-package` is the local full-CI counterpart: it owns and cleans one fresh +candidate plus one actual-host consumer. Required GitHub jobs separately own the +same-artifact four-platform matrix; they do not run this local wrapper or rebuild +in a consumer. diff --git a/tools/cli-package/aggregate.mjs b/tools/cli-package/aggregate.mjs new file mode 100644 index 00000000..6483e1c1 --- /dev/null +++ b/tools/cli-package/aggregate.mjs @@ -0,0 +1,123 @@ +import { Buffer } from 'node:buffer'; +import { execFileSync } from 'node:child_process'; +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readdirSync, + readSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { ARCHIVE_LIMITS, inspectPackageArchive } from './archive.mjs'; +import { + aggregateConsumers, + originFromEnvironment, + PackageBindingError, + requirePackageJobResults, + validatePackageReceipt, +} from './binding.mjs'; + +/** @param {string} path @param {number} maximum */ +function boundedFile(path, maximum) { + if (!lstatSync(path).isFile()) throw new PackageBindingError('INPUT_FILE'); + const descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const stat = fstatSync(descriptor); + if (!stat.isFile() || stat.size > maximum) throw new PackageBindingError('INPUT_SIZE'); + const bytes = Buffer.alloc(stat.size + 1); + let offset = 0; + while (offset < bytes.length) { + const count = readSync(descriptor, bytes, offset, bytes.length - offset, offset); + if (count === 0) break; + offset += count; + } + if (offset !== stat.size) throw new PackageBindingError('INPUT_CHANGED'); + return bytes.subarray(0, offset); + } finally { + closeSync(descriptor); + } +} + +/** @param {string} path */ +function directory(path) { + if (!lstatSync(path).isDirectory()) throw new PackageBindingError('INPUT_DIRECTORY'); + return readdirSync(path); +} + +/** @param {string[]} args @param {Record} environment */ +export function runAggregate(args, environment) { + if (args.length !== 2 || !args[0] || !args[1]) throw new PackageBindingError('ARGUMENTS'); + const artifactDirectory = resolve(args[0]); + const reportsDirectory = resolve(args[1]); + const repository = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + const source = + environment.RELAVIUM_PACKAGE_SOURCE_SHA ?? + execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: repository, + encoding: 'utf8', + timeout: 10000, + maxBuffer: 4096, + }).trim(); + const runtime = boundedFile(join(repository, '.nvmrc'), 20).toString('utf8').trim(); + if (!/^\d+$/u.test(runtime)) throw new PackageBindingError('RUNTIME'); + const expected = { + source, + origin: originFromEnvironment(environment), + nodeMajor: Number(runtime), + }; + requirePackageJobResults(JSON.parse(environment.RELAVIUM_PACKAGE_JOB_RESULTS ?? 'null')); + const artifactNames = directory(artifactDirectory); + const receipt = validatePackageReceipt( + JSON.parse( + boundedFile(join(artifactDirectory, 'package-test-receipt.json'), 512 * 1024).toString( + 'utf8', + ), + ), + expected, + ); + if ( + artifactNames.length !== 2 || + !artifactNames.includes(receipt.tarball) || + !artifactNames.includes('package-test-receipt.json') + ) + throw new PackageBindingError('ARTIFACT_FILES'); + const tarball = boundedFile( + join(artifactDirectory, receipt.tarball), + ARCHIVE_LIMITS.compressedBytes, + ); + const archive = inspectPackageArchive(tarball, { + expectedFiles: new Map( + receipt.files.map((file) => [file.path, { sha256: file.sha256, bytes: file.bytes }]), + ), + expectedPackage: receipt.package, + }); + if (archive.sha256 !== receipt.tarballSha256 || archive.bytes !== receipt.archiveBytes) + throw new PackageBindingError('ARTIFACT_BINDING'); + const folders = directory(reportsDirectory); + if (folders.length !== 4) throw new PackageBindingError('REPORT_COUNT'); + const reports = folders.map((folder) => { + if (!/^[A-Za-z0-9_-]{1,100}$/u.test(folder)) throw new PackageBindingError('REPORT_DIRECTORY'); + const path = join(reportsDirectory, folder); + const names = directory(path); + if (names.length !== 1 || names[0] !== 'installed-consumer.json') + throw new PackageBindingError('REPORT_FILES'); + return JSON.parse(boundedFile(join(path, names[0]), 512 * 1024).toString('utf8')); + }); + return aggregateConsumers(receipt, reports, expected); +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + try { + console.log(JSON.stringify(runAggregate(process.argv.slice(2), process.env))); + } catch (error) { + console.error( + error instanceof PackageBindingError + ? error.message + : 'CLI package aggregation refused: INPUT_INVALID', + ); + process.exitCode = 1; + } +} diff --git a/tools/cli-package/archive.mjs b/tools/cli-package/archive.mjs new file mode 100644 index 00000000..015880da --- /dev/null +++ b/tools/cli-package/archive.mjs @@ -0,0 +1,282 @@ +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; +import { isDeepStrictEqual, TextDecoder } from 'node:util'; +import { gunzipSync, inflateRawSync } from 'node:zlib'; + +// This inspects the pinned pnpm pack dialect, not arbitrary tar archives. There +// is no extraction and no fallback to a more permissive platform tar executable. +export const ARCHIVE_LIMITS = Object.freeze({ + compressedBytes: 16 * 1024 * 1024, + expandedBytes: 32 * 1024 * 1024, + fileBytes: 8 * 1024 * 1024, + files: 1024, + manifestBytes: 128 * 1024, +}); + +export class PackageArchiveError extends Error { + /** @param {string} code @param {ErrorOptions} [options] */ + constructor(code, options) { + // Never echo archive paths, manifest data or body contents in diagnostics. + super(`CLI package archive refused: ${code}`, options); + this.name = 'PackageArchiveError'; + this.code = code; + } +} + +/** @param {string} code @returns {never} */ +function refuse(code) { + throw new PackageArchiveError(code); +} + +/** @param {unknown} value @returns {value is Record} */ +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** @param {Uint8Array} value */ +function digest(value) { + return createHash('sha256').update(value).digest('hex'); +} + +/** @param {Uint8Array} bytes */ +function utf8(bytes) { + try { + return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes); + } catch (cause) { + throw new PackageArchiveError('UTF8', { cause }); + } +} + +/** @param {Buffer} bytes */ +function unpack(bytes) { + if (bytes.length < 18 || bytes.length > ARCHIVE_LIMITS.compressedBytes) refuse('GZIP_SIZE'); + // Pinned pnpm emits one gzip member, DEFLATE, no optional fields. Rejecting + // unknown flags also avoids two parsers interpreting different byte ranges. + if (bytes[0] !== 0x1f || bytes[1] !== 0x8b || bytes[2] !== 8 || bytes[3] !== 0) { + refuse('GZIP_HEADER'); + } + try { + // gunzip validates the native CRC/ISIZE. Its permissive concatenation and + // trailing-zero behavior is separately fenced by exact DEFLATE consumption. + const result = gunzipSync(bytes, { maxOutputLength: ARCHIVE_LIMITS.expandedBytes }); + /** @type {unknown} */ + const inflated = inflateRawSync(bytes.subarray(10), { + info: true, + maxOutputLength: ARCHIVE_LIMITS.expandedBytes, + }); + if ( + !isRecord(inflated) || + !isRecord(inflated.engine) || + inflated.engine.bytesWritten !== bytes.length - 18 + ) { + refuse('GZIP_TRAILING'); + } + return result; + } catch (cause) { + if (cause instanceof PackageArchiveError) throw cause; + throw new PackageArchiveError('GZIP_INVALID', { cause }); + } +} + +/** @param {Buffer} field */ +function octal(field) { + const text = field.toString('latin1'); + if (!/^[0-7]+[ \0]*$/u.test(text)) refuse('TAR_NUMBER'); + const value = Number.parseInt(text, 8); + if (!Number.isSafeInteger(value)) refuse('TAR_NUMBER'); + return value; +} + +/** @param {Buffer} field */ +function nameField(field) { + const end = field.indexOf(0); + if (end < 1 || field.subarray(end).some((byte) => byte !== 0)) refuse('TAR_NAME'); + return utf8(field.subarray(0, end)); +} + +/** @param {string} path */ +export function assertPublicPackagePath(path) { + if ( + !/^package\/(?:dist\/[A-Za-z0-9_-]+\.js|drizzle\/[0-9]{4}_[a-z0-9_]+\.sql|drizzle\/meta\/(?:[0-9]{4}_snapshot|_journal)\.json|package\.json|README\.md|CHANGELOG\.md|LICENSE)$/u.test( + path, + ) + ) { + refuse('PUBLIC_PATH'); + } + if (/\/(?:con|prn|aux|nul|com[1-9]|lpt[1-9])\.js$/iu.test(path)) refuse('PUBLIC_PATH'); +} + +/** @param {Buffer} header */ +function readHeader(header) { + const checksum = octal(header.subarray(148, 156)); + let actual = 0; + for (let index = 0; index < 512; index += 1) { + actual += index >= 148 && index < 156 ? 32 : (header[index] ?? 0); + } + if (checksum !== actual) refuse('TAR_CHECKSUM'); + if (!header.subarray(257, 265).equals(Buffer.from('ustar\0' + '00'))) refuse('TAR_FORMAT'); + if (header[156] !== 48) refuse('TAR_TYPE'); + // No linkname, owner strings, prefix, extension records or reserved bytes. + // A regular USTAR file is the only dialect produced by our current packer. + for (const [start, end] of [ + [157, 257], + [265, 329], + [345, 512], + ]) { + if (start === undefined || end === undefined) refuse('TAR_FORMAT'); + if (header.subarray(start, end).some((byte) => byte !== 0)) refuse('TAR_METADATA'); + } + for (const [start, end] of [ + [108, 116], + [116, 124], + [329, 337], + [337, 345], + ]) { + if (start === undefined || end === undefined) refuse('TAR_FORMAT'); + if (octal(header.subarray(start, end)) !== 0) refuse('TAR_METADATA'); + } + octal(header.subarray(136, 148)); // Validate mtime syntax without requiring a clock value. + const path = nameField(header.subarray(0, 100)); + assertPublicPackagePath(path); + const mode = octal(header.subarray(100, 108)); + if (mode !== (path === 'package/dist/index.js' ? 0o755 : 0o644)) refuse('TAR_MODE'); + const size = octal(header.subarray(124, 136)); + if (size > ARCHIVE_LIMITS.fileBytes) refuse('FILE_SIZE'); + return { path, mode, size }; +} + +/** @param {Buffer} bytes @param {Record} expected */ +function manifest(bytes, expected) { + if (bytes.length > ARCHIVE_LIMITS.manifestBytes) refuse('MANIFEST_SIZE'); + /** @type {unknown} */ + let parsed; + try { + parsed = JSON.parse(utf8(bytes)); + } catch (cause) { + throw new PackageArchiveError('MANIFEST_JSON', { cause }); + } + if (!isRecord(parsed) || !isDeepStrictEqual(parsed, expected)) refuse('MANIFEST_DRIFT'); + if ( + parsed.name !== 'relavium' || + typeof parsed.version !== 'string' || + parsed.private === true || + !isRecord(parsed.dependencies) + ) { + refuse('MANIFEST_RUNTIME'); + } + for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) { + const dependencies = parsed[field]; + if (dependencies === undefined) continue; + if (!isRecord(dependencies)) refuse('MANIFEST_RUNTIME'); + for (const [name, version] of Object.entries(dependencies)) { + if ( + name.startsWith('@relavium/') || + typeof version !== 'string' || + /^(?:workspace|catalog|file|link):/u.test(version) + ) { + refuse('MANIFEST_RUNTIME'); + } + } + } + return parsed; +} + +/** + * Compare an actual packed artifact with the caller's finalized public inventory. + * The expected manifest includes pnpm's catalog/workspace publication transform; + * it is compared semantically in full, every other file byte-for-byte. + * @param {Uint8Array} bytes + * @param {{expectedFiles: Map, expectedPackage: Record}} options + * @returns {{sha256: string, bytes: number, files: {path: string, bytes: number, sha256: string, mode: number}[], package: Record}} + * @throws {PackageArchiveError} for an unsupported or mismatching archive. + */ +export function inspectPackageArchive(bytes, { expectedFiles, expectedPackage }) { + if (!(bytes instanceof Uint8Array)) refuse('ARCHIVE_INPUT'); + if (!(expectedFiles instanceof Map) || expectedFiles.size > ARCHIVE_LIMITS.files) { + refuse('EXPECTED_INVENTORY'); + } + for (const [path, contents] of expectedFiles) { + assertPublicPackagePath(path); + if (contents instanceof Uint8Array) { + if (contents.byteLength > ARCHIVE_LIMITS.fileBytes) refuse('EXPECTED_INVENTORY'); + } else if ( + !isRecord(contents) || + Object.keys(contents).length !== 2 || + typeof contents.sha256 !== 'string' || + !/^[0-9a-f]{64}$/u.test(contents.sha256) || + typeof contents.bytes !== 'number' || + !Number.isSafeInteger(contents.bytes) || + contents.bytes < 0 || + contents.bytes > ARCHIVE_LIMITS.fileBytes + ) { + refuse('EXPECTED_INVENTORY'); + } + } + if (!expectedFiles.has('package/package.json') || !expectedFiles.has('package/dist/index.js')) { + refuse('EXPECTED_INVENTORY'); + } + if (bytes.byteLength > ARCHIVE_LIMITS.compressedBytes) refuse('GZIP_SIZE'); + const compressed = Buffer.from(bytes); + const tar = unpack(compressed); + const seen = new Set(); + const portableNames = new Set(); + const files = []; + let offset = 0; + let ended = false; + /** @type {Record | undefined} */ + let packageManifest; + while (offset < tar.length) { + if (tar.length - offset < 512) refuse('TAR_TRUNCATED'); + const header = tar.subarray(offset, offset + 512); + if (header.every((byte) => byte === 0)) { + if (tar.length - offset !== 1024 || tar.subarray(offset).some((byte) => byte !== 0)) { + refuse('TAR_END'); + } + ended = true; + break; + } + if (files.length >= ARCHIVE_LIMITS.files) refuse('FILE_COUNT'); + const { path, mode, size } = readHeader(header); + if (seen.has(path)) refuse('DUPLICATE_FILE'); + seen.add(path); + if (portableNames.has(path.toLowerCase())) refuse('PATH_ALIAS'); + portableNames.add(path.toLowerCase()); + const expected = expectedFiles.get(path); + if (expected === undefined) refuse('EXTRA_FILE'); + const start = offset + 512; + const end = start + size; + const next = start + Math.ceil(size / 512) * 512; + if (next > tar.length) refuse('TAR_TRUNCATED'); + if (tar.subarray(end, next).some((byte) => byte !== 0)) refuse('TAR_PADDING'); + const contents = tar.subarray(start, end); + if (path === 'package/package.json') packageManifest = manifest(contents, expectedPackage); + if (expected instanceof Uint8Array) { + if (path !== 'package/package.json' && !contents.equals(expected)) refuse('FILE_DRIFT'); + } else if (contents.length !== expected.bytes || digest(contents) !== expected.sha256) { + refuse('FILE_DRIFT'); + } + if (path.endsWith('.js')) { + const source = utf8(contents); + if ( + path === 'package/dist/index.js' + ? !source.startsWith('#!/usr/bin/env node\n') + : source.startsWith('#!') + ) { + refuse('HASHBANG'); + } + if (/sourceMappingURL\s*=/u.test(source)) refuse('MAP_LINK'); + } + files.push({ path, bytes: size, sha256: digest(contents), mode }); + offset = next; + } + if (!ended) refuse('TAR_END'); + if (seen.size !== expectedFiles.size || !packageManifest) refuse('MISSING_FILE'); + return { + sha256: digest(compressed), + bytes: compressed.length, + files: files.sort((left, right) => + left.path < right.path ? -1 : left.path > right.path ? 1 : 0, + ), + package: packageManifest, + }; +} diff --git a/tools/cli-package/archive.test.mjs b/tools/cli-package/archive.test.mjs new file mode 100644 index 00000000..2ceaee4f --- /dev/null +++ b/tools/cli-package/archive.test.mjs @@ -0,0 +1,384 @@ +import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; +import { createHash } from 'node:crypto'; +import { test } from 'node:test'; +import { gzipSync } from 'node:zlib'; +import { ARCHIVE_LIMITS, inspectPackageArchive, PackageArchiveError } from './archive.mjs'; + +const expectedPackage = { + name: 'relavium', + version: '0.1.1', + type: 'module', + bin: { relavium: 'dist/index.js' }, + dependencies: { commander: '^14.0.0' }, + devDependencies: { '@relavium/core': '0.0.0' }, +}; + +/** @returns {Map} */ +function inventory() { + return new Map([ + ['package/dist/index.js', Buffer.from('#!/usr/bin/env node\nimport "./chunk-ABC.js";\n')], + ['package/dist/chunk-ABC.js', Buffer.from('export const value = "é";\n')], + ['package/drizzle/0000_first.sql', Buffer.from('CREATE TABLE fixture(id TEXT);\n')], + ['package/drizzle/meta/0000_snapshot.json', Buffer.from('{}\n')], + ['package/drizzle/meta/_journal.json', Buffer.from('{"entries":[]}\n')], + ['package/package.json', Buffer.from(JSON.stringify(expectedPackage))], + ['package/README.md', Buffer.from('fixture package\n')], + ['package/CHANGELOG.md', Buffer.from('fixture changes\n')], + ['package/LICENSE', Buffer.from('fixture license\n')], + ]); +} + +/** @param {Buffer} header */ +function checksum(header) { + header.fill(32, 148, 156); + header.write( + header + .reduce((sum, byte) => sum + byte, 0) + .toString(8) + .padStart(6, '0'), + 148, + ); + header[154] = 0; + header[155] = 32; +} + +/** @param {string} path @param {Uint8Array} bytes */ +function entry(path, bytes) { + const header = Buffer.alloc(512); + header.write(path, 0, 100); + for (const offset of [100, 108, 116, 329, 337]) header.write('000000 \0', offset); + header.write(path === 'package/dist/index.js' ? '000755' : '000644', 100); + header.write(bytes.length.toString(8).padStart(11, '0') + ' ', 124); + header.write('00000000000 ', 136); + header[156] = 48; + header.write('ustar\0' + '00', 257); + checksum(header); + return Buffer.concat([header, bytes, Buffer.alloc((512 - (bytes.length % 512)) % 512)]); +} + +/** @param {Map} [files] */ +function tar(files = inventory()) { + return Buffer.concat([ + ...[...files].map(([path, bytes]) => entry(path, bytes)), + Buffer.alloc(1024), + ]); +} + +/** @param {Buffer} input @param {string} code @param {Map} [files] */ +function rejects(input, code, files = inventory()) { + assert.throws( + () => inspectPackageArchive(input, { expectedFiles: files, expectedPackage }), + (error) => error instanceof PackageArchiveError && error.code === code, + ); +} + +/** @param {(header: Buffer) => void} mutate */ +function mutateHeader(mutate) { + const input = tar(); + mutate(input.subarray(0, 512)); + checksum(input.subarray(0, 512)); + return gzipSync(input); +} + +test('inspects every finalized byte, USTAR mode and artifact digest', () => { + const bytes = gzipSync(tar()); + const result = inspectPackageArchive(bytes, { expectedFiles: inventory(), expectedPackage }); + assert.equal(result.sha256, createHash('sha256').update(bytes).digest('hex')); + assert.equal(result.bytes, bytes.length); + assert.equal(result.files.length, inventory().size); + assert.deepEqual(result.package, expectedPackage); + for (const [path, contents] of inventory()) { + assert.deepEqual( + result.files.find((file) => file.path === path), + { + path, + bytes: contents.length, + sha256: createHash('sha256').update(contents).digest('hex'), + mode: path === 'package/dist/index.js' ? 0o755 : 0o644, + }, + ); + } +}); + +test('publication manifest comparison is semantic and permits pnpm private devDependency transforms', () => { + const files = inventory(); + files.set('package/package.json', Buffer.from(JSON.stringify(expectedPackage, null, 2))); + const result = inspectPackageArchive(gzipSync(tar(files)), { + expectedFiles: inventory(), + expectedPackage, + }); + assert.deepEqual(result.package, expectedPackage); +}); + +test('receipt digest inventory binds every byte including manifest formatting', () => { + const expectedFiles = new Map( + [...inventory()].map(([path, contents]) => [ + path, + { + sha256: createHash('sha256').update(contents).digest('hex'), + bytes: contents.length, + }, + ]), + ); + assert.equal( + inspectPackageArchive(gzipSync(tar()), { expectedFiles, expectedPackage }).files.length, + 9, + ); + const files = inventory(); + files.set('package/package.json', Buffer.from(JSON.stringify(expectedPackage, null, 2))); + assert.throws( + () => inspectPackageArchive(gzipSync(tar(files)), { expectedFiles, expectedPackage }), + (error) => error instanceof PackageArchiveError && error.code === 'FILE_DRIFT', + ); + expectedFiles.set('package/dist/index.js', { sha256: 'invalid', bytes: 1 }); + assert.throws( + () => inspectPackageArchive(gzipSync(tar()), { expectedFiles, expectedPackage }), + (error) => error instanceof PackageArchiveError && error.code === 'EXPECTED_INVENTORY', + ); +}); + +test('compressed stream refuses truncation, CRC errors, extra members and trailing zeros', () => { + const bytes = gzipSync(tar()); + rejects(bytes.subarray(0, bytes.length - 3), 'GZIP_INVALID'); + const badCrc = Buffer.from(bytes); + badCrc[badCrc.length - 8] = (badCrc[badCrc.length - 8] ?? 0) ^ 1; + rejects(badCrc, 'GZIP_INVALID'); + rejects(Buffer.concat([bytes, gzipSync(Buffer.alloc(0))]), 'GZIP_TRAILING'); + rejects(Buffer.concat([bytes, Buffer.alloc(1)]), 'GZIP_TRAILING'); + const badFlags = Buffer.from(bytes); + badFlags[3] = 8; + rejects(badFlags, 'GZIP_HEADER'); + rejects(Buffer.alloc(ARCHIVE_LIMITS.compressedBytes + 1), 'GZIP_SIZE'); + rejects(gzipSync(Buffer.alloc(ARCHIVE_LIMITS.expandedBytes + 1)), 'GZIP_INVALID'); +}); + +test('tar refuses bad checksum, unsupported type/format/metadata and numeric fields', () => { + const wrongChecksum = tar(); + wrongChecksum[0] = 120; + rejects(gzipSync(wrongChecksum), 'TAR_CHECKSUM'); + for (const type of ['1', '2', '3', '4', '5', '6', '7', 'x', 'g', 'L', 'K', '\0']) { + rejects( + mutateHeader((header) => { + header.write(type, 156); + }), + 'TAR_TYPE', + ); + } + rejects( + mutateHeader((header) => { + header.write('ustar ', 257); + }), + 'TAR_FORMAT', + ); + for (const offset of [157, 265, 297, 345, 500]) { + rejects( + mutateHeader((header) => { + header[offset] = 65; + }), + 'TAR_METADATA', + ); + } + rejects( + mutateHeader((header) => { + header.write('000001', 108); + }), + 'TAR_METADATA', + ); + for (const value of ['-0000000001 ', '00000000009 ', '0000000001x ', ' ']) { + rejects( + mutateHeader((header) => { + header.write(value, 124); + }), + 'TAR_NUMBER', + ); + } + rejects( + mutateHeader((header) => { + header[124] = 128; + }), + 'TAR_NUMBER', + ); +}); + +test('tar refuses unsafe/noncanonical names, source/maps/private inventories even when expected', () => { + for (const path of [ + '/package/dist/index.js', + 'package/../dist/index.js', + 'package/dist/../index.js', + 'package//dist/index.js', + './package/dist/index.js', + 'package\\dist\\index.js', + 'package/dist/chunk.js.map', + 'package/dist/source.ts', + 'package/src/index.js', + 'package/dist/metafile.json', + 'package/private-manifest.json', + 'package/dist/chunk.JS', + 'package/dist/index.js:stream', + 'package/dist/index.js\n', + ]) { + rejects( + mutateHeader((header) => { + header.fill(0, 0, 100); + header.write(path, 0, 100); + }), + 'PUBLIC_PATH', + ); + } + rejects( + mutateHeader((header) => { + header[40] = 65; + }), + 'TAR_NAME', + ); + rejects( + mutateHeader((header) => { + header.fill(65, 0, 100); + }), + 'TAR_NAME', + ); + rejects( + mutateHeader((header) => { + header[0] = 255; + }), + 'UTF8', + ); + const files = inventory(); + files.set('package/dist/index.js.map', Buffer.from('{}')); + rejects(gzipSync(tar(files)), 'PUBLIC_PATH', files); +}); + +test('tar refuses duplicate, absent, extra and changed public files', () => { + const index = inventory().get('package/dist/index.js'); + assert.ok(index); + rejects( + gzipSync(Buffer.concat([entry('package/dist/index.js', index), tar()])), + 'DUPLICATE_FILE', + ); + const missing = inventory(); + missing.delete('package/LICENSE'); + rejects(gzipSync(tar(missing)), 'MISSING_FILE'); + const extra = inventory(); + extra.set('package/dist/extra.js', Buffer.from('export {};')); + rejects(gzipSync(tar(extra)), 'EXTRA_FILE'); + const changed = inventory(); + changed.set('package/drizzle/0000_first.sql', Buffer.from('DROP TABLE fixture;')); + rejects(gzipSync(tar(changed)), 'FILE_DRIFT'); +}); + +test('tar requires complete bodies, zero padding and exactly two final zero records', () => { + rejects(gzipSync(tar().subarray(0, 520)), 'TAR_TRUNCATED'); + rejects(gzipSync(tar().subarray(0, 100)), 'TAR_TRUNCATED'); + rejects(gzipSync(tar().subarray(0, -512)), 'TAR_END'); + rejects(gzipSync(tar().subarray(0, -1024)), 'TAR_END'); + rejects(gzipSync(Buffer.concat([tar(), Buffer.alloc(512)])), 'TAR_END'); + const padded = tar(); + padded[1023] = 1; + rejects(gzipSync(padded), 'TAR_PADDING'); + const trailing = tar(); + trailing[trailing.length - 1] = 1; + rejects(gzipSync(trailing), 'TAR_END'); + rejects( + mutateHeader((header) => { + header.write((ARCHIVE_LIMITS.fileBytes + 1).toString(8).padStart(11, '0') + ' ', 124); + }), + 'FILE_SIZE', + ); +}); + +test('only executable entry owns its exact hashbang, no output has a map link', () => { + for (const mode of ['000644', '004755', '000777']) { + rejects( + mutateHeader((header) => { + header.write(mode, 100); + }), + 'TAR_MODE', + ); + } + for (const [path, source, code] of [ + ['package/dist/index.js', '#!/usr/bin/node\n', 'HASHBANG'], + ['package/dist/index.js', 'export {};\n', 'HASHBANG'], + ['package/dist/chunk-ABC.js', '#!/usr/bin/env node\n', 'HASHBANG'], + ['package/dist/chunk-ABC.js', 'export {};\n//# sourceMappingURL=chunk.js.map\n', 'MAP_LINK'], + [ + 'package/dist/chunk-ABC.js', + 'export {};\n/*# sourceMappingURL=data:application/json;base64,e30= */', + 'MAP_LINK', + ], + ]) { + assert.ok(path && source && code); + const files = inventory(); + files.set(path, Buffer.from(source)); + rejects(gzipSync(tar(files)), code, files); + } +}); + +test('manifest rejects runtime drift and unresolved/private runtime dependencies', () => { + const drift = inventory(); + drift.set( + 'package/package.json', + Buffer.from(JSON.stringify({ ...expectedPackage, scripts: { install: 'bad' } })), + ); + rejects(gzipSync(tar(drift)), 'MANIFEST_DRIFT'); + for (const dependencies of [ + { commander: 'catalog:' }, + { commander: 'workspace:*' }, + { '@relavium/core': '0.0.0' }, + ]) { + const badPackage = { ...expectedPackage, dependencies }; + const files = inventory(); + files.set('package/package.json', Buffer.from(JSON.stringify(badPackage))); + assert.throws( + () => + inspectPackageArchive(gzipSync(tar(files)), { + expectedFiles: files, + expectedPackage: badPackage, + }), + (error) => error instanceof PackageArchiveError && error.code === 'MANIFEST_RUNTIME', + ); + } +}); + +test('portable install paths refuse Windows device names and case aliases', () => { + const aliases = inventory(); + aliases.set('package/dist/Chunk-ABC.js', Buffer.from('export {};')); + rejects(gzipSync(tar(aliases)), 'PATH_ALIAS', aliases); + for (const name of ['CON', 'prn', 'AUX', 'nul', 'com1', 'LPT9']) { + const devices = inventory(); + devices.set(`package/dist/${name}.js`, Buffer.from('export {};')); + rejects(gzipSync(tar(devices)), 'PUBLIC_PATH', devices); + } +}); + +test('inventory and manifest resource ceilings are enforced before comparison', () => { + const files = inventory(); + while (files.size < ARCHIVE_LIMITS.files) { + files.set(`package/dist/bound-${files.size}.js`, Buffer.from('export {};')); + } + const atLimit = inspectPackageArchive(gzipSync(tar(files)), { + expectedFiles: files, + expectedPackage, + }); + assert.equal(atLimit.files.length, ARCHIVE_LIMITS.files); + const overLimit = Buffer.concat([ + tar(files).subarray(0, -1024), + entry('package/dist/overflow.js', Buffer.alloc(0)), + Buffer.alloc(1024), + ]); + rejects(gzipSync(overLimit), 'FILE_COUNT', files); + files.set('package/dist/overflow.js', Buffer.alloc(0)); + rejects(gzipSync(tar(files)), 'EXPECTED_INVENTORY', files); + const oversizedManifest = inventory(); + oversizedManifest.set( + 'package/package.json', + Buffer.from(' '.repeat(ARCHIVE_LIMITS.manifestBytes + 1)), + ); + rejects(gzipSync(tar(oversizedManifest)), 'MANIFEST_SIZE', oversizedManifest); + const invalidManifest = inventory(); + invalidManifest.set('package/package.json', Buffer.from('{malformed')); + rejects(gzipSync(tar(invalidManifest)), 'MANIFEST_JSON', invalidManifest); + const missingEntry = inventory(); + missingEntry.delete('package/dist/index.js'); + rejects(gzipSync(tar(missingEntry)), 'EXPECTED_INVENTORY', missingEntry); +}); diff --git a/tools/cli-package/binding.mjs b/tools/cli-package/binding.mjs new file mode 100644 index 00000000..20c74d54 --- /dev/null +++ b/tools/cli-package/binding.mjs @@ -0,0 +1,406 @@ +import { isDeepStrictEqual } from 'node:util'; +import { ARCHIVE_LIMITS, assertPublicPackagePath } from './archive.mjs'; +import { REQUIRED_SMOKE_CASES } from './smoke/cases.mjs'; + +/** @typedef {{kind: 'local'} | {kind: 'github', repository: string, workflow: string, runId: string, runAttempt: string, event: string, source: string}} Origin */ +/** @typedef {{path: string, bytes: number, sha256: string, mode: number}} PublicFile */ +/** @typedef {{source: string, origin: Origin, nodeMajor: number, allowLocal?: boolean}} Expected */ + +export const REQUIRED_CONSUMER_LEGS = Object.freeze([ + 'current-linux', + 'current-macos', + 'current-windows', + 'minimum-linux', +]); + +export class PackageBindingError extends Error { + /** @param {string} code */ + constructor(code) { + super(`CLI package binding refused: ${code}`); + this.name = 'PackageBindingError'; + this.code = code; + } +} +/** @param {string} code @returns {never} */ +function refuse(code) { + throw new PackageBindingError(code); +} +/** @param {unknown} value @returns {value is Record} */ +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} +/** @param {unknown} value @returns {Record} */ +function record(value) { + if (!isRecord(value)) refuse('SHAPE'); + return value; +} +/** @param {unknown} value @param {string[]} expected */ +function exact(value, expected) { + const object = record(value); + if (!isDeepStrictEqual(Object.keys(object).sort(), [...expected].sort())) refuse('FIELDS'); + return object; +} +/** @param {unknown} value @param {RegExp} pattern @param {number} [max] */ +function string(value, pattern, max = 256) { + if (typeof value !== 'string' || value.length > max || !pattern.test(value)) refuse('VALUE'); + return value; +} +/** @param {unknown} value @param {number} maximum @param {number} [minimum] */ +function integer(value, maximum, minimum = 0) { + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < minimum || + value > maximum + ) + refuse('VALUE'); + return value; +} +/** @param {unknown} value */ +function sha(value) { + return string(value, /^[0-9a-f]{64}$/u); +} +/** @param {unknown} value */ +function sourceSha(value) { + return string(value, /^[0-9a-f]{40}$/u); +} +/** @param {unknown} value */ +function version(value) { + return string(value, /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u, 100); +} +/** @param {unknown} value */ +function nodeVersion(value) { + return string(value, /^v\d+\.\d+\.\d+$/u, 40); +} + +/** @param {unknown} value @returns {Origin} */ +function parseOrigin(value) { + const origin = record(value); + if (origin.kind === 'local') { + exact(origin, ['kind']); + return { kind: 'local' }; + } + exact(origin, ['kind', 'repository', 'workflow', 'runId', 'runAttempt', 'event', 'source']); + if (origin.kind !== 'github') refuse('ORIGIN'); + return { + kind: 'github', + repository: string(origin.repository, /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u, 200), + workflow: string(origin.workflow, /^\P{Cc}+$/u, 200), + runId: string(origin.runId, /^[1-9]\d*$/u, 20), + runAttempt: string(origin.runAttempt, /^[1-9]\d*$/u, 6), + event: string(origin.event, /^(?:push|pull_request|workflow_dispatch)$/u), + source: sourceSha(origin.source), + }; +} + +/** + * Environment values are provided by the current trusted job, never a receipt. + * A local origin cannot be promoted to GitHub evidence. + * @param {Record} environment + * @returns {Origin} + */ +export function originFromEnvironment(environment) { + if (environment.GITHUB_ACTIONS !== 'true') return { kind: 'local' }; + return parseOrigin({ + kind: 'github', + repository: environment.GITHUB_REPOSITORY, + workflow: environment.GITHUB_WORKFLOW, + runId: environment.GITHUB_RUN_ID, + runAttempt: environment.GITHUB_RUN_ATTEMPT, + event: environment.GITHUB_EVENT_NAME, + source: environment.RELAVIUM_PACKAGE_SOURCE_SHA ?? environment.GITHUB_SHA, + }); +} + +/** @param {unknown} value @param {Expected} expected @param {boolean} olderAttempt */ +function bindOrigin(value, expected, olderAttempt) { + const actual = parseOrigin(value); + const trusted = parseOrigin(expected.origin); + if (actual.kind !== trusted.kind) refuse('ORIGIN'); + if (actual.kind === 'github' && trusted.kind === 'github') { + if ( + actual.source !== expected.source || + trusted.source !== expected.source || + actual.repository !== trusted.repository || + actual.workflow !== trusted.workflow || + actual.runId !== trusted.runId || + actual.event !== trusted.event || + (olderAttempt + ? BigInt(actual.runAttempt) > BigInt(trusted.runAttempt) + : actual.runAttempt !== trusted.runAttempt) + ) + refuse('ORIGIN'); + } + return actual; +} + +/** @param {unknown} value @returns {PublicFile[]} */ +function inventory(value) { + if (!Array.isArray(value) || value.length < 2 || value.length > ARCHIVE_LIMITS.files) + refuse('INVENTORY'); + const paths = new Set(); + let total = 1024; + const files = value.map((item) => { + const file = exact(item, ['path', 'bytes', 'sha256', 'mode']); + const path = string(file.path, /^[\x20-\x7e]+$/u, 99); + assertPublicPackagePath(path); + if (paths.has(path.toLowerCase())) refuse('INVENTORY'); + paths.add(path.toLowerCase()); + const bytes = integer( + file.bytes, + path === 'package/package.json' ? ARCHIVE_LIMITS.manifestBytes : ARCHIVE_LIMITS.fileBytes, + ); + total += 512 + Math.ceil(bytes / 512) * 512; + const mode = path === 'package/dist/index.js' ? 0o755 : 0o644; + if (file.mode !== mode) refuse('INVENTORY'); + return { path, bytes, sha256: sha(file.sha256), mode }; + }); + if ( + total > ARCHIVE_LIMITS.expandedBytes || + !paths.has('package/package.json') || + !paths.has('package/dist/index.js') + ) + refuse('INVENTORY'); + return files.sort((left, right) => + left.path < right.path ? -1 : left.path > right.path ? 1 : 0, + ); +} + +/** @param {unknown} value */ +function publicPackage(value) { + const manifest = record(value); + const allowed = new Set([ + 'name', + 'version', + 'description', + 'license', + 'type', + 'bin', + 'files', + 'keywords', + 'repository', + 'engines', + 'scripts', + 'dependencies', + 'devDependencies', + ]); + if ( + Object.keys(manifest).some((key) => !allowed.has(key)) || + manifest.name !== 'relavium' || + manifest.type !== 'module' + ) + refuse('PACKAGE'); + version(manifest.version); + const dependencies = record(manifest.dependencies); + if (Object.keys(dependencies).length < 1 || Object.keys(dependencies).length > 100) + refuse('PACKAGE'); + for (const [name, requirement] of Object.entries(dependencies)) { + string(name, /^(?:@[a-z0-9_.-]+\/)?[a-z0-9_.-]+$/u, 200); + if ( + name.startsWith('@relavium/') || + typeof requirement !== 'string' || + requirement.length > 100 || + /(?:workspace|catalog|file|link):/u.test(requirement) + ) + refuse('PACKAGE'); + } + // Full package metadata is public npm material. Keep it bounded; private map + // manifests and build/source inventories have no admitted top-level field. + if (JSON.stringify(manifest).length > ARCHIVE_LIMITS.manifestBytes) refuse('PACKAGE'); + return manifest; +} + +/** @param {unknown} value @param {Expected} expected */ +export function validatePackageReceipt(value, expected) { + sourceSha(expected.source); + integer(expected.nodeMajor, 100, 22); + const receipt = exact(value, [ + 'version', + 'mode', + 'source', + 'dirty', + 'packageVersion', + 'tarball', + 'tarballSha256', + 'archiveBytes', + 'files', + 'package', + 'origin', + 'toolchain', + ]); + if ( + receipt.version !== 1 || + receipt.mode !== 'compiled_package_test' || + receipt.source !== expected.source || + typeof receipt.dirty !== 'boolean' + ) + refuse('RECEIPT'); + const origin = bindOrigin(receipt.origin, expected, true); + if (origin.kind === 'github' && receipt.dirty) refuse('DIRTY_SOURCE'); + const packageVersion = version(receipt.packageVersion); + if (receipt.tarball !== `relavium-${packageVersion}.tgz`) refuse('TARBALL'); + const toolchain = exact(receipt.toolchain, ['node', 'pnpm']); + const node = nodeVersion(toolchain.node); + const producerMajor = Number(node.split('.')[0]?.slice(1)); + if ( + toolchain.pnpm !== '9.12.3' || + (origin.kind === 'local' && expected.allowLocal === true + ? producerMajor < 22 + : producerMajor !== expected.nodeMajor) + ) + refuse('TOOLCHAIN'); + const manifest = publicPackage(receipt.package); + if (manifest.version !== packageVersion) refuse('PACKAGE'); + return { + version: 1, + mode: 'compiled_package_test', + source: expected.source, + dirty: receipt.dirty, + packageVersion, + tarball: `relavium-${packageVersion}.tgz`, + tarballSha256: sha(receipt.tarballSha256), + archiveBytes: integer(receipt.archiveBytes, ARCHIVE_LIMITS.compressedBytes, 18), + files: inventory(receipt.files), + package: manifest, + origin, + toolchain: { node, pnpm: '9.12.3' }, + }; +} + +/** @param {unknown} value @param {unknown} receiptValue @param {Expected} expected */ +export function validateConsumerRecord(value, receiptValue, expected) { + const receipt = validatePackageReceipt(receiptValue, expected); + const consumer = exact(value, [ + 'version', + 'kind', + 'source', + 'tarballSha256', + 'leg', + 'origin', + 'node', + 'platform', + 'arch', + 'success', + 'bootstrap', + 'installedFiles', + 'smoke', + 'packageVersion', + 'dependencies', + ]); + if ( + consumer.version !== 1 || + consumer.kind !== 'installed-cli-consumer' || + consumer.success !== true || + consumer.source !== receipt.source || + consumer.tarballSha256 !== receipt.tarballSha256 || + consumer.packageVersion !== receipt.packageVersion + ) + refuse('CONSUMER'); + const consumerOrigin = bindOrigin(consumer.origin, expected, true); + if ( + consumerOrigin.kind === 'github' && + receipt.origin.kind === 'github' && + BigInt(consumerOrigin.runAttempt) < BigInt(receipt.origin.runAttempt) + ) + refuse('ORIGIN'); + const leg = string(consumer.leg, /^(?:current-(?:linux|macos|windows)|minimum-linux|local)$/u); + if (leg === 'local' && (expected.allowLocal !== true || consumerOrigin.kind !== 'local')) + refuse('LOCAL_LEG'); + const platform = + leg === 'local' + ? string(consumer.platform, /^(?:linux|darwin|win32)$/u) + : leg === 'current-macos' + ? 'darwin' + : leg === 'current-windows' + ? 'win32' + : 'linux'; + if (consumer.platform !== platform || (consumer.arch !== 'x64' && consumer.arch !== 'arm64')) + refuse('PLATFORM'); + const node = nodeVersion(consumer.node); + const major = Number(node.split('.')[0]?.slice(1)); + if ( + leg === 'local' + ? major < 22 + : leg === 'minimum-linux' + ? node !== 'v22.0.0' + : major !== expected.nodeMajor + ) + refuse('RUNTIME'); + if ( + !Array.isArray(consumer.installedFiles) || + consumer.installedFiles.length !== receipt.files.length + ) + refuse('INSTALLED_FILES'); + const installedFiles = consumer.installedFiles + .map((item) => { + const file = exact(item, ['path', 'bytes', 'sha256']); + return { + path: string(file.path, /^[\x20-\x7e]+$/u, 99), + bytes: integer(file.bytes, ARCHIVE_LIMITS.fileBytes), + sha256: sha(file.sha256), + }; + }) + .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); + const packedFiles = receipt.files.map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 })); + if (!isDeepStrictEqual(installedFiles, packedFiles)) refuse('INSTALLED_FILES'); + const bootstrap = exact(consumer.bootstrap, ['controls', 'entrySha256']); + const entry = receipt.files.find((file) => file.path === 'package/dist/index.js'); + if (bootstrap.controls !== 175 || bootstrap.entrySha256 !== entry?.sha256) refuse('BOOTSTRAP'); + const smoke = exact(consumer.smoke, ['success', 'cases']); + if ( + smoke.success !== true || + !Array.isArray(smoke.cases) || + smoke.cases.length !== REQUIRED_SMOKE_CASES.length + ) + refuse('SMOKE'); + const cases = smoke.cases.map((item) => { + const result = exact(item, ['id', 'passed']); + if (result.passed !== true) refuse('SMOKE'); + return string(result.id, /^[a-z0-9-]+$/u); + }); + if (!isDeepStrictEqual(cases.sort(), [...REQUIRED_SMOKE_CASES].sort())) refuse('SMOKE'); + const approved = Object.keys(record(receipt.package.dependencies)).sort(); + if (!Array.isArray(consumer.dependencies) || consumer.dependencies.length !== approved.length) + refuse('DEPENDENCIES'); + const names = consumer.dependencies.map((item) => { + const dependency = exact(item, ['name', 'version', 'manifestSha256']); + version(dependency.version); + sha(dependency.manifestSha256); + return string(dependency.name, /^(?:@[a-z0-9_.-]+\/)?[a-z0-9_.-]+$/u, 200); + }); + if (!isDeepStrictEqual(names.sort(), approved)) refuse('DEPENDENCIES'); + return { leg, node, platform, source: receipt.source, tarballSha256: receipt.tarballSha256 }; +} + +/** @param {unknown} receiptValue @param {unknown} records @param {Expected} expected */ +export function aggregateConsumers(receiptValue, records, expected) { + const aggregateExpected = { ...expected, allowLocal: false }; + const receipt = validatePackageReceipt(receiptValue, aggregateExpected); + if (!Array.isArray(records) || records.length !== REQUIRED_CONSUMER_LEGS.length) refuse('LEGS'); + const consumers = records.map((value) => + validateConsumerRecord(value, receipt, aggregateExpected), + ); + if ( + !isDeepStrictEqual(consumers.map((item) => item.leg).sort(), [...REQUIRED_CONSUMER_LEGS].sort()) + ) + refuse('LEGS'); + return { + version: 1, + kind: 'installed-cli-aggregate', + source: receipt.source, + tarballSha256: receipt.tarballSha256, + packageVersion: receipt.packageVersion, + success: true, + consumers, + }; +} + +/** @param {unknown} value */ +export function requirePackageJobResults(value) { + const jobs = record(value); + const keys = Object.keys(jobs).sort(); + const ci = ['pack-cli', 'packed-cli-consumer']; + const release = ['pack', 'smoke']; + if (!isDeepStrictEqual(keys, ci) && !isDeepStrictEqual(keys, release)) refuse('JOBS'); + for (const key of keys) if (record(jobs[key]).result !== 'success') refuse('JOBS'); +} diff --git a/tools/cli-package/binding.test.mjs b/tools/cli-package/binding.test.mjs new file mode 100644 index 00000000..7b535a2f --- /dev/null +++ b/tools/cli-package/binding.test.mjs @@ -0,0 +1,496 @@ +import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; +import { mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { gzipSync } from 'node:zlib'; +import { ARCHIVE_LIMITS, inspectPackageArchive } from './archive.mjs'; +import { runAggregate } from './aggregate.mjs'; +import { + aggregateConsumers, + originFromEnvironment, + PackageBindingError, + REQUIRED_CONSUMER_LEGS, + requirePackageJobResults, + validateConsumerRecord, + validatePackageReceipt, +} from './binding.mjs'; +import { REQUIRED_SMOKE_CASES } from './smoke/cases.mjs'; + +const source = 'a'.repeat(40); +const digest = 'b'.repeat(64); +/** @type {import('./binding.mjs').Expected} */ +const expected = { source, origin: { kind: 'local' }, nodeMajor: 24 }; +function receipt() { + return { + version: 1, + mode: 'compiled_package_test', + source, + dirty: false, + packageVersion: '0.1.1', + tarball: 'relavium-0.1.1.tgz', + tarballSha256: digest, + archiveBytes: 900, + files: [ + { path: 'package/dist/index.js', bytes: 40, sha256: 'c'.repeat(64), mode: 0o755 }, + { path: 'package/dist/chunk-ABC.js', bytes: 10, sha256: 'd'.repeat(64), mode: 0o644 }, + { path: 'package/package.json', bytes: 120, sha256: 'e'.repeat(64), mode: 0o644 }, + ], + package: { + name: 'relavium', + version: '0.1.1', + type: 'module', + dependencies: { commander: '^14.0.0' }, + devDependencies: { '@relavium/core': '0.0.0' }, + }, + origin: { kind: 'local' }, + toolchain: { node: 'v24.9.0', pnpm: '9.12.3' }, + }; +} +/** @param {string} [leg] */ +function consumer(leg = 'current-linux') { + const pack = receipt(); + return { + version: 1, + kind: 'installed-cli-consumer', + source, + tarballSha256: digest, + leg, + origin: { kind: 'local' }, + node: leg === 'minimum-linux' ? 'v22.0.0' : 'v24.9.0', + platform: leg === 'current-macos' ? 'darwin' : leg === 'current-windows' ? 'win32' : 'linux', + arch: 'x64', + success: true, + bootstrap: { controls: 175, entrySha256: 'c'.repeat(64) }, + installedFiles: pack.files.map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 })), + smoke: { success: true, cases: REQUIRED_SMOKE_CASES.map((id) => ({ id, passed: true })) }, + packageVersion: '0.1.1', + dependencies: [{ name: 'commander', version: '14.0.2', manifestSha256: 'f'.repeat(64) }], + }; +} +/** @param {Record} object @param {string} key */ +function omit(object, key) { + return Object.fromEntries(Object.entries(object).filter(([name]) => name !== key)); +} +/** @param {() => unknown} operation */ +function refuses(operation) { + assert.throws( + operation, + (error) => + error instanceof PackageBindingError || + (error instanceof Error && error.name === 'PackageArchiveError'), + ); +} +/** @param {string} [attempt] @returns {import('./binding.mjs').Origin} */ +function github(attempt = '1') { + return { + kind: 'github', + repository: 'HodeTech/Relavium', + workflow: 'CI', + runId: '12345', + runAttempt: attempt, + event: 'pull_request', + source, + }; +} + +test('one actual artifact binds every required OS/minimum-runtime consumer', () => { + const result = aggregateConsumers(receipt(), REQUIRED_CONSUMER_LEGS.map(consumer), expected); + assert.equal(result.success, true); + assert.deepEqual( + result.consumers.map((item) => item.leg).sort(), + [...REQUIRED_CONSUMER_LEGS].sort(), + ); + assert.equal(result.tarballSha256, digest); +}); + +test('strict receipt refuses omitted or added private/custody claims', () => { + for (const key of Object.keys(receipt())) + refuses(() => validatePackageReceipt(omit(receipt(), key), expected)); + for (const key of [ + 'sourceMaps', + 'sourceInventory', + 'sourcesContent', + 'custody', + 'mapCheckComplete', + ]) { + refuses(() => validatePackageReceipt({ ...receipt(), [key]: true }, expected)); + } + for (const changed of [ + { mode: 'release' }, + { source: '0'.repeat(40) }, + { dirty: 'false' }, + { version: 2 }, + { tarball: '../relavium-0.1.1.tgz' }, + { packageVersion: '0.1.2' }, + { tarballSha256: 'invalid' }, + { archiveBytes: ARCHIVE_LIMITS.compressedBytes + 1 }, + { archiveBytes: 0 }, + { toolchain: { node: 'v22.0.0', pnpm: '9.12.3' } }, + { toolchain: { node: 'v24.9.0', pnpm: '10.0.0' } }, + ]) + refuses(() => validatePackageReceipt({ ...receipt(), ...changed }, expected)); +}); + +test('public inventory independently rejects duplicates, escapes, source/map files and incorrect modes', () => { + const pack = receipt(); + const entry = pack.files[0]; + assert.ok(entry); + for (const files of [ + [], + pack.files.slice(1), + [...pack.files, entry], + [...pack.files, { ...entry, path: 'package/dist/CHUNK-ABC.js' }], + pack.files.map((item) => ({ ...item, mode: 0o777 })), + pack.files.map((item) => ({ ...item, bytes: -1 })), + pack.files.map((item) => ({ ...item, sha256: 'NO' })), + [...pack.files, { ...entry, path: 'package/dist/../escape.js' }], + [...pack.files, { ...entry, path: 'package/dist/index.js.map' }], + [...pack.files, { ...entry, path: 'package/src/index.ts' }], + [...pack.files, { ...entry, source: 'private' }], + ]) + refuses(() => validatePackageReceipt({ ...pack, files }, expected)); +}); + +test('package metadata is public-only and keeps private packages out of runtime', () => { + const pack = receipt(); + for (const changed of [ + { name: 'different' }, + { version: '0.2.0' }, + { private: true }, + { sourceInventory: [] }, + { dependencies: { '@relavium/core': '0.0.0' } }, + { dependencies: { commander: 'workspace:*' } }, + { dependencies: { commander: 'catalog:' } }, + ]) + refuses(() => + validatePackageReceipt({ ...pack, package: { ...pack.package, ...changed } }, expected), + ); +}); + +test('GitHub identity binds repo/workflow/run/event/source and rejects local or dirty receipts', () => { + const trusted = { ...expected, origin: github('3') }; + const pack = { ...receipt(), origin: github('1') }; + assert.doesNotThrow(() => validatePackageReceipt(pack, trusted)); + refuses(() => validatePackageReceipt(receipt(), trusted)); + refuses(() => validatePackageReceipt({ ...pack, dirty: true }, trusted)); + refuses(() => validatePackageReceipt(pack, expected)); + for (const changed of [ + { repository: 'Other/Relavium' }, + { workflow: 'Release' }, + { runId: '999' }, + { runAttempt: '4' }, + { runAttempt: '0' }, + { event: 'push' }, + { source: '0'.repeat(40) }, + ]) + refuses(() => + validatePackageReceipt({ ...pack, origin: { ...github(), ...changed } }, trusted), + ); +}); + +test('failed-jobs retries retain exact artifact consumers within the originating/current attempt interval', () => { + const trusted = { ...expected, origin: github('3') }; + const pack = { ...receipt(), origin: github('2') }; + for (const attempt of ['2', '3']) + assert.doesNotThrow(() => + validateConsumerRecord({ ...consumer(), origin: github(attempt) }, pack, trusted), + ); + for (const attempt of ['1', '4']) + refuses(() => + validateConsumerRecord({ ...consumer(), origin: github(attempt) }, pack, trusted), + ); +}); + +test('consumer refuses replay, mismatched files, failed/skipped/incomplete controls and wrong runtime/platform', () => { + const report = consumer(); + for (const key of Object.keys(report)) + refuses(() => validateConsumerRecord(omit(report, key), receipt(), expected)); + for (const changed of [ + { source: '0'.repeat(40) }, + { tarballSha256: '0'.repeat(64) }, + { packageVersion: '0.1.2' }, + { success: false }, + { success: 'true' }, + { skipped: true }, + { platform: 'darwin' }, + { node: 'v22.0.0' }, + { node: 'v24.0.0-preview' }, + { bootstrap: { controls: 174, entrySha256: 'c'.repeat(64) } }, + { bootstrap: { controls: 175, entrySha256: '0'.repeat(64) } }, + { installedFiles: report.installedFiles.slice(1) }, + { installedFiles: report.installedFiles.map((item) => ({ ...item, mode: 0o644 })) }, + { installedFiles: report.installedFiles.map((item) => ({ ...item, sha256: '0'.repeat(64) })) }, + { smoke: { ...report.smoke, success: false } }, + { smoke: { ...report.smoke, cases: report.smoke.cases.slice(1) } }, + { + smoke: { + ...report.smoke, + cases: report.smoke.cases.map((item) => ({ ...item, passed: false })), + }, + }, + { + smoke: { + ...report.smoke, + cases: report.smoke.cases.map((item) => ({ ...item, id: 'installed-version' })), + }, + }, + ]) + refuses(() => validateConsumerRecord({ ...report, ...changed }, receipt(), expected)); + refuses(() => + validateConsumerRecord({ ...consumer('minimum-linux'), node: 'v22.1.0' }, receipt(), expected), + ); +}); + +test('resolved dependency evidence records every approved package without exposing host paths', () => { + const report = consumer(); + const dependency = report.dependencies[0]; + assert.ok(dependency); + for (const dependencies of [ + [], + [dependency, dependency], + [{ ...dependency, name: 'other' }], + [{ ...dependency, version: '' }], + [{ ...dependency, manifestSha256: 'x' }], + [{ ...dependency, path: '/private/host/node_modules/commander' }], + ]) + refuses(() => validateConsumerRecord({ ...report, dependencies }, receipt(), expected)); + assert.doesNotThrow(() => + validateConsumerRecord( + { ...report, dependencies: [{ ...dependency, version: '14.1.0' }] }, + receipt(), + expected, + ), + ); +}); + +test('aggregate cannot substitute duplicate or absent platform legs', () => { + const reports = REQUIRED_CONSUMER_LEGS.map(consumer); + refuses(() => aggregateConsumers(receipt(), reports.slice(1), expected)); + refuses(() => aggregateConsumers(receipt(), [...reports, consumer()], expected)); + refuses(() => + aggregateConsumers( + receipt(), + reports.map(() => consumer()), + expected, + ), + ); +}); + +test('environment origin is derived from the current job and fails closed on incomplete GitHub identity', () => { + assert.deepEqual(originFromEnvironment({}), { kind: 'local' }); + const environment = { + GITHUB_ACTIONS: 'true', + GITHUB_REPOSITORY: 'HodeTech/Relavium', + GITHUB_WORKFLOW: 'CI', + GITHUB_RUN_ID: '12345', + GITHUB_RUN_ATTEMPT: '1', + GITHUB_EVENT_NAME: 'pull_request', + GITHUB_SHA: source, + }; + assert.deepEqual(originFromEnvironment(environment), github()); + for (const key of Object.keys(environment).filter((key) => key !== 'GITHUB_ACTIONS')) { + const missing = Object.fromEntries( + Object.entries(environment).filter(([name]) => name !== key), + ); + refuses(() => originFromEnvironment(missing)); + } + refuses(() => + originFromEnvironment({ ...environment, GITHUB_EVENT_NAME: 'pull_request_target' }), + ); +}); + +test('CI/release job statuses are an independent required gate; missing/skipped/cancelled never pass', () => { + for (const pair of [ + ['pack-cli', 'packed-cli-consumer'], + ['pack', 'smoke'], + ]) { + const good = Object.fromEntries(pair.map((name) => [name, { result: 'success' }])); + assert.doesNotThrow(() => requirePackageJobResults(good)); + for (const name of pair) { + refuses(() => requirePackageJobResults(omit(good, name))); + for (const result of ['failure', 'cancelled', 'skipped', 'pending', '']) + refuses(() => requirePackageJobResults({ ...good, [name]: { result } })); + } + } + for (const value of [ + undefined, + null, + [], + {}, + { 'pack-cli': { result: 'success' }, smoke: { result: 'success' } }, + ]) + refuses(() => requirePackageJobResults(value)); +}); + +test('an explicitly local run retains its runtime scope and cannot fill a required platform leg', () => { + const local = { ...consumer('local'), node: 'v25.0.0', platform: 'darwin' }; + assert.doesNotThrow(() => + validateConsumerRecord(local, receipt(), { ...expected, allowLocal: true }), + ); + refuses(() => validateConsumerRecord(local, receipt(), expected)); + refuses(() => + validateConsumerRecord({ ...local, node: 'v20.0.0' }, receipt(), { + ...expected, + allowLocal: true, + }), + ); + refuses(() => + aggregateConsumers(receipt(), [local, ...REQUIRED_CONSUMER_LEGS.slice(1).map(consumer)], { + ...expected, + allowLocal: true, + }), + ); + refuses(() => + validateConsumerRecord( + { ...local, origin: github() }, + { ...receipt(), origin: github() }, + { ...expected, origin: github(), allowLocal: true }, + ), + ); + const localPack = { ...receipt(), toolchain: { node: 'v22.23.1', pnpm: '9.12.3' } }; + assert.doesNotThrow(() => validatePackageReceipt(localPack, { ...expected, allowLocal: true })); + refuses(() => validatePackageReceipt(localPack, expected)); + refuses(() => + aggregateConsumers(localPack, REQUIRED_CONSUMER_LEGS.map(consumer), { + ...expected, + allowLocal: true, + }), + ); +}); + +// Tiny, synthetic package for exercising the aggregator's real filesystem and +// gzip/archive boundary. This never packs, installs or builds the product. +function syntheticPackage() { + const pack = receipt(); + const files = new Map([ + ['package/dist/index.js', Buffer.from('#!/usr/bin/env node\nimport "./chunk-ABC.js";\n')], + ['package/dist/chunk-ABC.js', Buffer.from('export {};\n')], + ['package/package.json', Buffer.from(JSON.stringify(pack.package))], + ]); + const records = [...files].map(([path, bytes]) => { + const header = Buffer.alloc(512); + header.write(path); + for (const offset of [100, 108, 116, 329, 337]) header.write('000000 \0', offset); + header.write(path.endsWith('/index.js') ? '000755' : '000644', 100); + header.write(bytes.length.toString(8).padStart(11, '0') + ' ', 124); + header.write('00000000000 ', 136); + header[156] = 48; + header.write('ustar\0' + '00', 257); + header.fill(32, 148, 156); + header.write( + header + .reduce((sum, byte) => sum + byte, 0) + .toString(8) + .padStart(6, '0'), + 148, + ); + header[154] = 0; + header[155] = 32; + return Buffer.concat([header, bytes, Buffer.alloc((512 - (bytes.length % 512)) % 512)]); + }); + const tarball = gzipSync(Buffer.concat([...records, Buffer.alloc(1024)])); + const archive = inspectPackageArchive(tarball, { + expectedFiles: files, + expectedPackage: pack.package, + }); + return { + tarball, + pack: { + ...pack, + archiveBytes: archive.bytes, + tarballSha256: archive.sha256, + files: archive.files, + }, + }; +} + +/** @param {(fixture: ReturnType) => void} operation */ +function withFilesystem(operation) { + const fixture = filesystemFixture(); + try { + operation(fixture); + } finally { + rmSync(fixture.root, { recursive: true, force: true }); + } +} +function filesystemFixture() { + const root = mkdtempSync(join(tmpdir(), 'relavium-package-aggregate-test-')); + const artifact = join(root, 'artifact'); + const reports = join(root, 'reports'); + mkdirSync(artifact); + mkdirSync(reports); + const { tarball, pack } = syntheticPackage(); + writeFileSync(join(artifact, pack.tarball), tarball); + writeFileSync(join(artifact, 'package-test-receipt.json'), JSON.stringify(pack)); + const entry = pack.files.find((file) => file.path === 'package/dist/index.js'); + assert.ok(entry); + for (const leg of REQUIRED_CONSUMER_LEGS) { + mkdirSync(join(reports, leg)); + writeFileSync( + join(reports, leg, 'installed-consumer.json'), + JSON.stringify({ + ...consumer(leg), + tarballSha256: pack.tarballSha256, + installedFiles: pack.files.map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 })), + bootstrap: { controls: 175, entrySha256: entry.sha256 }, + }), + ); + } + const environment = { + RELAVIUM_PACKAGE_SOURCE_SHA: source, + RELAVIUM_PACKAGE_JOB_RESULTS: JSON.stringify({ + 'pack-cli': { result: 'success' }, + 'packed-cli-consumer': { result: 'success' }, + }), + }; + return { root, artifact, reports, pack, environment }; +} + +test('aggregate reads and independently inspects one tarball and four bounded consumer artifacts', () => { + withFilesystem(({ artifact, reports, environment }) => + assert.equal(runAggregate([artifact, reports], environment).success, true), + ); +}); + +test('aggregate rejects modified tarball, oversized input, extra/missing reports and symlink directories', () => { + withFilesystem(({ artifact, reports, environment, pack }) => { + writeFileSync(join(artifact, pack.tarball), Buffer.alloc(100)); + refuses(() => runAggregate([artifact, reports], environment)); + }); + withFilesystem(({ artifact, reports, environment }) => { + writeFileSync(join(artifact, 'package-test-receipt.json'), Buffer.alloc(512 * 1024 + 1)); + refuses(() => runAggregate([artifact, reports], environment)); + }); + withFilesystem(({ artifact, reports, environment }) => { + writeFileSync(join(reports, 'current-linux', 'private-map.json'), '{}'); + refuses(() => runAggregate([artifact, reports], environment)); + }); + withFilesystem(({ artifact, reports, environment }) => { + rmSync(join(reports, 'current-linux'), { recursive: true }); + refuses(() => runAggregate([artifact, reports], environment)); + }); + withFilesystem(({ root, artifact, reports, environment }) => { + const moved = join(root, 'moved'); + renameSync(join(reports, 'current-linux'), moved); + symlinkSync( + moved, + join(reports, 'current-linux'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + refuses(() => runAggregate([artifact, reports], environment)); + }); + withFilesystem(({ artifact, reports, environment }) => { + refuses(() => + runAggregate([artifact, reports], { ...environment, RELAVIUM_PACKAGE_JOB_RESULTS: 'null' }), + ); + refuses(() => + runAggregate([artifact, reports], { + ...environment, + RELAVIUM_PACKAGE_SOURCE_SHA: 'f'.repeat(40), + }), + ); + refuses(() => runAggregate([artifact, reports, 'extra'], environment)); + }); +}); diff --git a/tools/cli-package/check.mjs b/tools/cli-package/check.mjs new file mode 100644 index 00000000..3aaba4dd --- /dev/null +++ b/tools/cli-package/check.mjs @@ -0,0 +1,33 @@ +/** Local full-CI counterpart: one new candidate and one actual-host consumer, never a platform aggregate. */ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isolatedEnvironment, runCommand } from './process.mjs'; + +assert.equal(process.argv.length, 2, 'no arguments required'); +const directory = fileURLToPath(new URL('.', import.meta.url)); +const root = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-local-package-check-'))); +try { + const artifact = join(root, 'artifact'), + reports = join(root, 'reports'); + const options = { env: isolatedEnvironment(root), timeoutMs: 600_000 }; + await runCommand(process.execPath, [join(directory, 'pack.mjs'), artifact], options); + await runCommand(process.execPath, [join(directory, 'consume.mjs'), artifact, reports], options); + const record = JSON.parse(readFileSync(join(reports, 'installed-consumer.json'), 'utf8')); + assert.equal(record.leg, 'local'); + console.log( + JSON.stringify({ + scope: 'actual local host only; required CI owns four platform legs', + source: record.source, + sha256: record.tarballSha256, + node: record.node, + platform: record.platform, + controls: record.bootstrap.controls, + smokeCases: record.smoke.cases.length, + }), + ); +} finally { + rmSync(root, { recursive: true, force: true }); +} diff --git a/tools/cli-package/consume.mjs b/tools/cli-package/consume.mjs new file mode 100644 index 00000000..67799fe2 --- /dev/null +++ b/tools/cli-package/consume.mjs @@ -0,0 +1,184 @@ +/** Install the chosen public bytes outside the checkout; never builds or repacks product code. */ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runBootstrap } from '../cli-bootstrap/check.mjs'; +import { inspectPackageArchive } from './archive.mjs'; +import { + originFromEnvironment, + validatePackageReceipt, + validateConsumerRecord, +} from './binding.mjs'; +import { installedInventory, oneTarball, readOrdinaryFile, sha256 } from './files.mjs'; +import { isolatedEnvironment, runCommand, runPnpm } from './process.mjs'; +import { runInstalledSmoke } from './smoke/index.mjs'; + +assert.equal(process.argv.length, 4, 'artifact and report directories required'); +const repository = fileURLToPath(new URL('../../', import.meta.url)); +const artifactDirectory = resolve(process.argv[2]), + reportDirectory = resolve(process.argv[3]); +mkdirSync(reportDirectory, { recursive: true, mode: 0o700 }); +const receipt = JSON.parse( + readOrdinaryFile(join(artifactDirectory, 'package-test-receipt.json'), 512 * 1024), +); +const origin = originFromEnvironment(process.env); +const source = + process.env.RELAVIUM_PACKAGE_SOURCE_SHA ?? + ( + await runCommand('git', ['--no-replace-objects', 'rev-parse', 'HEAD'], { + cwd: repository, + env: isolatedEnvironment(repository), + }) + ).stdout.trim(); +const nodeMajor = Number(readFileSync(join(repository, '.nvmrc'), 'utf8').trim()); +const expected = { source, origin, nodeMajor, allowLocal: origin.kind === 'local' }; +validatePackageReceipt(receipt, expected); +const tarball = oneTarball(artifactDirectory); +const bytes = readOrdinaryFile(tarball, 16 * 1024 * 1024); +assert.equal(sha256(bytes), receipt.tarballSha256, 'PACKED_CLI_ARTIFACT_CHANGED'); +assert.equal(bytes.length, receipt.archiveBytes); +inspectPackageArchive(bytes, { + expectedFiles: new Map( + receipt.files.map((file) => [file.path, { sha256: file.sha256, bytes: file.bytes }]), + ), + expectedPackage: receipt.package, +}); +const home = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-package-consumer-'))); +const inside = (parent, child) => { + const part = relative(parent, child); + return part === '' || (!isAbsolute(part) && part !== '..' && !part.startsWith(`..${sep}`)); +}; +assert.equal(inside(realpathSync(repository), home), false, 'PACKED_CLI_INSTALL_IN_WORKSPACE'); +try { + const env = isolatedEnvironment(home); + writeFileSync( + join(home, 'package.json'), + JSON.stringify({ + name: 'relavium-package-consumer', + private: true, + version: '0.0.0', + packageManager: 'pnpm@9.12.3', + pnpm: { onlyBuiltDependencies: ['better-sqlite3', 'esbuild'] }, + }) + '\n', + ); + const version = await runPnpm(['--version'], { cwd: home, env }); + assert.equal(version.stdout.trim(), '9.12.3', 'PACKED_CLI_PNPM_VERSION'); + // Installation uses these already-vetted bytes, not a path the publisher could later replace. + const ownedTarball = join(home, 'artifact.tgz'); + writeFileSync(ownedTarball, bytes, { flag: 'wx' }); + const installation = await runPnpm( + ['add', '--prod', '--ignore-workspace', '--config.engine-strict=true', `file:${ownedTarball}`], + { cwd: home, env }, + ); + writeFileSync(join(reportDirectory, 'install.log'), installation.stdout + installation.stderr); + const packageRoot = realpathSync(join(home, 'node_modules/relavium')); + assert.ok(inside(home, packageRoot), 'PACKED_CLI_INSTALL_ESCAPED'); + const before = installedInventory(packageRoot); + const wanted = receipt.files + .map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 })) + .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + const sorted = (files) => + files.toSorted((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + assert.deepEqual(sorted(before), wanted, 'PACKED_CLI_INSTALLED_BYTES_DRIFT'); + if (process.platform !== 'win32') + for (const file of receipt.files) { + assert.equal( + lstatSync(join(packageRoot, file.path.slice('package/'.length))).mode & 0o7777, + file.mode, + 'PACKED_CLI_INSTALLED_MODE_DRIFT', + ); + } + const require = createRequire(join(packageRoot, 'package.json')); + const dependencies = []; + for (const name of Object.keys(receipt.package.dependencies).sort()) { + const entry = realpathSync( + require.resolve( + name === '@modelcontextprotocol/sdk' ? '@modelcontextprotocol/sdk/client/index.js' : name, + ), + ); + assert.ok(inside(home, entry), 'PACKED_CLI_DEPENDENCY_OUTSIDE_INSTALL'); + let directory = dirname(entry), + found = false; + while (inside(home, directory)) { + let content; + try { + content = readOrdinaryFile(join(directory, 'package.json'), 1024 * 1024); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + if (content !== undefined) { + const metadata = JSON.parse(content); + if (metadata.name === name) { + dependencies.push({ name, version: metadata.version, manifestSha256: sha256(content) }); + found = true; + break; + } + } + directory = dirname(directory); + } + assert.ok(found, 'PACKED_CLI_DEPENDENCY_MANIFEST_MISSING'); + } + const bootstrap = await runBootstrap(packageRoot); + writeFileSync( + join(reportDirectory, 'bootstrap-details.json'), + JSON.stringify(bootstrap, null, 2), + ); + const smoke = await runInstalledSmoke({ + packageRoot, + supportRoot: reportDirectory, + artifactSha256: receipt.tarballSha256, + source, + }); + assert.equal(smoke.success, true, 'PACKED_CLI_INSTALLED_SMOKE_FAILED'); + assert.deepEqual(installedInventory(packageRoot), before, 'PACKED_CLI_SMOKE_CHANGED_ARTIFACT'); + assert.equal(sha256(readOrdinaryFile(tarball, 16 * 1024 * 1024)), receipt.tarballSha256); + const leg = process.env.RELAVIUM_PACKAGE_LEG ?? 'local'; + const record = { + version: 1, + kind: 'installed-cli-consumer', + source, + tarballSha256: receipt.tarballSha256, + packageVersion: receipt.packageVersion, + leg, + origin, + node: process.version, + platform: process.platform, + arch: process.arch, + success: true, + bootstrap: { controls: bootstrap.controls, entrySha256: bootstrap.entrySha256 }, + installedFiles: before, + dependencies, + smoke: { success: true, cases: smoke.cases.map(({ id, passed }) => ({ id, passed })) }, + }; + // Local execution is evidence for its actual host, never one of the required four remote legs. + validateConsumerRecord(record, receipt, { ...expected, allowLocal: true }); + writeFileSync( + join(reportDirectory, 'installed-consumer.json'), + JSON.stringify(record, null, 2) + '\n', + { flag: 'wx' }, + ); + console.log( + JSON.stringify({ + source, + sha256: receipt.tarballSha256, + leg, + node: process.version, + platform: process.platform, + controls: bootstrap.controls, + smokeCases: smoke.cases.length, + }), + ); +} finally { + rmSync(home, { recursive: true, force: true }); +} diff --git a/tools/cli-package/files.mjs b/tools/cli-package/files.mjs new file mode 100644 index 00000000..e63455ef --- /dev/null +++ b/tools/cli-package/files.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { + closeSync, + constants, + fstatSync, + lstatSync, + openSync, + readSync, + readdirSync, +} from 'node:fs'; +import { Buffer } from 'node:buffer'; +import { join } from 'node:path'; + +export const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex'); + +export function readOrdinaryFile(path, maxBytes) { + assert.ok(Number.isSafeInteger(maxBytes) && maxBytes > 0); + const info = lstatSync(path, { bigint: true }); + assert.ok(info.isFile() && info.size <= BigInt(maxBytes), 'PACKED_CLI_FILE_KIND_OR_BOUND'); + const descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + const opened = fstatSync(descriptor, { bigint: true }); + assert.ok( + opened.isFile() && + opened.dev === info.dev && + opened.ino === info.ino && + opened.size === info.size, + 'PACKED_CLI_FILE_CHANGED', + ); + const bytes = Buffer.alloc(Number(opened.size) + 1); + let offset = 0; + while (offset < bytes.length) { + const count = readSync(descriptor, bytes, offset, bytes.length - offset, offset); + if (count === 0) break; + offset += count; + } + assert.equal(BigInt(offset), opened.size, 'PACKED_CLI_FILE_CHANGED'); + assert.equal( + fstatSync(descriptor, { bigint: true }).size, + opened.size, + 'PACKED_CLI_FILE_CHANGED', + ); + return bytes.subarray(0, offset); + } finally { + closeSync(descriptor); + } +} + +export function flatFiles(directory, prefix) { + const result = new Map(); + assert.ok(lstatSync(directory).isDirectory()); + for (const name of readdirSync(directory).sort()) { + assert.ok(!name.includes('/') && !name.includes('\\')); + const path = join(directory, name), + info = lstatSync(path); + if (info.isDirectory()) { + for (const [key, bytes] of flatFiles(path, `${prefix}/${name}`)) result.set(key, bytes); + } else result.set(`${prefix}/${name}`, readOrdinaryFile(path, 8 * 1024 * 1024)); + } + return result; +} + +export function oneTarball(directory) { + const names = readdirSync(directory); + const tarballs = names.filter((name) => /^relavium-[A-Za-z0-9.+-]+\.tgz$/u.test(name)); + assert.equal(tarballs.length, 1, 'PACKED_CLI_EXACTLY_ONE_TARBALL'); + assert.ok( + names.every((name) => name === tarballs[0] || name === 'package-test-receipt.json'), + 'PACKED_CLI_UNEXPECTED_PUBLIC_ARTIFACT', + ); + return join(directory, tarballs[0]); +} + +/** Actual installed public files; node_modules is installation state, never product content. */ +export function installedInventory(packageRoot) { + const files = new Map(); + for (const name of readdirSync(packageRoot).sort()) { + if (name === 'node_modules') continue; + const path = join(packageRoot, name), + info = lstatSync(path); + if (info.isDirectory()) { + for (const [key, bytes] of flatFiles(path, `package/${name}`)) files.set(key, bytes); + } else files.set(`package/${name}`, readOrdinaryFile(path, 8 * 1024 * 1024)); + } + return [...files].map(([path, bytes]) => ({ path, bytes: bytes.length, sha256: sha256(bytes) })); +} diff --git a/tools/cli-package/files.test.mjs b/tools/cli-package/files.test.mjs new file mode 100644 index 00000000..4eb0ef6c --- /dev/null +++ b/tools/cli-package/files.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; +import { test } from 'node:test'; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { installedInventory, oneTarball, readOrdinaryFile, sha256 } from './files.mjs'; + +test('native file admission bounds bytes and refuses links/directories', () => { + const root = mkdtempSync(join(tmpdir(), 'relavium-package-files-')); + try { + const file = join(root, 'fixture'); + writeFileSync(file, 'abcdef'); + assert.equal(readOrdinaryFile(file, 6).toString(), 'abcdef'); + assert.throws(() => readOrdinaryFile(file, 5), /KIND_OR_BOUND/); + assert.throws(() => readOrdinaryFile(root, 100), /KIND_OR_BOUND/); + assert.throws(() => readOrdinaryFile(file, Number.POSITIVE_INFINITY)); + if (process.platform !== 'win32') { + symlinkSync(file, join(root, 'alias')); + assert.throws(() => readOrdinaryFile(join(root, 'alias'), 100), /KIND_OR_BOUND/); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('artifact selection refuses missing, duplicate and unrelated public files', () => { + const root = mkdtempSync(join(tmpdir(), 'relavium-package-select-')); + try { + assert.throws(() => oneTarball(root), /ONE_TARBALL/); + writeFileSync(join(root, 'relavium-0.1.1.tgz'), 'fixture'); + assert.equal(oneTarball(root), join(root, 'relavium-0.1.1.tgz')); + writeFileSync(join(root, 'package-test-receipt.json'), '{}'); + assert.equal(oneTarball(root), join(root, 'relavium-0.1.1.tgz')); + writeFileSync(join(root, 'relavium-0.1.2.tgz'), 'fixture'); + assert.throws(() => oneTarball(root), /ONE_TARBALL/); + rmSync(join(root, 'relavium-0.1.2.tgz')); + writeFileSync(join(root, 'private-map-manifest.json'), '{}'); + assert.throws(() => oneTarball(root), /UNEXPECTED_PUBLIC/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('installed inventory hashes every public byte and admits only installation-owned node_modules exclusion', () => { + const root = mkdtempSync(join(tmpdir(), 'relavium-package-inventory-')); + try { + mkdirSync(join(root, 'dist')); + mkdirSync(join(root, 'node_modules')); + writeFileSync(join(root, 'node_modules', 'installation-state'), 'not product material'); + writeFileSync(join(root, 'dist', 'index.js'), 'public'); + assert.deepEqual(installedInventory(root), [ + { + path: 'package/dist/index.js', + bytes: 6, + sha256: sha256(Buffer.from('public')), + }, + ]); + writeFileSync(join(root, 'dist', 'unexpected.map'), 'extra'); + assert.equal(installedInventory(root).length, 2, 'extra file must remain visible to binding'); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tools/cli-package/pack.mjs b/tools/cli-package/pack.mjs new file mode 100644 index 00000000..eec38369 --- /dev/null +++ b/tools/cli-package/pack.mjs @@ -0,0 +1,40 @@ +/** Fresh dependency emit, sole CLI prepack, independent seal. Never calls a cached build. */ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readdirSync, rmSync, realpathSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isolatedEnvironment, runPnpm } from './process.mjs'; +import { sealPackage } from './seal.mjs'; +import { emitFreshPackage } from './pipeline.mjs'; + +assert.equal(process.argv.length, 3, 'one empty artifact directory required'); +const repository = fileURLToPath(new URL('../../', import.meta.url)); +const destination = resolve(process.argv[2]); +mkdirSync(destination, { recursive: true }); +assert.deepEqual(readdirSync(destination), [], 'PACKED_CLI_FRESH_DESTINATION_REQUIRED'); +const home = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-package-build-'))); +try { + const env = isolatedEnvironment(home); + const version = await runPnpm(['--version'], { cwd: repository, env }); + assert.equal(version.stdout.trim(), '9.12.3', 'PACKED_CLI_PNPM_VERSION'); + const receipt = await emitFreshPackage({ + repository, + destination, + env, + pnpm: runPnpm, + removeDist: (path) => rmSync(path, { recursive: true, force: true }), + seal: sealPackage, + }); + console.log( + JSON.stringify({ + mode: receipt.mode, + source: receipt.source, + sha256: receipt.tarballSha256, + files: receipt.files.length, + mapsAndSymbolication: 'not implemented until Wave 3 Step 9', + }), + ); +} finally { + rmSync(home, { recursive: true, force: true }); +} diff --git a/tools/cli-package/pipeline.mjs b/tools/cli-package/pipeline.mjs new file mode 100644 index 00000000..7317cfd4 --- /dev/null +++ b/tools/cli-package/pipeline.mjs @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict'; +import { join } from 'node:path'; + +/** One ordered emitter; ports are tooling boundaries, never shipped product hooks. */ +export async function emitFreshPackage({ repository, destination, env, pnpm, removeDist, seal }) { + for (const name of ['shared', 'llm', 'core', 'db', 'mcp']) { + const cwd = join(repository, 'packages', name); + await removeDist(join(cwd, 'dist')); + await pnpm(['exec', 'tsc', '-p', 'tsconfig.build.json', '--incremental', 'false'], { + cwd, + env, + }); + } + const packed = await pnpm(['pack', '--pack-destination', destination], { + cwd: join(repository, 'apps/cli'), + env, + }); + // Independently observe the installed packer's sole prepack build, rather than merely counting API calls. + assert.equal( + (packed.stdout.match(/\bESM Build start\b/gu) ?? []).length, + 1, + 'PACKED_CLI_SOLE_PREPACK_BUILD_REQUIRED', + ); + return seal(destination); +} diff --git a/tools/cli-package/pipeline.test.mjs b/tools/cli-package/pipeline.test.mjs new file mode 100644 index 00000000..cbf9a989 --- /dev/null +++ b/tools/cli-package/pipeline.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { emitFreshPackage } from './pipeline.mjs'; + +function fixture(failAt, stdout = 'ESM Build start\nESM Build success') { + const repository = join(process.cwd(), 'synthetic-owned-checkout'); + const destination = join(process.cwd(), 'synthetic-public-output'); + const calls = []; + let commands = 0; + return { + calls, + repository, + destination, + env: { HOME: 'synthetic' }, + async removeDist(path) { + calls.push({ remove: path }); + }, + async pnpm(args, options) { + calls.push({ args, cwd: options.cwd }); + commands += 1; + if (commands === failAt) throw new Error('synthetic dependency or prepack failure'); + return { stdout }; + }, + async seal(path) { + calls.push({ seal: path }); + return 'actual inspected receipt'; + }, + }; +} + +test('fresh pipeline orders five dependency emits before the sole prepack and independent seal', async () => { + const ports = fixture(); + assert.equal(await emitFreshPackage(ports), 'actual inspected receipt'); + const commands = ports.calls.filter((call) => call.args); + assert.deepEqual( + commands.map((call) => call.cwd), + [ + ...['shared', 'llm', 'core', 'db', 'mcp'].map((name) => + join(ports.repository, 'packages', name), + ), + join(ports.repository, 'apps/cli'), + ], + ); + assert.deepEqual( + commands.slice(0, 5).map((call) => call.args), + Array.from({ length: 5 }, () => [ + 'exec', + 'tsc', + '-p', + 'tsconfig.build.json', + '--incremental', + 'false', + ]), + ); + assert.deepEqual(commands[5].args, ['pack', '--pack-destination', ports.destination]); + assert.deepEqual(ports.calls.at(-1), { seal: ports.destination }); + assert.equal(ports.calls.filter((call) => call.remove).length, 5); + assert.ok(commands.every((call) => !call.args.includes('turbo') && !call.args.includes('build'))); +}); + +for (const failAt of [1, 2, 3, 4, 5, 6]) + test(`failure at emission ${failAt} prevents later work and seal`, async () => { + const ports = fixture(failAt); + await assert.rejects(emitFreshPackage(ports), /synthetic dependency or prepack failure/); + assert.equal(ports.calls.filter((call) => call.args).length, failAt); + assert.equal( + ports.calls.some((call) => call.seal), + false, + ); + }); + +for (const stdout of ['', 'ESM Build start\nESM Build start\n']) + test(`missing/duplicate observed prepack build refuses before seal: ${JSON.stringify(stdout)}`, async () => { + const ports = fixture(undefined, stdout); + await assert.rejects(emitFreshPackage(ports), /SOLE_PREPACK_BUILD_REQUIRED/); + assert.equal( + ports.calls.some((call) => call.seal), + false, + ); + }); + +test('the shipped prepack lifecycle has exactly one build and runs its closure gate', () => { + const metadata = JSON.parse( + readFileSync(new URL('../../apps/cli/package.json', import.meta.url), 'utf8'), + ); + assert.equal(metadata.scripts.prepack, 'tsup && pnpm -w run lint:bundle-closure'); +}); diff --git a/tools/cli-package/process.mjs b/tools/cli-package/process.mjs new file mode 100644 index 00000000..d4179075 --- /dev/null +++ b/tools/cli-package/process.mjs @@ -0,0 +1,143 @@ +/** Native tooling boundary. Never starts a shell or inherits credentials/config/preloads. */ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { Buffer } from 'node:buffer'; +import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'; +import { delimiter, extname, isAbsolute, join } from 'node:path'; +import { clearTimeout, setTimeout } from 'node:timers'; + +export function isolatedEnvironment(home) { + const env = { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: home, + XDG_CACHE_HOME: home, + TMPDIR: home, + TMP: home, + TEMP: home, + CI: 'true', + NO_COLOR: '1', + TERM: 'dumb', + COREPACK_ENABLE_DOWNLOAD_PROMPT: '0', + }; + for (const key of ['PATH', 'SystemRoot', 'WINDIR', 'LANG']) + if (process.env[key] !== undefined) env[key] = process.env[key]; + return env; +} + +/** Resolve reviewed pnpm JS shims or the action's standalone executable, without cmd.exe. */ +export function resolvePnpm(path = process.env.PATH ?? '', platform = process.platform) { + for (const directory of path.split(delimiter).filter(Boolean)) { + if (!isAbsolute(directory)) continue; + const candidates = + platform === 'win32' + ? [ + 'pnpm.exe', + 'pnpm.cjs', + 'pnpm.js', + '../pnpm/bin/pnpm.cjs', + '../lib/node_modules/pnpm/bin/pnpm.cjs', + ] + : ['pnpm', 'pnpm.cjs']; + for (const name of candidates) { + const candidate = join(directory, name); + if (!existsSync(candidate) || !statSync(candidate).isFile()) continue; + const target = realpathSync(candidate); + if (platform === 'win32' && extname(target).toLowerCase() === '.exe') + return { command: target, prefix: [] }; + if ( + /\.(?:c?js|mjs)$/u.test(target) || + readFileSync(target) + .subarray(0, 128) + .toString('utf8') + .match(/^#![^\n]*\bnode\b/u) + ) + return { command: process.execPath, prefix: [target] }; + } + } + throw new Error('PACKED_CLI_PINNED_PNPM_NOT_FOUND'); +} + +export function runCommand(command, args, options = {}) { + const { timeoutMs = 300_000, maxBytes = 8 * 1024 * 1024, ...spawnOptions } = options; + assert.ok(Number.isSafeInteger(timeoutMs) && timeoutMs > 0); + assert.ok(Number.isSafeInteger(maxBytes) && maxBytes > 0); + return new Promise((resolve, reject) => { + const grouped = process.platform !== 'win32'; + const child = spawn(command, args, { + ...spawnOptions, + detached: grouped, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let bytes = 0, + fault, + closed = false, + killer, + killerTimer; + const output = { stdout: [], stderr: [] }; + function stop() { + if (closed || child.pid === undefined) return; + if (grouped) { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') fault ??= error; + } + } else { + const systemRoot = process.env.SystemRoot; + if (systemRoot && isAbsolute(systemRoot)) { + // Tool-owned installer children only. Product owner adapters are a separate Step 8 gate. + killer ??= spawn( + join(systemRoot, 'System32', 'taskkill.exe'), + ['/PID', String(child.pid), '/T', '/F'], + { shell: false, stdio: 'ignore' }, + ); + killer.once('error', () => child.kill('SIGKILL')); + killerTimer ??= setTimeout(() => { + killer.kill('SIGKILL'); + child.kill('SIGKILL'); + }, 3000); + } else child.kill('SIGKILL'); + } + } + const timer = setTimeout(() => { + fault ??= new Error('PACKED_CLI_CHILD_TIMEOUT'); + stop(); + }, timeoutMs); + for (const sink of ['stdout', 'stderr']) + child[sink].on('data', (chunk) => { + bytes += chunk.length; + if (bytes > maxBytes) { + fault ??= new Error('PACKED_CLI_CHILD_OUTPUT_BOUND'); + stop(); + } else output[sink].push(chunk); + }); + child.once('error', (error) => { + fault ??= error; + }); + child.once('close', async (code, signal) => { + closed = true; + clearTimeout(timer); + if (killer && killer.exitCode === null && killer.signalCode === null) + await new Promise((done) => killer.once('close', done)); + clearTimeout(killerTimer); + const result = { + code, + signal, + stdout: Buffer.concat(output.stdout).toString('utf8'), + stderr: Buffer.concat(output.stderr).toString('utf8'), + }; + if (fault || code !== 0 || signal !== null) { + const error = new Error(`PACKED_CLI_CHILD_FAILED: ${fault?.message ?? code ?? signal}`); + error.result = result; + reject(error); + } else resolve(result); + }); + }); +} + +export async function runPnpm(args, options) { + const pnpm = resolvePnpm(); + return runCommand(pnpm.command, [...pnpm.prefix, ...args], options); +} diff --git a/tools/cli-package/process.test.mjs b/tools/cli-package/process.test.mjs new file mode 100644 index 00000000..e06366de --- /dev/null +++ b/tools/cli-package/process.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { isolatedEnvironment, resolvePnpm, runCommand } from './process.mjs'; + +test('installer receives an explicit credential/config/preload-free environment', () => { + const home = join(tmpdir(), 'synthetic-installer-home'); + const env = isolatedEnvironment(home); + assert.equal(env.HOME, home); + assert.equal(env.USERPROFILE, home); + assert.equal(env.COREPACK_ENABLE_DOWNLOAD_PROMPT, '0'); + for (const key of [ + 'NODE_OPTIONS', + 'NPM_TOKEN', + 'NODE_AUTH_TOKEN', + 'OPENAI_API_KEY', + 'RELAVIUM_OPENAI_API_KEY', + 'npm_config_userconfig', + 'TURBO_TOKEN', + 'TURBO_TEAM', + ]) + assert.equal(Object.hasOwn(env, key), false, key); +}); + +test('pnpm JS shim and Windows action layout resolve without executing a shell', () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-pnpm-layout-'))); + try { + writeFileSync(join(root, 'pnpm'), '#!/usr/bin/env node\nthrow new Error("not executed")'); + assert.deepEqual(resolvePnpm(root, 'darwin'), { + command: process.execPath, + prefix: [join(root, 'pnpm')], + }); + mkdirSync(join(root, '.bin')); + mkdirSync(join(root, 'pnpm-bin', 'bin'), { recursive: true }); + writeFileSync(join(root, '.bin', 'pnpm.cjs'), 'throw new Error("not executed")'); + assert.deepEqual(resolvePnpm(join(root, '.bin'), 'win32'), { + command: process.execPath, + prefix: [join(root, '.bin', 'pnpm.cjs')], + }); + assert.throws(() => resolvePnpm(join(root, 'absent')), /PINNED_PNPM_NOT_FOUND/); + assert.throws(() => resolvePnpm('relative-not-authoritative'), /PINNED_PNPM_NOT_FOUND/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('installer preserves nonzero exit evidence and joins its actual child', async () => { + await assert.rejects( + runCommand( + process.execPath, + ['-e', 'process.stderr.write("synthetic-install-failure");process.exitCode=12'], + { env: isolatedEnvironment(tmpdir()), timeoutMs: 5000 }, + ), + (error) => { + assert.equal(error.result.code, 12); + assert.equal(error.result.signal, null); + assert.equal(error.result.stderr, 'synthetic-install-failure'); + return true; + }, + ); +}); + +test('installer rejects and joins a real output flood', async () => { + await assert.rejects( + runCommand( + process.execPath, + ['-e', 'process.stdout.write("x".repeat(20000));setInterval(()=>{},1000)'], + { env: isolatedEnvironment(tmpdir()), timeoutMs: 5000, maxBytes: 32 }, + ), + /OUTPUT_BOUND/, + ); +}); + +test('installer rejects and joins a real deadline violation', async () => { + await assert.rejects( + runCommand(process.execPath, ['-e', 'setInterval(()=>{},1000)'], { + env: isolatedEnvironment(tmpdir()), + timeoutMs: 100, + }), + /CHILD_TIMEOUT/, + ); +}); diff --git a/tools/cli-package/seal.mjs b/tools/cli-package/seal.mjs new file mode 100644 index 00000000..4f5c6b5b --- /dev/null +++ b/tools/cli-package/seal.mjs @@ -0,0 +1,119 @@ +/** Bind the sole prepack result to independently inspected bytes. No build or archive extraction. */ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { inspectPackageArchive } from './archive.mjs'; +import { originFromEnvironment, validatePackageReceipt } from './binding.mjs'; +import { flatFiles, oneTarball, readOrdinaryFile } from './files.mjs'; +import { isolatedEnvironment, runCommand } from './process.mjs'; + +const repository = fileURLToPath(new URL('../../', import.meta.url)); + +export function expectedPackManifest(root = repository) { + const require = createRequire(join(root, 'apps/cli/package.json')); + const { parse } = require('yaml'); + const catalog = parse(readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8')).catalog; + const manifest = JSON.parse(readFileSync(join(root, 'apps/cli/package.json'), 'utf8')); + for (const field of ['dependencies', 'devDependencies']) { + for (const [name, value] of Object.entries(manifest[field] ?? {})) { + if (value === 'catalog:') { + assert.equal(typeof catalog[name], 'string', 'PACKED_CLI_UNKNOWN_CATALOG_DEPENDENCY'); + manifest[field][name] = catalog[name]; + } else if (value === 'workspace:*') { + assert.match(name, /^@relavium\/[a-z-]+$/u); + manifest[field][name] = JSON.parse( + readFileSync( + join(root, 'packages', name.slice('@relavium/'.length), 'package.json'), + 'utf8', + ), + ).version; + } + } + } + // pnpm removes the executed packaging lifecycle, preserving normal public metadata/scripts. + delete manifest.scripts.prepack; + return manifest; +} + +export function finalizedPublicFiles(root = repository) { + const cli = join(root, 'apps/cli'); + const files = new Map(); + const graph = JSON.parse(readOrdinaryFile(join(cli, 'dist/metafile-esm.json'), 4 * 1024 * 1024)); + const outputs = Object.keys(graph.outputs).map((path) => path.replaceAll('\\', '/')); + assert.ok(outputs.length > 0 && outputs.length <= 256); + for (const path of outputs) { + assert.match(path, /^dist\/[A-Za-z0-9_-]+\.js$/u, 'PACKED_CLI_UNREVIEWED_GRAPH_OUTPUT'); + files.set(`package/${path}`, readOrdinaryFile(join(cli, path), 8 * 1024 * 1024)); + } + assert.deepEqual( + readdirSync(join(cli, 'dist')).sort(), + [...outputs.map((path) => basename(path)), 'metafile-esm.json'].sort(), + 'PACKED_CLI_EXTRA_FINAL_OUTPUT', + ); + const canonical = flatFiles(join(root, 'packages/db/drizzle'), 'package/drizzle'); + const copied = flatFiles(join(cli, 'drizzle'), 'package/drizzle'); + assert.deepEqual(copied, canonical, 'PACKED_CLI_MIGRATION_COPY_CHANGED'); + for (const [path, bytes] of canonical) files.set(path, bytes); + for (const name of ['package.json', 'README.md', 'CHANGELOG.md']) + files.set(`package/${name}`, readOrdinaryFile(join(cli, name), 128 * 1024)); + files.set('package/LICENSE', readOrdinaryFile(join(root, 'LICENSE'), 128 * 1024)); + return files; +} + +export async function sealPackage(artifactDirectory) { + const tarball = oneTarball(artifactDirectory); + const source = ( + await runCommand('git', ['--no-replace-objects', 'rev-parse', 'HEAD'], { + cwd: repository, + env: isolatedEnvironment(repository), + }) + ).stdout.trim(); + assert.match(source, /^[0-9a-f]{40}$/u); + const dirty = + ( + await runCommand('git', ['status', '--porcelain'], { + cwd: repository, + env: isolatedEnvironment(repository), + }) + ).stdout !== ''; + const archive = inspectPackageArchive(readOrdinaryFile(tarball, 16 * 1024 * 1024), { + expectedFiles: finalizedPublicFiles(), + expectedPackage: expectedPackManifest(), + }); + const origin = originFromEnvironment(process.env); + const pnpm = JSON.parse(readFileSync(join(repository, 'package.json'), 'utf8')).packageManager; + assert.equal(pnpm, 'pnpm@9.12.3'); + const receipt = { + version: 1, + mode: 'compiled_package_test', + source, + dirty, + packageVersion: archive.package.version, + tarball: basename(tarball), + tarballSha256: archive.sha256, + archiveBytes: archive.bytes, + files: archive.files, + package: archive.package, + origin, + toolchain: { node: process.version, pnpm: '9.12.3' }, + }; + validatePackageReceipt(receipt, { + source, + origin, + nodeMajor: 24, + allowLocal: origin.kind === 'local', + }); + writeFileSync( + join(artifactDirectory, 'package-test-receipt.json'), + JSON.stringify(receipt, null, 2) + '\n', + { flag: 'wx' }, + ); + return receipt; +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + assert.equal(process.argv.length, 3, 'one artifact directory required'); + console.log(JSON.stringify(await sealPackage(resolve(process.argv[2])))); +} diff --git a/tools/cli-package/smoke/README.md b/tools/cli-package/smoke/README.md new file mode 100644 index 00000000..366f7639 --- /dev/null +++ b/tools/cli-package/smoke/README.md @@ -0,0 +1,60 @@ +# Installed CLI behavior smoke + +`runInstalledSmoke({ packageRoot, supportRoot, artifactSha256, source })` from +[index.mjs](index.mjs) tests the actual installed `relavium` package. The caller +inspects and installs the exact tarball first, binds its digest/source identity, +and supplies absolute paths. This runner never builds, packs, installs or rewrites +product files. `supportRoot/installed-smoke.json` contains bounded structured +results; the runner removes its owned temporary HOME/workspaces after joining +children. Every compiled JS digest must remain unchanged. + +[cases.mjs](cases.mjs) is the fixed required case inventory, shared with the +aggregate. Each result has `id`, `passed`, and evidence or an error. Overall +`success` requires the complete inventory and unchanged compiled bytes. + +- Load the actual keyring native addon without constructing `Entry` or calling a + keychain API; load actual SQLite and execute an in-memory query. Dependency and + native binary resolution must stay in the installed consumer's `node_modules`. +- Execute installed version and compare it with installed package metadata. + The surrounding bootstrap controls own exhaustive meta-operation isolation. +- Run the actual transform workflow and installed migrations; discover a paused + human gate and resume it in a fresh process. Produce a real budget pause, reject + four invalid decisions without durable mutation, abort and repeat idempotently. + A documented `agent run --fixture` cassette drives an actual governed write + denial: no target file, dispatch or effect-journal mutation. +- Drive two actual chat turns through the installed OpenAI SDK, inlined adapter, + AgentSession, JSON printer and SQLite stores. Hold stdin until assistant output + and its durable row exist, then `/exit` with code 4. Resume the same session and + check the second real SDK request carries the first exchange, four ordered + transcript rows, exact dispatch counts and complete durable usage. +- Spawn a real native stdio MCP fixture after the actual consent refusal/digest + flow, verify initialize carries the installed CLI version, discover tools, and + join the native child. No provider/key lookup is needed on this gate-only graph. +- Run the existing parameterized actual media/CAS native cleanup witness against + the installed entry, including the shared iterator-provenance ownership join. + +Chat's key resolver is keychain-first: exactly two synthetic `Entry` constructor +attempts are recorded and refused before native API access. The budget run has one +separately asserted synthetic preflight attempt. All other key/network attempts +are forbidden. The native addon load-only process has no keyring substitute. + +The external `--import` preload uses Node's `module.register` API available in +Node 22.0. It replaces only the keyring import and exact synthetic HTTP transport, +not shipped commands, engine code, SDK parsing or persistence. The only admitted +network-shaped data fetch is QuickJS's local WASM data URL; the only admitted +child spawn is the explicit MCP fixture. These are test witnesses, not a claimed +security sandbox. The chat transport admits one exact synthetic OpenAI request +shape and returns complete synthetic SSE. No real credential, provider call or +billing setup is used. + +The exact Node 22.0 Linux and current Node 24 Linux/macOS/Windows consumers must +execute this same runner and artifact digest. A local pass cannot substitute for +those required platform legs. Archive inspection, bootstrap/meta controls and +aggregate enforcement belong to the surrounding package runner. Native fatal +integration and original-TS maps/custody retain their later Wave 3 scopes. + +Narrow child-lifetime negative controls run with: + +```sh +node --test tools/cli-package/smoke/process.test.mjs +``` diff --git a/tools/cli-package/smoke/cases.mjs b/tools/cli-package/smoke/cases.mjs new file mode 100644 index 00000000..b06cdf41 --- /dev/null +++ b/tools/cli-package/smoke/cases.mjs @@ -0,0 +1,9 @@ +// Fixed inventory shared with CI aggregation. A missing case is never a skipped pass. +export const REQUIRED_SMOKE_CASES = Object.freeze([ + 'native-addon-load-only', + 'installed-version', + 'workflow-gate-budget-effects', + 'real-sdk-chat-persistence-resume', + 'actual-mcp-initialize-version', + 'actual-media-cas-iterator-cleanup', +]); diff --git a/tools/cli-package/smoke/chat.mjs b/tools/cli-package/smoke/chat.mjs new file mode 100644 index 00000000..2a9b35e3 --- /dev/null +++ b/tools/cli-package/smoke/chat.mjs @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { assertExit, ndjson, poll, startOwned } from './process.mjs'; + +export async function assertInstalledChat(context) { + const { root, Database, cli, environment, calls } = context; + const home = join(root, 'chat-home'), + cwd = join(root, 'chat-workspace'); + mkdirSync(home); + mkdirSync(cwd); + mkdirSync(join(cwd, '.relavium')); + writeFileSync( + join(cwd, '.relavium/project.toml'), + '[chat]\nmax_turns = 4\nauto_compact = false\n', + ); + writeFileSync( + join(cwd, 'probe.agent.yaml'), + 'id: packaged-chat\nprovider: openai\nmodel: gpt-4o-mini\nsystem_prompt: Synthetic offline packaged chat.\ntools: []\nmax_tokens: 64\n', + ); + const prompts = ['packaged-user-one', 'packaged-user-two']; + const replies = ['packaged-assistant-one', 'packaged-assistant-two']; + const dbPath = join(home, '.relavium/history.db'); + const snapshot = () => { + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + try { + return { + sessions: db.prepare('SELECT * FROM agent_sessions').all(), + messages: db.prepare('SELECT * FROM session_messages ORDER BY sequence_number').all(), + costs: db.prepare('SELECT * FROM session_costs').all(), + }; + } finally { + db.close(); + } + }; + const runs = []; + const before = calls().length; + async function drive(args, index) { + const env = environment(home, { + RELAVIUM_OPENAI_API_KEY: 'sk-relavium-synthetic-package-smoke', + RELAVIUM_PACKAGE_SMOKE_PROMPT: prompts[index], + RELAVIUM_PACKAGE_SMOKE_REPLY: replies[index], + }); + const owned = startOwned(cli(args), { cwd, env }); + let lastReadError; + try { + owned.child.stdin.write(prompts[index] + '\n'); + await poll( + () => { + assert.equal( + owned.isSettled(), + false, + `Chat exited before a completed turn: ${owned.output().stderr}`, + ); + return ndjson(owned.output().stdout.split('\n').slice(0, -1).join('\n')).some( + (event) => event.type === 'session:turn_completed', + ); + }, + 20000, + 'Actual installed AgentSession did not complete a turn', + ); + const events = ndjson(owned.output().stdout); + const completed = events.filter((event) => event.type === 'session:turn_completed'); + assert.equal(completed.length, 1); + assert.equal(completed[0].error, undefined); + assert.ok(owned.output().stdout.includes(replies[index])); + await poll( + () => { + try { + return snapshot().messages.some( + (row) => row.role === 'assistant' && JSON.stringify(row).includes(replies[index]), + ); + } catch (error) { + lastReadError = String(error); + return false; + } + }, + 5000, + `Assistant reply not persisted before end: ${lastReadError}`, + ); + assert.equal(owned.isSettled(), false, 'Stdin must remain held until assistant persistence'); + owned.child.stdin.end('/exit\n'); + const result = await owned.result(); + assertExit(result, 4); + runs.push({ + args, + exit: result.code, + events: ndjson(result.stdout).map((event) => event.type), + exitSentAfterPersist: true, + outputBytes: result.bytes, + }); + } finally { + await owned.close(); + } + } + await drive(['chat', '--agent', './probe.agent.yaml', '--json'], 0); + const first = snapshot(); + assert.equal(first.sessions.length, 1); + assert.equal(first.messages.length, 2); + const id = first.sessions[0].id; + await drive(['chat-resume', id, '--json'], 1); + const final = snapshot(); + assert.equal(final.sessions.length, 1); + assert.equal(final.sessions[0].id, id); + assert.equal(final.sessions[0].status, 'ended'); + assert.deepEqual( + final.messages.map((row) => [row.sequence_number, row.role]), + [ + [0, 'user'], + [1, 'assistant'], + [2, 'user'], + [3, 'assistant'], + ], + ); + for (const [index, marker] of [prompts[0], replies[0], prompts[1], replies[1]].entries()) + assert.ok(JSON.stringify(final.messages[index]).includes(marker)); + const observed = calls().slice(before); + const dispatches = observed.filter((call) => call.operation === 'sdk-fetch'); + assert.equal(dispatches.length, 2); + assert.ok( + dispatches[1].body.messages.some( + (message) => message.role === 'user' && message.content === prompts[0], + ), + ); + assert.ok( + dispatches[1].body.messages.some( + (message) => message.role === 'assistant' && message.content === replies[0], + ), + ); + const attempts = observed.filter((call) => call.operation === 'keyring.Entry'); + assert.equal(attempts.length, 2); + for (const attempt of attempts) + assert.deepEqual( + [attempt.service, attempt.account, attempt.denied], + ['relavium', 'openai:default', true], + ); + assert.equal( + observed.filter((call) => call.denied && call.operation !== 'keyring.Entry').length, + 0, + ); + const session = final.sessions[0]; + assert.equal(session.total_input_tokens, 22); + assert.equal(session.total_output_tokens, 14); + assert.equal(session.total_cost_microcents, 1170); + assert.equal(session.usage_unverified, 0); + assert.equal(session.usage_pending_invocations, 0); + assert.equal(session.usage_unknown_calls, 0); + assert.equal(session.usage_mutation_revision, session.usage_ack_revision); + assert.equal(final.costs.length, 1); + assert.equal(final.costs[0].call_count, 2); + assert.equal(final.costs[0].cost_microcents, session.total_cost_microcents); + return { + sessionId: id, + runs, + dispatches: 2, + keyringDeniedAttempts: 2, + forbiddenNetworkOrProcessCalls: 0, + transcript: final.messages, + usage: session, + costs: final.costs, + sdkRequests: dispatches, + }; +} diff --git a/tools/cli-package/smoke/guard-log.mjs b/tools/cli-package/smoke/guard-log.mjs new file mode 100644 index 00000000..f8a204ba --- /dev/null +++ b/tools/cli-package/smoke/guard-log.mjs @@ -0,0 +1,18 @@ +import { Buffer } from 'node:buffer'; +import { appendFileSync, existsSync, statSync } from 'node:fs'; + +export function record(value) { + const path = process.env.RELAVIUM_PACKAGE_SMOKE_LOG; + if (!path) throw new Error('Explicit package smoke log required'); + const line = JSON.stringify(value) + '\n'; + if ( + Buffer.byteLength(line) > 65536 || + (existsSync(path) && statSync(path).size + Buffer.byteLength(line) > 262144) + ) + throw new Error('Package smoke record byte ceiling exceeded'); + appendFileSync(path, line, { mode: 0o600 }); +} +export const denied = (operation) => () => { + record({ operation, denied: true }); + throw new Error(`Package smoke refuses ${operation}`); +}; diff --git a/tools/cli-package/smoke/index.mjs b/tools/cli-package/smoke/index.mjs new file mode 100644 index 00000000..81ea0a5f --- /dev/null +++ b/tools/cli-package/smoke/index.mjs @@ -0,0 +1,261 @@ +import { Buffer } from 'node:buffer'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { REQUIRED_SMOKE_CASES } from './cases.mjs'; +import { assertInstalledChat } from './chat.mjs'; +import { assertInstalledMcp, assertInstalledWorkflows } from './workflows.mjs'; +import { assertExit, explicitEnvironment, invoke, ndjson } from './process.mjs'; + +const directory = dirname(fileURLToPath(import.meta.url)); +const repository = resolve(directory, '../../..'); +const within = (parent, child) => { + const part = relative(parent, child); + return part === '' || (part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part)); +}; +const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const snapshot = (packageRoot) => + Object.fromEntries( + readdirSync(join(packageRoot, 'dist')) + .filter((name) => name.endsWith('.js')) + .sort() + .map((name) => [name, digest(readFileSync(join(packageRoot, 'dist', name)))]), + ); + +/** Shared by required exact-tarball consumers and release consumers. Does not pack/install/build. */ +export async function runInstalledSmoke({ packageRoot, supportRoot, artifactSha256, source }) { + assert.ok( + isAbsolute(packageRoot) && isAbsolute(supportRoot), + 'Explicit absolute installed/support roots required', + ); + assert.match(artifactSha256, /^[a-f0-9]{64}$/, 'Exact artifact SHA-256 required'); + assert.notEqual(source, undefined, 'Explicit source identity required'); + packageRoot = realpathSync(packageRoot); + assert.equal( + within(repository, packageRoot), + false, + 'Installed package must be outside the workspace', + ); + mkdirSync(supportRoot, { recursive: true, mode: 0o700 }); + supportRoot = realpathSync(supportRoot); + assert.equal( + within(packageRoot, supportRoot), + false, + 'Test support must remain outside the shipped package', + ); + const require = createRequire(join(packageRoot, 'package.json')); + const entry = join(packageRoot, 'dist/index.js'); + const before = snapshot(packageRoot); + let installedModules; + for (let parent = dirname(packageRoot); parent !== dirname(parent); parent = dirname(parent)) + if (parent.endsWith(`${sep}node_modules`)) installedModules = parent; + assert.ok(installedModules, 'Installed package must have an explicit node_modules owner'); + const root = mkdtempSync(join(supportRoot, 'installed-smoke-')); + const log = join(root, 'guard.ndjson'); + const report = { + schemaVersion: 1, + artifactSha256, + source, + packageRoot, + runtime: process.version, + platform: process.platform, + arch: process.arch, + cases: [], + success: false, + }; + const calls = () => (existsSync(log) ? ndjson(readFileSync(log, 'utf8')) : []); + const environment = (home, extra = {}) => + explicitEnvironment(home, root, { RELAVIUM_PACKAGE_SMOKE_LOG: log, ...extra }); + const cli = (args) => [ + '--import', + pathToFileURL(join(directory, 'preload.mjs')).href, + entry, + ...args, + ]; + async function runCase(name, operation) { + const started = Date.now(); + try { + const evidence = await operation(); + report.cases.push({ id: name, passed: true, elapsedMs: Date.now() - started, evidence }); + } catch (error) { + report.cases.push({ + id: name, + passed: false, + elapsedMs: Date.now() - started, + error: String(error.stack ?? error), + }); + } + } + try { + const metadata = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')); + assert.equal(metadata.name, 'relavium'); + const dependencies = {}; + for (const name of [ + 'better-sqlite3', + 'openai', + '@napi-rs/keyring', + '@modelcontextprotocol/sdk', + '@google/genai', + ]) { + const resolved = realpathSync( + require.resolve( + name === '@modelcontextprotocol/sdk' ? '@modelcontextprotocol/sdk/client/index.js' : name, + ), + ); + assert.equal(within(repository, resolved), false, `Workspace dependency leakage: ${name}`); + assert.equal( + within(installedModules, resolved), + true, + `Dependency escaped installed consumer: ${name}`, + ); + let metadataPath = dirname(resolved), + dependencyMetadata; + for (;;) { + const candidate = join(metadataPath, 'package.json'); + if (existsSync(candidate)) { + const parsed = JSON.parse(readFileSync(candidate, 'utf8')); + if (parsed.name === name) { + dependencyMetadata = parsed; + break; + } + } + assert.notEqual( + metadataPath, + dirname(metadataPath), + `Dependency metadata missing: ${name}`, + ); + metadataPath = dirname(metadataPath); + } + dependencies[name] = { + entry: resolved, + packageRoot: metadataPath, + version: dependencyMetadata.version, + }; + } + report.dependencies = dependencies; + const Database = require('better-sqlite3'); + const context = { root, Database, cli, environment, calls }; + await runCase('native-addon-load-only', async () => { + const result = await invoke([join(directory, 'native-load.mjs'), packageRoot], { + cwd: root, + env: environment(root), + }); + assertExit(result, 0); + const evidence = JSON.parse(result.stdout); + for (const path of [evidence.keyring, evidence.sqlite, ...evidence.nativeModules]) { + assert.equal(within(repository, realpathSync(path)), false); + assert.equal(within(installedModules, realpathSync(path)), true); + } + return evidence; + }); + let version; + await runCase('installed-version', async () => { + const result = await invoke(cli(['--version']), { cwd: root, env: environment(root) }); + assertExit(result, 0); + version = result.stdout.trim(); + assert.equal(version, metadata.version); + assert.equal(calls().length, 0, 'Version must not load/use external services'); + return { version, outputBytes: result.bytes }; + }); + await runCase('workflow-gate-budget-effects', () => assertInstalledWorkflows(context)); + await runCase('real-sdk-chat-persistence-resume', () => assertInstalledChat(context)); + await runCase('actual-mcp-initialize-version', async () => { + assert.ok(version, 'Installed version control must pass before version comparison'); + return assertInstalledMcp(context, version); + }); + await runCase('actual-media-cas-iterator-cleanup', async () => { + const fixture = join( + repository, + 'tools/fs-policy-controls/fixtures/media-native-packaging.mjs', + ); + const result = await invoke([fixture, entry], { + cwd: root, + env: environment(root), + timeout: 30000, + }); + assertExit(result, 0); + assert.ok( + result.stdout.includes( + 'actual CLI bundled native registration/consumption joined after owned-file cleanup', + ), + ); + return { + actualInstalledEntry: entry, + nativeJoinAndOwnedCasCleanup: true, + outputBytes: result.bytes, + }; + }); + report.guardCalls = calls(); + for (const call of report.guardCalls.filter((value) => value.operation === 'external-module')) { + const resolved = realpathSync(fileURLToPath(call.url)); + assert.equal( + within(repository, resolved), + false, + 'Actual SDK/SQLite dispatch resolved through workspace', + ); + assert.equal( + within(installedModules, resolved), + true, + 'Actual SDK/SQLite dispatch escaped installed consumer', + ); + assert.equal( + within(dependencies[call.specifier].packageRoot, resolved), + true, + 'Actual dispatch used another dependency instance', + ); + } + assert.ok( + report.guardCalls.some( + (call) => call.operation === 'external-module' && call.specifier === 'openai', + ), + 'Actual installed SDK resolution witness missing', + ); + assert.ok( + report.guardCalls.some( + (call) => call.operation === 'external-module' && call.specifier === 'better-sqlite3', + ), + 'Actual installed SQLite resolution witness missing', + ); + assert.deepEqual( + report.cases.map((test) => test.id).sort(), + [...REQUIRED_SMOKE_CASES].sort(), + 'Required installed case inventory mismatch', + ); + report.success = report.cases.every((test) => test.passed); + } catch (error) { + report.error = String(error.stack ?? error); + } finally { + try { + const after = snapshot(packageRoot); + report.compiledUnchanged = JSON.stringify(before) === JSON.stringify(after); + report.compiledDigests = after; + } catch (error) { + report.compiledUnchanged = false; + report.immutabilityError = String(error); + } + if (!report.compiledUnchanged) report.success = false; + try { + rmSync(root, { recursive: true, force: true }); + } catch (error) { + report.cleanupError = String(error); + } + report.ownedTempRemoved = !existsSync(root); + if (!report.ownedTempRemoved) report.success = false; + const json = JSON.stringify(report, null, 2) + '\n'; + assert.ok(Buffer.byteLength(json) <= 2097152, 'Installed smoke evidence byte ceiling exceeded'); + writeFileSync(join(supportRoot, 'installed-smoke.json'), json, { mode: 0o600 }); + } + return report; +} diff --git a/tools/cli-package/smoke/keyring-denied.mjs b/tools/cli-package/smoke/keyring-denied.mjs new file mode 100644 index 00000000..1b76a7d8 --- /dev/null +++ b/tools/cli-package/smoke/keyring-denied.mjs @@ -0,0 +1,7 @@ +import { record } from './guard-log.mjs'; +export class Entry { + constructor(service, account) { + record({ operation: 'keyring.Entry', service, account, denied: true }); + throw new Error('Synthetic package smoke refuses real keyring API'); + } +} diff --git a/tools/cli-package/smoke/mcp-server.mjs b/tools/cli-package/smoke/mcp-server.mjs new file mode 100644 index 00000000..ec297f51 --- /dev/null +++ b/tools/cli-package/smoke/mcp-server.mjs @@ -0,0 +1,44 @@ +import { Buffer } from 'node:buffer'; +import { appendFileSync, existsSync, statSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +const log = process.env.RELAVIUM_PACKAGE_SMOKE_MCP_LOG; +if (!log) throw new Error('Explicit MCP fixture log required'); +const record = (value) => { + const line = JSON.stringify(value) + '\n'; + if ( + Buffer.byteLength(line) > 65536 || + (existsSync(log) && statSync(log).size + Buffer.byteLength(line) > 262144) + ) + throw new Error('MCP fixture record byte ceiling exceeded'); + appendFileSync(log, line, { mode: 0o600 }); +}; +record({ operation: 'started', pid: process.pid }); +const lines = createInterface({ input: process.stdin }); +lines.on('line', (line) => { + if (Buffer.byteLength(line) > 65536) throw new Error('MCP fixture line ceiling'); + const message = JSON.parse(line); + if (message.method === 'initialize') { + record({ operation: 'initialize', params: message.params }); + process.stdout.write( + JSON.stringify({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: message.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'installed-package-smoke', version: '1.0.0' }, + }, + }) + '\n', + ); + } else if (message.method === 'tools/list') { + record({ operation: 'tools/list' }); + process.stdout.write( + JSON.stringify({ jsonrpc: '2.0', id: message.id, result: { tools: [] } }) + '\n', + ); + } else if (message.id !== undefined) { + throw new Error('Unexpected MCP fixture request'); + } +}); +lines.on('close', () => { + record({ operation: 'closed' }); +}); diff --git a/tools/cli-package/smoke/module-guard.mjs b/tools/cli-package/smoke/module-guard.mjs new file mode 100644 index 00000000..c432bc36 --- /dev/null +++ b/tools/cli-package/smoke/module-guard.mjs @@ -0,0 +1,13 @@ +import { record } from './guard-log.mjs'; + +// Node 22.0 supports module.register. Keep the installed engine and SDK bytes untouched. +export async function resolve(specifier, context, nextResolve) { + if (specifier === '@napi-rs/keyring') + return { url: new URL('./keyring-denied.mjs', import.meta.url).href, shortCircuit: true }; + if (specifier.startsWith('@napi-rs/keyring-')) + throw new Error('Native keyring API import refused'); + const resolved = await nextResolve(specifier, context); + if (specifier === 'openai' || specifier === 'better-sqlite3') + record({ operation: 'external-module', specifier, url: resolved.url }); + return resolved; +} diff --git a/tools/cli-package/smoke/native-load.mjs b/tools/cli-package/smoke/native-load.mjs new file mode 100644 index 00000000..d10c5494 --- /dev/null +++ b/tools/cli-package/smoke/native-load.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +const require = createRequire(join(process.argv[2], 'package.json')); +// This process has no keyring replacement hook. Loading the real addon exercises +// native resolution/ABI only: never construct Entry or call any keychain API. +const keyring = require('@napi-rs/keyring'); +assert.equal(typeof keyring.Entry, 'function'); +const Database = require('better-sqlite3'); +const db = new Database(':memory:'); +try { + assert.equal(db.prepare('select 42 as value').get().value, 42); +} finally { + db.close(); +} +const nativeModules = Object.keys(require.cache).filter((path) => path.endsWith('.node')); +assert.ok( + nativeModules.some((path) => path.includes('keyring')), + 'Actual native keyring binary must load', +); +assert.ok( + nativeModules.some((path) => path.includes('better_sqlite3')), + 'Actual native SQLite binary must load', +); +process.stdout.write( + JSON.stringify({ + nativeModules, + keyring: require.resolve('@napi-rs/keyring'), + sqlite: require.resolve('better-sqlite3'), + keyringApiCalls: 0, + sqliteValue: 42, + }) + '\n', +); diff --git a/tools/cli-package/smoke/preload.mjs b/tools/cli-package/smoke/preload.mjs new file mode 100644 index 00000000..8938e49f --- /dev/null +++ b/tools/cli-package/smoke/preload.mjs @@ -0,0 +1,119 @@ +import { Buffer } from 'node:buffer'; +import assert from 'node:assert/strict'; +import { register, syncBuiltinESMExports } from 'node:module'; +import net from 'node:net'; +import tls from 'node:tls'; +import http from 'node:http'; +import https from 'node:https'; +import dns from 'node:dns'; +import child from 'node:child_process'; +import dgram from 'node:dgram'; +import { record, denied } from './guard-log.mjs'; + +register('./module-guard.mjs', import.meta.url); +net.connect = denied('net.connect'); +net.createConnection = denied('net.createConnection'); +net.Socket.prototype.connect = denied('Socket.connect'); +net.createServer = denied('net.createServer'); +tls.connect = denied('tls.connect'); +dgram.createSocket = denied('dgram.createSocket'); +for (const module of [http, https]) { + module.request = denied('http.request'); + module.get = denied('http.get'); + module.createServer = denied('http.createServer'); +} +for (const [label, module] of [ + ['dns', dns], + ['dns.promises', dns.promises], +]) + for (const name of Object.keys(module)) + if ( + typeof module[name] === 'function' && + !['getServers', 'setDefaultResultOrder', 'getDefaultResultOrder'].includes(name) + ) + module[name] = denied(`${label}.${name}`); +const originalSpawn = child.spawn; +child.spawn = (...args) => { + // The only admitted native child is the explicit owned MCP fixture. Shell spawning stays forbidden. + const expected = process.env.RELAVIUM_PACKAGE_SMOKE_MCP; + if ( + expected && + args[0] === process.execPath && + args[1]?.length === 1 && + args[1][0] === expected && + !args[2]?.shell + ) { + record({ operation: 'mcp-spawn', executable: args[0], args: args[1] }); + return originalSpawn(...args); + } + return denied('child_process.spawn')(); +}; +for (const name of ['spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync', 'fork']) + child[name] = denied(`child_process.${name}`); +globalThis.WebSocket = denied('WebSocket'); +syncBuiltinESMExports(); +const localFetch = globalThis.fetch; +globalThis.fetch = async (input, init) => { + if (typeof input === 'string' && input.startsWith('data:application/octet-stream;base64,')) + return localFetch(input, init); + if (!process.env.RELAVIUM_PACKAGE_SMOKE_PROMPT) return denied('fetch')(); + const request = new globalThis.Request(input, init); + const raw = await request.text(); + assert.ok(Buffer.byteLength(raw) <= 65536, 'Synthetic request too large'); + const body = JSON.parse(raw); + const valid = + request.url === 'https://api.openai.com/v1/chat/completions' && + request.method === 'POST' && + request.headers.get('authorization') === 'Bearer sk-relavium-synthetic-package-smoke' && + body.model === 'gpt-4o-mini' && + body.stream === true && + body.stream_options?.include_usage === true && + Array.isArray(body.messages) && + body.messages.at(-1)?.role === 'user' && + body.messages.at(-1)?.content === process.env.RELAVIUM_PACKAGE_SMOKE_PROMPT; + if (!valid) return denied('unexpected-fetch')(); + record({ + operation: 'sdk-fetch', + url: request.url, + method: request.method, + body, + synthetic: true, + }); + const base = { + id: 'chatcmpl-synthetic-package-smoke', + object: 'chat.completion.chunk', + created: 1792100000, + model: 'gpt-4o-mini', + }; + const frames = [ + { + ...base, + choices: [ + { + index: 0, + delta: { role: 'assistant', content: process.env.RELAVIUM_PACKAGE_SMOKE_REPLY }, + finish_reason: null, + }, + ], + }, + { ...base, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }, + { + ...base, + choices: [], + usage: { + prompt_tokens: 11, + completion_tokens: 7, + total_tokens: 18, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 0 }, + }, + }, + ]; + return new globalThis.Response( + frames.map((frame) => 'data: ' + JSON.stringify(frame) + '\n\n').join('') + 'data: [DONE]\n\n', + { + status: 200, + headers: { 'content-type': 'text/event-stream', 'x-request-id': 'synthetic-package-smoke' }, + }, + ); +}; diff --git a/tools/cli-package/smoke/process.mjs b/tools/cli-package/smoke/process.mjs new file mode 100644 index 00000000..636a1748 --- /dev/null +++ b/tools/cli-package/smoke/process.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { clearTimeout, setTimeout } from 'node:timers'; +import { setTimeout as sleep } from 'node:timers/promises'; + +export const MAX_OUTPUT_BYTES = 524288; +export function explicitEnvironment(home, temporary, extra = {}) { + return { + ...(process.env.PATH === undefined ? {} : { PATH: process.env.PATH }), + ...(process.env.SystemRoot === undefined ? {} : { SystemRoot: process.env.SystemRoot }), + ...(process.env.WINDIR === undefined ? {} : { WINDIR: process.env.WINDIR }), + HOME: home, + USERPROFILE: home, + TMPDIR: temporary, + TMP: temporary, + TEMP: temporary, + LANG: 'en_US.UTF-8', + NO_COLOR: '1', + TERM: 'dumb', + ...extra, + }; +} + +export function startOwned(args, { cwd, env, timeout = 30000 }) { + const child = spawn(process.execPath, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }); + let stdout = '', + stderr = '', + bytes = 0, + failure; + let settled = false; + const done = new Promise((resolve) => { + child.once('error', (error) => { + failure = error; + }); + child.once('close', (code, signal) => { + settled = true; + resolve({ code, signal }); + }); + }); + const capture = (kind, chunk) => { + bytes += chunk.byteLength; + if (bytes > MAX_OUTPUT_BYTES) { + failure ??= new Error('Installed smoke output byte ceiling exceeded'); + child.kill('SIGKILL'); + return; + } + if (kind === 'stdout') stdout += chunk.toString(); + else stderr += chunk.toString(); + }; + child.stdout.on('data', (chunk) => capture('stdout', chunk)); + child.stderr.on('data', (chunk) => capture('stderr', chunk)); + child.stdin.on('error', (error) => { + failure ??= error; + }); + const timer = setTimeout(() => { + failure ??= new Error('Installed smoke child deadline exceeded'); + child.kill('SIGKILL'); + }, timeout); + const joined = done.then((exit) => { + clearTimeout(timer); + return { ...exit, stdout, stderr, bytes }; + }); + return { + child, + output: () => ({ stdout, stderr, bytes }), + isSettled: () => settled, + async result() { + const result = await joined; + if (failure) throw new Error(`${failure.message}\n${stderr}`, { cause: failure }); + return result; + }, + async close() { + if (!settled) child.kill('SIGKILL'); + await joined; + clearTimeout(timer); + }, + }; +} + +export async function invoke(args, options, input = '') { + const owned = startOwned(args, options); + try { + owned.child.stdin.end(input); + return await owned.result(); + } finally { + await owned.close(); + } +} +export function assertExit(result, code) { + assert.equal(result.signal, null, result.stderr); + assert.equal(result.code, code, result.stderr); +} +export function ndjson(text) { + return text + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); +} +export async function poll(predicate, milliseconds, explanation) { + const deadline = Date.now() + milliseconds; + while (Date.now() < deadline) { + if (await predicate()) return; + await sleep(25); + } + assert.ok(await predicate(), explanation); +} diff --git a/tools/cli-package/smoke/process.test.mjs b/tools/cli-package/smoke/process.test.mjs new file mode 100644 index 00000000..becc7549 --- /dev/null +++ b/tools/cli-package/smoke/process.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { tmpdir } from 'node:os'; +import { explicitEnvironment, invoke, MAX_OUTPUT_BYTES, startOwned } from './process.mjs'; + +const options = { cwd: tmpdir(), env: explicitEnvironment(tmpdir(), tmpdir()) }; +const gone = (pid) => { + try { + process.kill(pid, 0); + return false; + } catch (error) { + if (error.code === 'ESRCH') return true; + throw error; + } +}; +test('owned child deadline joins the actual hung process', async () => { + const owned = startOwned(['-e', 'setInterval(() => {}, 1000)'], { ...options, timeout: 200 }); + const pid = owned.child.pid; + try { + await assert.rejects(owned.result(), /deadline exceeded/); + } finally { + await owned.close(); + } + assert.ok(gone(pid)); +}); +test('output ceiling rejects and joins the actual flooding process', async () => { + const owned = startOwned( + ['-e', 'setInterval(() => process.stdout.write("x".repeat(65536)), 1)'], + options, + ); + const pid = owned.child.pid; + try { + await assert.rejects(owned.result(), /output byte ceiling/); + } finally { + await owned.close(); + } + assert.ok(gone(pid)); + assert.ok(owned.output().stdout.length <= MAX_OUTPUT_BYTES); +}); +test('explicit close joins a live child on assertion failure', async () => { + const owned = startOwned(['-e', 'setInterval(() => {}, 1000)'], options); + const pid = owned.child.pid; + await owned.close(); + assert.ok(gone(pid)); +}); +test('child environment does not inherit provider credentials or Node preload hooks', () => { + for (const key of [ + 'NODE_OPTIONS', + 'NODE_PATH', + 'OPENAI_API_KEY', + 'RELAVIUM_OPENAI_API_KEY', + 'ANTHROPIC_API_KEY', + ]) + assert.equal(Object.hasOwn(options.env, key), false); +}); +test('normal bounded child output preserves actual exit and output', async () => { + const result = await invoke( + ['-e', 'process.stdout.write("synthetic-marker"); process.exitCode = 4'], + options, + ); + assert.equal(result.code, 4); + assert.equal(result.signal, null); + assert.equal(result.stdout, 'synthetic-marker'); +}); diff --git a/tools/cli-package/smoke/workflows.mjs b/tools/cli-package/smoke/workflows.mjs new file mode 100644 index 00000000..ec001f49 --- /dev/null +++ b/tools/cli-package/smoke/workflows.mjs @@ -0,0 +1,404 @@ +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { assertExit, invoke, ndjson, poll } from './process.mjs'; + +const workflow = (id, nodes, edges, extra = {}) => + JSON.stringify({ schema_version: '1.0', workflow: { id, nodes, edges, ...extra } }); +const edge = (from, to) => ({ from, to }); +export async function assertInstalledWorkflows(context) { + const { root, Database, cli, environment, calls } = context; + const home = join(root, 'workflow-home'); + mkdirSync(home); + const invokeCli = async (args, code, input = '', extra = {}, keyringAttempts = 0) => { + const before = calls().length; + const result = await invoke(cli(args), { cwd: home, env: environment(home, extra) }, input); + assertExit(result, code); + const observed = calls().slice(before); + assert.equal( + observed.filter((call) => call.operation === 'keyring.Entry').length, + keyringAttempts, + ); + assert.equal( + observed.filter((call) => call.denied && call.operation !== 'keyring.Entry').length, + 0, + 'Offline workflow attempted a forbidden service', + ); + return result; + }; + const dbPath = join(home, '.relavium/history.db'); + const query = (sql, ...params) => { + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + try { + return db.prepare(sql).all(...params); + } finally { + db.close(); + } + }; + const snapshot = () => { + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + try { + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all(); + return Object.fromEntries( + tables.map(({ name }) => [ + name, + db.prepare(`SELECT * FROM "${name.replaceAll('"', '""')}"`).all(), + ]), + ); + } finally { + db.close(); + } + }; + const sequential = join(home, 'sequential.relavium.yaml'); + writeFileSync( + sequential, + workflow( + 'packaged-sequential', + [ + { id: 'start', type: 'input' }, + { id: 'double', type: 'transform', transform: '({ doubled: inputs.n * 2 })' }, + { id: 'out', type: 'output' }, + ], + [edge('start', 'double'), edge('double', 'out')], + { inputs: [{ name: 'n', type: 'number' }] }, + ), + ); + const sequentialResult = await invokeCli(['run', sequential, '--input', 'n=21', '--json'], 0); + const sequentialEvents = ndjson(sequentialResult.stdout); + const completed = sequentialEvents.find((event) => event.type === 'run:completed'); + assert.ok(completed, 'Real workflow terminal missing'); + assert.ok( + JSON.stringify(completed).includes('42'), + 'Compiled expression sandbox must return actual doubled input', + ); + assert.equal( + query('SELECT count(*) AS n FROM __drizzle_migrations')[0].n > 0, + true, + 'Installed drizzle migrations must execute', + ); + + const human = join(home, 'human.relavium.yaml'); + writeFileSync( + human, + workflow( + 'packaged-human', + [ + { id: 'start', type: 'input' }, + { id: 'gate', type: 'human_gate', gate_type: 'approval' }, + { id: 'out', type: 'output' }, + ], + [edge('start', 'gate'), edge('gate', 'out')], + ), + ); + const pause = await invokeCli(['run', human, '--json'], 3); + const paused = ndjson(pause.stdout).find((event) => event.type === 'human_gate:paused'); + assert.ok(paused?.runId && paused.gateId); + const list = await invokeCli(['gate', 'list', paused.runId, '--json'], 0); + assert.ok(list.stdout.includes(paused.gateId)); + const status = await invokeCli(['status', '--json'], 0); + assert.ok(status.stdout.includes(paused.gateId)); + const resume = await invokeCli( + ['gate', paused.runId, '--gate', paused.gateId, '--approve', '--json'], + 0, + ); + const resumeEvents = ndjson(resume.stdout); + assert.ok(resumeEvents.some((event) => event.type === 'human_gate:resumed')); + assert.ok( + resumeEvents.some((event) => event.type === 'run:completed' && event.runId === paused.runId), + ); + const history = await invokeCli(['logs', paused.runId, '--json'], 0); + assert.ok(ndjson(history.stdout).some((event) => event.type === 'run:completed')); + + const budget = join(home, 'budget.relavium.yaml'); + writeFileSync( + budget, + workflow( + 'packaged-budget', + [ + { + id: 'agent', + type: 'agent', + agent_ref: 'worker', + prompt_template: 'synthetic budget prompt', + max_tokens: 64, + }, + ], + [], + { + budget: { max_cost_microcents: 1, on_exceed: 'pause_for_approval', strict_cost_cap: true }, + agents: [ + { + id: 'worker', + model: 'gpt-4o-mini', + provider: 'openai', + system_prompt: 'Synthetic budget control.', + tools: [], + }, + ], + }, + ), + ); + const budgetPause = await invokeCli( + ['run', budget, '--json'], + 3, + '', + { RELAVIUM_OPENAI_API_KEY: 'sk-relavium-synthetic-package-smoke' }, + 1, + ); + const authorization = ndjson(budgetPause.stdout).find( + (event) => event.type === 'budget:authorization' && event.authorization?.state === 'paused', + ); + assert.ok(authorization?.gateId && authorization.runId); + const allowance = authorization.authorization.allowance; + assert.equal(allowance.kind, 'frozen'); + assert.equal(allowance.quote.kind, 'quoted'); + assert.equal(allowance.quote.quote.amount.kind, 'representable'); + const amount = allowance.quote.quote.amount.microcents; + assert.ok(Number.isSafeInteger(amount) && amount > 1); + const budgetStatus = JSON.parse((await invokeCli(['status', '--json'], 0)).stdout); + assert.ok(budgetStatus.pendingBudgetGates.some((gate) => gate.gateId === authorization.gateId)); + const before = snapshot(); + for (const invalid of ['-1', '1.5', '9007199254740992', String(amount + 1)]) { + const refusal = await invokeCli( + [ + 'budget', + 'resume', + authorization.runId, + '--gate', + authorization.gateId, + '--approve-amount', + invalid, + '--json', + ], + 2, + ); + assert.equal(refusal.stdout, ''); + assert.deepEqual(snapshot(), before, 'Invalid amount changed durable rows'); + } + const abortArgs = [ + 'budget', + 'resume', + authorization.runId, + '--gate', + authorization.gateId, + '--abort', + '--json', + ]; + const aborted = await invokeCli(abortArgs, 1); + assert.ok( + ndjson(aborted.stdout).some( + (event) => event.type === 'run:failed' && event.error.code === 'budget_exceeded', + ), + ); + const afterAbort = snapshot(); + const repeat = await invokeCli(abortArgs, 0); + assert.equal(repeat.stdout, ''); + assert.ok(repeat.stderr.includes('nothing to resume')); + assert.deepEqual(snapshot(), afterAbort); + + // Exercise the shipped, documented agent-run cassette seam and the real tool policy. + // No product implementation is substituted; a noninteractive governed write must be refused. + const agent = join(home, 'effects.agent.yaml'), + cassette = join(home, 'effects.json'); + writeFileSync( + agent, + 'id: packaged-effects\nprovider: openai\nmodel: gpt-4o-mini\nsystem_prompt: Synthetic denied effect.\ntools: [write_file]\nmax_tokens: 64\n', + ); + writeFileSync( + cassette, + JSON.stringify({ + schema_version: '2.0', + provider: 'openai', + model: 'gpt-4o-mini', + calls: [ + [ + { type: 'tool_call_start', id: 'synthetic-write', name: 'write_file' }, + { + type: 'tool_call_delta', + id: 'synthetic-write', + argsJsonDelta: JSON.stringify({ + path: 'must-not-exist.txt', + content: 'synthetic denied write', + }), + }, + { type: 'tool_call_end', id: 'synthetic-write' }, + { + type: 'stop', + stopReason: 'tool_use', + usage: { kind: 'complete', usage: { inputTokens: 1, outputTokens: 1 } }, + }, + ], + ], + }), + ); + const effectRows = query('SELECT * FROM run_effects'); + const deniedWrite = await invokeCli( + ['agent', 'run', agent, '--fixture', cassette, '--json'], + 1, + 'synthetic effect prompt\n', + ); + assert.ok( + ndjson(deniedWrite.stdout).some( + (event) => event.type === 'session:turn_completed' && event.error?.code === 'tool_denied', + ), + ); + assert.equal(existsSync(join(home, 'must-not-exist.txt')), false); + assert.deepEqual( + query('SELECT * FROM run_effects'), + effectRows, + 'Denied effect must not dispatch or create effect journal rows', + ); + return { + workflow: { + terminal: completed, + eventTypes: sequentialEvents.map((event) => event.type), + migrations: query('SELECT count(*) AS count FROM __drizzle_migrations')[0].count, + }, + gate: { + runId: paused.runId, + gateId: paused.gateId, + discovered: true, + resumedInFreshProcess: true, + eventTypes: resumeEvents.map((event) => event.type), + }, + budget: { + runId: authorization.runId, + gateId: authorization.gateId, + amount, + syntheticPreflightKeyringDenied: 1, + providerDispatches: 0, + invalidDecisionsUnchanged: 4, + aborted: true, + repeatUnchanged: true, + }, + effects: { realGovernedWriteRefused: true, targetAbsent: true, journalUnchanged: true }, + }; +} + +export async function assertInstalledMcp(context, expectedVersion) { + const { root, cli, environment, calls } = context; + const home = join(root, 'mcp-home'); + mkdirSync(home); + const fixture = fileURLToPath(new URL('./mcp-server.mjs', import.meta.url)); + const mcpLog = join(root, 'mcp.ndjson'); + const path = join(home, 'mcp.relavium.yaml'); + writeFileSync( + path, + workflow( + 'packaged-mcp', + [ + { id: 'gate', type: 'human_gate', gate_type: 'approval' }, + { id: 'out', type: 'output' }, + ], + [edge('gate', 'out')], + { + agents: [ + { + id: 'worker', + model: 'gpt-4o-mini', + provider: 'openai', + system_prompt: 'not reached', + tools: [], + mcp_servers: [ + { + id: 'fixture', + transport: 'stdio', + command: process.execPath, + args: [fixture], + env: { RELAVIUM_PACKAGE_SMOKE_MCP_LOG: mcpLog }, + }, + ], + }, + ], + }, + ), + ); + const before = calls().length; + const env = environment(home, { RELAVIUM_PACKAGE_SMOKE_MCP: fixture }); + let observations = []; + let nativeJoined = false; + try { + const refusal = await invoke(cli(['run', path, '--json']), { cwd: home, env }); + assertExit(refusal, 2); + assert.equal(existsSync(mcpLog), false, 'Consent refusal must precede actual native spawn'); + const digest = /\bv1:[0-9a-f]{64}\b/.exec(refusal.stderr)?.[0]; + assert.ok( + digest, + `Actual consent refusal must publish its declaration digest: ${refusal.stderr}`, + ); + const result = await invoke(cli(['run', path, '--json', '--allow-mcp-stdio', digest]), { + cwd: home, + env, + }); + assertExit(result, 3); + observations = ndjson(readFileSync(mcpLog, 'utf8')); + const initializes = observations.filter((value) => value.operation === 'initialize'); + assert.equal(initializes.length, 1); + assert.deepEqual(initializes[0].params.clientInfo, { + name: 'relavium', + version: expectedVersion, + }); + assert.equal(observations.filter((value) => value.operation === 'tools/list').length, 1); + assert.ok( + observations.some((value) => value.operation === 'closed'), + 'Actual MCP stdin owner must close', + ); + const pid = observations.find((value) => value.operation === 'started')?.pid; + assert.ok(Number.isSafeInteger(pid)); + await poll( + () => { + try { + process.kill(pid, 0); + return false; + } catch (error) { + if (error.code === 'ESRCH') return true; + throw error; + } + }, + 5000, + 'Actual MCP fixture process survived its CLI owner', + ); + nativeJoined = true; + const observed = calls().slice(before); + assert.equal(observed.filter((call) => call.operation === 'mcp-spawn').length, 1); + assert.equal(observed.filter((call) => call.denied).length, 0); + return { + version: expectedVersion, + actualInitialize: initializes[0].params, + nativeChildJoined: true, + dispatches: 1, + }; + } finally { + // A failing assertion still owns the exact fixture PIDs it created. No process-table search. + if (existsSync(mcpLog)) observations = ndjson(readFileSync(mcpLog, 'utf8')); + if (!nativeJoined) + for (const value of observations.filter((value) => value.operation === 'started')) { + await reapFixture(value.pid); + } + } +} + +async function reapFixture(pid) { + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (error.code === 'ESRCH') return; + throw error; + } + await poll( + () => { + try { + process.kill(pid, 0); + return false; + } catch (error) { + if (error.code === 'ESRCH') return true; + throw error; + } + }, + 5000, + 'Owned MCP fixture did not join after cleanup', + ); +} diff --git a/tools/fs-policy-controls/fixtures/media-native-packaging.mjs b/tools/fs-policy-controls/fixtures/media-native-packaging.mjs index e2f06162..50919d1b 100644 --- a/tools/fs-policy-controls/fixtures/media-native-packaging.mjs +++ b/tools/fs-policy-controls/fixtures/media-native-packaging.mjs @@ -17,9 +17,15 @@ import { syncBuiltinESMExports } from 'node:module'; import os from 'node:os'; import { dirname, isAbsolute, join, relative, sep } from 'node:path'; import { clearTimeout, setImmediate, setTimeout } from 'node:timers'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; -const entry = new URL('../../../apps/cli/dist/index.js', import.meta.url); +// Tool-only artifact selection; no fixture flag or exported hook enters the product. +assert.ok(process.argv.length === 2 || process.argv.length === 3, 'one optional CLI entry'); +if (process.argv[2] !== undefined) assert.ok(isAbsolute(process.argv[2]), 'absolute CLI entry'); +const entry = + process.argv[2] === undefined + ? new URL('../../../apps/cli/dist/index.js', import.meta.url) + : pathToFileURL(process.argv[2]); await access(entry).catch(() => { throw new Error( 'Build apps/cli before running the actual CLI native-lifecycle packaging witness', From 7b2b88ff8f47a40995f9c672484b3dc36a3a8155 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 09:39:34 +0300 Subject: [PATCH 071/222] docs(docs): record the installed package implementation boundary Document exact tarball/install controls and their public-only evidence without claiming platform execution, independent acceptance or the later map/custody gates. Record author setup failures and their verified fixes. Refs: ADR-0111, Wave 3 Step 7b Co-Authored-By: Codex --- docs/reference/cli/packaging.md | 28 ++++++++ .../phase-2.5.5-hardening-and-remediation.md | 65 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index 87535dab..5709370d 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -92,6 +92,34 @@ minimum Node, other platforms, original-TS symbolication or release custody. ## Build, map and candidate identity +### Installed-package implementation boundary — Step 7b + +The tooling producer now emits dependencies without a build cache, invokes the +sole prepack CLI build, and independently compares actual gzip/USTAR entries with +the finalized JS and canonical migrations. Its exact public allowlist and native +tar-header checks apply on every consumer platform. The consumer installs those +verified bytes outside the workspace with pinned pnpm and the existing lifecycle +allowlist, verifies installed public files, and records actual dependency versions. +Dependency installation uses the public registry; provider transport and keychain +API controls use synthetic data and refusals. + +Required workflows share one public tarball and a `compiled_package_test` receipt +with current `.nvmrc` Linux/macOS/Windows and exact Node 22.0.0 Linux. Each repeats +untouched-entry bootstrap controls and actual workflow, gate, budget, chat/resume, +SQLite, native-load, MCP-version and media-cleanup behavior. Their aggregate checks +all four actual success records and immutable artifact/source/origin identities. +Only public consumer summaries are uploaded; detailed support logs are excluded. +The [tooling guide](../../../tools/cli-package/README.md) describes invocation and +bounded retry evidence. These runners are implemented; required platform execution +and independent acceptance are recorded separately in the Wave 3 register. + +This intermediate receipt does not claim the full map-bearing package-test mode +below. Original-TypeScript symbolication, private staging and release custody +remain Step 9; native fatal-owner integration remains Step 8. No intermediate +local result closes those obligations or certifies a release. + +### Candidate finalization — Step 9 + 1. Build inlined dependencies fresh in an isolated candidate job with external TypeScript maps and no inline source contents. Ordinary workspace builds stay map-free. Disable build-cache read **and write**, including Turbo local/remote, diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 97c7a1fb..bab4161f 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -5085,3 +5085,68 @@ Actual registered native-owner cleanup remains Step 8, maps/custody Step 9 and new CLI discovery Step 10. This metadata landing does not imply new source execution, full CI/coverage, a release or whole-wave acceptance. No new ADR, dependency, deferral, billing action or user decision is introduced. + +## Wave 3 Step 7b implementation checkpoint — 2026-10-16 + +Step 7a remains independently accepted at source `6ffdd63e`, with metadata +`bc79793d`. Step 7b now implements independent archive inspection, exact installed +package controls and required platform aggregation under ADR-0111. This checkpoint +is author evidence, **not** independent acceptance or remote platform execution. +Whole Step 7 remains open. Two NEW complete cumulative clean reviews from the +Step 6 boundary, a package/CI security sitting, and actual required platform +success remain necessary before Step 8. + +The [package tooling](../../../tools/cli-package/README.md) owns one fresh ordered +dependency emit and the sole CLI prepack build. It observes one actual prepack +build-start record and independently inspects bounded gzip/USTAR bytes without +extraction. Every JS and migration byte matches finalized/canonical files; +publication metadata matches pnpm's catalog/workspace transform. Negative controls +cover malformed/truncated/oversized archives, checksums, paths, types, modes, +hashbangs, map links, missing/extra/duplicate files and digest drift. + +The consumer installs a verified copy outside the workspace with pnpm 9.12.3 and +the existing lifecycle allowlist. It compares all **69** installed public files, +records all **17** actual runtime dependency versions/metadata digests, repeats +**175** untouched-entry bootstrap controls and six fixed installed behaviour cases. +Actual chat uses the installed OpenAI SDK, bundled adapter/AgentSession and native +SQLite: two dispatches, four ordered durable transcript rows, same-session resume, +complete cost/usage and exit 4 after each persisted assistant. Its two synthetic +keyring constructor attempts are refused before native API access. The budget +preflight has one separately asserted refusal; no provider dispatch occurs there. +Actual native addons load in a separate unmodified process without a keychain API +call. Workflow/gate discovery and fresh-process resume, invalid budget decisions, +abort/idempotence, governed effect denial, MCP initialize version and media/CAS +cleanup/native iterator join execute through the installed package. + +The new required pack → four consumers → aggregate jobs share an immutable +tarball identity. Current `.nvmrc` (24) Linux/macOS/Windows and exact Node 22.0.0 +Linux must actually pass. Only the bounded public consumer summary is uploaded; +support logs, transport bodies and host paths are excluded. Source/digest/origin, +exact case inventory, dependency/file identities and runtime/platform checks +refuse incomplete or mismatched evidence. Job success is also required through +`ci.needs` and explicit checks under `always()`. Same-artifact consumer retries +retain prior successful legs and replace only their latest smoke receipts, never +the tarball. Release smoke uses the same consumer; Step 9 still owns original-TS +maps, encrypted custody and release discovery/archive/retry integration. + +Local author execution on **Node 22.23.1 / macOS arm64** passes archive inspection, +outside-workspace install, all 175 bootstrap controls and all six installed cases. +The finalized-pipeline candidate is `a1a4d5f8…`; the separate local full-CI wrapper +candidate is `620aa6e5…`, and both have complete local consumer results. Earlier +`386c106b…` controls are attributed to their own artifact. These are distinct +local candidates, not one shared remote artifact or production candidates. Local +receipts retain their precommit source/dirty flag and cannot certify a CI leg. +The first setup omitted the consumer's package-manager pin and Corepack selected +pnpm 12; that failed attempt is excluded. The corrected consumer pins 9.12.3. +The first integrated metadata reader's 128-KiB limit rejected Drizzle's actual +larger package manifest; the bounded dependency-only limit is corrected to 1 MiB. +Neither author setup failure is presented as a product regression or deferred work. + +Author gates pass **473 acceptance-tool tests** with no skips, tooling lint and +typecheck, formatting, seam/dependency guards and default compiled/media smokes. +The unchanged workspace suite retains **646 files / 14,659 passes / 20 established +skips**, with eleven valid cached tasks on this tooling-only increment; this is +not claimed as a fresh execution. Fresh ordered dependency emits and prepack build +execute in the package controls. Document/diff, isolation and DB-sync results are +recorded before review. No new runtime dependency, ADR, deferral, credential, +billing action, provider request or production upload is introduced. From 42704af3e575d73e35e1a22ed05097857c1e9f34 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 09:58:59 +0300 Subject: [PATCH 072/222] fix(repo): resolve the pnpm action entry on posix Recognize the reviewed JavaScript sibling behind the action's regular POSIX shell shim without invoking or parsing a shell. Add its actual layout and missing-target regression; preserve pinned tool verification. Refs: ADR-0111, Wave 3 Step 7b review R1 Co-Authored-By: Codex --- tools/cli-package/process.mjs | 2 +- tools/cli-package/process.test.mjs | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/tools/cli-package/process.mjs b/tools/cli-package/process.mjs index d4179075..85f59f40 100644 --- a/tools/cli-package/process.mjs +++ b/tools/cli-package/process.mjs @@ -38,7 +38,7 @@ export function resolvePnpm(path = process.env.PATH ?? '', platform = process.pl '../pnpm/bin/pnpm.cjs', '../lib/node_modules/pnpm/bin/pnpm.cjs', ] - : ['pnpm', 'pnpm.cjs']; + : ['pnpm', 'pnpm.cjs', '../pnpm/bin/pnpm.cjs', '../lib/node_modules/pnpm/bin/pnpm.cjs']; for (const name of candidates) { const candidate = join(directory, name); if (!existsSync(candidate) || !statSync(candidate).isFile()) continue; diff --git a/tools/cli-package/process.test.mjs b/tools/cli-package/process.test.mjs index e06366de..bd08babd 100644 --- a/tools/cli-package/process.test.mjs +++ b/tools/cli-package/process.test.mjs @@ -46,6 +46,25 @@ test('pnpm JS shim and Windows action layout resolve without executing a shell', } }); +test('the actual POSIX action shell-wrapper layout resolves only its reviewed Node sibling', () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-pnpm-action-layout-'))); + try { + const bin = join(root, 'node_modules', '.bin'); + const entry = join(root, 'node_modules', 'pnpm', 'bin', 'pnpm.cjs'); + mkdirSync(bin, { recursive: true }); + mkdirSync(join(root, 'node_modules', 'pnpm', 'bin'), { recursive: true }); + writeFileSync(join(bin, 'pnpm'), '#!/bin/sh\nexit 99\n'); + // The shell wrapper is neither parsed nor executed, and cannot alone confer an executable route. + assert.throws(() => resolvePnpm(bin, 'linux'), /PINNED_PNPM_NOT_FOUND/); + writeFileSync(entry, 'throw new Error("synthetic entry must not execute during resolution")'); + assert.deepEqual(resolvePnpm(bin, 'linux'), { command: process.execPath, prefix: [entry] }); + rmSync(entry); + assert.throws(() => resolvePnpm(bin, 'darwin'), /PINNED_PNPM_NOT_FOUND/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('installer preserves nonzero exit evidence and joins its actual child', async () => { await assert.rejects( runCommand( From 0f49e306c651c6cd71f44352e4b609d67adbd7a6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 09:58:59 +0300 Subject: [PATCH 073/222] test(cli): own compiled leaf checks after dependency builds Move shared/LLM compiled-export identity, exact redaction corpus and service-free evaluation checks to the CLI dependency consumer. Keep source unit checks in each package and remove the undeclared own-dist prerequisite from fresh lint/typecheck. No source aliases or task-graph changes; fresh author tests retain every compiled expectation. Refs: ADR-0111, Wave 3 Step 7b review R1 Co-Authored-By: Codex --- .../process/supported-leaf-exports.test.ts | 99 +++++++++++++++++++ packages/llm/src/scrub-secrets.test.ts | 43 -------- .../shared/src/terminal-safe-leaf.test.ts | 5 +- 3 files changed, 100 insertions(+), 47 deletions(-) create mode 100644 apps/cli/src/process/supported-leaf-exports.test.ts diff --git a/apps/cli/src/process/supported-leaf-exports.test.ts b/apps/cli/src/process/supported-leaf-exports.test.ts new file mode 100644 index 00000000..cc426edb --- /dev/null +++ b/apps/cli/src/process/supported-leaf-exports.test.ts @@ -0,0 +1,99 @@ +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +// CLI depends on these packages: its existing ^build boundary owns their actual compiled exports. +// These controls must not impose a self-dist prerequisite on either package's typecheck. +// Synthetic credentials assembled from fragments, never real provider material. +const key = ['sk-', 'ant-', 'api03-', 'AbCdEf0123456789xyz'].join(''); +const googleKey = ['AI', 'za', 'SyA1234567890abcdefghijklmnopqrstuv'].join(''); + +const compatibleDiagnostics = [ + { input: `invalid ${key}; retry later`, expected: 'invalid [REDACTED]; retry later' }, + { input: `invalid ${googleKey}; retry later`, expected: 'invalid [REDACTED]; retry later' }, + { + input: 'https://user:opaque@provider.invalid/x', + expected: 'https://[REDACTED]@provider.invalid/x', + }, + { input: 'https://opaque@provider.invalid/x', expected: 'https://[REDACTED]@provider.invalid/x' }, + { + input: 'Authorization: Bearer opaque-value rejected', + expected: 'Authorization: Bearer [REDACTED] rejected', + }, + { + input: 'Authorization: Basic dXNlcjpwYXNz rejected', + expected: 'Authorization: Basic [REDACTED] rejected', + }, + { + input: '?client_secret=opaque&ordinary=kept#visible', + expected: '?client_secret=[REDACTED]&ordinary=kept#visible', + }, + { input: key.slice(0, 12) + '\u0001' + key.slice(12), expected: '[REDACTED]' }, + { input: key.slice(0, 12) + '\n\t' + key.slice(12), expected: '[REDACTED]' }, + { input: `?api_secret=sk-ant-api03-\nAbCdEf0123456789xyz`, expected: '[REDACTED]' }, + { input: `?token=opaque; https://u\nser:opaque@provider.invalid/x`, expected: '[REDACTED]' }, + { input: `?token=opaque; Bear\u202eer opaque-value`, expected: '[REDACTED]' }, + // Literal query names, word boundaries and masked spans retain their finite scope. + { input: '?api%5fkey=opaque', expected: '?api%5fkey=opaque' }, + { input: '?client_secret_hint=ordinary', expected: '?client_secret_hint=ordinary' }, + { input: '?api_\nkey=opaque', expected: '?api_\nkey=opaque' }, + { input: 'sk-short', expected: 'sk-short' }, + { + input: 'ordinary prompt prose with an opaque value', + expected: 'ordinary prompt prose with an opaque value', + }, + { + input: '\u001b[31mrate limited\u001b[0m\nretry after 30s', + expected: '\u001b[31mrate limited\u001b[0m\nretry after 30s', + }, + { input: '', expected: '' }, +]; + +describe('supported compiled dependency leaves', () => { + it('retains the shared root and terminal-safe function identity in actual compiled exports', async () => { + const root = await import('@relavium/shared'); + const leaf = await import('@relavium/shared/terminal-safe'); + expect(root.stripTerminalControls).toBe(leaf.stripTerminalControls); + }); + + it('retains one function through the supported compiled root and leaf exports', async () => { + const root = await import('@relavium/llm'); + const leaf = await import('@relavium/llm/scrub-secrets'); + expect(root.scrubSecrets).toBe(leaf.scrubSecrets); + for (const { input, expected } of compatibleDiagnostics) { + expect(leaf.scrubSecrets(input)).toBe(expected); + } + }); + + it('loads the actual supported compiled leaf with only terminal-safe reachable', () => { + const require = createRequire(import.meta.url); + const allowed = [ + pathToFileURL(require.resolve('@relavium/llm/scrub-secrets')).href, + pathToFileURL(require.resolve('@relavium/shared/terminal-safe')).href, + ]; + // An isolated ESM loader refuses every other module, including schema/provider barrels. + // This tests evaluation without relying on bundler tree shaking or warm module caches. + const loader = `export async function load(url, context, nextLoad) { + if (!url.startsWith('data:') && !${JSON.stringify(allowed)}.includes(url)) { + throw new Error('Unexpected sanitizer dependency: ' + url); + } + return nextLoad(url, context); + }`; + const run = spawnSync( + process.execPath, + [ + '--no-warnings', + `--experimental-loader=data:text/javascript,${encodeURIComponent(loader)}`, + '--input-type=module', + '--eval', + `import { scrubSecrets } from ${JSON.stringify(allowed[0])}; + if (scrubSecrets('?api_key=opaque') !== '?api_key=[REDACTED]') process.exit(1);`, + ], + { encoding: 'utf8', timeout: 10_000 }, + ); + expect(run.error).toBeUndefined(); + expect(run.stderr).toBe(''); + expect(run.status).toBe(0); + }); +}); diff --git a/packages/llm/src/scrub-secrets.test.ts b/packages/llm/src/scrub-secrets.test.ts index bb468936..190c42e1 100644 --- a/packages/llm/src/scrub-secrets.test.ts +++ b/packages/llm/src/scrub-secrets.test.ts @@ -1,6 +1,3 @@ -import { spawnSync } from 'node:child_process'; -import { createRequire } from 'node:module'; -import { pathToFileURL } from 'node:url'; import { describe, expect, it } from 'vitest'; import { scrubSecrets as rootScrubSecrets } from './index.js'; @@ -64,44 +61,4 @@ describe('pure scrub-secrets leaf compatibility', () => { expect(error.message).toBe(expected); expect(error.code).toBe(expected); }); - - it('retains one function through the supported compiled root and leaf exports', async () => { - const root = await import('@relavium/llm'); - const leaf = await import('@relavium/llm/scrub-secrets'); - expect(root.scrubSecrets).toBe(leaf.scrubSecrets); - for (const { input, expected } of compatibleDiagnostics) { - expect(leaf.scrubSecrets(input)).toBe(expected); - } - }); - - it('loads the actual supported compiled leaf with only terminal-safe reachable', () => { - const require = createRequire(import.meta.url); - const allowed = [ - pathToFileURL(require.resolve('@relavium/llm/scrub-secrets')).href, - pathToFileURL(require.resolve('@relavium/shared/terminal-safe')).href, - ]; - // An isolated ESM loader refuses every other module, including schema/provider barrels. - // This tests evaluation without relying on bundler tree shaking or warm module caches. - const loader = `export async function load(url, context, nextLoad) { - if (!url.startsWith('data:') && !${JSON.stringify(allowed)}.includes(url)) { - throw new Error('Unexpected sanitizer dependency: ' + url); - } - return nextLoad(url, context); - }`; - const run = spawnSync( - process.execPath, - [ - '--no-warnings', - `--experimental-loader=data:text/javascript,${encodeURIComponent(loader)}`, - '--input-type=module', - '--eval', - `import { scrubSecrets } from ${JSON.stringify(allowed[0])}; - if (scrubSecrets('?api_key=opaque') !== '?api_key=[REDACTED]') process.exit(1);`, - ], - { encoding: 'utf8', timeout: 10_000 }, - ); - expect(run.error).toBeUndefined(); - expect(run.stderr).toBe(''); - expect(run.status).toBe(0); - }); }); diff --git a/packages/shared/src/terminal-safe-leaf.test.ts b/packages/shared/src/terminal-safe-leaf.test.ts index 5bdddc49..7a34b02f 100644 --- a/packages/shared/src/terminal-safe-leaf.test.ts +++ b/packages/shared/src/terminal-safe-leaf.test.ts @@ -4,11 +4,8 @@ import { stripTerminalControls as rootStripTerminalControls } from './index.js'; import { stripTerminalControls } from './terminal-safe.js'; describe('supported terminal-safe leaf', () => { - it('retains the root function identity in source and actual compiled exports', async () => { + it('retains the root function identity in source', () => { expect(rootStripTerminalControls).toBe(stripTerminalControls); - const root = await import('@relavium/shared'); - const leaf = await import('@relavium/shared/terminal-safe'); - expect(root.stripTerminalControls).toBe(leaf.stripTerminalControls); }); it('removes finite escape/control families while preserving text, tabs, newlines and shaping', () => { From cd78816ae73d28ac58999ce93b181fadfc81136a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 09:58:59 +0300 Subject: [PATCH 074/222] docs(docs): record failed package gates and source corrections Attribute the non-clean cumulative review and actual CI failures, their causal fixes and fresh author checks. Keep two new complete review rounds and actual four-platform execution open; distinguish production uploads from required ephemeral public CI artifact handoffs. Refs: ADR-0111, Wave 3 Step 7b review R1 Co-Authored-By: Codex --- docs/roadmap/current.md | 2 +- .../phase-2.5.5-hardening-and-remediation.md | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 5b989eaa..7acd217b 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1130,7 +1130,7 @@ remains open until they pass. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. -No provider capture, credential, billing or upload is needed for this implementation. +No provider capture, credential, billing or production upload is needed for this implementation. Decompose, guard and normalize every hot file a 2.6 workstream is about to land on. **The multi-file queue is Step 1 and runs first.** The approved diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index bab4161f..dcf3d26a 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -5150,3 +5150,40 @@ not claimed as a fresh execution. Fresh ordered dependency emits and prepack bui execute in the package controls. Document/diff, isolation and DB-sync results are recorded before review. No new runtime dependency, ADR, deferral, credential, billing action, provider request or production upload is introduced. + +### Step 7b first cumulative review and fresh-CI corrections — 2026-10-16 + +The first NEW complete cumulative review at `7b2b88ff` is **non-clean**: all +97 paths / 183 hunks / 58 full new files were covered, and two actual fresh-CI +failures were independently reproduced. Its 151 tooling tests, nine runtime files / +255 passes and 175 compiled controls are prepared-workspace evidence, not a +substitute for those failures. The separate security sitting found no verified +security defect but explicitly retained the failed platform gate. Both full local +reports are read by the parent and remain Git-ignored. + +1. The POSIX pnpm action installs a regular shell wrapper with its JavaScript + entry in the reviewed sibling package location. The resolver previously + supported that location only on Windows. The correction uses the same explicit + sibling-JS locations on POSIX, without parsing/executing shell text or weakening + the pinned-version check. A native layout regression proves positive resolution + and refusal when that sibling is absent; removing the correction breaks it. +2. The shared/LLM compiled root/leaf controls imported their own `dist` before + lint/typecheck's existing `^build` graph guaranteed it. Local pre-existing outputs + and cached gates concealed that prerequisite. Source identity/redaction checks + remain in their owning packages; actual compiled identity, complete redaction + corpus and service-free leaf-evaluation controls move to the CLI, whose declared + dependencies and existing build graph already guarantee those outputs. No + source alias, Turbo change, skip, dependency or weakened assertion is introduced. + The read-only TypeScript host probe that hides only each owner's `dist` changes + from four unresolved self imports to zero; fresh checkout execution is a + separate required verification. + +Corrected author gates pass **647 files / 14,660 tests / 20 established skips**: +all 17 lint/typecheck/build-prerequisite tasks are fresh, and all six workspace +test tasks execute freshly (the five prerequisite build tasks are valid cache +hits). **474 acceptance-tool tests** pass without skips. These results do not +yet imply new remote consumers, a clean post-fix review, or whole Step 7 acceptance. +The old failed push `38862829767` and its skipped consumers are not counted as +platform evidence. The corrections require two NEW complete cumulative clean +rounds after the final material fix, the affected security delta and real required +same-artifact platform success. No new decision or deferral is introduced. From 897ff9b7f3cda8a2ffb593dae7c78bf714532c81 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 10:21:24 +0300 Subject: [PATCH 075/222] fix(repo): preserve bootstrap preload file urls The required Windows consumer receives an ESM URL instead of an unsupported native drive specifier. Relocate the actual tooling under URL metacharacters so a path conversion also fails the permanent native control on POSIX. Refs: ADR-0111, Wave 3 Step 7b Co-Authored-By: Codex --- tools/cli-bootstrap/child.mjs | 4 ++-- tools/cli-bootstrap/witness.test.mjs | 26 +++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/tools/cli-bootstrap/child.mjs b/tools/cli-bootstrap/child.mjs index 5f8874d0..86dbb97e 100644 --- a/tools/cli-bootstrap/child.mjs +++ b/tools/cli-bootstrap/child.mjs @@ -11,7 +11,7 @@ import { } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, relative } from 'node:path'; -import { pathToFileURL, fileURLToPath } from 'node:url'; +import { pathToFileURL } from 'node:url'; import { createHash } from 'node:crypto'; import { spawnBounded } from './process.mjs'; import { maxWitnessBytes } from './policy.mjs'; @@ -78,7 +78,7 @@ export async function withFixture(run) { if (process.env[key] !== undefined) env[key] = process.env[key]; if (debug) env.RELAVIUM_DEBUG = '1'; const result = await spawnBounded( - ['--import', fileURLToPath(new URL('./preload.mjs', import.meta.url)), ...args], + ['--import', new URL('./preload.mjs', import.meta.url).href, ...args], { cwd: workspace, env }, bounds, ); diff --git a/tools/cli-bootstrap/witness.test.mjs b/tools/cli-bootstrap/witness.test.mjs index 956d4617..5cfada02 100644 --- a/tools/cli-bootstrap/witness.test.mjs +++ b/tools/cli-bootstrap/witness.test.mjs @@ -1,7 +1,9 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { writeFileSync } from 'node:fs'; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { withFixture, toEntryUrl } from './child.mjs'; import { assertWitness, importFailureCanary, servicePackages } from './policy.mjs'; import { spawnBounded } from './process.mjs'; @@ -23,6 +25,28 @@ test('native loader observes untouched ESM entry and tolerates Node loader reads assert.equal(result.stdout, 'function\n'); }); }); +test('production child retains file-URL semantics for a preload path with URL metacharacters', async () => { + const root = mkdtempSync(join(tmpdir(), 'relavium-bootstrap-url-')); + try { + const directory = join(root, 'loader # with %'); + mkdirSync(directory); + // Relocate the actual implementation unchanged, so converting its preload URL + // to a native path breaks this control on POSIX as well as Windows. + for (const name of ['child.mjs', 'process.mjs', 'policy.mjs', 'preload.mjs', 'loader.mjs']) + copyFileSync(new URL(name, import.meta.url), join(directory, name)); + // eslint-disable-next-line no-restricted-syntax -- Exact copied tooling module in this test-owned directory; URL metacharacters are the regression. + const relocated = await import(pathToFileURL(join(directory, 'child.mjs')).href); + await relocated.withFixture(async (fixture) => { + const entry = fixtureModule(fixture, "console.log('loaded with URL semantics');"); + const result = await fixture.child([entry]); + assert.equal(result.code, 0, result.stderr); + assertWitness(result.events, toEntryUrl(entry)); + assert.equal(result.stdout, 'loaded with URL semantics\n'); + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); test('each denied ESM package/subpath is witnessed even when the application swallows it', async () => { await withFixture(async (fixture) => { const specifiers = servicePackages.flatMap((name) => [name, `${name}/synthetic-subpath`]); From bb9a5828eb12bea95cef65bcc1014f87e0b3707a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 10:21:24 +0300 Subject: [PATCH 076/222] fix(repo): admit exact owned leaves in replay workers Recognize supported pure exports in the nested producer and predecessor loader while retaining per-mode manifest and physical ownership checks. Actual workers prove current identities, historical availability and refusal of absent frozen or unknown subpaths, with module-body witnesses. Refs: ADR-0100, ADR-0111, Wave 3 Step 7b Co-Authored-By: Codex --- tools/budget-replay-compat/README.md | 10 ++++++++ .../budget-replay-compat/evaluation-audit.mjs | 14 ++++++++++- tools/budget-replay-compat/loader.mjs | 5 ++++ tools/budget-replay-compat/predecessor.mjs | 16 +++++++++++++ tools/budget-replay-compat/producer.mjs | 23 +++++++++++++++++-- 5 files changed, 65 insertions(+), 3 deletions(-) diff --git a/tools/budget-replay-compat/README.md b/tools/budget-replay-compat/README.md index 446bd5b6..364ab4c3 100644 --- a/tools/budget-replay-compat/README.md +++ b/tools/budget-replay-compat/README.md @@ -232,3 +232,13 @@ preparation evidence. The bounded driver requires the actual worker and targeted under each worker PID, rejects loader-thread or opposite-root witnesses, joins current copied bytes to the outer variant manifest, and rechecks source/dependency bytes after worker closure. Frozen archive/supplement identities and the portable/native provenance limits above remain intact. + +The loader admits the exact supported shared/LLM/core pure leaves as well as the +existing root/adapters entries. Every alias still requires membership in its own +producer or frozen manifest; recognizing a name never borrows the other mode's +bytes. The actual producer worker checks all five leaves and root/leaf constructor +or function identities. The predecessor checks its historical terminal-safe leaf, +refuses the four later absent leaves, and both workers refuse an unknown subpath. +Required module-body witnesses include the loaded leaves. These controls add no +replay prefix or positive-control count and do not replace the actual old-engine +refusal cohort. diff --git a/tools/budget-replay-compat/evaluation-audit.mjs b/tools/budget-replay-compat/evaluation-audit.mjs index c118ee79..b211d00d 100644 --- a/tools/budget-replay-compat/evaluation-audit.mjs +++ b/tools/budget-replay-compat/evaluation-audit.mjs @@ -73,7 +73,19 @@ export function auditRequiredEvaluations(owned, processes) { ? ['packages/db/src/index.ts', 'packages/db/src/session-usage-write.ts'] : ['packages/core/src/engine/engine.ts']; if (child.stage === 'predecessor') - required.push('packages/db/src/run-history-store.ts', 'apps/cli/src/engine/checkpointer.ts'); + required.push( + 'packages/db/src/run-history-store.ts', + 'apps/cli/src/engine/checkpointer.ts', + 'packages/shared/src/terminal-safe.ts', + ); + if (child.stage === 'producer') + required.push( + 'packages/shared/src/relavium-error.ts', + 'packages/shared/src/fs-root-limits.ts', + 'packages/shared/src/terminal-safe.ts', + 'packages/llm/src/scrub-secrets.ts', + 'packages/core/src/tools/dispatch-error.ts', + ); for (const target of required) assert.ok( records.some( diff --git a/tools/budget-replay-compat/loader.mjs b/tools/budget-replay-compat/loader.mjs index d446e88b..c7cb466d 100644 --- a/tools/budget-replay-compat/loader.mjs +++ b/tools/budget-replay-compat/loader.mjs @@ -44,9 +44,14 @@ const trace = resolvePath(root, 'logs', `${process.env.COMPAT_LABEL}.resolution. const aliases = Object.fromEntries( [ ['@relavium/shared', 'packages/shared/src/index.ts'], + ['@relavium/shared/relavium-error', 'packages/shared/src/relavium-error.ts'], + ['@relavium/shared/fs-root-limits', 'packages/shared/src/fs-root-limits.ts'], + ['@relavium/shared/terminal-safe', 'packages/shared/src/terminal-safe.ts'], ['@relavium/llm', 'packages/llm/src/index.ts'], ['@relavium/llm/adapters', 'packages/llm/src/adapters/index.ts'], + ['@relavium/llm/scrub-secrets', 'packages/llm/src/scrub-secrets.ts'], ['@relavium/core', 'packages/core/src/index.ts'], + ['@relavium/core/tool-dispatch-error', 'packages/core/src/tools/dispatch-error.ts'], ['@relavium/db', 'packages/db/src/index.ts'], ['@relavium/mcp', 'packages/mcp/src/index.ts'], ].map(([name, path]) => [name, resolvePath(sourceRoot, path)]), diff --git a/tools/budget-replay-compat/predecessor.mjs b/tools/budget-replay-compat/predecessor.mjs index 214aa3bd..37ef2df9 100644 --- a/tools/budget-replay-compat/predecessor.mjs +++ b/tools/budget-replay-compat/predecessor.mjs @@ -13,6 +13,22 @@ import { UnreadableRunEventLogError, } from './frozen/packages/db/src/run-history-store.ts'; import { createHistoryCheckpointer } from './frozen/apps/cli/src/engine/checkpointer.ts'; +// This historical source exists in the frozen manifest. Later leaves must never +// fall through to the producer copy even though the loader recognizes their names. +const terminalSafe = await import('@relavium/shared/terminal-safe'); +assert.equal(terminalSafe.stripTerminalControls('\u001b[31mvisible\u001b[0m'), 'visible'); +for (const [specifier, load] of [ + ['@relavium/shared/relavium-error', () => import('@relavium/shared/relavium-error')], + ['@relavium/shared/fs-root-limits', () => import('@relavium/shared/fs-root-limits')], + ['@relavium/llm/scrub-secrets', () => import('@relavium/llm/scrub-secrets')], + ['@relavium/core/tool-dispatch-error', () => import('@relavium/core/tool-dispatch-error')], +]) + await assert.rejects(load(), { + message: `Source alias absent from frozen manifest: ${specifier}`, + }); +await assert.rejects(import('@relavium/shared/not-exported'), { + message: 'Unowned Relavium subpath forbidden: @relavium/shared/not-exported', +}); const uuid = (n) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`; const errorJson = (e) => ({ name: e.name, diff --git a/tools/budget-replay-compat/producer.mjs b/tools/budget-replay-compat/producer.mjs index 92bb774e..d9300ee9 100644 --- a/tools/budget-replay-compat/producer.mjs +++ b/tools/budget-replay-compat/producer.mjs @@ -3,9 +3,28 @@ import { produceUsagePrefixes } from './usage-producer.mjs'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { fixture, workflow, root, record } from './producer-common.mjs'; -import { RunEventSchema } from '@relavium/shared'; -import { reconstructCheckpointState } from '@relavium/core'; +import { RunEventSchema, RelaviumError as RootRelaviumError } from '@relavium/shared'; +import { RelaviumError } from '@relavium/shared/relavium-error'; +import { FS_ROOT_LIMITS } from '@relavium/shared/fs-root-limits'; +import { stripTerminalControls } from '@relavium/shared/terminal-safe'; +import { scrubSecrets as rootScrubSecrets } from '@relavium/llm'; +import { scrubSecrets } from '@relavium/llm/scrub-secrets'; +import { + reconstructCheckpointState, + ToolDispatchError as RootToolDispatchError, +} from '@relavium/core'; +import { ToolDispatchError } from '@relavium/core/tool-dispatch-error'; import { createClient, runMigrations, createRunHistoryStore } from '@relavium/db'; +// Exercise every supported leaf through the actual captured loader, not workspace dist. +assert.equal(RelaviumError, RootRelaviumError); +assert.equal(ToolDispatchError, RootToolDispatchError); +assert.equal(scrubSecrets, rootScrubSecrets); +assert.deepEqual(FS_ROOT_LIMITS, { count: 16, characters: 4096 }); +assert.equal(stripTerminalControls('\u001b[31mvisible\u001b[0m'), 'visible'); +assert.equal(scrubSecrets('?api_key=synthetic'), '?api_key=[REDACTED]'); +await assert.rejects(import('@relavium/shared/not-exported'), { + message: 'Unowned Relavium subpath forbidden: @relavium/shared/not-exported', +}); let fetches = 0; globalThis.fetch = () => { fetches += 1; From eb21b86c75d25ab15230413e822196f2fbb12667 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 10:21:24 +0300 Subject: [PATCH 077/222] docs(docs): record native package gate corrections Keep both failed cumulative rounds and actual CI failures visible. Record the independently fresh workspace pass, precise bootstrap and nested replay fixes, and remaining clean-review and platform requirements. Refs: ADR-0111, Wave 3 Step 7b Co-Authored-By: Codex --- .../phase-2.5.5-hardening-and-remediation.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index dcf3d26a..043caec2 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -5187,3 +5187,54 @@ The old failed push `38862829767` and its skipped consumers are not counted as platform evidence. The corrections require two NEW complete cumulative clean rounds after the final material fix, the affected security delta and real required same-artifact platform success. No new decision or deferral is introduced. + +### Step 7b second cumulative review and native CI corrections — 2026-10-16 + +The second NEW complete cumulative review at `cd78816a` covers **98 paths / +185 hunks / 59 full new files** and is **non-clean**. It independently verifies +both prior fixes but reproduces the required Windows consumer's preload failure. +Its 152 tooling tests, eight runtime files / 106 passes and 175 local compiled +controls do not substitute for Windows acceptance. The separate correction +security sitting is clean in its stated affected scope; neither report accepts +whole Step 7. The parent reads both complete reports. + +Actual push `38864509846` produces a clean-source tarball and successful current +Linux, exact Node 22.0.0 Linux and current macOS consumers. Windows and the required +aggregate fail. The separate required usage gate also fails; a diagnostic agent +verifies the retained worker error against the uploaded exact-source manifests. +Neither failure is attributed to transient scheduling or reported as success. + +- Windows `--import` must receive an ESM file URL, rather than a drive-qualified + native path. The bootstrap child now retains the existing URL. Its permanent + native regression relocates the unchanged tooling implementation beneath URL + metacharacters; converting back to a path makes that exact test fail on POSIX + too. An isolated copied-source fixed/removal/restored control exits 0/1/0 with + the expected module-resolution assertion; production source is not mutated. +- The nested replay loader lacked the newly supported pure subpaths although the + outer usage loader already knew them. The actual current producer failed before + its semantic cases on `@relavium/shared/terminal-safe`. Five exact leaf mappings + are added, preserving manifest membership, physical-root and opposite-mode + refusal. Actual current/frozen workers exercise all five current leaves, the + historical terminal-safe leaf, four absent-frozen refusals and unknown-subpath + refusal. Module-body audits pin the loaded leaves; no frozen archive, dependency + byte, replay prefix, positive control or semantic refusal is replaced. + +An independently cloned `development` tree at `cd78816a`, with no prior dist/cache +and distinct sibling HOME/TMP/cwd roots, passes all 17 fresh lint/typecheck/build +tasks and all six fresh workspace test tasks: **647 files / 14,659 passes / +21 established CI-mode skips**. Five prerequisite builds are valid subsequent +cache hits. The earlier clone run had a test-harness HOME/TMP ancestry collision +and is retained as failed verification, not accepted. The ordinary prepared +workspace result remains separately **14,660 passes / 20 skips**; the additional +CI-mode projection skip is existing conditional behavior, not a new source skip. + +The two native corrections pass **475 acceptance-tool tests**, tools typecheck, +lint and formatting, the 175 compiled bootstrap controls and the actual full +replay smoke (**16 refusals / two legacy controls / zero fetches**). The selected +owner-basis causal control has fixed/removal/restored inventories 2/2/2, with the +expected one semantic removal failure; it is not the complete usage matrix. +The first lint attempt rejects computed fixture imports; the correction retains +literal leaf imports and explicitly justifies only the owned URL-path fixture. +Two NEW complete cumulative clean rounds after these final material fixes, +affected security review and real required same-artifact platform success remain +necessary. No new decision, dependency or deferral is introduced. From d0fe14f7d28d7fb59ff2da1b1af6cf7f4b0de9cd Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 10:48:07 +0300 Subject: [PATCH 078/222] fix(repo): join installed native smoke before removing its tree Run the complete installed cohort in a bounded owned process and bind its private report to the joined PID. Remove the installation before publishing success, retaining primary failures when secondary cleanup or diagnostics fail. Native addon, early report, PID and removal controls exercise this order. Refs: ADR-0111, Wave 3 Step 7b Co-Authored-By: Codex --- package.json | 2 +- tools/cli-package/README.md | 7 + tools/cli-package/consume.mjs | 239 ++++++++-------- tools/cli-package/consumer-lifetime.mjs | 24 ++ tools/cli-package/consumer-lifetime.test.mjs | 170 +++++++++++ tools/cli-package/process.mjs | 1 + tools/cli-package/smoke/README.md | 8 + tools/cli-package/smoke/index.mjs | 280 ++----------------- tools/cli-package/smoke/worker.mjs | 272 ++++++++++++++++++ 9 files changed, 634 insertions(+), 369 deletions(-) create mode 100644 tools/cli-package/consumer-lifetime.mjs create mode 100644 tools/cli-package/consumer-lifetime.test.mjs create mode 100644 tools/cli-package/smoke/worker.mjs diff --git a/package.json b/package.json index 31768dc6..82a5ff63 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "smoke:budget-replay": "node tools/budget-replay-compat/check.mjs", "check:wire-mutations": "node tools/wire-conformance/check.mjs", "check:usage-mutations": "node tools/usage-coverage-controls/check.mjs", - "test:acceptance-tools": "node --test tools/ci/windows-headless-smoke.test.mjs tools/ci/workspace-scheduling.test.mjs tools/ci/require-native-db-diagnostics.test.mjs tools/usage-coverage-controls/outcome.test.mjs tools/usage-coverage-controls/workload.test.mjs tools/ci/require-w8-acceptance.test.mjs tools/acceptance-evidence/export.test.mjs tools/wire-conformance/provenance.test.mjs tools/causal-controls/outcome.test.mjs tools/causal-controls/process.test.mjs tools/causal-controls/provenance.test.mjs tools/causal-controls/retention.test.mjs tools/causal-controls/run.test.mjs tools/causal-controls/timeout-policy.test.mjs tools/bundle-closure/closure.test.mjs tools/cli-bootstrap/witness.test.mjs tools/ci/require-packed-cli.test.mjs tools/cli-package/archive.test.mjs tools/cli-package/binding.test.mjs tools/cli-package/process.test.mjs tools/cli-package/files.test.mjs tools/cli-package/smoke/process.test.mjs tools/cli-package/pipeline.test.mjs", + "test:acceptance-tools": "node --test tools/ci/windows-headless-smoke.test.mjs tools/ci/workspace-scheduling.test.mjs tools/ci/require-native-db-diagnostics.test.mjs tools/usage-coverage-controls/outcome.test.mjs tools/usage-coverage-controls/workload.test.mjs tools/ci/require-w8-acceptance.test.mjs tools/acceptance-evidence/export.test.mjs tools/wire-conformance/provenance.test.mjs tools/causal-controls/outcome.test.mjs tools/causal-controls/process.test.mjs tools/causal-controls/provenance.test.mjs tools/causal-controls/retention.test.mjs tools/causal-controls/run.test.mjs tools/causal-controls/timeout-policy.test.mjs tools/bundle-closure/closure.test.mjs tools/cli-bootstrap/witness.test.mjs tools/ci/require-packed-cli.test.mjs tools/cli-package/archive.test.mjs tools/cli-package/binding.test.mjs tools/cli-package/process.test.mjs tools/cli-package/files.test.mjs tools/cli-package/smoke/process.test.mjs tools/cli-package/pipeline.test.mjs tools/cli-package/consumer-lifetime.test.mjs", "check:fs-mutations": "node tools/fs-policy-controls/check.mjs", "check:secrets-mutations": "node tools/secrets-controls/check.mjs", "smoke:cli-bootstrap": "node tools/cli-bootstrap/check.mjs", diff --git a/tools/cli-package/README.md b/tools/cli-package/README.md index 205a5d27..69dad452 100644 --- a/tools/cli-package/README.md +++ b/tools/cli-package/README.md @@ -41,6 +41,13 @@ support directory. CI uploads **only** the bounded public `installed-consumer.js source/artifact/origin/runtime identities, public file/dependency digests, and exact successful case inventory. It does not upload transport bodies, host paths or logs. +All installed-smoke native loads and SQLite reads run in one bounded owned worker. +The cleanup owner observes actual process closure and binds the private report to +that worker PID before removing the installation. Closing a SQLite connection is +not unloading its native DLL on Windows. Success receipt/summary publication occurs +only after required installation removal; a worker or cleanup failure prevents +publication, and a secondary cleanup/diagnostic failure preserves the primary. + Required PR/push consumers use current `.nvmrc` Linux/macOS/Windows and exact Node 22.0.0 Linux. The aggregate reinspects the tarball and requires all four unique matching receipts plus successful pack/matrix jobs. Missing, failed, skipped, diff --git a/tools/cli-package/consume.mjs b/tools/cli-package/consume.mjs index 67799fe2..94e2878b 100644 --- a/tools/cli-package/consume.mjs +++ b/tools/cli-package/consume.mjs @@ -9,6 +9,7 @@ import { realpathSync, rmSync, writeFileSync, + writeSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; @@ -23,6 +24,7 @@ import { import { installedInventory, oneTarball, readOrdinaryFile, sha256 } from './files.mjs'; import { isolatedEnvironment, runCommand, runPnpm } from './process.mjs'; import { runInstalledSmoke } from './smoke/index.mjs'; +import { completeConsumer } from './consumer-lifetime.mjs'; assert.equal(process.argv.length, 4, 'artifact and report directories required'); const repository = fileURLToPath(new URL('../../', import.meta.url)); @@ -60,125 +62,136 @@ const inside = (parent, child) => { return part === '' || (!isAbsolute(part) && part !== '..' && !part.startsWith(`..${sep}`)); }; assert.equal(inside(realpathSync(repository), home), false, 'PACKED_CLI_INSTALL_IN_WORKSPACE'); -try { - const env = isolatedEnvironment(home); - writeFileSync( - join(home, 'package.json'), - JSON.stringify({ - name: 'relavium-package-consumer', - private: true, - version: '0.0.0', - packageManager: 'pnpm@9.12.3', - pnpm: { onlyBuiltDependencies: ['better-sqlite3', 'esbuild'] }, - }) + '\n', - ); - const version = await runPnpm(['--version'], { cwd: home, env }); - assert.equal(version.stdout.trim(), '9.12.3', 'PACKED_CLI_PNPM_VERSION'); - // Installation uses these already-vetted bytes, not a path the publisher could later replace. - const ownedTarball = join(home, 'artifact.tgz'); - writeFileSync(ownedTarball, bytes, { flag: 'wx' }); - const installation = await runPnpm( - ['add', '--prod', '--ignore-workspace', '--config.engine-strict=true', `file:${ownedTarball}`], - { cwd: home, env }, - ); - writeFileSync(join(reportDirectory, 'install.log'), installation.stdout + installation.stderr); - const packageRoot = realpathSync(join(home, 'node_modules/relavium')); - assert.ok(inside(home, packageRoot), 'PACKED_CLI_INSTALL_ESCAPED'); - const before = installedInventory(packageRoot); - const wanted = receipt.files - .map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 })) - .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); - const sorted = (files) => - files.toSorted((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); - assert.deepEqual(sorted(before), wanted, 'PACKED_CLI_INSTALLED_BYTES_DRIFT'); - if (process.platform !== 'win32') - for (const file of receipt.files) { - assert.equal( - lstatSync(join(packageRoot, file.path.slice('package/'.length))).mode & 0o7777, - file.mode, - 'PACKED_CLI_INSTALLED_MODE_DRIFT', - ); - } - const require = createRequire(join(packageRoot, 'package.json')); - const dependencies = []; - for (const name of Object.keys(receipt.package.dependencies).sort()) { - const entry = realpathSync( - require.resolve( - name === '@modelcontextprotocol/sdk' ? '@modelcontextprotocol/sdk/client/index.js' : name, - ), +await completeConsumer({ + async exercise() { + const env = isolatedEnvironment(home); + writeFileSync( + join(home, 'package.json'), + JSON.stringify({ + name: 'relavium-package-consumer', + private: true, + version: '0.0.0', + packageManager: 'pnpm@9.12.3', + pnpm: { onlyBuiltDependencies: ['better-sqlite3', 'esbuild'] }, + }) + '\n', + ); + const version = await runPnpm(['--version'], { cwd: home, env }); + assert.equal(version.stdout.trim(), '9.12.3', 'PACKED_CLI_PNPM_VERSION'); + // Installation uses these already-vetted bytes, not a path the publisher could later replace. + const ownedTarball = join(home, 'artifact.tgz'); + writeFileSync(ownedTarball, bytes, { flag: 'wx' }); + const installation = await runPnpm( + [ + 'add', + '--prod', + '--ignore-workspace', + '--config.engine-strict=true', + `file:${ownedTarball}`, + ], + { cwd: home, env }, ); - assert.ok(inside(home, entry), 'PACKED_CLI_DEPENDENCY_OUTSIDE_INSTALL'); - let directory = dirname(entry), - found = false; - while (inside(home, directory)) { - let content; - try { - content = readOrdinaryFile(join(directory, 'package.json'), 1024 * 1024); - } catch (error) { - if (error.code !== 'ENOENT') throw error; + writeFileSync(join(reportDirectory, 'install.log'), installation.stdout + installation.stderr); + const packageRoot = realpathSync(join(home, 'node_modules/relavium')); + assert.ok(inside(home, packageRoot), 'PACKED_CLI_INSTALL_ESCAPED'); + const before = installedInventory(packageRoot); + const wanted = receipt.files + .map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 })) + .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + const sorted = (files) => + files.toSorted((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + assert.deepEqual(sorted(before), wanted, 'PACKED_CLI_INSTALLED_BYTES_DRIFT'); + if (process.platform !== 'win32') + for (const file of receipt.files) { + assert.equal( + lstatSync(join(packageRoot, file.path.slice('package/'.length))).mode & 0o7777, + file.mode, + 'PACKED_CLI_INSTALLED_MODE_DRIFT', + ); } - if (content !== undefined) { - const metadata = JSON.parse(content); - if (metadata.name === name) { - dependencies.push({ name, version: metadata.version, manifestSha256: sha256(content) }); - found = true; - break; + const require = createRequire(join(packageRoot, 'package.json')); + const dependencies = []; + for (const name of Object.keys(receipt.package.dependencies).sort()) { + const entry = realpathSync( + require.resolve( + name === '@modelcontextprotocol/sdk' ? '@modelcontextprotocol/sdk/client/index.js' : name, + ), + ); + assert.ok(inside(home, entry), 'PACKED_CLI_DEPENDENCY_OUTSIDE_INSTALL'); + let directory = dirname(entry), + found = false; + while (inside(home, directory)) { + let content; + try { + content = readOrdinaryFile(join(directory, 'package.json'), 1024 * 1024); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + if (content !== undefined) { + const metadata = JSON.parse(content); + if (metadata.name === name) { + dependencies.push({ name, version: metadata.version, manifestSha256: sha256(content) }); + found = true; + break; + } } + directory = dirname(directory); } - directory = dirname(directory); + assert.ok(found, 'PACKED_CLI_DEPENDENCY_MANIFEST_MISSING'); } - assert.ok(found, 'PACKED_CLI_DEPENDENCY_MANIFEST_MISSING'); - } - const bootstrap = await runBootstrap(packageRoot); - writeFileSync( - join(reportDirectory, 'bootstrap-details.json'), - JSON.stringify(bootstrap, null, 2), - ); - const smoke = await runInstalledSmoke({ - packageRoot, - supportRoot: reportDirectory, - artifactSha256: receipt.tarballSha256, - source, - }); - assert.equal(smoke.success, true, 'PACKED_CLI_INSTALLED_SMOKE_FAILED'); - assert.deepEqual(installedInventory(packageRoot), before, 'PACKED_CLI_SMOKE_CHANGED_ARTIFACT'); - assert.equal(sha256(readOrdinaryFile(tarball, 16 * 1024 * 1024)), receipt.tarballSha256); - const leg = process.env.RELAVIUM_PACKAGE_LEG ?? 'local'; - const record = { - version: 1, - kind: 'installed-cli-consumer', - source, - tarballSha256: receipt.tarballSha256, - packageVersion: receipt.packageVersion, - leg, - origin, - node: process.version, - platform: process.platform, - arch: process.arch, - success: true, - bootstrap: { controls: bootstrap.controls, entrySha256: bootstrap.entrySha256 }, - installedFiles: before, - dependencies, - smoke: { success: true, cases: smoke.cases.map(({ id, passed }) => ({ id, passed })) }, - }; - // Local execution is evidence for its actual host, never one of the required four remote legs. - validateConsumerRecord(record, receipt, { ...expected, allowLocal: true }); - writeFileSync( - join(reportDirectory, 'installed-consumer.json'), - JSON.stringify(record, null, 2) + '\n', - { flag: 'wx' }, - ); - console.log( - JSON.stringify({ + const bootstrap = await runBootstrap(packageRoot); + writeFileSync( + join(reportDirectory, 'bootstrap-details.json'), + JSON.stringify(bootstrap, null, 2), + ); + const smoke = await runInstalledSmoke({ + packageRoot, + supportRoot: reportDirectory, + artifactSha256: receipt.tarballSha256, + source, + }); + assert.equal(smoke.success, true, 'PACKED_CLI_INSTALLED_SMOKE_FAILED'); + assert.deepEqual(installedInventory(packageRoot), before, 'PACKED_CLI_SMOKE_CHANGED_ARTIFACT'); + assert.equal(sha256(readOrdinaryFile(tarball, 16 * 1024 * 1024)), receipt.tarballSha256); + const leg = process.env.RELAVIUM_PACKAGE_LEG ?? 'local'; + const record = { + version: 1, + kind: 'installed-cli-consumer', source, - sha256: receipt.tarballSha256, + tarballSha256: receipt.tarballSha256, + packageVersion: receipt.packageVersion, leg, + origin, node: process.version, platform: process.platform, - controls: bootstrap.controls, - smokeCases: smoke.cases.length, - }), - ); -} finally { - rmSync(home, { recursive: true, force: true }); -} + arch: process.arch, + success: true, + bootstrap: { controls: bootstrap.controls, entrySha256: bootstrap.entrySha256 }, + installedFiles: before, + dependencies, + smoke: { success: true, cases: smoke.cases.map(({ id, passed }) => ({ id, passed })) }, + }; + // Local execution is evidence for its actual host, never one of the required four remote legs. + validateConsumerRecord(record, receipt, { ...expected, allowLocal: true }); + return record; + }, + cleanup: () => rmSync(home, { recursive: true, force: true }), + cleanupFailed: () => writeSync(2, 'PACKED_CLI_SECONDARY_INSTALLATION_CLEANUP_FAILED\n'), + publish(record) { + writeFileSync( + join(reportDirectory, 'installed-consumer.json'), + JSON.stringify(record, null, 2) + '\n', + { flag: 'wx' }, + ); + console.log( + JSON.stringify({ + source, + sha256: receipt.tarballSha256, + leg: record.leg, + node: process.version, + platform: process.platform, + controls: record.bootstrap.controls, + smokeCases: record.smoke.cases.length, + }), + ); + }, +}); diff --git a/tools/cli-package/consumer-lifetime.mjs b/tools/cli-package/consumer-lifetime.mjs new file mode 100644 index 00000000..1ba77b7b --- /dev/null +++ b/tools/cli-package/consumer-lifetime.mjs @@ -0,0 +1,24 @@ +/** A success publication includes actual worker settlement and owned installation removal. */ +export async function completeConsumer({ exercise, cleanup, cleanupFailed, publish }) { + let result; + let primary; + let primaryFailure = false; + try { + result = await exercise(); + } catch (error) { + primary = error; + primaryFailure = true; + } + try { + await cleanup(); + } catch (error) { + if (!primaryFailure) throw error; + try { + cleanupFailed(); + } catch { + // A failed secondary diagnostic cannot replace the original exercise failure. + } + } + if (primaryFailure) throw primary; + return publish(result); +} diff --git a/tools/cli-package/consumer-lifetime.test.mjs b/tools/cli-package/consumer-lifetime.test.mjs new file mode 100644 index 00000000..23d4bfff --- /dev/null +++ b/tools/cli-package/consumer-lifetime.test.mjs @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { pathToFileURL } from 'node:url'; +import { completeConsumer } from './consumer-lifetime.mjs'; + +async function fixture(t, { wrongPid = false, exitCode = 0 } = {}) { + const root = mkdtempSync(join(tmpdir(), 'relavium-consumer-lifetime-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const smoke = join(root, 'smoke'), + installation = join(root, 'installation'), + support = join(root, 'support'); + for (const directory of [smoke, installation, support]) mkdirSync(directory); + for (const name of ['process.mjs', 'files.mjs']) + copyFileSync(new URL(name, import.meta.url), join(root, name)); + copyFileSync(new URL('smoke/index.mjs', import.meta.url), join(smoke, 'index.mjs')); + const require = createRequire(new URL('../../packages/db/package.json', import.meta.url)); + const binary = join( + dirname(require.resolve('better-sqlite3/package.json')), + 'build/Release/better_sqlite3.node', + ); + copyFileSync(binary, join(installation, 'native.node')); + // The real launcher/process/file boundaries are unchanged. Only the owned worker + // body is synthetic: it loads a real copied addon and remains live after reporting. + writeFileSync( + join(smoke, 'worker.mjs'), + ` +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +const [, , packageRoot, supportRoot, artifactSha256, source] = process.argv; +const addon = createRequire(import.meta.url)(join(packageRoot, 'native.node')); +assert.equal(typeof addon.Database, 'function'); +writeFileSync(join(supportRoot, 'installed-smoke.json'), JSON.stringify({ + source, artifactSha256, pid: ${wrongPid ? '0' : 'process.pid'}, parentPid: process.ppid, + runtime: process.version, platform: process.platform, success: true, nativeLoaded: true +})); +process.on('exit', () => writeFileSync(join(supportRoot, 'worker-exit'), 'observed')); +setTimeout(() => { process.exitCode = ${exitCode}; }, 50); +`, + ); + // eslint-disable-next-line no-restricted-syntax -- Exact copied launcher in this test-owned directory; no product/source alias. + const { runInstalledSmoke } = await import(pathToFileURL(join(smoke, 'index.mjs')).href); + return { + root, + installation, + support, + run: () => + runInstalledSmoke({ + packageRoot: installation, + supportRoot: support, + artifactSha256: 'a'.repeat(64), + source: 'b'.repeat(40), + }), + }; +} + +test('real addon worker closes before installation removal and success publication without loading the cleanup owner', async (t) => { + const own = await fixture(t); + const original = process.dlopen; + let parentLoads = 0; + process.dlopen = () => { + parentLoads += 1; + throw new Error('cleanup owner must not load native addons'); + }; + try { + await completeConsumer({ + exercise: own.run, + cleanup: () => { + assert.equal(readFileSync(join(own.support, 'worker-exit'), 'utf8'), 'observed'); + // On Windows an in-process or still-running addon owner keeps this copy locked. + rmSync(own.installation, { recursive: true, force: true }); + }, + cleanupFailed: () => assert.fail('unexpected secondary cleanup failure'), + publish: (report) => { + assert.equal(existsSync(own.installation), false); + assert.equal(report.nativeLoaded, true); + assert.notEqual(report.pid, process.pid); + writeFileSync(join(own.support, 'success'), 'published after removal'); + }, + }); + assert.equal(parentLoads, 0); + assert.equal(readFileSync(join(own.support, 'success'), 'utf8'), 'published after removal'); + } finally { + process.dlopen = original; + } +}); + +for (const [name, options, expected] of [ + ['foreign PID', { wrongPid: true }, /not from the joined worker/], + ['nonzero worker after an early success file', { exitCode: 12 }, /CHILD_FAILED/], +]) + test(`${name} refuses publication and still removes the owned installation`, async (t) => { + const own = await fixture(t, options); + let published = false; + await assert.rejects( + completeConsumer({ + exercise: own.run, + cleanup: () => rmSync(own.installation, { recursive: true, force: true }), + cleanupFailed: () => assert.fail('unexpected secondary cleanup failure'), + publish: () => { + published = true; + }, + }), + expected, + ); + assert.equal(published, false); + assert.equal(existsSync(own.installation), false); + assert.equal(existsSync(join(own.support, 'worker-exit')), true); + }); + +test('failed required cleanup prevents publication of a successful exercise', async () => { + const cleanupFailure = new Error('synthetic owned cleanup refusal'); + let published = false; + await assert.rejects( + completeConsumer({ + exercise: async () => ({ success: true }), + cleanup: () => { + throw cleanupFailure; + }, + cleanupFailed: () => assert.fail('cleanup is primary here'), + publish: () => { + published = true; + }, + }), + (error) => error === cleanupFailure, + ); + assert.equal(published, false); +}); + +for (const primary of [new Error('synthetic worker refusal'), undefined, null]) + test(`exercise failure retains exact primary through cleanup and failed secondary diagnostic: ${String(primary)}`, async () => { + let cleaned = false, + published = false, + diagnosed = false; + await assert.rejects( + completeConsumer({ + exercise: async () => { + throw primary; + }, + cleanup: () => { + cleaned = true; + throw new Error('synthetic secondary cleanup refusal'); + }, + cleanupFailed: () => { + diagnosed = true; + throw new Error('synthetic failed diagnostic sink'); + }, + publish: () => { + published = true; + }, + }), + (error) => error === primary, + ); + assert.equal(cleaned, true); + assert.equal(diagnosed, true); + assert.equal(published, false); + }); diff --git a/tools/cli-package/process.mjs b/tools/cli-package/process.mjs index 85f59f40..a6bd8112 100644 --- a/tools/cli-package/process.mjs +++ b/tools/cli-package/process.mjs @@ -123,6 +123,7 @@ export function runCommand(command, args, options = {}) { await new Promise((done) => killer.once('close', done)); clearTimeout(killerTimer); const result = { + pid: child.pid, code, signal, stdout: Buffer.concat(output.stdout).toString('utf8'), diff --git a/tools/cli-package/smoke/README.md b/tools/cli-package/smoke/README.md index 366f7639..b203f731 100644 --- a/tools/cli-package/smoke/README.md +++ b/tools/cli-package/smoke/README.md @@ -8,6 +8,14 @@ product files. `supportRoot/installed-smoke.json` contains bounded structured results; the runner removes its owned temporary HOME/workspaces after joining children. Every compiled JS digest must remain unchanged. +The launcher runs the complete cohort in [worker.mjs](worker.mjs), a bounded +owned process, and reads its bounded report only after native process closure. +Actual worker PID/parent identity and source/artifact/runtime identities must match. +SQLite inspection and native addon loads therefore cannot lock the installation +in its cleanup owner. The surrounding consumer removes that installation before +publishing success. Worker failure, forged PID or cleanup refusal remains failure; +no successful receipt is inferred from an early private report. + [cases.mjs](cases.mjs) is the fixed required case inventory, shared with the aggregate. Each result has `id`, `passed`, and evidence or an error. Overall `success` requires the complete inventory and unchanged compiled bytes. diff --git a/tools/cli-package/smoke/index.mjs b/tools/cli-package/smoke/index.mjs index 81ea0a5f..16ce07a3 100644 --- a/tools/cli-package/smoke/index.mjs +++ b/tools/cli-package/smoke/index.mjs @@ -1,261 +1,31 @@ -import { Buffer } from 'node:buffer'; import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import { - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - realpathSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { createRequire } from 'node:module'; -import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { REQUIRED_SMOKE_CASES } from './cases.mjs'; -import { assertInstalledChat } from './chat.mjs'; -import { assertInstalledMcp, assertInstalledWorkflows } from './workflows.mjs'; -import { assertExit, explicitEnvironment, invoke, ndjson } from './process.mjs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readOrdinaryFile } from '../files.mjs'; +import { isolatedEnvironment, runCommand } from '../process.mjs'; -const directory = dirname(fileURLToPath(import.meta.url)); -const repository = resolve(directory, '../../..'); -const within = (parent, child) => { - const part = relative(parent, child); - return part === '' || (part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part)); -}; -const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); -const snapshot = (packageRoot) => - Object.fromEntries( - readdirSync(join(packageRoot, 'dist')) - .filter((name) => name.endsWith('.js')) - .sort() - .map((name) => [name, digest(readFileSync(join(packageRoot, 'dist', name)))]), - ); - -/** Shared by required exact-tarball consumers and release consumers. Does not pack/install/build. */ +/** Join native module lifetime before the caller removes the owned installation. */ export async function runInstalledSmoke({ packageRoot, supportRoot, artifactSha256, source }) { - assert.ok( - isAbsolute(packageRoot) && isAbsolute(supportRoot), - 'Explicit absolute installed/support roots required', - ); - assert.match(artifactSha256, /^[a-f0-9]{64}$/, 'Exact artifact SHA-256 required'); - assert.notEqual(source, undefined, 'Explicit source identity required'); - packageRoot = realpathSync(packageRoot); - assert.equal( - within(repository, packageRoot), - false, - 'Installed package must be outside the workspace', - ); - mkdirSync(supportRoot, { recursive: true, mode: 0o700 }); - supportRoot = realpathSync(supportRoot); - assert.equal( - within(packageRoot, supportRoot), - false, - 'Test support must remain outside the shipped package', + const result = await runCommand( + process.execPath, + [ + fileURLToPath(new URL('./worker.mjs', import.meta.url)), + packageRoot, + supportRoot, + artifactSha256, + source, + ], + { cwd: supportRoot, env: isolatedEnvironment(supportRoot), timeoutMs: 300_000 }, ); - const require = createRequire(join(packageRoot, 'package.json')); - const entry = join(packageRoot, 'dist/index.js'); - const before = snapshot(packageRoot); - let installedModules; - for (let parent = dirname(packageRoot); parent !== dirname(parent); parent = dirname(parent)) - if (parent.endsWith(`${sep}node_modules`)) installedModules = parent; - assert.ok(installedModules, 'Installed package must have an explicit node_modules owner'); - const root = mkdtempSync(join(supportRoot, 'installed-smoke-')); - const log = join(root, 'guard.ndjson'); - const report = { - schemaVersion: 1, - artifactSha256, - source, - packageRoot, - runtime: process.version, - platform: process.platform, - arch: process.arch, - cases: [], - success: false, - }; - const calls = () => (existsSync(log) ? ndjson(readFileSync(log, 'utf8')) : []); - const environment = (home, extra = {}) => - explicitEnvironment(home, root, { RELAVIUM_PACKAGE_SMOKE_LOG: log, ...extra }); - const cli = (args) => [ - '--import', - pathToFileURL(join(directory, 'preload.mjs')).href, - entry, - ...args, - ]; - async function runCase(name, operation) { - const started = Date.now(); - try { - const evidence = await operation(); - report.cases.push({ id: name, passed: true, elapsedMs: Date.now() - started, evidence }); - } catch (error) { - report.cases.push({ - id: name, - passed: false, - elapsedMs: Date.now() - started, - error: String(error.stack ?? error), - }); - } - } - try { - const metadata = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')); - assert.equal(metadata.name, 'relavium'); - const dependencies = {}; - for (const name of [ - 'better-sqlite3', - 'openai', - '@napi-rs/keyring', - '@modelcontextprotocol/sdk', - '@google/genai', - ]) { - const resolved = realpathSync( - require.resolve( - name === '@modelcontextprotocol/sdk' ? '@modelcontextprotocol/sdk/client/index.js' : name, - ), - ); - assert.equal(within(repository, resolved), false, `Workspace dependency leakage: ${name}`); - assert.equal( - within(installedModules, resolved), - true, - `Dependency escaped installed consumer: ${name}`, - ); - let metadataPath = dirname(resolved), - dependencyMetadata; - for (;;) { - const candidate = join(metadataPath, 'package.json'); - if (existsSync(candidate)) { - const parsed = JSON.parse(readFileSync(candidate, 'utf8')); - if (parsed.name === name) { - dependencyMetadata = parsed; - break; - } - } - assert.notEqual( - metadataPath, - dirname(metadataPath), - `Dependency metadata missing: ${name}`, - ); - metadataPath = dirname(metadataPath); - } - dependencies[name] = { - entry: resolved, - packageRoot: metadataPath, - version: dependencyMetadata.version, - }; - } - report.dependencies = dependencies; - const Database = require('better-sqlite3'); - const context = { root, Database, cli, environment, calls }; - await runCase('native-addon-load-only', async () => { - const result = await invoke([join(directory, 'native-load.mjs'), packageRoot], { - cwd: root, - env: environment(root), - }); - assertExit(result, 0); - const evidence = JSON.parse(result.stdout); - for (const path of [evidence.keyring, evidence.sqlite, ...evidence.nativeModules]) { - assert.equal(within(repository, realpathSync(path)), false); - assert.equal(within(installedModules, realpathSync(path)), true); - } - return evidence; - }); - let version; - await runCase('installed-version', async () => { - const result = await invoke(cli(['--version']), { cwd: root, env: environment(root) }); - assertExit(result, 0); - version = result.stdout.trim(); - assert.equal(version, metadata.version); - assert.equal(calls().length, 0, 'Version must not load/use external services'); - return { version, outputBytes: result.bytes }; - }); - await runCase('workflow-gate-budget-effects', () => assertInstalledWorkflows(context)); - await runCase('real-sdk-chat-persistence-resume', () => assertInstalledChat(context)); - await runCase('actual-mcp-initialize-version', async () => { - assert.ok(version, 'Installed version control must pass before version comparison'); - return assertInstalledMcp(context, version); - }); - await runCase('actual-media-cas-iterator-cleanup', async () => { - const fixture = join( - repository, - 'tools/fs-policy-controls/fixtures/media-native-packaging.mjs', - ); - const result = await invoke([fixture, entry], { - cwd: root, - env: environment(root), - timeout: 30000, - }); - assertExit(result, 0); - assert.ok( - result.stdout.includes( - 'actual CLI bundled native registration/consumption joined after owned-file cleanup', - ), - ); - return { - actualInstalledEntry: entry, - nativeJoinAndOwnedCasCleanup: true, - outputBytes: result.bytes, - }; - }); - report.guardCalls = calls(); - for (const call of report.guardCalls.filter((value) => value.operation === 'external-module')) { - const resolved = realpathSync(fileURLToPath(call.url)); - assert.equal( - within(repository, resolved), - false, - 'Actual SDK/SQLite dispatch resolved through workspace', - ); - assert.equal( - within(installedModules, resolved), - true, - 'Actual SDK/SQLite dispatch escaped installed consumer', - ); - assert.equal( - within(dependencies[call.specifier].packageRoot, resolved), - true, - 'Actual dispatch used another dependency instance', - ); - } - assert.ok( - report.guardCalls.some( - (call) => call.operation === 'external-module' && call.specifier === 'openai', - ), - 'Actual installed SDK resolution witness missing', - ); - assert.ok( - report.guardCalls.some( - (call) => call.operation === 'external-module' && call.specifier === 'better-sqlite3', - ), - 'Actual installed SQLite resolution witness missing', - ); - assert.deepEqual( - report.cases.map((test) => test.id).sort(), - [...REQUIRED_SMOKE_CASES].sort(), - 'Required installed case inventory mismatch', - ); - report.success = report.cases.every((test) => test.passed); - } catch (error) { - report.error = String(error.stack ?? error); - } finally { - try { - const after = snapshot(packageRoot); - report.compiledUnchanged = JSON.stringify(before) === JSON.stringify(after); - report.compiledDigests = after; - } catch (error) { - report.compiledUnchanged = false; - report.immutabilityError = String(error); - } - if (!report.compiledUnchanged) report.success = false; - try { - rmSync(root, { recursive: true, force: true }); - } catch (error) { - report.cleanupError = String(error); - } - report.ownedTempRemoved = !existsSync(root); - if (!report.ownedTempRemoved) report.success = false; - const json = JSON.stringify(report, null, 2) + '\n'; - assert.ok(Buffer.byteLength(json) <= 2097152, 'Installed smoke evidence byte ceiling exceeded'); - writeFileSync(join(supportRoot, 'installed-smoke.json'), json, { mode: 0o600 }); - } + const report = JSON.parse(readOrdinaryFile(join(supportRoot, 'installed-smoke.json'), 2097152)); + assert.equal(result.stdout, '', 'Installed smoke worker emitted unexpected stdout'); + assert.equal(report.pid, result.pid, 'Installed smoke report is not from the joined worker'); + assert.equal(report.parentPid, process.pid, 'Installed smoke report has a different owner'); + assert.notEqual(report.pid, process.pid, 'Installation cleanup owner loaded native smoke'); + assert.equal(report.source, source); + assert.equal(report.artifactSha256, artifactSha256); + assert.equal(report.runtime, process.version); + assert.equal(report.platform, process.platform); + assert.equal(report.success, true, 'Installed smoke worker failed its cohort'); return report; } diff --git a/tools/cli-package/smoke/worker.mjs b/tools/cli-package/smoke/worker.mjs new file mode 100644 index 00000000..e7f16836 --- /dev/null +++ b/tools/cli-package/smoke/worker.mjs @@ -0,0 +1,272 @@ +import { Buffer } from 'node:buffer'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { REQUIRED_SMOKE_CASES } from './cases.mjs'; +import { assertInstalledChat } from './chat.mjs'; +import { assertInstalledMcp, assertInstalledWorkflows } from './workflows.mjs'; +import { assertExit, explicitEnvironment, invoke, ndjson } from './process.mjs'; + +const directory = dirname(fileURLToPath(import.meta.url)); +const repository = resolve(directory, '../../..'); +const within = (parent, child) => { + const part = relative(parent, child); + return part === '' || (part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part)); +}; +const digest = (bytes) => createHash('sha256').update(bytes).digest('hex'); +const snapshot = (packageRoot) => + Object.fromEntries( + readdirSync(join(packageRoot, 'dist')) + .filter((name) => name.endsWith('.js')) + .sort() + .map((name) => [name, digest(readFileSync(join(packageRoot, 'dist', name)))]), + ); + +/** Native addons remain in this worker until its actual process closes. */ +async function exerciseInstalledSmoke({ packageRoot, supportRoot, artifactSha256, source }) { + assert.ok( + isAbsolute(packageRoot) && isAbsolute(supportRoot), + 'Explicit absolute installed/support roots required', + ); + assert.match(artifactSha256, /^[a-f0-9]{64}$/, 'Exact artifact SHA-256 required'); + assert.notEqual(source, undefined, 'Explicit source identity required'); + packageRoot = realpathSync(packageRoot); + assert.equal( + within(repository, packageRoot), + false, + 'Installed package must be outside the workspace', + ); + mkdirSync(supportRoot, { recursive: true, mode: 0o700 }); + supportRoot = realpathSync(supportRoot); + assert.equal( + within(packageRoot, supportRoot), + false, + 'Test support must remain outside the shipped package', + ); + const require = createRequire(join(packageRoot, 'package.json')); + const entry = join(packageRoot, 'dist/index.js'); + const before = snapshot(packageRoot); + let installedModules; + for (let parent = dirname(packageRoot); parent !== dirname(parent); parent = dirname(parent)) + if (parent.endsWith(`${sep}node_modules`)) installedModules = parent; + assert.ok(installedModules, 'Installed package must have an explicit node_modules owner'); + const root = mkdtempSync(join(supportRoot, 'installed-smoke-')); + const log = join(root, 'guard.ndjson'); + const report = { + schemaVersion: 1, + artifactSha256, + source, + packageRoot, + runtime: process.version, + platform: process.platform, + arch: process.arch, + pid: process.pid, + parentPid: process.ppid, + cases: [], + success: false, + }; + const calls = () => (existsSync(log) ? ndjson(readFileSync(log, 'utf8')) : []); + const environment = (home, extra = {}) => + explicitEnvironment(home, root, { RELAVIUM_PACKAGE_SMOKE_LOG: log, ...extra }); + const cli = (args) => [ + '--import', + pathToFileURL(join(directory, 'preload.mjs')).href, + entry, + ...args, + ]; + async function runCase(name, operation) { + const started = Date.now(); + try { + const evidence = await operation(); + report.cases.push({ id: name, passed: true, elapsedMs: Date.now() - started, evidence }); + } catch (error) { + report.cases.push({ + id: name, + passed: false, + elapsedMs: Date.now() - started, + error: String(error.stack ?? error), + }); + } + } + try { + const metadata = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')); + assert.equal(metadata.name, 'relavium'); + const dependencies = {}; + for (const name of [ + 'better-sqlite3', + 'openai', + '@napi-rs/keyring', + '@modelcontextprotocol/sdk', + '@google/genai', + ]) { + const resolved = realpathSync( + require.resolve( + name === '@modelcontextprotocol/sdk' ? '@modelcontextprotocol/sdk/client/index.js' : name, + ), + ); + assert.equal(within(repository, resolved), false, `Workspace dependency leakage: ${name}`); + assert.equal( + within(installedModules, resolved), + true, + `Dependency escaped installed consumer: ${name}`, + ); + let metadataPath = dirname(resolved), + dependencyMetadata; + for (;;) { + const candidate = join(metadataPath, 'package.json'); + if (existsSync(candidate)) { + const parsed = JSON.parse(readFileSync(candidate, 'utf8')); + if (parsed.name === name) { + dependencyMetadata = parsed; + break; + } + } + assert.notEqual( + metadataPath, + dirname(metadataPath), + `Dependency metadata missing: ${name}`, + ); + metadataPath = dirname(metadataPath); + } + dependencies[name] = { + entry: resolved, + packageRoot: metadataPath, + version: dependencyMetadata.version, + }; + } + report.dependencies = dependencies; + const Database = require('better-sqlite3'); + const context = { root, Database, cli, environment, calls }; + await runCase('native-addon-load-only', async () => { + const result = await invoke([join(directory, 'native-load.mjs'), packageRoot], { + cwd: root, + env: environment(root), + }); + assertExit(result, 0); + const evidence = JSON.parse(result.stdout); + for (const path of [evidence.keyring, evidence.sqlite, ...evidence.nativeModules]) { + assert.equal(within(repository, realpathSync(path)), false); + assert.equal(within(installedModules, realpathSync(path)), true); + } + return evidence; + }); + let version; + await runCase('installed-version', async () => { + const result = await invoke(cli(['--version']), { cwd: root, env: environment(root) }); + assertExit(result, 0); + version = result.stdout.trim(); + assert.equal(version, metadata.version); + assert.equal(calls().length, 0, 'Version must not load/use external services'); + return { version, outputBytes: result.bytes }; + }); + await runCase('workflow-gate-budget-effects', () => assertInstalledWorkflows(context)); + await runCase('real-sdk-chat-persistence-resume', () => assertInstalledChat(context)); + await runCase('actual-mcp-initialize-version', async () => { + assert.ok(version, 'Installed version control must pass before version comparison'); + return assertInstalledMcp(context, version); + }); + await runCase('actual-media-cas-iterator-cleanup', async () => { + const fixture = join( + repository, + 'tools/fs-policy-controls/fixtures/media-native-packaging.mjs', + ); + const result = await invoke([fixture, entry], { + cwd: root, + env: environment(root), + timeout: 30000, + }); + assertExit(result, 0); + assert.ok( + result.stdout.includes( + 'actual CLI bundled native registration/consumption joined after owned-file cleanup', + ), + ); + return { + actualInstalledEntry: entry, + nativeJoinAndOwnedCasCleanup: true, + outputBytes: result.bytes, + }; + }); + report.guardCalls = calls(); + for (const call of report.guardCalls.filter((value) => value.operation === 'external-module')) { + const resolved = realpathSync(fileURLToPath(call.url)); + assert.equal( + within(repository, resolved), + false, + 'Actual SDK/SQLite dispatch resolved through workspace', + ); + assert.equal( + within(installedModules, resolved), + true, + 'Actual SDK/SQLite dispatch escaped installed consumer', + ); + assert.equal( + within(dependencies[call.specifier].packageRoot, resolved), + true, + 'Actual dispatch used another dependency instance', + ); + } + assert.ok( + report.guardCalls.some( + (call) => call.operation === 'external-module' && call.specifier === 'openai', + ), + 'Actual installed SDK resolution witness missing', + ); + assert.ok( + report.guardCalls.some( + (call) => call.operation === 'external-module' && call.specifier === 'better-sqlite3', + ), + 'Actual installed SQLite resolution witness missing', + ); + assert.deepEqual( + report.cases.map((test) => test.id).sort(), + [...REQUIRED_SMOKE_CASES].sort(), + 'Required installed case inventory mismatch', + ); + report.success = report.cases.every((test) => test.passed); + } catch (error) { + report.error = String(error.stack ?? error); + } finally { + try { + const after = snapshot(packageRoot); + report.compiledUnchanged = JSON.stringify(before) === JSON.stringify(after); + report.compiledDigests = after; + } catch (error) { + report.compiledUnchanged = false; + report.immutabilityError = String(error); + } + if (!report.compiledUnchanged) report.success = false; + try { + rmSync(root, { recursive: true, force: true }); + } catch (error) { + report.cleanupError = String(error); + } + report.ownedTempRemoved = !existsSync(root); + if (!report.ownedTempRemoved) report.success = false; + const json = JSON.stringify(report, null, 2) + '\n'; + assert.ok(Buffer.byteLength(json) <= 2097152, 'Installed smoke evidence byte ceiling exceeded'); + writeFileSync(join(supportRoot, 'installed-smoke.json'), json, { mode: 0o600 }); + } + return report; +} + +assert.equal(process.argv.length, 6, 'installed package, support, artifact and source required'); +const report = await exerciseInstalledSmoke({ + packageRoot: process.argv[2], + supportRoot: process.argv[3], + artifactSha256: process.argv[4], + source: process.argv[5], +}); +if (!report.success) process.exitCode = 1; From 05e1e786b2024aed91cd835ea77ef89aba0341f7 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 10:48:07 +0300 Subject: [PATCH 079/222] test(repo): refresh exact cohorts and isolate coordinator scratch Preserve the four newly added historical-reader gate cases and the export case in their complete causal cohorts. Independently audited all 41 secrets and 110 filesystem declarations; only four exact counts needed adjustment. Give each native coordinator fixture its own temp authority and leak check. Refs: ADR-0111, Wave 3 Steps 2 and 7b Co-Authored-By: Codex --- tools/causal-controls/run.test.mjs | 35 ++++++++++++++++++++-------- tools/secrets-controls/mutations.mjs | 8 +++---- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/tools/causal-controls/run.test.mjs b/tools/causal-controls/run.test.mjs index ad549917..dd39a8d7 100644 --- a/tools/causal-controls/run.test.mjs +++ b/tools/causal-controls/run.test.mjs @@ -26,8 +26,13 @@ import { sourceState, digest } from '../usage-coverage-controls/source-graph.mjs const repository = realpathSync(fileURLToPath(new URL('../../', import.meta.url))); const target = 'tools/causal-controls/fixture.mjs'; const files = ['tools/causal-controls/fixture.cases.mjs']; -const scratchNames = () => - readdirSync(realpathSync(tmpdir())) +function scratchEnvironment(directory) { + const root = join(directory, 'owned-temporary'); + mkdirSync(root, { recursive: true }); + return { TMPDIR: root, TMP: root, TEMP: root }; +} +const scratchNames = (directory) => + readdirSync(scratchEnvironment(directory).TMPDIR) .filter((name) => /^relavium-(?:filesystem|secrets)-controls-/.test(name)) .sort(); @@ -43,7 +48,7 @@ for (const [kind, background] of [ const directory = realpathSync(mkdtempSync(join(tmpdir(), 'causal-runtime-error-'))); const before = sourceState(repository); const tooling = ownedTools(repository, ['tools/causal-controls', 'tools/fs-policy-controls']); - const scratches = scratchNames(); + const scratches = scratchNames(directory); try { const success = { id: 'clean-semantic-removal', @@ -69,7 +74,11 @@ for (const [kind, background] of [ mkdirSync(evidence); const child = await spawnJoined(process.execPath, [entry], { cwd: repository, - env: { ...process.env, RELAVIUM_ACCEPTANCE_EVIDENCE: evidence }, + env: { + ...process.env, + ...scratchEnvironment(directory), + RELAVIUM_ACCEPTANCE_EVIDENCE: evidence, + }, }); assert.equal(child.joined, true); assert.equal(child.timedOut, false); @@ -137,7 +146,7 @@ for (const [kind, background] of [ ownedTools(repository, ['tools/causal-controls', 'tools/fs-policy-controls']), tooling, ); - assert.deepEqual(scratchNames(), scratches); + assert.deepEqual(scratchNames(directory), scratches); } finally { rmSync(directory, { recursive: true, force: true }); } @@ -153,7 +162,7 @@ test( sourceHash = digest(readFileSync(join(repository, target))); const roots = ['tools/causal-controls', 'tools/fs-policy-controls', 'tools/secrets-controls']; const tooling = ownedTools(repository, roots); - const scratches = scratchNames(); + const scratches = scratchNames(directory); try { for (const [kind, failure, exportRefused] of [ ['filesystem', 'assertion', false], @@ -195,6 +204,7 @@ test( cwd: repository, env: { ...process.env, + ...scratchEnvironment(directory), RELAVIUM_ACCEPTANCE_EVIDENCE: exportRefused ? repository : evidence, }, }, @@ -243,7 +253,7 @@ test( !child.stderr.includes('Evidence must be outside'), 'Secondary export failure must preserve the actual primary', ); - assert.deepEqual(scratchNames(), scratches, 'Actual coordinator leaked scratch'); + assert.deepEqual(scratchNames(directory), scratches, 'Actual coordinator leaked scratch'); assert.equal(digest(readFileSync(join(repository, target))), sourceHash); if (exportRefused) { assert.equal(existsSync(join(evidence, kind)), false); @@ -311,7 +321,7 @@ test( test('a stale later anchor stops every selected control before any fixed phase', async () => { const directory = realpathSync(mkdtempSync(join(tmpdir(), 'causal-anchor-preflight-'))); const before = sourceState(repository), - scratches = scratchNames(); + scratches = scratchNames(directory); try { const entry = join(directory, 'run.mjs'); const good = { @@ -335,7 +345,11 @@ test('a stale later anchor stops every selected control before any fixed phase', [entry], { cwd: repository, - env: { ...process.env, RELAVIUM_ACCEPTANCE_EVIDENCE: evidence }, + env: { + ...process.env, + ...scratchEnvironment(directory), + RELAVIUM_ACCEPTANCE_EVIDENCE: evidence, + }, }, 30000, ); @@ -348,7 +362,7 @@ test('a stale later anchor stops every selected control before any fixed phase', assert.deepEqual(retained.restorations, []); assert.match(retained.failure.message, /later-stale: exact source anchor drift/); assert.equal(existsSync(join(evidence, 'filesystem', 'first-valid')), false); - assert.deepEqual(scratchNames(), scratches); + assert.deepEqual(scratchNames(directory), scratches); assert.deepEqual(sourceState(repository), before); } finally { rmSync(directory, { recursive: true, force: true }); @@ -387,6 +401,7 @@ test( cwd: repository, env: { ...process.env, + ...scratchEnvironment(directory), RELAVIUM_ACCEPTANCE_EVIDENCE: evidence, RELAVIUM_CAUSAL_TIMEOUT_FIXTURE: mode, }, diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index 8522ef5d..abf83c7f 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -367,7 +367,7 @@ export const mutations = [ ' const mayPrompt = args.secretStdin !== true;', ' const mayPrompt = true;', ['apps/cli/src/commands/gate.test.ts'], - 61, + 65, 'refuses unapproved stdio after a real read despite stale TTY, without prompting or writing a grant', 'SECRETS_CAUSAL_RESUME_CONSUMED_STDIN_CONSENT', ), @@ -473,7 +473,7 @@ export const mutations = [ " .normalize('NFKD')\n", '', ['packages/core/src/export/serializer.test.ts'], - 24, + 25, 'normalizes supported Unicode title forms into deterministic ASCII IDs that round-trip', 'WAVE2_EXPORT_ASCII_NORMALIZATION', ), @@ -483,7 +483,7 @@ export const mutations = [ ' return slug.length > 0 ? slug : fallbackWorkflowId(record.id);', " return slug.length > 0 ? slug : 'exported-session';", ['packages/core/src/export/serializer.test.ts'], - 24, + 25, 'uses exact session identity for absent or unusable titles and preserves production UUIDs', 'WAVE2_EXPORT_IDENTITY_FALLBACK', ), @@ -493,7 +493,7 @@ export const mutations = [ ' return `${SESSION_ID_PREFIX}id-${encoded}`;', ' return `${SESSION_ID_PREFIX}${sessionId}`;', ['packages/core/src/export/serializer.test.ts'], - 24, + 25, 'bounds odd external session identities with deterministic legal encoding and faithful metadata', 'WAVE2_EXPORT_EXTERNAL_ID_FALLBACK', ), From c836ffe616803697285f51204c78b76ce3a99814 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 10:48:07 +0300 Subject: [PATCH 080/222] docs(docs): retain package cleanup and workload gate corrections Record the third non-clean cumulative review, actual Windows cleanup and secrets count failures, process-owned smoke correction and required fresh gates without inferring platform success from completed behavior cases. Refs: ADR-0111, Wave 3 Step 7b Co-Authored-By: Codex --- docs/reference/cli/packaging.md | 7 +++ .../phase-2.5.5-hardening-and-remediation.md | 56 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index 5709370d..8c36ca6d 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -103,6 +103,13 @@ allowlist, verifies installed public files, and records actual dependency versio Dependency installation uses the public registry; provider transport and keychain API controls use synthetic data and refusals. +Installed native smoke and SQLite reads run in an owned bounded child. The +installation cleanup owner joins actual child closure before removing the tree; +a connection close alone does not unload a Windows native module. Required +removal precedes success receipt/summary publication. Worker or cleanup failure +prevents publication, and secondary cleanup/diagnostic failure preserves the +primary outcome. Private worker identity stays outside public receipts. + Required workflows share one public tarball and a `compiled_package_test` receipt with current `.nvmrc` Linux/macOS/Windows and exact Node 22.0.0 Linux. Each repeats untouched-entry bootstrap controls and actual workflow, gate, budget, chat/resume, diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 043caec2..077ec985 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -5238,3 +5238,59 @@ literal leaf imports and explicitly justifies only the owned URL-path fixture. Two NEW complete cumulative clean rounds after these final material fixes, affected security review and real required same-artifact platform success remain necessary. No new decision, dependency or deferral is introduced. + +### Step 7b third cumulative review and completed-consumer correction — 2026-10-16 + +The third NEW complete cumulative review at `eb21b86c` covers **103 paths / +193 hunks / 59 full new files** and is **non-clean**. It verifies the prior four +corrections, then independently reproduces the actual Windows consumer's outer +cleanup failure. All 175 bootstrap controls and six installed cases finish, but +`consume.mjs` still hosts installed SQLite native loads and fails to remove its +installation. A success receipt/summary was written before that required removal. +The real success-only upload and separate job-result aggregate refuse the failed +job. Its local 153 tooling / 106 runtime / 175 compiled passes do not close this +failure. The parent reads the full report and the separate native security sitting. + +The correction runs the complete installed cohort in one bounded native worker, +joins actual process closure, and binds its bounded private report to the observed +worker PID, parent, source, artifact and runtime. The cleanup process imports no +installed native module. Required installation removal precedes both success +receipt and summary. An exercise failure retains its exact primary value through +secondary cleanup or diagnostic failure; a cleanup-only failure prevents success. +Seven permanent controls include a real copied SQLite addon in the worker, +forbidden native loading in the parent, worker exit before removal/publication, +wrong PID, nonzero-after-early-report, cleanup refusal and Error/null/undefined +primary retention. Existing timeout/output/join controls remain required. + +Actual CI `38866746303` passes the **complete usage causal gate**, correcting the +nested replay alias failure. Pack, current Linux/macOS and exact Node 22.0.0 Linux +also pass, with the same remote `5cc4e96e…` artifact; Windows/aggregate remain +failed at this historical source. Independently downloaded tar bytes and three +successful receipts bind to clean source `eb21b86c`; no four-leg or full-CI pass +is inferred. The local `df7935b2…` candidate is separately attributed. + +The same run exposes four stale secrets-workload declarations after Step 2's +historical-reader regressions: the gate file contains 65, not 61 cases; each of +three serializer controls contains 25, not 24. A separate source-fenced actual +collection audits all **41 secrets declarations / 23 files / 1,041 cases** and +all **110 filesystem declarations / 38 files / 658 cases**. Only those four +counts differ. Their exact counts are corrected without weakening whole-file +equality, runtime receipts, semantic markers, restoration or source provenance. +The four selected fixed/removal/restored cohorts retain **65/25/25/25** cases per +phase, with **3/2/3/2** expected semantic removal failures and byte-identical +restoration. Selected runs remain incomplete-family evidence. + +The first author tooling run exposes a separate test-isolation assumption: +coordinator tests compare all matching names in the shared OS temp root while +four real selected controls create/remove their own roots concurrently. Each +coordinator fixture now supplies and scans its own TMPDIR/TMP/TEMP directory; +the scratch-leak assertion remains exact. No other invocation's temp state is +treated as this fixture's cleanup failure. The corrected **482 acceptance-tool +tests** pass with zero skips. Initial author lint catches a moved summary binding +and an unsafe-finally form; fixed lexical bindings and explicit primary capture +pass lint without rule suppression. The earlier consumer attempt with that binding +error remains failed evidence; its corrected local retry passes all 175/six cases. + +Affected security and two NEW complete cumulative clean rounds after these +material corrections, plus actual required same-artifact platform success, remain +open. No new ADR, dependency, deferral, credential or provider action is introduced. From d9625ff0a641197c6ed45968df99094a25db88d6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 11:20:58 +0300 Subject: [PATCH 081/222] docs(docs): accept wave 3 installed package gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record two new complete clean cumulative reviews, affected security review and same-artifact four-platform acceptance at c836ffe6. Keep broader CI status and historical failed evidence separately attributed. Record serial Step 8a–8d before native integration starts. Refs: ADR-0110, ADR-0111, Wave 3 Steps 7–8 Co-Authored-By: Codex --- AGENTS.md | 7 +- CLAUDE.md | 7 +- ...-operations-use-a-lazy-packaged-runtime.md | 13 +++ docs/reference/cli/commands.md | 5 +- docs/reference/cli/packaging.md | 9 +- docs/roadmap/current.md | 8 +- .../phase-2.5.5-hardening-and-remediation.md | 94 +++++++++++++++++++ docs/standards/security-review.md | 21 +++++ 8 files changed, 153 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eeca1e42..2285e29f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,8 +91,11 @@ sitting. Step 6 (pure process/discovery substrate) is independently accepted aft two NEW complete cumulative clean rounds and a separate clean security sitting. Step 7a (lazy runtime and finite compiled graph) is independently accepted after two NEW complete cumulative clean rounds and a separate clean security sitting. -Step 7b's installed-tarball and required platform controls are active; whole Step 7 -remains open until they pass. +Step 7b and whole Step 7 are independently accepted at `c836ffe6` after two NEW +complete cumulative clean rounds, a clean security sitting and successful exact-tarball +Linux/macOS/Windows plus Node 22.0.0 consumers. Broader CI retains its separately +attributed status. Step 8 proceeds as recorded serial increments 8a–8d; native +fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance remain staged in the canonical roadmap. diff --git a/CLAUDE.md b/CLAUDE.md index 4a4c2f78..7c99bf51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,8 +180,11 @@ sitting. Step 6 (pure process/discovery substrate) is independently accepted aft two NEW complete cumulative clean rounds and a separate clean security sitting. Step 7a (lazy runtime and finite compiled graph) is independently accepted after two NEW complete cumulative clean rounds and a separate clean security sitting. -Step 7b's installed-tarball and required platform controls are active; whole Step 7 -remains open until they pass. +Step 7b and whole Step 7 are independently accepted at `c836ffe6` after two NEW +complete cumulative clean rounds, a clean security sitting and successful exact-tarball +Linux/macOS/Windows plus Node 22.0.0 consumers. Broader CI retains its separately +attributed status. Step 8 proceeds as recorded serial increments 8a–8d; native +fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance remain staged in the canonical roadmap. diff --git a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md index e228b68d..bcf6b131 100644 --- a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md +++ b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md @@ -211,3 +211,16 @@ identities have supported pure leaves; no new runtime dependency or security pol is introduced. Context/module continuity does not claim the native registered-owner integration assigned to Step 8. Original-TS maps and custody remain Step 9. Neither this note nor local compiled controls close whole Step 7 or claim a new release. + +## Compiled and installed implementation landing — 2026-10-16 + +Step 7a, Step 7b and whole Step 7 are independently accepted on `development` +at `c836ffe6`. Two NEW complete cumulative clean rounds after the final correction +cover the entire Step 6 boundary to reviewed source; a separate clean security +sitting covers the affected installed lifetime boundaries. Actual required +consumers bind one tarball across current Linux/macOS/Windows and exact Node +22.0.0 Linux. The [acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-7b-and-whole-step-7-acceptance--2026-10-16) +owns precise source/artifact/review/gate attribution and broader CI status. +Earlier pending notes retain their historical scope. Native fatal ownership, +original-TS maps/custody, JSON help and whole-wave acceptance remain Steps 8, +9, 10 and 19 respectively. No release or production custody setup is inferred. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 96dd6569..b2ea6344 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -662,8 +662,9 @@ was approved by the maintainer. Step 6 implements the pure flag/error/observatio and ownership substrate, descriptor-derived current Commander grammar and the compatible model projection. Its discovery DTO is available as a pure function; production JSON help, aliases, pricing spellings, output categories and consent -remain Step 10. Lazy entry and actual fatal/native/terminal adapters remain -Steps 7–8. Earlier sections describe current behaviour unless stated here. +remain Step 10. Step 7's lazy entry and installed package are independently +accepted; actual fatal/native/terminal adapters remain Step 8. Earlier sections +describe current behaviour unless stated here. ### Diagnostic policy diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index 8c36ca6d..b151dc6f 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -1,6 +1,6 @@ # CLI Packaging Contract -- **Status**: Compiled graph independently accepted on `development`; installed-package and release gates staged +- **Status**: Compiled graph and installed package independently accepted on `development`; native fatal integration and release gates staged - **Date**: 2026-10-15 - **Related**: [ADR-0111](../../decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md), [commands.md](commands.md), [release runbook](../../runbooks/release-a-surface.md) @@ -8,8 +8,11 @@ This is the maintainer-approved canonical design contract (2026-10-16). Wave 3 Step 7a's lazy compiled graph is independently accepted on `development` after two new complete cumulative clean reviews and a separate security sitting. The published release retains its historical single bundle. -Step 7b owns actual tarball/install/platform acceptance, Step 8 owns native fatal -integration, and Step 9 owns maps and archival custody. +Step 7b and whole Step 7 are independently accepted at `c836ffe6`, including the +same-tarball current Linux/macOS/Windows and exact Node 22.0.0 Linux consumers. +The [acceptance register](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-7b-and-whole-step-7-acceptance--2026-10-16) +attributes reviews, artifacts and remaining broader CI separately. Step 8 owns +native fatal integration, and Step 9 owns maps and archival custody. No release, production recipient setup or asset upload is performed by this document. ## Finite runtime package diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 7acd217b..ef566a01 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1125,8 +1125,12 @@ correction and a separate clean security sitting. after two NEW complete cumulative clean rounds and a separate clean security sitting. [Step 7a is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-7a-acceptance--2026-10-16) after two NEW complete cumulative clean rounds and a separate clean security sitting. -Step 7b's installed-tarball and required platform controls are active; whole Step 7 -remains open until they pass. The +[Step 7b and whole Step 7 are independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-7b-and-whole-step-7-acceptance--2026-10-16) +at `c836ffe6` after two NEW complete cumulative clean rounds, a separate clean +security sitting and successful same-tarball current Linux/macOS/Windows and +exact Node 22.0.0 Linux consumers. Broader CI keeps its separately attributed +status. Step 8 proceeds as serial 8a–8d increments; whole native integration +remains open until their actual owners and all-surface controls pass. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 077ec985..a4addfcf 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -3667,6 +3667,10 @@ No credential values or request captures belong in tracked evidence. | 7a | **Recorded split, 2026-10-16 — source/router and finite compiled graph:** exact sanitizer leaves, guarded entry/runtime imports, untouched compiled human meta and grammar controls, actual execution admission ordering, one invocation file seed and eleven current DB-owner/quote/media/fixture controls. One split ESM build, flat layout, entry-only shebang and every-output file/import/dependency closure. Preserve affected compiled workflow/media smokes. Two NEW complete cumulative clean rounds and a scoped security sitting are required. This accepts only the compiled graph, not installed packaging or whole Step 7. | | 7b | **Recorded split, 2026-10-16 — exact tarball and required consumers:** independently inspect actual tar bytes/modes/public inventory; install the same digest outside the workspace; prove complete real chat/resume, workflow/migrations/native/CAS/MCP/class identity and invocation-context continuity. Required exact Node 22.0.0 and current-runtime Linux/macOS/Windows consumers aggregate the same artifact. Final cumulative reviews include all 7a changes from the Step 6 boundary. Whole Step 7 and ADR-0111's graph obligations close only after both increments; actual registered native-owner cleanup remains Step 8. | | 8 | **Native process integration:** #40/#41/#42/#224/#269/G35 under ADR-0110, after the early graph can catch import failures. Wire kind-specific owners, output_unavailable/cli_internal 9, known storage/history/departure classification, run-only health precedence and session join uncertainty. Preserve recoverable #228/signals; MCP PID versus tool PGID/Windows, terminal restore before stderr, total remaining fatal budget, competing faults and both sinks. Minimal I/O adapters land here; central DB refactor stays last. No false host-safe-close ACK. | +| 8a | **Recorded split, 2026-10-16 — origin and inert native foundation:** private factory/I/O storage facts and read-only classification, fixed projections, observable registry termination and one live absolute budget through diagnostic ACK. Preserve actual migration/checkpoint origin at fixed native/vendor boundaries; no arbitrary cause-tree classification. Native fixtures explicitly install the coordinator; product activation waits for all creation owners. | +| 8b | **Recorded split — creation-owned native processes:** tool POSIX group versus MCP PID, actual close/retirement, verified absolute Windows teardown admission and helper lifetime; connect OS/editor helpers and replace independent reapers without claiming full descendant containment. | +| 8c | **Recorded split — workflow and render integration:** run/gate/budget/driver owners, real return/throw health priority, cancellation, terminal/job-control leases and force-quit routing. No timer certifies DB close or receipt settlement. | +| 8d | **Recorded split — sessions, terminal and activation:** fresh/resumed/one-shot/Home/pending/replaced session lifetimes, retained publication failure and shared actual cleanup promises; connect caught slash/rebuild faults, restore terminal custody before diagnosis, then activate early coordinator/background/sink integration. Actual native/platform/packed controls and complete cumulative Step 8 acceptance follow. | | 9 | **Release map/custody substrate:** #46/ADR-0111, independently reviewed after the finite graph. Upstream TS maps, sole prepack final build, pre-hash trailer removal/staging, exact tarball/CLI+engine symbolication, encrypt-once candidate with permissioned prebuild discovery/reservation → pack/reuse → smoke → archive → npm → finalization. Required artifact/platform aggregation, all rerun modes, lost-staging refusal and permission-enforcing offline API controls. Dedicated archive/crypto security sitting. Fixture proof never claims production recipient/custody configuration. | | 10 | **CLI behaviour/discovery integration:** #45/#264/#265/#268/#13/#17/#18/#310/#309/#19 under ADR-0110, consuming Steps 6–9. Production owner category matrix, real root/subtree JSON help, pricing/chat aliases, non-echoing Commander refusal and signal-safe deletion consent. G15 is refuted and receives real engine/store/log preservation evidence, with no invented token coalescing. Repeat affected packed gates. | | 11 | **Chat normalization and abort:** #236/#237/#242/#307 → #218 → #21/#23. Canonical token/money source with approved additive LLM export, preserved two-decimal cap formatting and real immediate abort while a turn is blocked. Audit Home's imports. | @@ -5294,3 +5298,93 @@ error remains failed evidence; its corrected local retry passes all 175/six case Affected security and two NEW complete cumulative clean rounds after these material corrections, plus actual required same-artifact platform success, remain open. No new ADR, dependency, deferral, credential or provider action is introduced. + +## Wave 3 Step 7b and whole Step 7 acceptance — 2026-10-16 + +**Accepted on `development` at `c836ffe616803697285f51204c78b76ce3a99814`.** +The parent reads both NEW complete cumulative clean reports after the final +native-worker, workload and coordinator-isolation corrections. R4 uses +gpt-6-astra/high; R5 uses gpt-6.1-sol/xhigh. Each independently covers all +**107 paths / 211 zero-context hunks / 62 full added files** from the accepted +Step 6 boundary `b09008e7`, including all Step 7a work and every correction. +Each freshly passes **160 tooling tests, eight runtime files / 106 tests, +six actual coordinator tests and 175 untouched compiled bootstrap controls**, +with zero skips. Closure remains **24 JS / 375 inputs / 17 external dependencies**; +the hidden-own-dist resolution probe has zero unresolved self imports. +Finding-bearing R1–R3 and interrupted attempts are not counted clean. + +The separate NEW completed-consumer security sitting is clean at the same source: +**61 tooling tests and nine independent copied-launcher negative probes**. +The parent reads the whole report. Exact child closure precedes installation +removal, and required removal precedes public success. This gate is distinct +from both complete correctness rounds and from Step 8 product fatal ownership. + +### Author, artifact and actual required platform evidence + +The final author **482 acceptance-tool tests** pass with zero skips; tools +lint/typecheck/format/fences pass. Ordinary workspace checks retain the valid +**647 files / 14,660 passes / 20 established skips** cache attribution and the +separate earlier fresh clone's **647 / 14,659 / 21 established CI-projection +skips**. Neither is relabeled as a new fresh final-source full suite. Fresh +reviewer checks above independently exercise the affected current source. +Earlier build, selected causal and failed attempts keep their original scopes. + +GitHub push run **38869520079 / attempt 1** is the exact reviewed source. +Actual successful jobs are pack **116870449731**, current Linux **116870634562**, +current macOS **116870634638**, current Windows **116870634595**, exact minimum +Linux **116870634584**, and required packed aggregate **116871821131**. +All four consumers bind the same original tarball: +`9926f8eab1686f3e541052f1af22b832658166126c7a6ac72b908e2f7f18c228`. +Current legs run **v24.21.0**; the minimum leg runs **v22.0.0**. Both complete +reviewers independently inspect actual bytes and strict receipts against actual +API job/source/origin identity; R5 downloads its own copies. The parent also +independently inspects original bytes and all four receipts. Each leg passes +175 bootstrap controls and six installed cases. Historical Windows failures +remain failed evidence, not accepted runs. + +The separately produced local candidate is +`d707f89adbb71490a5c03b59cc095bce1ff6dda7b265df9c83d4ab965c09d7ed`, +**69 public files / 24 JS / 17 dependencies**, at the same source. Its completed +175/six consumer, joined worker and absent installation are independently checked; +its explicit local `dirty: true`/isolated-Git-HOME qualification is retained. +It is never substituted for the clean GitHub artifact or a remote platform leg. + +### Broader CI and physical evidence retain separate attribution + +At this acceptance checkpoint, actual current wire, complete secrets, complete +usage, coverage and advisory platform jobs are successful; filesystem controls +and the overall CI run remain **in progress**. Required Step 7 package/platform +gates above are complete. No whole-CI or successor-metadata execution is inferred. +The current CI run is allowed to finish without a superseding push; any later +failure remains actionable. Whole-wave source/coverage/platform gates repeat in +Step 19 after later implementation. + +A separate read-only physical audit downloads current wire, secrets and usage +artifacts from that run and validates exact source, counts, semantic witnesses, +restoration and retained bounds: wire **four mechanisms / 24 cases**; +secrets **41 controls / 123 phases**, including the repaired **65/25/25/25** +cohorts and all 123 zero-unhandled completed runtime receipts; usage **23 controls / +eight groups**, with **435 permanent cases / 71 native children / one ordered +readiness-timeout control**. Raw source/body/dependency journals are joined within +their respective export scope. This is inspection of remote execution, not new +local mutation execution; absent nested trees and reporter-boundary limits are +explicit. Filesystem's current physical artifact is not yet audited here. + +Detailed review/audit records remain local and Git-ignored. No installed smoke +claims real provider/keychain API use, TLS certification or hostile same-user +containment. No new dependency, ADR, deferral, credential, billing or approval +gate is introduced. **Step 7b and whole Step 7 are closed; Steps 8–19 remain open.** + +### Recorded Step 8 serial split before implementation + +The four rows 8a → 8b → 8c → 8d above preserve all original Step 8 IDs and +approved contracts. Each receives an implementation commit and two NEW complete +cumulative clean rounds after its last material correction; native/process +security sittings remain separate. 8a supplies provenance and an inert native +foundation. Product fatal activation waits until 8d connects every actual +creation owner and caught/forced-exit path. The fixed migration adapter may decode +one verified public Drizzle wrapper at its actual owned operation, never arbitrary +provider/plugin causes or a recursive private cause tree; checkpoint origin is +retained before native evidence is erased. No driver fork or migrator replacement +is planned. Whole Step 8 stays open until 8d's complete cumulative acceptance. +The central opener refactor remains Step 18, and whole-wave publication Step 19. diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index 60e414c5..c6c2af1e 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -605,6 +605,27 @@ platform consumers, native owner/fatal integration, maps/custody and whole-wave publication retain Steps 7b, 8, 9 and 19. No real provider, keychain, credential, billing or release operation is performed. +### Sitting: Wave 3 installed-consumer lifetime and publication (2026-10-16) + +A NEW independent affected cumulative sitting is **clean** at `c836ffe6`, after +the completed-consumer correction. Fresh execution passes **61 tooling tests** +and **nine independent copied-launcher refusal probes**, with zero skips. A real +copied SQLite addon loads only in the joined worker; its cleanup owner forbids +native loading. Wrong process/source/artifact/runtime identities, early success +followed by failure, oversized/symlink reports and required cleanup failure refuse. +Secondary cleanup or sink failure preserves Error/null/undefined primary outcomes. +The parent reads the complete report and both separate complete correctness rounds. + +The sitting independently checks actual successful Windows/aggregate job logs, +the original remote tarball and all four strict public receipts. Public outputs +exclude private worker identities, support paths and synthetic request bodies. +Explicit environments, bounded shell-free processes, archive/source binding, +unchanged grant/custody policy and exact-count/restoration obligations remain. +The [Step 7 acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-7b-and-whole-step-7-acceptance--2026-10-16) +joins source and artifact evidence. This is not a new all-path generic review, +real keychain/provider exercise, full-CI claim or native fatal-owner integration. +Steps 8/9/10/19 and existing cooperating-host limits retain their distinct scope. + ## Sandbox and tool policy (`run_command`, node tools, secret inputs) This is the **binding home** for the tool-policy rules; the rationale is From 2f7ddc687b03f11ff2b137d2d6ba426d49e8e8c3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 12:23:01 +0300 Subject: [PATCH 082/222] fix(db): preserve owned storage failure provenance Record read-only native-operation facts without reclassifying arbitrary error constructors or cause trees. Preserve the installed migration, checkpoint, media and locking contracts at their actual boundaries. Refs: ADR-0110 Co-Authored-By: Codex --- packages/db/src/client-native-origin.test.ts | 40 ++++ packages/db/src/client.ts | 34 ++- packages/db/src/index.ts | 1 + .../media-store-marker-acquisition.test.ts | 24 ++ packages/db/src/media-store-paths.ts | 30 +-- packages/db/src/media-store-temporaries.ts | 51 +++-- packages/db/src/migrate-lock.ts | 9 +- .../db/src/migration-storage-origin.test.ts | 90 ++++++++ packages/db/src/migration-storage-origin.ts | 42 ++++ packages/db/src/session-effect-privacy.ts | 12 +- packages/db/src/storage-failure-facts.ts | 167 ++++++++++++++ packages/db/src/storage-origin.test.ts | 216 ++++++++++++++++++ 12 files changed, 666 insertions(+), 50 deletions(-) create mode 100644 packages/db/src/client-native-origin.test.ts create mode 100644 packages/db/src/migration-storage-origin.test.ts create mode 100644 packages/db/src/migration-storage-origin.ts create mode 100644 packages/db/src/storage-failure-facts.ts create mode 100644 packages/db/src/storage-origin.test.ts diff --git a/packages/db/src/client-native-origin.test.ts b/packages/db/src/client-native-origin.test.ts new file mode 100644 index 00000000..b1c453dc --- /dev/null +++ b/packages/db/src/client-native-origin.test.ts @@ -0,0 +1,40 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import type NativeDatabase from 'better-sqlite3'; + +const faults: { failure: Error | undefined } = vi.hoisted(() => ({ failure: undefined })); +vi.mock('better-sqlite3', async (original) => { + const native = await original<{ readonly default: typeof NativeDatabase }>(); + class NativeConstructorFixture extends native.default { + constructor(...args: ConstructorParameters) { + if (faults.failure !== undefined) throw faults.failure; + super(...args); + } + } + return { ...native, default: NativeConstructorFixture }; +}); +import { createClient, DbOpenError } from './client.js'; +import { isOwnedStorageFailure } from './storage-failure-facts.js'; +afterEach(() => { + faults.failure = undefined; +}); + +it.each(['ERR_DLOPEN_FAILED', 'MODULE_NOT_FOUND', 'ENOENT', 'EACCES'])( + 'constructor-time %s is not storage merely because open_failed wraps it', + (code) => { + // Synthetic import/ABI/foreign constructor fault; the actual native constructor loads its addon here. + const cause = Object.assign(new Error('PRIVATE native loader payload'), { code }); + faults.failure = cause; + try { + createClient(); + } catch (error) { + expect(error).toBeInstanceOf(DbOpenError); + if (!(error instanceof DbOpenError)) throw new Error('wrong wrapper'); + expect(error.code).toBe('open_failed'); + expect(error.cause).toBe(cause); + expect(error.message).toBe('could not open the SQLite database'); + expect(isOwnedStorageFailure(error)).toBe(false); + return; + } + throw new Error('fixture did not fail'); + }, +); diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index ca70f80c..2f4be914 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -6,7 +6,14 @@ import { fileURLToPath } from 'node:url'; import Database from 'better-sqlite3'; import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; -import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; +import { migrateWithOwnedStorageFacts } from './migration-storage-origin.js'; +import { + hasOwnedClient, + rememberOwnedClient, + transferStorageFailure, + withOwnedFilesystem, + withOwnedSqlite, +} from './storage-failure-facts.js'; import { withMigrationLock } from './migrate-lock.js'; import * as schema from './schema.js'; @@ -120,12 +127,12 @@ export function createClient(path = ':memory:'): DbClient { // Create the parent directory for a real file path so a first-run open doesn't fail on a // missing folder (inside the try so a filesystem error is classified the same way). if (path !== ':memory:') { - mkdirSync(dirname(path), { recursive: true }); + withOwnedFilesystem(() => mkdirSync(dirname(path), { recursive: true })); } // A local binding as well as the outer `let`: the outer one is what the `catch` closes, and inside a // closure TypeScript cannot narrow it away from `undefined`, which would turn a real invariant into an // optional chain that silently skips the pragma. - const opened = new Database(path); + const opened = withOwnedSqlite(() => new Database(path)); sqlite = opened; // The PRAGMAs and the Drizzle bind are INSIDE the try: any of them can fail (a corrupt header surfaces on // the first `journal_mode` write, not on open), and outside it a failure would leak the just-opened @@ -143,12 +150,13 @@ export function createClient(path = ':memory:'): DbClient { // It was not: `createClient` is the shipping open path. (Setting `busy_timeout` first was measured too, // and does NOT help — 15/25 with it first against 17/25 with it second — so the pragma order is left as // it was rather than changed on a plausible-sounding theory.) - withBusyRetry(() => opened.pragma('journal_mode = WAL')); - opened.pragma('foreign_keys = ON'); // SQLite does not enforce FKs per connection by default - opened.pragma('busy_timeout = 5000'); // wait up to 5s for a writer lock instead of erroring - opened.pragma('synchronous = NORMAL'); // the recommended durability/throughput trade-off with WAL - opened.pragma('secure_delete = ON'); + withBusyRetry(() => withOwnedSqlite(() => opened.pragma('journal_mode = WAL'))); + withOwnedSqlite(() => opened.pragma('foreign_keys = ON')); + withOwnedSqlite(() => opened.pragma('busy_timeout = 5000')); + withOwnedSqlite(() => opened.pragma('synchronous = NORMAL')); + withOwnedSqlite(() => opened.pragma('secure_delete = ON')); const db = drizzle(opened, { schema }); + rememberOwnedClient(db, opened); return { db, sqlite: opened, path }; } catch (err) { // Close what we opened before propagating, or the connection leaks on every failed setup. @@ -161,9 +169,11 @@ export function createClient(path = ':memory:'): DbClient { // and this message is surfaced verbatim to the user by the CLI's history openers — interpolating it would // put the OS username back in user-facing output, which is the very thing moving the path to `.path` was // for. The driver's own error is preserved in full via `cause` for diagnosis. - throw new DbOpenError('open_failed', 'could not open the SQLite database', path, { + const failure = new DbOpenError('open_failed', 'could not open the SQLite database', path, { cause: err, }); + transferStorageFailure(failure, err); + throw failure; } } @@ -197,7 +207,11 @@ export interface RunMigrationsOptions { export function runMigrations(db: Db, options: RunMigrationsOptions = {}): SessionEffectCheckpoint { requireSessionEffectTransactionOwnership(db); return withMigrationLock(options.dbPath, () => { - migrate(db, { migrationsFolder: MIGRATIONS_DIR }); + migrateWithOwnedStorageFacts(db, MIGRATIONS_DIR); + if (hasOwnedClient(db)) { + withOwnedSqlite(() => initializeSessionEffectTurnKeys(db)); + return withOwnedSqlite(() => clearLegacySessionEffectResults(db)); + } initializeSessionEffectTurnKeys(db); return clearLegacySessionEffectResults(db); }); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 90977a69..6839664e 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -65,6 +65,7 @@ export { type DbOpenErrorCode, type RunMigrationsOptions, } from './client.js'; +export { isOwnedStorageFailure } from './storage-failure-facts.js'; // Session persistence (1.X) — the directly-stored, append-only transcript layer over the // agent_sessions + session_messages tables. The domain ↔ row mappers double as the validation diff --git a/packages/db/src/media-store-marker-acquisition.test.ts b/packages/db/src/media-store-marker-acquisition.test.ts index eb5e2f8a..f9dd7bac 100644 --- a/packages/db/src/media-store-marker-acquisition.test.ts +++ b/packages/db/src/media-store-marker-acquisition.test.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import { existsSync, + chmodSync, linkSync, mkdirSync, mkdtempSync, @@ -21,6 +22,7 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { FilesystemMediaStore } from './media-store.js'; import { MediaStoreFilesystemError } from './media-store-paths.js'; +import { isOwnedStorageFailure } from './storage-failure-facts.js'; const hooks = vi.hoisted(() => ({ afterOpen: vi.fn<(file: FileHandle, path: string) => void>(), @@ -46,6 +48,28 @@ type Scope = 'maintenance' | 'recovery'; const scopes: ReadonlyArray = ['maintenance', 'recovery']; const absentPid = 2147483647; const bytes = new Uint8Array([1, 2]); + +it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'retains real marker deletion permission failure with private storage origin', + async () => { + const store = new FilesystemMediaStore(root); + hooks.beforeUnlink.mockImplementation((path) => { + if (marker('maintenance', path)) chmodSync(root, 0o500); + }); + try { + const error: unknown = await store + .pruneLegacyTemporaries({ legacyOffline: true, force: true }) + .catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(MediaStoreFilesystemError); + expect(isOwnedStorageFailure(error), 'DB_ORIGIN_MEDIA_DELETE_BOUNDARY').toBe(true); + expect( + readdirSync(root).filter((name) => name.startsWith('media-maintenance-v1.')), + ).toHaveLength(1); + } finally { + chmodSync(root, 0o700); + } + }, +); let base: string; let root: string; diff --git a/packages/db/src/media-store-paths.ts b/packages/db/src/media-store-paths.ts index 5e246763..7fb5c534 100644 --- a/packages/db/src/media-store-paths.ts +++ b/packages/db/src/media-store-paths.ts @@ -2,6 +2,7 @@ import { RelaviumError } from '@relavium/shared'; import { lstatSync, mkdirSync, realpathSync, statSync, type Stats } from 'node:fs'; import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { transferStorageFailure, withOwnedFilesystem } from './storage-failure-facts.js'; /** Reason-only host failure; native errors may contain private absolute filenames. */ export class MediaStoreFilesystemError extends RelaviumError { @@ -51,16 +52,16 @@ export class MediaStorePaths { try { let entry: Stats; try { - entry = lstatSync(this.#lexical); + entry = withOwnedFilesystem(() => lstatSync(this.#lexical)); } catch (error) { if (!missing(error)) throw error; if (this.#pin !== undefined) throw new MediaStoreFilesystemError('unsafe_path'); if (!create) return undefined; - mkdirSync(this.#lexical, { recursive: true, mode: 0o700 }); - entry = lstatSync(this.#lexical); + withOwnedFilesystem(() => mkdirSync(this.#lexical, { recursive: true, mode: 0o700 })); + entry = withOwnedFilesystem(() => lstatSync(this.#lexical)); } - const canonical = realpathSync(this.#lexical); - const info = statSync(canonical); + const canonical = withOwnedFilesystem(() => realpathSync(this.#lexical)); + const info = withOwnedFilesystem(() => statSync(canonical)); if (!info.isDirectory()) throw new MediaStoreFilesystemError('unsafe_path'); const current = { canonical, @@ -97,12 +98,12 @@ export class MediaStorePaths { try { let info: Stats; try { - info = lstatSync(dir); + info = withOwnedFilesystem(() => lstatSync(dir)); } catch (error) { if (!missing(error)) throw error; if (!create) return undefined; try { - mkdirSync(dir, { mode: 0o700 }); + withOwnedFilesystem(() => mkdirSync(dir, { mode: 0o700 })); } catch (error) { // A cooperating writer may have created the same shard; it still must pass all checks below. if ( @@ -115,12 +116,12 @@ export class MediaStorePaths { ) throw error; } - info = lstatSync(dir); + info = withOwnedFilesystem(() => lstatSync(dir)); } if ( !info.isDirectory() || info.isSymbolicLink() || - realpathSync(dir) !== dir || + withOwnedFilesystem(() => realpathSync(dir)) !== dir || !within(root, dir) ) { throw new MediaStoreFilesystemError('unsafe_path'); @@ -169,7 +170,7 @@ export class MediaStorePaths { fileSnapshot(path: string): Stats | undefined { this.assertParent(path); try { - const info = lstatSync(path); + const info = withOwnedFilesystem(() => lstatSync(path)); if (!info.isFile() || info.isSymbolicLink() || (info.nlink !== 0 && info.nlink !== 1)) throw new MediaStoreFilesystemError('unsafe_path'); return info; @@ -214,8 +215,11 @@ export class MediaStorePaths { } error(error: unknown): MediaStoreFilesystemError { - return error instanceof MediaStoreFilesystemError - ? error - : new MediaStoreFilesystemError(missing(error) ? 'missing' : 'io'); + const failure = + error instanceof MediaStoreFilesystemError + ? error + : new MediaStoreFilesystemError(missing(error) ? 'missing' : 'io'); + transferStorageFailure(failure, error); + return failure; } } diff --git a/packages/db/src/media-store-temporaries.ts b/packages/db/src/media-store-temporaries.ts index 80f52645..2af51f62 100644 --- a/packages/db/src/media-store-temporaries.ts +++ b/packages/db/src/media-store-temporaries.ts @@ -6,6 +6,7 @@ import { mkdir, open, rename, rmdir, unlink } from 'node:fs/promises'; import { join } from 'node:path'; import { MediaStoreFilesystemError, MediaStorePaths } from './media-store-paths.js'; +import { withOwnedFilesystem, withOwnedFilesystemAsync } from './storage-failure-facts.js'; export type MediaTemporaryRetentionReason = | 'too_young' @@ -254,7 +255,7 @@ export class MediaStoreTemporaries { private entries(path: string): string[] { this.paths.root(false); try { - const names = readdirSync(path); + const names = withOwnedFilesystem(() => readdirSync(path)); this.paths.root(false); return names; } catch (error) { @@ -275,14 +276,16 @@ export class MediaStoreTemporaries { assertOwned: (identity: Stats) => void, cleanup: (identity: Stats) => Promise, ): Promise { - const file = await open( - path, - constants.O_WRONLY | - constants.O_CREAT | - constants.O_EXCL | - constants.O_NOFOLLOW | - constants.O_NONBLOCK, - 0o600, + const file = await withOwnedFilesystemAsync(() => + open( + path, + constants.O_WRONLY | + constants.O_CREAT | + constants.O_EXCL | + constants.O_NOFOLLOW | + constants.O_NONBLOCK, + 0o600, + ), ).catch((error: unknown) => { throw this.paths.error(error); }); @@ -291,12 +294,12 @@ export class MediaStoreTemporaries { let cleanupError: MediaStoreFilesystemError | undefined; try { try { - identity = await file.stat().catch((error: unknown) => { + identity = await withOwnedFilesystemAsync(() => file.stat()).catch((error: unknown) => { throw this.paths.error(error); }); } finally { try { - await file.close(); + await withOwnedFilesystemAsync(() => file.close()); } catch (error) { cleanupError = this.paths.error(error); } @@ -326,18 +329,18 @@ export class MediaStoreTemporaries { } private async removeOwned(path: string, identity: Stats): Promise { this.paths.assertOwnedFile(path, identity); - await unlink(path).catch((error: unknown) => { + await withOwnedFilesystemAsync(() => unlink(path)).catch((error: unknown) => { throw this.paths.error(error); }); } private directory(root: string, name: string, identity?: Stats): Stats { this.paths.root(false); const path = join(root, name); - const info = lstatSync(path); + const info = withOwnedFilesystem(() => lstatSync(path)); if ( !info.isDirectory() || info.isSymbolicLink() || - realpathSync(path) !== path || + withOwnedFilesystem(() => realpathSync(path)) !== path || (identity !== undefined && !same(info, identity)) ) throw new MediaStoreFilesystemError('unsafe_path'); @@ -347,7 +350,7 @@ export class MediaStoreTemporaries { private child(root: string, name: string, identity: Stats, child: string): Stats | undefined { this.directory(root, name, identity); try { - const info = lstatSync(join(root, name, child)); + const info = withOwnedFilesystem(() => lstatSync(join(root, name, child))); if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) throw new MediaStoreFilesystemError('unsafe_path'); return info; @@ -358,7 +361,7 @@ export class MediaStoreTemporaries { } /** Exact claim identity supplements, rather than replaces, the existing cooperative path floor. */ private nativeFile(path: string, expected: Stats): NativeIdentity { - const info = lstatSync(path, { bigint: true }); + const info = withOwnedFilesystem(() => lstatSync(path, { bigint: true })); if ( !info.isFile() || info.isSymbolicLink() || @@ -380,14 +383,14 @@ export class MediaStoreTemporaries { const name = `media-claim-v2.${pid}.${nonce}.${original.dev}.${original.ino}.${process.pid}.${randomUUID()}`; const dir = join(root, name); this.paths.root(false); - await mkdir(dir, { mode: 0o700 }); // Exclusive mkdir: never overwrite another claimant's target. + await withOwnedFilesystemAsync(() => mkdir(dir, { mode: 0o700 })); // Exclusive creation. const identity = this.directory(root, name); try { this.paths.assertOwnedFile(source, info); if (!sameNative(this.nativeFile(source, info), original)) throw new MediaStoreFilesystemError('unsafe_path'); this.directory(root, name, identity); - await rename(source, join(dir, 'blob')); + await withOwnedFilesystemAsync(() => rename(source, join(dir, 'blob'))); const moved = this.child(root, name, identity, 'blob'); if ( moved === undefined || @@ -395,7 +398,7 @@ export class MediaStoreTemporaries { !sameNative(this.nativeFile(join(dir, 'blob'), moved), original) ) throw new MediaStoreFilesystemError('unsafe_path'); - await unlink(join(dir, 'blob')); + await withOwnedFilesystemAsync(() => unlink(join(dir, 'blob'))); return true; } catch (error) { if ( @@ -410,7 +413,7 @@ export class MediaStoreTemporaries { throw error; } finally { this.directory(root, name, identity); - if (this.entries(dir).length === 0) await rmdir(dir); + if (this.entries(dir).length === 0) await withOwnedFilesystemAsync(() => rmdir(dir)); } } @@ -472,7 +475,7 @@ export class MediaStoreTemporaries { const current = this.child(root, name, identity, entry); if (current === undefined) continue; if (!same(current, stale)) return 'identity_changed'; - await unlink(join(dir, entry)); + await withOwnedFilesystemAsync(() => unlink(join(dir, entry))); } } const blob = this.child(root, name, identity, 'blob'); @@ -488,7 +491,7 @@ export class MediaStoreTemporaries { if (current === undefined || !same(current, blob)) return 'identity_changed'; if (exact && !sameNative(this.nativeFile(join(dir, 'blob'), current), original)) return 'identity_changed'; - await unlink(join(dir, 'blob')); + await withOwnedFilesystemAsync(() => unlink(join(dir, 'blob'))); } return undefined; })(); @@ -532,7 +535,7 @@ export class MediaStoreTemporaries { markerInfo: Stats, ): Promise { this.assertRecoveryMarker(root, name, identity, marker, markerInfo); - await unlink(join(root, name, marker)); + await withOwnedFilesystemAsync(() => unlink(join(root, name, marker))); } private async cleanupRecovery( root: string, @@ -546,7 +549,7 @@ export class MediaStoreTemporaries { this.directory(root, name, identity); const dir = join(root, name); if (this.entries(dir).length !== 0) return false; - await rmdir(dir); + await withOwnedFilesystemAsync(() => rmdir(dir)); return true; } catch (error) { // After our own identity-checked marker removal, a cooperating peer may win diff --git a/packages/db/src/migrate-lock.ts b/packages/db/src/migrate-lock.ts index fd29da07..4060b248 100644 --- a/packages/db/src/migrate-lock.ts +++ b/packages/db/src/migrate-lock.ts @@ -1,4 +1,5 @@ import Database from 'better-sqlite3'; +import { withOwnedSqlite } from './storage-failure-facts.js'; /** * Cross-process serialization for the `runMigrations` batch @@ -78,7 +79,10 @@ export function withMigrationLock( return runReconciling(run); } try { - lock.pragma(`busy_timeout = ${deps.timeoutMs ?? LOCK_TIMEOUT_MS}`); + const configureTimeout = (): unknown => + lock.pragma(`busy_timeout = ${deps.timeoutMs ?? LOCK_TIMEOUT_MS}`); + if (deps.openLock === undefined) withOwnedSqlite(configureTimeout); + else configureTimeout(); try { // The acquisition. `BEGIN EXCLUSIVE` is the only statement here that can block or fail on contention. lock.exec('BEGIN EXCLUSIVE'); @@ -98,6 +102,7 @@ export function withMigrationLock( } } } finally { - lock.close(); + if (deps.openLock === undefined) withOwnedSqlite(() => lock.close()); + else lock.close(); } } diff --git a/packages/db/src/migration-storage-origin.test.ts b/packages/db/src/migration-storage-origin.test.ts new file mode 100644 index 00000000..ec2a0279 --- /dev/null +++ b/packages/db/src/migration-storage-origin.test.ts @@ -0,0 +1,90 @@ +import Database from 'better-sqlite3'; +import { DrizzleError, DrizzleQueryError } from 'drizzle-orm'; +import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; +import { afterEach, expect, it, vi } from 'vitest'; + +import { createClient, type DbClient } from './client.js'; +import { migrateWithOwnedStorageFacts } from './migration-storage-origin.js'; +import { isOwnedStorageFailure } from './storage-failure-facts.js'; + +vi.mock('drizzle-orm/better-sqlite3/migrator', async (original) => { + const native = await original(); + return { ...native, migrate: vi.fn(native.migrate) }; +}); +const clients: DbClient[] = []; +afterEach(() => { + vi.mocked(migrate).mockReset(); + for (const client of clients.splice(0)) if (client.sqlite.open) client.sqlite.close(); +}); + +function failureAtMigration(error: unknown): unknown { + const client = createClient(); + clients.push(client); + vi.mocked(migrate).mockImplementationOnce(() => { + throw error; + }); + try { + migrateWithOwnedStorageFacts(client.db, 'unused-fixed-fixture'); + } catch (actual) { + return actual; + } + throw new Error('missing fixture failure'); +} + +it('only the exact escaping driver wrapper receives its own native storage fact', () => { + const error = new DrizzleError({ cause: new Database.SqliteError('PRIVATE', 'SQLITE_FULL') }); + expect(isOwnedStorageFailure(error)).toBe(false); + expect(failureAtMigration(error)).toBe(error); + expect(isOwnedStorageFailure(error)).toBe(true); + const later = new Error('PRIVATE', { cause: error }); + expect(failureAtMigration(later)).toBe(later); + expect(isOwnedStorageFailure(later)).toBe(false); +}); + +it('does not traverse nested causes, foreign wrappers, names or copied code shapes', () => { + const native = new Database.SqliteError('PRIVATE', 'SQLITE_READONLY'); + class ForeignDrizzleError extends DrizzleError {} + class ForeignSqliteError extends Database.SqliteError {} + // The driver's constructor deliberately returns a base instance even for new.target subclasses. + const foreignNative = new Database.SqliteError('PRIVATE', 'SQLITE_READONLY'); + Object.setPrototypeOf(foreignNative, ForeignSqliteError.prototype); + for (const error of [ + new Error('PRIVATE', { cause: new DrizzleError({ cause: native }) }), + new DrizzleError({ cause: new Error('PRIVATE', { cause: native }) }), + new DrizzleQueryError('PRIVATE', [], native), + new ForeignDrizzleError({ cause: native }), + new DrizzleError({ cause: foreignNative }), + { name: 'DrizzleError', cause: native }, + new DrizzleError({ cause: { name: 'SqliteError', code: 'SQLITE_READONLY' } }), + new DrizzleError({ cause: new Database.SqliteError('PRIVATE', 'SQLITE_IOERR_UNREVIEWED') }), + ]) { + expect(failureAtMigration(error)).toBe(error); + expect(isOwnedStorageFailure(error)).toBe(false); + } +}); + +it('never invokes accessor-backed causes/codes or hostile proxy traps as diagnostic data', () => { + const getter = vi.fn(() => { + throw new Error('PRIVATE'); + }); + const accessorCause = Object.defineProperty(new DrizzleError({}), 'cause', { get: getter }); + const native = Object.defineProperty(new Database.SqliteError('PRIVATE', 'SQLITE_FULL'), 'code', { + get: getter, + }); + const inheritedCause = new DrizzleError({}); + Reflect.deleteProperty(inheritedCause, 'cause'); + const proxy = new Proxy( + {}, + { + getPrototypeOf: () => { + throw new Error('PRIVATE'); + }, + get: getter, + }, + ); + for (const error of [accessorCause, new DrizzleError({ cause: native }), inheritedCause, proxy]) { + expect(failureAtMigration(error)).toBe(error); + expect(isOwnedStorageFailure(error)).toBe(false); + } + expect(getter).not.toHaveBeenCalled(); +}); diff --git a/packages/db/src/migration-storage-origin.ts b/packages/db/src/migration-storage-origin.ts new file mode 100644 index 00000000..665c761b --- /dev/null +++ b/packages/db/src/migration-storage-origin.ts @@ -0,0 +1,42 @@ +import Database from 'better-sqlite3'; +import { DrizzleError } from 'drizzle-orm'; +import { migrate } from 'drizzle-orm/better-sqlite3/migrator'; + +import type { Db } from './client.js'; +import { + hasOwnedClient, + markNativeStorageFailure, + transferStorageFailure, +} from './storage-failure-facts.js'; + +/** Fixed owned migration boundary; the installed migrator and error identity stay unchanged. */ +export function migrateWithOwnedStorageFacts(db: Db, migrationsFolder: string): void { + try { + migrate(db, { migrationsFolder }); + } catch (error) { + if (hasOwnedClient(db)) { + markNativeStorageFailure(error); + try { + // The sync installed driver creates exactly this one public wrapper. Never walk a cause tree. + if ( + error instanceof DrizzleError && + Object.getPrototypeOf(error) === DrizzleError.prototype + ) { + const descriptor = Object.getOwnPropertyDescriptor(error, 'cause'); + const cause: unknown = + descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined; + if ( + cause instanceof Database.SqliteError && + Object.getPrototypeOf(cause) === Database.SqliteError.prototype + ) { + markNativeStorageFailure(cause); + transferStorageFailure(error, cause); + } + } + } catch { + // An unexpected wrapper shape supplies no authority and cannot mask the primary failure. + } + } + throw error; + } +} diff --git a/packages/db/src/session-effect-privacy.ts b/packages/db/src/session-effect-privacy.ts index eaca2b29..d9bf6679 100644 --- a/packages/db/src/session-effect-privacy.ts +++ b/packages/db/src/session-effect-privacy.ts @@ -4,6 +4,11 @@ import { and, gte, isNotNull, lt } from 'drizzle-orm'; import type { Db } from './client.js'; import { withBusyRetry } from './retry.js'; +import { + hasOwnedClient, + markNativeStorageFailure, + transferStorageFailure, +} from './storage-failure-facts.js'; import { runEffects } from './schema.js'; /** A busy reader postpones physical erasure; logical session-result suppression still holds. */ @@ -40,7 +45,12 @@ export function checkpointSessionEffectPrivacy(db: Db): SessionEffectCheckpoint ) { return 'deferred'; } - throw new SessionEffectPrivacyError('checkpoint_failed'); + const failure = new SessionEffectPrivacyError('checkpoint_failed'); + if (hasOwnedClient(db)) { + markNativeStorageFailure(error); + transferStorageFailure(failure, error); + } + throw failure; } if (Array.isArray(result) && result.length === 1) { const row: unknown = result[0]; diff --git a/packages/db/src/storage-failure-facts.ts b/packages/db/src/storage-failure-facts.ts new file mode 100644 index 00000000..6a2007be --- /dev/null +++ b/packages/db/src/storage-failure-facts.ts @@ -0,0 +1,167 @@ +import Database from 'better-sqlite3'; +import { getSystemErrorName } from 'node:util'; + +// Authority is private identity, not a public error constructor, field, cause or message. +const storageFailures = new WeakSet(); +const clients = new WeakMap(); + +/** Read-only origin fact supplied at an owned DB/filesystem operation (ADR-0110). */ +export function isOwnedStorageFailure(value: unknown): boolean { + return typeof value === 'object' && value !== null && storageFailures.has(value); +} + +/** Package-internal: copy existing authority, never infer it from an arbitrary wrapped cause. */ +export function transferStorageFailure(target: object, source: unknown): void { + if (isOwnedStorageFailure(source)) storageFailures.add(target); +} + +function ownValue(value: object, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined; +} + +// Explicit native vocabulary from the installed SQLite header; unknown future extensions fail closed. +const SQLITE_STORAGE_CODES = new Set([ + 'SQLITE_PERM', + 'SQLITE_BUSY', + 'SQLITE_LOCKED', + 'SQLITE_READONLY', + 'SQLITE_IOERR', + 'SQLITE_CORRUPT', + 'SQLITE_FULL', + 'SQLITE_CANTOPEN', + 'SQLITE_NOTADB', + 'SQLITE_BUSY_RECOVERY', + 'SQLITE_BUSY_SNAPSHOT', + 'SQLITE_BUSY_TIMEOUT', + 'SQLITE_LOCKED_SHAREDCACHE', + 'SQLITE_LOCKED_VTAB', + 'SQLITE_CANTOPEN_NOTEMPDIR', + 'SQLITE_CANTOPEN_ISDIR', + 'SQLITE_CANTOPEN_FULLPATH', + 'SQLITE_CANTOPEN_CONVPATH', + 'SQLITE_CANTOPEN_DIRTYWAL', + 'SQLITE_CANTOPEN_SYMLINK', + 'SQLITE_CORRUPT_VTAB', + 'SQLITE_CORRUPT_SEQUENCE', + 'SQLITE_CORRUPT_INDEX', + 'SQLITE_READONLY_RECOVERY', + 'SQLITE_READONLY_CANTLOCK', + 'SQLITE_READONLY_ROLLBACK', + 'SQLITE_READONLY_DBMOVED', + 'SQLITE_READONLY_CANTINIT', + 'SQLITE_READONLY_DIRECTORY', + 'SQLITE_IOERR_READ', + 'SQLITE_IOERR_SHORT_READ', + 'SQLITE_IOERR_WRITE', + 'SQLITE_IOERR_FSYNC', + 'SQLITE_IOERR_DIR_FSYNC', + 'SQLITE_IOERR_TRUNCATE', + 'SQLITE_IOERR_FSTAT', + 'SQLITE_IOERR_UNLOCK', + 'SQLITE_IOERR_RDLOCK', + 'SQLITE_IOERR_DELETE', + 'SQLITE_IOERR_BLOCKED', + 'SQLITE_IOERR_NOMEM', + 'SQLITE_IOERR_ACCESS', + 'SQLITE_IOERR_CHECKRESERVEDLOCK', + 'SQLITE_IOERR_LOCK', + 'SQLITE_IOERR_CLOSE', + 'SQLITE_IOERR_DIR_CLOSE', + 'SQLITE_IOERR_SHMOPEN', + 'SQLITE_IOERR_SHMSIZE', + 'SQLITE_IOERR_SHMLOCK', + 'SQLITE_IOERR_SHMMAP', + 'SQLITE_IOERR_SEEK', + 'SQLITE_IOERR_DELETE_NOENT', + 'SQLITE_IOERR_MMAP', + 'SQLITE_IOERR_GETTEMPPATH', + 'SQLITE_IOERR_CONVPATH', + 'SQLITE_IOERR_VNODE', + 'SQLITE_IOERR_AUTH', + 'SQLITE_IOERR_BEGIN_ATOMIC', + 'SQLITE_IOERR_COMMIT_ATOMIC', + 'SQLITE_IOERR_ROLLBACK_ATOMIC', + 'SQLITE_IOERR_DATA', + 'SQLITE_IOERR_CORRUPTFS', + 'SQLITE_IOERR_IN_PAGE', + 'SQLITE_IOERR_BADKEY', + 'SQLITE_IOERR_CODEC', +]); + +/** Only call at the actual owned native operation, never from a generic error classifier. */ +export function markNativeStorageFailure(value: unknown): void { + try { + if ( + value instanceof Database.SqliteError && + Object.getPrototypeOf(value) === Database.SqliteError.prototype + ) { + const code = ownValue(value, 'code'); + if (typeof code === 'string' && SQLITE_STORAGE_CODES.has(code)) storageFailures.add(value); + } + } catch { + // A malformed/native-facade error is not provenance. Preserve the original escaping fault. + } +} + +/** The callback must be exactly a native SQLite operation owned by this package. */ +export function withOwnedSqlite(operation: () => T): T { + try { + return operation(); + } catch (error) { + markNativeStorageFailure(error); + throw error; + } +} + +/** Node's native errno must agree with its code; a code-shaped foreign object is insufficient. */ +function markFilesystemStorageFailure(value: unknown): void { + try { + if (!(value instanceof Error) || Object.getPrototypeOf(value) !== Error.prototype) return; + const code = ownValue(value, 'code'); + const errno = ownValue(value, 'errno'); + if ( + (code === 'EACCES' || code === 'EPERM' || code === 'ENOSPC' || code === 'EROFS') && + typeof errno === 'number' && + Number.isSafeInteger(errno) && + errno < 0 && + typeof ownValue(value, 'syscall') === 'string' && + getSystemErrorName(errno) === code + ) + storageFailures.add(value); + } catch { + // Native type/descriptor disagreement must not replace the operation's original failure. + } +} + +/** Package-internal wrappers: only actual filesystem calls may grant this authority. */ +export function withOwnedFilesystem(operation: () => T): T { + try { + return operation(); + } catch (error) { + markFilesystemStorageFailure(error); + throw error; + } +} + +export async function withOwnedFilesystemAsync(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + markFilesystemStorageFailure(error); + throw error; + } +} + +export function rememberOwnedClient(db: object, sqlite: Database.Database): void { + clients.set(db, sqlite); +} + +export function hasOwnedClient(db: object): boolean { + try { + const sqlite = clients.get(db); + return sqlite !== undefined && ownValue(db, '$client') === sqlite; + } catch { + return false; + } +} diff --git a/packages/db/src/storage-origin.test.ts b/packages/db/src/storage-origin.test.ts new file mode 100644 index 00000000..6f10eb0b --- /dev/null +++ b/packages/db/src/storage-origin.test.ts @@ -0,0 +1,216 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import Database from 'better-sqlite3'; +import { DrizzleError } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createClient, DbOpenError, runMigrations, type DbClient } from './client.js'; +import { isOwnedStorageFailure } from './index.js'; +import { FilesystemMediaStore } from './media-store.js'; +import { MediaStoreFilesystemError, MediaStorePaths } from './media-store-paths.js'; +import { migrateWithOwnedStorageFacts } from './migration-storage-origin.js'; +import * as schema from './schema.js'; +import { + checkpointSessionEffectPrivacy, + SessionEffectPrivacyError, +} from './session-effect-privacy.js'; + +let root: string; +const clients: DbClient[] = []; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'relavium-storage-origin-')); +}); +afterEach(() => { + vi.restoreAllMocks(); + for (const client of clients.splice(0)) if (client.sqlite.open) client.sqlite.close(); + rmSync(root, { recursive: true, force: true }); +}); +function client(): DbClient { + const value = createClient(); + clients.push(value); + return value; +} +function caught(operation: () => unknown): unknown { + try { + operation(); + } catch (error) { + return error; + } + throw new Error('expected a failure'); +} +function migrations(sqlText?: string): string { + const folder = join(root, 'migrations'); + mkdirSync(join(folder, 'meta'), { recursive: true }); + writeFileSync( + join(folder, 'meta', '_journal.json'), + JSON.stringify({ + version: '7', + dialect: 'sqlite', + entries: [{ idx: 0, version: '6', when: 1, tag: '0000_fixture', breakpoints: true }], + }), + ); + if (sqlText !== undefined) writeFileSync(join(folder, '0000_fixture.sql'), sqlText); + return folder; +} + +describe('owned storage origin (ADR-0110)', () => { + it('marks an actual corrupt database open while retaining family, cause and path-free message', () => { + const path = join(root, 'corrupt.db'); + writeFileSync(path, 'not a SQLite file'); + const error = caught(() => createClient(path)); + expect(error).toBeInstanceOf(DbOpenError); + expect(isOwnedStorageFailure(error), 'DB_ORIGIN_OPEN_WRAPPER').toBe(true); + if (!(error instanceof DbOpenError)) throw new Error('wrong failure family'); + expect(error.code).toBe('open_failed'); + expect(error.path).toBe(path); + expect(error.message).toBe('could not open the SQLite database'); + expect(error.cause).toBeInstanceOf(Database.SqliteError); + expect(isOwnedStorageFailure({ ...error })).toBe(false); + }); + + it('marks the real installed migrator READONLY wrapper without replacing any native operation', () => { + const value = client(); + const names = ['prepare', 'pragma', 'transaction']; + const methods = names.map((name) => Object.getOwnPropertyDescriptor(Database.prototype, name)); + const ownMethods = names.map((name) => Object.getOwnPropertyDescriptor(value.sqlite, name)); + value.sqlite.pragma('query_only = ON'); + const error = caught(() => runMigrations(value.db)); + expect(error).toBeInstanceOf(DrizzleError); + expect(isOwnedStorageFailure(error), 'DB_ORIGIN_MIGRATION_WRAPPER').toBe(true); + if (!(error instanceof DrizzleError)) throw new Error('wrong driver wrapper'); + const native: unknown = Object.getOwnPropertyDescriptor(error, 'cause')?.value; + expect(native).toBeInstanceOf(Database.SqliteError); + if (!(native instanceof Database.SqliteError)) throw new Error('wrong native failure'); + expect(native.code).toBe('SQLITE_READONLY'); + expect(names.map((name) => Object.getOwnPropertyDescriptor(Database.prototype, name))).toEqual( + methods, + ); + expect(names.map((name) => Object.getOwnPropertyDescriptor(value.sqlite, name))).toEqual( + ownMethods, + ); + value.sqlite.pragma('query_only = OFF'); + expect(runMigrations(value.db)).toBe('complete'); // No sticky failure poisons a recovered invocation. + expect(runMigrations(value.db)).toBe('complete'); + }); + + it.each(['missing-file', 'malformed-journal', 'invalid-sql'] as const)( + 'does not grant storage to actual migration %s failure', + (kind) => { + const value = client(); + const folder = migrations(kind === 'invalid-sql' ? 'THIS IS NOT SQL;' : undefined); + if (kind === 'malformed-journal') writeFileSync(join(folder, 'meta', '_journal.json'), '{'); + const error = caught(() => migrateWithOwnedStorageFacts(value.db, folder)); + expect(isOwnedStorageFailure(error)).toBe(false); + if (kind === 'invalid-sql') { + expect(error).toBeInstanceOf(DrizzleError); + const cause: unknown = Object.getOwnPropertyDescriptor(error, 'cause')?.value; + expect(cause).toBeInstanceOf(Database.SqliteError); + if (!(cause instanceof Database.SqliteError)) throw new Error('missing native error'); + expect(cause.code).toBe('SQLITE_ERROR'); + } + }, + ); + + it('does not grant provenance to a real foreign Drizzle database', () => { + const sqlite = new Database(':memory:'); + try { + sqlite.pragma('query_only = ON'); + const db = drizzle(sqlite, { schema }); + const error = caught(() => runMigrations(db)); + expect(error).toBeInstanceOf(DrizzleError); + expect(isOwnedStorageFailure(error), 'DB_ORIGIN_FOREIGN_CLIENT_REFUSAL').toBe(false); + } finally { + sqlite.close(); + } + }); + + it('public constructors, copies, lookalikes and hostile accessors grant no facts', () => { + const native = new Database.SqliteError('PRIVATE', 'SQLITE_READONLY'); + const wrapper = new DrizzleError({ cause: native }); + const getter = vi.fn(() => native); + const accessor = Object.defineProperty(new DrizzleError({}), 'cause', { get: getter }); + const hostile = new Proxy({}, { get: getter, getPrototypeOf: getter }); + for (const error of [ + native, + wrapper, + accessor, + hostile, + { ...wrapper }, + { code: 'EACCES' }, + new DbOpenError('open_failed', 'PRIVATE', '/PRIVATE', { cause: native }), + new SessionEffectPrivacyError('checkpoint_failed'), + new MediaStoreFilesystemError('io'), + new Error('PRIVATE', { cause: wrapper }), + ]) + expect(isOwnedStorageFailure(error), 'DB_ORIGIN_PUBLIC_CONSTRUCTOR_REFUSAL').toBe(false); + expect(getter).not.toHaveBeenCalled(); + const uri = caught(() => createClient('file:private')); + expect(uri).toBeInstanceOf(DbOpenError); + expect(isOwnedStorageFailure(uri)).toBe(false); + }); + + it('marks checkpoint storage at its actual pragma catch, keeping unknown/result errors unmarked', () => { + const value = client(); + runMigrations(value.db); + const pragma = vi.spyOn(value.sqlite, 'pragma'); + // Synthetic native fault at the actual owned operation, not a physical disk-failure claim. + pragma.mockImplementation(() => { + throw new Database.SqliteError('PRIVATE', 'SQLITE_IOERR_FSYNC'); + }); + const failure = caught(() => checkpointSessionEffectPrivacy(value.db)); + expect(failure).toBeInstanceOf(SessionEffectPrivacyError); + if (!(failure instanceof SessionEffectPrivacyError)) throw new Error('wrong checkpoint family'); + expect(isOwnedStorageFailure(failure), 'DB_ORIGIN_CHECKPOINT_WRAPPER').toBe(true); + expect(failure).toMatchObject({ + code: 'checkpoint_failed', + message: 'session effect WAL checkpoint failed', + }); + expect(Object.hasOwn(failure, 'cause')).toBe(false); + pragma.mockImplementation(() => { + throw new Error('PRIVATE'); + }); + expect(isOwnedStorageFailure(caught(() => checkpointSessionEffectPrivacy(value.db)))).toBe( + false, + ); + pragma.mockReturnValue([]); + expect(isOwnedStorageFailure(caught(() => checkpointSessionEffectPrivacy(value.db)))).toBe( + false, + ); + pragma.mockImplementation(() => { + throw new Database.SqliteError('PRIVATE', 'SQLITE_BUSY'); + }); + expect(checkpointSessionEffectPrivacy(value.db)).toBe('deferred'); + }); + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'carries actual native permission failures through DB mkdir and media root conversion', + async () => { + const denied = join(root, 'denied'); + mkdirSync(denied); + chmodSync(denied, 0); + try { + const failure = caught(() => createClient(join(denied, 'new', 'history.db'))); + expect(failure).toBeInstanceOf(DbOpenError); + expect(isOwnedStorageFailure(failure), 'DB_ORIGIN_DB_FS_BOUNDARY').toBe(true); + const media = new FilesystemMediaStore(join(denied, 'media')); + const error: unknown = await media + .pruneLegacyTemporaries({ legacyOffline: true, force: true }) + .catch((cause: unknown) => cause); + expect(error).toBeInstanceOf(MediaStoreFilesystemError); + expect(isOwnedStorageFailure(error), 'DB_ORIGIN_MEDIA_FS_BOUNDARY').toBe(true); + } finally { + chmodSync(denied, 0o700); + } + }, + ); + + it('the public media conversion cannot infer origin from arbitrary errno or internal io', () => { + const paths = new MediaStorePaths(join(root, 'media')); + expect( + isOwnedStorageFailure(paths.error(Object.assign(new Error('PRIVATE'), { code: 'EACCES' }))), + ).toBe(false); + expect(isOwnedStorageFailure(paths.error(new MediaStoreFilesystemError('io')))).toBe(false); + }); +}); From 189e9861deebca530df5b1dcf86ed3389f067723 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 12:23:10 +0300 Subject: [PATCH 083/222] feat(core): expose immutable transient error classifications Share the existing frozen code authority with descriptor-safe surface projections instead of maintaining another transient-code list. Refs: ADR-0110 Co-Authored-By: Codex --- packages/core/src/engine/errors.test.ts | 12 +++++++++++- packages/core/src/engine/errors.ts | 4 ++-- packages/core/src/index.ts | 6 +++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/core/src/engine/errors.test.ts b/packages/core/src/engine/errors.test.ts index 3272a2f1..4bc8cbf9 100644 --- a/packages/core/src/engine/errors.test.ts +++ b/packages/core/src/engine/errors.test.ts @@ -1,8 +1,18 @@ import { describe, expect, it } from 'vitest'; -import { EngineStateError } from './errors.js'; +import { + EngineStateError, + isTransientEngineStateError, + TRANSIENT_ENGINE_STATE_CODES, +} from './errors.js'; describe('EngineStateError', () => { + it('keeps the public transient allowlist immutable and authoritative for API refusals', () => { + expect(Reflect.set(TRANSIENT_ENGINE_STATE_CODES, 0, 'unknown_gate')).toBe(false); + expect(Reflect.set(TRANSIENT_ENGINE_STATE_CODES, 'length', 0)).toBe(false); + expect(isTransientEngineStateError(new EngineStateError('run_owned_elsewhere', ''))).toBe(true); + expect(isTransientEngineStateError(new EngineStateError('unknown_gate', ''))).toBe(false); + }); 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', diff --git a/packages/core/src/engine/errors.ts b/packages/core/src/engine/errors.ts index 9703e03f..0598b915 100644 --- a/packages/core/src/engine/errors.ts +++ b/packages/core/src/engine/errors.ts @@ -55,9 +55,9 @@ export type EngineStateErrorCode = * will fail identically forever. A surface uses this to tell a caller "try again shortly" from "never call * this again" — the CLI maps it to its own exit code (ADR-0079 §7). */ -export const TRANSIENT_ENGINE_STATE_CODES: readonly EngineStateErrorCode[] = [ +export const TRANSIENT_ENGINE_STATE_CODES: readonly EngineStateErrorCode[] = Object.freeze([ 'run_owned_elsewhere', -]; +]); /** Whether an {@link EngineStateError} is worth retrying unchanged. */ export function isTransientEngineStateError(error: EngineStateError): boolean { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 46dc9c8b..4a21698b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -202,7 +202,11 @@ export type { } from './engine/node-executor.js'; // Typed run-loop API-boundary errors (1.N) — narrow on `code`, never on `message`. -export { EngineStateError, isTransientEngineStateError } from './engine/errors.js'; +export { + EngineStateError, + isTransientEngineStateError, + TRANSIENT_ENGINE_STATE_CODES, +} from './engine/errors.js'; // ADR-0080's dispatch-side journal seam, re-exported so a `ToolDispatchContext` consumer can satisfy the // required port from this package alone. The loud unwired variant is what a fixture with no effects passes. From 6b4bda3f6871da2c0726459aae8b43356f238279 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 12:23:10 +0300 Subject: [PATCH 084/222] fix(cli): classify runtime origins and stage native cleanup Distinguish owned storage refusals from unknown infrastructure faults using fixed diagnostics. Add the inert invocation coordinator, live shared deadline and bounded background observer; activate production owners only after the remaining Step 8 integrations. Refs: ADR-0110 Co-Authored-By: Codex --- apps/cli/src/chat/export.test.ts | 38 +- apps/cli/src/chat/export.ts | 31 +- apps/cli/src/commands/fs-invocation.test.ts | 7 +- apps/cli/src/commands/gate.test.ts | 27 ++ apps/cli/src/commands/gate.ts | 7 +- apps/cli/src/commands/media.test.ts | 81 ++++ apps/cli/src/commands/media.ts | 33 +- .../cli/src/commands/provider-headers.test.ts | 7 +- apps/cli/src/commands/run.test.ts | 64 +++- apps/cli/src/commands/run.ts | 11 +- apps/cli/src/db/open.test.ts | 8 +- apps/cli/src/db/open.ts | 38 +- apps/cli/src/history/reader.test.ts | 56 +++ apps/cli/src/history/reader.ts | 12 +- apps/cli/src/history/session-open.ts | 13 +- .../src/process/background-observer.test.ts | 120 ++++++ apps/cli/src/process/background-observer.ts | 38 ++ apps/cli/src/process/errors.test.ts | 180 ++++++++- apps/cli/src/process/errors.ts | 178 ++++----- apps/cli/src/process/exit-observations.ts | 9 + apps/cli/src/process/fixed-diagnostic.test.ts | 49 +++ apps/cli/src/process/fixed-diagnostic.ts | 23 ++ .../fixtures/native-invocation-child.ts | 148 ++++++++ .../src/process/invocation-registry.test.ts | 99 +++++ apps/cli/src/process/invocation-registry.ts | 63 +++- apps/cli/src/process/io.test.ts | 10 +- apps/cli/src/process/io.ts | 8 +- .../process/native-invocation-native.test.ts | 139 +++++++ .../cli/src/process/native-invocation.test.ts | 349 ++++++++++++++++++ apps/cli/src/process/native-invocation.ts | 231 ++++++++++++ apps/cli/src/process/owned-storage.test.ts | 103 ++++++ apps/cli/src/process/owned-storage.ts | 65 ++++ .../src/process/pure-substrate-graph.test.ts | 3 + apps/cli/src/run-media-prune.test.ts | 2 +- apps/cli/src/run.test.ts | 4 +- 35 files changed, 2046 insertions(+), 208 deletions(-) create mode 100644 apps/cli/src/commands/media.test.ts create mode 100644 apps/cli/src/history/reader.test.ts create mode 100644 apps/cli/src/process/background-observer.test.ts create mode 100644 apps/cli/src/process/background-observer.ts create mode 100644 apps/cli/src/process/fixed-diagnostic.test.ts create mode 100644 apps/cli/src/process/fixed-diagnostic.ts create mode 100644 apps/cli/src/process/fixtures/native-invocation-child.ts create mode 100644 apps/cli/src/process/native-invocation-native.test.ts create mode 100644 apps/cli/src/process/native-invocation.test.ts create mode 100644 apps/cli/src/process/native-invocation.ts create mode 100644 apps/cli/src/process/owned-storage.test.ts create mode 100644 apps/cli/src/process/owned-storage.ts diff --git a/apps/cli/src/chat/export.test.ts b/apps/cli/src/chat/export.test.ts index b301159a..05c81b9e 100644 --- a/apps/cli/src/chat/export.test.ts +++ b/apps/cli/src/chat/export.test.ts @@ -215,7 +215,7 @@ describe('exportSession (2.P)', () => { expect(yaml).toContain('type: output'); expect(yaml).not.toContain('type: agent'); // no completed turn ⇒ no agent node }); - it('wraps native write failure behind a safe destination label and preserves cause', () => { + it('wraps unexpected write failure as fixed infrastructure 9 and preserves cause', () => { store.createSession(record()); const cause = new Error(`private native destination ${cwd}`); vi.mocked(writeFileSync).mockImplementationOnce(() => { @@ -227,11 +227,43 @@ describe('exportSession (2.P)', () => { } catch (error) { expect(isCliError(error)).toBe(true); if (!isCliError(error)) throw error; - expect(error.code).toBe('internal'); + expect(error.code).toBe('cli_internal'); + expect(error.exitCode).toBe(9); expect(error.cause).toBe(cause); - expect(error.message).toContain(join('exports', 'fault.yaml')); + expect(error.message).not.toContain(join('exports', 'fault.yaml')); expect(error.message).not.toContain(cwd); expect(error.message).not.toContain('private native'); } }); + it.each(['EACCES', 'EPERM', 'ENOSPC', 'EROFS'])( + 'projects owned native export write %s to fixed storage 1', + (code) => { + store.createSession(record()); + const cause = Object.assign(new Error('PRIVATE_WRITE_BYTES'), { code }); + vi.mocked(writeFileSync).mockImplementationOnce(() => { + throw cause; + }); + try { + exportSession({ store, sessionId: 's1', cwd, force: false }); + expect.fail('write must refuse'); + } catch (error) { + expect(isCliError(error)).toBe(true); + if (!isCliError(error)) throw error; + expect(error.code, 'CLI_ORIGIN_EXPORT_STORAGE').toBe('storage_unavailable'); + expect(error.exitCode).toBe(1); + expect(error.cause).toBe(cause); + expect(error.message).not.toContain('PRIVATE_'); + expect(error.message).not.toContain(cwd); + } + }, + ); + + it('does not promote an errno-shaped store failure outside its owned write boundary', () => { + const failure = Object.assign(new Error('PRIVATE_STORE_FAILURE'), { code: 'ENOSPC' }); + vi.spyOn(store, 'loadFull').mockImplementationOnce(() => { + throw failure; + }); + expect(() => exportSession({ store, sessionId: 's1', cwd, force: false })).toThrow(failure); + expect(vi.mocked(writeFileSync)).not.toHaveBeenCalled(); + }); }); diff --git a/apps/cli/src/chat/export.ts b/apps/cli/src/chat/export.ts index feb83085..e0464f94 100644 --- a/apps/cli/src/chat/export.ts +++ b/apps/cli/src/chat/export.ts @@ -1,4 +1,3 @@ -import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { serializeWorkflow, sessionToWorkflow } from '@relavium/core'; @@ -6,6 +5,11 @@ import type { SessionStore } from '@relavium/db'; import type { AgentSessionRecord } from '@relavium/shared'; import { CliError } from '../process/errors.js'; +import { + mkdirForStorage, + writeFileForStorage, + storageBoundaryError, +} from '../process/owned-storage.js'; import { sourceLabel } from '../process/source-label.js'; /** @@ -76,12 +80,12 @@ export function exportSession(opts: ExportSessionOptions): ExportResult { const label = sourceLabel(opts.cwd, path); try { // Create the parent directory (so `--out exports/wf.yaml` just works) before writing the scaffold. - mkdirSync(dirname(path), { recursive: true }); + mkdirForStorage(dirname(path)); // Atomic create: `wx` fails with EEXIST if the target already exists — no TOCTOU window between a // separate existence check and the write; `w` truncates/overwrites under `--force`. - writeFileSync(path, yaml, { encoding: 'utf8', flag: opts.force ? 'w' : 'wx' }); + writeFileForStorage(path, yaml, opts.force); } catch (err) { - const code = typeof err === 'object' && err !== null && 'code' in err ? err.code : undefined; + const code = exportTargetCode(err); // An existing target without `--force` (EEXIST) is the no-overwrite fault; a structurally-invalid // `--out` (a directory → EISDIR; a file used as a dir → ENOTDIR) is also an INVOCATION fault (exit 2). // Other native write faults retain their cause behind a fixed safe diagnostic. @@ -99,11 +103,7 @@ export function exportSession(opts: ExportSessionOptions): ExportResult { { cause: err }, ); } - throw new CliError( - 'internal', - `could not write ${label}: check destination permissions and available disk space`, - { cause: err }, - ); + throw storageBoundaryError(err); } // The session is append-only; the next event/seq is one past the durable MAX (a fold, not a spread). @@ -111,3 +111,16 @@ export function exportSession(opts: ExportSessionOptions): ExportResult { loaded.messages.reduce((max, m) => Math.max(max, m.sequenceNumber), -1) + 1; return { path, workflowId: definition.workflow.id, sequenceNumber, record: loaded.session }; } + +/** Target-shape refusals are admitted only from the export's actual mkdir/write catch. */ +function exportTargetCode(error: unknown): unknown { + try { + if (typeof error === 'object' && error !== null) { + const descriptor = Object.getOwnPropertyDescriptor(error, 'code'); + if (descriptor !== undefined && 'value' in descriptor) return descriptor.value; + } + } catch { + // An accessor/proxy is not evidence of a native target refusal. + } + return undefined; +} diff --git a/apps/cli/src/commands/fs-invocation.test.ts b/apps/cli/src/commands/fs-invocation.test.ts index 7532513a..e6b6e9a6 100644 --- a/apps/cli/src/commands/fs-invocation.test.ts +++ b/apps/cli/src/commands/fs-invocation.test.ts @@ -176,7 +176,9 @@ describe('real argv → Commander → dispatcher admission (synthetic key bounda }, ); // The injected key boundary deliberately throws: this certifies routing/admission, not a live provider turn. - expect(code).toBe(1); + expect(code).toBe(9); + expect(captured.err()).toContain('unexpected CLI failure'); + expect(captured.err()).not.toContain('synthetic composition boundary reached'); expect(effects.keys, 'FS_CAUSAL_INVOCATION_FORWARD').toBe(1); expect(effects.history).toBe(0); }, @@ -398,7 +400,8 @@ it('forwards real argv grants into a project selector before composition', async config(workspace, `[chat]\nfs_scope="project"\nextra_roots=[${JSON.stringify(outside)}]\n`); const captured = captureIo(); const code = await run(argv('chat', '--cwd', workspace, '--allow-fs-root', outside), captured.io); - expect(code, 'FS_CAUSAL_INVOCATION_FORWARD').toBe(1); + expect(code, 'FS_CAUSAL_INVOCATION_FORWARD').toBe(9); + expect(captured.err()).not.toContain('synthetic composition boundary reached'); expect(effects.keys, 'FS_CAUSAL_INVOCATION_FORWARD').toBe(1); expect(effects.history).toBe(0); }); diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index 09d35fd1..059a6fb6 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -222,6 +222,33 @@ function globalOptions(): GlobalOptions { } describe('gateCommand', () => { + it('classifies actual history corruption as storage 1 before any resume', async () => { + mkdirSync(join(home, '.relavium')); + writeFileSync(join(home, '.relavium', 'history.db'), 'PRIVATE_CORRUPT_HISTORY'); + await expect( + gateCommand( + { runId: 'synthetic', approve: true }, + { io: captureIo().io, global: globalOptions() }, + ), + ).rejects.toMatchObject({ code: 'storage_unavailable', exitCode: 1 }); + }); + + it('does not promote an injected errno-shaped opener failure to storage', async () => { + const cause = Object.assign(new Error('PRIVATE_FOREIGN_OPEN_FAILURE'), { code: 'ENOSPC' }); + await expect( + gateCommand( + { runId: 'synthetic', approve: true }, + { + io: captureIo().io, + global: globalOptions(), + openDb: () => { + throw cause; + }, + }, + ), + ).rejects.toMatchObject({ code: 'cli_internal', exitCode: 9, cause }); + }); + let client: DbClient; let db: Db; diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index 8d8c56f7..c0536508 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -74,6 +74,7 @@ import { selectGatePrompter } from '../gate/select-prompter.js'; import { readSecretFromStdin, type StdinSecretContext } from '../secrets/read-secret.js'; import { parseSecretLines } from '../secrets/parse-secret-lines.js'; import { CliError } from '../process/errors.js'; +import { storageBoundaryError } from '../process/owned-storage.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; @@ -274,11 +275,7 @@ async function resumeGateCommand( try { opened = (deps.openDb ?? openLocalDb)(homeDir); } catch (err) { - throw new CliError( - 'invalid_invocation', - `could not open the run history database: ${err instanceof Error ? err.message : String(err)}`, - { cause: err }, - ); + throw storageBoundaryError(err); } let mcpRuntime: WorkflowMcpRuntime | undefined; diff --git a/apps/cli/src/commands/media.test.ts b/apps/cli/src/commands/media.test.ts new file mode 100644 index 00000000..bfc6b744 --- /dev/null +++ b/apps/cli/src/commands/media.test.ts @@ -0,0 +1,81 @@ +import { chmodSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { FilesystemMediaStore, MediaTemporaryMaintenanceError } from '@relavium/db'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { captureIo } from '../test-support.js'; +import type { GlobalOptions } from '../process/options.js'; +import { pruneTemporariesCommand } from './media.js'; + +let home: string; +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'relavium-media-origin-')); +}); +afterEach(() => { + vi.restoreAllMocks(); + rmSync(home, { recursive: true, force: true }); +}); +const global: GlobalOptions = { + json: true, + color: false, + cwd: '.', + configPath: undefined, + verbosity: 'normal', +}; +const args = { legacyOffline: true, force: true }; + +it('output delivery after successful maintenance is fixed output 9, never maintenance/storage', async () => { + const prune = vi.spyOn(FilesystemMediaStore.prototype, 'pruneLegacyTemporaries'); + const { io } = captureIo(); + const cause = Object.assign(new Error('PRIVATE_OUTPUT_BYTES'), { code: 'ENOSPC' }); + await expect( + pruneTemporariesCommand(args, { + global, + homeDir: home, + io: { + ...io, + writeOut: () => { + throw cause; + }, + }, + }), + ).rejects.toMatchObject({ code: 'output_unavailable', exitCode: 9, cause }); + expect(prune).toHaveBeenCalledOnce(); +}); + +it('known maintenance refusal retains invocation 2 and private cause', async () => { + const cause = new MediaTemporaryMaintenanceError('producer_active'); + vi.spyOn(FilesystemMediaStore.prototype, 'pruneLegacyTemporaries').mockRejectedValueOnce(cause); + await expect( + pruneTemporariesCommand(args, { global, homeDir: home, io: captureIo().io }), + ).rejects.toMatchObject({ code: 'invalid_invocation', exitCode: 2, cause }); +}); + +it.each([ + Object.assign(new Error('PRIVATE_PLUGIN_ERRNO'), { code: 'EACCES' }), + Object.assign(new Error('PRIVATE_IO_INVARIANT'), { + name: 'MediaStoreFilesystemError', + reason: 'io', + }), +])('unowned maintenance failures are fixed infrastructure 9', async (cause) => { + vi.spyOn(FilesystemMediaStore.prototype, 'pruneLegacyTemporaries').mockRejectedValueOnce(cause); + await expect( + pruneTemporariesCommand(args, { global, homeDir: home, io: captureIo().io }), + ).rejects.toMatchObject({ code: 'cli_internal', exitCode: 9, cause }); +}); + +it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'actual unreadable media root retains native storage provenance', + async () => { + const root = join(home, '.relavium', 'media'); + mkdirSync(root, { recursive: true }); + chmodSync(root, 0o000); + try { + await expect( + pruneTemporariesCommand(args, { global, homeDir: home, io: captureIo().io }), + ).rejects.toMatchObject({ code: 'storage_unavailable', exitCode: 1 }); + } finally { + chmodSync(root, 0o700); + } + }, +); diff --git a/apps/cli/src/commands/media.ts b/apps/cli/src/commands/media.ts index 8e920b09..46816875 100644 --- a/apps/cli/src/commands/media.ts +++ b/apps/cli/src/commands/media.ts @@ -2,6 +2,7 @@ import { join } from 'node:path'; import { FilesystemMediaStore, MediaTemporaryMaintenanceError } from '@relavium/db'; import { globalConfigDir } from '../config/paths.js'; import { CliError } from '../process/errors.js'; +import { storageBoundaryError } from '../process/owned-storage.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; @@ -23,23 +24,29 @@ export async function pruneTemporariesCommand( 'Temporary maintenance requires --legacy-offline and --force.', ); } + let report: Awaited>; try { const store = new FilesystemMediaStore(join(globalConfigDir(deps.homeDir), 'media')); - const report = await store.pruneLegacyTemporaries({ legacyOffline: true, force: true }); - if (deps.global.json) deps.io.writeOut(`${stringifyJsonLine(report)}\n`); - else - deps.io.writeOut( - `Temporary maintenance: removed ${report.removed}, retained ${report.retained}.\n${Object.entries( - report.reasons, - ) - .map(([reason, count]) => `${reason}: ${count}\n`) - .join('')}`, - ); - return EXIT_CODES.success; + report = await store.pruneLegacyTemporaries({ legacyOffline: true, force: true }); } catch (error) { if (error instanceof MediaTemporaryMaintenanceError) { - throw new CliError('invalid_invocation', `Temporary maintenance refused: ${error.reason}.`); + throw new CliError('invalid_invocation', `Temporary maintenance refused: ${error.reason}.`, { + cause: error, + }); } - throw new CliError('internal', 'Temporary maintenance failed.'); + throw storageBoundaryError(error); } + const output = deps.global.json + ? `${stringifyJsonLine(report)}\n` + : `Temporary maintenance: removed ${report.removed}, retained ${report.retained}.\n${Object.entries( + report.reasons, + ) + .map(([reason, count]) => `${reason}: ${count}\n`) + .join('')}`; + try { + deps.io.writeOut(output); + } catch (error) { + throw new CliError('output_unavailable', '', { cause: error }); + } + return EXIT_CODES.success; } diff --git a/apps/cli/src/commands/provider-headers.test.ts b/apps/cli/src/commands/provider-headers.test.ts index 995eddb9..75f0e518 100644 --- a/apps/cli/src/commands/provider-headers.test.ts +++ b/apps/cli/src/commands/provider-headers.test.ts @@ -22,7 +22,8 @@ import { runProviderCommand, type ProviderCommandDeps } from './provider.js'; const BASE_URL = 'https://wave7-command.example/v1'; const POLICY_MESSAGE = 'Provider headers are disabled until keychain custody is implemented.'; const CORRUPT_MESSAGE = 'Stored provider headers are invalid.'; -const INTERNAL_MESSAGE = 'An unexpected internal error occurred.'; +const INTERNAL_MESSAGE = + 'An unexpected CLI failure occurred. Use --verbose for safe diagnostics. Inspect existing work before repeating paid or effectful operations.'; const MALFORMED_HEADERS = '{"x-wave7-malformed-opaque":"cedar-river-legacy",'; const NONSTRING_HEADERS = '{"x-wave7-nonstring-opaque":37}'; const ERROR_SENTINELS = [ @@ -309,9 +310,9 @@ describe('ADR-0108 provider-header command projections', () => { }); assertNoLegacy(JSON.stringify(ownProperties)); expect(projected).toEqual({ - code: policy ? 'invalid_invocation' : 'internal', + code: policy ? 'invalid_invocation' : 'cli_internal', message: policy ? POLICY_MESSAGE : INTERNAL_MESSAGE, - exitCode: policy ? 2 : 1, + exitCode: policy ? 2 : 9, }); expect(fetch).not.toHaveBeenCalled(); expect(globalThis.fetch).not.toHaveBeenCalled(); diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts index 702071d8..fdeb46a2 100644 --- a/apps/cli/src/commands/run.test.ts +++ b/apps/cli/src/commands/run.test.ts @@ -1582,12 +1582,56 @@ workflow: } }); - it("a history-db fault is exit 2, and still tears the MCP children down (the reorder's new context)", async () => { - // The block that maps a history-open failure to an INVOCATION fault carries an explicit contract — "so a - // `--json`/CI consumer can tell 'the history db couldn't open' from 'a node failed mid-run'" — and no - // test greped for it: a review measured the whole `CliError` replaceable with a bare rethrow while the - // suite stayed green. `630c3b6` moved that branch into a materially different context, where the MCP - // children are ALREADY SPAWNED when it fires, so the teardown is asserted with it. + it('keeps owned storage open failure at 1 without promoting the private DB path', async () => { + const dbPath = join(root, 'PRIVATE_CORRUPT_HISTORY.db'); + writeFileSync(dbPath, 'PRIVATE_CORRUPT_BYTES'); + const path = writeWorkflow('storage-fault.relavium.yaml', MCP_WF); + const { io } = captureIo(); + let cause: unknown; + try { + createClient(dbPath); + } catch (error) { + cause = error; + } + expect(cause).toBeDefined(); + let closed = 0; + const conn: McpConnection = { + listTools: () => Promise.resolve([{ name: 'read', inputSchema: { type: 'object' } }]), + callTool: () => Promise.resolve({ content: [], isError: false }), + close: () => { + closed += 1; + return Promise.resolve(); + }, + }; + let caught: unknown; + try { + await runCommand( + { workflow: path, input: [], allowMcpStdio: [] }, + { + io, + global: globalOptions(), + providers: scriptedResolver([textTurn('done')]), + consentGate: PASS_CONSENT, + startMcpClient: () => + realStartMcpClient([ + { id: 'fs', toolsAllowlist: ['read'], open: () => Promise.resolve(conn) }, + ]), + openRunStore: () => { + throw cause; + }, + }, + ); + } catch (error) { + caught = error; + } + expect(caught).toMatchObject({ code: 'storage_unavailable', exitCode: 1, cause }); + expect(isCliError(caught) && caught.message).not.toContain('PRIVATE_'); + expect(closed).toBe(1); + }); + + it('an unowned history-db fault is infrastructure 9 and still tears the MCP children down', async () => { + // An injected errno-shaped fault has no actual I/O provenance; it must still tear down + // already-created MCP children while refusing to misreport a storage/invocation fault. const path = writeWorkflow('mcp-dbfault.relavium.yaml', MCP_WF); const { io } = captureIo(); let closed = 0; @@ -1613,16 +1657,16 @@ workflow: { id: 'fs', toolsAllowlist: ['read'], open: () => Promise.resolve(conn) }, ]), openRunStore: () => { - throw new Error('EACCES: permission denied'); + throw Object.assign(new Error('PRIVATE_HISTORY_FAULT'), { code: 'EACCES' }); }, }, ); } catch (error) { caught = error; } - expect(isCliError(caught) && caught.code).toBe('invalid_invocation'); - expect(isCliError(caught) && caught.exitCode).toBe(EXIT_CODES.invalidInvocation); - expect(isCliError(caught) && caught.message).toContain('run history database'); + expect(isCliError(caught) && caught.code).toBe('cli_internal'); + expect(isCliError(caught) && caught.exitCode).toBe(EXIT_CODES.infrastructureFailed); + expect(isCliError(caught) && caught.message).not.toContain('PRIVATE_HISTORY_FAULT'); expect(closed).toBe(1); // the spawned connection did not leak past the fault }); diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 4aaf98c1..1dbf1e43 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -54,6 +54,7 @@ import { createConsentGate } from '../engine/mcp-consent-gate.js'; import { guardMcpTeardown } from '../engine/mcp-signal-teardown.js'; import { createConsentPrompter } from '../mcp/consent-prompt.js'; import { CliError } from '../process/errors.js'; +import { storageBoundaryError } from '../process/owned-storage.js'; import { sourceLabel } from '../process/source-label.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; @@ -301,14 +302,8 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr try { opened = deps.openRunStore?.(runWorkflow, homeDir, deps.global.cwd); } catch (err) { - // A pre-run history fault (cannot create / open / migrate ~/.relavium/history.db) is an INVOCATION - // fault (exit 2), not a workflow failure (exit 1) — surface it as such, before the engine starts, so a - // `--json`/CI consumer can tell "the history db couldn't open" from "a node failed mid-run". - throw new CliError( - 'invalid_invocation', - `could not open the run history database: ${err instanceof Error ? err.message : String(err)}`, - { cause: err }, - ); + // Classify only this open operation; engine/work faults retain their own boundary. + throw storageBoundaryError(err); } const mcpOption = diff --git a/apps/cli/src/db/open.test.ts b/apps/cli/src/db/open.test.ts index 0cad3c3b..2a368d67 100644 --- a/apps/cli/src/db/open.test.ts +++ b/apps/cli/src/db/open.test.ts @@ -62,7 +62,13 @@ describe('openLocalDb — the at-rest 0600 (#28, ADR-0050)', () => { vi.resetModules(); const { openLocalDb } = await import('./open.js'); - expect(() => openLocalDb(home)).toThrow('disk full'); + let failure: unknown; + try { + openLocalDb(home); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ code: 'cli_internal', exitCode: 9 }); // The file exists (createClient made it) and is owner-only despite the failure. expect(modeOf(join(globalConfigDir(home), 'history.db'))).toBe(0o600); }, diff --git a/apps/cli/src/db/open.ts b/apps/cli/src/db/open.ts index a9ac76b7..f1e2ebe9 100644 --- a/apps/cli/src/db/open.ts +++ b/apps/cli/src/db/open.ts @@ -1,9 +1,13 @@ -import { chmodSync } from 'node:fs'; import { join } from 'node:path'; import { createClient, runMigrations, type Db, type SessionEffectCheckpoint } from '@relavium/db'; -import { ensureGlobalConfigDir, globalConfigDir } from '../config/paths.js'; +import { globalConfigDir } from '../config/paths.js'; +import { + chmodForStorage, + mkdirForStorage, + storageBoundaryError, +} from '../process/owned-storage.js'; import { processIo } from '../process/io.js'; import { CHECKPOINT_DEFERRED } from './privacy-notice.js'; @@ -23,9 +27,16 @@ export function openLocalDb( homeDir: string, notice: (text: string) => void | Promise = (text) => processIo().writeErrAcknowledged(text), ): OpenedDb { - ensureGlobalConfigDir(homeDir); // creates ~/.relavium/ at 0700 (ADR-0050) + const dir = globalConfigDir(homeDir); + mkdirForStorage(dir); + chmodForStorage(dir, 0o700); const path = join(globalConfigDir(homeDir), 'history.db'); - const client = createClient(path); + let client: ReturnType; + try { + client = createClient(path); + } catch (error) { + throw storageBoundaryError(error); + } let checkpoint: SessionEffectCheckpoint; // If setup (migrations / the at-rest chmod) throws, close the just-opened handle before propagating — // otherwise the SQLite connection leaks for the lifetime of the failing process. @@ -42,7 +53,7 @@ export function openLocalDb( hardenAtRest(path); } catch (err) { client.sqlite.close(); - throw err; + throw storageBoundaryError(err); } if (checkpoint === 'deferred') { // Logical suppression succeeded; only physical erasure awaits a reader. Diagnostic failure @@ -75,10 +86,10 @@ export function openLocalDb( * Idempotent, so it is safe to call on both sides of the migration batch. */ function hardenAtRest(path: string): void { - chmodSync(path, 0o600); + chmodForStorage(path, 0o600); for (const suffix of ['-wal', '-shm']) { try { - chmodSync(`${path}${suffix}`, 0o600); + chmodForStorage(`${path}${suffix}`, 0o600); } catch (err) { if (errnoCode(err) !== 'ENOENT') { throw err; @@ -89,9 +100,16 @@ function hardenAtRest(path: string): void { /** The `errno` code of a Node fs error (`ENOENT`, `EPERM`, …), or `undefined` if it is not one. */ function errnoCode(err: unknown): string | undefined { - if (typeof err === 'object' && err !== null && 'code' in err) { - const code: unknown = err.code; - return typeof code === 'string' ? code : undefined; + try { + if (typeof err === 'object' && err !== null) { + const descriptor = Object.getOwnPropertyDescriptor(err, 'code'); + if (descriptor !== undefined && 'value' in descriptor) { + const code: unknown = descriptor.value; + return typeof code === 'string' ? code : undefined; + } + } + } catch { + // An uninspectable failure is not a benign absent sidecar. } return undefined; } diff --git a/apps/cli/src/history/reader.test.ts b/apps/cli/src/history/reader.test.ts new file mode 100644 index 00000000..15928701 --- /dev/null +++ b/apps/cli/src/history/reader.test.ts @@ -0,0 +1,56 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, expect, it } from 'vitest'; +import { DbOpenError } from '@relavium/db'; +import { openHistoryReader } from './reader.js'; +import { openSessionStore } from './session-open.js'; +import { openLocalDb } from '../db/open.js'; +import { globalConfigDir } from '../config/paths.js'; +import { CLI_ERROR_POLICY } from '../process/cli-error.js'; +import { toUserFacing } from '../process/errors.js'; + +let home: string; +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'relavium-history-origins-')); +}); +afterEach(() => rmSync(home, { recursive: true, force: true })); + +for (const [name, open] of [ + ['reader', openHistoryReader], + ['session', openSessionStore], + ['local', openLocalDb], +] as const) { + it(`${name}: projects actual corrupt DB setup to fixed storage 1`, () => { + mkdirSync(globalConfigDir(home)); + writeFileSync(join(globalConfigDir(home), 'history.db'), 'PRIVATE_CORRUPT_DB_BYTES'); + let failure: unknown; + try { + open(home); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ code: 'storage_unavailable', exitCode: 1 }); + expect(toUserFacing(failure).message).toBe(CLI_ERROR_POLICY.storage_unavailable.fixedMessage); + expect(JSON.stringify(toUserFacing(failure))).not.toContain(home); + expect(JSON.stringify(toUserFacing(failure))).not.toContain('PRIVATE_'); + }); +} + +it.each([ + Object.assign(new Error('PRIVATE_FOREIGN_ERRNO'), { code: 'EACCES' }), + new DbOpenError('open_failed', 'PRIVATE_ADDON_ABI', '/PRIVATE_PATH', { + cause: Object.assign(new Error('PRIVATE_CAUSE'), { code: 'ERR_DLOPEN_FAILED' }), + }), +])('reader: injected opener cannot manufacture storage provenance', (cause) => { + let failure: unknown; + try { + openHistoryReader(home, () => { + throw cause; + }); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ code: 'cli_internal', exitCode: 9, cause }); + expect(toUserFacing(failure).message).toBe(CLI_ERROR_POLICY.cli_internal.fixedMessage); +}); diff --git a/apps/cli/src/history/reader.ts b/apps/cli/src/history/reader.ts index 162bbd7d..7c85010e 100644 --- a/apps/cli/src/history/reader.ts +++ b/apps/cli/src/history/reader.ts @@ -1,7 +1,7 @@ import { createRunHistoryReader, type Db, type RunHistoryReader } from '@relavium/db'; import { openLocalDb } from '../db/open.js'; -import { CliError } from '../process/errors.js'; +import { storageBoundaryError } from '../process/owned-storage.js'; /** An opened workflow-agnostic read seam plus the handle to close its SQLite connection. */ export interface OpenedReader { @@ -12,8 +12,8 @@ export interface OpenedReader { /** * Open `~/.relavium/history.db` and wrap it in the workflow-agnostic {@link RunHistoryReader} the read commands * (`list`/`logs`/`status`/`gate list`, 2.I) consume — distinct from the workflow-scoped `openHistoryStore` the - * `run`/`gate` write paths use. A pre-run db-open fault (cannot create / open / migrate the file) is an - * INVOCATION fault (exit 2), surfaced before any read. Tests inject `openDb` to pass an in-memory db. + * `run`/`gate` write paths use. An owned storage-open fault is operational 1; an unexpected open failure is infrastructure 9. + * Tests inject `openDb` to pass an in-memory db, without granting arbitrary errors storage origin. */ export function openHistoryReader( homeDir: string, @@ -23,11 +23,7 @@ export function openHistoryReader( try { opened = openDb(homeDir); } catch (err) { - throw new CliError( - 'invalid_invocation', - `could not open the run history database: ${err instanceof Error ? err.message : String(err)}`, - { cause: err }, - ); + throw storageBoundaryError(err); } return { reader: createRunHistoryReader(opened.db), close: opened.close }; } diff --git a/apps/cli/src/history/session-open.ts b/apps/cli/src/history/session-open.ts index 59694a08..7cac272c 100644 --- a/apps/cli/src/history/session-open.ts +++ b/apps/cli/src/history/session-open.ts @@ -1,7 +1,7 @@ import { createSessionStore, type Db, type SessionStore } from '@relavium/db'; import { openLocalDb } from '../db/open.js'; -import { CliError } from '../process/errors.js'; +import { storageBoundaryError } from '../process/owned-storage.js'; /** An opened session store plus the handle to close its SQLite connection at REPL end. */ export interface OpenedSessionStore { @@ -19,20 +19,15 @@ export interface OpenedSessionStore { * Production (`commands/chat.ts`, `chat-list`, …) wires this; the unit tests drive a `createSessionStore` * over an in-memory db directly, so they never touch the user's home. * - * A db-open fault (cannot create / open / migrate the file) is an INVOCATION fault (exit 2), surfaced before - * any session work — mirroring {@link openHistoryReader} so every session command (`chat`, `chat-list`, and - * the upcoming resume/export) reports an unreadable `history.db` as a clean exit 2, not an opaque exit 1. + * Owned storage-open faults are operational 1. Unknown native/import/wiring faults are + * infrastructure 9; neither can be diagnosed from arbitrary messages or cause trees. */ export function openSessionStore(homeDir: string): OpenedSessionStore { let opened: { db: Db; close: () => void }; try { opened = openLocalDb(homeDir); } catch (err) { - throw new CliError( - 'invalid_invocation', - `could not open the session history database: ${err instanceof Error ? err.message : String(err)}`, - { cause: err }, - ); + throw storageBoundaryError(err); } return { store: createSessionStore(opened.db), db: opened.db, close: opened.close }; } diff --git a/apps/cli/src/process/background-observer.test.ts b/apps/cli/src/process/background-observer.test.ts new file mode 100644 index 00000000..4ae7f6ef --- /dev/null +++ b/apps/cli/src/process/background-observer.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createBackgroundObserver } from './background-observer.js'; +import { + ExitObservations, + type OrdinaryExit, + type WorkflowSafetyExit, +} from './exit-observations.js'; +import { InvocationRegistry } from './invocation-registry.js'; + +function fixture(initial: string | number | null | undefined = undefined) { + const observations = new ExitObservations(); + let exitCode = initial; + const records: string[] = []; + const outputFailed = vi.fn(); + const observer = createBackgroundObserver({ + observations, + policy: { json: true, verbosity: 'verbose' }, + readExitCode: () => exitCode, + setExitCode: (code) => { + exitCode = code; + }, + write: (text) => { + records.push(text); + return Promise.resolve(); + }, + outputFailed, + }); + return { observations, records, outputFailed, observer, exitCode: () => exitCode }; +} + +describe('bounded background keepalive observer', () => { + it('does not inspect even hostile Error fields and saturates at five records plus one suppression', () => { + const f = fixture(); + const touched = vi.fn(() => { + throw new Error('secret getter'); + }); + const reason = Object.defineProperties(new Error(), { + message: { get: touched }, + cause: { get: touched }, + stack: { get: touched }, + toString: { get: touched }, + [Symbol.toPrimitive]: { get: touched }, + }); + expect(f.observer.occurred()).toBe(false); + for (let index = 0; index < 10_000; index++) f.observer.observe(reason); + expect(touched).not.toHaveBeenCalled(); + expect(f.records, 'BACKGROUND_DIAGNOSTIC_BOUND').toHaveLength(6); + expect(new Set(f.records.slice(0, 5)).size).toBe(1); + expect(f.records[5]).toContain('Further background failures suppressed.'); + for (const text of f.records) + expect(JSON.parse(text)).toMatchObject({ type: 'error', code: 'cli_internal' }); + expect(f.exitCode()).toBe(9); + expect(f.observations.exitCode()).toBe(9); + expect(f.observations.hasInfrastructureFailure()).toBe(false); + expect(f.observer.occurred()).toBe(true); + }); + + it.each([1, 2, 3, 4, 5, 6, 7, 8, 130, 143, '2'])( + 'case %#: preserves established native exit %s', + (code) => { + const f = fixture(code); + f.observer.observe(new Error('private')); + expect(f.exitCode(), 'BACKGROUND_NONZERO_PRESERVATION').toBe(code); + }, + ); + + it.each([undefined, null, 0])('upgrades clean native exit %s only', (code) => { + const f = fixture(code); + f.observer.observe(undefined); + expect(f.exitCode()).toBe(9); + }); + + it('preserves actual ordinary, workflow and signal facts and leaves admission open', () => { + for (const code of [1, 2, 3, 4, 6] satisfies OrdinaryExit[]) { + const f = fixture(code); + f.observations.observeOutcome(code); + f.observer.observe(null); + expect(f.observations.exitCode()).toBe(code); + } + for (const code of [5, 6, 7, 8] satisfies WorkflowSafetyExit[]) { + const f = fixture(code); + f.observations.observeWorkflowSafety(code); + f.observer.observe(null); + expect(f.observations.exitCode()).toBe(code); + } + const f = fixture(143); + f.observations.observeTerminatingSignal(15); + f.observer.observe(null); + expect(f.observations.exitCode()).toBe(143); + const registry = new InvocationRegistry( + { now: () => 0, schedule: () => () => undefined }, + f.observations, + ); + registry.assertAdmitting(); + expect(registry.termination).toBeUndefined(); + }); + + it.each(['reject', 'throw'] as const)( + 'reports %s diagnostic delivery once without recursive records', + async (mode) => { + const outputFailed = vi.fn(); + const write = vi.fn(() => { + if (mode === 'throw') throw new Error('private output error'); + return Promise.reject(new Error('private output error')); + }); + const observer = createBackgroundObserver({ + observations: new ExitObservations(), + policy: { json: true, verbosity: 'verbose' }, + readExitCode: () => 0, + setExitCode: () => undefined, + write, + outputFailed, + }); + observer.observe(new Error('private rejection')); + await Promise.resolve(); + expect(write).toHaveBeenCalledOnce(); + expect(outputFailed).toHaveBeenCalledOnce(); + }, + ); +}); diff --git a/apps/cli/src/process/background-observer.ts b/apps/cli/src/process/background-observer.ts new file mode 100644 index 00000000..d93a1179 --- /dev/null +++ b/apps/cli/src/process/background-observer.ts @@ -0,0 +1,38 @@ +import type { DiagnosticPolicy } from './diagnostic-policy.js'; +import type { ExitObservations } from './exit-observations.js'; +import { formatFixedDiagnostic } from './fixed-diagnostic.js'; + +export interface BackgroundObserverPorts { + readonly observations: ExitObservations; + readonly policy: DiagnosticPolicy; + readonly readExitCode: () => string | number | null | undefined; + readonly setExitCode: (code: number) => void; + readonly write: (text: string) => Promise; + readonly outputFailed: () => void; +} + +/** Inert successor to the legacy net. Process listeners are installed only by the native adapter. */ +export function createBackgroundObserver(ports: BackgroundObserverPorts): { + readonly observe: (reason: unknown) => void; + readonly occurred: () => boolean; +} { + let count = 0; + return Object.freeze({ + occurred: () => count > 0, + observe: () => { + // Saturated finite state, even with an unlimited stream of rejections. Never inspect the reason. + if (count < 6) { + count++; + const kind = count === 6 ? 'background_suppressed' : 'background_failure'; + try { + void ports.write(formatFixedDiagnostic(kind, ports.policy)).catch(ports.outputFailed); + } catch { + ports.outputFailed(); + } + } + ports.observations.observeBackgroundFailure(); + const previous = ports.readExitCode(); + if (previous === undefined || previous === null || previous === 0) ports.setExitCode(9); + }, + }); +} diff --git a/apps/cli/src/process/errors.test.ts b/apps/cli/src/process/errors.test.ts index 14ba876a..54542799 100644 --- a/apps/cli/src/process/errors.test.ts +++ b/apps/cli/src/process/errors.test.ts @@ -8,11 +8,17 @@ import { } from '@relavium/core'; import { CorruptRunEventError, + createClient, + DbOpenError, ProviderHeaderPolicyError, SessionMessageBoundaryError, UnreadableRunEventLogError, } from '@relavium/db'; import { describe, expect, it } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CLI_ERROR_POLICY } from './cli-error.js'; import { CliError, isCliError, toUserFacing } from './errors.js'; import { @@ -126,12 +132,12 @@ describe('toUserFacing', () => { message: new ProviderHeaderPolicyError().message, exitCode: EXIT_CODES.invalidInvocation, }); - expect(toUserFacing({ code: 'provider_header_policy' }).code).toBe('internal'); + expect(toUserFacing({ code: 'provider_header_policy' }).code).toBe('cli_internal'); compatible.message = 'opaque compatibility payload'; expect(toUserFacing(compatible)).toEqual({ - code: 'internal', - message: 'An unexpected internal error occurred.', - exitCode: EXIT_CODES.workflowFailed, + code: 'cli_internal', + message: CLI_ERROR_POLICY.cli_internal.fixedMessage, + exitCode: EXIT_CODES.infrastructureFailed, }); }); @@ -208,8 +214,8 @@ describe('toUserFacing', () => { it('maps an unknown throw to a generic internal error without leaking detail', () => { const userFacing = toUserFacing(new Error('secret stack detail')); - expect(userFacing.code).toBe('internal'); - expect(userFacing.exitCode).toBe(EXIT_CODES.workflowFailed); + expect(userFacing.code).toBe('cli_internal'); + expect(userFacing.exitCode).toBe(EXIT_CODES.infrastructureFailed); expect(userFacing.message).not.toContain('secret'); }); }); @@ -255,10 +261,10 @@ describe('a graph fault is an AUTHORING fault, not an internal one (CR-64 review expect(leak.message).toContain('inputs.api_key'); // the tainted SYMBOL, never a resolved value }); - it('a genuinely unknown error is still internal/exit 1', () => { + it('an unknown error is infrastructure/exit 9', () => { const out = toUserFacing(new Error('boom')); - expect(out.code).toBe('internal'); - expect(out.exitCode).toBe(EXIT_CODES.workflowFailed); + expect(out.code).toBe('cli_internal'); + expect(out.exitCode).toBe(EXIT_CODES.infrastructureFailed); }); }); @@ -275,3 +281,159 @@ describe('toUserFacing — a malformed standalone agent file', () => { expect(mapped.message).toContain('invalid agent'); }); }); + +describe('owned storage origin (ADR-0110 Step 8a)', () => { + it('projects real corrupt database setup to fixed storage 1 without its private path/cause', () => { + const dir = mkdtempSync(join(tmpdir(), 'relavium-owned-storage-')); + const path = join(dir, 'PRIVATE_HISTORY_PATH.db'); + writeFileSync(path, 'PRIVATE_NOT_SQLITE_BYTES'); + try { + let failure: unknown; + try { + createClient(path); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(DbOpenError); + expect(toUserFacing(failure), 'CLI_ORIGIN_DB_FACT').toEqual({ + code: 'storage_unavailable', + message: CLI_ERROR_POLICY.storage_unavailable.fixedMessage, + exitCode: EXIT_CODES.workflowFailed, + }); + expect(JSON.stringify(toUserFacing(failure))).not.toContain('PRIVATE_'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it.each([ + new DbOpenError('open_failed', 'PRIVATE_ABI_MESSAGE', '/PRIVATE_PATH', { + cause: Object.assign(new Error('PRIVATE_ABI_CAUSE'), { code: 'ERR_DLOPEN_FAILED' }), + }), + new DbOpenError('open_failed', 'PRIVATE_NATIVE_MESSAGE', '/PRIVATE_PATH', { + cause: Object.assign(new Error('PRIVATE_NATIVE_CAUSE'), { code: 'SQLITE_FULL' }), + }), + Object.assign(new Error('PRIVATE_ERRNO_MESSAGE'), { code: 'EACCES' }), + { code: 'open_failed', cause: { code: 'SQLITE_CANTOPEN' } }, + ])( + 'case %#: does not infer storage from a public constructor, errno or convincing cause', + (failure) => { + expect(toUserFacing(failure)).toEqual({ + code: 'cli_internal', + message: CLI_ERROR_POLICY.cli_internal.fixedMessage, + exitCode: EXIT_CODES.infrastructureFailed, + }); + expect(JSON.stringify(toUserFacing(failure))).not.toContain('PRIVATE_'); + }, + ); + + it('preserves the programmer-invalid DB URI API refusal as invocation 2', () => { + let failure: unknown; + try { + createClient('file:PRIVATE_URI?mode=memory'); + } catch (error) { + failure = error; + } + expect(toUserFacing(failure).code).toBe('invalid_invocation'); + expect(toUserFacing(failure).exitCode).toBe(EXIT_CODES.invalidInvocation); + expect(toUserFacing(failure).message).not.toContain('PRIVATE_URI'); + }); +}); + +describe('defensive runtime projection', () => { + it.each(['code', 'message', 'name', 'stack', 'cause'])( + 'does not invoke hostile %s accessors on unknown errors', + (key) => { + let calls = 0; + const value = new Error('PRIVATE_UNKNOWN'); + Object.defineProperty(value, key, { + get: () => { + calls += 1; + throw new Error('PRIVATE_GETTER'); + }, + }); + expect(toUserFacing(value)).toEqual({ + code: 'cli_internal', + message: CLI_ERROR_POLICY.cli_internal.fixedMessage, + exitCode: 9, + }); + expect(calls).toBe(0); + }, + ); + + it.each([ + new CorruptRunEventError('run-safe', 1, 'node:started', undefined), + new UnreadableRunEventLogError('run-safe', [1]), + new EngineStateError('unknown_run', 'safe'), + new AgentParseError('agent_validation', 'safe'), + new WorkflowSyntaxError('safe'), + new CliError('internal', 'safe'), + ])( + 'case %#: refuses accessor messages on known mutable typed errors without executing them', + (value) => { + let calls = 0; + Object.defineProperty(value, 'message', { + get: () => { + calls += 1; + return 'PRIVATE_GETTER'; + }, + }); + expect(toUserFacing(value).code).toBe('cli_internal'); + expect(toUserFacing(value).exitCode).toBe(9); + expect(calls).toBe(0); + }, + ); + + it('does not invoke inherited code accessors or coerce their arbitrary values', () => { + let calls = 0; + const value = new Error('PRIVATE_UNKNOWN'); + const inherited: unknown = Object.create(Error.prototype, { + code: { + get: () => { + calls += 1; + return 'corrupt_run_event'; + }, + }, + }); + if (typeof inherited !== 'object' || inherited === null) + throw new Error('Missing test prototype'); + Object.setPrototypeOf(value, inherited); + expect(toUserFacing(value).code).toBe('cli_internal'); + expect(calls).toBe(0); + }); + + it.each(['getPrototypeOf', 'getOwnPropertyDescriptor'] as const)( + 'reflection trap %s becomes a fixed infrastructure diagnostic', + (trap) => { + const value = new Proxy(new Error('PRIVATE_PROXY_BYTES'), { + [trap]: () => { + throw new Error('PRIVATE_REFLECTION'); + }, + }); + expect(toUserFacing(value)).toEqual({ + code: 'cli_internal', + message: CLI_ERROR_POLICY.cli_internal.fixedMessage, + exitCode: 9, + }); + }, + ); + + it('refuses a mutated public CliError code and derives valid-code exit only from policy', () => { + const value = new CliError('internal', 'known refusal'); + Object.defineProperty(value, 'code', { value: '__proto__' }); + expect(toUserFacing(value).code).toBe('cli_internal'); + expect(toUserFacing(value).exitCode).toBe(9); + Object.defineProperty(value, 'code', { value: 'storage_unavailable' }); + Object.defineProperty(value, 'exitCode', { value: 0 }); + expect(toUserFacing(value).exitCode).toBe(1); + }); + + it.each(['corrupt_run_event', 'unreadable_run_event_log'])( + 'retains stable own-data %s history compatibility from another bundle', + (code) => { + const value = Object.assign(new Error('safe history reason'), { code }); + expect(toUserFacing(value).code).toBe('internal'); + expect(toUserFacing(value).exitCode).toBe(1); + }, + ); +}); diff --git a/apps/cli/src/process/errors.ts b/apps/cli/src/process/errors.ts index de086a08..19feaecc 100644 --- a/apps/cli/src/process/errors.ts +++ b/apps/cli/src/process/errors.ts @@ -1,19 +1,18 @@ import { AgentParseError, EngineStateError, - isTransientEngineStateError, + TRANSIENT_ENGINE_STATE_CODES, WorkflowParseError, } from '@relavium/core'; import { - isCorruptRunEventError, + DbOpenError, + isOwnedStorageFailure, isProviderHeaderPolicyError, - isUnreadableRunEventLogError, ProviderHeaderPolicyError, SessionMessageBoundaryError, } from '@relavium/db'; import { EXIT_CODES } from './exit-codes.js'; - import { CLI_ERROR_POLICY, cliErrorExitCode, @@ -24,21 +23,37 @@ import { export { CliError, isCliError } from './cli-error.js'; export type { CliErrorCode, UserFacingError } from './cli-error.js'; -/** - * Map any thrown value to a user-safe projection. A `CliError` passes through; anything else - * is an unexpected fault reported generically as `internal` — its raw message/stack is never - * promoted to primary output (the renderer may still write the stack to stderr under `--verbose`). - */ +/** Safe runtime projection; a hostile reflection/classifier fault is itself infrastructure failure. */ export function toUserFacing(value: unknown): UserFacingError { + try { + return projectUserFacing(value); + } catch { + // Never coerce the incoming object, inspect its cause or retry a hostile accessor. + return fixedFailure('cli_internal'); + } +} + +function projectUserFacing(value: unknown): UserFacingError { if (isCliError(value)) { + const code = ownData(value, 'code'); + if (!isCliCode(code)) return fixedFailure('cli_internal'); + const message = CLI_ERROR_POLICY[code].fixedMessage ?? ownData(value, 'message'); + if (typeof message !== 'string') return fixedFailure('cli_internal'); + // Policy owns the exit. Public mutation cannot manufacture a new code/exit disposition. + return { code, message, exitCode: cliErrorExitCode(code) }; + } + // Only the actual DB/native/media boundary may add this private identity fact. Constructors, + // errno-shaped objects and convincing causes never supply storage authority to this classifier. + if (isOwnedStorageFailure(value)) return fixedFailure('storage_unavailable'); + if (value instanceof DbOpenError && ownData(value, 'code') === 'uri_unsupported') { return { - code: value.code, - message: CLI_ERROR_POLICY[value.code].fixedMessage ?? value.message, - exitCode: value.exitCode, + code: 'invalid_invocation', + message: + "SQLite URI paths are not supported by createClient — pass ':memory:' or a filesystem path", + exitCode: EXIT_CODES.invalidInvocation, }; } - // ADR-0108: the disabled header field is an invocation refusal. Keep this projection fixed rather than - // promoting the incoming message: structurally compatible errors can originate from another bundle. + // This DB guard already requires own data descriptors, retaining stable cross-bundle compatibility. if (isProviderHeaderPolicyError(value)) { return { code: 'invalid_invocation', @@ -46,9 +61,7 @@ export function toUserFacing(value: unknown): UserFacingError { exitCode: EXIT_CODES.invalidInvocation, }; } - // SessionMessageBoundaryError is raised by both read and write validation. The DB type has no - // session id, and its cause/message must never become a transcript diagnostic. A refused read can - // be recovered from a safe history backup; a refused write must not claim the row was persisted. + // Read and write validation share this fixed message; neither message/body nor cause is inspected. if (value instanceof SessionMessageBoundaryError) { return { code: 'invalid_invocation', @@ -57,91 +70,64 @@ export function toUserFacing(value: unknown): UserFacingError { exitCode: EXIT_CODES.invalidInvocation, }; } - // A damaged `run_events` row (ADR-0074 §5 / ADR-0050). The generic fallback below would report a corrupt row - // out of a 4,000-event log as `An unexpected internal error occurred.` — no run, no row, no next step. The - // store's typed error already carries the run id, the `seq`, and the `event_type`, none of which is user - // content or a secret, so promote them and name the recovery. Still exit 1: the read genuinely failed. - // No remedy is prescribed because the CLI genuinely has none for a single bad row: there is no run-delete - // command, and upgrading does not repair corruption (that is the OTHER half of §5 — a skipped row, which is - // reported as a note, not an error). Naming the run and the row is the actionable part; it is what makes the - // failure diagnosable at all, and what lets a user restore just that history DB from a backup. - // ADR-0075: this binary is too OLD to replay the run, which is a different thing from the data being - // damaged — and the difference is the whole value of the message. The remedy is real and the user can - // perform it, so it is named. Exit 1: the resume genuinely did not happen. - if (isUnreadableRunEventLogError(value)) { - return { - code: 'internal', - // The store's message already carries the run, the count and the leading `seq` values, and none of it - // is user content or a secret. Adding what is still POSSIBLE matters as much as the refusal: the run is - // not lost, and every read-only surface still shows it. - message: `${value.message} Its history is still readable — \`relavium logs\` and \`status\` are unaffected.`, - exitCode: EXIT_CODES.workflowFailed, - }; - } - if (isCorruptRunEventError(value)) { - return { - code: 'internal', - // Says only what is true. It claimed "the rest of your history is unaffected", which held for the DATA but - // not for AVAILABILITY: the Home / `status` / `gate list` fan a per-run read over many runs, and one throw - // used to take the whole listing down with it. `readPerRunOrDegrade` now bounds that to the one run, so - // those surfaces stay usable — but this message is reached by the per-run commands, where the read really - // did fail, so it promises nothing about them. - message: `${value.message}. Other runs are still listable.`, - exitCode: EXIT_CODES.workflowFailed, - }; + // History permits compatible errors from another bundle. Preflight own data directly rather than + // calling structural guards that read arbitrary code/message accessors. The typed store messages are + // echo-safe by their contract and retain their existing operational-1 recovery advice. + if (value instanceof Error) { + const code = ownData(value, 'code'); + const message = + code === 'unreadable_run_event_log' || code === 'corrupt_run_event' + ? ownData(value, 'message') + : undefined; + if (typeof message === 'string' && code === 'unreadable_run_event_log') { + return { + code: 'internal', + message: `${message} Its history is still readable — \`relavium logs\` and \`status\` are unaffected.`, + exitCode: EXIT_CODES.workflowFailed, + }; + } + if (typeof message === 'string' && code === 'corrupt_run_event') { + return { + code: 'internal', + message: `${message}. Other runs are still listable.`, + exitCode: EXIT_CODES.workflowFailed, + }; + } } - // A typed engine API-boundary refusal that reached the process boundary un-wrapped. `gate.ts` wraps its - // own `resumeFromCheckpoint` call, but `relavium run` calls `engine.start()` directly — and since ADR-0083 - // that call throws `input_admission_failed` for an input the CLI's own coercion layer deliberately does not - // check (`inputs.ts`: "deep per-field validation stays the engine's"). Without this arm, `--input - // severity=99` against `max: 10` printed `An unexpected internal error occurred.` and exited 1 instead of - // naming the field and exiting 2. Mapped the same way `gate.ts` maps it: transient keeps its own code and - // exit, every other engine-state refusal is a permanent invocation fault. if (value instanceof EngineStateError) { - const code: CliErrorCode = isTransientEngineStateError(value) + const engineCode = ownData(value, 'code'); + const message = ownData(value, 'message'); + if (typeof engineCode !== 'string' || typeof message !== 'string') + return fixedFailure('cli_internal'); + // Read the existing engine authority against the captured data, never invoke a getter on the error. + const code = TRANSIENT_ENGINE_STATE_CODES.some((candidate) => candidate === engineCode) ? 'run_owned_elsewhere' : 'invalid_invocation'; - // The engine's message is already user-safe by its own contract ("secret-free… never carries run - // inputs"), and for an admission refusal it carries the echo-safe half of each issue — so it is - // promoted as-is rather than re-flattened from `value.issues` here. - return { code, message: value.message, exitCode: cliErrorExitCode(code) }; + return { code, message, exitCode: cliErrorExitCode(code) }; } - if (value instanceof AgentParseError) { - // **A sibling of the arm below, and it needed its own.** `AgentParseError extends Error`, not - // `WorkflowParseError`, so a malformed standalone agent file fell straight through to the generic - // `internal` arm: `chat --agent`, the Home browser and `agent run` all answered an unloadable agent - // with "An unexpected internal error occurred." and exit 1 — the code reserved for a run that ran and - // failed, not for a file that could never run. - // - // The message is promoted, and it is safe to promote because the source label the CLI hands `parseAgent` - // is already cwd-relative at every call site (agent-source.ts, catalog.ts, authoring.ts). An absolute - // path in a user-facing error is exactly what the WorkflowIssue contract forbids. - return { - code: 'invalid_invocation', - message: value.message, - exitCode: cliErrorExitCode('invalid_invocation'), - }; - } - if (value instanceof WorkflowParseError) { - // An AUTHORING fault — a parse error, or a graph fault from `buildRunPlan` (`WorkflowGraphError` - // extends this): a cycle, an unknown edge target, an admission ceiling, a widened tool grant. Every one - // of them fell through to the generic `internal` arm below, so `relavium run` answered a malformed - // workflow with "An unexpected internal error occurred." and exit 1 — the code `commands.md` reserves - // for "the workflow ran and failed", not for a file that could never run. - // - // Promoted as-is, on the same reasoning the `EngineStateError` arm above records: a `GraphIssue`'s - // message is echo-safe by its own contract (`errors.ts` — "never an authored value"), and - // `parseOrRefuse` already maps this exact family to `invalid_invocation` when the fault happens to - // surface from `parseWorkflow` instead. The two paths now agree. - return { - code: 'invalid_invocation', - message: value.message, - exitCode: cliErrorExitCode('invalid_invocation'), - }; + // Parser/graph messages carry validated source labels/identifiers, never authored values. + if (value instanceof AgentParseError || value instanceof WorkflowParseError) { + const message = ownData(value, 'message'); + if (typeof message !== 'string') return fixedFailure('cli_internal'); + return { code: 'invalid_invocation', message, exitCode: EXIT_CODES.invalidInvocation }; } + return fixedFailure('cli_internal'); +} + +/** Local to this projection: no property reads, inherited values, getters or coercion. */ +function ownData(value: object, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined; +} + +function isCliCode(value: unknown): value is CliErrorCode { + return typeof value === 'string' && Object.hasOwn(CLI_ERROR_POLICY, value); +} + +function fixedFailure(code: 'cli_internal' | 'storage_unavailable'): UserFacingError { return { - code: 'internal', - message: 'An unexpected internal error occurred.', - exitCode: EXIT_CODES.workflowFailed, + code, + message: CLI_ERROR_POLICY[code].fixedMessage ?? '', + exitCode: cliErrorExitCode(code), }; } diff --git a/apps/cli/src/process/exit-observations.ts b/apps/cli/src/process/exit-observations.ts index 1909a658..a38d3856 100644 --- a/apps/cli/src/process/exit-observations.ts +++ b/apps/cli/src/process/exit-observations.ts @@ -95,4 +95,13 @@ export class ExitObservations { if (this.#infrastructure || this.#background) return 'cli_internal'; return undefined; } + + /** Background keepalive uncertainty is deliberately excluded from fatal shutdown authority. */ + hasInfrastructureFailure(): boolean { + return this.#infrastructure || this.#output; + } + + hasTerminatingSignal(): boolean { + return this.#signal !== undefined; + } } diff --git a/apps/cli/src/process/fixed-diagnostic.test.ts b/apps/cli/src/process/fixed-diagnostic.test.ts new file mode 100644 index 00000000..5289ef6f --- /dev/null +++ b/apps/cli/src/process/fixed-diagnostic.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { CLI_ERROR_POLICY } from './cli-error.js'; +import { formatFixedDiagnostic, type FixedDiagnosticKind } from './fixed-diagnostic.js'; + +const kinds: readonly FixedDiagnosticKind[] = [ + 'cli_internal', + 'output_unavailable', + 'background_failure', + 'background_suppressed', +]; + +describe('fixed native diagnostics', () => { + it.each(kinds)( + 'projects %s as a single fixed stderr JSON envelope at every verbosity', + (kind) => { + const expectedCode = kind === 'output_unavailable' ? kind : 'cli_internal'; + const messages = { + cli_internal: CLI_ERROR_POLICY.cli_internal.fixedMessage, + output_unavailable: CLI_ERROR_POLICY.output_unavailable.fixedMessage, + background_failure: + 'A background operation failed. Inspect existing work before repeating paid or effectful operations.', + background_suppressed: + 'Further background failures suppressed. Inspect existing work before repeating paid or effectful operations.', + }; + for (const verbosity of ['quiet', 'normal', 'verbose'] as const) { + const text = formatFixedDiagnostic(kind, { json: true, verbosity }); + expect(text).toBe( + `${JSON.stringify({ type: 'error', code: expectedCode, message: messages[kind] })}\n`, + ); + expect(text.split('\n')).toHaveLength(2); + } + }, + ); + + it.each(kinds)('human %s uses the same authored message without JSON decoration', (kind) => { + const json: unknown = JSON.parse( + formatFixedDiagnostic(kind, { json: true, verbosity: 'normal' }), + ); + expect(json).toHaveProperty('message'); + const message: unknown = + typeof json === 'object' && json !== null + ? Object.getOwnPropertyDescriptor(json, 'message')?.value + : undefined; + if (typeof message !== 'string') throw new Error('fixed envelope requires a message'); + expect(formatFixedDiagnostic(kind, { json: false, verbosity: 'verbose' })).toBe( + `relavium: ${message}\n`, + ); + }); +}); diff --git a/apps/cli/src/process/fixed-diagnostic.ts b/apps/cli/src/process/fixed-diagnostic.ts new file mode 100644 index 00000000..4f058034 --- /dev/null +++ b/apps/cli/src/process/fixed-diagnostic.ts @@ -0,0 +1,23 @@ +import { CLI_ERROR_POLICY } from './cli-error.js'; +import type { DiagnosticPolicy } from './diagnostic-policy.js'; + +export type FixedDiagnosticKind = + | 'cli_internal' + | 'output_unavailable' + | 'background_failure' + | 'background_suppressed'; + +/** Closed, authored constants only: no thrown value, stack, cause or coercion enters this projection. */ +export function formatFixedDiagnostic(kind: FixedDiagnosticKind, policy: DiagnosticPolicy): string { + const code = kind === 'output_unavailable' ? 'output_unavailable' : 'cli_internal'; + const message = + kind === 'background_failure' + ? 'A background operation failed. Inspect existing work before repeating paid or effectful operations.' + : kind === 'background_suppressed' + ? 'Further background failures suppressed. Inspect existing work before repeating paid or effectful operations.' + : CLI_ERROR_POLICY[code].fixedMessage; + // All fields are fixed ASCII constants, independently of user content and writable Error properties. + return policy.json + ? `${JSON.stringify({ type: 'error', code, message })}\n` + : `relavium: ${message}\n`; +} diff --git a/apps/cli/src/process/fixtures/native-invocation-child.ts b/apps/cli/src/process/fixtures/native-invocation-child.ts new file mode 100644 index 00000000..950e4c28 --- /dev/null +++ b/apps/cli/src/process/fixtures/native-invocation-child.ts @@ -0,0 +1,148 @@ +/** Synthetic fault injection around the actual inert native coordinator; no product activation claim. */ +import { writeFileSync } from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { join } from 'node:path'; +import { createClient } from '@relavium/db'; +import { InvocationRegistry, type OwnerRetirement } from '../invocation-registry.js'; +import { installNativeInvocation } from '../native-invocation.js'; +import { acknowledgeWrite } from '../io.js'; + +const mode = process.argv[2]; +const root = process.argv[3]; +if (root === undefined) throw new Error('fixture requires owned scratch'); +const registry = new InvocationRegistry({ + now: () => performance.now(), + schedule: (milliseconds, fire) => { + const timer = setTimeout(fire, milliseconds); + return () => clearTimeout(timer); + }, +}); +const native = installNativeInvocation(registry, { json: true, verbosity: 'verbose' }); +let startedAt = 0; +let stops = 0; +let restored = false; +let sqlite: ReturnType['sqlite'] | undefined; +const keepalive = setInterval(() => undefined, 1000); +process.on('exit', () => { + writeFileSync( + join(root, 'exit.json'), + JSON.stringify({ + elapsed: performance.now() - startedAt, + activeOwners: registry.activeOwners, + stops, + restored, + dbOpen: sqlite?.open, + deadline: registry.budget.deadline(), + }), + ); +}); + +if (mode === 'autonomous-db') { + sqlite = createClient(join(root, 'owned.sqlite')).sqlite; + sqlite.exec('CREATE TABLE fixture (value INTEGER)'); + const retired = new Promise(() => undefined); + const lease = registry.register({ + kind: 'session', + surface: 'chat', + retired, + stop: () => { + stops++; + }, + // Synthetic join fault: actual DB remains owned by the unresolved session lifetime. + join: () => { + throw new Error('synthetic opaque owner join fault'); + }, + classifyFailure: () => 'cli_internal', + }); + process.on('message', () => { + startedAt = performance.now(); + void lease.close(); + }); +} else if (mode === 'held-ack') { + let retire: (value: OwnerRetirement) => void = () => undefined; + const retired = new Promise((resolve) => { + retire = resolve; + }); + registry.register({ + kind: 'session', + surface: 'chat', + retired, + stop: () => { + stops++; + }, + join: () => undefined, + classifyFailure: () => 'cli_internal', + }); + // Honest synthetic ACK hold: real stderr bytes are sent; only callback delivery is withheld. + const write = process.stderr.write.bind(process.stderr); + process.stderr.write = ( + chunk: string | Uint8Array, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + ) => { + if (typeof encodingOrCallback === 'string') + return write(chunk, encodingOrCallback, () => undefined); + return write(chunk, () => undefined); + }; + process.on('message', () => { + startedAt = performance.now(); + native.fail('cli_internal'); + setTimeout(() => retire({ kind: 'settled' }), 1200); + }); +} else if (mode === 'restoration') { + let retire: (value: OwnerRetirement) => void = () => undefined; + const retired = new Promise((resolve) => { + retire = resolve; + }); + registry.register({ + kind: 'terminal', + retired, + stop: () => { + stops++; + }, + join: () => undefined, + restore: async () => { + // Native fixture custody witness, not an Ink/raw-mode integration claim. + await acknowledgeWrite(process.stderr, '\u001b[?25h\u001b[?1049l'); + restored = true; + retire({ kind: 'settled' }); + }, + classifyFailure: () => 'cli_internal', + }); + process.on('message', () => { + startedAt = performance.now(); + native.fail('cli_internal'); + native.fail('cli_internal'); + }); +} else if (mode === 'stdout-pipe' || mode === 'stderr-pipe') { + process.on('message', () => { + startedAt = performance.now(); + const stream = mode === 'stdout-pipe' ? process.stdout : process.stderr; + // An actual native pipe write after the parent closes its reader produces EPIPE. + stream.write('synthetic native pipe probe\n'); + }); +} else if (mode === 'fatal-opaque') { + process.on('message', () => { + startedAt = performance.now(); + const touched = () => { + writeFileSync(join(root, 'getter-touched'), 'bad'); + throw new Error('synthetic secret getter'); + }; + const reason = Object.defineProperties(new Error(), { + message: { get: touched }, + stack: { get: touched }, + cause: { get: touched }, + toString: { get: touched }, + [Symbol.toPrimitive]: { get: touched }, + }); + setImmediate(() => { + throw reason; + }); + }); +} else { + clearInterval(keepalive); + native.dispose(); + throw new Error('unknown fixture mode'); +} +process.send?.({ type: 'ready' }); +// This main never completes: native observation, rather than top-level await settlement, must exit. +await new Promise(() => undefined); diff --git a/apps/cli/src/process/invocation-registry.test.ts b/apps/cli/src/process/invocation-registry.test.ts index f12ac7f3..f20df2ea 100644 --- a/apps/cli/src/process/invocation-registry.test.ts +++ b/apps/cli/src/process/invocation-registry.test.ts @@ -72,6 +72,105 @@ async function retire( } describe('InvocationRegistry ownership', () => { + it('notifies the one process owner of autonomous faults without waiting for runtime completion', async () => { + const registry = new InvocationRegistry(new ManualClock()); + const life = deferred(); + const failure = deferred(); + const lease = registry.register(session(life.promise, { join: () => failure.promise })); + const notified = deferred>(); + let notificationReceived = false; + const remove = registry.observeTermination(() => { + notificationReceived = true; + const termination = registry.termination; + if (termination !== undefined) notified.resolve(termination); + }); + expect(() => registry.observeTermination(() => undefined)).toThrow(); + expect(registry.termination).toBeUndefined(); + const closing = lease.close(); + failure.reject(new Error('private owner failure while runtime is held')); + await failure.promise.catch(() => undefined); + expect(notificationReceived, 'REGISTRY_TERMINATION_OBSERVATION').toBe(true); + const observed = notified.promise; + expect(registry.termination).toBeDefined(); + expect(registry.budget.deadline()).toBe(3000); + expect(registry.observations.exitCode()).toBe(9); + await retire(life); + expect(await closing).toEqual({ kind: 'retired' }); + expect(await observed).toEqual({ kind: 'settled', unacknowledgedOwners: 0 }); + remove(); + }); + + it('shares its original deadline after owner settlement and reports a tighter final-flush deadline', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const budgets: RemainingBudget[] = []; + const remove = registry.observeTermination(() => budgets.push(registry.budget)); + const closing = registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + await closing; + expect(clock.timers.size).toBe(0); + clock.advanceTo(700); + expect(registry.terminate({ kind: 'infrastructure', code: 'output_unavailable' })).toBe( + closing, + ); + expect(registry.budget.deadline()).toBe(3000); + expect(registry.budget.remainingMs()).toBe(2300); + void registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 1500 }); + expect(clock.timers.size).toBe(0); + expect(registry.budget.remainingMs()).toBe(800); + expect(budgets.length).toBeGreaterThan(0); + expect(budgets.every((budget) => budget === registry.budget)).toBe(true); + remove(); + }); + + it('starts one fatal budget for the first output failure after normal cleanup has already settled', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const closing = registry.terminate({ kind: 'normal' }); + await closing; + expect(registry.budget.deadline()).toBeUndefined(); + const deadlines: (number | undefined)[] = []; + const remove = registry.observeTermination(() => deadlines.push(registry.budget.deadline())); + clock.advanceTo(200); + expect(registry.terminate({ kind: 'infrastructure', code: 'output_unavailable' })).toBe( + closing, + ); + expect(registry.budget.deadline()).toBe(3200); + clock.advanceTo(600); + void registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + expect(registry.budget.deadline()).toBe(3200); + expect(deadlines).toContain(3200); + expect(clock.timers.size).toBe(0); + remove(); + }); + + it('guards process observer failure and reentry without skipping any sibling stop', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const stops: number[] = []; + for (const index of [0, 1, 2]) + registry.register( + session(life.promise, { + stop: () => { + stops.push(index); + }, + }), + ); + const remove = registry.observeTermination(() => { + void registry.terminate({ kind: 'normal' }); + throw new Error('private observer failure'); + }); + const closing = registry.terminate({ kind: 'normal' }); + expect(stops).toEqual([0, 1, 2]); + expect(registry.observations.exitCode()).toBe(9); + expect(registry.budget.deadline()).toBe(3000); + await retire(life); + expect((await closing).kind).toBe('settled'); + remove(); + const next = registry.observeTermination(() => undefined); + next(); + }); + it('registers before admission without joining an idle session, and closes only that instance', async () => { const registry = new InvocationRegistry(new ManualClock()); const first = deferred(); diff --git a/apps/cli/src/process/invocation-registry.ts b/apps/cli/src/process/invocation-registry.ts index 46f01cd8..d80543ce 100644 --- a/apps/cli/src/process/invocation-registry.ts +++ b/apps/cli/src/process/invocation-registry.ts @@ -122,6 +122,8 @@ export class InvocationRegistry { #sampling = false; #finished = false; #stopping = false; + #terminationObserver: (() => void) | undefined; + #notifying = false; constructor(clock: InvocationClock, observations = new ExitObservations()) { this.#clock = clock; @@ -141,6 +143,26 @@ export class InvocationRegistry { return this.#failures.size; } + /** The same live absolute budget used by owners; settling cleanup never renews it. */ + get budget(): RemainingBudget { + return this.#budget; + } + + /** Published before callbacks; a native coordinator can observe autonomous owner escalation. */ + get termination(): Promise | undefined { + return this.#termination; + } + + /** One invocation-owned process adapter, not an unbounded history of subscribers. */ + observeTermination(listener: () => void): () => void { + if (this.#terminationObserver !== undefined) throw new CliError('cli_internal', ''); + this.#terminationObserver = listener; + if (this.#termination !== undefined) this.#notifyTermination(); + return () => { + if (this.#terminationObserver === listener) this.#terminationObserver = undefined; + }; + } + assertAdmitting(): void { if (!this.#admitting) throw new CliError('cli_internal', ''); } @@ -204,6 +226,7 @@ export class InvocationRegistry { this.#stopping = true; try { this.#observeRequest(request); + this.#notifyTermination(); for (const entry of entries) { entry.closing = true; this.#attempt(entry, 'stop', entry.owner.stop); @@ -229,6 +252,7 @@ export class InvocationRegistry { unacknowledgedOwners: this.#entries.size, }), ); + this.#notifyTermination(); }); return this.#termination; } @@ -389,7 +413,7 @@ export class InvocationRegistry { if (!isOwnerFailureCode(code)) code = 'cli_internal'; this.#failures.add(`${entry.owner.kind}:${stage}:${code}`); this.observations.observeFailure(code); - if ((code === 'cli_internal' || code === 'output_unavailable') && !this.#finished) { + if (code === 'cli_internal' || code === 'output_unavailable') { // A callback's first infrastructure failure has the same absolute budget as an external fatal request. // Recoverable background rejection is deliberately a separate observation API. try { @@ -405,17 +429,15 @@ export class InvocationRegistry { #observeRequest(request: TerminationRequest): void { if (request.kind === 'infrastructure') { this.observations.observeFailure(request.code); - if (!this.#finished) { - try { - this.#tighten(this.#now() + FATAL_TERMINATION_MS); - } catch { - this.observations.observeFailure('cli_internal'); - this.#expire(); - } + try { + this.#tighten(this.#now() + FATAL_TERMINATION_MS); + } catch { + this.observations.observeFailure('cli_internal'); + this.#expire(); } } else if (request.kind === 'signal') { this.observations.observeTerminatingSignal(request.signalNumber); - if (!this.#finished && request.deadline !== undefined) { + if (request.deadline !== undefined) { try { this.#tighten(request.deadline); } catch { @@ -425,6 +447,7 @@ export class InvocationRegistry { } } this.#sampleHealth(); + this.#notifyTermination(); } #now(): number { @@ -440,7 +463,27 @@ export class InvocationRegistry { this.#disarmTimer(); const remaining = deadline - this.#now(); if (remaining <= 0) this.#expire(); - else this.#disarm = this.#clock.schedule(remaining, () => this.#expire()); + else if (!this.#finished) this.#disarm = this.#clock.schedule(remaining, () => this.#expire()); + this.#notifyTermination(); + } + + #notifyTermination(): void { + const listener = this.#terminationObserver; + if (listener === undefined || this.#termination === undefined || this.#notifying) return; + this.#notifying = true; + try { + // Native adapters schedule their finalization; synchronous notification cannot acknowledge an owner. + listener(); + } catch { + this.observations.observeFailure('cli_internal'); + try { + void this.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + } catch { + this.#expire(); + } + } finally { + this.#notifying = false; + } } #disarmTimer(): void { diff --git a/apps/cli/src/process/io.test.ts b/apps/cli/src/process/io.test.ts index fde081aa..6ca4696d 100644 --- a/apps/cli/src/process/io.test.ts +++ b/apps/cli/src/process/io.test.ts @@ -74,8 +74,8 @@ describe('native diagnostic acknowledgement (ADR-0098)', () => { try { const done = ioFor(stderr).writeErrAcknowledged('notice\n'); const assertion = expect(done).rejects.toMatchObject({ - code: 'internal', - message: 'The diagnostic output could not be acknowledged.', + code: 'output_unavailable', + exitCode: 9, }); if (kind === 'callback') stderr.complete?.(new Error('SECRET_NATIVE_FAILURE')); else stderr.destroy(kind === 'destroy' ? new Error('SECRET_NATIVE_FAILURE') : undefined); @@ -107,7 +107,9 @@ describe('native diagnostic acknowledgement (ADR-0098)', () => { } const io = ioFor(stderr); stderr.destroy(new Error('SECRET_FILE_FAILURE')); - await expect(io.writeErrAcknowledged('notice\n')).rejects.toMatchObject({ code: 'internal' }); + await expect(io.writeErrAcknowledged('notice\n')).rejects.toMatchObject({ + code: 'output_unavailable', + }); await released(stderr); expect(stderr.closed).toBe(true); } finally { @@ -121,7 +123,7 @@ describe('native diagnostic acknowledgement (ADR-0098)', () => { stderr.destroy(); await new Promise((resolve) => setImmediate(resolve)); await expect(ioFor(stderr).writeErrAcknowledged('notice\n')).rejects.toMatchObject({ - code: 'internal', + code: 'output_unavailable', }); expect(stderr.frames).toEqual([]); await released(stderr); diff --git a/apps/cli/src/process/io.ts b/apps/cli/src/process/io.ts index 5b423bd6..e663cbf3 100644 --- a/apps/cli/src/process/io.ts +++ b/apps/cli/src/process/io.ts @@ -39,9 +39,7 @@ export function processIo(): CliIo { }; } -const STDERR_FAILED = 'The diagnostic output could not be acknowledged.'; - -function canWrite(stream: NodeJS.WriteStream): boolean { +export function canWrite(stream: NodeJS.WriteStream): boolean { return ( stream.writable && !stream.destroyed && @@ -52,7 +50,7 @@ function canWrite(stream: NodeJS.WriteStream): boolean { } /** Native write callbacks acknowledge delivery; returning from write() only acknowledges enqueueing. */ -function acknowledgeWrite(stream: NodeJS.WriteStream, text: string): Promise { +export function acknowledgeWrite(stream: NodeJS.WriteStream, text: string): Promise { return new Promise((resolve, reject) => { let pending = true; const release = (): void => { @@ -72,7 +70,7 @@ function acknowledgeWrite(stream: NodeJS.WriteStream, text: string): Promise finish(false); stream.on('error', failed); diff --git a/apps/cli/src/process/native-invocation-native.test.ts b/apps/cli/src/process/native-invocation-native.test.ts new file mode 100644 index 00000000..abc67dcc --- /dev/null +++ b/apps/cli/src/process/native-invocation-native.test.ts @@ -0,0 +1,139 @@ +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const register = fileURLToPath( + new URL('../engine/fixtures/usage-coverage-crash-register.mjs', import.meta.url), +); +const fixture = fileURLToPath(new URL('./fixtures/native-invocation-child.ts', import.meta.url)); + +async function run(mode: string) { + const root = mkdtempSync(join(tmpdir(), 'relavium-native-invocation-')); + const child = spawn( + process.execPath, + ['--no-warnings', '--experimental-strip-types', '--import', register, fixture, mode, root], + { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + env: { ...process.env, RELAVIUM_DEBUG: '1' }, + }, + ); + const input = child.stdin; + const output = child.stdout; + const errorOutput = child.stderr; + if (input === null || output === null || errorOutput === null) + throw new Error('fixture pipes missing'); + let stdout = ''; + let stderr = ''; + let watchdogFired = false; + const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + child.once('close', (code, signal) => resolve({ code, signal })); + }); + const watchdog = setTimeout(() => { + watchdogFired = true; + child.kill('SIGKILL'); + }, 9000); + const collect = (sink: 'stdout' | 'stderr', chunk: Buffer) => { + if (sink === 'stdout') stdout += chunk.toString('utf8'); + else stderr += chunk.toString('utf8'); + if (stdout.length + stderr.length > 32_768) child.kill('SIGKILL'); + }; + output.on('data', (chunk: Buffer) => collect('stdout', chunk)); + errorOutput.on('data', (chunk: Buffer) => collect('stderr', chunk)); + try { + const ready = await Promise.race([ + new Promise((resolve) => { + child.once('message', () => resolve(true)); + }), + closed.then(() => false), + ]); + expect(ready, stderr).toBe(true); + if (mode === 'stdout-pipe') output.destroy(); + if (mode === 'stderr-pipe') errorOutput.destroy(); + child.send({ type: 'go' }); + const exit = await closed; // Native close joins the process and all inherited pipes. + expect(watchdogFired, stderr).toBe(false); + expect(exit.signal, stderr).toBeNull(); + expect(exit.code, stderr).toBe(9); + const witness: unknown = JSON.parse(readFileSync(join(root, 'exit.json'), 'utf8')); + return { stdout, stderr, witness, touched: existsSync(join(root, 'getter-touched')) }; + } finally { + clearTimeout(watchdog); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await closed; + input.destroy(); + output.destroy(); + errorOutput.destroy(); + rmSync(root, { recursive: true, force: true }); + } +} + +describe('native coordinator fixture processes (explicit foundation installation)', () => { + it('autonomous owner fault exits hung main at the common deadline without closing its actual owned SQLite DB', async () => { + const result = await run('autonomous-db'); + expect(result.witness).toMatchObject({ + activeOwners: 1, + stops: 1, + dbOpen: true, + }); + expect(result.witness).toHaveProperty('elapsed', expect.any(Number)); + const elapsed: unknown = + typeof result.witness === 'object' && result.witness !== null + ? Object.getOwnPropertyDescriptor(result.witness, 'elapsed')?.value + : undefined; + expect(elapsed).toBeGreaterThanOrEqual(2700); + expect(elapsed).toBeLessThan(4100); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); // No async diagnosis starts at expiry. + }, 12_000); + + it('does not renew three seconds after owner retirement while a synthetic diagnostic ACK stays held', async () => { + const result = await run('held-ack'); + expect(result.witness).toMatchObject({ activeOwners: 0, stops: 1 }); + const elapsed: unknown = + typeof result.witness === 'object' && result.witness !== null + ? Object.getOwnPropertyDescriptor(result.witness, 'elapsed')?.value + : undefined; + expect(elapsed).toBeGreaterThanOrEqual(2700); + expect(elapsed).toBeLessThan(4000); // A renewed budget after 1200ms retirement exits after 4200ms. + expect(result.stdout).toBe(''); + expect(result.stderr.trim().split('\n')).toHaveLength(1); + expect(JSON.parse(result.stderr)).toMatchObject({ type: 'error', code: 'cli_internal' }); + }, 12_000); + + it('ACKs real restoration bytes before the one fixed final diagnostic', async () => { + const result = await run('restoration'); + const restoration = '\u001b[?25h\u001b[?1049l'; + expect(result.witness).toMatchObject({ activeOwners: 0, stops: 1, restored: true }); + expect(result.stderr.startsWith(restoration)).toBe(true); + const diagnostic = result.stderr.slice(restoration.length); + expect(diagnostic.trim().split('\n')).toHaveLength(1); + expect(JSON.parse(diagnostic)).toMatchObject({ type: 'error', code: 'cli_internal' }); + expect(result.stdout).toBe(''); + }, 12_000); + + it.each(['stdout-pipe', 'stderr-pipe'])( + 'observes actual %s EPIPE without diagnostic fallback to stdout', + async (mode) => { + const result = await run(mode); + expect(result.stdout).toBe(''); + if (mode === 'stderr-pipe') expect(result.stderr).toBe(''); + else + expect(JSON.parse(result.stderr)).toMatchObject({ + type: 'error', + code: 'output_unavailable', + }); + }, + 12_000, + ); + + it('native uncaughtException hook ignores hostile message, cause, stack and coercion getters', async () => { + const result = await run('fatal-opaque'); + expect(result.touched).toBe(false); + expect(result.stdout).toBe(''); + expect(JSON.parse(result.stderr)).toMatchObject({ type: 'error', code: 'cli_internal' }); + expect(result.stderr).not.toContain('secret'); + }, 12_000); +}); diff --git a/apps/cli/src/process/native-invocation.test.ts b/apps/cli/src/process/native-invocation.test.ts new file mode 100644 index 00000000..f55dee83 --- /dev/null +++ b/apps/cli/src/process/native-invocation.test.ts @@ -0,0 +1,349 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { OwnedTtyOutput } from '../test-support.js'; +import { + InvocationRegistry, + type InvocationClock, + type OwnerRetirement, + type SessionOwner, +} from './invocation-registry.js'; +import { installNativeInvocation, type NativeInvocationPorts } from './native-invocation.js'; + +function deferred() { + let resolve: (value: T) => void = () => undefined; + let reject: (reason: unknown) => void = () => undefined; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +class Clock implements InvocationClock { + time = 0; + readonly timers = new Set<{ at: number; fire: () => void }>(); + readonly now = () => this.time; + readonly schedule = (milliseconds: number, fire: () => void) => { + const timer = { at: this.time + milliseconds, fire }; + this.timers.add(timer); + return () => { + this.timers.delete(timer); + }; + }; + advance(time: number) { + this.time = time; + for (const timer of [...this.timers]) + if (timer.at <= time && this.timers.delete(timer)) timer.fire(); + } +} + +class HeldOutput extends OwnedTtyOutput { + readonly entered = deferred(); + acknowledge: (error?: Error | null) => void = () => undefined; + override _write( + chunk: unknown, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + this.frames.push(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)); + this.acknowledge = callback; + this.entered.resolve(); + } +} + +function owner(retired: Promise, extra: Partial = {}): SessionOwner { + return { + kind: 'session', + surface: 'chat', + retired, + stop: () => undefined, + join: () => undefined, + classifyFailure: () => 'cli_internal', + ...extra, + }; +} +const cleanup: (() => void)[] = []; +afterEach(() => { + for (const dispose of cleanup.splice(0)) dispose(); +}); + +function fixture(stderr: OwnedTtyOutput = new OwnedTtyOutput(), stdout = new OwnedTtyOutput()) { + const clock = new Clock(); + const registry = new InvocationRegistry(clock); + let fatal: () => void = () => undefined; + let background: (reason: unknown) => void = () => undefined; + let exitCode: string | number | undefined; + const exit = vi.fn(); + const removeFatal = vi.fn(); + const removeBackground = vi.fn(); + const ports: NativeInvocationPorts = { + stdout, + stderr, + schedule: clock.schedule, + onFatal: (listener) => { + fatal = listener; + return removeFatal; + }, + onBackground: (listener) => { + background = listener; + return removeBackground; + }, + readExitCode: () => exitCode, + setExitCode: (code) => { + exitCode = code; + }, + exit, + }; + const native = installNativeInvocation(registry, { json: true, verbosity: 'verbose' }, ports); + cleanup.push(() => { + native.dispose(); + stdout.destroy(); + stderr.destroy(); + }); + return { + registry, + clock, + native, + stdout, + stderr, + fatal: () => fatal(), + background: (reason: unknown) => background(reason), + exit, + exitCode: () => exitCode, + removeFatal, + removeBackground, + }; +} + +describe('inert native invocation foundation', () => { + it('registers captured hooks without installing global process listeners and disposes them once', () => { + const before = [ + process.listenerCount('uncaughtException'), + process.listenerCount('unhandledRejection'), + ]; + const f = fixture(); + expect([ + process.listenerCount('uncaughtException'), + process.listenerCount('unhandledRejection'), + ]).toEqual(before); + f.native.dispose(); + f.native.dispose(); + expect(f.removeFatal).toHaveBeenCalledOnce(); + expect(f.removeBackground).toHaveBeenCalledOnce(); + expect(f.stdout.listenerCount('error')).toBe(0); + expect(f.stderr.listenerCount('error')).toBe(0); + }); + + it('autonomously observes an individual owner close fault while runtime work stays unresolved', async () => { + const f = fixture(); + const life = deferred(); + const stop = vi.fn(); + const runtime = deferred(); + const lease = f.registry.register( + owner(life.promise, { + stop, + join: () => { + throw new Error('opaque join fault'); + }, + }), + ); + const closing = lease.close(); + expect(f.registry.termination).toBeDefined(); + expect(() => f.registry.assertAdmitting()).toThrow(); + f.clock.advance(3000); + expect(await f.native.finished).toBe(9); + expect(await closing).toEqual({ kind: 'unacknowledged' }); + expect(f.registry.activeOwners).toBe(1); + expect(stop).toHaveBeenCalledOnce(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(f.stderr.frames).toEqual([]); // Deadline does not begin a new flush. + runtime.resolve(); + life.resolve({ kind: 'settled' }); + }); + + it('keeps the original deadline through owner retirement and held final diagnostic ACK', async () => { + const stderr = new HeldOutput(); + const f = fixture(stderr); + const life = deferred(); + const stop = vi.fn(); + f.registry.register(owner(life.promise, { stop })); + f.native.fail('cli_internal'); + f.clock.advance(1900); + life.resolve({ kind: 'settled' }); + await stderr.entered.promise; + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.budget.deadline()).toBe(3000); + expect(f.registry.budget.remainingMs()).toBe(1100); + expect(f.exit).not.toHaveBeenCalled(); + f.native.fail('cli_internal'); + f.clock.advance(2999); + expect(f.exit).not.toHaveBeenCalled(); + f.clock.advance(3000); + expect(await f.native.finished).toBe(9); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(stderr.frames).toHaveLength(1); + expect(stop).toHaveBeenCalledOnce(); + stderr.acknowledge(); + }); + + it('tightens the native backstop on a later absolute signal deadline without restarting diagnosis', async () => { + const stderr = new HeldOutput(); + const f = fixture(stderr); + f.native.fail('cli_internal'); + await stderr.entered.promise; + f.clock.advance(100); + f.native.signal(15, 700); + expect(f.registry.budget.deadline()).toBe(700); + f.clock.advance(700); + expect(await f.native.finished).toBe(9); + expect(stderr.frames).toHaveLength(1); + stderr.acknowledge(); + }); + + it('finishes normal cleanup with the actual published outcome and no forced native exit', async () => { + const f = fixture(); + f.registry.observations.observeOutcome(3); + expect(await f.native.complete()).toBe(3); + expect(f.exitCode()).toBe(3); + expect(f.exit).not.toHaveBeenCalled(); + expect(f.stderr.frames).toEqual([]); + }); + + it('starts one fatal budget after normal owner settlement when a later real stream fails', async () => { + const f = fixture(); + expect(await f.native.complete()).toBe(0); + f.clock.advance(4000); + f.stdout.emit('error', new Error('opaque native failure')); + expect(f.registry.budget.deadline()).toBe(7000); + // Delivery, rather than the already-resolved complete promise, is the exit barrier. + await new Promise((resolve) => setImmediate(resolve)); + expect(f.exit, 'NATIVE_FINALIZATION_LATE_FAULT').toHaveBeenCalledExactlyOnceWith(9); + expect(f.stderr.frames.join('')).toContain('output_unavailable'); + }); + + it.each(['stdout', 'stderr'] as const)( + 'observes direct %s errors and never redirects diagnosis to stdout', + async (sink) => { + const f = fixture(); + f[sink].emit('error', new Error('private output canary')); + expect(await f.native.finished).toBe(9); + expect(f.stdout.frames).toEqual([]); + if (sink === 'stderr') expect(f.stderr.frames).toEqual([]); + else expect(f.stderr.frames.join('')).toContain('output_unavailable'); + expect(f.stderr.frames.join('')).not.toContain('canary'); + }, + ); + + it.each(['stdout', 'stderr'] as const)( + 'detects an already ended %s at installation', + async (sink) => { + const stdout = new OwnedTtyOutput(); + const stderr = new OwnedTtyOutput(); + const stream = sink === 'stdout' ? stdout : stderr; + await new Promise((resolve) => stream.end(resolve)); + const f = fixture(stderr, stdout); + expect(await f.native.finished).toBe(9); + expect(stdout.frames).toEqual([]); + if (sink === 'stderr') expect(stderr.frames).toEqual([]); + }, + ); + + it('treats failed diagnostic callback as output failure without retry or stdout fallback', async () => { + const stderr = new HeldOutput(); + const f = fixture(stderr); + f.native.fail('cli_internal'); + await stderr.entered.promise; + stderr.acknowledge(new Error('private callback details')); + expect(await f.native.finished).toBe(9); + expect(f.registry.observations.infrastructureCode()).toBe('output_unavailable'); + expect(stderr.frames).toHaveLength(1); + expect(f.stdout.frames).toEqual([]); + }); + + it('restores terminal custody before the single final diagnosis and ignores opaque fatal reasons', async () => { + const f = fixture(); + const terminal = deferred(); + const trace: string[] = []; + f.stderr.onFrame = () => trace.push('diagnostic'); + f.registry.register({ + ...owner(terminal.promise), + kind: 'terminal', + stop: () => { + trace.push('stop'); + }, + restore: () => { + trace.push('restore'); + terminal.resolve({ kind: 'settled' }); + }, + }); + f.fatal(); + f.fatal(); + f.native.fail('output_unavailable'); + expect(await f.native.finished).toBe(9); + expect(trace).toEqual(['stop', 'restore', 'diagnostic']); + expect(f.stderr.frames).toHaveLength(1); + expect(f.exit).toHaveBeenCalledOnce(); + }); + + it('preserves background keepalive without sealing admission or forcing exit', async () => { + const f = fixture(); + const touched = vi.fn(() => { + throw new Error('private getter'); + }); + const hostile = Object.defineProperties( + {}, + { message: { get: touched }, stack: { get: touched }, cause: { get: touched } }, + ); + for (let index = 0; index < 10_000; index++) f.background(hostile); + f.registry.assertAdmitting(); + expect(f.registry.termination).toBeUndefined(); + expect(f.exit).not.toHaveBeenCalled(); + expect(touched).not.toHaveBeenCalled(); + await new Promise((resolve) => setImmediate(resolve)); + expect(f.stderr.frames).toHaveLength(6); + expect(await f.native.complete()).toBe(9); + expect(f.exit).not.toHaveBeenCalled(); + }); + + it('samples actual workflow safety again at deadline despite competing fatal, output and signal facts', async () => { + const f = fixture(); + const life = deferred(); + let safety: 5 | 8 = 5; + f.registry.register({ ...owner(life.promise), kind: 'workflow', readSafetyExit: () => safety }); + f.native.signal(15, 3000); + f.native.fail('cli_internal'); + f.native.fail('output_unavailable'); + safety = 8; + f.clock.advance(3000); + expect(await f.native.finished).toBe(8); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(8); + expect(f.registry.activeOwners).toBe(1); + life.resolve({ kind: 'settled' }); + }); + + it('does not preempt sibling finite stop requests when the first stop consumes the entire deadline', async () => { + const f = fixture(); + const life = deferred(); + const trace: string[] = []; + f.exit.mockImplementation(() => trace.push('exit')); + f.registry.register( + owner(life.promise, { + stop: () => { + trace.push('first'); + f.clock.advance(3000); + }, + }), + ); + f.registry.register( + owner(life.promise, { + stop: (budget) => { + trace.push(`second:${budget.remainingMs()}`); + }, + }), + ); + f.native.fail('cli_internal'); + expect(trace).toEqual(['first', 'second:0']); + expect(await f.native.finished).toBe(9); + expect(trace).toEqual(['first', 'second:0', 'exit']); + life.resolve({ kind: 'settled' }); + }); +}); diff --git a/apps/cli/src/process/native-invocation.ts b/apps/cli/src/process/native-invocation.ts new file mode 100644 index 00000000..c024a4af --- /dev/null +++ b/apps/cli/src/process/native-invocation.ts @@ -0,0 +1,231 @@ +import { createBackgroundObserver } from './background-observer.js'; +import type { DiagnosticPolicy } from './diagnostic-policy.js'; +import { formatFixedDiagnostic } from './fixed-diagnostic.js'; +import type { InvocationRegistry } from './invocation-registry.js'; +import { acknowledgeWrite, canWrite } from './io.js'; + +export interface NativeInvocationPorts { + readonly stdout: NodeJS.WriteStream; + readonly stderr: NodeJS.WriteStream; + readonly schedule: (milliseconds: number, fire: () => void) => () => void; + readonly onFatal: (listener: () => void) => () => void; + readonly onBackground: (listener: (reason: unknown) => void) => () => void; + readonly readExitCode: () => string | number | null | undefined; + readonly setExitCode: (code: number) => void; + readonly exit: (code: number) => void; +} + +export interface NativeInvocation { + readonly fail: (code: 'cli_internal' | 'output_unavailable') => void; + /** Caller owns the original absolute terminating-signal deadline, not a renewed cleanup allowance. */ + readonly signal: (signalNumber: number, deadline: number) => void; + /** Runtime owners publish the actual surface outcome/health before calling complete. */ + readonly complete: () => Promise; + readonly finished: Promise; + /** Fixture/lifetime disposal only; never acknowledges owner retirement or terminal restoration. */ + readonly dispose: () => void; +} + +function processPorts(): NativeInvocationPorts { + return { + stdout: process.stdout, + stderr: process.stderr, + schedule: (milliseconds, fire) => { + const timer = setTimeout(fire, milliseconds); + return () => clearTimeout(timer); + }, + onFatal: (listener) => { + process.on('uncaughtException', listener); + return () => process.off('uncaughtException', listener); + }, + onBackground: (listener) => { + process.on('unhandledRejection', listener); + return () => process.off('unhandledRejection', listener); + }, + readExitCode: () => process.exitCode, + setExitCode: (code) => { + process.exitCode = code; + }, + exit: (code) => process.exit(code), + }; +} + +/** + * Explicitly installed native foundation. Product bootstrap activation waits for Step 8d's actual owners. + * No import-time listeners, service imports, cause inspection, DB close or receipt acknowledgement. + */ +export function installNativeInvocation( + registry: InvocationRegistry, + policy: DiagnosticPolicy, + ports: NativeInvocationPorts = processPorts(), +): NativeInvocation { + let finalizationPending = false; + let changedWhilePending = false; + let exited = false; + let disposed = false; + let diagnosticStarted = false; + let stderrUsable = canWrite(ports.stderr); + let stdoutUsable = canWrite(ports.stdout); + let disarm: (() => void) | undefined; + let scheduledDeadline: number | undefined; + let finish: (code: number) => void = () => undefined; + const finished = new Promise((resolve) => { + finish = resolve; + }); + + const clearTimer = (): void => { + const previous = disarm; + disarm = undefined; + try { + previous?.(); + } catch { + registry.observations.observeFailure('cli_internal'); + } + }; + const exitNow = (): void => { + if (exited || disposed) return; + exited = true; + clearTimer(); + registry.sampleWorkflowHealth(); + const code = registry.observations.exitCode(); + ports.setExitCode(code); + finish(code); + ports.exit(code); + }; + const scheduleBackstop = (): void => { + const deadline = registry.budget.deadline(); + if (exited || disposed || deadline === undefined || scheduledDeadline === deadline) return; + scheduledDeadline = deadline; + clearTimer(); + const remaining = registry.budget.remainingMs(); + if (remaining === undefined || remaining <= 0) { + // Notifications run inside the registry's attempt-all stop pass. Never preempt sibling cancellation. + queueMicrotask(exitNow); + return; + } + try { + disarm = ports.schedule(remaining, () => queueMicrotask(exitNow)); + } catch { + registry.observations.observeFailure('cli_internal'); + queueMicrotask(exitNow); + } + }; + const fail = (code: 'cli_internal' | 'output_unavailable'): void => { + if (exited || disposed) return; + void registry.terminate({ kind: 'infrastructure', code }); + }; + const finalize = async (): Promise => { + const termination = registry.termination; + if (termination === undefined || exited || disposed) return; + const result = await termination; + if (exited || disposed) return; + registry.sampleWorkflowHealth(); + if (result.kind === 'deadline' || registry.budget.remainingMs() === 0) { + exitNow(); + return; + } + if (registry.observations.hasInfrastructureFailure() && stderrUsable && !diagnosticStarted) { + diagnosticStarted = true; + const code = registry.observations.infrastructureCode() ?? 'cli_internal'; + try { + await acknowledgeWrite(ports.stderr, formatFixedDiagnostic(code, policy)); + } catch { + stderrUsable = false; + fail('output_unavailable'); + } + } + if (exited || disposed) return; + registry.sampleWorkflowHealth(); + if ( + registry.observations.hasInfrastructureFailure() || + registry.observations.hasTerminatingSignal() + ) { + exitNow(); + return; + } + clearTimer(); + const code = registry.observations.exitCode(); + ports.setExitCode(code); + finish(code); + }; + const changed = (): void => { + scheduleBackstop(); + if (exited || disposed) return; + if (finalizationPending) { + changedWhilePending = true; + return; + } + finalizationPending = true; + changedWhilePending = false; + queueMicrotask(() => { + void finalize() + .catch(() => { + fail('cli_internal'); + exitNow(); + }) + .finally(() => { + finalizationPending = false; + // complete() can resolve before this finally reaction runs. Retain any competing late fault. + if ( + changedWhilePending && + !exited && + !disposed && + (registry.observations.hasInfrastructureFailure() || + registry.observations.hasTerminatingSignal()) + ) + changed(); + }); + }); + }; + const removeObserver = registry.observeTermination(changed); + const stdoutFailed = (): void => { + stdoutUsable = false; + fail('output_unavailable'); + }; + const stderrFailed = (): void => { + stderrUsable = false; + fail('output_unavailable'); + }; + ports.stdout.on('error', stdoutFailed); + ports.stdout.on('close', stdoutFailed); + ports.stderr.on('error', stderrFailed); + ports.stderr.on('close', stderrFailed); + const background = createBackgroundObserver({ + observations: registry.observations, + policy, + readExitCode: ports.readExitCode, + setExitCode: ports.setExitCode, + write: (text) => { + if (!stderrUsable) return Promise.reject(new Error('Diagnostic output is unavailable.')); + return acknowledgeWrite(ports.stderr, text); + }, + outputFailed: stderrFailed, + }); + const removeFatal = ports.onFatal(() => fail('cli_internal')); + const removeBackground = ports.onBackground(background.observe); + if (!stdoutUsable) stdoutFailed(); + if (!stderrUsable) stderrFailed(); + return Object.freeze({ + fail, + signal: (signalNumber: number, deadline: number) => { + void registry.terminate({ kind: 'signal', signalNumber, deadline }); + }, + complete: () => { + void registry.terminate({ kind: 'normal' }); + return finished; + }, + finished, + dispose: () => { + if (disposed) return; + disposed = true; + clearTimer(); + removeObserver(); + removeFatal(); + removeBackground(); + ports.stdout.off('error', stdoutFailed); + ports.stdout.off('close', stdoutFailed); + ports.stderr.off('error', stderrFailed); + ports.stderr.off('close', stderrFailed); + }, + }); +} diff --git a/apps/cli/src/process/owned-storage.test.ts b/apps/cli/src/process/owned-storage.test.ts new file mode 100644 index 00000000..68fd74be --- /dev/null +++ b/apps/cli/src/process/owned-storage.test.ts @@ -0,0 +1,103 @@ +import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { CliError, toUserFacing } from './errors.js'; +import { + chmodForStorage, + mkdirForStorage, + storageBoundaryError, + writeFileForStorage, +} from './owned-storage.js'; + +vi.mock('node:fs', async (original) => { + const native = await original(); + return { ...native, chmodSync: vi.fn(), mkdirSync: vi.fn(), writeFileSync: vi.fn() }; +}); +afterEach(() => vi.resetAllMocks()); + +const operations = [ + { name: 'mkdir', native: vi.mocked(mkdirSync), run: () => mkdirForStorage('PRIVATE_PATH') }, + { + name: 'chmod', + native: vi.mocked(chmodSync), + run: () => chmodForStorage('PRIVATE_PATH', 0o600), + }, + { + name: 'write', + native: vi.mocked(writeFileSync), + run: () => writeFileForStorage('PRIVATE_PATH', 'PRIVATE_BYTES', false), + }, +]; + +describe('actual CLI filesystem boundaries', () => { + for (const operation of operations) { + it.each(['EACCES', 'EPERM', 'ENOSPC', 'EROFS'])( + `${operation.name}: owns native %s without exposing native detail`, + (code) => { + const cause = Object.assign(new Error('PRIVATE_NATIVE_TEXT'), { code }); + operation.native.mockImplementationOnce(() => { + throw cause; + }); + let failure: unknown; + try { + operation.run(); + } catch (error) { + failure = error; + } + expect(failure, 'CLI_ORIGIN_FS_BOUNDARY').toBeInstanceOf(CliError); + expect(failure).toMatchObject({ code: 'storage_unavailable', exitCode: 1, cause }); + expect(JSON.stringify(toUserFacing(failure))).not.toContain('PRIVATE_'); + }, + ); + + it(`${operation.name}: leaves unknown faults and target refusals without storage authority`, () => { + for (const code of ['EEXIST', 'EISDIR', 'ENOTDIR', 'ENOENT', 'ERR_DLOPEN_FAILED']) { + const failure = Object.assign(new Error('PRIVATE_NATIVE_TEXT'), { code }); + operation.native.mockImplementationOnce(() => { + throw failure; + }); + expect(operation.run).toThrow(failure); + expect(toUserFacing(failure).code).toBe('cli_internal'); + } + }); + + it(`${operation.name}: does not invoke inherited/accessor errno or reflection traps`, () => { + const getter = vi.fn(() => 'ENOSPC'); + const accessor = Object.defineProperty({}, 'code', { get: getter }); + const inherited: unknown = Object.create({ code: 'ENOSPC' }); + const trapped = new Proxy( + {}, + { + getOwnPropertyDescriptor: () => { + throw new Error('PRIVATE_TRAP'); + }, + }, + ); + for (const failure of [accessor, inherited, trapped]) { + operation.native.mockImplementationOnce(() => { + throw failure; + }); + let observed: unknown; + try { + operation.run(); + } catch (error) { + observed = error; + } + expect(observed).toBe(failure); + } + expect(getter).not.toHaveBeenCalled(); + }); + } + + it('never promotes an errno-shaped error from an injected opener outside owned I/O', () => { + const cause = Object.assign(new Error('PRIVATE_PLUGIN_BYTES'), { code: 'ENOSPC' }); + const error = storageBoundaryError(cause); + expect(error).toMatchObject({ code: 'cli_internal', exitCode: 9, cause }); + expect(error.message).not.toContain('PRIVATE_'); + }); + + it('does not remap an already classified known operational failure', () => { + const error = new CliError('internal', 'known departure refusal'); + expect(storageBoundaryError(error)).toBe(error); + expect(error.exitCode).toBe(1); + }); +}); diff --git a/apps/cli/src/process/owned-storage.ts b/apps/cli/src/process/owned-storage.ts new file mode 100644 index 00000000..6e189f93 --- /dev/null +++ b/apps/cli/src/process/owned-storage.ts @@ -0,0 +1,65 @@ +import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; + +import { CliError, isCliError, toUserFacing } from './errors.js'; + +/** Project an escaping open/maintenance failure without inventing storage provenance. */ +export function storageBoundaryError(value: unknown): CliError { + const projected = toUserFacing(value); + try { + if ( + isCliError(value) && + Object.getOwnPropertyDescriptor(value, 'code')?.value === projected.code && + Object.getOwnPropertyDescriptor(value, 'message')?.value === projected.message && + Object.getOwnPropertyDescriptor(value, 'exitCode')?.value === projected.exitCode + ) + return value; + } catch { + // A hostile public error cannot bypass the defensive runtime projection. + } + return new CliError(projected.code, projected.message, { cause: value }); +} + +/** Directory creation owned by the CLI's storage/export boundary. */ +export function mkdirForStorage(path: string): void { + try { + mkdirSync(path, { recursive: true }); + } catch (error) { + throwOwnedFilesystemFailure(error); + } +} + +/** Permission hardening owned by the CLI's local storage boundary. */ +export function chmodForStorage(path: string, mode: number): void { + try { + chmodSync(path, mode); + } catch (error) { + throwOwnedFilesystemFailure(error); + } +} + +/** The session-export file write, with atomic no-overwrite admission preserved. */ +export function writeFileForStorage(path: string, content: string, force: boolean): void { + try { + writeFileSync(path, content, { encoding: 'utf8', flag: force ? 'w' : 'wx' }); + } catch (error) { + throwOwnedFilesystemFailure(error); + } +} + +// Only these actual native operations grant errno origin. No exported general errno classifier, +// arbitrary callback, message or cause can promote a foreign error to storage authority. +function throwOwnedFilesystemFailure(error: unknown): never { + let code: unknown; + try { + if (typeof error === 'object' && error !== null) { + const descriptor = Object.getOwnPropertyDescriptor(error, 'code'); + if (descriptor !== undefined && 'value' in descriptor) code = descriptor.value; + } + } catch { + // A hostile descriptor trap supplies no native storage fact. + } + if (code === 'EACCES' || code === 'EPERM' || code === 'ENOSPC' || code === 'EROFS') { + throw new CliError('storage_unavailable', '', { cause: error }); + } + throw error; +} diff --git a/apps/cli/src/process/pure-substrate-graph.test.ts b/apps/cli/src/process/pure-substrate-graph.test.ts index 52ec6ccc..21c807f4 100644 --- a/apps/cli/src/process/pure-substrate-graph.test.ts +++ b/apps/cli/src/process/pure-substrate-graph.test.ts @@ -121,6 +121,9 @@ describe('pure Step 6 substrate runtime import closure', () => { 'apps/cli/src/process/options.ts', 'apps/cli/src/process/exit-observations.ts', 'apps/cli/src/process/invocation-registry.ts', + 'apps/cli/src/process/native-invocation.ts', + 'apps/cli/src/process/fixed-diagnostic.ts', + 'apps/cli/src/process/background-observer.ts', 'apps/cli/src/process/output-mode.ts', 'apps/cli/src/process/source-label.ts', 'apps/cli/src/commands/input.ts', diff --git a/apps/cli/src/run-media-prune.test.ts b/apps/cli/src/run-media-prune.test.ts index 5f3b84d6..4917accd 100644 --- a/apps/cli/src/run-media-prune.test.ts +++ b/apps/cli/src/run-media-prune.test.ts @@ -250,7 +250,7 @@ for (const kind of ['root', 'shard', 'file-symlink', 'hardlink'] as const) { io.io, ), 'MEDIA_MAINTENANCE_CONTAINMENT', - ).toBe(kind === 'root' ? 0 : 1); + ).toBe(kind === 'root' ? 0 : 9); // Initial root aliases are admitted and pinned by ADR-0105; linked descendants are refused. expect(readFileSync(witness, 'utf8')).toBe('PRIVATE_OUTSIDE_BYTES'); expect(io.out() + io.err()).not.toContain(isolated.home); diff --git a/apps/cli/src/run.test.ts b/apps/cli/src/run.test.ts index ac4ac300..6f48d374 100644 --- a/apps/cli/src/run.test.ts +++ b/apps/cli/src/run.test.ts @@ -335,10 +335,10 @@ describe('run — bare-invocation Home gate', () => { expect(err()).toContain('home config is broken'); }); - it('reports an unexpected Home throw generically (exit 1, no raw message/stack leak)', async () => { + it('reports an unexpected Home throw as infrastructure (exit 9, no raw message/stack leak)', async () => { const { io, out, err } = interactiveIo(); const { openHome } = fakeOpenHome(() => Promise.reject(new Error('internal boom'))); - expect(await run(argv(), io, openHome)).toBe(EXIT_CODES.workflowFailed); // unexpected → exit 1 + expect(await run(argv(), io, openHome)).toBe(EXIT_CODES.infrastructureFailed); expect(out()).toBe(''); expect(err()).not.toContain('internal boom'); // the raw message is never surfaced expect(err()).toContain('unexpected'); From a4fcbc02e2dfd6b66ea2376240339a2822760e6a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 12:23:10 +0300 Subject: [PATCH 085/222] test(repo): align causal controls with origin adapters Preserve the exact CAS shard removal anchor and complete secrets workload counts after the permanent runtime-origin regressions. Refs: ADR-0110 Co-Authored-By: Codex --- tools/fs-policy-controls/mutations.mjs | 2 +- tools/secrets-controls/mutations.mjs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/fs-policy-controls/mutations.mjs b/tools/fs-policy-controls/mutations.mjs index 7ccd82eb..a6caaa7a 100644 --- a/tools/fs-policy-controls/mutations.mjs +++ b/tools/fs-policy-controls/mutations.mjs @@ -55,7 +55,7 @@ export const mutations = [ control( 'CAS_SHARD', paths, - '!info.isDirectory() ||\n info.isSymbolicLink() ||\n realpathSync(dir) !== dir ||\n !within(root, dir)', + '!info.isDirectory() ||\n info.isSymbolicLink() ||\n withOwnedFilesystem(() => realpathSync(dir)) !== dir ||\n !within(root, dir)', 'false', 'FS_CAUSAL_CAS_shard', [cases], diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index abf83c7f..d035bec7 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -347,7 +347,7 @@ export const mutations = [ ' const raw = await (deps.readSecretInput?.() ?? readSecretFromStdin(RUN_SECRET_STDIN_CONTEXT));', " const raw = await (deps.readSecretInput?.() ?? Promise.resolve(''));", ['apps/cli/src/commands/run.test.ts'], - 76, + 77, 'uses the real default reader exactly once and suppresses even an injected gate selector', 'SECRETS_CAUSAL_RUN_DEFAULT_READER', ), @@ -367,7 +367,7 @@ export const mutations = [ ' const mayPrompt = args.secretStdin !== true;', ' const mayPrompt = true;', ['apps/cli/src/commands/gate.test.ts'], - 65, + 67, 'refuses unapproved stdio after a real read despite stale TTY, without prompting or writing a grant', 'SECRETS_CAUSAL_RESUME_CONSUMED_STDIN_CONSENT', ), @@ -581,7 +581,7 @@ export const mutations = [ ' warnSnapshotLiterals(def, deps.io);\n', '', ['apps/cli/src/commands/run.test.ts'], - 76, + 77, 'warns after admission before services and preserves the actual augmented frozen workflow', 'SNAPSHOT_CAUSAL_RUN_CALLER', ), From e0a5ae19981e749a75455e6b5698c806d9f1c674 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 12:23:10 +0300 Subject: [PATCH 086/222] docs(docs): record step 8a boundaries and completed step 7 ci Document the implemented origin subset and inert native foundation. Attribute local selected controls, installed-host checks and the later completed c836 CI observation separately; independent Step 8a review and whole Step 8 acceptance remain pending. Refs: ADR-0110 Co-Authored-By: Codex --- AGENTS.md | 4 +- CLAUDE.md | 4 +- ...d-discovery-have-explicit-compatibility.md | 12 +++ docs/reference/cli/commands.md | 65 ++++++++++++- docs/reference/cli/packaging.md | 4 +- docs/reference/shared-core/database-schema.md | 39 +++++++- docs/roadmap/current.md | 10 +- .../phase-2.5.5-hardening-and-remediation.md | 93 +++++++++++++++++++ docs/standards/error-handling.md | 9 ++ 9 files changed, 228 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2285e29f..7c2df29f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,9 @@ two NEW complete cumulative clean rounds and a separate clean security sitting. Step 7b and whole Step 7 are independently accepted at `c836ffe6` after two NEW complete cumulative clean rounds, a clean security sitting and successful exact-tarball Linux/macOS/Windows plus Node 22.0.0 consumers. Broader CI retains its separately -attributed status. Step 8 proceeds as recorded serial increments 8a–8d; native +attributed status. Step 8a implements origin adapters and an inert native foundation; +its independent acceptance remains in progress. +Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance remain staged in the canonical roadmap. diff --git a/CLAUDE.md b/CLAUDE.md index 7c99bf51..e7c9f317 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,7 +183,9 @@ two NEW complete cumulative clean rounds and a separate clean security sitting. Step 7b and whole Step 7 are independently accepted at `c836ffe6` after two NEW complete cumulative clean rounds, a clean security sitting and successful exact-tarball Linux/macOS/Windows plus Node 22.0.0 consumers. Broader CI retains its separately -attributed status. Step 8 proceeds as recorded serial increments 8a–8d; native +attributed status. Step 8a implements origin adapters and an inert native foundation; +its independent acceptance remains in progress. +Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance remain staged in the canonical roadmap. diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index e6748767..d7888655 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -192,3 +192,15 @@ contracts and the nineteen-increment Wave 3 plan. Earlier proposal wording above records the review stage; approval is complete. Implementation, independent review gates and canonical promotion remain staged in that plan. Acceptance does not claim shipping behaviour, an actual release or production custody setup. + +## Step 8a partial implementation — 2026-10-16 + +The approved storage-origin subset and fixed runtime classification are implemented +on `development`; independent acceptance is still pending. The +[CLI contract](../reference/cli/commands.md#step-8a-origin-adapters) owns concrete +dispositions and the [Node database contract](../reference/shared-core/database-schema.md#node-client-failure-provenance) +owns the read-only private-identity fact. The native coordinator remains inert +until Step 8d connects every actual creation owner and caught/forced-exit path. +This does not claim whole Step 8, production fatal integration or later discovery +work complete. The [implementation checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-implementation-checkpoint--2026-10-16) +attributes local author, selected causal and installed-host evidence separately. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index b2ea6344..5caff2e8 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -580,7 +580,7 @@ Human output reports removed/retained counts and fixed retention reasons. `--jso `{ removed, retained, reasons }` object; retained reason counts sum to `retained`. Neither form contains paths, native diagnostics or file bytes. Successful maintenance, including conservatively retained candidates, exits `0`; missing flags/exclusive-offline refusal exits `2`; an operational -filesystem failure exits `1`. This command never changes published CAS objects or read grants. +filesystem failure exits `1`; unexpected and failed-output faults exit `9`. This command never changes published CAS objects or read grants. Exact temp grammars, ownership/recovery rules and reason vocabulary belong to the [physical media lifecycle contract](../shared-core/built-in-tools.md#physical-media-storage-and-limits), under [ADR-0106](../../decisions/0106-media-publication-requires-bounded-verified-bytes.md). @@ -596,7 +596,7 @@ not one of them: its Ctrl-C is a cooperative cancel that exits `1`. | Code | Meaning | |------|---------| | `0` | Workflow completed successfully | -| `1` | Workflow failed (a node errored and exhausted retries/fallbacks) | +| `1` | Workflow failed, or a classified operational/history/storage refusal; see the [surface dispositions](#failure-classification-and-remedies) | | `2` | Invalid invocation (bad arguments, workflow not found, schema validation error) | | `3` | Run paused at a human gate (CI/non-interactive mode) — resume with `relavium gate` | | `4` | A chat session ended — via `/exit`, `/cancel` (or Ctrl-C in TTY mode), or an input-stream EOF — a user-initiated end of a `relavium chat` REPL — see [chat-session.md](chat-session.md) | @@ -604,6 +604,11 @@ not one of them: its Ctrl-C is a cooperative cancel that exits `1`. | `6` | Ownership was refused before starting, or this host was fenced out and stopped without claiming an outcome ([ADR-0079](../../decisions/0079-cross-process-run-ownership-lease-and-fencing-token.md) §5, §7). Inspect current history; the successor may already have finished | | `7` | An external **effect** needs attention, including a prior unresolved attempt or an admitted effect whose final receipt remained unresolved after host joining ([effect-journal.md](../shared-core/effect-journal.md) §4, §8). **Do not retry**: inspect the target, then resolve the row out of band | | `8` | Required money receipts were not durably acknowledged. **Do not repeat paid work**: inspect `relavium logs ` and the ledger before recovery. This is separate from terminal-outbox uncertainty | +| `9` | CLI infrastructure failed, including output delivery. Inspect existing work before repeating paid/effectful operations; see the [classification and remedy contract](#failure-classification-and-remedies) | + +Wave 3 Step 8a implements runtime origin classification and fixed boundary projections. +Full fatal coordination, actual workflow-health precedence on escaping faults and +all-surface integration remain staged in Steps 8b–8d below. > Final required-money uncertainty takes priority (`8`), then effect attention (`7`), then the actual terminal/ownership disposition (`5`/`6`) and ordinary outcome. A combined `8` warning also directs the operator to inspect the effect target and resolve its row. Final health never replaces the actual terminal, its error code or durable history; it selects the command's disposition after joining admitted work. > The fixed final-health stderr diagnostic has `type: diagnostic`, `code`, `message`, `terminalDurability` (`durable`, `uncertain` or `none`) and `effectNeedsAttention`. It reports one highest-priority warning; a combined money/effect case includes both remedies. These fields are nondurable and never added to stdout RunEvents. @@ -662,9 +667,12 @@ was approved by the maintainer. Step 6 implements the pure flag/error/observatio and ownership substrate, descriptor-derived current Commander grammar and the compatible model projection. Its discovery DTO is available as a pure function; production JSON help, aliases, pricing spellings, output categories and consent -remain Step 10. Step 7's lazy entry and installed package are independently -accepted; actual fatal/native/terminal adapters remain Step 8. Earlier sections -describe current behaviour unless stated here. +remain Step 10. Step 7's lazy entry and installed package are independently accepted. Step 8a +implements minimal storage-origin adapters and defensive runtime projections, +and adds an inert native invocation foundation. Product fatal/native/terminal +activation remains pending in Step 8d, after all actual creation owners and +caught/forced-exit paths are connected. Earlier sections describe current +behaviour unless stated here. ### Diagnostic policy @@ -768,6 +776,34 @@ that label runtime permission/capacity failures invalid invocation/2 deliberatel move to operational 1. Step 6 defines this mapping and Step 8 installs minimal boundary adapters; Step 18 centralizes them after all callers have moved. +#### Step 8a origin adapters + +The runtime classifier queries the read-only +[`@relavium/db` storage-origin fact](../shared-core/database-schema.md#node-client-failure-provenance). +A public `DbOpenError('open_failed', …)`, a copied error, an errno-shaped object +or a convincing cause does not establish storage origin. The CLI's concrete +directory-creation, permission-hardening and export-write operations classify +`EACCES`, `EPERM`, `ENOSPC` and `EROFS` at those owned native calls. Shared local +opening, run/gate opening and history helpers preserve these dispositions; +centralizing all openers remains Step 18. + +Recognized storage failures project the fixed `storage_unavailable` message +and operational exit 1. Unexpected native constructor/import/ABI failures and +unknown throws project fixed `cli_internal` and exit 9. Runtime projection +uses own data descriptors; reflection/accessor faults fall back to the same +fixed infrastructure diagnostic without incoming message, cause or coercion. +Exit classification comes from the CLI policy, not a mutable error exit field. +Typed history corruption/unreadability and known run/gate departure failures +retain operational 1; URI/API validation, authoring and known maintenance +refusals retain their existing invocation disposition. + +Session export preserves target existence/shape refusal 2. Media maintenance +classifies maintenance failures before rendering its result; a failed result +write is `output_unavailable`/9, not a storage or maintenance refusal. Failed +acknowledged diagnostic writes also use `output_unavailable`/9. These boundary +changes do not yet connect every command's escaping fault or forced exit to +the invocation coordinator. + A sticky observation record computes this total priority on return **and throw**: actual workflow health **8 > 7 > 5 > 6**, then fatal/output infrastructure **9**, then an actually terminating external signal **128+signo**, then ordinary outcome. @@ -795,6 +831,25 @@ numeric value alone ([Node exit codes](https://nodejs.org/docs/latest-v22.x/api/ ### Invocation owners and fatal ordering +**Step 8a foundation; product activation pending in 8d.** The registry now +exposes read-only termination and remaining-budget observations so an +explicitly installed native adapter can react to autonomous owner failure. +The adapter observes both real output streams and uses the same absolute +three-second infrastructure budget through owner joins, registered terminal +restoration and final diagnostic acknowledgement. Later observations can +upgrade disposition or shorten that deadline; they cannot renew it. Fixed +human/JSON diagnostic formatting does not inspect a thrown value, stack or +cause, and stderr failure has no stdout diagnostic fallback. + +The successor background observer keeps the process alive, emits at most five +fixed failure records plus one suppression record, and upgrades only an +otherwise clean exit to 9. Existing nonzero outcomes remain intact. Importing +these modules installs no listeners. Production bootstrap still uses the +existing net until 8d activation; fixtures explicitly install the foundation +and register their own owners. Fixture success does not certify connection of +production workflow, session, terminal, native-child or caught/forced-exit +owners. The complete required integration contract follows. + One invocation-owned lightweight registry is installed before dynamic imports. Owners register before admitting work and retire only after actual settlement. Callbacks expose facts/capabilities specific to their owner, not a generic false diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index b151dc6f..8b0abc5c 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -75,7 +75,9 @@ shared terminal stripping and the error base, LLM secret scrubbing, and core's tool-dispatch error base. Runtime classification remains separate from diagnostic rendering. Dependency/evaluation failures reach fixed early diagnostics even if the heavy classifier cannot load; their historical fallback exit 1 is retained -until Step 8 integrates the approved origin and exit-9 policy. +until Step 8d connects all creation owners and activates the native coordinator. +Step 8a separately implements runtime storage-origin and fixed classification; +its inert native foundation does not change this early fallback yet. The emitted graph checker parses actual JavaScript and compares every loading edge with the build inventory. It rejects missing/extra/unreachable/escaping chunks, diff --git a/docs/reference/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index 93400fef..8a41e469 100644 --- a/docs/reference/shared-core/database-schema.md +++ b/docs/reference/shared-core/database-schema.md @@ -1,6 +1,6 @@ # Database Schema (Local SQLite) -> Last updated: 2026-10-13 +> Last updated: 2026-10-16 - **Status**: Reference - **Surface**: Shared — Desktop (Tauri v2, `tauri-plugin-sql`/SQLCipher), CLI (`better-sqlite3`, [ADR-0021](../../decisions/0021-node-sqlite-driver-better-sqlite3.md)/[ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md)), and VS Code (a wasm SQLite build). One schema, three drivers — see [Concurrency & transaction behavior](#concurrency--transaction-behavior) and [Encryption at rest](#encryption-at-rest) for the per-surface divergences. @@ -43,6 +43,43 @@ upgrade remain accepted residuals. See [effect-journal.md §11](effect-journal.m > Workflows and agents are **not** the database's source of truth. The git-committable YAML files (`.relavium.yaml` / `.agent.yaml`) are authoritative; see [../contracts/workflow-yaml-spec.md](../contracts/workflow-yaml-spec.md) and [../contracts/agent-yaml-spec.md](../contracts/agent-yaml-spec.md). The catalog tables below cache and snapshot them for fast querying, run reproducibility, and offline browsing. +## Node client failure provenance + +`@relavium/db` exports `isOwnedStorageFailure(value: unknown): boolean` as a +read-only origin query for the Node client. Its authority is private object +identity recorded at an owned native database or filesystem operation, not a +public error constructor, message, errno-shaped field or arbitrary cause. +Callers cannot mint or decode that authority through the package's public API. +`DbOpenError` retains its existing constructor, code/path, identity and cause +contract; an owned failure can transfer its fact to the existing wrapper. + +Factory-created clients retain an exact private association with their native +connection. SQLite classification requires the installed native `SqliteError` +brand and an explicit storage-code allowlist; generic `SQLITE_ERROR`, malformed +native failures and unknown codes do not qualify. Owned filesystem failures +require the native Error shape and matching code/errno for `EACCES`, `EPERM`, +`ENOSPC` or `EROFS`. Creation, configured PRAGMAs, migration/privacy operations +and actual media-filesystem/SQLite-lock catches supply or transfer facts at their +operation boundaries. This is not a classifier for arbitrary SQL or foreign +clients. + +Migration continues to call the installed Drizzle migrator unchanged. At that +one owned operation, a verified imported public `DrizzleError` may transfer a +fact from its single own data `cause` only when that cause is the verified +native SQLite error. There is no recursive cause traversal, driver fork, +connection instrumentation or replacement query/transaction implementation. +Foreign clients and wrappers without the factory's exact native association +supply no origin authority. + +Checkpoint failures retain native origin before conversion to the existing +privacy error. Busy/locked checkpoint deferral, logical versus physical +erasure, recovery and migration locking retain their existing semantics. +Media wrappers transfer only already-established facts; typed reason `io` +alone grants none. Media error grammar, identity checks and retention policy +remain unchanged. The [CLI disposition contract](../cli/commands.md#failure-classification-and-remedies) +owns presentation and recovery advice; origin does not certify receipt +durability, absence of effects or safe retry. + ## SQLite type conventions Because this schema is adapted from a Postgres-first design (see [../../analysis/_archive/](../../analysis/_archive/)), the following local-first conventions apply consistently across every table: diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index ef566a01..1082b018 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1128,9 +1128,13 @@ after two NEW complete cumulative clean rounds and a separate clean security sit [Step 7b and whole Step 7 are independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-7b-and-whole-step-7-acceptance--2026-10-16) at `c836ffe6` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful same-tarball current Linux/macOS/Windows and -exact Node 22.0.0 Linux consumers. Broader CI keeps its separately attributed -status. Step 8 proceeds as serial 8a–8d increments; whole native integration -remains open until their actual owners and all-surface controls pass. The +exact Node 22.0.0 Linux consumers. [The accepted c836 full CI run subsequently completed successfully](phases/phase-2.5.5-hardening-and-remediation.md#step-7-completed-full-ci-follow-up--2026-10-16), +with its executed source and CI totals recorded separately from the historical +acceptance observation. [Step 8a implements origin adapters and an inert native +foundation](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-implementation-checkpoint--2026-10-16); +its independent acceptance remains pending. Step 8 proceeds as serial 8a–8d +increments, with product fatal activation in 8d and whole Step 8 still open. +Steps 8b–19 remain pending. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index a4addfcf..c67c2e49 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -5388,3 +5388,96 @@ provider/plugin causes or a recursive private cause tree; checkpoint origin is retained before native evidence is erased. No driver fork or migrator replacement is planned. Whole Step 8 stays open until 8d's complete cumulative acceptance. The central opener refactor remains Step 18, and whole-wave publication Step 19. + +### Step 7 completed full-CI follow-up — 2026-10-16 + +After the acceptance checkpoint above, GitHub run **38869520079 / attempt 1** +completed successfully at the exact accepted source +**`c836ffe616803697285f51204c78b76ce3a99814`**. The completed CI workspace suite +reports **647 files / 14,659 passes / 21 established CI-projection skips**. +This appends the later result to the earlier in-progress observation; it does +not rewrite that historical checkpoint or claim execution of successor +metadata `d9625ff0` or the Step 8a working tree. Earlier physical artifact +audits retain their recorded scopes. Whole-wave gates repeat in Step 19. + +A separate completed filesystem physical audit verifies **110 controls / 330 phases** +from that run: all completed zero-unhandled receipts, exact immutable-source pins, +named semantic failures and byte-identical restoration. The retained tree contains +**1,987 files / 39,697,863 bytes**; the fourteen native controls retain their +42 phase identities and child/dependency witnesses. The **8,148 repeated phase +cases / 361 semantic failures** are execution totals, not unique permanent cases. +The parent reads the complete separate audit. No local re-execution or +merge-/metadata-head execution is inferred. + +## Wave 3 Step 8a implementation checkpoint — 2026-10-16 + +Step 8a implements the approved minimal storage-origin and runtime projection +subset above accepted Step 7 metadata `d9625ff0`. The DB root exposes only the +read-only storage-failure query; private factory/native association, native +storage allowlisting and fixed one-wrapper Drizzle handling preserve existing +error, migration, checkpoint and media contracts. CLI owned filesystem and +open/maintenance/export adapters distinguish storage 1, known refusals 2, +unknown infrastructure 9 and failed output 9 without a blanket internal-code +rewrite. Core exports its frozen existing transient-code authority. + +The native registry observer, fixed early diagnostic formatter, successor +keepalive observer and native stream/deadline adapter form an **inert product +foundation**. Explicit native fixtures exercise registered owners, both real +streams, restoration-before-diagnostic ordering and the common absolute +budget. Production activation waits for Step 8d's actual owners and all +caught/forced-exit paths; this checkpoint does not claim those are connected. + +Author gates, isolated causal controls, physical custody audits and independent +review reports retain their individual source and execution attribution in +the accompanying evidence record. **Step 8a is not accepted by this +implementation checkpoint. Whole Step 8 and Steps 8b–19 remain open.** No new +ADR, runtime dependency, credential, billing or provider capture is required. + +### Step 8a author and selected-control evidence + +The implementation is based on `d9625ff0`; these local checks execute its +uncommitted Step 8a body, not pristine metadata or a published commit. Ordinary +workspace checks pass **657 files / 14,791 tests / 20 established local skips**. +The last complete workspace invocation reruns CLI and reuses ten valid test +tasks; earlier affected DB/core invocations remain separately attributed. +Two subsequent parameterized-title changes keep all 66 affected cases and their +assertions, and the full two-file workload passes. Build/format, tools +lint/typecheck, schema sync, seam/engine fences and **482 acceptance-tool tests** +pass. Compiled CLI, media-packaging, overflow and budget-replay smokes pass; +negative replay controls retain their expected fixed failure diagnostics. + +A fresh local prepack and outside-workspace consumer pass **175 bootstrap controls +and six installed cases** on macOS/Node **22.23.1**. The candidate is +`ff18f5f5812b28e313cfc2d5ea1beac3f1e5685722fe63bc34df1ba80fc549f8`, +**422,086 bytes / 70 public files / 25 JS chunks / 17 dependencies**, with +explicit local `dirty: true` and parent metadata `d9625ff0`. Actual consumer +publication follows joined work and required installation removal. This is +local-host evidence; it is not a clean remote artifact or four new platform legs. + +Five DB-origin controls and six CLI/native controls independently remove one +mechanism in copied source, then restore the exact original bytes. Their +**33 completed zero-unhandled runner receipts / 750 repeated phase cases / +41 semantic failures with declared named witnesses** are independently audited with complete inventories, +source/target/test body pins, runtime/stdio/dependency receipts and restoration. +The retained local artifacts contain **222 files / 14,149,147 bytes** and +**255 / 16,239,468 bytes**, with their raw graphs and caches removed. The two +DB-backed CLI controls additionally require an actual native-addon receipt in +a target worker; four pure controls expressly decline that claim. Controlled +filesystem/pragma failures are fault injection, not measured OS failures. +The local coordinators retain `complete: false` / `acceptance: false`: they are +selected raw causal evidence, not a new finite-family archive or whole-wave +certificate. The first two CLI runs refused duplicate parameterized case names; +those refusals remain failed evidence. Permanent names were made unique before +the final full six-control run, with no reduced count or weakened guard. + +The adapted CAS-shard anchor and three updated secrets workloads also pass their +canonical fixed/removal/restored checks: **21 / 77 / 77 / 67 cases** respectively. +These selected checks do not claim new complete filesystem/secrets-family +execution. All copied removals leave the developer source unchanged. Existing +actual-native fixtures test autonomous cleanup, a common deadline through the +final write ACK, competing late stream failure, both broken pipes and terminal +restoration ordering without safe-DB-close or universal containment claims. + +The implementation commits carry this candidate. Two NEW complete cumulative +clean reviews after the last material correction and a separate security sitting remain required. +Whole Step 8 and Steps 8b–19 remain open; no user decision is pending. diff --git a/docs/standards/error-handling.md b/docs/standards/error-handling.md index 9a4372d1..abaf37f5 100644 --- a/docs/standards/error-handling.md +++ b/docs/standards/error-handling.md @@ -47,6 +47,15 @@ of the heavy runtime-family classifier. `LlmError` seam contract is the one error type detailed in full below, because the fallback chains depend on its classification.) +`@relavium/core` also exports the frozen canonical +`TRANSIENT_ENGINE_STATE_CODES` array used by `isTransientEngineStateError`. +Boundary projections that must avoid reading caller-controlled accessors may +compare a captured own-data code against that same authority. Do not create a +second transient-code list. This export adds no engine code or retry behavior. +Database storage origin has a separate +[read-only identity fact](../reference/shared-core/database-schema.md#node-client-failure-provenance); +error ancestry and public construction do not grant it. + ## `LlmError` classification — the contract the fallback chains depend on Every failure inside an `@relavium/llm` adapter is normalized to a single `LlmError` type From a5742dd9f574ab3e5c343cf531c42202911c9956 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 12:55:07 +0300 Subject: [PATCH 087/222] docs(docs): accept wave 3 step 8a after independent reviews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Join two new complete cumulative clean rounds and the separate security sitting at e0a5ae19. Attribute actual same-tarball platform success and ongoing broader CI separately; whole Step 8 remains staged in 8b–8d. Refs: ADR-0110 Co-Authored-By: Codex --- AGENTS.md | 3 +- CLAUDE.md | 3 +- ...d-discovery-have-explicit-compatibility.md | 9 +++ docs/roadmap/current.md | 7 +- .../phase-2.5.5-hardening-and-remediation.md | 64 +++++++++++++++++++ docs/standards/security-review.md | 16 +++++ 6 files changed, 97 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7c2df29f..3c6aa0d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,8 @@ Step 7b and whole Step 7 are independently accepted at `c836ffe6` after two NEW complete cumulative clean rounds, a clean security sitting and successful exact-tarball Linux/macOS/Windows plus Node 22.0.0 consumers. Broader CI retains its separately attributed status. Step 8a implements origin adapters and an inert native foundation; -its independent acceptance remains in progress. +it is independently accepted at `e0a5ae19` after two NEW complete cumulative +clean rounds and a separate clean security sitting. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index e7c9f317..4bc0dfe4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -184,7 +184,8 @@ Step 7b and whole Step 7 are independently accepted at `c836ffe6` after two NEW complete cumulative clean rounds, a clean security sitting and successful exact-tarball Linux/macOS/Windows plus Node 22.0.0 consumers. Broader CI retains its separately attributed status. Step 8a implements origin adapters and an inert native foundation; -its independent acceptance remains in progress. +it is independently accepted at `e0a5ae19` after two NEW complete cumulative +clean rounds and a separate clean security sitting. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index d7888655..3b52037d 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -204,3 +204,12 @@ until Step 8d connects every actual creation owner and caught/forced-exit path. This does not claim whole Step 8, production fatal integration or later discovery work complete. The [implementation checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-implementation-checkpoint--2026-10-16) attributes local author, selected causal and installed-host evidence separately. + +## Step 8a independent acceptance — 2026-10-16 + +The [Step 8a register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-acceptance--2026-10-16) +accepts the origin adapters and inert native foundation at `e0a5ae19` after two +NEW complete cumulative clean rounds and a separate clean security sitting. +Actual same-tarball platform controls and broader CI retain their exact source +and completion status. Product activation, remaining owners and whole Step 8 +stay staged in 8b–8d; later review remains cumulative over the entire Step 8 range. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 1082b018..5793ea79 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1132,9 +1132,10 @@ exact Node 22.0.0 Linux consumers. [The accepted c836 full CI run subsequently c with its executed source and CI totals recorded separately from the historical acceptance observation. [Step 8a implements origin adapters and an inert native foundation](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-implementation-checkpoint--2026-10-16); -its independent acceptance remains pending. Step 8 proceeds as serial 8a–8d -increments, with product fatal activation in 8d and whole Step 8 still open. -Steps 8b–19 remain pending. The +[its independent acceptance](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-acceptance--2026-10-16) +at `e0a5ae19` includes two NEW complete cumulative clean rounds and a separate +clean security sitting. Step 8b proceeds next; product fatal activation remains +in 8d and whole Step 8 stays open. Steps 8b–19 remain pending. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index c67c2e49..0db30bba 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -5481,3 +5481,67 @@ restoration ordering without safe-DB-close or universal containment claims. The implementation commits carry this candidate. Two NEW complete cumulative clean reviews after the last material correction and a separate security sitting remain required. Whole Step 8 and Steps 8b–19 remain open; no user decision is pending. + +## Wave 3 Step 8a acceptance — 2026-10-16 + +**Independently accepted on `development` at +`e0a5ae19981e749a75455e6b5698c806d9f1c674`.** The parent reads both NEW +complete cumulative clean reports, R1 (gpt-6-astra/xhigh) and R2 +(gpt-6.1-sol/xhigh). Each reviews every **61 paths / 177 zero-context hunks / +17 complete added files** from `d9625ff0`, then independently passes all +**24 changed test files / 481 tests / zero skips**. No material correction is +requested, and no source or dist mutation occurs between those rounds. + +The separate security sitting is also clean at that exact source: **26 complete +files / 525 passing tests / zero skips**, plus five independently authored +probes. It verifies fixed projection/privacy, private origin identity and factory +association, inert imports, actual native fixture observations and emitted +DB/core-source equality. These overlapping reviewer cohorts are separate +executions, not added together as unique test coverage. The parent reads its +full report; this sitting does not substitute for either correctness round. + +Both correctness reviewers inspect the actual retained local causal coordinators, +source/test bodies, exact removals/restorations, stdio, complete inventories, +runtime/dependency and zero-unhandled receipts. R2 additionally reconciles all +21,013 installed dependency file digests. The **11 controls / 33 phases / +750 repeated cases / 41 semantic failed cases with declared named witnesses** +keep their original uncommitted-body attribution and `complete: false` / +`acceptance: false` local labels. The DB capture predates six unrelated CLI +assertion-marker changes; all production and selected DB targets/tests match. +All 1,256 CLI capture pins match current. No new family certificate or fresh +reviewer mutation execution is inferred. + +### Actual current-source package and CI attribution + +A separate read-only audit and R2 independently inspect actual downloaded +GitHub bytes and strict receipts against freshly queried job/source identities. +Push run **38880157607 / attempt 1** executes the reviewed `e0a5ae19` source. +Actual successful jobs are pack **116904676623**, current Linux **116904849909**, +current macOS **116904849827**, current Windows **116904849911**, minimum Linux +**116904849776**, and required aggregate **116906278121**. All four consumers +bind **`7d02747c6f8c52b0b12ef44dab990e1658fa652b83600280a928aad22897e18e`**: +**421,903 bytes / 70 public files / 25 JS / 17 dependencies**. Each passes +175 bootstrap controls and six installed cases. Current runtimes are **24.21.0**; +minimum Linux is exactly **22.0.0**. Forty-four canonical migration/public asset +files match frozen source byte for byte. Public receipts do not export their +private worker/cleanup logs, installation trees or native dependency bodies; +those absent physical details are not reconstructed or independently attested. +The separately qualified local dirty candidate is not substituted for this tarball. + +At this checkpoint, current wire, complete secrets, complete usage, enforced +LLM/MCP coverage, advisory Windows and Node-floor jobs also report success. +Filesystem controls remain in progress; the dependent main CI job and overall +run have not yet completed. This is independent Step 8a acceptance, **not a +whole-CI claim**. The existing run is allowed to finish; any failure remains +actively actionable. Earlier full-CI evidence belongs to `c836ffe6`, and no +execution of this successor acceptance-metadata commit is inferred. Whole-wave +gates repeat at the final implementation in Step 19. + +Step 8a's origin adapters and inert native foundation are closed. **Whole Step 8 +remains open**, with actual creation owners in 8b, workflow health in 8c, and +session/terminal/caught/forced-exit activation in 8d. Later 8b–8d reviews remain +complete and cumulative from `d9625ff0`; accepting this foundation does not +narrow those reviews. Step 18 still owns central DB-opener consolidation and +Step 19 publication. Detailed reports/artifacts stay local and Git-ignored. +No new ADR, dependency, deferral, credential, billing, provider or user-decision +gate is introduced. Step 8b proceeds automatically. diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index c6c2af1e..034b466f 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -626,6 +626,22 @@ joins source and artifact evidence. This is not a new all-path generic review, real keychain/provider exercise, full-CI claim or native fatal-owner integration. Steps 8/9/10/19 and existing cooperating-host limits retain their distinct scope. +### Sitting: Wave 3 storage-origin and inert native foundation (2026-10-16) + +Separate clean sitting at `e0a5ae19981e749a75455e6b5698c806d9f1c674`, independent +of the two complete cumulative correctness rounds. All **26 full files / 525 tests** +pass without skips; five additional independent probes cover private-origin +cloning, foreign client substitution, hostile reflection and inert imports. +The sitting inspects the retained eleven local controls and all 33 completed +zero-unhandled receipts, preserving selected/uncommitted-source attribution. +Fixed diagnostics expose no raw throwable/cause, storage authority remains a +read-only identity fact, and native fixture expiry supplies no receipt or safe +DB-close ACK. No confirmed finding or new deferral remains. Production owners, +process-tree teardown and all-surface activation remain 8b–8d. The +[acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-acceptance--2026-10-16) +owns exact execution/platform limits; detailed review records remain local and +Git-ignored. + ## Sandbox and tool policy (`run_command`, node tools, secret inputs) This is the **binding home** for the tool-policy rules; the rationale is From 96bd8380bf6016d39f180dc8a22ea332bf740322 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 14:22:45 +0300 Subject: [PATCH 088/222] feat(mcp): expose creation-owned stdio native lifetime Attach the SDK-free host observer before start and distinguish native closure from public errors, cancellation and transitive work completion. Recheck admission at entry and observe raw start failure before callbacks. Validate with all 235 MCP tests and full workspace lint/typecheck/tests. Refs: ADR-0110, ADR-0088, ADR-0103 Co-Authored-By: Codex --- packages/mcp/src/index.ts | 2 + .../mcp/src/sdk-stdio-native-observer.test.ts | 420 ++++++++++++++++++ packages/mcp/src/sdk-stdio.ts | 75 +++- packages/mcp/src/sdk-work.ts | 16 +- 4 files changed, 509 insertions(+), 4 deletions(-) create mode 100644 packages/mcp/src/sdk-stdio-native-observer.test.ts diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 0bb0a63d..fc293374 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -67,6 +67,8 @@ export { setMcpClientVersion, type ConnectBound, type StdioServerSpec, + type StdioNativeLifetime, + type StdioNativeObserver, } from './sdk-stdio.js'; export { openHttpConnection, type HttpServerSpec } from './sdk-http.js'; export { openSseConnection, type SseServerSpec } from './sdk-sse.js'; diff --git a/packages/mcp/src/sdk-stdio-native-observer.test.ts b/packages/mcp/src/sdk-stdio-native-observer.test.ts new file mode 100644 index 00000000..2940b653 --- /dev/null +++ b/packages/mcp/src/sdk-stdio-native-observer.test.ts @@ -0,0 +1,420 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { + liveMcpChildPids, + McpAbortedError, + McpConnectError, + openStdioConnection, + type StdioNativeLifetime, + type StdioNativeObserver, +} from './index.js'; +import { SdkTransportOwner } from './sdk-work.js'; +import { McpWorkScope } from './work-scope.js'; + +const silentServer = fileURLToPath( + new URL('../test-fixtures/silent-mcp-server.mjs', import.meta.url), +); +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +function deferred() { + let resolve: () => void = () => {}; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} + +async function until(predicate: () => boolean): Promise { + const deadline = Date.now() + 10000; + while (!predicate()) { + if (Date.now() >= deadline) + throw new Error('native observer fixture transition did not complete'); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function observe(beforeStart: () => void = () => {}) { + let lifetime: StdioNativeLifetime | undefined; + const read = (): StdioNativeLifetime => { + if (lifetime === undefined) throw new Error('stdio native lifetime was not attached'); + return lifetime; + }; + const observer: StdioNativeObserver = { + attach: (value) => { + lifetime = value; + expect(value.readPid()).toBeUndefined(); + expect(value.isClosed()).toBe(false); + }, + beforeStart, + }; + return { observer, read }; +} + +/** Failed semantic assertions must still join the exact fixture owner, without inventing native ACK. */ +async function cleanupObserved(lifetime: StdioNativeLifetime | undefined, work?: McpWorkScope) { + work?.seal(); + const pid = lifetime?.readPid(); + if (pid !== undefined && alive(pid)) process.kill(pid, 'SIGKILL'); + await lifetime?.close(); + await lifetime?.whenClosed; + await work?.done; +} + +describe('stdio host projection of actual native ownership', () => { + it('exposes the actual pre-initialize child and retains its PID through bounded failure and SDK close', async () => { + const before = liveMcpChildPids(); + const abort = new AbortController(); + const work = new McpWorkScope(); + const { observer, read } = observe(); + const opening = openStdioConnection( + 'observed-pre-initialize', + { + command: process.execPath, + args: [silentServer], + env: {}, + connectTimeoutMs: 10000, + nativeObserver: observer, + }, + abort.signal, + work, + ); + const outcome = opening.catch((error: unknown) => error); + const lifetime = read(); + const pid = lifetime.readPid(); + let nativeClosed = false; + void lifetime.whenClosed.then(() => { + nativeClosed = true; + }); + try { + expect(pid).toBeDefined(); + if (pid === undefined) throw new Error('missing observed native child PID'); + expect(before).not.toContain(pid); + expect(alive(pid)).toBe(true); + expect(liveMcpChildPids()).toContain(pid); + // The fixture never answers initialize: native ownership cannot wait for a connection handle. + await tick(); + expect(nativeClosed).toBe(false); + abort.abort(); + expect(await outcome).toBeInstanceOf(McpAbortedError); + work.seal(); + const closing = lifetime.close(); + expect(lifetime.close()).toBe(closing); + expect(lifetime.readPid()).toBe(pid); + expect(lifetime.isClosed()).toBe(false); + expect(nativeClosed).toBe(false); + expect(work.isComplete).toBe(false); + await lifetime.whenClosed; + expect(lifetime.isClosed()).toBe(true); + expect(lifetime.readPid()).toBeUndefined(); + expect(liveMcpChildPids()).not.toContain(pid); + expect(alive(pid)).toBe(false); + await closing; + await work.done; + } finally { + abort.abort(); + if (pid !== undefined && alive(pid)) process.kill(pid, 'SIGKILL'); + await lifetime.close(); + work.seal(); + await work.done; + } + }, 15000); + + it.each(['refusal', 'cancellation', 'close'] as const)( + 'before-start %s refuses actual native entry and acknowledges only the empty lifetime', + async (kind) => { + const before = liveMcpChildPids(); + const work = new McpWorkScope(); + const abort = new AbortController(); + const refusal = new Error('host admission refused'); + const { observer, read } = observe(() => { + expect(read().readPid()).toBeUndefined(); + if (kind === 'refusal') throw refusal; + if (kind === 'cancellation') abort.abort(); + else + void read() + .close() + .catch(() => undefined); + }); + const outcome = await openStdioConnection( + 'refused-before-start', + { + command: process.execPath, + args: [silentServer], + env: {}, + connectTimeoutMs: 1000, + nativeObserver: observer, + }, + abort.signal, + work, + ).catch((error: unknown) => error); + const lifetime = read(); + try { + if (kind === 'cancellation') { + expect(outcome, 'MCP_SDK_PRESTART_CANCELLATION').toBeInstanceOf(McpAbortedError); + } else { + if (kind === 'refusal') { + expect(outcome, 'MCP_SDK_PRESTART_ADMISSION').toHaveProperty('cause', refusal); + } else { + expect(outcome, 'MCP_SDK_PRESTART_REENTRANT').toHaveProperty('cause.code', 'closed'); + } + expect(outcome).toBeInstanceOf(McpConnectError); + } + // Check before waiting for an accidentally spawned resource to finish cleanup. + expect(lifetime.isClosed(), 'MCP_SDK_EMPTY_START').toBe(true); + expect(lifetime.readPid()).toBeUndefined(); + expect(liveMcpChildPids()).toEqual(before); + } finally { + abort.abort(); + await cleanupObserved(lifetime, work); + } + expect(work.isComplete).toBe(true); + }, + 15000, + ); + + it.each([false, true])( + 'attachment refusal releases the acquired empty owner (reentrant close: %s)', + async (reentrant) => { + const before = liveMcpChildPids(); + const work = new McpWorkScope(); + const refusal = new Error('native attachment refused'); + let lifetime: StdioNativeLifetime | undefined; + const opening = openStdioConnection( + 'refused-attachment', + { + command: process.execPath, + args: [silentServer], + env: {}, + nativeObserver: { + attach: (value) => { + lifetime = value; + if (reentrant) void value.close().catch(() => undefined); + throw refusal; + }, + beforeStart: () => { + throw new Error('native start entered after refused attachment'); + }, + }, + }, + undefined, + work, + ); + try { + await expect(opening).rejects.toBe(refusal); + if (lifetime === undefined) throw new Error('missing refused native attachment'); + expect(lifetime.isClosed()).toBe(true); + expect(lifetime.readPid()).toBeUndefined(); + expect(liveMcpChildPids()).toEqual(before); + } finally { + await cleanupObserved(lifetime, work); + } + expect(work.isComplete).toBe(true); + }, + ); + + it('an already cancelled connect still attaches and releases a verified empty native lifetime', async () => { + const abort = new AbortController(); + abort.abort(); + const { observer, read } = observe(() => { + throw new Error('SDK start entered for pre-cancelled connect'); + }); + await expect( + openStdioConnection( + 'cancelled-before-attach', + { command: process.execPath, args: [silentServer], env: {}, nativeObserver: observer }, + abort.signal, + ), + ).rejects.toBeInstanceOf(McpAbortedError); + await read().whenClosed; + await read().close(); + expect(read().isClosed()).toBe(true); + expect(read().readPid()).toBeUndefined(); + }); + + it.each(['attach', 'beforeStart'] as const)( + '%s refuses an unexpected async return, observing native rejection without reading a then getter', + async (phase) => { + const before = liveMcpChildPids(); + const work = new McpWorkScope(); + const { observer, read } = observe(); + let getterReads = 0; + const returned = (): unknown => { + const promise = Promise.reject(new Error('unexpected asynchronous observer failure')); + void Object.defineProperty(promise, 'then', { + get: () => { + getterReads += 1; + throw new Error('foreign then getter must not be read'); + }, + }); + return promise; + }; + const outcome = await openStdioConnection( + 'async-native-observer', + { + command: process.execPath, + args: [silentServer], + env: {}, + connectTimeoutMs: 1000, + nativeObserver: { + attach: (value) => { + observer.attach(value); + return phase === 'attach' ? returned() : undefined; + }, + beforeStart: () => (phase === 'beforeStart' ? returned() : undefined), + }, + }, + undefined, + work, + ).catch((error: unknown) => error); + try { + expect(outcome, 'MCP_SYNC_CALLBACK_REFUSAL').toHaveProperty( + phase === 'attach' ? 'code' : 'cause.code', + 'observer_unavailable', + ); + expect(read().isClosed()).toBe(true); + expect(read().readPid()).toBeUndefined(); + await tick(); + expect(getterReads).toBe(0); + expect(liveMcpChildPids()).toEqual(before); + } finally { + await cleanupObserved(read(), work); + } + }, + 15000, + ); + + it('refuses a foreign thenable without invoking its protocol or getters', async () => { + const { observer, read } = observe(); + let getterReads = 0; + const thenable: unknown = Object.defineProperty({}, 'then', { + get: () => { + getterReads += 1; + throw new Error('foreign then protocol must not run'); + }, + }); + const outcome = await openStdioConnection('foreign-native-observer', { + command: process.execPath, + args: [silentServer], + env: {}, + connectTimeoutMs: 1000, + nativeObserver: { + attach: (value) => { + observer.attach(value); + return thenable; + }, + beforeStart: () => {}, + }, + }).catch((error: unknown) => error); + try { + expect(outcome, 'MCP_FOREIGN_CALLBACK_REFUSAL').toHaveProperty( + 'code', + 'observer_unavailable', + ); + expect(read().isClosed()).toBe(true); + expect(read().readPid()).toBeUndefined(); + expect(getterReads).toBe(0); + } finally { + await cleanupObserved(read()); + } + }); +}); + +describe('installed SDK native start callback failures', () => { + it('observes rejected raw start before a post-entry hook fails, preserving verified empty-start ACK', async () => { + // The real SDK wraps native argument validation in its start Promise; no child/error/close is created. + const transport = new StdioClientTransport({ command: '', env: {}, stderr: 'ignore' }); + const refusal = new Error('post-entry observer refused'); + const work = new McpWorkScope(); + const owner = new SdkTransportOwner(transport, work, undefined, { + onStartEntered: () => { + throw refusal; + }, + }); + try { + expect(() => owner.start()).toThrow(refusal); + } finally { + await owner.close(); + await work.done; + } + expect(transport.pid).toBeNull(); + expect(owner.nativeCloseAcknowledged).toBe(true); + }); + + it.skipIf(process.platform === 'win32')( + 'native error and post-start hook failure cannot acknowledge a real child whose close remains owed', + async () => { + const directory = mkdtempSync(join(tmpdir(), 'relavium-mcp-native-observer-')); + const ready = join(directory, 'ready'); + const child = String.raw` +process.on('SIGTERM',()=>{}); +process.stdin.resume(); +setInterval(()=>{},1000); +require('node:fs').writeFileSync(process.argv[1],'ready'); +process.stdout.write('invalid-json\n');`; + const transport = new StdioClientTransport({ + command: process.execPath, + args: ['-e', child, ready], + env: {}, + stderr: 'ignore', + }); + const errors: Error[] = []; + transport.onerror = (error) => errors.push(error); + const work = new McpWorkScope(); + const heldWork = deferred(); + void work.retainWork(() => heldWork.promise); + const refusal = new Error('post-start hook refused'); + const owner = new SdkTransportOwner(transport, work, undefined, { + onStartEntered: () => { + throw refusal; + }, + }); + let pid: number | undefined; + try { + expect(() => owner.start()).toThrow(refusal); + pid = transport.pid ?? undefined; + if (pid === undefined) throw new Error('missing real post-entry child PID'); + await until(() => existsSync(ready) && errors.length > 0); + expect(owner.nativeCloseAcknowledged, 'MCP_SDK_ERROR_NOT_ACK').toBe(false); + expect(alive(pid)).toBe(true); + let complete = false; + const closing = owner.close().then(() => { + complete = true; + }); + // SDK close makes its public getter blind before EOF/TERM/KILL or actual native close. + expect(transport.pid).toBeNull(); + await tick(); + expect(owner.nativeCloseAcknowledged).toBe(false); + expect(complete).toBe(false); + expect(work.isComplete).toBe(false); + await owner.nativeClosed; + expect(owner.nativeCloseAcknowledged).toBe(true); + expect(alive(pid)).toBe(false); + // Native ACK does not release the independent producer work joined by owner.close(). + expect(complete).toBe(false); + expect(work.isComplete).toBe(false); + heldWork.resolve(); + await closing; + expect(complete).toBe(true); + } finally { + heldWork.resolve(); + if (pid !== undefined && alive(pid)) process.kill(pid, 'SIGKILL'); + await owner.close(); + rmSync(directory, { recursive: true, force: true }); + } + }, + 15000, + ); +}); diff --git a/packages/mcp/src/sdk-stdio.ts b/packages/mcp/src/sdk-stdio.ts index 0078ca8b..159f69c4 100644 --- a/packages/mcp/src/sdk-stdio.ts +++ b/packages/mcp/src/sdk-stdio.ts @@ -21,7 +21,7 @@ import { McpConnectError, McpError } from './errors.js'; import { INGRESS_BOUNDS, toolDefinitionBytes } from './ingress-bounds.js'; import { shapeToolResult } from './result.js'; import { SdkTransportOwner, type SdkRequestInvocation } from './sdk-work.js'; -import { McpWorkScope, mcpWorkEntryFailure } from './work-scope.js'; +import { McpLifetimeError, McpWorkScope, mcpWorkEntryFailure } from './work-scope.js'; import { OwnedSdkClient } from './sdk-client.js'; /** @@ -47,6 +47,52 @@ export interface StdioServerSpec { readonly cwd?: string; /** The authored `connect_timeout_ms` (ADR-0088 §1.4). Absent ⇒ {@link MCP_DEADLINES.stdioConnectMs}. */ readonly connectTimeoutMs?: number; + /** Optional host-owned native registration, installed before SDK start or initialize. */ + readonly nativeObserver?: StdioNativeObserver; +} + +/** SDK-free creation ownership of one stdio child's native lifetime (not full MCP work completion). */ +export interface StdioNativeLifetime { + /** Creation-latched PID while native close is owed; absent before spawn and after actual close. */ + readonly readPid: () => number | undefined; + /** Only actual native close, or a verified empty start, acknowledges closure. */ + readonly isClosed: () => boolean; + readonly whenClosed: Promise; + /** The existing idempotent transport-owner close, including its retained MCP work. */ + readonly close: () => Promise; +} + +/** + * Synchronous host hooks returning undefined; neither callback starts the transport or owns an SDK object. + * An unexpected returned value refuses admission; ordinary native Promise rejection is observed independently. + */ +export interface StdioNativeObserver { + readonly attach: (lifetime: StdioNativeLifetime) => void; + /** Refusal or reentrant close prevents native start; admission is checked again afterward. */ + readonly beforeStart: () => void; +} + +const observeNativePromise: unknown = Object.getOwnPropertyDescriptor( + Promise.prototype, + 'then', +)?.value; + +/** + * Refuse async/malformed hooks without reading a foreign then getter or treating it as native ACK. + * Intrinsic observation retains the native Promise constructor/species constraints; these trusted hooks + * must not return promises with hostile constructor/species accessors. Foreign protocols are not invoked. + */ +function invokeNativeObserver(callback: () => void): void { + const returned: unknown = callback(); + if (returned === undefined) return; + if (typeof observeNativePromise === 'function') { + try { + Reflect.apply(observeNativePromise, returned, [() => undefined, () => undefined]); + } catch { + // Non-native promises/thenables are refused, never assimilated through arbitrary getters. + } + } + throw new McpLifetimeError('observer_unavailable'); } /** @@ -150,7 +196,9 @@ export async function openStdioConnection( ): Promise { const timeoutMs = spec.connectTimeoutMs ?? MCP_DEADLINES.stdioConnectMs; const work = new McpWorkScope(options); + let owner: SdkTransportOwner | undefined; try { + const nativeObserver = spec.nativeObserver; const transport = new StdioClientTransport({ command: spec.command, // The host-constructed env (declared vars + resolved `mcp-secret:*`). The SDK force-merges its curated @@ -174,12 +222,32 @@ export async function openStdioConnection( // handler evaluates it. const latchedPid = latchPid(() => transport.pid ?? undefined); const release = registerLiveChild(latchedPid); - const owner = new SdkTransportOwner(transport, work, undefined, { + owner = new SdkTransportOwner(transport, work, undefined, { onAcknowledged: release, + beforeStart: () => { + if (nativeObserver !== undefined) { + invokeNativeObserver(() => nativeObserver.beforeStart()); + } + // A host admission callback may synchronously cancel without closing the owner itself. + if (signal?.aborted === true) throw new McpAbortedError(serverId, 'connect'); + }, onStartEntered: () => { latchedPid.read(); }, }); + const nativeOwner = owner; + const whenClosed = nativeOwner.nativeClosed; + if (whenClosed === undefined) throw new McpLifetimeError('unavailable'); + if (nativeObserver !== undefined) { + invokeNativeObserver(() => + nativeObserver.attach({ + readPid: () => (nativeOwner.nativeCloseAcknowledged ? undefined : latchedPid.read()), + isClosed: () => nativeOwner.nativeCloseAcknowledged, + whenClosed, + close: () => nativeOwner.close(), + }), + ); + } return connectSdkTransport( serverId, transport, @@ -191,6 +259,9 @@ export async function openStdioConnection( owner, ); } catch (error) { + // Attachment can refuse after acquiring the owner, including reentrant close. Never lose its + // empty/native obligation or replace the original callback failure with a cleanup fault. + if (owner !== undefined) void safeClose(owner); work.seal(); throw error; } diff --git a/packages/mcp/src/sdk-work.ts b/packages/mcp/src/sdk-work.ts index d5ef3604..52b9a8d5 100644 --- a/packages/mcp/src/sdk-work.ts +++ b/packages/mcp/src/sdk-work.ts @@ -34,6 +34,8 @@ export type SdkLaneFactory = (lifetime: SdkLaneLifetime) => Target; /** Only stdio/WebSocket SDK callbacks denote the actual native resource close. */ export interface SdkNativeClose { + /** Synchronous admission immediately before actual native start entry. */ + readonly beforeStart?: () => void; readonly onStartEntered?: () => void; readonly onAcknowledged?: () => void; } @@ -273,6 +275,7 @@ export class SdkTransportOwner { #startEntered = false; #nativeErrorSeen = false; #nativeAcknowledge: (() => void) | undefined; + readonly #nativeClosed: Promise | undefined; #cleanupFault: { readonly error: unknown } | undefined; readonly #nativeClose: SdkNativeClose | undefined; @@ -290,6 +293,7 @@ export class SdkTransportOwner { const closed = new Promise((resolve) => { this.#nativeAcknowledge = resolve; }); + this.#nativeClosed = closed; void this.work.retainWork(() => closed); } const previousClose = base.onclose; @@ -328,6 +332,10 @@ export class SdkTransportOwner { get nativeCloseAcknowledged(): boolean { return this.#nativeClose !== undefined && this.#nativeAcknowledge === undefined; } + /** Native close alone, separate from the owner's transitive work/close join. */ + get nativeClosed(): Promise | undefined { + return this.#nativeClosed; + } /** Package-internal cardinality of control lanes still owned by live requests. */ get pendingControlLaneCount(): number { let count = 0; @@ -340,22 +348,26 @@ export class SdkTransportOwner { } start(): Promise { this.#assertOpen(); - this.#startEntered = true; let raw: Promise; try /* NOSONAR — S4822: only sync entry proves no native resource; raw.catch below owns async start failure. */ { + this.#nativeClose?.beforeStart?.(); + // Admission can synchronously request close. No resource may be created after that retirement. + this.#assertOpen(); + this.#startEntered = true; raw = this.work.retainWork(() => this.#base.start()); } catch (error) { // The installed native transports throw synchronously only before native construction. this.#acknowledgeNativeClose(); throw error; } - this.#nativeClose?.onStartEntered?.(); void raw.catch(() => { // In SDK 1.29, async spawn/socket failure invokes onerror before rejecting start. // Rejection with no native error callback instead proves constructor/start entry failed // before a native resource existed. A native error always still owes actual onclose. if (!this.#nativeErrorSeen) this.#acknowledgeNativeClose(); }); + // Observe raw start BEFORE a post-entry callback can throw and abandon the caller's join. + this.#nativeClose?.onStartEntered?.(); return raw; } From 8dcd9b376262a8f31f8318bae2891138bd6cef0a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 14:22:46 +0300 Subject: [PATCH 089/222] feat(cli): own native processes through actual target and helper close Reserve original-invocation ownership before native entry across workflow, resume, chat, Home, OS and editor creation. Keep PID/group authority distinct and verify the absolute Windows facility before admitting a subprocess. Join target/helper closes independently under live nonrenewable budgets; use only finite creation-owned capabilities at process exit. Retain existing consent through standalone clear/reseat rebuilds. Fatal coordinator activation and workflow/session health remain in Steps 8c-8d. Workspace gates pass 664 files / 14943 tests with 21 local/platform skips. Refs: ADR-0110, ADR-0103, ADR-0084 Co-Authored-By: Codex --- apps/cli/src/chat/session-host.test.ts | 152 +++++ apps/cli/src/chat/session-host.ts | 19 +- apps/cli/src/commands/agent-run.test.ts | 31 + apps/cli/src/commands/agent-run.ts | 23 +- apps/cli/src/commands/budget.test.ts | 16 +- apps/cli/src/commands/chat.test.ts | 160 ++++- apps/cli/src/commands/chat.ts | 70 ++- .../dispatch-native-forwarding.test.ts | 69 +++ apps/cli/src/commands/dispatch.ts | 6 + apps/cli/src/commands/gate.test.ts | 26 + apps/cli/src/commands/gate.ts | 20 +- .../native-forwarding.test-support.ts | 94 +++ apps/cli/src/commands/run.test.ts | 33 ++ apps/cli/src/commands/run.ts | 22 +- apps/cli/src/engine/build-engine.test.ts | 56 +- apps/cli/src/engine/build-engine.ts | 10 +- .../src/engine/mcp-native-ownership.test.ts | 256 ++++++++ apps/cli/src/engine/mcp-servers.ts | 88 ++- .../src/engine/mcp-signal-teardown.test.ts | 88 ++- apps/cli/src/engine/mcp-signal-teardown.ts | 84 +-- .../cli/src/engine/tool-host/assemble.test.ts | 19 + apps/cli/src/engine/tool-host/assemble.ts | 12 +- .../tool-host/os-native-lifetime.test.ts | 81 +++ apps/cli/src/engine/tool-host/os.test.ts | 63 +- apps/cli/src/engine/tool-host/os.ts | 96 ++- apps/cli/src/engine/tool-host/process.ts | 100 ++-- apps/cli/src/harness/mcp-orphan.e2e.test.ts | 48 +- apps/cli/src/home/drive-home.test.ts | 143 ++++- apps/cli/src/home/drive-home.tsx | 8 +- .../src/process/invocation-registry.test.ts | 494 +++++++++++++++ apps/cli/src/process/invocation-registry.ts | 144 ++++- .../src/process/native-process-owner.test.ts | 554 +++++++++++++++++ apps/cli/src/process/native-process-owner.ts | 560 ++++++++++++++++++ apps/cli/src/process/windows-teardown.test.ts | 317 ++++++++++ apps/cli/src/process/windows-teardown.ts | 153 +++++ .../src/render/editor-native-lifetime.test.ts | 79 +++ apps/cli/src/render/editor.test.ts | 16 + apps/cli/src/render/editor.ts | 61 +- 38 files changed, 4028 insertions(+), 243 deletions(-) create mode 100644 apps/cli/src/commands/dispatch-native-forwarding.test.ts create mode 100644 apps/cli/src/commands/native-forwarding.test-support.ts create mode 100644 apps/cli/src/engine/mcp-native-ownership.test.ts create mode 100644 apps/cli/src/engine/tool-host/os-native-lifetime.test.ts create mode 100644 apps/cli/src/process/native-process-owner.test.ts create mode 100644 apps/cli/src/process/native-process-owner.ts create mode 100644 apps/cli/src/process/windows-teardown.test.ts create mode 100644 apps/cli/src/process/windows-teardown.ts create mode 100644 apps/cli/src/render/editor-native-lifetime.test.ts diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 2715171b..d3325f0a 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -1,3 +1,9 @@ +import { fileURLToPath } from 'node:url'; +import type { StdioNativeLifetime } from '@relavium/mcp'; +import type { NativeProcessController } from '../process/native-process-owner.js'; +import { resolveStdioSpawn } from '../engine/mcp-consent.js'; +import { createCliInvocation } from '../process/invocation.js'; +import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; import { prepareOutputCapPlan } from '@relavium/llm'; import type { PreEgressInfo } from '@relavium/core'; import { execFileSync } from 'node:child_process'; @@ -327,7 +333,11 @@ describe('buildChatSession', () => { const repo = mkdtempSync(join(tmpdir(), 'relavium-git-')); try { execFileSync('git', ['init', '-q'], { cwd: repo }); // `git status` needs no user identity (unlike commits) + const invocation = createCliInvocation(); + const nativeProcesses = nativeProcessesForInvocation(invocation); + const register = vi.spyOn(invocation.registry, 'register'); const built = await build({ + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), cwd: repo, providers: scriptedResolver([callWithArgs('c1', 'git_status', {}), textTurn('clean')]), }); @@ -354,6 +364,12 @@ describe('buildChatSession', () => { // …and the post-tool answer streamed, so the loop completed after the fold. const tokens = events.flatMap((e) => (e.type === 'agent:token' ? [e.token] : [])).join(''); expect(tokens).toContain('clean'); + expect(register, 'NATIVE_SESSION_FACTORY_FORWARDING').toHaveBeenCalledWith( + expect.objectContaining({ + kind: process.platform === 'win32' ? 'windows-live-root' : 'native-group', + }), + ); + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(0)); } finally { rmSync(repo, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } @@ -814,6 +830,41 @@ describe('buildResumedChatSession (2.N)', () => { } }); + it('registers the resumed session real process factory under the original invocation', async () => { + const repo = mkdtempSync(join(tmpdir(), 'relavium-resumed-native-')); + const invocation = createCliInvocation(); + const nativeProcesses = nativeProcessesForInvocation(invocation); + const register = vi.spyOn(invocation.registry, 'register'); + try { + execFileSync('git', ['init', '-q'], { cwd: repo }); + const built = await buildResumedChatSession({ + chat: EMPTY_CHAT, + record: record({ context: { workingDir: repo, fsScopeTier: 'project' } }), + messages: [], + now: () => Date.parse(ISO), + providers: scriptedResolver([callWithArgs('c1', 'git_status', {}), textTurn('clean')]), + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), + }); + expect(invocation.registry.activeOwners).toBe(0); // construction alone creates no native owner + await built.session.sendMessage('inspect the repository'); + built.session.cancel(); + const events = await built.recordedEvents; + const toolResult = events.find((event) => event.type === 'agent:tool_result'); + expect(toolResult).toMatchObject({ toolId: 'git_status', success: true }); + expect( + toolResult?.type === 'agent:tool_result' ? toolResult.outputSummary : undefined, + ).toContain('"exitCode":0'); + expect(register, 'NATIVE_SESSION_FACTORY_FORWARDING').toHaveBeenCalledWith( + expect.objectContaining({ + kind: process.platform === 'win32' ? 'windows-live-root' : 'native-group', + }), + ); + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(0)); + } finally { + rmSync(repo, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }); + it('forwards mcpConnectSignal on the RESUMED path, not only the fresh one (ADR-0088 §1.3)', async () => { // **The field that was wired and dead, now bound.** The resumed path built its `connectAgentMcp` options // inline instead of going through `mcpOptionsFor`, so `mcpConnectSignal` — added for the connect-cancel — @@ -1724,3 +1775,104 @@ it('reads the attached initial usage snapshot once across a streamed turn', asyn await built.whenHostSafeToClose(); expect(initialCoverage).toHaveBeenCalledTimes(1); }); + +describe('session MCP native owner forwarding during pending startup', () => { + it.each(['fresh', 'resumed'] as const)( + '%s forwards authority before the actual SDK initializer resolves', + async (surface) => { + const invocation = createCliInvocation(); + const native = nativeProcessesForInvocation(invocation); + if (native === undefined) throw new Error('missing native invocation controller'); + let lifetime: StdioNativeLifetime | undefined; + const nativeProcesses: NativeProcessController = { + reserve: native.reserve, + observeMcp: async () => { + const observer = await native.observeMcp(); + return { + ...observer, + attach: (value) => { + lifetime = value; + observer.attach(value); + }, + }; + }, + }; + const cwd = process.cwd(); + const server = fileURLToPath( + new URL('../../../../packages/mcp/test-fixtures/silent-mcp-server.mjs', import.meta.url), + ); + const ref = { + id: 'pending-native', + transport: 'stdio' as const, + command: process.execPath, + args: [server], + connect_timeout_ms: 10_000, + }; + const consented = await resolveStdioSpawn( + { + serverId: ref.id, + provenance: { kind: 'inline' }, + command: ref.command, + args: ref.args, + env: {}, + }, + cwd, + ); + const abort = new AbortController(); + const agent = { ...buildDefaultChatAgent('claude-sonnet-4-6'), mcp_servers: [ref] }; + const common = { + chat: EMPTY_CHAT, + now: () => 0, + providers: scriptedResolver([]), + nativeProcesses, + mcpConnectSignal: abort.signal, + consentGate: () => Promise.resolve(new Map([[ref.id, consented]])), + }; + const record: AgentSessionRecord = { + id: 'pending-resume', + agentSlug: agent.id, + agentSnapshot: agent, + context: { workingDir: cwd, fsScopeTier: 'project' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: '2026-10-16T00:00:00.000Z', + updatedAt: '2026-10-16T00:00:00.000Z', + }; + const opening = + surface === 'fresh' + ? buildFreshSession({ + ...common, + agent, + agentRef: undefined, + cwd, + projectConfigDir: undefined, + uuid: () => 'pending-fresh', + }) + : buildRestoredSession({ ...common, record, messages: [] }); + const outcome = opening.catch((error: unknown) => error); + try { + await vi.waitFor(() => + expect(lifetime?.readPid(), 'NATIVE_SESSION_MCP_FORWARDING').toBeTypeOf('number'), + ); + expect(invocation.registry.activeOwners).toBe(1); + expect(lifetime?.isClosed()).toBe(false); + // The builder has no returned session/client yet. The original registry still owns its native child. + invocation.registry.reapNativeAtExit(); + expect(invocation.registry.activeOwners).toBe(1); // a reap request does not acknowledge close + abort.abort(); + await outcome; + await lifetime?.whenClosed; + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(0)); + } finally { + invocation.registry.reapNativeAtExit(); + abort.abort(); + await outcome; + await lifetime?.close(); + } + }, + 15_000, + ); +}); diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index e63bb124..385bb5ee 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -1,3 +1,4 @@ +import type { NativeProcessController } from '../process/native-process-owner.js'; import { type AgentDefinition, AgentSession, @@ -88,6 +89,8 @@ const SESSION_DURABILITY_FAILURE = */ export interface BuildChatSessionOptions { + /** Host-only native authority for both tool processes and MCP startup. */ + readonly nativeProcesses?: NativeProcessController; /** The resolved `[chat]` block (default model, fs scope, turn cap, cost cap). */ readonly chat: ResolvedChatConfig; readonly maxTokensEstimate?: number; @@ -316,6 +319,7 @@ type SessionRuntimeOptions = Pick< | 'providers' | 'toolHost' | 'fsPolicy' + | 'nativeProcesses' | 'afterTurnCompaction' | 'onCompactionStart' | 'onBudgetWarning' @@ -406,6 +410,7 @@ function buildSessionRuntime( profile: 'chat-read-write', fsScopeTier: context.fsScopeTier, workspaceDir: context.workingDir, + ...(opts.nativeProcesses === undefined ? {} : { nativeProcesses: opts.nativeProcesses }), ...(opts.fsPolicy === undefined ? {} : { fsPolicy: opts.fsPolicy }), }); const baseHost: ToolHost = opts.toolHost ?? factoryEnv.host; @@ -753,12 +758,17 @@ function bindChatAgent(opts: BuildChatSessionOptions): ResolvedChatAgent { * Exactly the fields {@link mcpOptionsFor} reads — deliberately NOT `BuildChatSessionOptions`. * * The fresh and resumed paths carry two different option types, and the mapper has no business requiring - * either whole. Naming the five it actually consumes is what lets both satisfy it structurally, and makes a - * sixth option impossible to add on one path only: it has to be added here, and then both call sites see it. + * either whole. Naming the host inputs it actually consumes is what lets both satisfy it structurally, and makes an + * additional option impossible to add on one path only: it has to be added here, and then both call sites see it. */ type McpConnectInputs = Pick< BuildChatSessionOptions, - 'consentGate' | 'startMcpClient' | 'mcpSecretResolver' | 'mcpRegistrations' | 'mcpConnectSignal' + | 'consentGate' + | 'startMcpClient' + | 'mcpSecretResolver' + | 'mcpRegistrations' + | 'mcpConnectSignal' + | 'nativeProcesses' >; /** @@ -776,6 +786,7 @@ function mcpOptionsFor( ): ConnectAgentMcpOptions { return { cwd, + ...(opts.nativeProcesses === undefined ? {} : { nativeProcesses: opts.nativeProcesses }), ...(opts.consentGate === undefined ? {} : { consentGate: opts.consentGate }), // The caller's label wins (`agent run` names the ref the user typed); otherwise the path the agent was // actually read from — §7's "declared in " for the imported-artifact case. @@ -980,7 +991,7 @@ export interface BuiltResumedChatSession extends BuiltChatSession { export interface BuildResumedChatSessionOptions extends Pick< BuildChatSessionOptions, - 'afterTurnCompaction' | 'onCompactionStart' | 'fsPolicy' + 'afterTurnCompaction' | 'onCompactionStart' | 'fsPolicy' | 'nativeProcesses' > { /** The resolved `[chat]` block (turn cap, cost cap) — applied to the resumed session's deps. */ readonly chat: ResolvedChatConfig; diff --git a/apps/cli/src/commands/agent-run.test.ts b/apps/cli/src/commands/agent-run.test.ts index 3b00c0c4..c1cd9e35 100644 --- a/apps/cli/src/commands/agent-run.test.ts +++ b/apps/cli/src/commands/agent-run.test.ts @@ -1,3 +1,5 @@ +import { createCliInvocation } from '../process/invocation.js'; +import { witnessNativeToolProcess } from './native-forwarding.test-support.js'; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -291,6 +293,35 @@ describe('agentRunCommand (2.Q)', () => { const agentPath = (): string => join(cwd, 'coder.agent.yaml'); + it.each([false, true])( + 'forwards native tool ownership before one-shot startup resolves (offline=%s)', + async (offline) => { + const invocation = createCliInvocation(); + writeFileSync(join(cwd, 'native-cassette.json'), JSON.stringify(CASSETTE)); + const { d } = deps('inspect', { providers: scriptedResolver([textTurn('done')]) }); + expect( + await agentRunCommand( + { + agent: agentPath(), + input: [], + allowMcpStdio: [], + ...(offline ? { fixture: 'native-cassette.json' } : {}), + }, + { + ...d, + invocation, + buildSession: async (options) => { + await witnessNativeToolProcess(options.nativeProcesses, invocation, cwd); + expect(options.disableMcp).toBe(offline ? true : undefined); + return buildChatSession(options); + }, + }, + ), + ).toBe(EXIT_CODES.success); + expect(invocation.registry.activeOwners).toBe(0); + }, + ); + it('runs one turn from a stdin prompt and prints the reply (exit 0)', async () => { mkdirSync(join(cwd, '.relavium'), { recursive: true }); writeFileSync(join(cwd, '.relavium', 'project.toml'), '[defaults]\nmax_tokens_estimate = 17\n'); diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index ed2e4e86..936107ba 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -1,3 +1,5 @@ +import type { CliInvocation } from '../process/invocation.js'; +import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; import { closeQuietly } from '../process/close-quietly.js'; import { randomUUID } from 'node:crypto'; import { StringDecoder } from 'node:string_decoder'; @@ -10,7 +12,6 @@ import { cassetteResolver, loadCassette } from '../chat/fixture.js'; import { onceEffortNotice } from '../chat/effort-notice.js'; import { buildChatSession, type BuiltChatSession } from '../chat/session-host.js'; import { createConsentGate } from '../engine/mcp-consent-gate.js'; -import { liveMcpChildPids } from '@relavium/mcp'; import { guardMcpTeardown } from '../engine/mcp-signal-teardown.js'; import type { StdioConsentGate } from '../engine/mcp-servers.js'; @@ -65,6 +66,8 @@ export interface AgentRunCommandArgs { } export interface AgentRunCommandDeps { + /** The original bootstrap invocation; native authority stays host-only. */ + readonly invocation?: CliInvocation; readonly io: CliIo; readonly global: GlobalOptions; /** Injectable provider seam (tests). Ignored when `--fixture` is given (the cassette resolver always wins). */ @@ -87,6 +90,7 @@ export async function agentRunCommand( args: AgentRunCommandArgs, deps: AgentRunCommandDeps, ): Promise { + const nativeProcesses = nativeProcessesForInvocation(deps.invocation); const grants = admitFsInvocation(deps.global); const now = deps.now ?? Date.now; const uuid = deps.uuid ?? randomUUID; @@ -122,8 +126,8 @@ export async function agentRunCommand( * * `buildChatSession` is where the stdio children are spawned, and a cold `npx` can hold that call for up to * 120 s with the terminal silent — the highest-probability window for a Ctrl-C. Arming afterwards, which was - * the first attempt, left exactly that window uncovered. Two things follow: the pids are read LAZILY (there - * are none yet here) and the connect is given a cancel signal, so a Ctrl-C during it stops rather than being + * the first attempt, left exactly that window uncovered. Two things follow: native ownership is registered during creation + * and the connect is given a cancel signal, so a Ctrl-C during it stops rather than being * merely survived. * * Unlike `relavium run`, this surface has no other signal handler, so the guard DOES own the exit code. @@ -135,17 +139,16 @@ export async function agentRunCommand( mcpConnectCancel.abort(); await session?.closeMcp?.(); }, - // **`liveMcpChildPids()`, not `session?.mcpChildPids`** — and a review reproduced why against a real - // subprocess. `session` is undefined for the whole connect, so on a signal there the cooperative close - // resolved instantly, the guard exited `128+signo`, and the exit net asked a `session` that did not exist - // for pids: it killed nothing while the SDK's ~4 s close ladder had barely started. Measured: the host - // exited 143 and the MCP child kept running at `ppid 1`. The registry is populated at SPAWN time, so the - // synchronous net can see a child the connect has not finished handshaking with. - () => liveMcpChildPids(), + // Native creation is registered before the builder resolves, under this original invocation. + undefined, + { + ...(deps.invocation === undefined ? {} : { invocation: deps.invocation }), + }, ); const built = await (async (): Promise => { const b = await (deps.buildSession ?? buildChatSession)({ + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), chat: config.chat, fsPolicy, afterTurnCompaction: false, // One-shot output has no next turn; retain active summary permission. diff --git a/apps/cli/src/commands/budget.test.ts b/apps/cli/src/commands/budget.test.ts index 4b656bb4..627f7ebb 100644 --- a/apps/cli/src/commands/budget.test.ts +++ b/apps/cli/src/commands/budget.test.ts @@ -1,3 +1,5 @@ +import { createCliInvocation } from '../process/invocation.js'; +import { witnessNativeToolProcess } from './native-forwarding.test-support.js'; import { randomUUID } from 'node:crypto'; import { spawn } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; @@ -243,6 +245,8 @@ function deps(io: ReturnType['io']): GateCommandDeps { for (const abort of [false, true]) { it(`native SQLite budget resume is binary and amount bounded (abort=${abort})`, async () => { const paused = await seed(); + const invocation = createCliInvocation(); + const exitListeners = process.listenerCount('exit'); const { io } = captureIo(); expect( await budgetCommand( @@ -250,9 +254,19 @@ for (const abort of [false, true]) { runId: paused.runId, ...(abort ? { abort: true } : { approveAmount: String(paused.amount) }), }, - deps(io), + { + ...deps(io), + invocation, + buildEngine: async (options) => { + if (!abort) + await witnessNativeToolProcess(options?.toolEnv?.nativeProcesses, invocation, root); + return buildEngine({ ...options, resolvePrice: prices }); + }, + }, ), ).toBe(abort ? 1 : 0); + expect(invocation.registry.activeOwners).toBe(0); + expect(process.listenerCount('exit')).toBe(exitListeners); const rows = reader().loadRunEventLogForReplay(paused.runId); const authorization = rows.find( (event) => event.type === 'budget:authorization' && event.authorization.state === 'decided', diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index a2e75663..7239d306 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -1,3 +1,7 @@ +import { createCliInvocation } from '../process/invocation.js'; +import { witnessNativeToolProcess } from './native-forwarding.test-support.js'; +import { appendGrant, fingerprint, resolveStdioSpawn } from '../engine/mcp-consent.js'; +import { mcpConsentPath } from '../engine/mcp-consent-gate.js'; import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -1863,6 +1867,141 @@ describe('chatCommand', () => { return p; } + it('binds the default standalone editor to the original native registry', async () => { + const invocation = createCliInvocation(); + const marker = join(cwd, 'editor-started'); + const script = join(cwd, 'editor.cjs'); + writeFileSync( + script, + `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'started'); setTimeout(() => {}, 200);`, + ); + const { d } = deps([], []); + const drive: ChatDriver = async (ctx) => { + ctx.startSession(); + ctx.store.notice('editor transcript witness'); + if (ctx.suspendPort === undefined) throw new Error('default editor suspend port is missing'); + ctx.suspendPort.attach((body) => body()); + try { + const editing = ctx.processLine('/edit'); + await vi.waitFor( + () => { + expect(invocation.registry.activeOwners, 'NATIVE_STANDALONE_EDITOR_FORWARDING').toBe(1); + expect(existsSync(marker)).toBe(true); + }, + { interval: 1 }, + ); + await editing; + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(0)); + } finally { + ctx.suspendPort.attach(undefined); + } + await ctx.processLine('/exit'); + return { kind: ctx.stopReason() }; + }; + expect( + await chatCommand( + { agent: undefined }, + { + ...d, + ...INERT_HOIST, + invocation, + io: { ...d.io, stdoutIsTty: true, env: { EDITOR: `"${process.execPath}" "${script}"` } }, + drive, + }, + ), + ).toBe(EXIT_CODES.chatEnded); + expect(existsSync(marker)).toBe(true); + }); + + it.each([ + ['fresh', 'clear'], + ['fresh', 'reseat'], + ['resumed', 'clear'], + ['resumed', 'reseat'], + ] as const)( + 'retains the real consent gate and native authority through a %s standalone %s rebuild', + async (entry, replacement) => { + const agentPath = writeMcpAgent(); + const spawn = await resolveStdioSpawn( + { serverId: 'fs', provenance: { kind: 'inline' }, command: 'node', args: [], env: {} }, + cwd, + ); + appendGrant(mcpConsentPath(home), { + v: 1, + digest: fingerprint(spawn), + command: spawn.command, + args: [...spawn.args], + envNames: [], + cwd: spawn.cwd, + grantedAt: '2026-10-16T00:00:00.000Z', + }); + const { d, err, store } = deps([], []); + const invocation = createCliInvocation(); + let starts = 0; + let nativeCreations = 0; + const connect: NonNullable[0]['startMcpClient']> = ( + servers, + ) => { + starts++; + return realStartMcpClient( + servers.map((server) => ({ ...server, open: () => Promise.resolve(mcpConn().conn) })), + ); + }; + let drives = 0; + const drive: ChatDriver = async (ctx) => { + ctx.startSession(); + await ctx.onActivated?.(() => !ctx.shouldStop()); + if (drives++ === 0) { + if (replacement === 'clear') return { kind: 'clear' }; + ctx.onReseat?.({ modelId: 'claude-opus-4-8', provider: 'anthropic' }); + return { kind: ctx.stopReason() }; + } + await ctx.processLine('/exit'); + return { kind: ctx.stopReason() }; + }; + let originalSessionId = ''; + if (entry === 'resumed') { + await chatCommand( + { agent: agentPath }, + { + ...d, + drive: linesDriver(['/exit']), + buildSession: (options) => buildChatSession({ ...options, startMcpClient: connect }), + }, + ); + originalSessionId = store.listSessions()[0]?.id ?? ''; + expect(originalSessionId).not.toBe(''); + } + const nativeDeps: ChatCommandDeps & ChatResumeCommandDeps = { + ...d, + ...INERT_HOIST, + invocation, + io: { ...d.io, stdoutIsTty: true }, + drive, + // Keep the production consent gate untouched. Fake only the post-consent connection. + buildSession: async (options) => { + await witnessNativeToolProcess(options.nativeProcesses, invocation, cwd); + nativeCreations++; + return buildChatSession({ ...options, startMcpClient: connect }); + }, + buildResumedSession: async (options) => { + await witnessNativeToolProcess(options.nativeProcesses, invocation, cwd); + nativeCreations++; + return buildResumedChatSession({ ...options, startMcpClient: connect }); + }, + }; + if (entry === 'resumed') { + await chatResumeCommand({ sessionId: originalSessionId }, nativeDeps); + } else { + await chatCommand({ agent: agentPath }, nativeDeps); + } + expect(nativeCreations, 'NATIVE_STANDALONE_BUILD_FORWARDING').toBe(2); + expect(starts, 'CONSENT_REBUILD_FORWARDING').toBe(entry === 'resumed' ? 3 : 2); + expect(drives).toBe(2); + expect(err()).not.toContain('without a consent gate'); + }, + ); + it('an MCP-declaring chat agent: surfaces dropped tools to stderr and tears the connection down at REPL teardown (2.R)', async () => { // chat.ts's OWN command-level MCP wiring: surfaceMcpSkipped (→ stderr, never the stdout chrome) + the // closeMcp teardown in runReplLoop's finally. Drives the REAL buildChatSession over a fake connection. @@ -2310,6 +2449,7 @@ describe('chatResumeCommand (2.N)', () => { it('/clear from a resumed session rebinds the SNAPSHOT agent into a fresh session (ADR-0062 §7)', async () => { writeOutputEstimate(cwd); + const invocation = createCliInvocation(); const resumedEstimates: (number | undefined)[] = []; const freshEstimates: (number | undefined)[] = []; const store = createSessionStore(client.db); @@ -2334,12 +2474,19 @@ describe('chatResumeCommand (2.N)', () => { { sessionId: 'id-0' }, { ...d, + invocation, drive: clearThenExit, - buildSession: (options) => { + buildSession: async (options) => { + await witnessNativeToolProcess(options.nativeProcesses, invocation, options.cwd); freshEstimates.push(options.maxTokensEstimate); return buildChatSession(options); }, - buildResumedSession: (options) => { + buildResumedSession: async (options) => { + await witnessNativeToolProcess( + options.nativeProcesses, + invocation, + options.record.context.workingDir, + ); resumedEstimates.push(options.maxTokensEstimate); return buildResumedChatSession(options); }, @@ -2360,6 +2507,7 @@ describe('chatResumeCommand (2.N)', () => { it('preserves the configured estimate through a resumed-session model reseat', async () => { writeOutputEstimate(cwd); + const invocation = createCliInvocation(); const store = createSessionStore(client.db); await chatCommand({ agent: undefined }, freshDeps(['hello', '/exit'], [textTurn('hi')], store)); seedCatalogModel(client.db, 'anthropic', 'claude-sonnet-4-6'); @@ -2382,10 +2530,16 @@ describe('chatResumeCommand (2.N)', () => { { sessionId: 'id-0' }, { ...d, + invocation, ...INERT_HOIST, io: { ...d.io, stdoutIsTty: true }, drive, - buildResumedSession: (options) => { + buildResumedSession: async (options) => { + await witnessNativeToolProcess( + options.nativeProcesses, + invocation, + options.record.context.workingDir, + ); estimates.push(options.maxTokensEstimate); return buildResumedChatSession(options); }, diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 2171d623..7a821778 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -1,3 +1,8 @@ +import type { CliInvocation } from '../process/invocation.js'; +import { + nativeProcessesForInvocation, + type NativeProcessController, +} from '../process/native-process-owner.js'; import { randomUUID } from 'node:crypto'; import { realpathSync } from 'node:fs'; import { isAbsolute } from 'node:path'; @@ -88,7 +93,7 @@ import type { GlobalOptions } from '../process/options.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import { detectOutputMode, isCiEnv } from '../process/output-mode.js'; import { createAltScreenController, type AltScreenController } from '../render/alt-screen.js'; -import { nodeCreateTempDocument, nodeSpawnEditor } from '../render/editor.js'; +import { nodeCreateTempDocument, createNodeSpawnEditor } from '../render/editor.js'; import { createHatches, hoistedTerminal, @@ -333,6 +338,8 @@ export interface ChatDriveOutcome { export type ChatDriver = (ctx: ChatDriveContext) => Promise; export interface ChatCommandDeps { + /** The original bootstrap invocation, shared across clear and reseat. */ + readonly invocation?: CliInvocation; readonly io: CliIo; readonly global: GlobalOptions; readonly providers?: ProviderResolver; @@ -370,6 +377,8 @@ export interface ChatResumeCommandArgs { } export interface ChatResumeCommandDeps { + /** The original bootstrap invocation, shared across clear and reseat. */ + readonly invocation?: CliInvocation; readonly io: CliIo; readonly global: GlobalOptions; readonly providers?: ProviderResolver; @@ -402,6 +411,8 @@ export interface ChatResumeCommandDeps { /** The subset the shared {@link runReplLoop} needs — satisfied by both {@link ChatCommandDeps} and {@link ChatResumeCommandDeps}. */ interface ChatReplDeps { + /** The original bootstrap invocation, shared across clear and reseat. */ + readonly invocation?: CliInvocation; readonly io: CliIo; readonly global: GlobalOptions; readonly drive?: ChatDriver; @@ -542,6 +553,7 @@ export function budgetWarningText(warning: ChatBudgetWarning): string { } export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): Promise { + const nativeProcesses = nativeProcessesForInvocation(deps.invocation); const grants = admitFsInvocation(deps.global); const now = deps.now ?? Date.now; const uuid = deps.uuid ?? randomUUID; @@ -552,6 +564,12 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): cwd: deps.global.cwd, configPath: deps.global.configPath, }); + const consentGate = createConsentGate({ + io: deps.io, + global: deps.global, + homeDir, + prompt: createConsentPrompter(), + }); const fsPolicy = admitChatFsPolicy(grants, deps.global.cwd, config.chat.extraRoots); const keys = deps.resolveKeys?.(); const providers = deps.providers ?? keys?.providers ?? createProviderResolver(deps.io.env); @@ -572,6 +590,7 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): const built = await (deps.buildSession ?? buildChatSession)({ chat: config.chat, fsPolicy, + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), ...(config.maxTokensEstimate === undefined ? {} : { maxTokensEstimate: config.maxTokensEstimate }), @@ -579,12 +598,7 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): // agent is opened, which is the case the gate exists for — and the one the first wiring missed by // covering only `relavium run`. No `--allow-mcp-stdio` here: a chat is interactive by construction, so // the refusal directs a scripted caller at `agent run`, which has the flag. - consentGate: createConsentGate({ - io: deps.io, - global: deps.global, - homeDir, - prompt: createConsentPrompter(), - }), + consentGate, agentRef: args.agent, cwd: deps.global.cwd, projectConfigDir, @@ -671,6 +685,8 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): const rebuild = createClearRebuild({ chat: config.chat, fsPolicy, + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), + consentGate, ...(config.maxTokensEstimate === undefined ? {} : { maxTokensEstimate: config.maxTokensEstimate }), @@ -695,6 +711,8 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): const reseatRebuild = createReseatRebuild({ chat: config.chat, fsPolicy, + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), + consentGate, ...(config.maxTokensEstimate === undefined ? {} : { maxTokensEstimate: config.maxTokensEstimate }), @@ -744,6 +762,7 @@ export async function chatResumeCommand( args: ChatResumeCommandArgs, deps: ChatResumeCommandDeps, ): Promise { + const nativeProcesses = nativeProcessesForInvocation(deps.invocation); const now = deps.now ?? Date.now; const uuid = deps.uuid ?? randomUUID; @@ -755,6 +774,7 @@ export async function chatResumeCommand( let fsPolicy: FsPathPolicy; let providers: ProviderResolver; let mcpSecretResolver: McpSecretResolver; + let consentGate: NonNullable; let built: BuiltChatSession; let store: ChatStoreController; @@ -813,19 +833,21 @@ export async function chatResumeCommand( // session enforces + tracks a user-priced model exactly like a fresh `chat`. Non-fatal (an empty map on a // read fault): a corrupt pricing row must not fail an otherwise-valid resume. resolvePrice = readUserPricingOverlay(opened.db); + consentGate = createConsentGate({ + io: deps.io, + global: deps.global, + homeDir, + prompt: createConsentPrompter(), + }); const resumed = await (deps.buildResumedSession ?? buildResumedChatSession)({ chat: config.chat, fsPolicy, + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), ...(config.maxTokensEstimate === undefined ? {} : { maxTokensEstimate: config.maxTokensEstimate }), // Consent before any stdio MCP spawn (ADR-0084 §1) — every path that opens an agent, not only `run`. - consentGate: createConsentGate({ - io: deps.io, - global: deps.global, - homeDir, - prompt: createConsentPrompter(), - }), + consentGate, record: loaded.session, messages: loaded.messages, now, @@ -903,6 +925,8 @@ export async function chatResumeCommand( const rebuild = createClearRebuild({ chat: config.chat, fsPolicy, + consentGate, + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), ...(config.maxTokensEstimate === undefined ? {} : { maxTokensEstimate: config.maxTokensEstimate }), @@ -926,6 +950,8 @@ export async function chatResumeCommand( const reseatRebuild = createReseatRebuild({ chat: config.chat, fsPolicy, + consentGate, + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), ...(config.maxTokensEstimate === undefined ? {} : { maxTokensEstimate: config.maxTokensEstimate }), @@ -1613,6 +1639,8 @@ export function createChatLineHandler( interface FreshChatWiringDeps { readonly chat: BuildChatSessionOptions['chat']; readonly fsPolicy?: FsPathPolicy; + readonly nativeProcesses?: NativeProcessController; + readonly consentGate?: BuildChatSessionOptions['consentGate']; readonly maxTokensEstimate?: number; readonly agent: AgentDefinition; readonly cwd: string; @@ -1648,6 +1676,8 @@ async function buildFreshChatWiring(deps: FreshChatWiringDeps, intro: string): P const built = await deps.buildSession({ chat: deps.chat, ...(deps.fsPolicy === undefined ? {} : { fsPolicy: deps.fsPolicy }), + ...(deps.nativeProcesses === undefined ? {} : { nativeProcesses: deps.nativeProcesses }), + ...(deps.consentGate === undefined ? {} : { consentGate: deps.consentGate }), ...(deps.maxTokensEstimate === undefined ? {} : { maxTokensEstimate: deps.maxTokensEstimate }), agent: deps.agent, agentRef: deps.agent.id, // ignored when `agent` is set, but the option key is required @@ -1758,6 +1788,8 @@ async function buildFreshChatWiring(deps: FreshChatWiringDeps, intro: string): P function createClearRebuild(params: { readonly chat: BuildChatSessionOptions['chat']; readonly fsPolicy?: FsPathPolicy; + readonly nativeProcesses?: NativeProcessController; + readonly consentGate?: BuildChatSessionOptions['consentGate']; readonly maxTokensEstimate?: number; readonly agent: AgentDefinition; readonly projectConfigDir: string | undefined; @@ -1776,6 +1808,8 @@ function createClearRebuild(params: { const wiringDeps: FreshChatWiringDeps = { chat: params.chat, ...(params.fsPolicy === undefined ? {} : { fsPolicy: params.fsPolicy }), + ...(params.nativeProcesses === undefined ? {} : { nativeProcesses: params.nativeProcesses }), + ...(params.consentGate === undefined ? {} : { consentGate: params.consentGate }), ...(params.maxTokensEstimate === undefined ? {} : { maxTokensEstimate: params.maxTokensEstimate }), @@ -1881,6 +1915,8 @@ function seedResumedWiring( interface ReseatWiringDeps { readonly chat: BuildChatSessionOptions['chat']; readonly fsPolicy?: FsPathPolicy; + readonly nativeProcesses?: NativeProcessController; + readonly consentGate?: BuildChatSessionOptions['consentGate']; readonly maxTokensEstimate?: number; readonly now: () => number; readonly uuid: () => string; @@ -1949,6 +1985,8 @@ async function buildReseatWiring( const resumed = await deps.buildResumedSession({ chat: deps.chat, ...(deps.fsPolicy === undefined ? {} : { fsPolicy: deps.fsPolicy }), + ...(deps.nativeProcesses === undefined ? {} : { nativeProcesses: deps.nativeProcesses }), + ...(deps.consentGate === undefined ? {} : { consentGate: deps.consentGate }), ...(deps.maxTokensEstimate === undefined ? {} : { maxTokensEstimate: deps.maxTokensEstimate }), record, messages: loaded.messages, @@ -2035,6 +2073,8 @@ async function buildReseatWiring( function createReseatRebuild(params: { readonly chat: BuildChatSessionOptions['chat']; readonly fsPolicy?: FsPathPolicy; + readonly nativeProcesses?: NativeProcessController; + readonly consentGate?: BuildChatSessionOptions['consentGate']; readonly maxTokensEstimate?: number; readonly now: () => number; readonly uuid: () => string; @@ -2055,6 +2095,8 @@ function createReseatRebuild(params: { const wiringDeps: ReseatWiringDeps = { chat: params.chat, ...(params.fsPolicy === undefined ? {} : { fsPolicy: params.fsPolicy }), + ...(params.nativeProcesses === undefined ? {} : { nativeProcesses: params.nativeProcesses }), + ...(params.consentGate === undefined ? {} : { consentGate: params.consentGate }), ...(params.maxTokensEstimate === undefined ? {} : { maxTokensEstimate: params.maxTokensEstimate }), @@ -2490,7 +2532,7 @@ export async function runReplLoop( clipboard: (text: string) => copyToClipboard({ writeControl, env: deps.io.env }, text), editor: { env: deps.io.env, - spawnEditor: nodeSpawnEditor, + spawnEditor: createNodeSpawnEditor(nativeProcessesForInvocation(deps.invocation)), createTempDocument: nodeCreateTempDocument, onDisposeFailed: (path, error) => { deps.io.writeErr( diff --git a/apps/cli/src/commands/dispatch-native-forwarding.test.ts b/apps/cli/src/commands/dispatch-native-forwarding.test.ts new file mode 100644 index 00000000..0029c5a4 --- /dev/null +++ b/apps/cli/src/commands/dispatch-native-forwarding.test.ts @@ -0,0 +1,69 @@ +import { mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; + +import { createCliInvocation } from '../process/invocation.js'; +import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; +import { captureIo } from '../test-support.js'; +import { executeCommand, type CommandInput, type DispatchContext } from './dispatch.js'; +import { witnessNativeToolProcess } from './native-forwarding.test-support.js'; + +/** Retain the real production dependency projections; replace only the command body with a native owner. */ +async function nativeBoundary(deps: DispatchContext): Promise<0> { + expect(deps.invocation, 'NATIVE_DISPATCH_INVOCATION').toBeDefined(); + if (deps.invocation === undefined) throw new Error('dispatch omitted the original invocation'); + await witnessNativeToolProcess( + nativeProcessesForInvocation(deps.invocation), + deps.invocation, + deps.global.cwd, + ); + return 0; +} + +vi.mock('./run.js', () => ({ + runCommand: (_args: unknown, deps: DispatchContext) => nativeBoundary(deps), +})); +vi.mock('./chat.js', () => ({ + chatCommand: (_args: unknown, deps: DispatchContext) => nativeBoundary(deps), + chatResumeCommand: (_args: unknown, deps: DispatchContext) => nativeBoundary(deps), +})); +vi.mock('./agent-run.js', () => ({ + agentRunCommand: (_args: unknown, deps: DispatchContext) => nativeBoundary(deps), +})); +vi.mock('./gate.js', () => ({ + gateCommand: (_args: unknown, deps: DispatchContext) => nativeBoundary(deps), + budgetCommand: (_args: unknown, deps: DispatchContext) => nativeBoundary(deps), +})); + +const routes: readonly (readonly [string, CommandInput])[] = [ + ['run', { positionals: ['workflow'], options: {} }], + ['chat', { positionals: [], options: {} }], + ['chat-resume', { positionals: ['session'], options: {} }], + ['agent.run', { positionals: ['agent'], options: {} }], + ['gate', { positionals: ['run'], options: { approve: true } }], + ['budget.resume', { positionals: ['run'], options: { approveAmount: '1' } }], +]; + +describe('production native dispatch dependency forwarding', () => { + it.each(routes)( + '%s reaches physical native creation under the supplied registry', + async (id, input) => { + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-dispatch-native-'))); + const invocation = createCliInvocation(); + try { + expect( + await executeCommand(id, input, { + io: captureIo().io, + global: { json: false, color: false, cwd, configPath: undefined, verbosity: 'normal' }, + invocation, + }), + ).toBe(0); + expect(invocation.registry.activeOwners).toBe(0); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/apps/cli/src/commands/dispatch.ts b/apps/cli/src/commands/dispatch.ts index 28862db6..26b4c3c7 100644 --- a/apps/cli/src/commands/dispatch.ts +++ b/apps/cli/src/commands/dispatch.ts @@ -407,6 +407,7 @@ const executeRun: CommandExecutor = (input, ctx) => runCommand(buildRunArgs(input), { io: ctx.io, global: ctx.global, + ...(ctx.invocation === undefined ? {} : { invocation: ctx.invocation }), openRunStore: openHistoryStore, // ADR-0107: construct native credential services only after complete input admission. resolveKeys: () => keyResolvers(ctx), @@ -416,6 +417,7 @@ const executeChat: CommandExecutor = (input, ctx) => chatCommand(buildChatArgs(input), { io: ctx.io, global: ctx.global, + ...(ctx.invocation === undefined ? {} : { invocation: ctx.invocation }), resolveKeys: (db) => keyResolvers(ctx, db), openSessionStore, drive: selectChatDriver, @@ -427,6 +429,7 @@ const executeChatResume: CommandExecutor = (input, ctx) => { io: ctx.io, global: ctx.global, + ...(ctx.invocation === undefined ? {} : { invocation: ctx.invocation }), resolveKeys: (db) => keyResolvers(ctx, db), openSessionStore, drive: selectChatDriver, @@ -458,6 +461,7 @@ const executeAgentRun: CommandExecutor = (input, ctx) => agentRunCommand(buildAgentRunArgs(input), { io: ctx.io, global: ctx.global, + ...(ctx.invocation === undefined ? {} : { invocation: ctx.invocation }), resolveKeys: () => keyResolvers(ctx), }); @@ -465,6 +469,7 @@ const executeGate: CommandExecutor = (input, ctx) => gateCommand(buildGateArgs(input), { io: ctx.io, global: ctx.global, + ...(ctx.invocation === undefined ? {} : { invocation: ctx.invocation }), resolveKeys: (db) => keyResolvers(ctx, db), }); @@ -472,6 +477,7 @@ const executeBudget: CommandExecutor = (input, ctx) => budgetCommand(buildBudgetArgs(input), { io: ctx.io, global: ctx.global, + ...(ctx.invocation === undefined ? {} : { invocation: ctx.invocation }), resolveKeys: (db) => keyResolvers(ctx, db), }); diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index 059a6fb6..6f5430d5 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -1,3 +1,5 @@ +import { createCliInvocation } from '../process/invocation.js'; +import { witnessNativeToolProcess } from './native-forwarding.test-support.js'; import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -315,6 +317,30 @@ describe('gateCommand', () => { return { runId, gateIds }; } + it('forwards the resuming invocation before the actual engine build resolves', async () => { + const { runId } = await setupPausedRun(); + const invocation = createCliInvocation(); + const { io } = captureIo(); + expect( + await gateCommand( + { runId, approve: true }, + { + ...deps(io), + invocation, + buildEngine: async (options) => { + await witnessNativeToolProcess( + options?.toolEnv?.nativeProcesses, + invocation, + options?.toolEnv?.workspaceDir ?? root, + ); + return buildEngine(options); + }, + }, + ), + ).toBe(EXIT_CODES.success); + expect(invocation.registry.activeOwners).toBe(0); + }); + it.each([ { label: 'an inline agent with repeated fallback model/provider entries', diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index c0536508..4b352185 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -1,3 +1,5 @@ +import type { CliInvocation } from '../process/invocation.js'; +import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; import { closeQuietly, warnTeardown } from '../process/close-quietly.js'; import { EgressWorkScope } from '../engine/egress-work.js'; import { randomUUID } from 'node:crypto'; @@ -29,7 +31,6 @@ import { type GateDecision, type RunStatus, } from '@relavium/shared'; -import { liveMcpChildPids } from '@relavium/mcp'; import { loadResolvedConfig } from '../config/load.js'; import { openLocalDb } from '../db/open.js'; @@ -139,6 +140,8 @@ export interface GateCommandArgs extends ResumeGateArgs, GateFlags {} export interface BudgetCommandArgs extends ResumeGateArgs, BudgetDecisionFlags {} export interface GateCommandDeps { + /** The original bootstrap invocation; native authority stays host-only. */ + readonly invocation?: CliInvocation; readonly io: CliIo; readonly global: GlobalOptions; /** Injectable so tests drive a stub provider + the in-memory host engine. */ @@ -267,6 +270,7 @@ async function resumeGateCommand( kind: 'human' | 'budget', decision: GateDecision, ): Promise { + const nativeProcesses = nativeProcessesForInvocation(deps.invocation); const { config, homeDir } = loadResolvedConfig({ cwd: deps.global.cwd, configPath: deps.global.configPath, @@ -412,10 +416,14 @@ async function resumeGateCommand( async () => { await mcpRuntime?.client.close(); }, - () => liveMcpChildPids(), - { reapOnly: true }, + undefined, + { + reapOnly: true, + ...(deps.invocation === undefined ? {} : { invocation: deps.invocation }), + }, ); mcpRuntime = await connectWorkflowMcp(workflow, { + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), cwd: saveToRoot, preserveFrozenGrants: true, connectSignal: resumeCancel.signal, @@ -491,7 +499,11 @@ async function resumeGateCommand( // on this machine, else the resumer's cwd, exactly like the `save_to` root) at the resolved `fs_scope`. So a // tool-using agent node on the FAR side of a human gate reads/writes the ORIGINAL project context // (checkpoint/resume parity), not the gate caller's directory, and not `tool_unavailable`. - toolEnv: { workspaceDir: saveToRoot, fsScopeTier: config.fsScope ?? 'sandboxed' }, + toolEnv: { + workspaceDir: saveToRoot, + fsScopeTier: config.fsScope ?? 'sandboxed', + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), + }, host: createCliHost(store, { checkpointer, media: wiring.media, diff --git a/apps/cli/src/commands/native-forwarding.test-support.ts b/apps/cli/src/commands/native-forwarding.test-support.ts new file mode 100644 index 00000000..26b36f82 --- /dev/null +++ b/apps/cli/src/commands/native-forwarding.test-support.ts @@ -0,0 +1,94 @@ +import { randomUUID } from 'node:crypto'; +import { existsSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect, vi } from 'vitest'; + +import { assembleToolEnv } from '../engine/tool-host/assemble.js'; +import type { CliInvocation } from '../process/invocation.js'; +import { + nativeProcessesForInvocation, + type NativeProcessController, +} from '../process/native-process-owner.js'; + +/** A physical child witness for host-only forwarding, with an independent tool timeout. */ +export async function witnessNativeToolProcess( + nativeProcesses: NativeProcessController | undefined, + invocation: CliInvocation, + workspaceDir: string, +): Promise { + expect(nativeProcesses, 'NATIVE_INVOCATION_FORWARDING').toBe( + nativeProcessesForInvocation(invocation), + ); + const { host } = assembleToolEnv({ + profile: 'workflow-read-write', + fsScopeTier: 'project', + workspaceDir, + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), + }); + if (host.process === undefined) throw new Error('the real process arm is missing'); + expect(invocation.registry.activeOwners).toBe(0); + const pending = host.process.spawn( + process.execPath, + ['-e', 'setTimeout(() => process.stdout.write("native-witness"), 150)'], + {}, + { timeoutMs: 2_000 }, + ); + await vi.waitFor( + () => expect(invocation.registry.activeOwners, 'NATIVE_PROCESS_ASSEMBLY_OWNERSHIP').toBe(1), + { interval: 1 }, + ); + expect(await pending).toMatchObject({ exitCode: 0, stdout: 'native-witness' }); + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(0), { interval: 1 }); +} + +/** Keep an actual tool child alive across overlapping builders, bounded independently of their gates. */ +export async function holdNativeToolProcess( + nativeProcesses: NativeProcessController | undefined, + invocation: CliInvocation, + workspaceDir: string, +): Promise<{ close: () => Promise }> { + expect(nativeProcesses, 'NATIVE_PENDING_FORWARDING').toBe( + nativeProcessesForInvocation(invocation), + ); + const before = invocation.registry.activeOwners; + const marker = join(workspaceDir, `native-${randomUUID()}.marker`); + const abort = new AbortController(); + const { host } = assembleToolEnv({ + profile: 'workflow-read-write', + fsScopeTier: 'project', + workspaceDir, + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), + }); + if (host.process === undefined) throw new Error('the real process arm is missing'); + const pending = host.process + .spawn( + process.execPath, + [ + '-e', + 'require("node:fs").writeFileSync(process.argv[1], "started"); setTimeout(() => {}, 30000)', + marker, + ], + {}, + { timeoutMs: 3_000 }, + abort.signal, + ) + .catch((error: unknown) => error); + const close = async (): Promise => { + abort.abort(); + await pending; + rmSync(marker, { force: true }); + }; + try { + await vi.waitFor( + () => { + expect(existsSync(marker)).toBe(true); // physical native entry, not only a reserved field + expect(invocation.registry.activeOwners).toBe(before + 1); + }, + { interval: 1 }, + ); + return { close }; + } catch (error) { + await close(); + throw error; + } +} diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts index fdeb46a2..9e317d62 100644 --- a/apps/cli/src/commands/run.test.ts +++ b/apps/cli/src/commands/run.test.ts @@ -1,3 +1,5 @@ +import { createCliInvocation } from '../process/invocation.js'; +import { witnessNativeToolProcess } from './native-forwarding.test-support.js'; import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -327,6 +329,37 @@ function makeStallingCancelEngine(): { } describe('runCommand', () => { + it.each([false, true])( + 'forwards the original native owner before the engine build resolves (durable=%s)', + async (durable) => { + const invocation = createCliInvocation(); + const path = writeWorkflow('native-forwarding.relavium.yaml', HAPPY); + const { io } = captureIo(); + const client = createClient(':memory:'); + runMigrations(client.db); + try { + const code = await runCommand( + { workflow: path, input: ['n=2'], allowMcpStdio: [] }, + deps(io, globalOptions(), { + invocation, + ...(durable ? { openRunStore: historyOpenRunStore(client.db) } : {}), + buildEngine: async (options) => { + await witnessNativeToolProcess(options?.toolEnv?.nativeProcesses, invocation, root); + return buildEngine({ + ...options, + ...(durable ? {} : { host: createInMemoryHost() }), + }); + }, + }), + ); + expect(code).toBe(EXIT_CODES.success); + expect(invocation.registry.activeOwners).toBe(0); + } finally { + client.sqlite.close(); + } + }, + ); + it('warns after admission before services and preserves the actual augmented frozen workflow', async () => { const authoredLiteral = 'sk-runAuthoredSyntheticCredential123456789'; const stdinLiteral = 'sk-runStdinSyntheticCredential987654321'; diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 1dbf1e43..c1bf316b 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -1,3 +1,5 @@ +import type { CliInvocation } from '../process/invocation.js'; +import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; import { closeQuietly, warnTeardown } from '../process/close-quietly.js'; import { EgressWorkScope } from '../engine/egress-work.js'; import { randomUUID } from 'node:crypto'; @@ -10,7 +12,6 @@ import { type WorkflowEngine, WorkflowParseError, } from '@relavium/core'; -import { liveMcpChildPids } from '@relavium/mcp'; import type { MediaBilledModality } from '@relavium/shared'; import { warnSnapshotLiterals } from '../authoring/snapshot-notice.js'; @@ -96,6 +97,8 @@ export interface RunCommandArgs { } export interface RunCommandDeps { + /** The original bootstrap invocation; native authority stays host-only. */ + readonly invocation?: CliInvocation; readonly io: CliIo; readonly global: GlobalOptions; /** Injectable so tests (and the 2.K harness) drive a stub provider + the in-memory host. */ @@ -172,6 +175,7 @@ function parseOrRefuse(yaml: string, source: string): WorkflowDefinition { } export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Promise { + const nativeProcesses = nativeProcessesForInvocation(deps.invocation); if ((deps.global.allowFsRoots?.length ?? 0) > 0) { throw new CliError( 'invalid_invocation', @@ -231,7 +235,7 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr * Arming it after `connectWorkflowMcp` resolved was the first attempt, and a review found the hole: the * SDK spawns each stdio child inside `start()`, so for up to 120 s per server — precisely the silent window * where a user reaches for Ctrl-C — the children existed with no guard armed. Two things follow from arming - * early: the pids are read LAZILY (there are none yet at this point), and the connect itself is given a + * early: native ownership is registered during creation, and the connect itself is given a * cancel signal so a Ctrl-C during it actually stops rather than merely being survived. * * `reapOnly`: `driveRun` owns this surface's documented signal contract — Ctrl-C drains to `run:cancelled` @@ -248,12 +252,12 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr mcpConnectCancel.abort(); // stops an in-flight connect; a no-op once one completed await mcpRuntime?.client.close(); }, - // The live registry, not the assembled client's snapshot: `mcpRuntime` is undefined for the whole - // connect, which is exactly the window a child exists with no handshake yet. See `agent-run.ts` for - // the reproduction — this surface survives it only because `reapOnly` keeps it from exiting early, - // which is a property of ITS signal contract rather than of the reaper. Both now read the same source. - () => liveMcpChildPids(), - { reapOnly: true }, + // The original invocation owns native creation during the pending connect as well as after it. + undefined, + { + reapOnly: true, + ...(deps.invocation === undefined ? {} : { invocation: deps.invocation }), + }, ) : (): void => undefined; try { @@ -262,6 +266,7 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr // workflow so each inline agent's grant includes ONLY its own servers' discovered tools. `undefined` ⇒ no // inline agent declared a server. The spawned children are torn down at the run terminal (the finally). mcpRuntime = await connectWorkflowMcp(def, { + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), cwd: deps.global.cwd, connectSignal: mcpConnectCancel.signal, work: mcpWork, @@ -325,6 +330,7 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr // resolved `[defaults].fs_scope` (default sandboxed). The factory feeds BOTH this run path and the chat // path, so a workflow's read_file / write_file / run_command / git_status work (the MCP arm merges on top). const toolEnv = { + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), workspaceDir: deps.global.cwd, fsScopeTier: config.fsScope ?? 'sandboxed', }; diff --git a/apps/cli/src/engine/build-engine.test.ts b/apps/cli/src/engine/build-engine.test.ts index 2520408a..cd52de3b 100644 --- a/apps/cli/src/engine/build-engine.test.ts +++ b/apps/cli/src/engine/build-engine.test.ts @@ -1,5 +1,8 @@ +import { createCliInvocation } from '../process/invocation.js'; +import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; import { randomUUID } from 'node:crypto'; import { mkdtempSync, rmSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -18,12 +21,12 @@ import { runMigrations, } from '@relavium/db'; import { buildServerToolDefs } from '@relavium/mcp'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildEngine } from './build-engine.js'; import { createCliHost } from './host.js'; import { createHistoryCheckpointer } from './checkpointer.js'; -import { scriptedResolver, textTurn } from '../chat/test-support.js'; +import { scriptedResolver, textTurn, toolUseTurn } from '../chat/test-support.js'; describe('buildEngine configured output fallback (ADR-0101)', () => { it.each([undefined, 17])( @@ -295,3 +298,52 @@ ${ordinary ? ' - {from: ordinary, to: out}\n' : ''}`); }, ); }); + +describe('buildEngine native factory forwarding', () => { + it('registers an actual workflow tool process in the original invocation', async () => { + const cwd = mkdtempSync(join(tmpdir(), 'relavium-engine-native-')); + const invocation = createCliInvocation(); + const nativeProcesses = nativeProcessesForInvocation(invocation); + const register = vi.spyOn(invocation.registry, 'register'); + try { + execFileSync('git', ['init', '-q'], { cwd }); + const engine = await buildEngine({ + host: createInMemoryHost(), + providers: scriptedResolver([toolUseTurn('c1', 'git_status'), textTurn('clean')]), + toolEnv: { + workspaceDir: cwd, + fsScopeTier: 'project', + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), + }, + }); + expect(invocation.registry.activeOwners).toBe(0); + const workflow = parseWorkflow(`schema_version: '1.0' +workflow: + id: native-tool + agents: + - {id: worker, model: claude-sonnet-4-6, provider: anthropic, system_prompt: inspect, tools: [git_status]} + nodes: + - {id: work, type: agent, agent_ref: worker, prompt_template: inspect} + - {id: out, type: output} + edges: + - {from: work, to: out} +`); + const events: RunEvent[] = []; + for await (const event of engine.start({ workflow }).events) events.push(event); + const toolResult = events.find((event) => event.type === 'agent:tool_result'); + expect(toolResult).toMatchObject({ toolId: 'git_status', success: true }); + expect( + toolResult?.type === 'agent:tool_result' ? toolResult.outputSummary : undefined, + ).toContain('"exitCode":0'); + expect(events.at(-1)?.type).toBe('run:completed'); + expect(register, 'NATIVE_WORKFLOW_FACTORY_FORWARDING').toHaveBeenCalledWith( + expect.objectContaining({ + kind: process.platform === 'win32' ? 'windows-live-root' : 'native-group', + }), + ); + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(0)); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/engine/build-engine.ts b/apps/cli/src/engine/build-engine.ts index 4a95df07..c7e00d13 100644 --- a/apps/cli/src/engine/build-engine.ts +++ b/apps/cli/src/engine/build-engine.ts @@ -1,3 +1,4 @@ +import type { NativeProcessController } from '../process/native-process-owner.js'; import { type AgentRunnerDeps, BUILTIN_TOOLS, @@ -70,7 +71,11 @@ export interface BuildEngineOptions { * a tool needing a capability is cleanly `tool_unavailable`. `run.ts` passes the launch cwd + the resolved * `fs_scope`. The MCP arm is merged on top below. */ - readonly toolEnv?: { readonly workspaceDir: string; readonly fsScopeTier: FsScopeTier }; + readonly toolEnv?: { + readonly workspaceDir: string; + readonly fsScopeTier: FsScopeTier; + readonly nativeProcesses?: NativeProcessController; + }; /** * The ADR-0065 §2 user-pricing overlay (2.5.G S10) — a `ReadonlyMap` the caller (`run.ts`) * projects from the `model_catalog` `source='user'` rows. Threaded into BOTH the workflow PRE-EGRESS governor @@ -135,6 +140,9 @@ export async function buildEngine(options: BuildEngineOptions = {}): Promise new Promise((resolve) => setImmediate(resolve)); + +function workflow(server: McpServerRef = ref): WorkflowDefinition { + return parseWorkflow( + `schema_version: '1.0' +workflow: + id: native-owner + agents: + - id: scanner + model: gpt-4o + provider: openai + system_prompt: go + mcp_servers: ${JSON.stringify([server])} + nodes: + - {id: start, type: input} + - {id: agent, type: agent, agent_ref: scanner, prompt_template: go} + - {id: out, type: output} + edges: + - {from: start, to: agent} + - {from: agent, to: out} +`, + ); +} + +const consent = (): Promise> => Promise.resolve(new Map()); + +async function until(predicate: () => boolean): Promise { + const deadline = Date.now() + 10000; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error('MCP native ownership transition did not complete'); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +describe('MCP CLI bridge to actual creation-owned native lifetime', () => { + it.each(['agent', 'workflow'] as const)( + '%s forwards the same controller through config.open before an actual SDK handshake', + async (surface) => { + const invocation = createCliInvocation(); + const native = createNativeProcessController(invocation.registry); + const work = new EgressWorkScope(); + const abort = new AbortController(); + let lifetime: StdioNativeLifetime | undefined; + const nativeProcesses: NativeProcessController = { + reserve: native.reserve, + observeMcp: async () => { + const observer = await native.observeMcp(); + return { + ...observer, + attach: (value) => { + lifetime = value; + observer.attach(value); + }, + }; + }, + }; + const options = { + cwd: process.cwd(), + consentGate: consent, + connectSignal: abort.signal, + nativeProcesses, + work, + }; + const beforeExitListeners = process.listenerCount('exit'); + const opening = + surface === 'agent' + ? connectAgentMcp([ref], options) + : connectWorkflowMcp(workflow(), options); + const outcome = opening.catch((error: unknown) => error); + let pid: number | undefined; + try { + await until(() => lifetime?.readPid() !== undefined); + pid = lifetime?.readPid(); + if (pid === undefined || lifetime === undefined) + throw new Error('missing SDK native owner'); + expect(invocation.registry.activeOwners).toBe(1); + expect(lifetime.isClosed()).toBe(false); + expect(alive(pid)).toBe(true); + // Finite exit delegates to the exact reservation, even before a client handle exists. + // It neither launches an async helper nor certifies close from a successful kill request. + invocation.registry.reapNativeAtExit(); + expect(lifetime.isClosed()).toBe(false); + expect(invocation.registry.activeOwners).toBe(1); + expect(invocation.registry.termination).toBeUndefined(); + abort.abort(); + await outcome; + await lifetime.whenClosed; + work.seal(); + await work.done; + await tick(); + expect(alive(pid)).toBe(false); + expect(lifetime.readPid()).toBeUndefined(); + expect(invocation.registry.activeOwners).toBe(0); + expect(process.listenerCount('exit')).toBe(beforeExitListeners); + } finally { + abort.abort(); + if (pid !== undefined && alive(pid)) process.kill(pid, 'SIGKILL'); + await outcome; + await lifetime?.close(); + work.seal(); + await work.done; + } + }, + 15000, + ); + + it('abandons a reserved but unattached native owner when the actual MCP work retainer refuses entry', async () => { + const invocation = createCliInvocation(); + const before = process.listenerCount('exit'); + const configs = resolveServerConfigs( + [ref], + process.cwd(), + undefined, + {}, + undefined, + createNativeProcessController(invocation.registry), + ); + const refusal = new Error('host work admission refused'); + await expect( + configs[0]?.open(undefined, { + retainWork: () => { + throw refusal; + }, + }), + ).rejects.toBeDefined(); + await tick(); + expect(invocation.registry.activeOwners).toBe(0); + expect(process.listenerCount('exit')).toBe(before); + expect(invocation.registry.termination).toBeUndefined(); + }); + + it.each(['agent', 'workflow'] as const)( + '%s preserves fixed Windows facility refusal through the actual MCP manager wrapper', + async (surface) => { + const nativeProcesses: NativeProcessController = { + reserve: () => Promise.reject(new Error('unexpected direct process reservation')), + observeMcp: () => + Promise.reject( + new WindowsTeardownUnavailableError({ cause: new Error('PRIVATE_NATIVE_CANARY') }), + ), + }; + const options = { cwd: process.cwd(), consentGate: consent, nativeProcesses }; + const outcome = await ( + surface === 'agent' + ? connectAgentMcp([ref], options) + : connectWorkflowMcp(workflow(), options) + ).catch((error: unknown) => error); + expect(outcome).toBeInstanceOf(CliError); + expect(outcome).toMatchObject({ + code: 'internal', + exitCode: 1, + message: WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE, + }); + expect(outcome).not.toHaveProperty('cause'); + expect(String(outcome)).not.toContain('PRIVATE_NATIVE_CANARY'); + }, + ); + + it.each(['agent', 'workflow'] as const)( + '%s refuses consent before native facility admission or SDK work', + async (surface) => { + const refusal = new Error('consent refused before native admission'); + const nativeProcesses: NativeProcessController = { + reserve: () => Promise.reject(new Error('unexpected process reservation')), + observeMcp: () => Promise.reject(new Error('unexpected native facility admission')), + }; + const options = { + cwd: process.cwd(), + nativeProcesses, + consentGate: () => Promise.reject(refusal), + }; + await expect( + surface === 'agent' + ? connectAgentMcp([ref], options) + : connectWorkflowMcp(workflow(), options), + ).rejects.toBe(refusal); + }, + ); + + it('invalid child environment is refused before native facility admission', () => { + const nativeProcesses: NativeProcessController = { + reserve: () => Promise.reject(new Error('unexpected process reservation')), + observeMcp: () => Promise.reject(new Error('unexpected native facility admission')), + }; + expect(() => + resolveServerConfigs( + [{ ...ref, env: { NODE_OPTIONS: '--require PRIVATE_ENV_CANARY' } }], + process.cwd(), + undefined, + {}, + undefined, + nativeProcesses, + ), + ).toThrow(CliError); + }); + + it('network config opens never acquire a native process reservation or carry an observer', async () => { + const refusal = new Error('network opener stopped before I/O'); + const nativeProcesses: NativeProcessController = { + reserve: () => Promise.reject(new Error('unexpected network process reservation')), + observeMcp: () => Promise.reject(new Error('unexpected network native observer')), + }; + const configs = resolveServerConfigs( + [{ id: 'remote', transport: 'http', url: 'https://example.com/mcp' }], + process.cwd(), + undefined, + { + http: (_serverId, spec) => { + expect(spec).not.toHaveProperty('nativeObserver'); + return Promise.reject(refusal); + }, + }, + undefined, + nativeProcesses, + ); + await expect(configs[0]?.open()).rejects.toBe(refusal); + }); +}); diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index 53a96275..e45850a1 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -2,6 +2,7 @@ import type { WorkflowDefinition, ToolHostCallOptions } from '@relavium/core'; import { MCP_DEADLINES, McpAbortedError, + McpConnectError, McpDeadlineError, McpError, openWindow, @@ -47,6 +48,14 @@ import type { ResolvedStdioSpawn } from './mcp-consent.js'; import type { CliIo } from '../process/io.js'; import { sanitizeInline } from '../render/tui/chat-projection.js'; import type { McpSecretResolver } from '../secrets/mcp-secret.js'; +import { + createNativeProcessController, + type NativeProcessController, +} from '../process/native-process-owner.js'; +import { + WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE, + WindowsTeardownUnavailableError, +} from '../process/windows-teardown.js'; /** * Resolve an agent's inline `mcp_servers` into a live {@link McpClient} (2.R — CLI host wiring). This is the @@ -83,6 +92,8 @@ export interface ConnectAgentMcpOptions { readonly connectSignal?: AbortSignalLike; /** Transfer startup/connection descendants before entry, including bounded failed opens. */ readonly work?: ToolHostCallOptions; + /** Creation-owned stdio native lifetime; network transports create no native process owner. */ + readonly nativeProcesses?: NativeProcessController; /** * Resolve a `{{secrets.}}` placeholder in a server `env` value (2.R Step 4, ADR-0052 §6). When absent, * any `{{…}}` in an `env` value is rejected loud (a placeholder is never passed to the child as a literal). @@ -291,6 +302,7 @@ export function resolveServerConfigs( * command, which is the un-gated path every unit fixture uses. */ consented?: ReadonlyMap, + nativeProcesses?: NativeProcessController, ): McpServerConfig[] { const openStdio = openers.stdio ?? openStdioConnection; const makeFetch = openers.makeFetch ?? createMcpFetch; @@ -311,7 +323,15 @@ export function resolveServerConfigs( } configs.push( ref.transport === 'stdio' - ? buildStdioConfig(ref.id, ref, cwd, resolveSecret, openStdio, consented?.get(ref.id)) + ? buildStdioConfig( + ref.id, + ref, + cwd, + resolveSecret, + openStdio, + consented?.get(ref.id), + nativeProcesses, + ) : buildNetworkConfig(ref.id, ref.transport, ref, network, makeFetch, resolveHost), ); } @@ -333,6 +353,7 @@ function buildStdioConfig( resolveSecret: McpSecretResolver | undefined, openStdio: OpenStdioConnection, consented?: ResolvedStdioSpawn, + nativeProcesses?: NativeProcessController, ): McpServerConfig { // The schema's `superRefine` already guarantees `command` for a stdio transport; re-assert so the spawn spec // is total without a non-null assertion (a defensive, typed failure rather than an undefined spawn). @@ -360,6 +381,9 @@ function buildStdioConfig( } const env = buildChildEnv(serverId, ref.env, resolveSecret); const args = ref.args; + // Direct host fixtures retain local cleanup; product paths pass the invocation's same controller. + // Construction is inert, including on Windows; actual facility/owner admission belongs in open(). + const native = nativeProcesses ?? createNativeProcessController(); return { id: serverId, ...toolsAllowlistFields(ref), @@ -369,21 +393,30 @@ function buildStdioConfig( // `McpServerConfig.open` doc records one layer down, and it was still true HERE until a review found it: // a Ctrl-C during a cold `npx` reached this point and stopped nothing, orphaning the child — because // `client.childPids` stays empty until a connect fully succeeds, so neither reaper had a pid either. - open: (signal, work) => - openStdio( - serverId, - { - command, - env, - cwd, - ...(args === undefined ? {} : { args }), - ...(ref.connect_timeout_ms === undefined - ? {} - : { connectTimeoutMs: ref.connect_timeout_ms }), - }, - signal, - work, - ), + open: async (signal, work) => { + const observer = await native.observeMcp(); + try { + return await openStdio( + serverId, + { + command, + env, + cwd, + nativeObserver: observer, + ...(args === undefined ? {} : { args }), + ...(ref.connect_timeout_ms === undefined + ? {} + : { connectTimeoutMs: ref.connect_timeout_ms }), + }, + signal, + work, + ); + } finally { + // An external work retainer or injected opener can refuse before SDK attachment. + // Only that still-empty reservation may be abandoned; attached native work remains owed. + observer.abandonIfUnattached(); + } + }, }; } @@ -676,7 +709,14 @@ export async function connectAgentMcp( // declare a server with no `[[mcp_servers]]` registration at all — the imported-artifact case. requireGateForStdio(inline, opts.consentGate); const consented = await opts.consentGate?.(inline, opts.cwd, opts.artifact); - const configs = resolveServerConfigs(inline, opts.cwd, opts.resolveSecret, {}, consented); + const configs = resolveServerConfigs( + inline, + opts.cwd, + opts.resolveSecret, + {}, + consented, + opts.nativeProcesses, + ); if (configs.length === 0) return undefined; return startMcpClientFailLoud(configs, opts.startMcpClient, opts.connectSignal, opts.work); } @@ -704,6 +744,9 @@ async function startMcpClientFailLoud( // A session builder without an external pre-handle owner must self-clean before propagating. owned?.seal(); await owned?.done; + if (windowsTeardownRefusal(err)) { + throw new CliError('internal', WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE); + } if (err instanceof McpError) { throw new CliError( 'invalid_invocation', @@ -716,6 +759,14 @@ async function startMcpClientFailLoud( } } +/** Inspect only the known manager wrapper's own data cause; never render or traverse native details. */ +function windowsTeardownRefusal(error: unknown): boolean { + if (error instanceof WindowsTeardownUnavailableError) return true; + if (!(error instanceof McpConnectError)) return false; + const cause: unknown = Object.getOwnPropertyDescriptor(error, 'cause')?.value; + return cause instanceof WindowsTeardownUnavailableError; +} + /** * The one-line remedy for a connect that ran out of time. * @@ -803,6 +854,8 @@ export interface ConnectWorkflowMcpOptions { readonly connectSignal?: AbortSignalLike; /** Transfer startup/connection descendants before entry, including bounded failed opens. */ readonly work?: ToolHostCallOptions; + /** Creation-owned stdio native lifetime; see {@link ConnectAgentMcpOptions.nativeProcesses}. */ + readonly nativeProcesses?: NativeProcessController; /** Resolve `{{secrets.}}` in a server `env` value (2.R Step 4, ADR-0052 §6); see {@link ConnectAgentMcpOptions}. */ readonly resolveSecret?: McpSecretResolver; /** The merged config `[[mcp_servers]]` registrations (Step 4b) — resolves a by-name `ref` entry; see {@link ConnectAgentMcpOptions}. */ @@ -883,6 +936,7 @@ export async function connectWorkflowMcp( opts.resolveSecret, {}, consented, + opts.nativeProcesses, ); const client = await startMcpClientFailLoud( configs, diff --git a/apps/cli/src/engine/mcp-signal-teardown.test.ts b/apps/cli/src/engine/mcp-signal-teardown.test.ts index fba240a5..e630365c 100644 --- a/apps/cli/src/engine/mcp-signal-teardown.test.ts +++ b/apps/cli/src/engine/mcp-signal-teardown.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; +import { createCliInvocation } from '../process/invocation.js'; import { guardMcpTeardown, @@ -239,16 +240,13 @@ describe('guardMcpTeardown — the synchronous exit net', () => { }); describe('guardMcpTeardown — the production defaults', () => { - it('registers on the REAL process signals and removes them; the exit reaper is process-scoped', () => { + it('registers on the REAL process signals and removes them without acquiring a second native exit net', () => { // Every other test injects `subscribeSignals`, so the default — the four-signal subscription every // surface shares — was never taken. A review pointed out that with the surface binding also missing, the // real registration path had zero coverage from either end. // - // **The `exit` half deliberately does NOT come back off**, and this test used to assert that it did. A - // real-subprocess reproduction showed why that was wrong: tied to a guard's lifetime, the net was removed - // by whichever `unguardMcp()` ran first — on `agent run` that is the build's own `.catch`, which fires on - // an aborted connect BEFORE the cooperative half exits, so the reaper was gone exactly when a live child - // needed it. An `exit` listener does not hold the event loop open, so keeping it costs nothing. + // Native ownership is installed at actual creation and outlives this guard until real native close. + const invocation = createCliInvocation(); const before = { sigint: process.listenerCount('SIGINT'), sigterm: process.listenerCount('SIGTERM'), @@ -256,7 +254,7 @@ describe('guardMcpTeardown — the production defaults', () => { sigquit: process.listenerCount('SIGQUIT'), exit: process.listenerCount('exit'), }; - const unguard = guardMcpTeardown(() => Promise.resolve(), none); + const unguard = guardMcpTeardown(() => Promise.resolve(), undefined, { invocation }); expect(process.listenerCount('SIGINT')).toBe(before.sigint + 1); expect(process.listenerCount('SIGTERM')).toBe(before.sigterm + 1); expect(process.listenerCount('SIGHUP')).toBe(before.sighup + 1); @@ -268,20 +266,20 @@ describe('guardMcpTeardown — the production defaults', () => { expect(process.listenerCount('SIGTERM')).toBe(before.sigterm); expect(process.listenerCount('SIGHUP')).toBe(before.sighup); expect(process.listenerCount('SIGQUIT')).toBe(before.sigquit); + expect(process.listenerCount('exit')).toBe(before.exit); }); - it('installs exactly ONE `exit` listener however many guards run', () => { - // The reaper is process-scoped, so the thing to bound is accumulation: a long-lived host running many - // commands must not gain a listener per command. One Node listener, running a set of reaps. + it('does not accumulate historical exit listeners across invocation-bound guards', () => { + const invocation = createCliInvocation(); const before = process.listenerCount('exit'); const guards = [ - guardMcpTeardown(() => Promise.resolve(), none), - guardMcpTeardown(() => Promise.resolve(), none), - guardMcpTeardown(() => Promise.resolve(), none), + guardMcpTeardown(() => Promise.resolve(), undefined, { invocation }), + guardMcpTeardown(() => Promise.resolve(), undefined, { invocation }), + guardMcpTeardown(() => Promise.resolve(), undefined, { invocation }), ]; - expect(process.listenerCount('exit')).toBeLessThanOrEqual(before + 1); + expect(process.listenerCount('exit')).toBe(before); for (const unguard of guards) unguard(); - expect(process.listenerCount('exit')).toBeLessThanOrEqual(before + 1); + expect(process.listenerCount('exit')).toBe(before); }); it('keeps the grace short enough to serve its own stated purpose', () => { @@ -293,6 +291,66 @@ describe('guardMcpTeardown — the production defaults', () => { }); }); +describe('guardMcpTeardown — invocation native authority', () => { + it('delegates finite exit to the same live registry and ignores legacy PID/kill authority', () => { + const invocation = createCliInvocation(); + let eligible = true; + const finiteExit = vi.fn(); + const stop = vi.fn(); + const join = vi.fn(); + const reap = vi.fn(); + invocation.registry.register({ + kind: 'native-pid', + retired: new Promise(() => undefined), + stop, + join, + reap, + mayReap: () => eligible, + exitReap: finiteExit, + classifyFailure: () => 'cli_internal', + }); + const h = harness({ invocation }); + const unguard = guardMcpTeardown( + never, + () => { + throw new Error('legacy PID observation is not product kill authority'); + }, + h.options, + ); + try { + h.exitNet.fire(); + expect(finiteExit, 'MCP_GUARD_REGISTRY_EXIT').toHaveBeenCalledTimes(1); + expect(h.killed).toEqual([]); + expect(stop).not.toHaveBeenCalled(); + expect(join).not.toHaveBeenCalled(); + expect(reap).not.toHaveBeenCalled(); + expect(invocation.registry.activeOwners).toBe(1); + expect(invocation.registry.termination).toBeUndefined(); + eligible = false; + h.exitNet.fire(); + expect(finiteExit).toHaveBeenCalledTimes(1); + } finally { + unguard(); + } + }); + + it('preserves first-signal cooperative cleanup without invoking whole-registry termination', async () => { + const invocation = createCliInvocation(); + const h = harness({ invocation, reapOnly: true }); + const close = vi.fn(() => Promise.resolve()); + const unguard = guardMcpTeardown(close, undefined, h.options); + try { + h.signals.fire(2); + await vi.waitFor(() => expect(close).toHaveBeenCalledTimes(1)); + expect(invocation.registry.termination).toBeUndefined(); + expect(h.exit).not.toHaveBeenCalled(); + expect(h.killed).toEqual([]); + } finally { + unguard(); + } + }); +}); + describe('guardMcpTeardown — unsubscribe', () => { it('removes BOTH subscriptions when the caller finishes normally', () => { // Leaving either registered would hold a handle on a process exiting cleanly — and the exit net would diff --git a/apps/cli/src/engine/mcp-signal-teardown.ts b/apps/cli/src/engine/mcp-signal-teardown.ts index 8af75978..8be26842 100644 --- a/apps/cli/src/engine/mcp-signal-teardown.ts +++ b/apps/cli/src/engine/mcp-signal-teardown.ts @@ -1,4 +1,5 @@ import { defaultSubscribeSignals } from '../process/signals.js'; +import type { CliInvocation } from '../process/invocation.js'; /** * Reap spawned MCP children when a one-shot command is signalled @@ -56,6 +57,8 @@ export interface McpTeardownGuardDeps { } export interface McpTeardownGuardOptions extends McpTeardownGuardDeps { + /** Product native exit cleanup uses this invocation's exact creation-owned registrations. */ + readonly invocation?: CliInvocation; /** * Reap the children but do **not** exit — for a surface that already owns a documented signal contract. * @@ -71,8 +74,8 @@ export interface McpTeardownGuardOptions extends McpTeardownGuardDeps { } /** - * The pids to reap synchronously if the process exits without awaiting the close. Read lazily, because a guard - * armed BEFORE the connect — which is when the children are actually spawned — has none yet. + * Compatibility fixture PID observation. Product callers pass the explicit invocation instead; this list + * never grants kill authority when invocation-owned native registrations are available. */ export type LiveChildPids = () => readonly number[]; @@ -93,6 +96,7 @@ export function guardMcpTeardown( const kill = options.killPid ?? defaultKillPid; const exit = options.exit ?? exitAfterFlush; const delay = options.delay ?? defaultDelay; + let released = false; // The synchronous net. It runs on EVERY exit — normal or forced — and is a no-op once the cooperative // teardown has finished, because a reaped child's pid no longer exists. @@ -102,6 +106,10 @@ export function guardMcpTeardown( // ESRCH/EPERM, which is exactly what made the bare loop look correct — the isolation belongs to the loop, // which owns the guarantee, not to one implementation of the thing it calls. const reap = (): void => { + if (options.invocation !== undefined) { + options.invocation.registry.reapNativeAtExit(); + return; + } for (const pid of livePids()) { try { kill(pid); @@ -110,20 +118,25 @@ export function guardMcpTeardown( } } }; - // **Installed for the PROCESS, not for this guard** — and a real-subprocess reproduction is why. Tied to the - // guard's lifetime, the net was removed by whichever `unguardMcp()` ran first, and on `agent run` that is - // the build's own `.catch`: an aborted connect rejects, the catch releases the guard, and only THEN does the - // cooperative half's `exit(128+signo)` run — with no net left. Measured end to end against a real - // `agent run` subprocess: the unguard ran, the exit net never did, host exit 143, child at `ppid 1`. - // - // "SIGKILL any MCP child this process still owns, at exit" is a process-level invariant, so binding it to a - // command's lifecycle is a race by construction — this is the second one in this file. An `exit` listener - // does not hold the event loop open, so keeping it costs nothing. Tests inject `subscribeProcessExit` and - // keep the old per-guard behaviour, which is what lets them drive the net directly. + // Product exit custody is installed/removable by the native controller when an actual owner reserves. + // It outlives this cooperative guard through genuine native close, including failed initialization. + // An explicit test subscription can drive that same registry's finite capability. Legacy direct fixtures + // without an invocation keep a local fallback; no historical process-wide reaper collection remains. const unsubscribeExit = - options.subscribeProcessExit === undefined - ? installProcessExitReaper(reap) - : options.subscribeProcessExit(reap); + options.subscribeProcessExit !== undefined + ? options.subscribeProcessExit(reap) + : options.invocation === undefined + ? subscribeProcessExit(reap) + : () => undefined; + + const releaseEmptyLegacyNet = (): void => { + if (!released || options.invocation !== undefined) return; + try { + if (livePids().length === 0) unsubscribeExit(); + } catch { + // Failed compatibility observation cannot prove the still-installed fallback empty. + } + }; let tearingDown = false; const unsubscribeSignals = subscribe((signo) => { @@ -132,7 +145,16 @@ export function guardMcpTeardown( // guarantee the process ends and the children die. if (tearingDown) return; tearingDown = true; - void Promise.race([close().catch(() => undefined), delay(MCP_TEARDOWN_GRACE_MS)]).then(() => { + // Keep synchronous close refusal from escaping the signal callback or stranding its grace path. + let closing: Promise; + try { + // Entry remains synchronous: the command's close callback also cancels a pending initialization. + closing = close().catch(() => undefined); + } catch { + closing = Promise.resolve(); + } + void closing.then(releaseEmptyLegacyNet); + void Promise.race([closing, delay(MCP_TEARDOWN_GRACE_MS)]).then(() => { if (options.reapOnly === true) return; // the caller owns the exit code — see `reapOnly` unsubscribeSignals(); exit(signalExitCode(signo)); @@ -140,33 +162,17 @@ export function guardMcpTeardown( }); return () => { + released = true; unsubscribeSignals(); - // The PROCESS reaper is deliberately not removed here — see its comment above. Only an injected one is, - // so a test can still assert the guard cleans up what it was given. - if (options.subscribeProcessExit !== undefined) unsubscribeExit(); + if (options.subscribeProcessExit !== undefined || options.invocation !== undefined) + unsubscribeExit(); + else releaseEmptyLegacyNet(); }; } -/** - * The one process-wide `process.on('exit')` reaper: a single Node listener, running every registered reap. - * - * A SET rather than a single latched function, because a latch would silently ignore the second guard's - * `livePids` — both CLI surfaces happen to pass the same registry today, and a guarantee that holds only - * because two call sites agree is the shape this wave keeps finding. One Node listener, so a long-lived host - * that runs many commands does not accumulate them. - */ -const processReapers = new Set<() => void>(); -let exitListenerInstalled = false; - -function installProcessExitReaper(reap: () => void): () => void { - processReapers.add(reap); - if (!exitListenerInstalled) { - exitListenerInstalled = true; - process.on('exit', () => { - for (const run of processReapers) run(); - }); - } - return () => undefined; // never removed — see `unsubscribeExit`'s comment +function subscribeProcessExit(reap: () => void): () => void { + process.on('exit', reap); + return () => process.off('exit', reap); } /** diff --git a/apps/cli/src/engine/tool-host/assemble.test.ts b/apps/cli/src/engine/tool-host/assemble.test.ts index 027682ab..ee4069fa 100644 --- a/apps/cli/src/engine/tool-host/assemble.test.ts +++ b/apps/cli/src/engine/tool-host/assemble.test.ts @@ -1,3 +1,6 @@ +import { createCliInvocation } from '../../process/invocation.js'; +import { nativeProcessesForInvocation } from '../../process/native-process-owner.js'; +import { witnessNativeToolProcess } from '../../commands/native-forwarding.test-support.js'; import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -23,6 +26,22 @@ afterEach(async () => { }); describe('assembleToolEnv', () => { + it('carries native authority inertly, then registers a real process in the original invocation', async () => { + const invocation = createCliInvocation(); + const nativeProcesses = nativeProcessesForInvocation(invocation); + const exitListeners = process.listenerCount('exit'); + assembleToolEnv({ + profile: 'chat-read-write', + fsScopeTier: 'project', + workspaceDir: workspace, + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), + }); + expect(invocation.registry.activeOwners).toBe(0); + expect(process.listenerCount('exit')).toBe(exitListeners); + await witnessNativeToolProcess(nativeProcesses, invocation, workspace); + expect(process.listenerCount('exit')).toBe(exitListeners); + }); + it('chat-read-only: fs reads a real file but write_file fail-closes as tool_unavailable; process is wired', async () => { await writeFile(join(workspace, 'a.txt'), 'hello'); const { host, policy } = assembleToolEnv({ diff --git a/apps/cli/src/engine/tool-host/assemble.ts b/apps/cli/src/engine/tool-host/assemble.ts index 3df6ca5e..c7b31698 100644 --- a/apps/cli/src/engine/tool-host/assemble.ts +++ b/apps/cli/src/engine/tool-host/assemble.ts @@ -1,3 +1,4 @@ +import type { NativeProcessController } from '../../process/native-process-owner.js'; import type { FsScopeTier, ToolDef, ToolDelegateName, ToolHost } from '@relavium/core'; import type { ToolPolicy } from '@relavium/shared'; @@ -45,6 +46,8 @@ export { ProcessCapabilityError, ProcessDeniedError } from './process.js'; export type ToolEnvProfile = 'chat-read-only' | 'chat-read-write' | 'workflow-read-write'; export interface AssembleToolEnvOptions { + /** Invocation-owned native creation authority, inert until a capability actually spawns. */ + readonly nativeProcesses?: NativeProcessController; readonly profile: ToolEnvProfile; /** The active filesystem scope tier (chat: `[chat].fs_scope` ?? sandboxed; run: the workflow's `fs_scope`). */ readonly fsScopeTier: FsScopeTier; @@ -107,7 +110,10 @@ export function assembleToolEnv(opts: AssembleToolEnvOptions): AssembledToolEnv // The process arm itself has no read-only notion — chat-safety rests ENTIRELY on the policy layer above: // the empty `allowedCommands` default denies `run_command`, and `git_status` exposes no model-controlled // command. Loosening the chat `allowedCommands` in a future profile is therefore a security-review trigger. - process: createNodeProcessCapability({ workspaceDir: opts.workspaceDir }), + process: createNodeProcessCapability({ + workspaceDir: opts.workspaceDir, + ...(opts.nativeProcesses === undefined ? {} : { nativeProcesses: opts.nativeProcesses }), + }), // egress + os: wired ONLY for the full-capability `chat-read-write` profile (ADR-0057 closes the 2.5.A // deferral). BOTH are GOVERNED classes that ride the fail-closed approval floor — `egress` (an // exfiltration sink) and `os` (`read_clipboard` reads ambient secret-bearing OS state; `notify` paints a @@ -121,7 +127,9 @@ export function assembleToolEnv(opts: AssembleToolEnvOptions): AssembledToolEnv ? {} : { resolveCredential: opts.egressCredentialResolver }, ), - os: createNodeOsCapability(), + os: createNodeOsCapability( + opts.nativeProcesses === undefined ? {} : { nativeProcesses: opts.nativeProcesses }, + ), } : {}), }; diff --git a/apps/cli/src/engine/tool-host/os-native-lifetime.test.ts b/apps/cli/src/engine/tool-host/os-native-lifetime.test.ts new file mode 100644 index 00000000..1adbf889 --- /dev/null +++ b/apps/cli/src/engine/tool-host/os-native-lifetime.test.ts @@ -0,0 +1,81 @@ +import { spawn, type ChildProcess } from 'node:child_process'; + +import { describe, expect, it } from 'vitest'; + +import { createCliInvocation } from '../../process/invocation.js'; +import { nativeProcessesForInvocation } from '../../process/native-process-owner.js'; +import { createNodeOsCapability, type OsSpawnFn } from './os.js'; +import { OsCapabilityError } from './errors.js'; + +/** Real inert Node child at the explicit spawn seam; no clipboard/notification program is executed. */ +function ownedFixture() { + let child: ChildProcess | undefined; + let closed = false; + let acknowledgeReady = (): void => undefined; + let acknowledgeClosed = (): void => undefined; + const ready = new Promise((resolve) => { + acknowledgeReady = resolve; + }); + const whenClosed = new Promise((resolve) => { + acknowledgeClosed = resolve; + }); + const spawnImpl: OsSpawnFn = () => { + const created = spawn( + process.execPath, + ['-e', 'process.stdout.write("ready");setInterval(()=>{},1000)'], + { + stdio: ['ignore', 'pipe', 'ignore'], + shell: false, + }, + ); + child = created; + created.stdout?.once('data', () => acknowledgeReady()); + created.once('close', () => { + closed = true; + acknowledgeClosed(); + }); + return created; + }; + const getChild = (): ChildProcess => { + if (child === undefined) throw new Error('fixture native entry missing'); + return child; + }; + return { spawnImpl, ready, whenClosed, isClosed: () => closed, getChild }; +} + +describe('OS helper actual native lifetime', () => { + it('retains real errored helper ownership until native close instead of returning from error', async () => { + const fixture = ownedFixture(); + const invocation = createCliInvocation(); + const nativeProcesses = nativeProcessesForInvocation(invocation); + if (nativeProcesses === undefined) throw new Error('explicit invocation missing'); + const os = createNodeOsCapability({ + platform: 'linux', + spawnImpl: fixture.spawnImpl, + nativeProcesses, + }); + const result = os + .notify({ title: 'synthetic', body: 'synthetic' }) + .catch((cause: unknown) => cause); + let watchdogFired = false; + const watchdog = setTimeout(() => { + watchdogFired = true; + fixture.getChild().kill('SIGKILL'); + }, 5000); + try { + await fixture.ready; + expect(invocation.registry.activeOwners).toBe(1); + fixture.getChild().emit('error', new Error('synthetic owned native fault')); + const failure = await result; + expect(failure).toBeInstanceOf(OsCapabilityError); + expect(fixture.isClosed(), 'OS_NATIVE_ERROR_CLOSE_JOIN').toBe(true); + expect(invocation.registry.activeOwners, 'OS_NATIVE_ERROR_CLOSE_JOIN').toBe(0); + expect(watchdogFired, 'OS_NATIVE_ERROR_CANCEL').toBe(false); + } finally { + clearTimeout(watchdog); + if (!fixture.isClosed()) fixture.getChild().kill('SIGKILL'); + await fixture.whenClosed; + await result; + } + }, 10000); +}); diff --git a/apps/cli/src/engine/tool-host/os.test.ts b/apps/cli/src/engine/tool-host/os.test.ts index 59bae6ff..6e253646 100644 --- a/apps/cli/src/engine/tool-host/os.test.ts +++ b/apps/cli/src/engine/tool-host/os.test.ts @@ -1,8 +1,13 @@ import { EventEmitter } from 'node:events'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { EgressCapabilityError } from './errors.js'; // sibling check: the os errors live in the same module +import { EgressCapabilityError, HostDeniedError } from './errors.js'; // sibling check: the os errors live in the same module +import type { NativeProcessController } from '../../process/native-process-owner.js'; +import { + WindowsTeardownUnavailableError, + WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE, +} from '../../process/windows-teardown.js'; import { clipboardReadPlans, createNodeOsCapability, @@ -45,6 +50,7 @@ function fakeSpawn(script: readonly ScriptedCall[]): { queueMicrotask(() => { if (scripted.emitError === true) { child.emit('error'); + child.emit('close', null); // failed spawn still owes its native close, as node:child_process does return; } if (scripted.stdout !== undefined && scripted.stdout.length > 0) { @@ -242,7 +248,7 @@ describe('createNodeOsCapability — timeout + mid-spawn abort + the bounded buf const os = createNodeOsCapability({ platform: 'linux', spawnImpl }); // 3 candidates (wl-paste, xclip, xsel) const ac = new AbortController(); const pending = os.readClipboard(ac.signal); - await Promise.resolve(); // let the first candidate spawn + register its abort listener + await vi.waitFor(() => expect(calls).toHaveLength(1)); // actual entry, independent of preparation awaits ac.abort(); // onAbort ⇒ SIGKILL ⇒ close(null) with aborted=true ⇒ reject; the loop must NOT fall through await expect(pending).rejects.toBeInstanceOf(Error); expect(calls).toHaveLength(1); // ONLY wl-paste — the abort stopped the loop; xclip/xsel never spawned @@ -256,6 +262,57 @@ describe('createNodeOsCapability — timeout + mid-spawn abort + the bounded buf }); }); +describe('OS candidate native-close ownership', () => { + it('preserves an actionable deterministic Windows facility refusal without trying a fallback', async () => { + const { spawnImpl, calls } = fakeSpawn([]); + const nativeProcesses: NativeProcessController = { + reserve: () => + Promise.reject( + new WindowsTeardownUnavailableError({ cause: new Error('private-path-detail') }), + ), + observeMcp: () => Promise.reject(new Error('MCP was not requested')), + }; + const os = createNodeOsCapability({ platform: 'win32', spawnImpl, nativeProcesses }); + const failure: unknown = await os.readClipboard().catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(HostDeniedError); + expect(failure).toMatchObject({ + message: WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE, + runErrorCode: 'tool_denied', + }); + await expect(os.notify({ title: 'synthetic', body: 'synthetic' })).rejects.toBeInstanceOf( + HostDeniedError, + ); + expect(calls).toHaveLength(0); + }); + + it('retains an errored candidate and starts no fallback before its actual close', async () => { + const children: EventEmitter[] = []; + class Child extends EventEmitter implements OsSpawnLike { + readonly stdout = new EventEmitter(); + kill(): void {} // request is deliberately not an acknowledgement + } + const spawnImpl: OsSpawnFn = () => { + const child = new Child(); + children.push(child); + if (children.length === 2) + queueMicrotask(() => { + child.stdout.emit('data', Buffer.from('second candidate')); + child.emit('close', 0); + }); + return child; + }; + const os = createNodeOsCapability({ platform: 'linux', spawnImpl }); + const pending = os.readClipboard(); + await vi.waitFor(() => expect(children).toHaveLength(1)); + children[0]?.emit('error'); + await new Promise((resolve) => setImmediate(resolve)); + expect(children).toHaveLength(1); + children[0]?.emit('close', null); + await expect(pending).resolves.toBe('second candidate'); + expect(children).toHaveLength(2); + }); +}); + describe('os errors share the host taxonomy', () => { it('OsCapabilityError and EgressCapabilityError are distinct transient host errors', () => { expect(new OsCapabilityError('x')).toBeInstanceOf(OsCapabilityError); diff --git a/apps/cli/src/engine/tool-host/os.ts b/apps/cli/src/engine/tool-host/os.ts index a9304a91..8923f699 100644 --- a/apps/cli/src/engine/tool-host/os.ts +++ b/apps/cli/src/engine/tool-host/os.ts @@ -3,7 +3,21 @@ import { spawn } from 'node:child_process'; import type { NotifyInput, OsCapability } from '@relavium/core'; import type { AbortSignalLike } from '@relavium/shared'; -import { OsCapabilityError, throwIfAborted as throwIfAbortedShared } from './errors.js'; +import { + createNativeProcessController, + type NativeProcessController, + type NativeChildHandle, +} from '../../process/native-process-owner.js'; +import { + WindowsTeardownUnavailableError, + WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE, +} from '../../process/windows-teardown.js'; + +import { + HostDeniedError, + OsCapabilityError, + throwIfAborted as throwIfAbortedShared, +} from './errors.js'; /** * The Node host **mechanism** half of the `ToolHost.os` capability arm (2.5.E Step 3, [ADR-0057](../../../../../docs/decisions/0057-cli-chat-modes-and-per-tool-approval.md)) — @@ -56,6 +70,7 @@ const WINDOWS_NOTIFY_SCRIPT = [ ].join(' '); export interface NodeOsCapabilityConfig { + readonly nativeProcesses?: NativeProcessController; /** Per-call timeout in ms (default {@link DEFAULT_TIMEOUT_MS}). */ readonly timeoutMs?: number; /** Clipboard-read capture ceiling in bytes (default {@link DEFAULT_MAX_BUFFER_BYTES}). */ @@ -68,8 +83,12 @@ export interface NodeOsCapabilityConfig { /** The minimal child surface the os arm uses — a real node `ChildProcess` structurally satisfies it. */ export interface OsSpawnLike { + readonly pid?: number | undefined; + readonly exitCode?: number | null | undefined; + readonly signalCode?: string | null | undefined; readonly stdout: { on(event: 'data', listener: (chunk: Buffer) => void): void } | null; on(event: 'error', listener: () => void): void; + on(event: 'exit', listener: () => void): void; on(event: 'close', listener: (code: number | null) => void): void; kill(signal: 'SIGKILL'): void; } @@ -97,6 +116,7 @@ export function createNodeOsCapability(config: NodeOsCapabilityConfig = {}): OsC const maxBufferBytes = config.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; const platform = config.platform ?? process.platform; const spawnImpl = config.spawnImpl ?? nodeSpawn; + const nativeProcesses = config.nativeProcesses ?? createNativeProcessController(); return { readClipboard: async (signal) => { @@ -110,8 +130,14 @@ export function createNodeOsCapability(config: NodeOsCapabilityConfig = {}): OsC for (const plan of clipboardReadPlans(platform)) { let result: CaptureResult; try { - result = await spawnCapturing(spawnImpl, plan, { signal, timeoutMs, maxBufferBytes }); + result = await spawnCapturing(spawnImpl, plan, { + signal, + timeoutMs, + maxBufferBytes, + nativeProcesses, + }); } catch (error) { + if (error instanceof HostDeniedError) throw error; throwIfAborted(signal); // an abort wins; a missing/failed candidate falls through to the next // A timeout (the tool exists but hung) is a real failure worth surfacing; a spawn-start error is // most likely a missing binary, for which the default "no tool available" is the more helpful reason. @@ -130,6 +156,7 @@ export function createNodeOsCapability(config: NodeOsCapabilityConfig = {}): OsC signal, timeoutMs, maxBufferBytes, + nativeProcesses, }); if (result.exitCode !== 0) { throw new OsCapabilityError('the notification could not be delivered'); @@ -194,11 +221,28 @@ interface CaptureResult { } /** Spawn a fixed host binary (`shell: false`), capturing bounded stdout, with a timeout + abort kill. */ -function spawnCapturing( +async function spawnCapturing( spawnImpl: OsSpawnFn, plan: SpawnPlan, - opts: { signal: AbortSignalLike | undefined; timeoutMs: number; maxBufferBytes: number }, + opts: { + signal: AbortSignalLike | undefined; + timeoutMs: number; + maxBufferBytes: number; + nativeProcesses: NativeProcessController; + }, ): Promise { + const native = await opts.nativeProcesses.reserve('pid').catch((cause: unknown) => { + if (cause instanceof WindowsTeardownUnavailableError) + throw new HostDeniedError(WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE); + throw cause; + }); + try { + throwIfAborted(opts.signal); + native.assertAdmitting(); + } catch (cause) { + native.abandon(); + throw cause; + } return new Promise((resolvePromise, reject) => { let child: OsSpawnLike; try { @@ -208,22 +252,27 @@ function spawnCapturing( env: plan.env === undefined ? process.env : { ...process.env, ...plan.env }, }); } catch { + native.abandon(); reject(new OsCapabilityError('the os command could not be started')); return; } + const nativeChild: NativeChildHandle = child; + native.bindChild(nativeChild); const stdout = new BoundedBuffer(opts.maxBufferBytes); let settled = false; let aborted = false; let timedOut = false; + let failed = false; const timer = setTimeout(() => { + if (child.exitCode != null || child.signalCode != null) return; timedOut = true; - child.kill('SIGKILL'); + void native.stop(); }, opts.timeoutMs); const onAbort = (): void => { aborted = true; - child.kill('SIGKILL'); + void native.stop(); }; if (opts.signal !== undefined) opts.signal.addEventListener('abort', onAbort); @@ -236,20 +285,27 @@ function spawnCapturing( }; child.stdout?.on('data', (chunk: Buffer) => stdout.push(chunk)); - child.on('error', () => - finish(() => reject(new OsCapabilityError('the os command failed to run'))), - ); - child.on('close', (code) => - finish(() => { - if (aborted) { - reject(new OsCapabilityError('the os command was aborted')); - } else if (timedOut && code === null) { - reject(new OsTimeoutError('the os command timed out')); - } else { - resolvePromise({ exitCode: code ?? 1, stdout: stdout.text() }); - } - }), - ); + child.on('error', () => { + failed = true; + void native.stop(); + }); + child.on('close', (code) => { + clearTimeout(timer); + if (opts.signal !== undefined) opts.signal.removeEventListener('abort', onAbort); + void native.whenClosed.then(() => + finish(() => { + if (aborted) { + reject(new OsCapabilityError('the os command was aborted')); + } else if (failed) { + reject(new OsCapabilityError('the os command failed to run')); + } else if (timedOut && (code === null || (process.platform === 'win32' && code !== 0))) { + reject(new OsTimeoutError('the os command timed out')); + } else { + resolvePromise({ exitCode: code ?? 1, stdout: stdout.text() }); + } + }), + ); + }); }); } diff --git a/apps/cli/src/engine/tool-host/process.ts b/apps/cli/src/engine/tool-host/process.ts index 63d98215..6b7c3e69 100644 --- a/apps/cli/src/engine/tool-host/process.ts +++ b/apps/cli/src/engine/tool-host/process.ts @@ -6,6 +6,14 @@ import type { ProcessCapability, ProcessResult } from '@relavium/core'; import { isForbiddenDeclaredEnvKey, type AbortSignalLike } from '@relavium/shared'; import { findOnPath } from '../find-on-path.js'; +import { + createNativeProcessController, + type NativeProcessController, +} from '../../process/native-process-owner.js'; +import { + WindowsTeardownUnavailableError, + WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE, +} from '../../process/windows-teardown.js'; import { HostCapabilityError, @@ -71,6 +79,7 @@ const WINDOWS_ENV_KEYS = [ ] as const; export interface NodeProcessCapabilityConfig { + readonly nativeProcesses?: NativeProcessController; /** The session/run working directory (absolute) — the default spawn cwd when the tool pins none. */ readonly workspaceDir: string; /** Default timeout (ms) when the tool config pins none (default {@link DEFAULT_TIMEOUT_MS}). */ @@ -115,6 +124,7 @@ export function createNodeProcessCapability( const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS; const maxTimeoutMs = config.maxTimeoutMs ?? DEFAULT_MAX_TIMEOUT_MS; const maxBufferBytes = config.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; + const nativeProcesses = config.nativeProcesses ?? createNativeProcessController(); return { spawn: async (command, args, declaredEnv, opts, signal) => { throwIfAborted(signal); @@ -130,6 +140,7 @@ export function createNodeProcessCapability( timeoutMs, maxBufferBytes, signal, + nativeProcesses, }); }, }; @@ -143,10 +154,23 @@ interface RunChildOptions { readonly timeoutMs: number; readonly maxBufferBytes: number; readonly signal: AbortSignalLike | undefined; + readonly nativeProcesses: NativeProcessController; } /** Spawn the resolved child (`shell:false`), wiring the timeout + abort kill and the bounded output capture. */ -function runChild(opts: RunChildOptions): Promise { +async function runChild(opts: RunChildOptions): Promise { + const native = await opts.nativeProcesses.reserve('group').catch((cause: unknown) => { + if (cause instanceof WindowsTeardownUnavailableError) + throw new ProcessDeniedError(WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE); + throw cause; + }); + try { + throwIfAborted(opts.signal); + native.assertAdmitting(); + } catch (cause) { + native.abandon(); + throw cause; + } const start = Date.now(); return new Promise((resolvePromise, reject) => { // Check cancellation after executable/cwd resolution and argument copying, at native entry. @@ -161,13 +185,15 @@ function runChild(opts: RunChildOptions): Promise { windowsHide: true, // POSIX: make the child a process-group leader so a timeout/abort can SIGKILL the WHOLE group (the // child AND any grandchildren it forked) — a single-pid kill would leak a forking subprocess. Not - // unref'd: we await `close`. Windows has no POSIX groups, so kill falls back to the single child. + // unref'd: we await actual close. Windows instead admits the verified taskkill facility first. detached: process.platform !== 'win32', }); } catch { + native.abandon(); reject(new ProcessCapabilityError('the command could not be started')); return; } + native.bindChild(child); const stdout = new BoundedBuffer(opts.maxBufferBytes); const stderr = new BoundedBuffer(opts.maxBufferBytes); @@ -177,13 +203,14 @@ function runChild(opts: RunChildOptions): Promise { let failed = false; const timer = setTimeout(() => { + if (child.exitCode !== null || child.signalCode !== null) return; timedOut = true; - killTree(child); + void native.stop(); }, opts.timeoutMs); const onAbort = (): void => { aborted = true; - killTree(child); + void native.stop(); }; if (opts.signal !== undefined) opts.signal.addEventListener('abort', onAbort); @@ -205,52 +232,37 @@ function runChild(opts: RunChildOptions): Promise { child.on('error', () => { if (failed) return; failed = true; - killTree(child); + void native.stop(); }); child.on('close', (code) => { - finish(() => { - // `code === null` ⇒ the process was signal-killed. A timeout/abort kill lands here with null code; a - // process that exited NATURALLY at the same tick the timer fired keeps its real `code`, so gate the - // timeout label on a null code — a same-tick natural exit is reported with its true exit code, not a - // spurious timeout. - if (aborted) { - reject(new ProcessCapabilityError('the command was aborted')); - } else if (failed) { - reject(new ProcessCapabilityError('the command failed to run')); - } else if (timedOut && code === null) { - reject(new ProcessCapabilityError(`the command timed out after ${opts.timeoutMs}ms`)); - } else { - // Reap the whole process group on a NORMAL exit too — a backgrounded grandchild the command forked - // must not outlive the tool call. killTree is idempotent on an already-exited group (the ESRCH is - // swallowed), so this is a no-op when nothing survives and a clean reaping when something does. - killTree(child); - resolvePromise({ - // An (external) signal-kill exit has `code === null` — report a conventional non-zero so the model - // sees a failure exit rather than a misleading `0`. - exitCode: code ?? 1, - stdout: stdout.text(), - stderr: stderr.text(), - durationMs: Date.now() - start, - }); - } - }); + cleanup(); + void native.whenClosed.then(() => + finish(() => { + // POSIX cancellation reports a null code; Windows taskkill reports a nonzero native exit code. + // An already observed exit never starts timeout cancellation, and natural success keeps code 0. + if (aborted) { + reject(new ProcessCapabilityError('the command was aborted')); + } else if (failed) { + reject(new ProcessCapabilityError('the command failed to run')); + } else if (timedOut && (code === null || (process.platform === 'win32' && code !== 0))) { + reject(new ProcessCapabilityError(`the command timed out after ${opts.timeoutMs}ms`)); + } else { + // Native ownership has joined close and its final group/helper attempt before publication. + resolvePromise({ + // An (external) signal-kill exit has `code === null` — report a conventional non-zero so the model + // sees a failure exit rather than a misleading `0`. + exitCode: code ?? 1, + stdout: stdout.text(), + stderr: stderr.text(), + durationMs: Date.now() - start, + }); + } + }), + ); }); }); } -/** SIGKILL the child and (on POSIX) its whole process group, so a forked grandchild can't survive the kill. */ -function killTree(child: ChildProcess): void { - if (process.platform !== 'win32' && child.pid !== undefined) { - try { - process.kill(-child.pid, 'SIGKILL'); // negative pid ⇒ the process group (the detached child is its leader) - return; - } catch { - // The group is already gone, or the kill is not permitted — fall back to the single child below. - } - } - child.kill('SIGKILL'); -} - /** A byte-bounded UTF-8 accumulator: appends until the cap, then drops the rest and marks the output truncated. */ class BoundedBuffer { readonly #chunks: Buffer[] = []; diff --git a/apps/cli/src/harness/mcp-orphan.e2e.test.ts b/apps/cli/src/harness/mcp-orphan.e2e.test.ts index 7dae9224..3a244e62 100644 --- a/apps/cli/src/harness/mcp-orphan.e2e.test.ts +++ b/apps/cli/src/harness/mcp-orphan.e2e.test.ts @@ -7,6 +7,8 @@ import { describe, expect, it } from 'vitest'; import { connectAgentMcp } from '../engine/mcp-servers.js'; import { guardMcpTeardown } from '../engine/mcp-signal-teardown.js'; import type { ResolvedStdioSpawn } from '../engine/mcp-consent.js'; +import { createCliInvocation } from '../process/invocation.js'; +import { createNativeProcessController } from '../process/native-process-owner.js'; /** * `#21` / [ADR-0088](../../../../docs/decisions/0088-the-mcp-boundary-is-hostile.md) §1.3 and §11 — **no orphan @@ -130,9 +132,11 @@ describe('MCP orphan reaping — against the real child-process table', () => { // and any `process.exit` in the tree all terminate without awaiting `close()`. The cooperative half cannot // cover them; this one runs on `process.on('exit')` and must be synchronous, which is why it SIGKILLs a // pid rather than awaiting a transport. + const invocation = createCliInvocation(); const client = await connectAgentMcp([stubbornServer()], { cwd: process.cwd(), consentGate: allowAll, + nativeProcesses: createNativeProcessController(invocation.registry), }); // **Everything after the connect runs under a `finally`.** A failing assertion here used to leave a live // `SIGTERM`-trapping child behind, and this file's own later test filters the process table by fixture @@ -145,17 +149,14 @@ describe('MCP orphan reaping — against the real child-process table', () => { // Drive the net directly rather than exiting this test process: the mechanism under test is the // callback, and a real `process.exit` here would take the runner with it. let fireExitNet = (): void => undefined; - const unguard = guardMcpTeardown( - () => client?.close() ?? Promise.resolve(), - () => client?.childPids ?? [], - { - subscribeSignals: () => () => undefined, // the cooperative half is the other test's subject - subscribeProcessExit: (onExit) => { - fireExitNet = onExit; - return () => undefined; - }, + const unguard = guardMcpTeardown(() => client?.close() ?? Promise.resolve(), undefined, { + invocation, + subscribeSignals: () => () => undefined, // the cooperative half is the other test's subject + subscribeProcessExit: (onExit) => { + fireExitNet = onExit; + return () => undefined; }, - ); + }); try { fireExitNet(); // `kill` returns immediately; the process leaves the table a moment later. @@ -172,9 +173,11 @@ describe('MCP orphan reaping — against the real child-process table', () => { // The ordinary path: a signal arrives, `close()` runs, the child dies. Asserted with a `reapOnly` guard — // the shape `relavium run` uses — so a regression that made `reapOnly` skip the teardown as well as the // exit would redden here. + const invocation = createCliInvocation(); const client = await connectAgentMcp([stubbornServer()], { cwd: process.cwd(), consentGate: allowAll, + nativeProcesses: createNativeProcessController(invocation.registry), }); try { const pid = client?.childPids[0]; @@ -184,21 +187,18 @@ describe('MCP orphan reaping — against the real child-process table', () => { let fireSignal = (signo: number): void => { signalsSeen.push(signo); }; - const unguard = guardMcpTeardown( - () => client?.close() ?? Promise.resolve(), - () => client?.childPids ?? [], - { - subscribeSignals: (onSignal) => { - fireSignal = onSignal; - return () => undefined; - }, - subscribeProcessExit: () => () => undefined, // the OTHER half must not be what reaps here - reapOnly: true, - exit: () => { - throw new Error('reapOnly must not exit — driveRun owns this surface’s exit code'); - }, + const unguard = guardMcpTeardown(() => client?.close() ?? Promise.resolve(), undefined, { + invocation, + subscribeSignals: (onSignal) => { + fireSignal = onSignal; + return () => undefined; }, - ); + subscribeProcessExit: () => () => undefined, // the OTHER half must not be what reaps here + reapOnly: true, + exit: () => { + throw new Error('reapOnly must not exit — driveRun owns this surface’s exit code'); + }, + }); try { fireSignal(2); expect(signalsSeen).toEqual([]); // the guard replaced the placeholder, so the guard is what ran diff --git a/apps/cli/src/home/drive-home.test.ts b/apps/cli/src/home/drive-home.test.ts index eabd32a0..136a8535 100644 --- a/apps/cli/src/home/drive-home.test.ts +++ b/apps/cli/src/home/drive-home.test.ts @@ -1,4 +1,9 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { createCliInvocation } from '../process/invocation.js'; +import { + holdNativeToolProcess, + witnessNativeToolProcess, +} from '../commands/native-forwarding.test-support.js'; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Readable } from 'node:stream'; @@ -404,12 +409,15 @@ describe('driveHome (2.5.B / ADR-0054)', () => { }); it('startChat builds the default-agent session and persists its row; a clean return ends it', async () => { + const invocation = createCliInvocation(); mkdirSync(join(cwd, '.relavium'), { recursive: true }); writeFileSync(join(cwd, '.relavium', 'project.toml'), '[defaults]\nmax_tokens_estimate = 17\n'); const estimates: (number | undefined)[] = []; let captured: RootAppProps | undefined; const { deps } = makeDeps((p) => (captured = p), { - buildSession: (options) => { + invocation, + buildSession: async (options) => { + await witnessNativeToolProcess(options.nativeProcesses, invocation, options.cwd); estimates.push(options.maxTokensEstimate); return buildChatSession(options); }, @@ -420,8 +428,11 @@ describe('driveHome (2.5.B / ADR-0054)', () => { type(props, 'hello'); props.controller.handleKey('', ENTER); // submit ⇒ build + first turn - await flush(); - expect(props.controller.getSnapshot().mode).toBe('chat'); + await vi.waitFor(() => + expect(invocation.registry.activeOwners, 'NATIVE_HOME_FRESH_FORWARDING').toBe(1), + ); + expect(props.controller.getSnapshot().mode).toBe('loading'); + await vi.waitFor(() => expect(props.controller.getSnapshot().mode).toBe('chat')); const sessions = createSessionStore(client.db); expect(sessions.listSessions({ limit: 10 })).toHaveLength(1); // the chat persisted its row @@ -432,6 +443,9 @@ describe('driveHome (2.5.B / ADR-0054)', () => { // The filter also matches /cost's "clears held estimates" description; choose the Clear row. props.controller.handleKey('', { downArrow: true }); props.controller.handleKey('', ENTER); + await vi.waitFor(() => + expect(invocation.registry.activeOwners, 'NATIVE_HOME_FRESH_FORWARDING').toBe(1), + ); await vi.waitFor(() => expect(estimates).toEqual([17, 17])); expect(props.controller.getSnapshot().mode).toBe('chat'); expect(estimates).toEqual([17, 17]); @@ -444,6 +458,107 @@ describe('driveHome (2.5.B / ADR-0054)', () => { expect(sessions.listSessions({ limit: 10 })[0]?.status).toBe('ended'); }); + it('retains both original native reservations when a pending clear build is superseded', async () => { + const invocation = createCliInvocation(); + let captured: RootAppProps | undefined; + const permits: (() => void)[] = []; + const children: { close: () => Promise }[] = []; + let builds = 0; + const { deps } = makeDeps((props) => (captured = props), { + invocation, + buildSession: async (options) => { + if (builds++ === 0) return buildChatSession(options); + const child = await holdNativeToolProcess(options.nativeProcesses, invocation, options.cwd); + children.push(child); + await new Promise((resolve) => permits.push(resolve)); + const built = await buildChatSession(options); + return { + ...built, + closeMcp: async () => { + await child.close(); + await built.closeMcp?.(); + }, + }; + }, + }); + const drivePromise = driveHome(deps); + const props = captured; + if (props === undefined) throw new Error('Home was not mounted'); + try { + type(props, 'hello'); + props.controller.handleKey('', ENTER); + await vi.waitFor(() => expect(props.controller.getSnapshot().mode).toBe('chat')); + await vi.waitFor(() => expect(props.controller.getSnapshot().submitBusy).toBe(false)); + const original = props.controller.getSnapshot().session; + expect(original).toBeDefined(); + props.controller.handlePaste('/clear'); + props.controller.handleKey('', ENTER); + await vi.waitFor(() => expect(permits, 'NATIVE_HOME_PENDING_FORWARDING').toHaveLength(1)); + expect(invocation.registry.activeOwners).toBe(1); + expect(props.controller.getSnapshot().session).toBe(original); // first replacement still initializes + props.controller.handleKey('c', CTRL_C); // sticky /clear starts a second replacement + await vi.waitFor(() => expect(permits).toHaveLength(2)); + expect(invocation.registry.activeOwners).toBe(2); + permits[1]?.(); // second replacement wins while both native creations remain live + await vi.waitFor(() => expect(props.controller.getSnapshot().session).not.toBe(original)); + const winner = props.controller.getSnapshot().session; + expect(invocation.registry.activeOwners).toBe(2); + permits[0]?.(); // stale completion may close only its own native creation + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(1)); + expect(props.controller.getSnapshot().session).toBe(winner); + await vi.waitFor(() => expect(props.controller.getSnapshot().submitBusy).toBe(false)); + props.controller.handleKey('c', CTRL_C); + await vi.waitFor(() => expect(props.controller.getSnapshot().mode).toBe('home')); + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(0)); + props.controller.handleKey('c', CTRL_C); + expect(await drivePromise).toBe(EXIT_CODES.success); + } finally { + for (const permit of permits) permit(); + await Promise.all(children.map((child) => child.close())); + await props.controller.teardownActive(); + } + }); + + it('binds the default Home editor to the original native registry', async () => { + const invocation = createCliInvocation(); + const marker = join(cwd, 'home-editor-started'); + const script = join(cwd, 'home-editor.cjs'); + writeFileSync( + script, + `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'started'); setTimeout(() => {}, 200);`, + ); + let captured: RootAppProps | undefined; + const { deps } = makeDeps((props) => (captured = props), { + invocation, + io: { ...io, env: { EDITOR: `"${process.execPath}" "${script}"` } }, + }); + const drivePromise = driveHome(deps); + const props = captured; + if (props?.suspendPort === undefined) + throw new Error('default Home editor suspend port is missing'); + props.suspendPort.attach((body) => body()); + type(props, 'hello'); + props.controller.handleKey('', ENTER); + await vi.waitFor(() => expect(props.controller.getSnapshot().session).toBeDefined()); + await vi.waitFor(() => expect(props.controller.getSnapshot().submitBusy).toBe(false)); + props.controller.handlePaste('/edit'); + props.controller.handleKey('', ENTER); + await vi.waitFor( + () => { + expect(invocation.registry.activeOwners, 'NATIVE_HOME_EDITOR_FORWARDING').toBe(1); + expect(existsSync(marker)).toBe(true); + }, + { interval: 1 }, + ); + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(0)); + await vi.waitFor(() => expect(props.controller.getSnapshot().submitBusy).toBe(false)); + props.suspendPort.attach(undefined); + props.controller.handleKey('c', CTRL_C); + await vi.waitFor(() => expect(props.controller.getSnapshot().mode).toBe('home')); + props.controller.handleKey('c', CTRL_C); + expect(await drivePromise).toBe(EXIT_CODES.success); + }); + it('a /models write is picked up by the NEXT chat started in the SAME Home process (2.5.G S7 regression)', async () => { // The blocker Sonnet review caught: `startChat` closed over the load-once `config` snapshot, so a `/models` // write never reached a same-process chat even though the notice claimed "applies to your next chat session". @@ -552,6 +667,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { }); it('in-Home /models reseat: the REAL reseatChat resumes the session under the switched model, carrying the transcript (ADR-0059)', async () => { + const invocation = createCliInvocation(); mkdirSync(join(cwd, '.relavium'), { recursive: true }); writeFileSync(join(cwd, '.relavium', 'project.toml'), '[defaults]\nmax_tokens_estimate = 17\n'); const freshEstimates: (number | undefined)[] = []; @@ -560,12 +676,19 @@ describe('driveHome (2.5.B / ADR-0054)', () => { // store) end-to-end — not the mocked controller-level test — pinning the build-first swap over the same sessionId. let captured: RootAppProps | undefined; const { deps } = makeDeps((p) => (captured = p), { + invocation, providers: scriptedResolver([textTurn('sonnet reply'), textTurn('opus reply')]), - buildSession: (options) => { + buildSession: async (options) => { + await witnessNativeToolProcess(options.nativeProcesses, invocation, options.cwd); freshEstimates.push(options.maxTokensEstimate); return buildChatSession(options); }, - buildResumedSession: (options) => { + buildResumedSession: async (options) => { + await witnessNativeToolProcess( + options.nativeProcesses, + invocation, + options.record.context.workingDir, + ); resumedEstimates.push(options.maxTokensEstimate); return buildResumedChatSession(options); }, @@ -577,7 +700,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { // Start a chat (bound to the default claude-sonnet-4-6) + run one turn so the transcript persists. type(props, 'first'); props.controller.handleKey('', ENTER); - await flush(); + await vi.waitFor(() => expect(props.controller.getSnapshot().session).toBeDefined()); expect(props.controller.getSnapshot().mode).toBe('chat'); const oldSession = props.controller.getSnapshot().session; const sessionId = oldSession?.sessionId ?? ''; @@ -599,7 +722,11 @@ describe('driveHome (2.5.B / ADR-0054)', () => { expect(effortPicker?.phase).toBe('effort'); expect(effortPicker?.pending?.modelId).toBe('claude-opus-4-8'); props.controller.handleKey('', ENTER); // apply the highlighted tier (default 'medium') ⇒ the REAL reseatChat - await flush(); + await vi.waitFor(() => + expect(invocation.registry.activeOwners, 'NATIVE_HOME_RESEAT_FORWARDING').toBe(1), + ); + await vi.waitFor(() => expect(resumedEstimates).toEqual([17])); + await vi.waitFor(() => expect(props.controller.getSnapshot().session).not.toBe(oldSession)); // The session was reseated: a NEW session object, the SAME sessionId, bound to opus, carrying the prior turn. const reseated = props.controller.getSnapshot().session; diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index 99797169..7160b34d 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -1,3 +1,4 @@ +import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; import type { CliInvocation } from '../process/invocation.js'; import { closeQuietly, warnTeardown } from '../process/close-quietly.js'; import { COMPACTION_UNKNOWN_WINDOW_NOTICE } from '../chat/repl-info.js'; @@ -71,7 +72,7 @@ import { type HomeModelsPort, } from '../render/tui/home-controller.js'; import { DISABLE_MOUSE, ENABLE_MOUSE, HIDE_CURSOR, SHOW_CURSOR } from '../render/alt-screen.js'; -import { nodeCreateTempDocument, nodeSpawnEditor } from '../render/editor.js'; +import { nodeCreateTempDocument, createNodeSpawnEditor } from '../render/editor.js'; import { inkOwnedTerminal, type HatchDeps } from '../render/hatches.js'; import { nodeWaitForContinue, nodeWriteOut } from '../render/scrollback.js'; import { copyToClipboard, type ClipboardOutcome } from '../render/clipboard.js'; @@ -194,6 +195,7 @@ export const defaultOnboardingTerminalLifecycle: OnboardingTerminalLifecycle = { }; export async function driveHome(deps: HomeDeps): Promise { + const nativeProcesses = nativeProcessesForInvocation(deps.invocation); const grants = admitFsInvocation(deps.global); const now = deps.now ?? Date.now; const uuid = deps.uuid ?? randomUUID; @@ -403,7 +405,7 @@ export async function driveHome(deps: HomeDeps): Promise { }, editor: { env: deps.io.env, - spawnEditor: nodeSpawnEditor, + spawnEditor: createNodeSpawnEditor(nativeProcesses), createTempDocument: nodeCreateTempDocument, onDisposeFailed: (path, error) => { deps.io.writeErr( @@ -677,6 +679,7 @@ export async function driveHome(deps: HomeDeps): Promise { ? [freshModel, effectiveChat?.defaultProvider] : [config.chat.defaultModel, config.chat.defaultProvider]; const built: BuiltChatSession = await (deps.buildSession ?? buildChatSession)({ + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), // Consent before any stdio MCP spawn (ADR-0084 §1) — the Home opens an agent like every other path. consentGate: createConsentGate({ io: deps.io, @@ -770,6 +773,7 @@ export async function driveHome(deps: HomeDeps): Promise { const noteBudget = (warning: ChatBudgetWarning): void => noteToStore(budgetWarningText(warning)); const built = await (deps.buildResumedSession ?? buildResumedChatSession)({ + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), chat: config.chat, fsPolicy, ...(config.maxTokensEstimate === undefined diff --git a/apps/cli/src/process/invocation-registry.test.ts b/apps/cli/src/process/invocation-registry.test.ts index f20df2ea..28ee098e 100644 --- a/apps/cli/src/process/invocation-registry.test.ts +++ b/apps/cli/src/process/invocation-registry.test.ts @@ -4,6 +4,7 @@ import { InvocationRegistry, type InvocationClock, type InvocationCloseResult, + type NativeOwner, type OwnerRetirement, type RemainingBudget, type SessionOwner, @@ -59,6 +60,19 @@ function session( }; } +function native( + retired: Promise, + overrides: Partial = {}, +): NativeOwner { + return { + ...session(retired), + kind: 'native-pid', + mayReap: () => true, + reap: () => undefined, + ...overrides, + }; +} + const settled: OwnerRetirement = Object.freeze({ kind: 'settled' }); // Awaiting the exact witness is an explicit observer-order barrier: registry registration @@ -988,3 +1002,483 @@ describe('InvocationRegistry ownership', () => { expect(registry.activeOwners).toBe(0); }); }); + +describe('InvocationRegistry native budget and exit capabilities', () => { + it('delivers the current ordinary budget before native creation without taking the process observer', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const stop = vi.fn(); + const join = vi.fn(); + const budgets: RemainingBudget[] = []; + const coordinator = vi.fn(); + const remove = registry.observeTermination(coordinator); + registry.register( + native(life.promise, { + stop, + join, + budgetChanged: (budget) => { + budgets.push(budget); + expect(budget.deadline()).toBeUndefined(); + expect(budget.remainingMs()).toBeUndefined(); + }, + }), + ); + expect(budgets).toEqual([registry.budget]); + expect(stop).not.toHaveBeenCalled(); + expect(join).not.toHaveBeenCalled(); + expect(coordinator).not.toHaveBeenCalled(); + expect(() => registry.observeTermination(() => undefined)).toThrow(); + expect(clock.timers.size).toBe(0); + await retire(life); + await registry.terminate({ kind: 'normal' }); + expect(coordinator).toHaveBeenCalled(); + remove(); + }); + + it('notifies native owners only for accepted earlier deadlines and actual expiry', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const states: { deadline: number | undefined; remaining: number | undefined }[] = []; + registry.register( + native(life.promise, { + budgetChanged: (budget) => { + states.push({ deadline: budget.deadline(), remaining: budget.remainingMs() }); + }, + }), + ); + const closing = registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 1000 }); + clock.advanceTo(100); + void registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 1000 }); + void registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 2000 }); + void registry.terminate({ kind: 'infrastructure', code: 'output_unavailable' }); + expect(states).toEqual([ + { deadline: undefined, remaining: undefined }, + { deadline: 1000, remaining: 1000 }, + ]); + void registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 500 }); + clock.advanceTo(500); + expect(states.slice(2)).toEqual([ + { deadline: 500, remaining: 400 }, + { deadline: 500, remaining: 0 }, + ]); + expect(await closing).toEqual({ kind: 'deadline', unacknowledgedOwners: 1 }); + expect(registry.activeOwners).toBe(1); + await retire(life); + }); + + it('tightens an already-entered ordinary helper without restarting cleanup or acknowledging native close', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const targetClosed = deferred(); + const helperClosed = deferred(); + let helperStarted = false; + let helperCancellation = 0; + let disarmHelper: (() => void) | undefined; + const helperDeadline = 10_000; + const adjustHelper = (budget: RemainingBudget): void => { + if (!helperStarted) return; + disarmHelper?.(); + const remaining = budget.remainingMs(); + const delay = Math.min(helperDeadline - clock.time, remaining ?? Number.POSITIVE_INFINITY); + if (delay <= 0) { + helperCancellation += 1; + disarmHelper = undefined; + } else { + disarmHelper = clock.schedule(delay, () => { + helperCancellation += 1; + disarmHelper = undefined; + }); + } + }; + const stop = vi.fn(); + const reap = vi.fn((budget: RemainingBudget) => { + helperStarted = true; + adjustHelper(budget); + return helperClosed.promise; + }); + const lease = registry.register( + native(targetClosed.promise, { stop, reap, budgetChanged: adjustHelper }), + ); + const ordinaryClose = lease.close(); + expect(helperStarted).toBe(true); + expect([...clock.timers].map((timer) => timer.at)).toEqual([helperDeadline]); + expect(registry.budget.deadline()).toBeUndefined(); + clock.advanceTo(200); + const closing = registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + expect([...clock.timers].map((timer) => timer.at)).toEqual([3200, 3200]); + clock.advanceTo(300); + void registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 500 }); + expect([...clock.timers].map((timer) => timer.at)).toEqual([500, 500]); + clock.advanceTo(400); + void registry.terminate({ kind: 'infrastructure', code: 'output_unavailable' }); + expect([...clock.timers].map((timer) => timer.at)).toEqual([500, 500]); + expect(stop).toHaveBeenCalledOnce(); + expect(reap).toHaveBeenCalledOnce(); + clock.advanceTo(500); + expect(helperCancellation).toBe(1); + expect(await ordinaryClose).toEqual({ kind: 'unacknowledged' }); + expect(await closing).toEqual({ kind: 'deadline', unacknowledgedOwners: 1 }); + expect(registry.activeOwners).toBe(1); + helperClosed.resolve(); + await helperClosed.promise; + expect(registry.activeOwners).toBe(1); + await retire(targetClosed); + expect(registry.activeOwners).toBe(0); + }); + + it('releases native budget and exit callbacks only on actual retirement', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const remaining: (number | undefined)[] = []; + const exitReap = vi.fn(); + const lease = registry.register( + native(life.promise, { + budgetChanged: (budget) => remaining.push(budget.remainingMs()), + exitReap, + }), + ); + const ordinaryClose = lease.close(); + registry.reapNativeAtExit(); + expect(exitReap).toHaveBeenCalledOnce(); + expect(registry.activeOwners).toBe(1); + await retire(life); + expect(await ordinaryClose).toEqual({ kind: 'retired' }); + const closing = registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + registry.reapNativeAtExit(); + clock.advanceTo(3000); + await closing; + expect(remaining).toEqual([undefined]); + expect(exitReap).toHaveBeenCalledOnce(); + expect(registry.activeOwners).toBe(0); + }); + + it('isolates fixed budget faults without reading their causes or skipping siblings', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const touched = vi.fn(() => { + throw new Error('private fault must not be inspected'); + }); + const privateFault = Object.defineProperties(new Error('synthetic callback failure'), { + message: { get: touched }, + cause: { get: touched }, + stack: { get: touched }, + }); + const classify = vi.fn(() => 'storage_unavailable' as const); + const stops: number[] = []; + const states: (number | undefined)[] = []; + registry.register( + native(life.promise, { + classifyFailure: classify, + stop: () => { + stops.push(1); + }, + budgetChanged: (budget) => { + if (budget.remainingMs() !== undefined) throw privateFault; + }, + }), + ); + registry.register( + native(life.promise, { + stop: () => { + stops.push(2); + }, + budgetChanged: (budget) => { + states.push(budget.remainingMs()); + }, + }), + ); + const closing = registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 1000 }); + expect(stops).toEqual([1, 2]); + expect(states).toEqual([undefined, 1000]); + expect(touched).not.toHaveBeenCalled(); + expect(classify).not.toHaveBeenCalled(); + expect(registry.observations.infrastructureCode()).toBe('cli_internal'); + expect(registry.failureKinds).toBe(1); + clock.advanceTo(1000); + expect(states).toEqual([undefined, 1000, 0]); + expect(await closing).toEqual({ kind: 'deadline', unacknowledgedOwners: 2 }); + await retire(life); + }); + + it('revisits an earlier sibling after a reentrant tightening without restarting the process observer', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const first: (number | undefined)[] = []; + const second: (number | undefined)[] = []; + registry.register( + native(life.promise, { + budgetChanged: (budget) => { + first.push(budget.remainingMs()); + }, + }), + ); + registry.register( + native(life.promise, { + budgetChanged: (budget) => { + second.push(budget.remainingMs()); + if (budget.deadline() === 3000) + void registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 1000 }); + }, + }), + ); + const observer = vi.fn(); + const remove = registry.observeTermination(observer); + const closing = registry.terminate({ kind: 'infrastructure', code: 'output_unavailable' }); + expect(first).toEqual([undefined, 3000, 1000]); + expect(second).toEqual([undefined, 3000, 1000]); + expect(registry.budget.deadline()).toBe(1000); + expect(registry.failureKinds).toBe(0); + expect(observer).toHaveBeenCalled(); + expect(() => registry.observeTermination(() => undefined)).toThrow(); + await retire(life); + await closing; + remove(); + }); + + it('bounds repeated self-tightening and delivers a final zero budget to every active native owner', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const sibling: (number | undefined)[] = []; + const reentrant: (number | undefined)[] = []; + registry.register( + native(life.promise, { + budgetChanged: (budget) => { + sibling.push(budget.remainingMs()); + }, + }), + ); + registry.register( + native(life.promise, { + budgetChanged: (budget) => { + reentrant.push(budget.remainingMs()); + const deadline = budget.deadline(); + if (deadline !== undefined) + void registry.terminate({ + kind: 'signal', + signalNumber: 15, + deadline: deadline - 0.001, + }); + }, + }), + ); + const closing = registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 1000 }); + expect(reentrant).toHaveLength(4); + expect(sibling).toHaveLength(4); + expect(reentrant.at(-1)).toBe(0); + expect(sibling.at(-1)).toBe(0); + expect(registry.observations.infrastructureCode()).toBe('cli_internal'); + expect(registry.failureKinds).toBe(1); + expect(await closing).toEqual({ kind: 'deadline', unacknowledgedOwners: 2 }); + expect(registry.activeOwners).toBe(2); + await retire(life); + }); + + it.each(['clock', 'scheduler'] as const)( + 'delivers fail-closed expiry after a %s fault and never reads a failed clock after expiry', + async (fault) => { + const life = deferred(); + const now = vi.fn(() => { + if (fault === 'clock') throw new Error('private unavailable clock'); + return 0; + }); + const registry = new InvocationRegistry({ + now, + schedule: () => { + throw new Error('private unavailable scheduler'); + }, + }); + const states: { deadline: number | undefined; remaining: number | undefined }[] = []; + registry.register( + native(life.promise, { + budgetChanged: (budget) => { + states.push({ deadline: budget.deadline(), remaining: budget.remainingMs() }); + }, + }), + ); + expect(states).toEqual([{ deadline: undefined, remaining: undefined }]); + const closing = registry.terminate({ kind: 'infrastructure', code: 'output_unavailable' }); + expect(states.at(-1)).toEqual({ + deadline: fault === 'clock' ? undefined : 3000, + remaining: 0, + }); + const reads = now.mock.calls.length; + expect(registry.budget.remainingMs()).toBe(0); + expect(registry.budget.remainingMs()).toBe(0); + expect(now).toHaveBeenCalledTimes(reads); + expect(await closing).toEqual({ kind: 'deadline', unacknowledgedOwners: 1 }); + await retire(life); + }, + ); + + it('expires on a later live-budget clock read failure without throwing through the helper callback', async () => { + const clock = new ManualClock(); + let failedClock = false; + const registry = new InvocationRegistry({ + now: () => { + if (failedClock) throw new Error('private clock failure'); + return clock.time; + }, + schedule: clock.schedule, + }); + const life = deferred(); + const states: (number | undefined)[] = []; + registry.register( + native(life.promise, { + budgetChanged: (budget) => { + states.push(budget.remainingMs()); + }, + }), + ); + const closing = registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 1000 }); + failedClock = true; + expect(registry.budget.remainingMs()).toBe(0); + expect(states).toEqual([undefined, 1000, 0]); + expect(registry.observations.infrastructureCode()).toBe('cli_internal'); + expect(await closing).toEqual({ kind: 'deadline', unacknowledgedOwners: 1 }); + await retire(life); + }); + + it('ignores native-shaped budget hooks on other owner kinds at runtime', async () => { + const registry = new InvocationRegistry(new ManualClock()); + const life = deferred(); + const budgetChanged = vi.fn(); + const sessionOwner = { ...session(life.promise), budgetChanged }; + const terminalOwner = { + ...session(life.promise), + kind: 'terminal' as const, + restore: () => undefined, + budgetChanged, + }; + const workflowOwner = { + ...session(life.promise), + kind: 'workflow' as const, + readSafetyExit: () => undefined, + budgetChanged, + }; + registry.register(sessionOwner); + registry.register(terminalOwner); + registry.register(workflowOwner); + const closing = registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + expect(budgetChanged).not.toHaveBeenCalled(); + await retire(life); + await closing; + }); + + it('attempts only eligible creation-native exit callbacks without tails, acknowledgement or deadline reset', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const stop = vi.fn(); + const join = vi.fn(); + const reap = vi.fn(); + const restore = vi.fn(); + const readSafetyExit = vi.fn(() => 8 as const); + const foreignExit = vi.fn(); + const trace: string[] = []; + for (const kind of ['native-group', 'native-pid', 'windows-live-root'] as const) + registry.register( + native(life.promise, { + kind, + stop, + join, + reap, + mayReap: () => kind !== 'native-pid', + exitReap: () => { + trace.push(kind); + registry.reapNativeAtExit(); + }, + }), + ); + const sessionOwner = { ...session(life.promise, { stop, join }), exitReap: foreignExit }; + const terminalOwner = { + ...session(life.promise, { stop, join }), + kind: 'terminal' as const, + restore, + exitReap: foreignExit, + }; + const workflowOwner = { + ...session(life.promise, { stop, join }), + kind: 'workflow' as const, + readSafetyExit, + exitReap: foreignExit, + }; + registry.register(sessionOwner); + registry.register(terminalOwner); + registry.register(workflowOwner); + registry.reapNativeAtExit(); + expect(trace).toEqual(['native-group', 'windows-live-root']); + for (const callback of [stop, join, reap, restore, readSafetyExit, foreignExit]) + expect(callback).not.toHaveBeenCalled(); + expect(registry.termination).toBeUndefined(); + expect(registry.budget.deadline()).toBeUndefined(); + expect(clock.timers.size).toBe(0); + expect(registry.activeOwners).toBe(6); + registry.assertAdmitting(); + await retire(life); + registry.reapNativeAtExit(); + expect(trace).toEqual(['native-group', 'windows-live-root']); + expect(registry.activeOwners).toBe(0); + }); + + it('isolates exit eligibility and callback faults without classification, normal termination or skipped siblings', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const life = deferred(); + const classify = vi.fn(() => 'storage_unavailable' as const); + const exit = vi.fn(); + registry.register( + native(life.promise, { + classifyFailure: classify, + mayReap: () => { + throw new Error('private eligibility failure'); + }, + exitReap: exit, + }), + ); + registry.register( + native(life.promise, { + kind: 'native-group', + classifyFailure: classify, + exitReap: () => { + throw new Error('private reap failure'); + }, + }), + ); + registry.register(native(life.promise, { kind: 'windows-live-root', exitReap: exit })); + registry.reapNativeAtExit(); + expect(exit).toHaveBeenCalledOnce(); + expect(classify).not.toHaveBeenCalled(); + expect(registry.observations.infrastructureCode()).toBe('cli_internal'); + expect(registry.failureKinds).toBe(2); + expect(registry.termination).toBeUndefined(); + expect(registry.budget.remainingMs()).toBeUndefined(); + expect(clock.timers.size).toBe(0); + expect(registry.activeOwners).toBe(3); + await retire(life); + }); + + it('uses current exit eligibility and frozen callback identity rather than a cached PID decision', async () => { + const registry = new InvocationRegistry(new ManualClock()); + const life = deferred(); + let eligible = true; + const original = vi.fn(); + const replacement = vi.fn(); + const owner = native(life.promise, { mayReap: () => eligible, exitReap: original }); + registry.register(owner); + expect(Reflect.set(owner, 'exitReap', replacement)).toBe(true); + eligible = false; + registry.reapNativeAtExit(); + expect(original).not.toHaveBeenCalled(); + eligible = true; + registry.reapNativeAtExit(); + expect(original).toHaveBeenCalledOnce(); + expect(replacement).not.toHaveBeenCalled(); + await retire(life); + }); +}); diff --git a/apps/cli/src/process/invocation-registry.ts b/apps/cli/src/process/invocation-registry.ts index d80543ce..72db453e 100644 --- a/apps/cli/src/process/invocation-registry.ts +++ b/apps/cli/src/process/invocation-registry.ts @@ -63,10 +63,22 @@ export interface NativeOwner extends OwnerBase { readonly mayReap: () => boolean; /** A request/attempt, never target close ACK. Concrete PID/PGID/facility stays in the native adapter. */ readonly reap: (budget: RemainingBudget) => void | Promise; + /** Adjust/cancel already-entered helper work only; synchronous notification grants no new entry or ACK. */ + readonly budgetChanged?: (budget: RemainingBudget) => void; + /** Finite synchronous exit fallback only; never start an async helper or acknowledge native closure. */ + readonly exitReap?: () => void; } export type InvocationOwner = WorkflowOwner | SessionOwner | TerminalOwner | NativeOwner; -export type OwnerStage = 'stop' | 'join' | 'restore' | 'reap' | 'retirement' | 'health'; +export type OwnerStage = + | 'stop' + | 'join' + | 'restore' + | 'reap' + | 'retirement' + | 'health' + | 'budget' + | 'exit'; export interface OwnerLease { readonly assertAdmitting: () => void; @@ -97,6 +109,10 @@ interface Entry { retired: boolean; closeResult: Promise | undefined; sampling: boolean; + budgetObserved: boolean; + budgetDeadline: number | undefined; + budgetExpired: boolean; + budgetReentered: boolean; } // Observe actual native Promise settlement, bypassing overridden then/foreign thenable protocols. @@ -124,14 +140,16 @@ export class InvocationRegistry { #stopping = false; #terminationObserver: (() => void) | undefined; #notifying = false; + #notifyingBudget = false; + #budgetCallbackEntry: Entry | undefined; + #reapingAtExit = false; constructor(clock: InvocationClock, observations = new ExitObservations()) { this.#clock = clock; this.observations = observations; this.#budget = Object.freeze({ deadline: () => this.#deadline, - remainingMs: () => - this.#deadline === undefined ? undefined : Math.max(0, this.#deadline - this.#now()), + remainingMs: () => this.#remainingMs(), }); } @@ -184,6 +202,10 @@ export class InvocationRegistry { retired: false, closeResult: undefined, sampling: false, + budgetObserved: false, + budgetDeadline: undefined, + budgetExpired: false, + budgetReentered: false, }; this.#entries.add(entry); // Registration observes lifetime only: joining an idle session here would falsely retire future turns. @@ -196,6 +218,9 @@ export class InvocationRegistry { ) { this.#record(entry, 'retirement', 'cli_internal'); } + // The native adapter sees current state before creation, without using the coordinator's observer. + if (this.#notifyingBudget) this.#notifyBudget(entry); + else this.#notifyBudgets(); return Object.freeze({ assertAdmitting: () => { this.assertAdmitting(); @@ -262,6 +287,28 @@ export class InvocationRegistry { this.#sampleHealth(); } + /** Attempt only finite existing native capabilities; exit cannot await or certify any resource closed. */ + reapNativeAtExit(): void { + if (this.#reapingAtExit) return; + this.#reapingAtExit = true; + try { + // Snapshot creation owners: callback reentry cannot add another exit obligation to this pass. + for (const entry of [...this.#entries]) { + const owner = entry.owner; + if (entry.retired || !isNativeOwner(owner) || owner.exitReap === undefined) continue; + try { + if (owner.mayReap() === true) owner.exitReap(); + } catch { + // Exit has no async cleanup tail or diagnostic sink. Retain only the fixed observation. + this.#failures.add(`${owner.kind}:exit:cli_internal`); + this.observations.observeFailure('cli_internal'); + } + } + } finally { + this.#reapingAtExit = false; + } + } + #closeEntry(entry: Entry): Promise { if (entry.closeResult !== undefined) return entry.closeResult; let finish: (value: OwnerCloseResult) => void = () => undefined; @@ -285,11 +332,7 @@ export class InvocationRegistry { this.#attempt(entry, 'join', entry.owner.join); const owner = entry.owner; if (owner.kind === 'terminal') this.#attempt(entry, 'restore', owner.restore); - else if ( - owner.kind === 'native-group' || - owner.kind === 'native-pid' || - owner.kind === 'windows-live-root' - ) { + else if (isNativeOwner(owner)) { if (!this.#canStart() || entry.retired) return; try { if (owner.mayReap()) this.#attempt(entry, 'reap', owner.reap); @@ -456,17 +499,91 @@ export class InvocationRegistry { return value; } + #remainingMs(): number | undefined { + if (this.#expired) return 0; + if (this.#deadline === undefined) return undefined; + try { + const remaining = Math.max(0, this.#deadline - this.#now()); + if (remaining === 0) this.#expire(); + return this.#expired ? 0 : remaining; + } catch { + this.observations.observeFailure('cli_internal'); + this.#expire(); + return 0; + } + } + #tighten(deadline: number): void { if (!Number.isFinite(deadline)) throw new CliError('cli_internal', ''); + if (this.#expired) return; if (this.#deadline !== undefined && this.#deadline <= deadline) return; this.#deadline = deadline; + const callbackEntry = this.#budgetCallbackEntry; + if (callbackEntry !== undefined) { + if (callbackEntry.budgetReentered) { + // A callback that keeps producing tighter deadlines cannot monopolize a notification drain. + // Expiry is stronger than the accepted deadline; other owners still receive the final zero budget. + this.#expire(); + this.#record(callbackEntry, 'budget', 'cli_internal'); + return; + } + callbackEntry.budgetReentered = true; + } this.#disarmTimer(); + if (this.#expired) return; const remaining = deadline - this.#now(); if (remaining <= 0) this.#expire(); else if (!this.#finished) this.#disarm = this.#clock.schedule(remaining, () => this.#expire()); + this.#notifyBudgets(); this.#notifyTermination(); } + #notifyBudgets(): void { + if (this.#notifyingBudget) return; + this.#notifyingBudget = true; + for (const entry of this.#entries) entry.budgetReentered = false; + try { + // Each native entry may tighten once in this drain; repeated self-tightening expires it above. + // Rescan current entries so a sibling already notified before a tighter reentrant request sees it. + let notified: boolean; + do { + notified = false; + for (const entry of this.#entries) if (this.#notifyBudget(entry)) notified = true; + } while (notified); + } finally { + this.#notifyingBudget = false; + } + } + + #notifyBudget(entry: Entry): boolean { + const owner = entry.owner; + if ( + entry.retired || + !isNativeOwner(owner) || + owner.budgetChanged === undefined || + (entry.budgetObserved && + entry.budgetDeadline === this.#deadline && + entry.budgetExpired === this.#expired) + ) + return false; + entry.budgetObserved = true; + entry.budgetDeadline = this.#deadline; + entry.budgetExpired = this.#expired; + const previous = this.#budgetCallbackEntry; + let failed = false; + this.#budgetCallbackEntry = entry; + try { + owner.budgetChanged(this.#budget); + } catch { + // This fixed native protocol never passes raw callback faults to a plugin/owner classifier. + failed = true; + } finally { + this.#budgetCallbackEntry = previous; + } + if (failed) this.#record(entry, 'budget', 'cli_internal'); + return true; + } + #notifyTermination(): void { const listener = this.#terminationObserver; if (listener === undefined || this.#termination === undefined || this.#notifying) return; @@ -493,14 +610,17 @@ export class InvocationRegistry { disarm?.(); } catch { this.observations.observeFailure('cli_internal'); + this.#expire(); } } #expire(): void { + if (this.#expired) return; this.#expired = true; const waiters = [...this.#waiters]; this.#waiters.clear(); for (const resolve of waiters) resolve(); + this.#notifyBudgets(); } #canStart(): boolean { @@ -531,6 +651,14 @@ export class InvocationRegistry { } } +function isNativeOwner(owner: InvocationOwner): owner is NativeOwner { + return ( + owner.kind === 'native-group' || + owner.kind === 'native-pid' || + owner.kind === 'windows-live-root' + ); +} + function isOwnerFailureCode(value: unknown): value is OwnerFailureCode { return ( value === 'internal' || diff --git a/apps/cli/src/process/native-process-owner.test.ts b/apps/cli/src/process/native-process-owner.test.ts new file mode 100644 index 00000000..6bc03752 --- /dev/null +++ b/apps/cli/src/process/native-process-owner.test.ts @@ -0,0 +1,554 @@ +import { ChildProcess, spawn } from 'node:child_process'; + +import { describe, expect, it, vi } from 'vitest'; + +import { createCliInvocation } from './invocation.js'; +import { InvocationRegistry } from './invocation-registry.js'; +import { + createNativeProcessController, + nativeProcessesForInvocation, + type NativeProcessPorts, +} from './native-process-owner.js'; +import { WindowsTeardownUnavailableError } from './windows-teardown.js'; + +function deferred(): { readonly promise: Promise; readonly resolve: (value: T) => void } { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +/** Explicit simulated native events: these test ownership protocol, not OS containment. */ +function child(pid = 40): ChildProcess { + const target = new ChildProcess(); + Object.defineProperty(target, 'pid', { value: pid }); + vi.spyOn(target, 'kill').mockReturnValue(true); + return target; +} + +function harness(platform: NodeJS.Platform = 'linux') { + let now = 0; + const scheduled = new Map<() => void, number>(); + const schedule = (milliseconds: number, fire: () => void): (() => void) => { + scheduled.set(fire, now + milliseconds); + return () => scheduled.delete(fire); + }; + const registry = new InvocationRegistry({ now: () => now, schedule }); + const helper = child(80); + const request = vi.fn(() => helper); + const verify = vi.fn(() => Promise.resolve({ spawn: request })); + const signal = vi.fn<(pid: number, signal: 0 | 'SIGKILL') => void>(() => undefined); + const helperKill = vi.spyOn(helper, 'kill'); + const removeExit = vi.fn(); + let exit: (() => void) | undefined; + const subscribeExit = vi.fn((listener: () => void) => { + exit = listener; + return removeExit; + }); + const ports: NativeProcessPorts = { + platform, + now: () => now, + schedule, + signal, + verifyWindows: verify, + subscribeExit, + }; + const controller = createNativeProcessController(registry, ports); + const advance = (milliseconds: number): void => { + now += milliseconds; + for (const [fire, at] of [...scheduled]) { + if (at > now || !scheduled.has(fire)) continue; + scheduled.delete(fire); + fire(); + } + }; + return { + registry, + controller, + ports, + helper, + helperKill, + request, + verify, + signal, + subscribeExit, + removeExit, + scheduled, + advance, + exit: () => exit?.(), + now: () => now, + }; +} + +describe('creation-owned native processes', () => { + it('constructs inertly and binds only the explicit original invocation identity', () => { + const h = harness('win32'); + expect(h.verify).not.toHaveBeenCalled(); + expect(h.subscribeExit).not.toHaveBeenCalled(); + expect(h.registry.activeOwners).toBe(0); + const invocation = createCliInvocation(); + expect(nativeProcessesForInvocation(invocation)).toBe(nativeProcessesForInvocation(invocation)); + expect(nativeProcessesForInvocation(invocation)).not.toBe( + nativeProcessesForInvocation(createCliInvocation()), + ); + expect(nativeProcessesForInvocation(undefined)).toBeUndefined(); + }); + + it('reserves before Windows verification and refuses a stopped creation before native entry', async () => { + const h = harness('win32'); + const verified = deferred<{ readonly spawn: (pid: number) => ChildProcess }>(); + const controller = createNativeProcessController(h.registry, { + ...h.ports, + verifyWindows: () => verified.promise, + }); + const prepared = controller.reserve('pid').catch((cause: unknown) => cause); + expect(h.registry.activeOwners, 'NATIVE_PREENTRY_REGISTRATION').toBe(1); + const termination = h.registry.terminate({ kind: 'normal' }); + verified.resolve({ spawn: h.request }); + expect(await prepared).toMatchObject({ code: 'cli_internal' }); + expect(await termination).toMatchObject({ kind: 'settled', unacknowledgedOwners: 0 }); + expect(h.request).not.toHaveBeenCalled(); + expect(h.removeExit).toHaveBeenCalledTimes(1); + }); + + it('preserves fixed facility refusal and retires only the proved-empty reservation', async () => { + const h = harness('win32'); + const controller = createNativeProcessController(h.registry, { + ...h.ports, + verifyWindows: () => Promise.reject(new WindowsTeardownUnavailableError()), + }); + await expect(controller.reserve('group')).rejects.toBeInstanceOf( + WindowsTeardownUnavailableError, + ); + expect(h.registry.activeOwners).toBe(0); + expect(h.signal).not.toHaveBeenCalled(); + }); + + it('caches only the invocation facility, never target PID or eligibility', async () => { + const h = harness('win32'); + const first = await h.controller.reserve('pid'); + const second = await h.controller.reserve('pid'); + expect(h.verify).toHaveBeenCalledTimes(1); + expect(h.registry.activeOwners).toBe(2); + expect(h.subscribeExit).toHaveBeenCalledTimes(1); + first.abandon(); + expect(h.removeExit).not.toHaveBeenCalled(); + second.abandon(); + await second.whenClosed; + expect(h.removeExit).toHaveBeenCalledTimes(1); + const third = await h.controller.reserve('pid'); + expect(h.subscribeExit).toHaveBeenCalledTimes(2); + third.abandon(); + }); + + it('retains a native error and successful kill request until actual close', async () => { + const h = harness(); + const lease = await h.controller.reserve('pid'); + const target = child(); + lease.bindChild(target); + target.emit('error', new Error('untrusted command error')); + await Promise.resolve(); // Observe any erroneous retirement reaction before asking for cancellation. + expect(h.registry.activeOwners, 'NATIVE_ERROR_NOT_ACK').toBe(1); + const stop = lease.stop(); + let settled = false; + void stop.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled, 'NATIVE_REQUEST_NOT_ACK').toBe(false); + expect(h.registry.activeOwners).toBe(1); + expect(h.signal.mock.calls).toEqual([ + [40, 0], + [40, 'SIGKILL'], + ]); + target.emit('close', null); + await stop; + expect(h.registry.activeOwners).toBe(0); + }); + + it('uses group authority after leader exit and releases it after final close reap', async () => { + const h = harness(); + const lease = await h.controller.reserve('group'); + const target = child(); + lease.bindChild(target); + target.emit('exit', 0); + expect(h.signal).toHaveBeenCalledWith(-40, 'SIGKILL'); + expect(h.registry.activeOwners).toBe(1); + target.emit('close', 0); + await lease.whenClosed; + expect(h.signal.mock.calls, 'NATIVE_GROUP_FINAL_REAP').toEqual([ + [-40, 'SIGKILL'], + [-40, 'SIGKILL'], + ]); + h.registry.reapNativeAtExit(); + expect(h.signal).toHaveBeenCalledTimes(2); + expect(h.registry.activeOwners).toBe(0); + }); + + it('never promotes a shared foreground PID to group authority', async () => { + const h = harness(); + const lease = await h.controller.reserve('pid'); + const target = child(); + lease.bindChild(target); + target.emit('exit', 0); + h.exit(); + expect(h.signal).not.toHaveBeenCalled(); + target.emit('close', 0); + await lease.whenClosed; + }); + + it('keeps a Windows helper and target as two independent native close obligations', async () => { + const h = harness('win32'); + const lease = await h.controller.reserve('pid'); + const target = child(); + lease.bindChild(target); + const closed = lease.stop(); + expect(h.request).toHaveBeenCalledExactlyOnceWith(40); + h.helper.emit('close', 0); + await Promise.resolve(); + expect(h.registry.activeOwners, 'WINDOWS_TARGET_JOIN').toBe(1); + target.emit('close', null); + await closed; + expect(h.registry.activeOwners).toBe(0); + expect(h.scheduled.size).toBe(0); + }); + + it('retains helper authority when target closes first and never repeats taskkill', async () => { + const h = harness('win32'); + const lease = await h.controller.reserve('group'); + const target = child(); + lease.bindChild(target); + const stopped = lease.stop(); + target.emit('close', null); + await Promise.resolve(); + expect(h.registry.activeOwners, 'WINDOWS_HELPER_JOIN').toBe(1); + void lease.stop(); + expect(h.request).toHaveBeenCalledTimes(1); + h.helper.emit('close', 0); + await stopped; + expect(h.registry.activeOwners).toBe(0); + }); + + it('preserves normal outcome when taskkill loses a race with actual cooperative root close', async () => { + const h = harness('win32'); + const lease = await h.controller.reserve('pid'); + const target = child(); + lease.bindChild(target); + void lease.stop(); + target.emit('close', 0); + h.helper.emit('close', 128); + await lease.whenClosed; + expect(h.registry.termination).toBeUndefined(); + expect(h.registry.observations.exitCode()).toBe(0); + expect(h.registry.activeOwners).toBe(0); + }); + + it('retains helper error as a failure and joins both actual native closes', async () => { + const h = harness('win32'); + const lease = await h.controller.reserve('pid'); + const target = child(); + lease.bindChild(target); + void lease.stop(); + h.helper.emit('error', new Error('private helper fault')); + expect(h.registry.observations.exitCode()).toBe(9); + expect(h.registry.activeOwners, 'WINDOWS_HELPER_ERROR_JOIN').toBe(1); + target.emit('close', null); + await Promise.resolve(); + expect(h.registry.activeOwners, 'WINDOWS_HELPER_ERROR_JOIN').toBe(1); + h.helper.emit('close', null); + await lease.whenClosed; + expect(h.registry.activeOwners).toBe(0); + }); + + it('retains a fixed infrastructure fault when taskkill fails while its owned root remains live', async () => { + const h = harness('win32'); + const lease = await h.controller.reserve('pid'); + const target = child(); + lease.bindChild(target); + void lease.stop(); + h.helper.emit('close', 1); + expect(h.registry.observations.exitCode()).toBe(9); + expect(h.registry.activeOwners).toBe(1); + expect(h.request).toHaveBeenCalledTimes(1); + target.emit('close', null); + await lease.whenClosed; + }); + + it('does not enter taskkill for a root whose exit precedes stdio close', async () => { + const h = harness('win32'); + const lease = await h.controller.reserve('pid'); + const target = child(); + lease.bindChild(target); + target.emit('exit', 0); + const stopped = lease.stop(); + expect(h.request, 'WINDOWS_ROOT_EXIT_REFUSAL').not.toHaveBeenCalled(); + expect(h.registry.activeOwners).toBe(1); + target.emit('close', 0); + await stopped; + }); + + it('performs immediate best-effort live-root checking before taskkill', async () => { + const h = harness('win32'); + h.signal.mockImplementation(() => { + const error = new Error('absent'); + Object.defineProperty(error, 'code', { value: 'ESRCH' }); + throw error; + }); + const lease = await h.controller.reserve('pid'); + const target = child(); + lease.bindChild(target); + void lease.stop(); + expect(h.request, 'WINDOWS_LIVE_ROOT_CHECK').not.toHaveBeenCalled(); + expect(h.registry.activeOwners).toBe(1); + target.emit('close', null); + await lease.whenClosed; + }); + + it('shortens an already-entered ordinary helper to later fatal and earlier signal deadlines', async () => { + const h = harness('win32'); + const lease = await h.controller.reserve('pid'); + const target = child(); + lease.bindChild(target); + void lease.stop(); + expect([...h.scheduled.values()]).toEqual([3000]); + h.advance(500); + void h.registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + // Ordinary helper's original limit is earlier than the newly observed fatal bound. + expect([...h.scheduled.values()].sort()).toEqual([3000, 3500]); + void h.registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 1000 }); + expect([...h.scheduled.values()], 'WINDOWS_CHANGED_BUDGET').toEqual([1000, 1000]); + h.advance(499); + expect(h.helperKill).not.toHaveBeenCalled(); + h.advance(1); + expect(h.helperKill).toHaveBeenCalled(); + expect(h.request).toHaveBeenCalledTimes(1); + expect(h.registry.activeOwners).toBe(1); // expiry never fabricates close + target.emit('close', null); + h.helper.emit('close', null); + await lease.whenClosed; + expect(h.registry.activeOwners).toBe(0); + }); + + it('does not renew an ordinary helper deadline on equal or later failures', async () => { + const h = harness('win32'); + const lease = await h.controller.reserve('pid'); + const target = child(); + lease.bindChild(target); + void lease.stop(); + h.advance(400); + void h.registry.terminate({ kind: 'infrastructure', code: 'output_unavailable' }); + h.advance(400); + void h.registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + expect([...h.scheduled.values()].sort(), 'WINDOWS_ORIGINAL_BOUND').toEqual([3000, 3400]); + target.emit('close', 0); + h.helper.emit('close', 0); + await lease.whenClosed; + }); + + it('at expiry performs finite existing requests without starting a helper or acknowledging closure', async () => { + const h = harness('win32'); + const lease = await h.controller.reserve('pid'); + const target = child(); + lease.bindChild(target); + const closing = h.registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 0 }); + expect(h.request).not.toHaveBeenCalled(); + expect(h.signal).toHaveBeenCalledWith(40, 'SIGKILL'); + expect(await closing).toMatchObject({ kind: 'deadline', unacknowledgedOwners: 1 }); + target.emit('close', null); + await lease.whenClosed; + }); + + it('rechecks the live budget when immediate liveness work synchronously expires it', async () => { + const h = harness('win32'); + const lease = await h.controller.reserve('pid'); + const target = child(); + lease.bindChild(target); + h.signal.mockImplementation((_pid, signal) => { + if (signal === 0) + void h.registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 0 }); + }); + void lease.stop(); + expect(h.request, 'WINDOWS_PREHELPER_BUDGET').not.toHaveBeenCalled(); + expect(h.registry.budget.remainingMs()).toBe(0); + expect(h.signal).toHaveBeenCalledWith(40, 'SIGKILL'); + expect(h.registry.activeOwners).toBe(1); + target.emit('close', null); + await lease.whenClosed; + }); + + it('finite exit cleanup kills only active owned resources and starts no taskkill helper', async () => { + const h = harness('win32'); + const lease = await h.controller.reserve('pid'); + const target = child(); + lease.bindChild(target); + h.exit(); + expect(h.signal.mock.calls).toEqual([ + [40, 0], + [40, 'SIGKILL'], + ]); + expect(h.request).not.toHaveBeenCalled(); + expect(h.registry.activeOwners).toBe(1); + target.emit('close', null); + await lease.whenClosed; + h.exit(); + expect(h.signal).toHaveBeenCalledTimes(2); + }); + + it('a finite exit failure records a fixed diagnosis without entering normal async shutdown', async () => { + const h = harness('win32'); + const lease = await h.controller.reserve('pid'); + const target = child(); + lease.bindChild(target); + h.signal.mockImplementation(() => { + throw new Error('native signal failure'); + }); + h.exit(); + expect(h.registry.termination, 'NATIVE_EXIT_NO_TERMINATE').toBeUndefined(); + expect(h.registry.observations.exitCode()).toBe(9); + expect(h.request).not.toHaveBeenCalled(); + expect(h.scheduled.size).toBe(0); + expect(h.registry.activeOwners).toBe(1); + target.emit('close', 0); + await lease.whenClosed; + }); + + it('finite exit still cancels an existing helper after target close without reauthorizing the root', async () => { + const h = harness('win32'); + const lease = await h.controller.reserve('pid'); + const target = child(); + lease.bindChild(target); + void lease.stop(); + target.emit('close', 0); + h.signal.mockClear(); + h.exit(); + expect(h.helperKill, 'NATIVE_EXIT_EXISTING_HELPER').toHaveBeenCalledTimes(1); + expect(h.signal).not.toHaveBeenCalled(); + expect(h.request).toHaveBeenCalledTimes(1); + expect(h.registry.activeOwners).toBe(1); + h.helper.emit('close', 0); + await lease.whenClosed; + }); + + it('retains SDK close independently from public cancellation and uses positive live PID only', async () => { + const h = harness(); + const observer = await h.controller.observeMcp(); + const nativeClosed = deferred(); + let closed = false; + const close = vi.fn(() => Promise.resolve()); + observer.attach({ + readPid: () => 70, + isClosed: () => closed, + whenClosed: nativeClosed.promise, + close, + }); + void h.registry.terminate({ kind: 'normal' }); + expect(close).toHaveBeenCalledTimes(1); + expect(h.signal.mock.calls, 'MCP_POSITIVE_PID').toEqual([ + [70, 0], + [70, 'SIGKILL'], + ]); + await Promise.resolve(); + expect(h.registry.activeOwners).toBe(1); + observer.abandonIfUnattached(); + expect(h.registry.activeOwners).toBe(1); + closed = true; + nativeClosed.resolve(); + await vi.waitFor(() => expect(h.registry.activeOwners).toBe(0)); + }); + + it('releases a bridge refusal before SDK attachment without losing an attached native obligation', async () => { + const h = harness(); + const observer = await h.controller.observeMcp(); + expect(h.registry.activeOwners).toBe(1); + observer.abandonIfUnattached(); + await vi.waitFor(() => expect(h.registry.activeOwners).toBe(0)); + expect(() => observer.beforeStart()).toThrow(); + expect(h.signal).not.toHaveBeenCalled(); + }); +}); + +describe('actual owned native child lifetime', () => { + it('registers before spawn, terminates a real owned child and joins native close', async () => { + const invocation = createCliInvocation(); + const controller = nativeProcessesForInvocation(invocation); + if (controller === undefined) throw new Error('missing explicit invocation'); + const lease = await controller.reserve('pid'); + expect(invocation.registry.activeOwners, 'NATIVE_REAL_PREENTRY').toBe(1); + const target = spawn( + process.execPath, + ['-e', 'process.stdout.write("ready");setInterval(()=>{},1000)'], + { + stdio: ['ignore', 'pipe', 'ignore'], + shell: false, + }, + ); + lease.bindChild(target); + let watchdogFired = false; + const watchdog = setTimeout(() => { + watchdogFired = true; + target.kill('SIGKILL'); + }, 5000); + try { + await new Promise((resolve, reject) => { + target.stdout?.once('data', () => resolve()); + target.once('error', reject); + }); + await lease.stop(); + expect(watchdogFired, 'NATIVE_OWNER_PID_CANCEL').toBe(false); + if (process.platform === 'win32') + expect(target.exitCode !== null || target.signalCode !== null).toBe(true); + else expect(target.signalCode).toBe('SIGKILL'); + expect(invocation.registry.activeOwners).toBe(0); + } finally { + clearTimeout(watchdog); + target.kill('SIGKILL'); + await lease.whenClosed; + } + }, 10000); + + it.runIf(process.platform !== 'win32')( + 'reaps an explicitly detached group when leader exit leaves inherited pipes open', + async () => { + const invocation = createCliInvocation(); + const controller = nativeProcessesForInvocation(invocation); + if (controller === undefined) throw new Error('missing explicit invocation'); + const lease = await controller.reserve('group'); + const target = spawn( + process.execPath, + [ + '-e', + [ + 'const {spawn}=require("node:child_process");', + 'const descendant=spawn(process.execPath,["-e","setInterval(()=>{},1000)"],{stdio:["ignore",1,2]});', + 'process.stdout.write("descendant "+descendant.pid+"\\n");', + 'descendant.unref();', + ].join(''), + ], + { detached: true, stdio: ['ignore', 'pipe', 'pipe'], shell: false }, + ); + lease.bindChild(target); + let closed = false; + let watchdogFired = false; + target.once('close', () => { + closed = true; + }); + const watchdog = setTimeout(() => { + watchdogFired = true; + if (!closed && target.pid !== undefined) process.kill(-target.pid, 'SIGKILL'); + }, 5000); + try { + await lease.whenClosed; + expect(watchdogFired, 'NATIVE_OWNER_GROUP_EXIT_REAP').toBe(false); + expect(target.exitCode).toBe(0); // leader completed naturally; its descendant held the inherited pipe + expect(closed).toBe(true); + expect(invocation.registry.activeOwners).toBe(0); + } finally { + clearTimeout(watchdog); + if (!closed && target.pid !== undefined) process.kill(-target.pid, 'SIGKILL'); + await lease.whenClosed; + } + }, + 10000, + ); +}); diff --git a/apps/cli/src/process/native-process-owner.ts b/apps/cli/src/process/native-process-owner.ts new file mode 100644 index 00000000..53b6e457 --- /dev/null +++ b/apps/cli/src/process/native-process-owner.ts @@ -0,0 +1,560 @@ +import type { ChildProcess } from 'node:child_process'; + +import type { StdioNativeLifetime, StdioNativeObserver } from '@relavium/mcp'; + +import { CliError } from './cli-error.js'; +import type { CliInvocation } from './invocation.js'; +import { + FATAL_TERMINATION_MS, + type InvocationRegistry, + type OwnerLease, + type OwnerRetirement, + type RemainingBudget, +} from './invocation-registry.js'; +import { + verifyWindowsTaskkillFacility, + WindowsTeardownUnavailableError, + type WindowsTaskkillFacility, +} from './windows-teardown.js'; + +/** The actual native surface; command result/error and native close are separate observations. */ +export interface NativeChildHandle { + readonly pid?: number | undefined; + readonly exitCode?: number | null | undefined; + readonly signalCode?: string | null | undefined; + on(event: 'error', listener: () => void): unknown; + on(event: 'exit', listener: () => void): unknown; + on(event: 'close', listener: (code: number | null) => void): unknown; + kill(signal: 'SIGKILL'): unknown; +} + +export interface NativeProcessReservation { + /** Recheck after every preparation await, immediately before the actual spawn. */ + readonly assertAdmitting: () => void; + /** Transfer a child that this creation actually entered, even if stop won during entry. */ + readonly bindChild: (child: NativeChildHandle) => void; + /** Only the creation owner can prove that construction refused without creating a child. */ + readonly abandon: () => void; + /** One cancellation operation; settlement waits actual target and already-created helper close. */ + readonly stop: () => Promise; + readonly whenClosed: Promise; +} + +export interface NativeProcessController { + readonly reserve: (kind: 'group' | 'pid') => Promise; + readonly observeMcp: () => Promise; +} + +export interface McpNativeReservation extends StdioNativeObserver { + /** The bridge calls this if startup refuses before the SDK ownership attachment. */ + readonly abandonIfUnattached: () => void; +} + +export interface NativeProcessPorts { + readonly platform: NodeJS.Platform; + readonly now: () => number; + readonly schedule: (milliseconds: number, fire: () => void) => () => void; + /** Positive PID only for liveness; a negative target is allowed solely for a created detached group. */ + readonly signal: (target: number, signal: 0 | 'SIGKILL') => void; + readonly verifyWindows: () => Promise; + readonly subscribeExit: (listener: () => void) => () => void; +} + +const unboundedBudget: RemainingBudget = Object.freeze({ + deadline: () => undefined, + remainingMs: () => undefined, +}); +const controllers = new WeakMap(); + +/** Explicit invocation identity, never an ambient current invocation or completed PID history. */ +export function nativeProcessesForInvocation( + invocation: CliInvocation | undefined, +): NativeProcessController | undefined { + if (invocation === undefined) return undefined; + let controller = controllers.get(invocation.registry); + if (controller === undefined) { + controller = createNativeProcessController(invocation.registry); + controllers.set(invocation.registry, controller); + } + return controller; +} + +/** + * Inert construction. Each actual creation reserves before facility verification/native entry. + * Without an invocation this supplies local tool cleanup only, never process-wide fatal ownership. + */ +export function createNativeProcessController( + registry?: InvocationRegistry, + ports: NativeProcessPorts = nativePorts(), +): NativeProcessController { + let facility: Promise | undefined; + let active = 0; + let removeExit: (() => void) | undefined; + const acquire = (): (() => void) => { + if (registry !== undefined && active === 0) + removeExit = ports.subscribeExit(() => registry.reapNativeAtExit()); + active++; + return () => { + active--; + if (active !== 0) return; + const remove = removeExit; + removeExit = undefined; + remove?.(); + }; + }; + const prepare = async (kind: 'group' | 'pid'): Promise => { + registry?.assertAdmitting(); + const release = acquire(); + let creation: NativeCreation; + try { + creation = new NativeCreation(kind, registry, ports, release); + } catch (cause) { + release(); + throw cause; + } + try { + if (ports.platform === 'win32') { + facility ??= ports.verifyWindows(); + creation.setFacility(await facility); + } + creation.assertAdmitting(); + return creation; + } catch (cause) { + creation.abandon(); + throw cause; + } + }; + return Object.freeze({ + reserve: async (kind: 'group' | 'pid') => (await prepare(kind)).reservation(), + observeMcp: async () => { + const creation = await prepare('pid'); + return Object.freeze({ + attach: (lifetime: StdioNativeLifetime) => creation.bindMcp(lifetime), + beforeStart: () => creation.assertAdmitting(), + abandonIfUnattached: () => creation.abandonIfUnattached(), + }); + }, + }); +} + +interface Target { + readonly readPid: () => number | undefined; + readonly isClosed: () => boolean; + readonly isExited: () => boolean; + readonly cancel?: (() => void) | undefined; + readonly cooperativeClose?: (() => Promise) | undefined; +} + +class NativeCreation { + readonly #kind: 'group' | 'pid'; + readonly #registry: InvocationRegistry | undefined; + readonly #ports: NativeProcessPorts; + readonly #budget: RemainingBudget; + readonly #released: () => void; + readonly #lease: OwnerLease | undefined; + readonly #closed: Promise; + readonly #resolveClosed: () => void; + readonly #resolveRetired: (retirement: OwnerRetirement) => void; + #target: Target | undefined; + #facility: WindowsTaskkillFacility | undefined; + #sealed = false; + #retired = false; + #targetClosed = false; + #groupPid: number | undefined; + #cancelEntered = false; + #cooperativeEntered = false; + #helper: ChildProcess | undefined; + #helperEntered = false; + #helperClosed = true; + #helperFailed = false; + #helperDeadline: number | undefined; + #timerDeadline: number | undefined; + #removeHelperTimer: (() => void) | undefined; + #failure = false; + #exitReaping = false; + + constructor( + kind: 'group' | 'pid', + registry: InvocationRegistry | undefined, + ports: NativeProcessPorts, + released: () => void, + ) { + this.#kind = kind; + this.#registry = registry; + this.#ports = ports; + this.#released = released; + this.#budget = registry?.budget ?? unboundedBudget; + let resolveClosed = (): void => undefined; + this.#closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + this.#resolveClosed = resolveClosed; + let resolveRetired: (retirement: OwnerRetirement) => void = () => undefined; + const retired = new Promise((resolve) => { + resolveRetired = resolve; + }); + this.#resolveRetired = resolveRetired; + this.#lease = registry?.register({ + kind: + ports.platform === 'win32' + ? 'windows-live-root' + : kind === 'group' + ? 'native-group' + : 'native-pid', + retired, + stop: () => this.#requestStop(), + join: () => this.#closed, + mayReap: () => this.#eligible() || (this.#helper !== undefined && !this.#helperClosed), + reap: () => this.#requestStop(), + budgetChanged: () => this.#updateHelperBudget(), + exitReap: () => this.#finiteExitReap(), + classifyFailure: () => 'cli_internal', + }); + } + + setFacility(facility: WindowsTaskkillFacility): void { + this.#facility = facility; + } + + assertAdmitting(): void { + this.#lease?.assertAdmitting(); + if (this.#sealed || this.#retired) throw new CliError('cli_internal', ''); + } + + reservation(): NativeProcessReservation { + return Object.freeze({ + assertAdmitting: () => this.assertAdmitting(), + bindChild: (child: NativeChildHandle) => this.bindChild(child), + abandon: () => this.abandon(), + stop: () => { + try { + this.#requestStop(); + } catch { + this.#failed(); + } + return this.#closed; + }, + whenClosed: this.#closed, + }); + } + + abandon(): void { + if (this.#target !== undefined) throw new CliError('cli_internal', ''); + this.#sealed = true; + this.#targetClosed = true; + this.#finish(); + } + + abandonIfUnattached(): void { + if (this.#target === undefined) this.abandon(); + } + + bindChild(child: NativeChildHandle): void { + let closed = false; + let exited = false; + const pid = positivePid(child.pid); + this.#bind({ + readPid: () => pid, + isClosed: () => closed, + isExited: () => exited || child.exitCode != null || child.signalCode != null, + cancel: () => { + child.kill('SIGKILL'); + }, + }); + // An error diagnoses the command, not native closure. Callers retain their public error contract. + child.on('error', () => undefined); + child.on('exit', () => { + exited = true; + if (this.#kind === 'group' && this.#ports.platform !== 'win32') this.#signalGroup(); + }); + child.on('close', () => { + closed = true; + if (this.#kind === 'group' && this.#ports.platform !== 'win32') this.#signalGroup(); + this.#targetClosed = true; + this.#finish(); + }); + if (this.#sealed) this.#requestStop(); + } + + bindMcp(lifetime: StdioNativeLifetime): void { + this.#bind({ + readPid: lifetime.readPid, + isClosed: lifetime.isClosed, + // The public SDK has no exit event. Immediate liveness below supplies the explicit best-effort limit. + isExited: lifetime.isClosed, + cooperativeClose: lifetime.close, + }); + void lifetime.whenClosed.then( + () => { + this.#targetClosed = true; + this.#finish(); + }, + () => this.#failed(), + ); + if (this.#sealed) this.#requestStop(); + } + + #bind(target: Target): void { + if (this.#target !== undefined || this.#retired) throw new CliError('cli_internal', ''); + this.#target = target; + if (this.#kind === 'group' && this.#ports.platform !== 'win32') + this.#groupPid = positivePid(target.readPid()); + } + + #eligible(): boolean { + if (this.#retired || this.#target === undefined) return false; + if (this.#kind === 'group' && this.#ports.platform !== 'win32') + return this.#groupPid !== undefined; + return !this.#targetClosed && !this.#target.isClosed() && !this.#target.isExited(); + } + + #requestStop(): void { + this.#sealed = true; + if (this.#target === undefined || this.#retired) return; + if (!this.#cancelEntered) { + this.#cancelEntered = true; + if (this.#ports.platform === 'win32') this.#startWindowsHelper(); + else if (this.#kind === 'group') this.#signalGroup(); + else this.#signalPid(); + } + if (!this.#cooperativeEntered && this.#target.cooperativeClose !== undefined) { + this.#cooperativeEntered = true; + try { + void this.#target.cooperativeClose().catch(() => this.#failed()); + } catch { + this.#failed(); + } + } + } + + #signalGroup(): void { + const pid = this.#groupPid; + if (pid === undefined || this.#retired) return; + try { + this.#ports.signal(-pid, 'SIGKILL'); + } catch (cause) { + if (!absentTarget(cause)) this.#failed(); + } + } + + #livePid(): number | undefined { + if (!this.#eligible()) return undefined; + const pid = positivePid(this.#target?.readPid()); + if (pid === undefined) return undefined; + try { + this.#ports.signal(pid, 0); + return pid; + } catch (cause) { + if (!absentTarget(cause)) this.#failed(); + return undefined; + } + } + + #signalPid(): void { + const pid = this.#livePid(); + if (pid === undefined) { + // A synchronously returned child can have no PID when native spawn later emits error/close. + // Its own handle remains creation-owned; never substitute a guessed or historical PID. + if (this.#eligible() && positivePid(this.#target?.readPid()) === undefined) { + try { + this.#target?.cancel?.(); + } catch { + this.#failed(); + } + } + return; + } + try { + this.#ports.signal(pid, 'SIGKILL'); + } catch (cause) { + if (!absentTarget(cause)) this.#failed(); + } + } + + #finiteExitReap(): void { + // No helper/async close/retirement/DB ACK can be started in a process exit callback. + this.#exitReaping = true; + try { + if (this.#kind === 'group' && this.#ports.platform !== 'win32') this.#signalGroup(); + else this.#signalPid(); + if (this.#helper !== undefined && !this.#helperClosed) this.#cancelHelper(); + } finally { + this.#exitReaping = false; + } + } + + #startWindowsHelper(): void { + const remaining = this.#remaining(); + if (remaining === 0) { + this.#signalPid(); + return; + } + const pid = this.#livePid(); + if (pid === undefined) return; + // Reserve helper lifetime before entering the facility: registry admission may already be sealed. + if (this.#helperEntered) return; + this.#helperEntered = true; + this.#helperClosed = false; + try { + if (this.#facility === undefined) throw new WindowsTeardownUnavailableError(); + this.#helperDeadline = this.#ports.now() + FATAL_TERMINATION_MS; + if (!Number.isFinite(this.#helperDeadline)) throw new CliError('cli_internal', ''); + // Liveness/clock ports can synchronously tighten or expire admission. The initial sample + // cannot authorize a new helper after that reentry; the final check uses the same live budget. + if (this.#remaining() === 0 || !this.#eligible()) { + this.#helperClosed = true; + this.#signalPid(); + this.#finish(); + return; + } + const helper = this.#facility.spawn(pid); + this.#helper = helper; + helper.on('error', () => { + this.#helperFailed = true; + this.#failed(); + this.#cancelHelper(); + this.#signalPid(); + }); + helper.on('close', (code) => { + this.#helperClosed = true; + this.#clearHelperTimer(); + // The root may finish cooperatively between the live check and taskkill's request. + // A nonzero result with a proved-closed/now-absent root is not a new infrastructure fault. + if (code !== 0 && !this.#helperFailed && this.#livePid() !== undefined) { + this.#failed(); + this.#signalPid(); + } + this.#finish(); + }); + this.#updateHelperBudget(); + } catch { + // A synchronous native factory refusal created no helper. If binding failed after spawn, + // retain uncertainty and cancellation authority instead of fabricating its native close. + if (this.#helper === undefined) this.#helperClosed = true; + else this.#cancelHelper(); + this.#failed(); + this.#signalPid(); + this.#finish(); + } + } + + #remaining(): number | undefined { + try { + const remaining = this.#budget.remainingMs(); + if (remaining === undefined || (Number.isFinite(remaining) && remaining >= 0)) + return remaining; + } catch { + // A failed live budget never authorizes a fresh helper. + } + this.#failed(); + return 0; + } + + #updateHelperBudget(): void { + if (this.#helper === undefined || this.#helperClosed) return; + try { + const now = this.#ports.now(); + const remaining = this.#remaining(); + const deadline = Math.min( + this.#helperDeadline ?? now, + remaining === undefined ? Infinity : now + remaining, + ); + if (!Number.isFinite(now) || !Number.isFinite(deadline)) + throw new CliError('cli_internal', ''); + if (deadline <= now) { + this.#clearHelperTimer(); + this.#cancelHelper(); + // Cancelling the helper is not target close. Make only the already-owned finite root request. + this.#signalPid(); + return; + } + if (this.#timerDeadline !== undefined && this.#timerDeadline <= deadline) return; + this.#clearHelperTimer(); + this.#timerDeadline = deadline; + this.#removeHelperTimer = this.#ports.schedule(deadline - now, () => { + this.#clearHelperTimer(); + this.#cancelHelper(); + this.#signalPid(); + this.#failed(); + }); + } catch { + this.#cancelHelper(); + this.#failed(); + } + } + + #cancelHelper(): void { + if (this.#helper === undefined || this.#helperClosed) return; + try { + this.#helper.kill('SIGKILL'); + } catch { + this.#failed(); + } + } + + #clearHelperTimer(): void { + const remove = this.#removeHelperTimer; + this.#removeHelperTimer = undefined; + this.#timerDeadline = undefined; + try { + remove?.(); + } catch { + this.#failed(); + } + } + + #failed(): void { + if (this.#failure) return; + this.#failure = true; + if (this.#exitReaping) this.#registry?.observations.observeFailure('cli_internal'); + else if (this.#registry !== undefined) + void this.#registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + } + + #finish(): void { + if (this.#retired || !this.#targetClosed || !this.#helperClosed) return; + this.#retired = true; + this.#clearHelperTimer(); + this.#groupPid = undefined; + try { + this.#released(); + } catch { + this.#failed(); + } + this.#resolveRetired( + Object.freeze( + this.#failure ? { kind: 'settled', failure: 'cli_internal' } : { kind: 'settled' }, + ), + ); + this.#resolveClosed(); + } +} + +function positivePid(value: number | undefined): number | undefined { + return value !== undefined && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} + +/** Owned native signal errors only; never consult an arbitrary nested cause or interpolate errno. */ +function absentTarget(cause: unknown): boolean { + if (!(cause instanceof Error)) return false; + return Object.getOwnPropertyDescriptor(cause, 'code')?.value === 'ESRCH'; +} + +function nativePorts(): NativeProcessPorts { + return { + platform: process.platform, + now: () => performance.now(), + schedule: (milliseconds, fire) => { + const timer = setTimeout(fire, milliseconds); + return () => clearTimeout(timer); + }, + signal: (target, signal) => { + process.kill(target, signal); + }, + verifyWindows: verifyWindowsTaskkillFacility, + subscribeExit: (listener) => { + process.on('exit', listener); + return () => process.off('exit', listener); + }, + }; +} diff --git a/apps/cli/src/process/windows-teardown.test.ts b/apps/cli/src/process/windows-teardown.test.ts new file mode 100644 index 00000000..bb8ee7bb --- /dev/null +++ b/apps/cli/src/process/windows-teardown.test.ts @@ -0,0 +1,317 @@ +import { ChildProcess } from 'node:child_process'; +import type { EventEmitter } from 'node:events'; +import { constants } from 'node:fs'; +import { RelaviumError } from '@relavium/shared/relavium-error'; +import { describe, expect, it, vi } from 'vitest'; + +import { + verifyWindowsTaskkillFacility, + WINDOWS_TEARDOWN_UNAVAILABLE_CODE, + WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE, + WindowsTeardownUnavailableError, + type WindowsTaskkillVerificationPorts, +} from './windows-teardown.js'; + +const ROOT = 'C:\\Windows'; +const SYSTEM_DIRECTORY = `${ROOT}\\System32`; +const EXECUTABLE = `${SYSTEM_DIRECTORY}\\taskkill.exe`; + +type FileKind = 'directory' | 'file' | 'symlink' | 'other'; + +/** Windows paths/filesystem/spawn are explicitly simulated. No native taskkill executes here. */ +function simulatedWindows() { + const kinds = new Map([ + [ROOT, 'directory'], + [SYSTEM_DIRECTORY, 'directory'], + [EXECUTABLE, 'file'], + ]); + const canonicalPaths = new Map(); + const helper = new ChildProcess(); + const spawn = vi.fn(() => helper); + const stat = vi.fn((path) => { + const kind = kinds.get(path); + if (kind === undefined) return Promise.reject(new Error('simulated missing file')); + return Promise.resolve({ + isDirectory: () => kind === 'directory', + isFile: () => kind === 'file', + isSymbolicLink: () => kind === 'symlink', + }); + }); + const realpath = vi.fn((path) => + Promise.resolve(canonicalPaths.get(path) ?? path), + ); + const access = vi.fn(() => Promise.resolve()); + const ports: WindowsTaskkillVerificationPorts = { + platform: 'win32', + systemRoot: ROOT, + lstat: stat, + realpath, + access, + spawn, + }; + return { ports, kinds, canonicalPaths, helper, spawn, stat, realpath, access }; +} + +describe('Windows taskkill facility (simulated Windows native ports)', () => { + it('imports lazily without filesystem access, helper spawn or process listeners', async () => { + const nativeAccess = vi.fn(); + const nativeLstat = vi.fn(); + const nativeRealpath = vi.fn(); + const nativeSpawn = vi.fn(); + const processEvents: EventEmitter = process; + const events = processEvents.eventNames(); + const listeners = events.map((event) => [event, processEvents.listeners(event)]); + vi.resetModules(); + vi.doMock('node:fs/promises', () => ({ + access: nativeAccess, + lstat: nativeLstat, + realpath: nativeRealpath, + })); + vi.doMock('node:child_process', () => ({ spawn: nativeSpawn })); + try { + const imported = await import('./windows-teardown.js'); + expect(nativeAccess).not.toHaveBeenCalled(); + expect(nativeLstat).not.toHaveBeenCalled(); + expect(nativeRealpath).not.toHaveBeenCalled(); + expect(nativeSpawn).not.toHaveBeenCalled(); + expect(processEvents.eventNames()).toEqual(events); + expect(events.map((event) => [event, processEvents.listeners(event)])).toEqual(listeners); + const simulated = simulatedWindows(); + await imported.verifyWindowsTaskkillFacility(simulated.ports); + expect(simulated.stat).toHaveBeenCalledTimes(3); + expect(simulated.spawn).not.toHaveBeenCalled(); + } finally { + vi.doUnmock('node:fs/promises'); + vi.doUnmock('node:child_process'); + vi.resetModules(); + } + }); + + it('verifies the fixed installation path before producing an inert frozen capability', async () => { + const simulated = simulatedWindows(); + const facility = await verifyWindowsTaskkillFacility(simulated.ports); + expect(simulated.stat.mock.calls).toEqual([[ROOT], [SYSTEM_DIRECTORY], [EXECUTABLE]]); + expect(simulated.realpath.mock.calls).toEqual([[ROOT], [SYSTEM_DIRECTORY], [EXECUTABLE]]); + expect(simulated.access).toHaveBeenCalledExactlyOnceWith(EXECUTABLE, constants.X_OK); + expect(simulated.spawn).not.toHaveBeenCalled(); + expect(Object.isFrozen(facility)).toBe(true); + expect(Object.keys(facility)).toEqual(['spawn']); + }); + + it('uses only absolute taskkill, one decimal PID and fixed shell-free ignored-output options', async () => { + const simulated = simulatedWindows(); + const facility = await verifyWindowsTaskkillFacility(simulated.ports); + expect(facility.spawn(1234)).toBe(simulated.helper); + expect(simulated.spawn).toHaveBeenCalledExactlyOnceWith( + EXECUTABLE, + ['/PID', '1234', '/T', '/F'], + { shell: false, windowsHide: true, stdio: 'ignore' }, + ); + // The exact unmodified helper is returned. Neither request success nor error claims target close. + expect(simulated.helper.eventNames()).toEqual([]); + }); + + it('captures the verified executable and spawn port, without retaining mutable env/port decisions', async () => { + const simulated = simulatedWindows(); + const facility = await verifyWindowsTaskkillFacility(simulated.ports); + const replacement = vi.fn(); + Object.defineProperty(simulated.ports, 'systemRoot', { value: 'D:\\hostile-root' }); + Object.defineProperty(simulated.ports, 'spawn', { value: replacement }); + facility.spawn(1); + facility.spawn(4321); + expect(simulated.spawn.mock.calls.map(([path, argv]) => [path, argv])).toEqual([ + [EXECUTABLE, ['/PID', '1', '/T', '/F']], + [EXECUTABLE, ['/PID', '4321', '/T', '/F']], + ]); + expect(replacement).not.toHaveBeenCalled(); + expect(simulated.stat).toHaveBeenCalledTimes(3); + }); + + it.each([ + 0, + -1, + 0.5, + NaN, + Infinity, + -Infinity, + Number.MAX_SAFE_INTEGER + 1, + '123 /IM *', + '123', + null, + undefined, + {}, + [], + 1n, + Symbol('pid'), + ])('refuses malformed PID %s before any helper spawn', async (pid) => { + const simulated = simulatedWindows(); + const facility = await verifyWindowsTaskkillFacility(simulated.ports); + expect(() => { + Reflect.apply(facility.spawn, undefined, [pid]); + }).toThrow(WindowsTeardownUnavailableError); + expect(simulated.spawn).not.toHaveBeenCalled(); + }); + + it.each([ + undefined, + '', + 'Windows', + '\\Windows', + 'C:Windows', + '\\\\server\\Windows', + '\\\\?\\C:\\Windows', + '\\\\.\\C:\\Windows', + 'C:\\Windows\\..\\elsewhere', + 'C:\\Windows\\.', + 'C:\\Windows:stream', + 'C:\\Windows ', + 'C:\\Windows.', + 'C:\\Windows\\\\child', + 'C:\\Windows\\NUL', + 'C:\\Windows\\COM1.txt', + 'C:\\Windows\u0000', + 'C:\\Windows\\*', + ])('rejects nonordinary installation root %s before filesystem work', async (systemRoot) => { + const simulated = simulatedWindows(); + await expect( + verifyWindowsTaskkillFacility({ ...simulated.ports, systemRoot }), + ).rejects.toBeInstanceOf(WindowsTeardownUnavailableError); + expect(simulated.stat).not.toHaveBeenCalled(); + expect(simulated.spawn).not.toHaveBeenCalled(); + }); + + it.each(['linux', 'darwin'] satisfies NodeJS.Platform[])( + 'rejects %s platform without filesystem work or spawning', + async (platform) => { + const simulated = simulatedWindows(); + await expect( + verifyWindowsTaskkillFacility({ ...simulated.ports, platform }), + ).rejects.toBeInstanceOf(WindowsTeardownUnavailableError); + expect(simulated.stat).not.toHaveBeenCalled(); + expect(simulated.spawn).not.toHaveBeenCalled(); + }, + ); + + it('accepts normal Windows casing and separators while preserving canonical executable spelling', async () => { + const simulated = simulatedWindows(); + simulated.canonicalPaths.set(ROOT, 'c:\\WINDOWS'); + simulated.kinds.set('c:\\WINDOWS\\System32', 'directory'); + simulated.canonicalPaths.set('c:\\WINDOWS\\System32', 'C:\\Windows\\SYSTEM32'); + simulated.kinds.set('C:\\Windows\\SYSTEM32\\taskkill.exe', 'file'); + simulated.canonicalPaths.set('C:\\Windows\\SYSTEM32\\taskkill.exe', EXECUTABLE.toUpperCase()); + const facility = await verifyWindowsTaskkillFacility({ + ...simulated.ports, + systemRoot: 'C:/Windows/', + }); + facility.spawn(2222); + expect(simulated.spawn.mock.calls[0]?.[0]).toBe(EXECUTABLE.toUpperCase()); + }); + + it.each([ + [ROOT, 'C:\\Elsewhere'], + [SYSTEM_DIRECTORY, 'C:\\Windows\\RedirectedSystem32'], + [SYSTEM_DIRECTORY, 'D:\\Windows\\System32'], + [EXECUTABLE, 'C:\\Windows\\taskkill.exe'], + [EXECUTABLE, 'C:\\Windows-other\\System32\\taskkill.exe'], + [EXECUTABLE, 'C:\\Windows\\System32\\other.exe'], + [EXECUTABLE, '\\\\?\\C:\\Windows\\System32\\taskkill.exe'], + [EXECUTABLE, 'C:\\Windows\\System32\\taskkill.exe:stream'], + ])('rejects redirected/nonordinary canonical path for %s', async (path, canonical) => { + const simulated = simulatedWindows(); + simulated.canonicalPaths.set(path, canonical); + await expect(verifyWindowsTaskkillFacility(simulated.ports)).rejects.toBeInstanceOf( + WindowsTeardownUnavailableError, + ); + expect(simulated.spawn).not.toHaveBeenCalled(); + }); + + it.each([ + [ROOT, 'file'], + [ROOT, 'symlink'], + [SYSTEM_DIRECTORY, 'file'], + [SYSTEM_DIRECTORY, 'symlink'], + [EXECUTABLE, 'directory'], + [EXECUTABLE, 'symlink'], + [EXECUTABLE, 'other'], + ] satisfies [string, FileKind][])( + 'rejects nonordinary file kind at %s (%s)', + async (path, kind) => { + const simulated = simulatedWindows(); + simulated.kinds.set(path, kind); + await expect(verifyWindowsTaskkillFacility(simulated.ports)).rejects.toBeInstanceOf( + WindowsTeardownUnavailableError, + ); + expect(simulated.spawn).not.toHaveBeenCalled(); + }, + ); + + it.each([ROOT, SYSTEM_DIRECTORY, EXECUTABLE])( + 'refuses missing %s without spawning', + async (path) => { + const simulated = simulatedWindows(); + simulated.kinds.delete(path); + await expect(verifyWindowsTaskkillFacility(simulated.ports)).rejects.toBeInstanceOf( + WindowsTeardownUnavailableError, + ); + expect(simulated.spawn).not.toHaveBeenCalled(); + }, + ); + + it.each(['lstat', 'realpath', 'access'] as const)( + 'wraps %s failure with a fixed public refusal while retaining internal cause identity', + async (operation) => { + const simulated = simulatedWindows(); + const cause = new Error('secret-path argv env native-cause-stack'); + Object.defineProperty(simulated.ports, operation, { + value: vi.fn().mockRejectedValue(cause), + }); + const error: unknown = await verifyWindowsTaskkillFacility(simulated.ports).catch( + (failure: unknown) => failure, + ); + expect(error).toBeInstanceOf(RelaviumError); + expect(error).toBeInstanceOf(WindowsTeardownUnavailableError); + if (!(error instanceof WindowsTeardownUnavailableError)) + throw new Error('missing typed refusal'); + expect(error.code).toBe(WINDOWS_TEARDOWN_UNAVAILABLE_CODE); + expect(error.message).toBe(WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE); + expect(error.message).not.toContain('secret'); + expect(error.cause).toBe(cause); + expect(simulated.spawn).not.toHaveBeenCalled(); + }, + ); + + it('leaves synchronous native spawn failures to the caller without inventing helper or target close', async () => { + const simulated = simulatedWindows(); + const cause = new Error('simulated native spawn failure'); + simulated.spawn.mockImplementation(() => { + throw cause; + }); + const facility = await verifyWindowsTaskkillFacility(simulated.ports); + expect(() => facility.spawn(3456)).toThrow(cause); + expect(simulated.spawn).toHaveBeenCalledTimes(1); + expect(simulated.helper.eventNames()).toEqual([]); + }); +}); + +describe('Windows taskkill facility (actual host verification only)', () => { + it.skipIf(process.platform !== 'win32')( + 'verifies the real launching installation with a hostile PATH shim never consulted', + async () => { + const savedPath = process.env['PATH']; + const savedPathExt = process.env['PATHEXT']; + try { + // The real FS verifier must succeed without PATH/PATHEXT. This executes no taskkill and + // targets no PID. Owned-child/helper-close integration belongs to the native owner suite. + process.env['PATH'] = 'Z:\\relavium-hostile-taskkill-shim'; + process.env['PATHEXT'] = '.CMD;.BAT'; + const facility = await verifyWindowsTaskkillFacility(); + expect(Object.isFrozen(facility)).toBe(true); + } finally { + if (savedPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = savedPath; + if (savedPathExt === undefined) delete process.env['PATHEXT']; + else process.env['PATHEXT'] = savedPathExt; + } + }, + ); +}); diff --git a/apps/cli/src/process/windows-teardown.ts b/apps/cli/src/process/windows-teardown.ts new file mode 100644 index 00000000..570659d3 --- /dev/null +++ b/apps/cli/src/process/windows-teardown.ts @@ -0,0 +1,153 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { constants } from 'node:fs'; +import { access, lstat, realpath } from 'node:fs/promises'; +import { win32 } from 'node:path'; +import { RelaviumError } from '@relavium/shared/relavium-error'; + +export const WINDOWS_TEARDOWN_UNAVAILABLE_CODE = 'windows_teardown_unavailable'; +export const WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE = + 'Windows subprocess teardown is unavailable. Restore the Windows system taskkill facility before retrying.'; + +/** Admission refusal only. The host maps this fact through its existing CLI error authority. */ +export class WindowsTeardownUnavailableError extends RelaviumError { + readonly code = WINDOWS_TEARDOWN_UNAVAILABLE_CODE; + + constructor(options?: { readonly cause?: unknown }) { + super( + WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE, + options?.cause === undefined ? undefined : { cause: options.cause }, + ); + this.name = 'WindowsTeardownUnavailableError'; + } +} + +export interface WindowsTaskkillFacility { + /** + * Request only: the caller owns this exact helper's error/close and the target's actual close. + * Immediately before entry, the caller must check creation ownership, root liveness and its + * live remaining budget. This capability stores no PID, ownership or liveness decision. + */ + readonly spawn: (pid: number) => ChildProcess; +} + +interface WindowsFileKind { + isDirectory(): boolean; + isFile(): boolean; + isSymbolicLink(): boolean; +} + +interface WindowsTaskkillSpawnOptions { + readonly shell: false; + readonly windowsHide: true; + readonly stdio: 'ignore'; +} + +/** Explicit native ports for simulated Windows tests; product callers use the defaults. */ +export interface WindowsTaskkillVerificationPorts { + readonly platform: NodeJS.Platform; + readonly systemRoot: string | undefined; + readonly lstat: (path: string) => Promise; + readonly realpath: (path: string) => Promise; + readonly access: (path: string, mode: number) => Promise; + readonly spawn: ( + executable: string, + argv: readonly string[], + options: WindowsTaskkillSpawnOptions, + ) => ChildProcess; +} + +/** + * Lazily verify the fixed SystemRoot/System32/taskkill.exe facility before native admission. + * Rejects with WindowsTeardownUnavailableError, with a fixed content-free public message. + * There is no import-time OS work, cache, listener or subprocess. The launching SystemRoot + * and installation are trusted; canonical checks do not defeat same-user filesystem races + * or attest an OS binary. Windows X_OK is equivalent to F_OK; native execution can still fail. + */ +export async function verifyWindowsTaskkillFacility( + ports: WindowsTaskkillVerificationPorts = nativePorts(), +): Promise { + try { + const root = ordinaryWindowsPath(ports.systemRoot); + if (ports.platform !== 'win32' || root === undefined) + throw new WindowsTeardownUnavailableError(); + + const canonicalRoot = await verifyPath(root, 'directory', ports); + const systemDirectory = win32.join(canonicalRoot, 'System32'); + const canonicalSystemDirectory = await verifyPath(systemDirectory, 'directory', ports); + const executable = await verifyPath( + win32.join(canonicalSystemDirectory, 'taskkill.exe'), + 'file', + ports, + ); + await ports.access(executable, constants.X_OK); + + // Retain only the verified fixed path and this native port, never the mutable input object. + const spawnHelper = ports.spawn; + return Object.freeze({ + spawn: (pid: number): ChildProcess => { + // Integers outside the safe range would stringify ambiguously; no coercion or selectors. + if (!Number.isSafeInteger(pid) || pid <= 0) throw new WindowsTeardownUnavailableError(); + return spawnHelper(executable, ['/PID', pid.toString(10), '/T', '/F'], { + shell: false, + windowsHide: true, + stdio: 'ignore', + }); + }, + }); + } catch (error) { + if (error instanceof WindowsTeardownUnavailableError) throw error; + // Native detail remains an internal cause; it is never interpolated into the public refusal. + throw new WindowsTeardownUnavailableError({ cause: error }); + } +} + +function nativePorts(): WindowsTaskkillVerificationPorts { + return { + platform: process.platform, + systemRoot: process.env['SystemRoot'], + lstat, + realpath, + access, + spawn, + }; +} + +async function verifyPath( + expected: string, + kind: 'directory' | 'file', + ports: WindowsTaskkillVerificationPorts, +): Promise { + const metadata = await ports.lstat(expected); + if ( + metadata.isSymbolicLink() || + (kind === 'directory' ? !metadata.isDirectory() : !metadata.isFile()) + ) + throw new WindowsTeardownUnavailableError(); + const canonical = ordinaryWindowsPath(await ports.realpath(expected)); + // Exact component equality also rejects in-root redirection and prefix lookalikes. Windows + // ordinarily compares these installation paths case-insensitively; preserve realpath's spelling. + if (canonical === undefined || canonical.toLowerCase() !== expected.toLowerCase()) + throw new WindowsTeardownUnavailableError(); + return canonical; +} + +/** Drive-absolute ordinary paths only: no UNC/device namespace, traversal, ADS or Win32 aliases. */ +function ordinaryWindowsPath(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const path = value.replaceAll('/', '\\'); + if (!/^[a-z]:\\/i.test(path)) return undefined; + const components = path.slice(3).split('\\'); + if (components.at(-1) === '') components.pop(); + if ( + components.some( + (component) => + component.length === 0 || + /[<>:"|?*]/u.test(component) || + Array.from(component).some((character) => character.charCodeAt(0) < 32) || + /[. ]$/u.test(component) || + /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(component), + ) + ) + return undefined; + return win32.normalize(path).replace(/(?('node:child_process'); +afterEach(() => { + vi.mocked(spawn).mockReset(); + vi.mocked(spawn).mockImplementation(native.spawn); +}); + +describe('foreground editor actual native lifetime', () => { + it('requests cancellation on a real native error and retains ownership after the public rejection', async () => { + const invocation = createCliInvocation(); + const nativeProcesses = nativeProcessesForInvocation(invocation); + if (nativeProcesses === undefined) throw new Error('explicit invocation missing'); + let child: ChildProcess | undefined; + let closed = false; + let acknowledgeReady = (): void => undefined; + let acknowledgeClosed = (): void => undefined; + const ready = new Promise((resolve) => { + acknowledgeReady = resolve; + }); + const whenClosed = new Promise((resolve) => { + acknowledgeClosed = resolve; + }); + vi.mocked(spawn).mockImplementationOnce((command, args, options) => { + // Keep the exact production binary/argv/shell/detached choices. Capture a pipe only to acknowledge + // the inert child's entry instead of opening a real terminal editor in the test runner. + const created = native.spawn(command, args, { + ...options, + stdio: ['ignore', 'pipe', 'ignore'], + }); + child = created; + created.stdout?.once('data', () => acknowledgeReady()); + created.once('close', () => { + closed = true; + acknowledgeClosed(); + }); + return created; + }); + const getChild = (): ChildProcess => { + if (child === undefined) throw new Error('fixture native entry missing'); + return child; + }; + const result = createNodeSpawnEditor(nativeProcesses)( + process.execPath, + ['-e', 'process.stdout.write("ready");setInterval(()=>{},1000)'], + 'synthetic-unused-file', + ).catch((cause: unknown) => cause); + let watchdogFired = false; + const watchdog = setTimeout(() => { + watchdogFired = true; + getChild().kill('SIGKILL'); + }, 5000); + try { + await ready; + expect(invocation.registry.activeOwners).toBe(1); + const fault = new Error('synthetic owned native fault'); + getChild().emit('error', fault); + expect(await result).toBe(fault); + expect(invocation.registry.activeOwners, 'EDITOR_ERROR_NOT_NATIVE_ACK').toBe(1); + await whenClosed; + const retirement = await invocation.registry.terminate({ kind: 'normal' }); + expect(retirement).toEqual({ kind: 'settled', unacknowledgedOwners: 0 }); + expect(invocation.registry.activeOwners).toBe(0); + expect(watchdogFired, 'EDITOR_NATIVE_ERROR_CANCEL').toBe(false); + } finally { + clearTimeout(watchdog); + if (!closed) getChild().kill('SIGKILL'); + await whenClosed; + await result; + } + }, 10000); +}); diff --git a/apps/cli/src/render/editor.test.ts b/apps/cli/src/render/editor.test.ts index 1eb9afd8..6cce529f 100644 --- a/apps/cli/src/render/editor.test.ts +++ b/apps/cli/src/render/editor.test.ts @@ -3,6 +3,11 @@ import { tmpdir } from 'node:os'; import { dirname } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; +import { + WindowsTeardownUnavailableError, + WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE, +} from '../process/windows-teardown.js'; + /** A switchable `writeFile` fault, so the "a partial write must not strand the conversation" path is driven for real * (an ENOSPC/EIO cannot be provoked portably). Everything else in `node:fs/promises` stays real. */ const fsFault = vi.hoisted(() => ({ @@ -169,6 +174,17 @@ describe('openInEditor', () => { expect(trace).toContain('dispose'); // the conversation is not left on disk }); + it('surfaces the fixed Windows facility remedy and disposes the transcript after admission refusal', async () => { + const { deps, trace } = harness({ + spawnThrows: new WindowsTeardownUnavailableError({ cause: new Error('private-native-path') }), + }); + await expect(openInEditor(deps, 'synthetic transcript')).resolves.toEqual({ + kind: 'failed', + message: WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE, + }); + expect(trace.at(-1)).toBe('dispose'); + }); + it('the editor is KILLED by a signal ⇒ `failed`, and the temp file is STILL disposed', async () => { const { deps, trace } = harness({ exit: { code: null, signal: 'SIGKILL' } }); await expect(openInEditor(deps, 'x')).resolves.toEqual({ diff --git a/apps/cli/src/render/editor.ts b/apps/cli/src/render/editor.ts index bec1d904..07ab4d61 100644 --- a/apps/cli/src/render/editor.ts +++ b/apps/cli/src/render/editor.ts @@ -4,6 +4,15 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { + createNativeProcessController, + type NativeProcessController, +} from '../process/native-process-owner.js'; +import { + WindowsTeardownUnavailableError, + WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE, +} from '../process/windows-teardown.js'; + /** * The `$EDITOR` half of the ADR-0068 §e copy-and-search escape hatches (2.6.F Step 5d): write the transcript to a * private temp file and hand the terminal to the user's editor, so they can search, select, and copy it — the @@ -153,7 +162,9 @@ export async function openInEditor( return { kind: 'failed', message: `${editor.command} was terminated by ${exit.signal}` }; } return { kind: 'closed', exitCode: exit.code ?? 0 }; - } catch { + } catch (cause) { + if (cause instanceof WindowsTeardownUnavailableError) + return { kind: 'failed', message: WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE }; // The editor could not be started (ENOENT / EACCES). The command NAME is the user's own env value, so echoing // it is safe and is the only actionable part of the message. return { kind: 'failed', message: `could not start ${editor.command}` }; @@ -266,17 +277,39 @@ export const nodeCreateTempDocument = async (contents: string): Promise => - new Promise((resolve, reject) => { - const child = spawn(command, [...args, file], { - stdio: 'inherit', // the editor owns the terminal; suspendFullScreen already handed it over - shell: false, // SECURITY: no shell — a metacharacter in $EDITOR is an inert argv token, never a command - windowsHide: false, +export function createNodeSpawnEditor( + nativeProcesses: NativeProcessController = createNativeProcessController(), +): (command: string, args: readonly string[], file: string) => Promise { + return async (command, args, file) => { + const native = await nativeProcesses.reserve('pid'); + return new Promise((resolve, reject) => { + let child: ReturnType; + try { + native.assertAdmitting(); + child = spawn(command, [...args, file], { + stdio: 'inherit', // the editor owns the terminal; suspendFullScreen already handed it over + shell: false, // SECURITY: metacharacters remain inert argv, never a command + windowsHide: false, + }); + } catch (cause) { + native.abandon(); + reject( + cause instanceof Error ? cause : new Error('The editor could not be started.', { cause }), + ); + return; + } + native.bindChild(child); + // The public start error is unchanged, but the registered lifetime remains until native close. + child.once('error', (cause) => { + void native.stop(); + reject(cause); + }); + child.once('close', (code, signal) => { + void native.whenClosed.then(() => resolve({ code, signal })); + }); }); - child.once('error', reject); // ENOENT / EACCES — the command does not exist or is not executable - child.once('close', (code, signal) => resolve({ code, signal })); - }); + }; +} + +/** Compatibility host with local cleanup; product constructors bind their original invocation explicitly. */ +export const nodeSpawnEditor = createNodeSpawnEditor(); From 1461f0e44053c29a99f3ff683e681d6ed7d2e8e0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 14:22:46 +0300 Subject: [PATCH 090/222] test(repo): require installed native process ownership controls Add the seventh same-tarball platform case through the public offline agent route. Exercise real error/close, detached-group leader exit, creation-owned finite exit cleanup and verified Windows helper lifetimes. Keep native evidence and watchdog cleanup distinct; retain failed modes. Bound and verify shared runtime support for copied Homebrew Node fixtures. All 496 acceptance-tool tests and the local seven-case consumer pass. Required current-platform and minimum-Node execution remain actionable. Refs: ADR-0110, ADR-0111 Co-Authored-By: Codex --- package.json | 2 +- tools/cli-package/README.md | 4 +- tools/cli-package/binding.test.mjs | 28 + tools/cli-package/smoke/README.md | 36 +- tools/cli-package/smoke/cases.mjs | 1 + tools/cli-package/smoke/native-descendant.cjs | 17 + tools/cli-package/smoke/native-process.mjs | 522 ++++++++++++++++++ .../cli-package/smoke/native-process.test.mjs | 265 +++++++++ .../smoke/native-spawn-witness.mjs | 196 +++++++ .../smoke/native-spawn-witness.test.mjs | 279 ++++++++++ tools/cli-package/smoke/native-status.cjs | 33 ++ tools/cli-package/smoke/preload.mjs | 11 +- tools/cli-package/smoke/worker.mjs | 8 +- 13 files changed, 1394 insertions(+), 8 deletions(-) create mode 100644 tools/cli-package/smoke/native-descendant.cjs create mode 100644 tools/cli-package/smoke/native-process.mjs create mode 100644 tools/cli-package/smoke/native-process.test.mjs create mode 100644 tools/cli-package/smoke/native-spawn-witness.mjs create mode 100644 tools/cli-package/smoke/native-spawn-witness.test.mjs create mode 100644 tools/cli-package/smoke/native-status.cjs diff --git a/package.json b/package.json index 82a5ff63..d3bbbd80 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "smoke:budget-replay": "node tools/budget-replay-compat/check.mjs", "check:wire-mutations": "node tools/wire-conformance/check.mjs", "check:usage-mutations": "node tools/usage-coverage-controls/check.mjs", - "test:acceptance-tools": "node --test tools/ci/windows-headless-smoke.test.mjs tools/ci/workspace-scheduling.test.mjs tools/ci/require-native-db-diagnostics.test.mjs tools/usage-coverage-controls/outcome.test.mjs tools/usage-coverage-controls/workload.test.mjs tools/ci/require-w8-acceptance.test.mjs tools/acceptance-evidence/export.test.mjs tools/wire-conformance/provenance.test.mjs tools/causal-controls/outcome.test.mjs tools/causal-controls/process.test.mjs tools/causal-controls/provenance.test.mjs tools/causal-controls/retention.test.mjs tools/causal-controls/run.test.mjs tools/causal-controls/timeout-policy.test.mjs tools/bundle-closure/closure.test.mjs tools/cli-bootstrap/witness.test.mjs tools/ci/require-packed-cli.test.mjs tools/cli-package/archive.test.mjs tools/cli-package/binding.test.mjs tools/cli-package/process.test.mjs tools/cli-package/files.test.mjs tools/cli-package/smoke/process.test.mjs tools/cli-package/pipeline.test.mjs tools/cli-package/consumer-lifetime.test.mjs", + "test:acceptance-tools": "node --test tools/ci/windows-headless-smoke.test.mjs tools/ci/workspace-scheduling.test.mjs tools/ci/require-native-db-diagnostics.test.mjs tools/usage-coverage-controls/outcome.test.mjs tools/usage-coverage-controls/workload.test.mjs tools/ci/require-w8-acceptance.test.mjs tools/acceptance-evidence/export.test.mjs tools/wire-conformance/provenance.test.mjs tools/causal-controls/outcome.test.mjs tools/causal-controls/process.test.mjs tools/causal-controls/provenance.test.mjs tools/causal-controls/retention.test.mjs tools/causal-controls/run.test.mjs tools/causal-controls/timeout-policy.test.mjs tools/bundle-closure/closure.test.mjs tools/cli-bootstrap/witness.test.mjs tools/ci/require-packed-cli.test.mjs tools/cli-package/archive.test.mjs tools/cli-package/binding.test.mjs tools/cli-package/process.test.mjs tools/cli-package/files.test.mjs tools/cli-package/smoke/process.test.mjs tools/cli-package/smoke/native-process.test.mjs tools/cli-package/smoke/native-spawn-witness.test.mjs tools/cli-package/pipeline.test.mjs tools/cli-package/consumer-lifetime.test.mjs", "check:fs-mutations": "node tools/fs-policy-controls/check.mjs", "check:secrets-mutations": "node tools/secrets-controls/check.mjs", "smoke:cli-bootstrap": "node tools/cli-bootstrap/check.mjs", diff --git a/tools/cli-package/README.md b/tools/cli-package/README.md index 69dad452..15452fe7 100644 --- a/tools/cli-package/README.md +++ b/tools/cli-package/README.md @@ -4,6 +4,8 @@ These tooling runners implement Wave 3 Step 7b under [ADR-0111](../../docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md). The canonical package and release contract lives in [packaging.md](../../docs/reference/cli/packaging.md). +Step 8b adds required creation-owned native process controls to the same installed +artifact cohort; fatal coordinator activation remains Step 8d. ```sh pnpm package:cli:pack /absolute/empty/artifact-directory @@ -35,7 +37,7 @@ access is refused. No workspace install, product build, repack, provider request credential is used by a consumer. Actual installed dependency versions and metadata digests are recorded; a workspace resolution is never substituted. -Each consumer repeats the 175 untouched-entry bootstrap controls and the six +Each consumer repeats the 175 untouched-entry bootstrap controls and the seven [installed behavior cases](smoke/README.md). Detailed local reports remain in the support directory. CI uploads **only** the bounded public `installed-consumer.json`: source/artifact/origin/runtime identities, public file/dependency digests, and exact diff --git a/tools/cli-package/binding.test.mjs b/tools/cli-package/binding.test.mjs index 7b535a2f..7ffc1240 100644 --- a/tools/cli-package/binding.test.mjs +++ b/tools/cli-package/binding.test.mjs @@ -243,6 +243,34 @@ test('consumer refuses replay, mismatched files, failed/skipped/incomplete contr ); }); +test('each required platform refuses the former six-case cohort without its native owner control', () => { + assert.ok(REQUIRED_SMOKE_CASES.includes('actual-native-process-owner')); + assert.equal(REQUIRED_SMOKE_CASES.length, 7); + for (const leg of REQUIRED_CONSUMER_LEGS) { + const report = consumer(leg); + const cases = report.smoke.cases.filter((item) => item.id !== 'actual-native-process-owner'); + assert.equal(cases.length, 6); + refuses(() => + validateConsumerRecord({ ...report, smoke: { success: true, cases } }, receipt(), expected), + ); + refuses(() => + validateConsumerRecord( + { + ...report, + smoke: { + success: true, + cases: report.smoke.cases.map((item) => + item.id === 'actual-native-process-owner' ? { ...item, passed: false } : item, + ), + }, + }, + receipt(), + expected, + ), + ); + } +}); + test('resolved dependency evidence records every approved package without exposing host paths', () => { const report = consumer(); const dependency = report.dependencies[0]; diff --git a/tools/cli-package/smoke/README.md b/tools/cli-package/smoke/README.md index b203f731..0709342a 100644 --- a/tools/cli-package/smoke/README.md +++ b/tools/cli-package/smoke/README.md @@ -40,6 +40,26 @@ aggregate. Each result has `id`, `passed`, and evidence or an error. Overall join the native child. No provider/key lookup is needed on this gate-only graph. - Run the existing parameterized actual media/CAS native cleanup witness against the installed entry, including the shared iterator-provenance ownership join. +- Replay a public offline `agent run --fixture` with `git_status` through the actual + native process host. A private copied Node executable named `git`/`git.exe` runs + the fixed extensionless fixture script, which creates only its recorded child. + Inject a synthetic error on the real target after readiness, then observe actual + target and descendant closure. Delay delivery of real close events to distinguish + an error/request from the owned native acknowledgement. POSIX additionally covers + natural leader exit while a descendant holds inherited pipes. Windows runs the + verified absolute system taskkill with exact `/PID /T /F`, joins its + actual helper independently, ignores an executable hostile PATH shim, and refuses + an invalid system facility before target spawn. An unrelated owned sibling must + survive; external watchdogs and exact created PIDs bound and join cleanup. + Each mode owns a fresh sibling with its own finite watchdog; watchdog cleanup + remains failure. A copied Homebrew macOS Node also receives its fixed adjacent + `libnode..dylib` when present, so dyld can enter the fixture. The two same-directory + executables share that support file only after bounded ordinary-file byte verification; + an existing read-only copy is verified and reused. Readiness requires + the exact root, created descendant and descendant's matching PID/parent ACK; + a root/stdout marker alone never authorizes injection. Failed modes retain bounded + native stdout/stderr, partial mode records and actual cleanup observations in the + private report; the public receipt still carries only the required case ID/pass. Chat's key resolver is keychain-first: exactly two synthetic `Entry` constructor attempts are recorded and refused before native API access. The budget run has one @@ -50,7 +70,8 @@ The external `--import` preload uses Node's `module.register` API available in Node 22.0. It replaces only the keyring import and exact synthetic HTTP transport, not shipped commands, engine code, SDK parsing or persistence. The only admitted network-shaped data fetch is QuickJS's local WASM data URL; the only admitted -child spawn is the explicit MCP fixture. These are test witnesses, not a claimed +child spawns are the explicit MCP fixture and the new native case's exact target +and verified Windows taskkill helper. These are test witnesses, not a claimed security sandbox. The chat transport admits one exact synthetic OpenAI request shape and returns complete synthetic SSE. No real credential, provider call or billing setup is used. @@ -58,11 +79,18 @@ billing setup is used. The exact Node 22.0 Linux and current Node 24 Linux/macOS/Windows consumers must execute this same runner and artifact digest. A local pass cannot substitute for those required platform legs. Archive inspection, bootstrap/meta controls and -aggregate enforcement belong to the surrounding package runner. Native fatal -integration and original-TS maps/custody retain their later Wave 3 scopes. +aggregate enforcement belong to the surrounding package runner. The new case +captures the actual packaged creation-registry exit callback before native entry, +invokes that exact finite callback in a separate subcase, and observes its release +after native close. This proves packaged creation/fallback identity; source +forwarding controls separately prove original bootstrap object identity. The error +is explicit native-edge fault injection, not an OS-fault claim. Fatal coordinator +activation and live fatal budget integration retain the later Step 8d scope; +original-TS maps/custody remain Step 9. All existing six behavior cases and 175 +bootstrap controls remain required. Narrow child-lifetime negative controls run with: ```sh -node --test tools/cli-package/smoke/process.test.mjs +node --test tools/cli-package/smoke/process.test.mjs tools/cli-package/smoke/native-process.test.mjs tools/cli-package/smoke/native-spawn-witness.test.mjs ``` diff --git a/tools/cli-package/smoke/cases.mjs b/tools/cli-package/smoke/cases.mjs index b06cdf41..43a08127 100644 --- a/tools/cli-package/smoke/cases.mjs +++ b/tools/cli-package/smoke/cases.mjs @@ -6,4 +6,5 @@ export const REQUIRED_SMOKE_CASES = Object.freeze([ 'real-sdk-chat-persistence-resume', 'actual-mcp-initialize-version', 'actual-media-cas-iterator-cleanup', + 'actual-native-process-owner', ]); diff --git a/tools/cli-package/smoke/native-descendant.cjs b/tools/cli-package/smoke/native-descendant.cjs new file mode 100644 index 00000000..513c993d --- /dev/null +++ b/tools/cli-package/smoke/native-descendant.cjs @@ -0,0 +1,17 @@ +// Exact fixture descendant only. No transport, provider, keychain or ambient process discovery. +const assert = require('node:assert/strict'); +const { appendFileSync } = require('node:fs'); +const { setInterval } = require('node:timers'); +assert.equal(process.argv.length, 3); +appendFileSync( + process.argv[2], + JSON.stringify({ + operation: 'native-fixture-descendant-ready', + pid: process.pid, + parentPid: process.ppid, + }) + '\n', + { mode: 0o600 }, +); +process.send('ready'); +process.stdout.write('RELAVIUM_NATIVE_FIXTURE_READY\n'); +setInterval(() => {}, 1000); diff --git a/tools/cli-package/smoke/native-process.mjs b/tools/cli-package/smoke/native-process.mjs new file mode 100644 index 00000000..45660032 --- /dev/null +++ b/tools/cli-package/smoke/native-process.mjs @@ -0,0 +1,522 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { + chmodSync, + constants, + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + realpathSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { basename, delimiter, dirname, join } from 'node:path'; +import { clearTimeout, setTimeout } from 'node:timers'; +import { fileURLToPath } from 'node:url'; +import { readOrdinaryFile, sha256 } from '../files.mjs'; +import { ndjson, poll, startOwned } from './process.mjs'; + +const fixture = (name) => fileURLToPath(new URL(name, import.meta.url)); +const positivePid = (pid) => Number.isSafeInteger(pid) && pid > 0; +const completeEvents = (text) => ndjson(text.slice(0, text.lastIndexOf('\n') + 1)); +/** One owned runtime support file may serve both copied executables; reuse requires exact ordinary bytes. */ +export function copyFixtureRuntimeSupport(source, destination) { + const canonical = realpathSync(source); + const maximum = 256 * 1024 * 1024; + const expected = sha256(readOrdinaryFile(canonical, maximum)); + if (!existsSync(destination)) copyFileSync(canonical, destination, constants.COPYFILE_EXCL); + assert.equal( + sha256(readOrdinaryFile(destination, maximum)), + expected, + 'Copied fixture runtime support bytes differ', + ); +} +/** Relocatable official Node needs only its executable; Homebrew macOS also needs its adjacent ABI dylib. */ +export function copyFixtureNode(executable) { + const runtime = realpathSync(process.execPath); + copyFileSync(runtime, executable); + chmodSync(executable, 0o700); + const libraries = []; + if (process.platform === 'darwin') { + const library = join(dirname(runtime), '../lib', `libnode.${process.versions.modules}.dylib`); + if (existsSync(library)) { + assert.ok(statSync(library).isFile() && statSync(library).size <= 256 * 1024 * 1024); + const name = basename(library); + copyFixtureRuntimeSupport(library, join(dirname(executable), name)); + libraries.push(name); + } + } + return libraries; +} + +export class InstalledNativeControlError extends Error { + constructor(primary, evidence) { + super('Installed native ownership control failed.', { cause: primary }); + this.name = 'InstalledNativeControlError'; + this.primaryFailure = String( + primary instanceof Error ? (primary.stack ?? primary.message) : primary, + ).slice(0, 16384); + this.evidence = evidence; + } +} +export function nativePidGone(pid) { + assert.ok(positivePid(pid), 'Only this fixture creation may supply a PID'); + try { + process.kill(pid, 0); + return false; + } catch (error) { + if (error.code === 'ESRCH') return true; + throw error; + } +} +function records(path) { + if (!existsSync(path)) return []; + assert.ok(statSync(path).isFile() && statSync(path).size <= 65536); + return ndjson(readFileSync(path, 'utf8')); +} +/** Actual close releases PID authority, even if a later liveness sample would see that number reused. */ +export function pendingNativeFixturePids(log) { + const observed = records(log); + const closed = new Set( + observed + .filter((item) => + ['native-target-actual-close', 'native-helper-actual-close'].includes(item.operation), + ) + .map((item) => item.pid), + ); + return [ + ...new Set( + observed + .filter((item) => + ['native-target-spawn', 'native-fixture-descendant', 'native-helper-spawn'].includes( + item.operation, + ), + ) + .map((item) => item.pid), + ), + ].filter((pid) => positivePid(pid) && !closed.has(pid)); +} +function killOwned(pid) { + if (nativePidGone(pid)) return; + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } +} + +/** Actual sibling outside the target's detached group/tree. Its own owner always joins close. */ +export async function createFixtureSentinel(cwd, env, timeoutMs = 25000) { + const child = spawn( + process.execPath, + ['-e', 'process.stdout.write("ready");setInterval(()=>{},1000)'], + { + cwd, + env, + shell: false, + stdio: ['ignore', 'pipe', 'ignore'], + }, + ); + let closed = false; + let expired = false; + const joined = new Promise((resolve) => + child.once('close', () => { + closed = true; + resolve(); + }), + ); + const ready = new Promise((resolve, reject) => { + child.stdout.once('data', resolve); + child.once('error', reject); + }); + const timer = setTimeout(() => { + expired = true; + if (!closed) child.kill('SIGKILL'); + }, timeoutMs); + try { + await Promise.race([ + ready, + joined.then(() => { + throw new Error('Owned sentinel closed before readiness'); + }), + ]); + } catch (error) { + if (!closed) child.kill('SIGKILL'); + await joined; + clearTimeout(timer); + throw error; + } + return { + assertAlive() { + assert.equal(expired, false, 'Owned sentinel external watchdog expired'); + assert.equal(closed, false, 'An unrelated owned sibling was killed'); + assert.equal(nativePidGone(child.pid), false); + }, + async close() { + if (!closed) child.kill('SIGKILL'); + await joined; + clearTimeout(timer); + assert.equal(expired, false, 'Owned sentinel external watchdog supplied cleanup'); + }, + state: () => ({ pid: child.pid, actualCloseObserved: closed, watchdogFired: expired }), + }; +} + +function cassette() { + const usage = { kind: 'complete', usage: { inputTokens: 1, outputTokens: 1 } }; + return { + schema_version: '2.0', + provider: 'openai', + model: 'gpt-4o-mini', + calls: [ + [ + { type: 'tool_call_start', id: 'native-status', name: 'git_status' }, + { type: 'tool_call_delta', id: 'native-status', argsJsonDelta: '{}' }, + { type: 'tool_call_end', id: 'native-status' }, + { type: 'stop', stopReason: 'tool_use', usage }, + ], + [ + { type: 'text_delta', text: 'native control complete' }, + { type: 'stop', stopReason: 'stop', usage }, + ], + ], + }; +} + +/** Public offline route; all native mechanism/controller/registry bytes are from the selected installation. */ +export async function assertInstalledNativeProcesses(context) { + const { root, cli, environment, calls } = context; + const home = join(root, 'native-home'); + const bin = join(home, 'bin'); + mkdirSync(bin, { recursive: true, mode: 0o700 }); + const executable = join(bin, process.platform === 'win32' ? 'git.exe' : 'git'); + const copiedRuntimeLibraries = copyFixtureNode(executable); + const shim = join(bin, process.platform === 'win32' ? 'taskkill.exe' : 'taskkill'); + copyFixtureNode(shim); + const systemTaskkill = + process.platform === 'win32' + ? realpathSync(join(process.env.SystemRoot, 'System32', 'taskkill.exe')) + : null; + const before = calls().length; + const outcomes = []; + let caseFailure; + let sibling; + const modes = [ + 'injected-error', + 'exit-fallback', + ...(process.platform === 'win32' ? ['facility-refusal'] : ['leader-exit']), + ]; + try { + for (const mode of modes) { + const cwd = join(home, mode); + mkdirSync(cwd, { mode: 0o700 }); + const log = join(cwd, 'native.ndjson'); + const release = join(cwd, 'release-close'); + const configuration = join(cwd, 'witness.json'); + const gate = + mode === 'facility-refusal' + ? 'none' + : process.platform === 'win32' && mode === 'injected-error' + ? 'helper' + : 'target'; + const outcome = { + mode, + gate, + passed: false, + observed: [], + cleanup: { native: [], failures: [] }, + }; + outcomes.push(outcome); + copyFileSync(fixture('native-status.cjs'), join(cwd, 'status')); + writeFileSync( + join(cwd, 'native-fixture.json'), + JSON.stringify({ + mode, + log, + node: process.execPath, + descendant: fixture('native-descendant.cjs'), + }), + { mode: 0o600 }, + ); + writeFileSync( + configuration, + JSON.stringify({ cwd, executable, log, mode, gate, release, taskkill: systemTaskkill }), + { mode: 0o600 }, + ); + const agent = join(cwd, 'native.agent.yaml'); + const recorded = join(cwd, 'native.cassette.json'); + writeFileSync( + agent, + 'id: packaged-native-owner\nprovider: openai\nmodel: gpt-4o-mini\nsystem_prompt: Offline native ownership control.\ntools: [git_status]\nmax_tokens: 64\n', + ); + writeFileSync(recorded, JSON.stringify(cassette())); + const env = environment(home, { + PATH: [bin, process.env.PATH].filter(Boolean).join(delimiter), + ...(process.platform === 'win32' ? { PATHEXT: '.exe;.cmd;.bat;.com' } : {}), + RELAVIUM_PACKAGE_SMOKE_NATIVE: configuration, + ...(mode === 'facility-refusal' ? { SystemRoot: join(cwd, 'missing-system-root') } : {}), + }); + // Each independently bounded mode owns a fresh sibling and its full finite lifetime. + sibling = await createFixtureSentinel(cwd, environment(home)); + const owned = startOwned(cli(['agent', 'run', agent, '--fixture', recorded, '--json']), { + cwd, + env, + timeout: 20000, + }); + owned.child.stdin.end('exercise the exact native fixture\n'); + let watchdogFired = false; + const observedAbsent = new Set(); + const watchdog = setTimeout(() => { + watchdogFired = true; + try { + for (const pid of pendingNativeFixturePids(log).filter( + (pid) => !observedAbsent.has(pid), + )) { + try { + killOwned(pid); + } catch { + /* cleanup below retains failures */ + } + } + } catch { + // A broken private journal cannot skip the independently owned CLI handle. + } + owned.child.kill('SIGKILL'); + }, 15000); + let primary; + try { + if (mode !== 'facility-refusal') { + await poll( + () => + records(log).some((item) => item.operation === 'native-target-actual-close') && + (gate !== 'helper' || + records(log).some((item) => item.operation === 'native-helper-actual-close')), + 10000, + 'Real owned target/helper did not close', + ); + assert.equal( + owned.isSettled(), + false, + 'Caller departed while a delivered-close obligation was held', + ); + assert.equal( + completeEvents(owned.output().stdout).some( + (event) => event.type === 'session:turn_completed', + ), + false, + 'A request/error supplied a premature close acknowledgement', + ); + const atClose = records(log); + const descendant = atClose.find((item) => item.operation === 'native-fixture-descendant'); + const started = atClose.find((item) => item.operation === 'native-target-spawn'); + if (mode !== 'exit-fallback') { + assert.ok( + descendant && positivePid(descendant.pid), + 'Exact created descendant identity missing', + ); + assert.equal(descendant.parentPid, started.pid); + assert.ok( + atClose.some( + (item) => + item.operation === 'native-fixture-descendant-ready' && + item.pid === descendant.pid && + item.parentPid === started.pid, + ), + 'The actual descendant never acknowledged readiness', + ); + await poll( + () => nativePidGone(descendant.pid), + 5000, + 'Owned descendant survived native cleanup', + ); + observedAbsent.add(descendant.pid); + } + sibling.assertAlive(); + writeFileSync(release, 'release the delayed real close delivery\n', { mode: 0o600 }); + } + const result = await owned.result(); + assert.equal(result.signal, null, result.stderr); + assert.ok([0, 1].includes(result.code), result.stderr); + assert.equal( + ndjson(result.stdout).filter((event) => event.type === 'session:turn_completed').length, + 1, + 'The public offline turn did not complete', + ); + const observed = records(log); + assert.equal(watchdogFired, false, 'External fixture watchdog supplied native cleanup'); + assert.equal( + observed.some((item) => item.operation === 'native-witness-gate-timeout'), + false, + ); + sibling.assertAlive(); + if (mode === 'facility-refusal') { + assert.equal( + observed.length, + 0, + 'Invalid Windows facility must refuse before target entry', + ); + assert.ok( + (result.stdout + result.stderr).includes('Windows subprocess teardown is unavailable'), + 'Fixed Windows facility refusal missing', + ); + assert.equal(result.code, 1); + } else { + const started = observed.filter((item) => item.operation === 'native-target-spawn'); + assert.equal(started.length, 1); + assert.equal(started[0].exitCallbacksBeforeEntry, 1); + assert.equal(started[0].detached, process.platform !== 'win32'); + assert.ok( + observed.some((item) => item.operation === 'native-exit-listener-released'), + 'Creation-owned exit closure retained after real retirement', + ); + const closed = observed.find((item) => item.operation === 'native-target-actual-close'); + assert.ok(closed); + assert.equal(nativePidGone(started[0].pid), true); + if (mode === 'leader-exit') + assert.equal( + closed.code, + 0, + 'The owned leader must exit naturally before the group final reap', + ); + if (mode === 'exit-fallback') + assert.equal( + observed.filter((item) => item.operation === 'native-captured-exit-reap-invoked') + .length, + 1, + ); + const helpers = observed.filter((item) => item.operation === 'native-helper-spawn'); + assert.equal( + observed.filter((item) => item.operation === 'native-synthetic-error-injected').length, + mode === 'injected-error' ? 1 : 0, + ); + if (process.platform === 'win32' && mode === 'injected-error') { + assert.equal(helpers.length, 1); + assert.equal(helpers[0].executable, systemTaskkill); + assert.notEqual( + helpers[0].executable.toLowerCase(), + shim.toLowerCase(), + 'Hostile PATH taskkill shim selected', + ); + assert.deepEqual(helpers[0].args, ['/PID', String(started[0].pid), '/T', '/F']); + assert.equal(helpers[0].shell, false); + assert.equal( + observed.filter((item) => item.operation === 'native-helper-actual-close').length, + 1, + ); + assert.equal( + observed.find((item) => item.operation === 'native-helper-actual-close').code, + 0, + 'The actual verified taskkill request failed', + ); + assert.equal(nativePidGone(helpers[0].pid), true); + } else assert.equal(helpers.length, 0, 'Finite exit cleanup must not start a helper'); + } + Object.assign(outcome, { + actualNativeClose: mode !== 'facility-refusal', + syntheticNativeError: mode === 'injected-error', + ownedDescendantClosed: mode === 'injected-error' || mode === 'leader-exit', + siblingSurvived: true, + observed, + }); + } catch (error) { + primary = error; + } finally { + try { + writeFileSync(release, 'release fixture cleanup\n', { mode: 0o600 }); + outcome.cleanup.releaseWritten = true; + } catch (error) { + primary ??= error; + outcome.cleanup.failures.push(String(error)); + } + let pids = []; + try { + pids = pendingNativeFixturePids(log).filter((pid) => !observedAbsent.has(pid)); + } catch (error) { + primary ??= error; + outcome.cleanup.failures.push(String(error)); + } + const cleanup = await Promise.allSettled( + pids.map(async (pid) => { + killOwned(pid); + await poll( + () => nativePidGone(pid), + 5000, + 'Exact owned fixture PID remained live after cleanup', + ); + outcome.cleanup.native.push({ pid, absenceObserved: true }); + }), + ); + await owned.close().catch((error) => { + primary ??= error; + outcome.cleanup.failures.push(String(error)); + }); + clearTimeout(watchdog); + for (const result of cleanup) + if (result.status === 'rejected') { + primary ??= result.reason; + outcome.cleanup.failures.push(String(result.reason)); + } + outcome.cleanup.cliActualCloseObserved = owned.isSettled(); + await sibling.close().catch((error) => { + primary ??= error; + outcome.cleanup.failures.push(String(error)); + }); + outcome.cleanup.sentinel = sibling.state(); + sibling = undefined; + outcome.watchdogFired = watchdogFired; + outcome.cli = { + code: owned.child.exitCode, + signal: owned.child.signalCode, + stdout: owned.output().stdout.slice(0, 16384), + stderr: owned.output().stderr.slice(0, 16384), + }; + try { + outcome.observed = records(log); + } catch (error) { + primary ??= error; + outcome.cleanup.failures.push(String(error)); + } + outcome.passed = primary === undefined; + } + if (primary !== undefined) throw primary; + } + const observed = calls().slice(before); + assert.equal( + observed.filter( + (call) => + call.denied || call.operation === 'keyring.Entry' || call.operation === 'sdk-fetch', + ).length, + 0, + 'Native offline control attempted a provider/keychain/forbidden service', + ); + assert.equal(observed.filter((call) => call.operation === 'native-owned-spawn').length, 3); + return { + platform: process.platform, + runtime: process.version, + publicOfflineAgentRoute: true, + capturedPackagedCreationExitClosure: true, + originalBootstrapIdentity: 'separately checked by source forwarding controls', + fatalCoordinatorActivated: false, + copiedRuntimeLibraries, + outcomes, + }; + } catch (error) { + caseFailure = error; + throw new InstalledNativeControlError(error, { + platform: process.platform, + runtime: process.version, + copiedRuntimeLibraries, + outcomes, + }); + } finally { + if (sibling !== undefined) { + await sibling.close().catch((error) => { + outcomes.at(-1)?.cleanup.failures.push(String(error)); + if (caseFailure === undefined) throw error; + }); + const last = outcomes.at(-1); + if (last !== undefined) last.cleanup.sentinel = sibling.state(); + } + } +} diff --git a/tools/cli-package/smoke/native-process.test.mjs b/tools/cli-package/smoke/native-process.test.mjs new file mode 100644 index 00000000..6896f73e --- /dev/null +++ b/tools/cli-package/smoke/native-process.test.mjs @@ -0,0 +1,265 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { + copyFileSync, + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { clearTimeout, setTimeout } from 'node:timers'; +import { fileURLToPath } from 'node:url'; +import { + copyFixtureNode, + copyFixtureRuntimeSupport, + createFixtureSentinel, + InstalledNativeControlError, + pendingNativeFixturePids, +} from './native-process.mjs'; +import { explicitEnvironment, ndjson, poll } from './process.mjs'; + +test('each fixture sentinel owns a separate bounded lifetime and joins native close', async () => { + const root = mkdtempSync(join(tmpdir(), 'relavium-native-sentinel-test-')); + try { + for (let mode = 0; mode < 3; mode++) { + const sibling = await createFixtureSentinel(root, explicitEnvironment(root, root), 5000); + try { + sibling.assertAlive(); + } finally { + await sibling.close(); + } + assert.equal(sibling.state().actualCloseObserved, true); + assert.equal(sibling.state().watchdogFired, false); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('sentinel watchdog cleanup is joined but remains failure evidence', async () => { + const root = mkdtempSync(join(tmpdir(), 'relavium-native-sentinel-timeout-')); + let sibling; + try { + sibling = await createFixtureSentinel(root, explicitEnvironment(root, root), 1000); + await poll(() => sibling.state().watchdogFired, 3000, 'Sentinel watchdog did not fire'); + assert.throws(() => sibling.assertAlive(), /watchdog/); + await assert.rejects(sibling.close(), /watchdog/); + assert.equal(sibling.state().actualCloseObserved, true); + } finally { + await sibling?.close().catch(() => undefined); + rmSync(root, { recursive: true, force: true }); + } +}); + +test('native control failure preserves its exact primary and partial mode/cleanup evidence', () => { + const primary = new Error('fixture startup failed'); + const evidence = { + outcomes: [ + { + mode: 'injected-error', + passed: false, + observed: [ + { operation: 'native-target-actual-close', stderr: 'bounded dyld startup failure' }, + ], + cleanup: { cliActualCloseObserved: true, failures: [] }, + }, + ], + }; + const failure = new InstalledNativeControlError(primary, evidence); + assert.equal(failure.cause, primary); + assert.equal(failure.primaryFailure, primary.stack); + assert.equal(failure.evidence, evidence); + assert.equal(failure.evidence.outcomes[0].passed, false); +}); + +test( + 'two same-directory copied Nodes share verified runtime support and acknowledge the actual descendant', + { timeout: 10000 }, + async () => { + const root = mkdtempSync(join(tmpdir(), 'relavium-status-fixture-test-')); + const executable = join(root, process.platform === 'win32' ? 'git.exe' : 'git'); + const descendantNode = join(root, process.platform === 'win32' ? 'taskkill.exe' : 'taskkill'); + const log = join(root, 'native.ndjson'); + const libraries = copyFixtureNode(executable); + for (const name of libraries) chmodSync(join(root, name), 0o444); + assert.deepEqual(copyFixtureNode(descendantNode), libraries); + copyFileSync( + fileURLToPath(new URL('./native-status.cjs', import.meta.url)), + join(root, 'status'), + ); + writeFileSync( + join(root, 'native-fixture.json'), + JSON.stringify({ + mode: 'injected-error', + log, + node: descendantNode, + descendant: fileURLToPath(new URL('./native-descendant.cjs', import.meta.url)), + }), + ); + const child = spawn(executable, ['status'], { + cwd: root, + env: explicitEnvironment(root, root), + shell: false, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = '', + stderr = '', + closed = false, + descendant; + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + const joined = new Promise((resolve) => + child.once('close', () => { + closed = true; + resolve(); + }), + ); + const rows = () => (existsSync(log) ? ndjson(readFileSync(log, 'utf8')) : []); + const killDescendant = () => { + descendant ??= rows().find( + (item) => item.operation === 'native-fixture-descendant' && item.parentPid === child.pid, + )?.pid; + if (descendant !== undefined) { + try { + process.kill(descendant, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } + }; + const stopTarget = () => { + if (closed) return; + if (process.platform !== 'win32' && child.pid !== undefined) { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } + child.kill('SIGKILL'); + }; + let expired = false; + const watchdog = setTimeout(() => { + expired = true; + try { + killDescendant(); + } finally { + stopTarget(); + } + }, 5000); + try { + await poll( + () => { + assert.equal(closed, false, stderr); + return stdout.includes('RELAVIUM_NATIVE_FIXTURE_READY\n'); + }, + 4000, + 'Fixture descendant readiness missing', + ); + const observed = rows(); + descendant = observed.find((item) => item.operation === 'native-fixture-descendant')?.pid; + assert.ok(Number.isSafeInteger(descendant) && descendant > 0); + assert.ok( + observed.some( + (item) => + item.operation === 'native-fixture-descendant-ready' && + item.pid === descendant && + item.parentPid === child.pid, + ), + ); + assert.equal(expired, false); + } finally { + try { + killDescendant(); + } finally { + stopTarget(); + } + await joined; + if (descendant !== undefined) + await poll( + () => { + try { + process.kill(descendant, 0); + return false; + } catch (error) { + if (error.code === 'ESRCH') return true; + throw error; + } + }, + 2000, + 'Fixture-only regression did not join descendant absence', + ); + clearTimeout(watchdog); + rmSync(root, { recursive: true, force: true }); + } + assert.equal(expired, false, 'External fixture watchdog supplied regression cleanup'); + }, +); + +test('shared fixture runtime support reuses read-only identical bytes and refuses replacement content or links', () => { + const root = mkdtempSync(join(tmpdir(), 'relavium-shared-runtime-test-')); + const source = join(root, 'source.dylib'); + const destination = join(root, 'owned.dylib'); + writeFileSync(source, 'exact current runtime support'); + try { + copyFixtureRuntimeSupport(source, destination); + chmodSync(destination, 0o444); + copyFixtureRuntimeSupport(source, destination); + assert.equal(readFileSync(destination, 'utf8'), 'exact current runtime support'); + chmodSync(destination, 0o600); + writeFileSync(destination, 'different runtime support'); + assert.throws(() => copyFixtureRuntimeSupport(source, destination), /bytes differ/); + if (process.platform !== 'win32') { + rmSync(destination); + symlinkSync(source, destination); + assert.throws(() => copyFixtureRuntimeSupport(source, destination), /FILE_KIND/); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('fixture watchdog never reacquires a root/helper PID after actual native close', () => { + const root = mkdtempSync(join(tmpdir(), 'relavium-fixture-pid-test-')); + const log = join(root, 'native.ndjson'); + const write = (rows) => + writeFileSync(log, rows.map((row) => JSON.stringify(row) + '\n').join('')); + try { + const created = [ + { operation: 'native-target-spawn', pid: 40 }, + { operation: 'native-fixture-descendant', pid: 60, parentPid: 40 }, + { operation: 'native-helper-spawn', pid: 80 }, + ]; + write(created); + assert.deepEqual(pendingNativeFixturePids(log), [40, 60, 80]); + write([...created, { operation: 'native-target-actual-close', pid: 40 }]); + assert.deepEqual(pendingNativeFixturePids(log), [60, 80]); + write([ + ...created, + { operation: 'native-target-actual-close', pid: 40 }, + { operation: 'native-helper-actual-close', pid: 80 }, + ]); + assert.deepEqual(pendingNativeFixturePids(log), [60]); + // Readiness is observation only, not native close; unrelated/invalid records grant no authority. + write([ + ...created, + { operation: 'native-fixture-descendant-ready', pid: 100 }, + { operation: 'unrelated', pid: 120 }, + { operation: 'native-target-spawn', pid: -1 }, + ]); + assert.deepEqual(pendingNativeFixturePids(log), [40, 60, 80]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tools/cli-package/smoke/native-spawn-witness.mjs b/tools/cli-package/smoke/native-spawn-witness.mjs new file mode 100644 index 00000000..f9b0a94d --- /dev/null +++ b/tools/cli-package/smoke/native-spawn-witness.mjs @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; +import { appendFileSync, existsSync, lstatSync, readFileSync, statSync } from 'node:fs'; +import { isAbsolute } from 'node:path'; +import { clearInterval, setImmediate, setInterval } from 'node:timers'; + +/** A stdout marker alone is insufficient: match the actual root, its created child and that child's ACK. */ +export function assertNativeFixtureReady(observed, pid, mode) { + assert.ok( + observed.some((item) => item.operation === 'native-fixture-root' && item.pid === pid), + 'Exact fixture root readiness missing', + ); + if (mode === 'exit-fallback') return; + const descendant = observed.find( + (item) => item.operation === 'native-fixture-descendant' && item.parentPid === pid, + ); + assert.ok( + descendant && + Number.isSafeInteger(descendant.pid) && + descendant.pid > 0 && + descendant.pid !== pid, + 'Exact created descendant identity missing', + ); + assert.ok( + observed.some( + (item) => + item.operation === 'native-fixture-descendant-ready' && + item.pid === descendant.pid && + item.parentPid === pid, + ), + 'Actual created descendant readiness missing', + ); +} + +/** Tool-only native-edge witness. No product export, source alias or replacement controller. */ +export function createNativeSpawnWitness(configuration, spawn) { + assert.ok(isAbsolute(configuration)); + assert.ok(lstatSync(configuration).isFile()); + assert.ok(statSync(configuration).size <= 16384); + const config = JSON.parse(readFileSync(configuration, 'utf8')); + assert.deepEqual( + Object.keys(config).sort(), + ['cwd', 'executable', 'gate', 'log', 'mode', 'release', 'taskkill'].sort(), + ); + for (const key of ['cwd', 'executable', 'log', 'release']) assert.ok(isAbsolute(config[key])); + assert.ok( + ['injected-error', 'leader-exit', 'exit-fallback', 'facility-refusal'].includes(config.mode), + ); + assert.ok(['target', 'helper', 'none'].includes(config.gate)); + assert.ok(config.taskkill === null || isAbsolute(config.taskkill)); + const initialExit = new Set(process.listeners('exit')); + const record = (value) => { + const line = JSON.stringify(value) + '\n'; + assert.ok(Buffer.byteLength(line) <= 16384); + assert.ok( + !existsSync(config.log) || statSync(config.log).size + Buffer.byteLength(line) <= 65536, + ); + appendFileSync(config.log, line, { mode: 0o600 }); + }; + let target; + let targetClosed = false; + let helperEntered = false; + let exitReaper; + let triggered = false; + let pending = []; + let gateTimer; + let gateTicks = 0; + const afterDelivery = () => { + setImmediate(() => { + if (exitReaper !== undefined && !process.listeners('exit').includes(exitReaper)) + record({ operation: 'native-exit-listener-released' }); + }); + }; + function holdClose(child, kind) { + const on = child.on; + const output = { stdout: '', stderr: '' }; + for (const sink of ['stdout', 'stderr']) + child[sink]?.on('data', (chunk) => { + output[sink] = (output[sink] + chunk.toString()).slice(0, 1024); + }); + on.call(child, 'error', () => record({ operation: `native-${kind}-error` })); + on.call(child, 'close', (code, signal) => { + if (kind === 'target') targetClosed = true; + record({ operation: `native-${kind}-actual-close`, pid: child.pid, code, signal, ...output }); + }); + child.on = function (event, listener) { + if (event !== 'close') return on.call(this, event, listener); + return on.call(this, event, (...args) => { + const deliver = () => { + listener.apply(this, args); + afterDelivery(); + }; + if (config.gate !== kind || existsSync(config.release)) return deliver(); + pending.push(deliver); + if (gateTimer !== undefined) return; + gateTimer = setInterval(() => { + if (!existsSync(config.release) && ++gateTicks < 400) return; + if (!existsSync(config.release)) record({ operation: 'native-witness-gate-timeout' }); + clearInterval(gateTimer); + gateTimer = undefined; + const deliveries = pending; + pending = []; + for (const delivery of deliveries) delivery(); + }, 25); + }); + }; + } + return Object.freeze({ + /** Undefined means refusal: the surrounding existing preload keeps its deny-all spawn rule. */ + trySpawn(executable, args, options) { + if (executable === config.executable) { + assert.equal(target, undefined, 'Only one exact fixture target may enter'); + assert.notEqual( + config.mode, + 'facility-refusal', + 'Invalid facility admitted a native target', + ); + assert.deepEqual(args, ['status']); + assert.equal(options?.cwd, config.cwd); + assert.equal(options?.shell, false); + assert.equal(options?.detached, process.platform !== 'win32'); + const additions = process + .listeners('exit') + .filter((listener) => !initialExit.has(listener)); + assert.equal( + additions.length, + 1, + 'One shipped creation-registry exit callback must precede spawn', + ); + exitReaper = additions[0]; + target = spawn(executable, args, options); + assert.ok(Number.isSafeInteger(target.pid) && target.pid > 0); + record({ + operation: 'native-target-spawn', + pid: target.pid, + executable, + args, + detached: options.detached, + exitCallbacksBeforeEntry: additions.length, + }); + holdClose(target, 'target'); + let output = ''; + target.stdout.on('data', (chunk) => { + output += chunk.toString(); + assert.ok(Buffer.byteLength(output) <= 4096); + if (triggered || !output.includes('RELAVIUM_NATIVE_FIXTURE_READY\n')) return; + triggered = true; + setImmediate(() => { + try { + assertNativeFixtureReady( + readFileSync(config.log, 'utf8').trim().split('\n').map(JSON.parse), + target.pid, + config.mode, + ); + } catch (error) { + record({ + operation: 'native-witness-readiness-refused', + reason: String(error.message).slice(0, 1024), + }); + return; + } + if (config.mode === 'injected-error') { + record({ operation: 'native-synthetic-error-injected', pid: target.pid }); + target.emit('error', new Error('Synthetic installed native lifetime control')); + } else if (config.mode === 'exit-fallback') { + assert.ok(process.listeners('exit').includes(exitReaper)); + record({ operation: 'native-captured-exit-reap-invoked' }); + exitReaper(); + } + }); + }); + return target; + } + if (process.platform === 'win32' && executable === config.taskkill) { + assert.ok(target !== undefined && !targetClosed); + assert.equal(target.exitCode, null); + assert.equal(target.signalCode, null); + assert.equal(helperEntered, false, 'Only one taskkill helper may enter'); + assert.deepEqual(args, ['/PID', String(target.pid), '/T', '/F']); + assert.deepEqual(options, { shell: false, windowsHide: true, stdio: 'ignore' }); + helperEntered = true; + const helper = spawn(executable, args, options); + record({ + operation: 'native-helper-spawn', + pid: helper.pid, + executable, + args, + shell: options.shell, + }); + holdClose(helper, 'helper'); + return helper; + } + return undefined; + }, + }); +} diff --git a/tools/cli-package/smoke/native-spawn-witness.test.mjs b/tools/cli-package/smoke/native-spawn-witness.test.mjs new file mode 100644 index 00000000..fcab5362 --- /dev/null +++ b/tools/cli-package/smoke/native-spawn-witness.test.mjs @@ -0,0 +1,279 @@ +import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; +import { EventEmitter } from 'node:events'; +import { + appendFileSync, + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { setImmediate } from 'node:timers'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { assertNativeFixtureReady, createNativeSpawnWitness } from './native-spawn-witness.mjs'; + +/** Explicit simulated event/port tests. These execute neither product code nor Windows taskkill. */ +function harness(mode = 'injected-error', gate = 'none') { + const root = mkdtempSync(join(tmpdir(), 'relavium-native-witness-test-')); + const config = { + cwd: root, + executable: join(root, 'git'), + log: join(root, 'native.ndjson'), + mode, + gate, + release: join(root, 'release'), + taskkill: join(root, 'system-taskkill.exe'), + }; + const configuration = join(root, 'witness.json'); + writeFileSync(configuration, JSON.stringify(config)); + const calls = []; + const target = Object.assign(new EventEmitter(), { + pid: 40, + exitCode: null, + signalCode: null, + stdout: new EventEmitter(), + }); + const helper = Object.assign(new EventEmitter(), { pid: 80, exitCode: null, signalCode: null }); + const spawn = (...args) => { + calls.push(args); + return calls.length === 1 ? target : helper; + }; + const witness = createNativeSpawnWitness(configuration, spawn); + const options = { cwd: root, shell: false, detached: process.platform !== 'win32' }; + let reaped = 0; + const exit = () => { + reaped++; + }; + const log = () => + existsSync(config.log) + ? readFileSync(config.log, 'utf8').trim().split('\n').map(JSON.parse) + : []; + const acknowledgeReady = () => { + const rows = [ + { operation: 'native-fixture-root', pid: 40 }, + ...(mode === 'exit-fallback' + ? [] + : [ + { operation: 'native-fixture-descendant', pid: 60, parentPid: 40 }, + { operation: 'native-fixture-descendant-ready', pid: 60, parentPid: 40 }, + ]), + ]; + appendFileSync(config.log, rows.map((row) => JSON.stringify(row) + '\n').join('')); + }; + return { + config, + configuration, + calls, + target, + helper, + witness, + options, + exit, + reaped: () => reaped, + log, + acknowledgeReady, + async close() { + process.off('exit', exit); + writeFileSync(config.release, 'release'); + await sleep(60); + rmSync(root, { recursive: true, force: true }); + }, + }; +} + +test('native witness refuses omitted/duplicate creation exit callbacks before native entry', async () => { + const h = harness(); + const duplicate = () => undefined; + try { + assert.throws( + () => h.witness.trySpawn(h.config.executable, ['status'], h.options), + /exit callback/, + ); + process.on('exit', h.exit); + process.on('exit', duplicate); + assert.throws( + () => h.witness.trySpawn(h.config.executable, ['status'], h.options), + /exit callback/, + ); + assert.equal(h.calls.length, 0); + } finally { + process.off('exit', duplicate); + await h.close(); + } +}); + +test('native witness admits one exact target and refuses executable/argv/cwd/shell/group widening', async () => { + const h = harness(); + try { + process.on('exit', h.exit); + assert.equal(h.witness.trySpawn(join(h.config.cwd, 'other'), ['status'], h.options), undefined); + for (const [args, options] of [ + [['status', '--extra'], h.options], + [['status'], { ...h.options, shell: true }], + [['status'], { ...h.options, cwd: tmpdir() }], + [['status'], { ...h.options, detached: !h.options.detached }], + ]) + assert.throws(() => h.witness.trySpawn(h.config.executable, args, options)); + assert.equal(h.calls.length, 0); + assert.equal(h.witness.trySpawn(h.config.executable, ['status'], h.options), h.target); + assert.throws(() => h.witness.trySpawn(h.config.executable, ['status'], h.options), /Only one/); + assert.equal(h.calls.length, 1); + } finally { + await h.close(); + } +}); + +test('callback capture uses the exact pre-entry function and releases only after close', async () => { + const h = harness('exit-fallback'); + try { + process.on('exit', h.exit); + h.witness.trySpawn(h.config.executable, ['status'], h.options); + h.acknowledgeReady(); + h.target.stdout.emit('data', Buffer.from('RELAVIUM_NATIVE_FIXTURE_READY\n')); + await new Promise(setImmediate); + assert.equal(h.reaped(), 1); + assert.equal( + h.log().filter((item) => item.operation === 'native-captured-exit-reap-invoked').length, + 1, + ); + assert.equal( + h.log().some((item) => item.operation === 'native-exit-listener-released'), + false, + ); + h.target.on('close', () => process.off('exit', h.exit)); + h.target.emit('close', null, 'SIGKILL'); + await new Promise(setImmediate); + assert.equal( + h.log().filter((item) => item.operation === 'native-exit-listener-released').length, + 1, + ); + } finally { + await h.close(); + } +}); + +test('synthetic native error is explicit and delayed real-close delivery cannot acknowledge early', async () => { + const h = harness('injected-error', 'target'); + try { + process.on('exit', h.exit); + h.witness.trySpawn(h.config.executable, ['status'], h.options); + let errors = 0, + deliveries = 0; + h.target.on('error', (error) => { + assert.match(error.message, /Synthetic installed/); + errors++; + }); + h.target.on('close', () => { + deliveries++; + process.off('exit', h.exit); + }); + h.acknowledgeReady(); + h.target.stdout.emit('data', Buffer.from('RELAVIUM_NATIVE_FIXTURE_READY\n')); + await new Promise(setImmediate); + assert.equal(errors, 1); + h.target.emit('close', null, 'SIGKILL'); + assert.equal(deliveries, 0); + assert.equal( + h.log().filter((item) => item.operation === 'native-target-actual-close').length, + 1, + ); + writeFileSync(h.config.release, 'release'); + await sleep(60); + assert.equal(deliveries, 1); + assert.equal( + h.log().some((item) => item.operation === 'native-witness-gate-timeout'), + false, + ); + } finally { + await h.close(); + } +}); + +test('readiness refuses root-only, foreign, missing or mismatched descendant acknowledgements', () => { + const root = { operation: 'native-fixture-root', pid: 40 }; + const child = { operation: 'native-fixture-descendant', pid: 60, parentPid: 40 }; + const ready = { operation: 'native-fixture-descendant-ready', pid: 60, parentPid: 40 }; + for (const observed of [ + [root], + [root, child], + [root, { ...child, parentPid: 41 }, ready], + [root, child, { ...ready, pid: 61 }], + [root, child, { ...ready, parentPid: 41 }], + ]) + assert.throws(() => assertNativeFixtureReady(observed, 40, 'injected-error')); + assert.doesNotThrow(() => assertNativeFixtureReady([root, child, ready], 40, 'injected-error')); + assert.doesNotThrow(() => assertNativeFixtureReady([root], 40, 'exit-fallback')); +}); + +test('invalid Windows facility mode refuses target entry rather than manufacturing a native witness', async () => { + const h = harness('facility-refusal'); + try { + process.on('exit', h.exit); + assert.throws( + () => h.witness.trySpawn(h.config.executable, ['status'], h.options), + /Invalid facility/, + ); + assert.equal(h.calls.length, 0); + assert.deepEqual(h.log(), []); + } finally { + await h.close(); + } +}); + +test('simulated Windows guard requires verified absolute helper, current root and fixed argv/options', async () => { + const saved = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + const h = harness('injected-error', 'helper'); + try { + process.on('exit', h.exit); + const helperOptions = { shell: false, windowsHide: true, stdio: 'ignore' }; + assert.equal( + h.witness.trySpawn( + join(h.config.cwd, 'taskkill.exe'), + ['/PID', '40', '/T', '/F'], + helperOptions, + ), + undefined, + ); + assert.throws(() => + h.witness.trySpawn(h.config.taskkill, ['/PID', '40', '/T', '/F'], helperOptions), + ); + h.witness.trySpawn(h.config.executable, ['status'], h.options); + for (const [args, options] of [ + [['/PID', '41', '/T', '/F'], helperOptions], + [['/PID', '40', '/IM', '*'], helperOptions], + [['/PID', '40', '/T', '/F'], { ...helperOptions, shell: true }], + ]) + assert.throws(() => h.witness.trySpawn(h.config.taskkill, args, options)); + assert.equal( + h.witness.trySpawn(h.config.taskkill, ['/PID', '40', '/T', '/F'], helperOptions), + h.helper, + ); + assert.throws( + () => h.witness.trySpawn(h.config.taskkill, ['/PID', '40', '/T', '/F'], helperOptions), + /Only one/, + ); + assert.equal(h.calls.length, 2); + h.target.emit('close', null, 'SIGKILL'); + let delivered = false; + h.helper.on('close', () => { + delivered = true; + }); + h.helper.emit('close', 0, null); + assert.equal(delivered, false); + assert.equal( + h.log().some((item) => item.operation === 'native-helper-actual-close'), + true, + ); + writeFileSync(h.config.release, 'release'); + await sleep(60); + assert.equal(delivered, true); + } finally { + await h.close(); + Object.defineProperty(process, 'platform', saved); + } +}); diff --git a/tools/cli-package/smoke/native-status.cjs b/tools/cli-package/smoke/native-status.cjs new file mode 100644 index 00000000..d79572a9 --- /dev/null +++ b/tools/cli-package/smoke/native-status.cjs @@ -0,0 +1,33 @@ +// Copied, unchanged, to the extensionless `status` file next to the private fixture config. +// A copied Node executable named git/git.exe runs this through the real git_status host arm. +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const { appendFileSync, readFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { setInterval } = require('node:timers'); + +const config = JSON.parse(readFileSync(join(process.cwd(), 'native-fixture.json'), 'utf8')); +const record = (value) => appendFileSync(config.log, JSON.stringify(value) + '\n', { mode: 0o600 }); +assert.equal(process.argv.length, 2, 'Only the fixed status script may execute'); +assert.ok(['injected-error', 'leader-exit', 'exit-fallback'].includes(config.mode)); +record({ operation: 'native-fixture-root', pid: process.pid }); +if (config.mode === 'exit-fallback') { + process.stdout.write('RELAVIUM_NATIVE_FIXTURE_READY\n'); + setInterval(() => {}, 1000); +} else { + const descendant = spawn(config.node, [config.descendant, config.log], { + shell: false, + stdio: ['ignore', 'inherit', 'inherit', 'ipc'], + }); + assert.ok(Number.isSafeInteger(descendant.pid) && descendant.pid > 0); + record({ operation: 'native-fixture-descendant', pid: descendant.pid, parentPid: process.pid }); + descendant.on('error', () => { + process.exitCode = 1; + }); + descendant.once('message', (message) => { + assert.equal(message, 'ready'); + descendant.disconnect(); + if (config.mode === 'leader-exit') descendant.unref(); + }); + if (config.mode !== 'leader-exit') setInterval(() => {}, 1000); +} diff --git a/tools/cli-package/smoke/preload.mjs b/tools/cli-package/smoke/preload.mjs index 8938e49f..8ffcf521 100644 --- a/tools/cli-package/smoke/preload.mjs +++ b/tools/cli-package/smoke/preload.mjs @@ -9,6 +9,7 @@ import dns from 'node:dns'; import child from 'node:child_process'; import dgram from 'node:dgram'; import { record, denied } from './guard-log.mjs'; +import { createNativeSpawnWitness } from './native-spawn-witness.mjs'; register('./module-guard.mjs', import.meta.url); net.connect = denied('net.connect'); @@ -33,8 +34,16 @@ for (const [label, module] of [ ) module[name] = denied(`${label}.${name}`); const originalSpawn = child.spawn; +const nativeWitness = process.env.RELAVIUM_PACKAGE_SMOKE_NATIVE + ? createNativeSpawnWitness(process.env.RELAVIUM_PACKAGE_SMOKE_NATIVE, originalSpawn) + : undefined; child.spawn = (...args) => { - // The only admitted native child is the explicit owned MCP fixture. Shell spawning stays forbidden. + const native = nativeWitness?.trySpawn(...args); + if (native !== undefined) { + record({ operation: 'native-owned-spawn', executable: args[0], args: args[1] }); + return native; + } + // The separate existing MCP fixture remains exact; no generic process/shell spawning is admitted. const expected = process.env.RELAVIUM_PACKAGE_SMOKE_MCP; if ( expected && diff --git a/tools/cli-package/smoke/worker.mjs b/tools/cli-package/smoke/worker.mjs index e7f16836..498b6562 100644 --- a/tools/cli-package/smoke/worker.mjs +++ b/tools/cli-package/smoke/worker.mjs @@ -18,6 +18,7 @@ import { REQUIRED_SMOKE_CASES } from './cases.mjs'; import { assertInstalledChat } from './chat.mjs'; import { assertInstalledMcp, assertInstalledWorkflows } from './workflows.mjs'; import { assertExit, explicitEnvironment, invoke, ndjson } from './process.mjs'; +import { assertInstalledNativeProcesses, InstalledNativeControlError } from './native-process.mjs'; const directory = dirname(fileURLToPath(import.meta.url)); const repository = resolve(directory, '../../..'); @@ -96,7 +97,11 @@ async function exerciseInstalledSmoke({ packageRoot, supportRoot, artifactSha256 id: name, passed: false, elapsedMs: Date.now() - started, - error: String(error.stack ?? error), + error: + error instanceof InstalledNativeControlError + ? error.primaryFailure + : String(error.stack ?? error), + ...(error instanceof InstalledNativeControlError ? { evidence: error.evidence } : {}), }); } } @@ -199,6 +204,7 @@ async function exerciseInstalledSmoke({ packageRoot, supportRoot, artifactSha256 outputBytes: result.bytes, }; }); + await runCase('actual-native-process-owner', () => assertInstalledNativeProcesses(context)); report.guardCalls = calls(); for (const call of report.guardCalls.filter((value) => value.operation === 'external-module')) { const resolved = realpathSync(fileURLToPath(call.url)); From e6e91039f943d01155642410ed01a8f49f8c7b1c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 14:22:46 +0300 Subject: [PATCH 091/222] docs(docs): record step 8b implementation and exact author evidence Record actual native creation ownership and its SDK-free host contract, local installed evidence and explicit remaining causal/platform/review gates. Keep whole Step 8 and fatal activation open pending Steps 8c-8d. Append completed Step 8a full-CI attribution without rewriting historical pending checkpoints or inferring execution of acceptance metadata. Refs: ADR-0110, ADR-0111, ADR-0103 Co-Authored-By: Codex --- AGENTS.md | 3 + CLAUDE.md | 3 + ...d-discovery-have-explicit-compatibility.md | 13 +++ docs/reference/cli/commands.md | 27 ++++++ docs/reference/shared-core/mcp-integration.md | 27 +++++- docs/roadmap/current.md | 6 +- .../phase-2.5.5-hardening-and-remediation.md | 88 +++++++++++++++++++ 7 files changed, 163 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3c6aa0d9..f7d0e177 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,9 @@ Linux/macOS/Windows plus Node 22.0.0 consumers. Broader CI retains its separatel attributed status. Step 8a implements origin adapters and an inert native foundation; it is independently accepted at `e0a5ae19` after two NEW complete cumulative clean rounds and a separate clean security sitting. +Step 8b's creation-owned native integration passes local author and installed +checks; required platform controls, two NEW complete cumulative reviews +and its separate security sitting remain open. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index 4bc0dfe4..0d4b9e3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,6 +186,9 @@ Linux/macOS/Windows plus Node 22.0.0 consumers. Broader CI retains its separatel attributed status. Step 8a implements origin adapters and an inert native foundation; it is independently accepted at `e0a5ae19` after two NEW complete cumulative clean rounds and a separate clean security sitting. +Step 8b's creation-owned native integration passes local author and installed +checks; required platform controls, two NEW complete cumulative reviews +and its separate security sitting remain open. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index 3b52037d..c3e26f47 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -213,3 +213,16 @@ NEW complete cumulative clean rounds and a separate clean security sitting. Actual same-tarball platform controls and broader CI retain their exact source and completion status. Product activation, remaining owners and whole Step 8 stay staged in 8b–8d; later review remains cumulative over the entire Step 8 range. + +## Step 8b partial implementation — 2026-10-16 + +The creation-owned native integration is implemented on `development`, with +author and independent acceptance gates still open. The +[native teardown contract](../reference/cli/commands.md#native-crash-teardown-scope) +owns platform distinctions, actual target/helper closure and finite exit +cleanup; the [MCP contract](../reference/shared-core/mcp-integration.md#creation-owned-native-stdio-lifetime) +owns the SDK-free attachment and native-versus-full-work close distinction. +The [implementation checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8b-implementation-checkpoint--2026-10-16) +keeps author, installed-host, causal and review evidence separately attributed. +Production fatal activation, workflow/session health integration and whole Step 8 +remain staged in 8c–8d. This note grants no acceptance or safe-store-close ACK. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 5caff2e8..efa20e5b 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -896,6 +896,24 @@ This attempt-all cancellation rule grants no retirement or safe-close ACK. ### Native crash teardown scope +**Step 8b implementation; independent acceptance pending.** Actual process, +OS-helper, editor and stdio MCP creation paths now receive the original invocation's +native controller through run/resume, standalone chat, one-shot agent and Home +builders, including pending/replaced builds. Construction is inert. Each actual +creation registers before asynchronous facility verification and rechecks +admission before native entry. Actual target and cleanup-helper close remain +independent retirement obligations; command rejection and successful cancellation +requests supply neither acknowledgement. Standalone clear/reseat also preserve +the already-captured MCP consent gate. + +One removable invocation-bound exit callback iterates the existing registry's +active native capabilities. There is no completed PID/group history or independent +product MCP reaper collection. The compatibility signal guard retains its existing +cooperative/grace/outcome routing until 8c/8d; connecting native creation does not +activate the complete fatal coordinator. Source fixtures explicitly install that +foundation when testing fatal cleanup. Workflow/session/terminal retirement and +safe DB close remain their separate integration obligations. + | Creation owner / platform | Permitted finite teardown | |---|---| | POSIX run_command | Its explicitly detached owned process group; retain the group token through bounded final reap even if the leader closes | @@ -910,6 +928,15 @@ Missing/invalid facility refuses Windows subprocess admission, with a fixed remedy. The OS root is trusted launching environment, not protection against an already hostile launching user/process. The helper is itself owned and its runtime fits the same remaining deadline; a successful request is not target close ACK. +Its ordinary operational request limit is fixed at entry, independently of a +later fatal deadline. Owner-scoped budget notifications only shorten/cancel that +already-entered helper; expiry without a numeric deadline is zero remaining time. +They cannot create another helper or renew either bound. Helper failure retains +the fixed infrastructure diagnosis and permits only finite cancellation of the +already-owned root/helper while their actual close remains owed. If the root +finished cooperatively before a nonzero taskkill result, that closed/now-absent +root does not create a new failure. Finite process-exit cleanup launches no helper +or normal asynchronous shutdown, including when its own cancellation attempt fails. Use actual owner close observations. MCP's latched live PID list alone is not a ChildProcess handle or race-free liveness proof: a narrow owner adapter must expose diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index 7c9b43b5..ed7e0735 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -161,10 +161,33 @@ acknowledgement and admitted descendants remain owed. `liveMcpChildPids()` includes pre-initialize stdio children; a connection's `childPid` remains latched until positive native-close acknowledgement. `McpClient.childPids` is a current view of retained initialized connections. Each acknowledged PID disappears independently while another -cleanup may still be pending; forced-exit callers must read the current view rather than cache -an old PID. The manager retains failed connection handles and reports each stored fault once +cleanup may still be pending. These compatibility observations grant no CLI kill authority; +the CLI uses the exact creation-owned native registration described below. External callers +must not retain historical PIDs as fresh liveness evidence. The manager retains failed connection handles and reports each stored fault once per supplied reporter; its default remains best-effort reporting, not semantic receipt health. +### Creation-owned native stdio lifetime + +**Wave 3 Step 8b implementation; independent acceptance pending.** +`StdioServerSpec.nativeObserver` is an optional trusted, SDK-free host projection. +Its synchronous `attach` receives a creation-latched PID reader, known-close +reader, actual native `whenClosed` promise and the existing idempotent full-owner +`close` function. Attachment precedes connect/start. Synchronous `beforeStart` +admission runs immediately before native entry, followed by an owner-open and +caller-abort recheck. Neither hook receives a transport, client or native process +handle. Both return `undefined`; an unexpected return refuses with the fixed +`observer_unavailable` lifetime reason. Ordinary returned native Promise rejection +is observed without reading a foreign `then` getter; hostile Promise +constructor/species protocols are outside this trusted callback contract. + +Actual native close remains separate from full MCP work completion. A rejected +start, callback refusal or cancellation cannot acknowledge an already-created +resource. If the host bridge refuses before attachment, it may retire only its +proved-empty reservation. Attached native work remains owed through the existing +owner. The CLI connects this projection to its original invocation registry; +its [native scope](../cli/commands.md#native-crash-teardown-scope) +owns platform-specific reap authority and its stated containment limits. + Manager startup transfers an aggregate before any server open. `startMcpClient` accepts optional trusted work options as its third argument; `McpServerConfig.open` receives them second, and SDK adapter constructors fourth. Per-server open/discovery, raw DNS, transport/fetch and failed-start diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 5793ea79..29f0ec9a 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1134,8 +1134,10 @@ acceptance observation. [Step 8a implements origin adapters and an inert native foundation](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-implementation-checkpoint--2026-10-16); [its independent acceptance](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-acceptance--2026-10-16) at `e0a5ae19` includes two NEW complete cumulative clean rounds and a separate -clean security sitting. Step 8b proceeds next; product fatal activation remains -in 8d and whole Step 8 stays open. Steps 8b–19 remain pending. The +clean security sitting. [The same reviewed source's full CI subsequently completed successfully](phases/phase-2.5.5-hardening-and-remediation.md#step-8a-completed-full-ci-follow-up--2026-10-16), +with remote totals attributed separately. [Step 8b is in implementation](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8b-implementation-checkpoint--2026-10-16) and its +acceptance remains open; product fatal activation remains in 8d and whole Step 8 +stays open. Steps 8c–19 remain staged. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 0db30bba..ecd0dfe6 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -5545,3 +5545,91 @@ narrow those reviews. Step 18 still owns central DB-opener consolidation and Step 19 publication. Detailed reports/artifacts stay local and Git-ignored. No new ADR, dependency, deferral, credential, billing, provider or user-decision gate is introduced. Step 8b proceeds automatically. + +### Step 8a completed full CI follow-up — 2026-10-16 + +The previously pending push run **38880157607 / attempt 1** subsequently +reports **success** for the exact reviewed source +`e0a5ae19981e749a75455e6b5698c806d9f1c674`. The parent queries the completed +job identities and reads main job **116919762337**: lint/typecheck, build, +format, isolation and CLI/media/overflow/budget-replay smoke gates pass. +The serialized workspace execution records **657 files / 14,790 passing tests / +21 established CI skips**: eleven dialect capability cases, nine filesystem +platform cases and one CI-only Ink projection case. These are remote execution +totals, separate from the earlier local 14,791 / twenty-skip observation. + +Filesystem, secrets, usage, wire, enforced LLM/MCP coverage and all required +same-tarball consumers/aggregate also report success. The earlier physical +package audit retains its exact six-case scope and artifact identity; a +successful job status does not imply a new independent download/audit of its +private causal artifacts. No execution of acceptance metadata `a5742dd9`, the +subsequent Step 8b working tree or its added seventh installed case is inferred. +The historical in-progress checkpoint above remains unchanged. Step 8b is in +implementation; whole Step 8 and later whole-wave gates remain open. + +## Wave 3 Step 8b implementation checkpoint — 2026-10-16 + +Step 8b implements actual native creation ownership above accepted Step 8a +metadata `a5742dd9`. Every product creation receives the original invocation's +native controller before asynchronous initialization: workflow and gate/budget +resume, one-shot live/offline agents, fresh/resumed chat, pending/replaced Home +hosts, OS helpers and both default editor bindings. Native attachment is reserved +before entry; a public error or successful cancellation request does not retire +the target. The [CLI contract](../../reference/cli/commands.md#native-crash-teardown-scope) +owns platform and helper semantics. The [MCP contract](../../reference/shared-core/mcp-integration.md#creation-owned-native-stdio-lifetime) +separates actual native closure from full SDK/work completion. A separately +reproduced standalone clear/reseat omission now carries the existing consent +gate alongside the same native authority, without bypassing approval. + +Registry entries carry live budget notifications and finite exit hooks, with +one removable creation-owned exit callback and no completed PID history or +independent process-wide reaper set. Windows admission verifies its absolute +installation facility before native entry; target and already-entered helper +remain separate obligations. Existing cooperative signal/grace behaviour is +preserved. Product fatal activation and workflow/session/terminal health remain +8c–8d; native closure alone does not acknowledge storage or receipts. + +### Step 8b author evidence and remaining gates + +The local uncommitted candidate passes serialized workspace checks: +**664 files / 14,943 passing tests / 21 declared local/platform skips**. CLI and MCP +execute freshly; nine other test tasks reuse valid cache entries. Build/format, +tools lint/typecheck, schema sync, seam/engine fences, isolation and the finite +**26-JS / 380-input / 17-dependency** closure pass. The complete acceptance-tool +command passes **496 tests** after the fixture corrections below. Its earlier execution correctly refused source +custody after a concurrent author script edit; that failed run remains failed +evidence and is followed by the successful frozen-source rerun. Compiled CLI, +media-packaging, bootstrap, overflow-capture and budget-replay smokes also pass. + +The seventh required installed case exercises actual creation-owned native +cleanup through the public offline agent route. Two early local executions +remain failed evidence: a copied Homebrew Node lacked its adjacent ABI dylib, +then two same-directory copies tried to overwrite that read-only support file. +The corrected fixture verifies and reuses the exact ordinary support bytes; +its permanent actual regression runs both copies and verifies the descendant's +own PID/parent readiness. Separate sentinel, altered-byte, symlink, callback and +delayed-close controls pass. No product mechanism changes for these fixture fixes. + +A fresh local tarball and corrected outside-workspace consumer pass **175 +bootstrap controls and all seven installed cases** on macOS/Node **22.23.1**: +**`39f47a9e006d924c551867522fd65d445074e6cf9f9bf92974f8afe241a5547f`**, +**425,640 bytes / 71 public files / 26 JS / 17 dependencies**. The parent +independently inspects exact archive file bytes/modes, source/runtime bindings, +all three actual native subcases and joined worker/installation removal. +Synthetic native error, finite creation-exit callback and natural POSIX leader +exit each retain real close obligations, leave their unrelated owned sibling +alive and complete without external-watchdog cleanup. This candidate explicitly +records `dirty: true` at parent metadata `a5742dd9`; it is local-host evidence, +not execution of a pristine successor commit or four new platform legs. +Earlier Step 8a six-case platform evidence is not substituted. Windows +simulation is not actual Windows execution; current platform receipts must +bind the same new tarball. + +Two isolated causal preparations cover 23 native mechanisms and 23 forwarding +sites; they are still unexecuted and unpinned at this checkpoint. Full source, +runtime, complete-cohort and physical dependency custody must be fixed before +execution. Any resulting local selected evidence remains distinct from a +whole-wave or family certificate. Two NEW complete cumulative clean reviews +from `d9625ff0` and a separate native/process security sitting remain required. +**Step 8b is not accepted. Whole Step 8 and Steps 8c–19 remain open.** No new +ADR, dependency, credential, billing, provider or user-decision gate is introduced. From 375d56f65a75a16e6f1b80dfa2831a0fc4315801 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 15:28:45 +0300 Subject: [PATCH 092/222] fix(cli): bound helper reentry and pin native editor fixtures Guard synchronous ChildProcess kill-error cancellation reentry while preserving later independent finite requests and both actual-close obligations. Exercise helper-error and finite-exit paths with bounded semantic regressions. Use a captured ESM editor fixture with readiness/release and watchdog evidence so native editor ownership tests remain valid under the existing source fence. Validation: full workspace lint/typecheck/test, build/format, 572 focused cases. Refs: ADR-0110, Wave 3 Step 8b Co-Authored-By: Codex --- apps/cli/src/commands/chat.test.ts | 38 +++++++++----- apps/cli/src/home/drive-home.test.ts | 51 ++++++++++--------- .../src/process/native-process-owner.test.ts | 39 ++++++++++++++ apps/cli/src/process/native-process-owner.ts | 8 ++- .../cli/src/render/fixtures/native-editor.mjs | 20 ++++++++ 5 files changed, 118 insertions(+), 38 deletions(-) create mode 100644 apps/cli/src/render/fixtures/native-editor.mjs diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index 7239d306..3b5babb5 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -3,10 +3,19 @@ import { witnessNativeToolProcess } from './native-forwarding.test-support.js'; import { appendGrant, fingerprint, resolveStdioSpawn } from '../engine/mcp-consent.js'; import { mcpConsentPath } from '../engine/mcp-consent-gate.js'; import { randomUUID } from 'node:crypto'; -import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough, Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; import type { SessionStreamHandleEvent } from '@relavium/core'; import { makeLlmError, type ProviderId, type StreamChunk } from '@relavium/llm'; @@ -1870,31 +1879,30 @@ describe('chatCommand', () => { it('binds the default standalone editor to the original native registry', async () => { const invocation = createCliInvocation(); const marker = join(cwd, 'editor-started'); - const script = join(cwd, 'editor.cjs'); - writeFileSync( - script, - `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'started'); setTimeout(() => {}, 200);`, - ); + const release = join(cwd, 'editor-release'); + const script = fileURLToPath(new URL('../render/fixtures/native-editor.mjs', import.meta.url)); const { d } = deps([], []); const drive: ChatDriver = async (ctx) => { ctx.startSession(); ctx.store.notice('editor transcript witness'); if (ctx.suspendPort === undefined) throw new Error('default editor suspend port is missing'); ctx.suspendPort.attach((body) => body()); + const editing = ctx.processLine('/edit'); try { - const editing = ctx.processLine('/edit'); await vi.waitFor( () => { expect(invocation.registry.activeOwners, 'NATIVE_STANDALONE_EDITOR_FORWARDING').toBe(1); - expect(existsSync(marker)).toBe(true); + expect(readFileSync(marker, 'utf8')).toBe('ready\n'); }, - { interval: 1 }, + { interval: 5, timeout: 3000 }, ); - await editing; - await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(0)); } finally { + writeFileSync(release, 'release'); + await editing; ctx.suspendPort.attach(undefined); } + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(0)); + expect(readFileSync(marker, 'utf8')).toBe('ready\nreleased\n'); await ctx.processLine('/exit'); return { kind: ctx.stopReason() }; }; @@ -1905,13 +1913,17 @@ describe('chatCommand', () => { ...d, ...INERT_HOIST, invocation, - io: { ...d.io, stdoutIsTty: true, env: { EDITOR: `"${process.execPath}" "${script}"` } }, + io: { + ...d.io, + stdoutIsTty: true, + env: { EDITOR: `"${process.execPath}" "${script}" "${marker}" "${release}"` }, + }, drive, }, ), ).toBe(EXIT_CODES.chatEnded); expect(existsSync(marker)).toBe(true); - }); + }, 10000); it.each([ ['fresh', 'clear'], diff --git a/apps/cli/src/home/drive-home.test.ts b/apps/cli/src/home/drive-home.test.ts index 136a8535..7b960f6b 100644 --- a/apps/cli/src/home/drive-home.test.ts +++ b/apps/cli/src/home/drive-home.test.ts @@ -3,10 +3,11 @@ import { holdNativeToolProcess, witnessNativeToolProcess, } from '../commands/native-forwarding.test-support.js'; -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Readable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; import { createClient, @@ -522,42 +523,44 @@ describe('driveHome (2.5.B / ADR-0054)', () => { it('binds the default Home editor to the original native registry', async () => { const invocation = createCliInvocation(); const marker = join(cwd, 'home-editor-started'); - const script = join(cwd, 'home-editor.cjs'); - writeFileSync( - script, - `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'started'); setTimeout(() => {}, 200);`, - ); + const release = join(cwd, 'home-editor-release'); + const script = fileURLToPath(new URL('../render/fixtures/native-editor.mjs', import.meta.url)); let captured: RootAppProps | undefined; const { deps } = makeDeps((props) => (captured = props), { invocation, - io: { ...io, env: { EDITOR: `"${process.execPath}" "${script}"` } }, + io: { ...io, env: { EDITOR: `"${process.execPath}" "${script}" "${marker}" "${release}"` } }, }); const drivePromise = driveHome(deps); const props = captured; if (props?.suspendPort === undefined) throw new Error('default Home editor suspend port is missing'); props.suspendPort.attach((body) => body()); - type(props, 'hello'); - props.controller.handleKey('', ENTER); - await vi.waitFor(() => expect(props.controller.getSnapshot().session).toBeDefined()); - await vi.waitFor(() => expect(props.controller.getSnapshot().submitBusy).toBe(false)); - props.controller.handlePaste('/edit'); - props.controller.handleKey('', ENTER); - await vi.waitFor( - () => { - expect(invocation.registry.activeOwners, 'NATIVE_HOME_EDITOR_FORWARDING').toBe(1); - expect(existsSync(marker)).toBe(true); - }, - { interval: 1 }, - ); - await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(0)); - await vi.waitFor(() => expect(props.controller.getSnapshot().submitBusy).toBe(false)); - props.suspendPort.attach(undefined); + try { + type(props, 'hello'); + props.controller.handleKey('', ENTER); + await vi.waitFor(() => expect(props.controller.getSnapshot().session).toBeDefined()); + await vi.waitFor(() => expect(props.controller.getSnapshot().submitBusy).toBe(false)); + props.controller.handlePaste('/edit'); + props.controller.handleKey('', ENTER); + await vi.waitFor( + () => { + expect(invocation.registry.activeOwners, 'NATIVE_HOME_EDITOR_FORWARDING').toBe(1); + expect(readFileSync(marker, 'utf8')).toBe('ready\n'); + }, + { interval: 5, timeout: 3000 }, + ); + } finally { + writeFileSync(release, 'release'); + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(0), { timeout: 6000 }); + await vi.waitFor(() => expect(props.controller.getSnapshot().submitBusy).toBe(false)); + props.suspendPort.attach(undefined); + } + expect(readFileSync(marker, 'utf8')).toBe('ready\nreleased\n'); props.controller.handleKey('c', CTRL_C); await vi.waitFor(() => expect(props.controller.getSnapshot().mode).toBe('home')); props.controller.handleKey('c', CTRL_C); expect(await drivePromise).toBe(EXIT_CODES.success); - }); + }, 10000); it('a /models write is picked up by the NEXT chat started in the SAME Home process (2.5.G S7 regression)', async () => { // The blocker Sonnet review caught: `startChat` closed over the load-once `config` snapshot, so a `/models` diff --git a/apps/cli/src/process/native-process-owner.test.ts b/apps/cli/src/process/native-process-owner.test.ts index 6bc03752..b05f71a1 100644 --- a/apps/cli/src/process/native-process-owner.test.ts +++ b/apps/cli/src/process/native-process-owner.test.ts @@ -275,6 +275,45 @@ describe('creation-owned native processes', () => { await lease.whenClosed; }); + it.each(['helper error', 'finite exit'])( + 'bounds synchronous helper kill-error reentry during %s without acknowledging close', + async (entry) => { + const h = harness('win32'); + const lease = await h.controller.reserve('pid'); + const target = child(); + lease.bindChild(target); + void lease.stop(); + let killRequests = 0; + h.helperKill.mockImplementation(() => { + killRequests++; + // Node's native kill refusal emits error synchronously. Bound the simulated refusal + // so removing the guard yields a semantic count failure, not a stack-overflow witness. + if (killRequests <= 2) h.helper.emit('error', new Error('synthetic native kill refusal')); + return false; + }); + if (entry === 'finite exit') { + target.emit('close', 0); + h.exit(); + expect(h.registry.termination).toBeUndefined(); + } else { + h.helper.emit('error', new Error('synthetic helper failure')); + } + expect(killRequests, 'WINDOWS_HELPER_CANCEL_REENTRY').toBe(1); + expect(h.registry.observations.exitCode()).toBe(9); + expect(h.registry.activeOwners, 'WINDOWS_HELPER_CANCEL_REENTRY').toBe(1); + target.emit('close', 0); + await Promise.resolve(); + expect(h.registry.activeOwners, 'WINDOWS_HELPER_CANCEL_REENTRY').toBe(1); + // A later, independent exit attempt remains possible; neither failed request was an ACK. + h.helperKill.mockReturnValue(false); + h.exit(); + expect(h.helperKill).toHaveBeenCalledTimes(2); + h.helper.emit('close', null); + await lease.whenClosed; + expect(h.registry.activeOwners).toBe(0); + }, + ); + it('does not enter taskkill for a root whose exit precedes stdio close', async () => { const h = harness('win32'); const lease = await h.controller.reserve('pid'); diff --git a/apps/cli/src/process/native-process-owner.ts b/apps/cli/src/process/native-process-owner.ts index 53b6e457..7d5e077d 100644 --- a/apps/cli/src/process/native-process-owner.ts +++ b/apps/cli/src/process/native-process-owner.ts @@ -167,6 +167,7 @@ class NativeCreation { #helperEntered = false; #helperClosed = true; #helperFailed = false; + #helperCancelling = false; #helperDeadline: number | undefined; #timerDeadline: number | undefined; #removeHelperTimer: (() => void) | undefined; @@ -484,11 +485,16 @@ class NativeCreation { } #cancelHelper(): void { - if (this.#helper === undefined || this.#helperClosed) return; + if (this.#helper === undefined || this.#helperClosed || this.#helperCancelling) return; + // ChildProcess.kill can emit error synchronously. Its listener may request cancellation + // again; suppress that reentry while allowing a later independent finite request. + this.#helperCancelling = true; try { this.#helper.kill('SIGKILL'); } catch { this.#failed(); + } finally { + this.#helperCancelling = false; } } diff --git a/apps/cli/src/render/fixtures/native-editor.mjs b/apps/cli/src/render/fixtures/native-editor.mjs new file mode 100644 index 00000000..36a6f4df --- /dev/null +++ b/apps/cli/src/render/fixtures/native-editor.mjs @@ -0,0 +1,20 @@ +/** Inert, source-pinned editor child: readiness and release replace a scheduling-dependent sleep. */ +import { appendFileSync, existsSync, writeFileSync } from 'node:fs'; +import process from 'node:process'; +import { clearInterval, clearTimeout, setInterval, setTimeout } from 'node:timers'; + +const marker = process.argv[2]; +const release = process.argv[3]; +if (marker === undefined || release === undefined) throw new Error('Missing editor fixture paths'); +writeFileSync(marker, 'ready\n'); +const watchdog = setTimeout(() => { + appendFileSync(marker, 'watchdog\n'); + clearInterval(poll); + process.exitCode = 2; +}, 5000); +const poll = setInterval(() => { + if (!existsSync(release)) return; + clearTimeout(watchdog); + clearInterval(poll); + appendFileSync(marker, 'released\n'); +}, 5); From f14b6f8ab2d53159b58b1b8e3d48085cf234e6fc Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 15:28:53 +0300 Subject: [PATCH 093/222] fix(repo): retain worker failures and refresh control inventories Emit a bounded synthetic failure diagnostic after installed-worker cleanup, without publishing success or bypassing actual worker/installation joins. Cover available and refused stderr in real addon-worker regressions. Refresh five complete secrets workload pins and the 440-case usage permanent inventory, keeping every causal witness, native child and source fence intact. Validation: 505 acceptance-tool tests, tools lint/typecheck and full workspaces. Refs: ADR-0110, ADR-0111, Wave 3 Step 8b Co-Authored-By: Codex --- package.json | 2 +- tools/cli-package/README.md | 7 + tools/cli-package/consumer-lifetime.test.mjs | 87 ++++++- .../cli-package/smoke/failure-diagnostic.mjs | 181 ++++++++++++++ .../smoke/failure-diagnostic.test.mjs | 220 ++++++++++++++++++ tools/cli-package/smoke/worker.mjs | 6 +- tools/secrets-controls/README.md | 6 +- tools/secrets-controls/mutations.mjs | 10 +- tools/usage-coverage-controls/README.md | 2 +- tools/usage-coverage-controls/workload.mjs | 2 +- .../usage-coverage-controls/workload.test.mjs | 4 +- 11 files changed, 508 insertions(+), 19 deletions(-) create mode 100644 tools/cli-package/smoke/failure-diagnostic.mjs create mode 100644 tools/cli-package/smoke/failure-diagnostic.test.mjs diff --git a/package.json b/package.json index d3bbbd80..4cfb5cd6 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "smoke:budget-replay": "node tools/budget-replay-compat/check.mjs", "check:wire-mutations": "node tools/wire-conformance/check.mjs", "check:usage-mutations": "node tools/usage-coverage-controls/check.mjs", - "test:acceptance-tools": "node --test tools/ci/windows-headless-smoke.test.mjs tools/ci/workspace-scheduling.test.mjs tools/ci/require-native-db-diagnostics.test.mjs tools/usage-coverage-controls/outcome.test.mjs tools/usage-coverage-controls/workload.test.mjs tools/ci/require-w8-acceptance.test.mjs tools/acceptance-evidence/export.test.mjs tools/wire-conformance/provenance.test.mjs tools/causal-controls/outcome.test.mjs tools/causal-controls/process.test.mjs tools/causal-controls/provenance.test.mjs tools/causal-controls/retention.test.mjs tools/causal-controls/run.test.mjs tools/causal-controls/timeout-policy.test.mjs tools/bundle-closure/closure.test.mjs tools/cli-bootstrap/witness.test.mjs tools/ci/require-packed-cli.test.mjs tools/cli-package/archive.test.mjs tools/cli-package/binding.test.mjs tools/cli-package/process.test.mjs tools/cli-package/files.test.mjs tools/cli-package/smoke/process.test.mjs tools/cli-package/smoke/native-process.test.mjs tools/cli-package/smoke/native-spawn-witness.test.mjs tools/cli-package/pipeline.test.mjs tools/cli-package/consumer-lifetime.test.mjs", + "test:acceptance-tools": "node --test tools/ci/windows-headless-smoke.test.mjs tools/ci/workspace-scheduling.test.mjs tools/ci/require-native-db-diagnostics.test.mjs tools/usage-coverage-controls/outcome.test.mjs tools/usage-coverage-controls/workload.test.mjs tools/ci/require-w8-acceptance.test.mjs tools/acceptance-evidence/export.test.mjs tools/wire-conformance/provenance.test.mjs tools/causal-controls/outcome.test.mjs tools/causal-controls/process.test.mjs tools/causal-controls/provenance.test.mjs tools/causal-controls/retention.test.mjs tools/causal-controls/run.test.mjs tools/causal-controls/timeout-policy.test.mjs tools/bundle-closure/closure.test.mjs tools/cli-bootstrap/witness.test.mjs tools/ci/require-packed-cli.test.mjs tools/cli-package/archive.test.mjs tools/cli-package/binding.test.mjs tools/cli-package/process.test.mjs tools/cli-package/files.test.mjs tools/cli-package/smoke/process.test.mjs tools/cli-package/smoke/failure-diagnostic.test.mjs tools/cli-package/smoke/native-process.test.mjs tools/cli-package/smoke/native-spawn-witness.test.mjs tools/cli-package/pipeline.test.mjs tools/cli-package/consumer-lifetime.test.mjs", "check:fs-mutations": "node tools/fs-policy-controls/check.mjs", "check:secrets-mutations": "node tools/secrets-controls/check.mjs", "smoke:cli-bootstrap": "node tools/cli-bootstrap/check.mjs", diff --git a/tools/cli-package/README.md b/tools/cli-package/README.md index 15452fe7..23a524fd 100644 --- a/tools/cli-package/README.md +++ b/tools/cli-package/README.md @@ -50,6 +50,13 @@ not unloading its native DLL on Windows. Success receipt/summary publication occ only after required installation removal; a worker or cleanup failure prevents publication, and a secondary cleanup/diagnostic failure preserves the primary. +A failed worker emits one bounded synthetic-test diagnostic on stderr after its +cleanup and private report write. The projection identifies failed canonical cases +and selected native-close facts without copying report paths, environment, argv, +transcripts or raw child output. It remains failure evidence: the parent still joins +the worker and removes its installation, and no success receipt is published. +An unavailable diagnostic sink does not replace the original failure. + Required PR/push consumers use current `.nvmrc` Linux/macOS/Windows and exact Node 22.0.0 Linux. The aggregate reinspects the tarball and requires all four unique matching receipts plus successful pack/matrix jobs. Missing, failed, skipped, diff --git a/tools/cli-package/consumer-lifetime.test.mjs b/tools/cli-package/consumer-lifetime.test.mjs index 23d4bfff..7f222285 100644 --- a/tools/cli-package/consumer-lifetime.test.mjs +++ b/tools/cli-package/consumer-lifetime.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; import { copyFileSync, existsSync, @@ -14,8 +15,12 @@ import { dirname, join } from 'node:path'; import { test } from 'node:test'; import { pathToFileURL } from 'node:url'; import { completeConsumer } from './consumer-lifetime.mjs'; +import { REQUIRED_SMOKE_CASES } from './smoke/cases.mjs'; -async function fixture(t, { wrongPid = false, exitCode = 0 } = {}) { +async function fixture( + t, + { wrongPid = false, exitCode = 0, nativeFailure = false, diagnosticSinkFailure = false } = {}, +) { const root = mkdtempSync(join(tmpdir(), 'relavium-consumer-lifetime-')); t.after(() => rmSync(root, { recursive: true, force: true })); const smoke = join(root, 'smoke'), @@ -25,6 +30,8 @@ async function fixture(t, { wrongPid = false, exitCode = 0 } = {}) { for (const name of ['process.mjs', 'files.mjs']) copyFileSync(new URL(name, import.meta.url), join(root, name)); copyFileSync(new URL('smoke/index.mjs', import.meta.url), join(smoke, 'index.mjs')); + for (const name of ['failure-diagnostic.mjs', 'cases.mjs']) + copyFileSync(new URL(`smoke/${name}`, import.meta.url), join(smoke, name)); const require = createRequire(new URL('../../packages/db/package.json', import.meta.url)); const binary = join( dirname(require.resolve('better-sqlite3/package.json')), @@ -38,16 +45,33 @@ async function fixture(t, { wrongPid = false, exitCode = 0 } = {}) { ` import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; -import { writeFileSync } from 'node:fs'; +import { closeSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { emitInstalledWorkerFailure } from './failure-diagnostic.mjs'; const [, , packageRoot, supportRoot, artifactSha256, source] = process.argv; const addon = createRequire(import.meta.url)(join(packageRoot, 'native.node')); assert.equal(typeof addon.Database, 'function'); -writeFileSync(join(supportRoot, 'installed-smoke.json'), JSON.stringify({ +const report = { source, artifactSha256, pid: ${wrongPid ? '0' : 'process.pid'}, parentPid: process.ppid, - runtime: process.version, platform: process.platform, success: true, nativeLoaded: true -})); + runtime: process.version, platform: process.platform, success: ${!nativeFailure}, nativeLoaded: true, + compiledUnchanged: true, ownedTempRemoved: true, + cases: ${JSON.stringify(REQUIRED_SMOKE_CASES.map((id) => ({ id, passed: true })))}, +}; +if (${nativeFailure}) { + const failed = report.cases.find((item) => item.id === 'actual-native-process-owner'); + failed.passed = false; + failed.error = 'AssertionError [ERR_ASSERTION]: synthetic helper-close failure'; + failed.evidence = { outcomes: [{ mode: 'injected-error', gate: 'helper', passed: false, + observed: [{ operation: 'native-helper-actual-close', pid: 4242, code: 1, signal: null }], + cleanup: { cliActualCloseObserved: true, sentinel: { actualCloseObserved: true, watchdogFired: false } } + }] }; +} +writeFileSync(join(supportRoot, 'installed-smoke.json'), JSON.stringify(report)); process.on('exit', () => writeFileSync(join(supportRoot, 'worker-exit'), 'observed')); +if (${nativeFailure}) { + if (${diagnosticSinkFailure}) closeSync(2); + emitInstalledWorkerFailure(report); +} setTimeout(() => { process.exitCode = ${exitCode}; }, 50); `, ); @@ -98,6 +122,59 @@ test('real addon worker closes before installation removal and success publicati } }); +for (const diagnosticSinkFailure of [false, true]) + test(`synthetic native-case failure in a real addon worker is joined, diagnosed and unpublished (stderr refusal: ${diagnosticSinkFailure})`, async (t) => { + const own = await fixture(t, { exitCode: 1, nativeFailure: true, diagnosticSinkFailure }); + let primary, + published = false; + await assert.rejects( + completeConsumer({ + exercise: async () => { + try { + return await own.run(); + } catch (error) { + primary = error; + throw error; + } + }, + cleanup: () => { + assert.equal(readFileSync(join(own.support, 'worker-exit'), 'utf8'), 'observed'); + rmSync(own.installation, { recursive: true, force: true }); + }, + cleanupFailed: () => assert.fail('unexpected secondary cleanup failure'), + publish: () => { + published = true; + }, + }), + (error) => { + assert.equal(error, primary); + assert.equal(error.result.code, 1); + assert.equal(error.result.signal, null); + assert.equal(error.result.stdout, ''); + if (diagnosticSinkFailure) assert.equal(error.result.stderr, ''); + else { + assert.ok(Buffer.byteLength(error.result.stderr) <= 8192); + const diagnostic = JSON.parse(error.result.stderr); + assert.equal(diagnostic.type, 'installed-cli-worker-failure'); + assert.equal(diagnostic.source, 'b'.repeat(40)); + assert.equal(diagnostic.artifactSha256, 'a'.repeat(64)); + assert.equal(diagnostic.runtime, process.version); + assert.equal(diagnostic.workerPid, error.result.pid); + assert.equal(diagnostic.parentPid, process.pid); + assert.equal(diagnostic.cases.length, 1); + assert.equal(diagnostic.cases[0].id, 'actual-native-process-owner'); + assert.match(diagnostic.cases[0].error, /synthetic helper-close failure/); + assert.equal(diagnostic.cases[0].native[0].observed[0].code, 1); + assert.equal(diagnostic.cases[0].native[0].cleanup.cliActualCloseObserved, true); + } + return true; + }, + ); + assert.equal(published, false); + assert.equal(existsSync(own.installation), false); + assert.equal(existsSync(join(own.support, 'installed-consumer.json')), false); + }); + for (const [name, options, expected] of [ ['foreign PID', { wrongPid: true }, /not from the joined worker/], ['nonzero worker after an early success file', { exitCode: 12 }, /CHILD_FAILED/], diff --git a/tools/cli-package/smoke/failure-diagnostic.mjs b/tools/cli-package/smoke/failure-diagnostic.mjs new file mode 100644 index 00000000..4fdfbbcc --- /dev/null +++ b/tools/cli-package/smoke/failure-diagnostic.mjs @@ -0,0 +1,181 @@ +import { Buffer } from 'node:buffer'; +import { writeSync } from 'node:fs'; +import { REQUIRED_SMOKE_CASES } from './cases.mjs'; + +export const MAX_WORKER_FAILURE_BYTES = 8192; +const modes = new Set(['injected-error', 'exit-fallback', 'facility-refusal', 'leader-exit']); +const signals = new Set([ + 'SIGKILL', + 'SIGTERM', + 'SIGINT', + 'SIGHUP', + 'SIGQUIT', + 'SIGABRT', + 'SIGSEGV', + 'SIGILL', + 'SIGBUS', + 'SIGFPE', + 'SIGPIPE', + 'SIGTRAP', + 'SIGSYS', + 'SIGXCPU', + 'SIGXFSZ', +]); +const operations = new Set([ + 'native-target-spawn', + 'native-helper-spawn', + 'native-fixture-root', + 'native-fixture-descendant', + 'native-fixture-descendant-ready', + 'native-target-error', + 'native-helper-error', + 'native-target-actual-close', + 'native-helper-actual-close', + 'native-exit-listener-released', + 'native-synthetic-error-injected', + 'native-captured-exit-reap-invoked', + 'native-witness-gate-timeout', + 'native-witness-readiness-refused', +]); +const object = (value) => (value !== null && typeof value === 'object' ? value : {}); +const list = (value, maximum) => (Array.isArray(value) ? value.slice(0, maximum) : []); +const bool = (value) => (typeof value === 'boolean' ? value : undefined); +const pid = (value) => (Number.isSafeInteger(value) && value > 0 ? value : undefined); +const code = (value) => + value === null || (Number.isSafeInteger(value) && Math.abs(value) <= 0xffffffff) + ? value + : undefined; +const signal = (value) => (value === null || signals.has(value) ? value : undefined); +const identity = (value, pattern) => + typeof value === 'string' && value.length <= 128 && pattern.test(value) ? value : 'unavailable'; +function errorText(value, maximum = 256) { + if (typeof value !== 'string') return undefined; + // Only the first diagnostic line is useful here; stacks, assertion diffs and child output stay private. + const first = value.slice(0, 1024).split(/[\r\n]/u, 1)[0]; + if (/[a-z]:[\\/]|file:\/\/|(?:^|[\s("'=])(?:\.{1,2}[\\/]|[\\/])/iu.test(first)) { + const errno = + /^Error:\s*(EACCES|EPERM|ENOENT|EBUSY|EIO|EEXIST|EINVAL|ENOTDIR|EISDIR|ENAMETOOLONG|ETIMEDOUT|ENOSPC|EROFS)\b/u.exec( + first, + )?.[1]; + return `${errno === undefined ? 'Failure' : errno}: path detail omitted.`; + } + return first.replace(/[^\x20-\x7e]/gu, '?').slice(0, maximum); +} +function observation(raw) { + const value = object(raw); + if (!operations.has(value.operation)) return undefined; + return { + operation: value.operation, + pid: pid(value.pid), + parentPid: pid(value.parentPid), + code: code(value.code), + signal: signal(value.signal), + detached: bool(value.detached), + shell: bool(value.shell), + error: errorText(value.reason, 128), + }; +} +function nativeMode(raw) { + const value = object(raw), + cleanup = object(value.cleanup), + cli = object(value.cli); + if (!modes.has(value.mode)) return undefined; + const observed = Array.isArray(value.observed) ? value.observed : []; + const sampled = + observed.length > 16 ? [...observed.slice(0, 8), ...observed.slice(-8)] : observed; + return { + mode: value.mode, + passed: bool(value.passed), + gate: ['target', 'helper', 'none'].includes(value.gate) ? value.gate : undefined, + watchdogFired: bool(value.watchdogFired), + cli: { code: code(cli.code), signal: signal(cli.signal) }, + observed: sampled.map(observation).filter(Boolean), + observationsTruncated: observed.length > 16, + cleanup: { + releaseWritten: bool(cleanup.releaseWritten), + cliActualCloseObserved: bool(cleanup.cliActualCloseObserved), + native: list(cleanup.native, 8).map((raw) => { + const item = object(raw); + return { pid: pid(item.pid), absenceObserved: bool(item.absenceObserved) }; + }), + sentinel: { + pid: pid(object(cleanup.sentinel).pid), + actualCloseObserved: bool(object(cleanup.sentinel).actualCloseObserved), + watchdogFired: bool(object(cleanup.sentinel).watchdogFired), + }, + failures: list(cleanup.failures, 3) + .map((value) => errorText(value, 128)) + .filter(Boolean), + }, + }; +} + +/** Strict synthetic-test projection; no report spread, paths, argv, env, transcript or child output. */ +export function formatInstalledWorkerFailure(raw) { + const report = object(raw); + if (report.success === true) return undefined; + const cases = list(report.cases, REQUIRED_SMOKE_CASES.length * 2); + const diagnostic = { + type: 'installed-cli-worker-failure', + source: identity(report.source, /^[a-f0-9]{40}$/u), + artifactSha256: identity(report.artifactSha256, /^[a-f0-9]{64}$/u), + runtime: identity(report.runtime, /^v\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/u), + platform: ['linux', 'darwin', 'win32'].includes(report.platform) + ? report.platform + : 'unavailable', + workerPid: pid(report.pid), + parentPid: pid(report.parentPid), + error: errorText(report.error), + cleanupError: errorText(report.cleanupError), + immutabilityError: errorText(report.immutabilityError), + compiledUnchanged: bool(report.compiledUnchanged), + ownedTempRemoved: bool(report.ownedTempRemoved), + cases: REQUIRED_SMOKE_CASES.flatMap((id) => { + const item = cases.find((value) => object(value).id === id); + if (item?.passed === true) return []; + return [ + { + id, + status: item === undefined ? 'missing' : 'failed', + error: errorText(item?.error), + ...(id === 'actual-native-process-owner' + ? { + native: list(object(item?.evidence).outcomes, 6) + .map(nativeMode) + .filter(Boolean) + .slice(0, 3), + } + : {}), + }, + ]; + }), + }; + let line = JSON.stringify(diagnostic) + '\n'; + if (Buffer.byteLength(line) > MAX_WORKER_FAILURE_BYTES) { + diagnostic.truncated = true; + for (const item of diagnostic.cases) { + item.error = errorText(item.error, 96); + for (const mode of item.native ?? []) { + if (mode.observed.length > 4) + mode.observed = [...mode.observed.slice(0, 2), ...mode.observed.slice(-2)]; + mode.observationsTruncated = true; + mode.cleanup.failures = mode.cleanup.failures.slice(0, 1); + } + } + line = JSON.stringify(diagnostic) + '\n'; + } + // The compact fixed inventory fits well below this bound even with all seven failures. + if (Buffer.byteLength(line) > MAX_WORKER_FAILURE_BYTES) + throw new Error('Worker failure diagnostic bound exceeded'); + return line; +} + +/** Failure reporting is secondary: fd2 refusal never replaces the primary or falls back to stdout. */ +export function emitInstalledWorkerFailure(report, write = (line) => writeSync(2, line)) { + try { + const line = formatInstalledWorkerFailure(report); + if (line !== undefined) write(line); + } catch { + // The caller has already latched its failed exit; actual worker join/cleanup still belongs to its owner. + } +} diff --git a/tools/cli-package/smoke/failure-diagnostic.test.mjs b/tools/cli-package/smoke/failure-diagnostic.test.mjs new file mode 100644 index 00000000..39f76e60 --- /dev/null +++ b/tools/cli-package/smoke/failure-diagnostic.test.mjs @@ -0,0 +1,220 @@ +import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; +import { test } from 'node:test'; +import { REQUIRED_SMOKE_CASES } from './cases.mjs'; +import { + emitInstalledWorkerFailure, + formatInstalledWorkerFailure, + MAX_WORKER_FAILURE_BYTES, +} from './failure-diagnostic.mjs'; + +function report() { + return { + source: 'a'.repeat(40), + artifactSha256: 'b'.repeat(64), + runtime: process.version, + platform: process.platform, + pid: 100, + parentPid: 50, + success: false, + compiledUnchanged: true, + ownedTempRemoved: true, + cases: REQUIRED_SMOKE_CASES.map((id) => ({ id, passed: true })), + }; +} + +test('successful worker stays silent on both formatter and failure-only emitter', () => { + const success = { ...report(), success: true }; + assert.equal(formatInstalledWorkerFailure(success), undefined); + let writes = 0; + emitInstalledWorkerFailure(success, () => { + writes++; + }); + assert.equal(writes, 0); +}); + +test('failure identifies the exact artifact/runtime/canonical case and partial native cleanup facts', () => { + const failed = report(); + failed.cases.at(-1).passed = false; + failed.cases.at(-1).error = + 'AssertionError [ERR_ASSERTION]: synthetic helper close failed\nprivate stack omitted'; + failed.cases.at(-1).evidence = { + outcomes: [ + { + mode: 'injected-error', + gate: 'helper', + passed: false, + watchdogFired: false, + observed: [{ operation: 'native-helper-actual-close', pid: 80, code: 1, signal: null }], + cleanup: { + releaseWritten: true, + cliActualCloseObserved: true, + native: [{ pid: 40, absenceObserved: true }], + sentinel: { pid: 70, actualCloseObserved: true, watchdogFired: false }, + failures: ['Error: synthetic cleanup fault'], + }, + }, + ], + }; + const line = formatInstalledWorkerFailure(failed); + assert.equal(line.split('\n').length, 2); + const diagnostic = JSON.parse(line); + assert.equal(diagnostic.source, failed.source); + assert.equal(diagnostic.artifactSha256, failed.artifactSha256); + assert.equal(diagnostic.runtime, process.version); + assert.equal(diagnostic.workerPid, 100); + assert.equal(diagnostic.parentPid, 50); + assert.deepEqual( + diagnostic.cases.map((item) => item.id), + ['actual-native-process-owner'], + ); + assert.equal( + diagnostic.cases[0].error, + 'AssertionError [ERR_ASSERTION]: synthetic helper close failed', + ); + assert.equal(diagnostic.cases[0].native[0].observed[0].code, 1); + assert.equal(diagnostic.cases[0].native[0].cleanup.sentinel.actualCloseObserved, true); +}); + +test('strict projection excludes paths, env, argv, transcripts, unknown IDs and raw child output', () => { + const failed = report(); + const secret = 'UNEXPECTED_PRIVATE_PAYLOAD'; + failed.cases = REQUIRED_SMOKE_CASES.map((id) => ({ + id, + passed: id !== 'actual-native-process-owner', + error: 'Error: EACCES opening C:\\private\\fixture.exe', + env: { secret }, + argv: [secret], + transcript: secret, + evidence: { + outcomes: [ + { + mode: 'injected-error', + cli: { code: 1, signal: null, stdout: secret, stderr: secret }, + observed: [ + { + operation: 'native-target-actual-close', + pid: 40, + code: 1, + signal: null, + executable: '/private/fixture', + args: [secret], + stdout: secret, + stderr: secret, + }, + { operation: secret, pid: 41 }, + ], + cleanup: { cliActualCloseObserved: true, failures: ["Error: unlink '../private/file'"] }, + }, + ], + }, + })); + failed.cases.push({ id: secret, passed: false, error: secret }); + failed.error = 'Error: failed at /private/path'; + failed.packageRoot = '/private/package'; + const line = formatInstalledWorkerFailure(failed); + for (const excluded of [ + secret, + '/private', + 'C:\\private', + 'transcript', + 'executable', + 'argv', + 'stdout', + 'stderr', + ]) + assert.equal(line.includes(excluded), false); + const diagnostic = JSON.parse(line); + assert.equal(diagnostic.cases.length, 1); + assert.match(diagnostic.error, /path/); + assert.equal(diagnostic.cases[0].error, 'EACCES: path detail omitted.'); + assert.equal(diagnostic.cases[0].native[0].observed.length, 1); +}); + +test('worker-level cleanup and immutability failures remain visible when all cases passed', () => { + const failed = { + ...report(), + compiledUnchanged: false, + ownedTempRemoved: false, + cleanupError: 'Error: synthetic cleanup refusal', + immutabilityError: 'Error: synthetic compiled drift', + }; + const diagnostic = JSON.parse(formatInstalledWorkerFailure(failed)); + assert.deepEqual(diagnostic.cases, []); + assert.equal(diagnostic.compiledUnchanged, false); + assert.equal(diagnostic.ownedTempRemoved, false); + assert.match(diagnostic.cleanupError, /synthetic cleanup refusal/); + assert.match(diagnostic.immutabilityError, /synthetic compiled drift/); +}); + +test('oversized Unicode/errors/native observations produce one valid line within eight KiB', () => { + const failed = report(); + failed.error = 'Error: ' + '超'.repeat(100000); + failed.cases = REQUIRED_SMOKE_CASES.map((id) => ({ + id, + passed: false, + error: 'AssertionError: ' + 'x'.repeat(100000), + evidence: { + outcomes: Array.from({ length: 100 }, () => ({ + mode: 'injected-error', + gate: 'helper', + passed: false, + observed: Array.from({ length: 100 }, () => ({ + operation: 'native-helper-actual-close', + pid: 80, + parentPid: 40, + code: 0xc0000135, + signal: null, + detached: false, + shell: false, + reason: 'Error: ' + 'x'.repeat(1000), + })), + cleanup: { failures: Array(100).fill('Error: ' + 'x'.repeat(1000)) }, + })), + }, + })); + const line = formatInstalledWorkerFailure(failed); + assert.ok(Buffer.byteLength(line) <= MAX_WORKER_FAILURE_BYTES); + assert.equal(line.split('\n').length, 2); + const diagnostic = JSON.parse(line); + assert.equal(diagnostic.truncated, true); + assert.equal(diagnostic.cases.length, REQUIRED_SMOKE_CASES.length); + assert.equal(diagnostic.cases.at(-1).native[0].observed[0].code, 0xc0000135); +}); + +test('invalid identities and missing cases cannot introduce arbitrary diagnostic fields or case IDs', () => { + const failed = { + ...report(), + source: '/private/source', + artifactSha256: 'invalid', + runtime: 'untrusted runtime', + platform: 'untrusted platform', + cases: [{ id: 'unknown-case', passed: false }], + }; + const diagnostic = JSON.parse(formatInstalledWorkerFailure(failed)); + for (const key of ['source', 'artifactSha256', 'runtime', 'platform']) + assert.equal(diagnostic[key], 'unavailable'); + assert.deepEqual( + diagnostic.cases.map((item) => item.id), + REQUIRED_SMOKE_CASES, + ); + assert.ok(diagnostic.cases.every((item) => item.status === 'missing')); + assert.equal( + JSON.parse(formatInstalledWorkerFailure({ ...report(), runtime: `v${'1'.repeat(10000)}.0.0` })) + .runtime, + 'unavailable', + ); +}); + +test('stderr refusal is secondary and never invokes another sink or changes the failed report', () => { + const failed = report(); + let attempted = 0; + assert.doesNotThrow(() => + emitInstalledWorkerFailure(failed, () => { + attempted++; + throw new Error('synthetic stderr refusal'); + }), + ); + assert.equal(attempted, 1); + assert.equal(failed.success, false); +}); diff --git a/tools/cli-package/smoke/worker.mjs b/tools/cli-package/smoke/worker.mjs index 498b6562..d31aab0d 100644 --- a/tools/cli-package/smoke/worker.mjs +++ b/tools/cli-package/smoke/worker.mjs @@ -19,6 +19,7 @@ import { assertInstalledChat } from './chat.mjs'; import { assertInstalledMcp, assertInstalledWorkflows } from './workflows.mjs'; import { assertExit, explicitEnvironment, invoke, ndjson } from './process.mjs'; import { assertInstalledNativeProcesses, InstalledNativeControlError } from './native-process.mjs'; +import { emitInstalledWorkerFailure } from './failure-diagnostic.mjs'; const directory = dirname(fileURLToPath(import.meta.url)); const repository = resolve(directory, '../../..'); @@ -275,4 +276,7 @@ const report = await exerciseInstalledSmoke({ artifactSha256: process.argv[4], source: process.argv[5], }); -if (!report.success) process.exitCode = 1; +if (!report.success) { + process.exitCode = 1; + emitInstalledWorkerFailure(report); +} diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index a1935af8..951d4b28 100644 --- a/tools/secrets-controls/README.md +++ b/tools/secrets-controls/README.md @@ -68,7 +68,7 @@ The eleven Step 4 controls remove separate production mechanisms: The Step 4 declarations require 51 additional named assertion witnesses. Every selected workload remains a complete test file: 33 input cases, 14 action-forwarding -cases, 32 dispatch cases, 76 run-command cases, three run-consent cases, 61 gate cases, +cases, 32 dispatch cases, 79 run-command cases, three run-consent cases, 68 gate cases, 28 top-level parser cases, or 131 native integration cases, as declared per control. The coordinator independently enforces exact case counts, unique complete case identities and identical fixed/removal/restored inventories. It accepts only a @@ -109,7 +109,7 @@ transliteration or uniqueness of generated IDs. Two independent CLI notice controls remove the successful standalone command and live `/export` notice sites. Each requires named human and JSON witnesses in -the complete 15-case command-export or 122-case chat file. The live control also +the complete 15-case command-export or 127-case chat file. The live control also requires its actual interactive transcript/Ink-frame witness. A third control restores raw stderr delivery only for the live notice: human/JSON positive cases still pass, while the interactive witness must fail with its named assertion marker. @@ -121,7 +121,7 @@ permanent negative cases; no notice is produced before a successful export. Four snapshot controls remove the finite scrubber detection site and the actual `create`, `import`, and `run` warning calls. Each has a named value-free assertion in -its complete 10-case detector, 11-case create, 11-case import or 76-case run file. +its complete 10-case detector, 11-case create, 11-case import or 79-case run file. The adjacent tests check preserved authored files and admitted run snapshots, JSON stdout, and scan-budget/incomplete notices. These controls certify warning delivery for the planted supported shapes at these entry points, not universal secret diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index d035bec7..aaedfbdf 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -347,7 +347,7 @@ export const mutations = [ ' const raw = await (deps.readSecretInput?.() ?? readSecretFromStdin(RUN_SECRET_STDIN_CONTEXT));', " const raw = await (deps.readSecretInput?.() ?? Promise.resolve(''));", ['apps/cli/src/commands/run.test.ts'], - 77, + 79, 'uses the real default reader exactly once and suppresses even an injected gate selector', 'SECRETS_CAUSAL_RUN_DEFAULT_READER', ), @@ -367,7 +367,7 @@ export const mutations = [ ' const mayPrompt = args.secretStdin !== true;', ' const mayPrompt = true;', ['apps/cli/src/commands/gate.test.ts'], - 67, + 68, 'refuses unapproved stdio after a real read despite stale TTY, without prompting or writing a grant', 'SECRETS_CAUSAL_RESUME_CONSUMED_STDIN_CONSENT', ), @@ -520,7 +520,7 @@ export const mutations = [ ' emitOutput(SESSION_EXPORT_REVIEW_NOTICE.trimEnd());\n', '', ['apps/cli/src/commands/chat.test.ts'], - 122, + 127, "reports fixed continuity and review notice on stderr after live 'human' /export", 'W2_EXPORT_LIVE_NOTICE', [ @@ -540,7 +540,7 @@ export const mutations = [ ' emitOutput(SESSION_EXPORT_REVIEW_NOTICE.trimEnd());\n', ' deps.io.writeErr(SESSION_EXPORT_REVIEW_NOTICE);\n', ['apps/cli/src/commands/chat.test.ts'], - 122, + 127, "renders the live export 'success' through the interactive transcript", 'W2_EXPORT_LIVE_RENDERED_NOTICE', ), @@ -581,7 +581,7 @@ export const mutations = [ ' warnSnapshotLiterals(def, deps.io);\n', '', ['apps/cli/src/commands/run.test.ts'], - 77, + 79, 'warns after admission before services and preserves the actual augmented frozen workflow', 'SNAPSHOT_CAUSAL_RUN_CALLER', ), diff --git a/tools/usage-coverage-controls/README.md b/tools/usage-coverage-controls/README.md index 62527cef..e2af348c 100644 --- a/tools/usage-coverage-controls/README.md +++ b/tools/usage-coverage-controls/README.md @@ -70,7 +70,7 @@ shared 100,000-case scale files run separately from repeated small mutation coho crash, lock, receipt and scale tests remain part of ordinary package CI. Each ordinary phase retains its five-minute outer watchdog. The one complete permanent -cohort pins 435 cases and 71 joined native children, including Wave 2's two DB readiness +cohort pins 440 cases and 71 joined native children, including Wave 2's two DB readiness controls. Six additional CLI chat cases exercise live export notices, refusals and transcript rendering; they add no native children. Its outer watchdog is calculated from the existing five-minute setup/general allowance, 71 existing 20-second native-case bounds and the existing ten-minute DB scale diff --git a/tools/usage-coverage-controls/workload.mjs b/tools/usage-coverage-controls/workload.mjs index 1a74bb07..fde3c4e5 100644 --- a/tools/usage-coverage-controls/workload.mjs +++ b/tools/usage-coverage-controls/workload.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; export const ordinaryPhaseTimeoutMs = 300_000; // Pinned complete cohort: 57 CLI crash, four causal crash and ten DB process children. // Each native case already has a 20s bound; the full DB scale test permits 600s. -export const permanentWorkload = Object.freeze({ cases: 435, nativeChildren: 71 }); +export const permanentWorkload = Object.freeze({ cases: 440, nativeChildren: 71 }); export const permanentPhaseTimeoutMs = ordinaryPhaseTimeoutMs + permanentWorkload.nativeChildren * 20_000 + 600_000; diff --git a/tools/usage-coverage-controls/workload.test.mjs b/tools/usage-coverage-controls/workload.test.mjs index 96aee853..377fbaed 100644 --- a/tools/usage-coverage-controls/workload.test.mjs +++ b/tools/usage-coverage-controls/workload.test.mjs @@ -57,11 +57,11 @@ test('the complete native/scale workload has its own calculated bound and exact assert.equal(ordinaryPhaseTimeoutMs, 300_000); assert.equal(permanentPhaseTimeoutMs, 300_000 + 71 * 20_000 + 600_000); const result = { - tests: Array.from({ length: 435 }), + tests: Array.from({ length: 440 }), runtime: { nativeChildren: 71, readinessTimeoutChildren: 1 }, }; assert.doesNotThrow(() => assertPermanentWorkload(result)); - for (const count of [0, 433, 434, 436]) { + for (const count of [0, 435, 439, 441]) { assert.throws( () => assertPermanentWorkload({ ...result, tests: Array.from({ length: count }) }), /Permanent case inventory drift/, From 76fb13971db2c60e2a74ea6ba69b2201598c92f4 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 15:28:53 +0300 Subject: [PATCH 094/222] docs(docs): record step 8b nonclean reviews and correction gates Keep original failed CI and review scopes visible, distinguish corrected author checks from pending causal/platform/independent acceptance, and preserve the cumulative review base through staged Step 8 integration. Validation: relative links, append-only ADR controls and git diff --check. Refs: ADR-0110, Wave 3 Step 8b Co-Authored-By: Codex --- AGENTS.md | 7 +- CLAUDE.md | 7 +- docs/roadmap/current.md | 2 +- .../phase-2.5.5-hardening-and-remediation.md | 67 +++++++++++++++++++ 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f7d0e177..5441042f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,9 +97,10 @@ Linux/macOS/Windows plus Node 22.0.0 consumers. Broader CI retains its separatel attributed status. Step 8a implements origin adapters and an inert native foundation; it is independently accepted at `e0a5ae19` after two NEW complete cumulative clean rounds and a separate clean security sitting. -Step 8b's creation-owned native integration passes local author and installed -checks; required platform controls, two NEW complete cumulative reviews -and its separate security sitting remain open. +Step 8b's first complete cumulative review and separate security sitting are +NONCLEAN: helper cancellation reentry and required-control inventory/fixture +corrections are in progress. Required actual-Windows package evidence and two +NEW complete cumulative clean reviews plus a NEW clean security sitting remain open. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index 0d4b9e3e..cd48deb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,9 +186,10 @@ Linux/macOS/Windows plus Node 22.0.0 consumers. Broader CI retains its separatel attributed status. Step 8a implements origin adapters and an inert native foundation; it is independently accepted at `e0a5ae19` after two NEW complete cumulative clean rounds and a separate clean security sitting. -Step 8b's creation-owned native integration passes local author and installed -checks; required platform controls, two NEW complete cumulative reviews -and its separate security sitting remain open. +Step 8b's first complete cumulative review and separate security sitting are +NONCLEAN: helper cancellation reentry and required-control inventory/fixture +corrections are in progress. Required actual-Windows package evidence and two +NEW complete cumulative clean reviews plus a NEW clean security sitting remain open. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 29f0ec9a..c55415a2 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1135,7 +1135,7 @@ foundation](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-imple [its independent acceptance](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-acceptance--2026-10-16) at `e0a5ae19` includes two NEW complete cumulative clean rounds and a separate clean security sitting. [The same reviewed source's full CI subsequently completed successfully](phases/phase-2.5.5-hardening-and-remediation.md#step-8a-completed-full-ci-follow-up--2026-10-16), -with remote totals attributed separately. [Step 8b is in implementation](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8b-implementation-checkpoint--2026-10-16) and its +with remote totals attributed separately. [Step 8b is addressing its first review and CI corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8b-first-review-and-ci-corrections--2026-10-16) and its acceptance remains open; product fatal activation remains in 8d and whole Step 8 stays open. Steps 8c–19 remain staged. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index ecd0dfe6..a152c835 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -5633,3 +5633,70 @@ whole-wave or family certificate. Two NEW complete cumulative clean reviews from `d9625ff0` and a separate native/process security sitting remain required. **Step 8b is not accepted. Whole Step 8 and Steps 8c–19 remain open.** No new ADR, dependency, credential, billing, provider or user-decision gate is introduced. + + +## Wave 3 Step 8b first review and CI corrections — 2026-10-16 + +The complete cumulative review of `d9625ff0` → `e6e91039` covers **112 paths / +456 hunks / 33 whole added files** and is **NONCLEAN**. Independent ordinary +execution passes **1,076 tests / 45 files**, with one actual-Windows-only local +skip. The separate security sitting is also NONCLEAN and independently reproduces +the same helper-cancellation reentry. Neither sitting counts towards the two new +clean rounds required after the final material correction. + +Node's actual `ChildProcess.kill` error protocol reproduces 1,296 nested helper +kill requests from one native refusal, using an inert simulated native handle +and no PID operation. The correction guards synchronous cancellation reentry, +allows later independent finite requests and still owes both actual native closes. +Two permanent semantic cases cover helper error and finite exit. The independent +real-Node-protocol correction probe observes one request; it is local simulation, +not actual Windows execution or a new native-close acknowledgement. + +Required CI run **38893418852**, exact source `e6e91039`, fails secrets, usage and +the current-Windows installed consumer. Linux/macOS/current and Linux/Node 22.0 +installed legs pass but do not substitute for Windows or the four-leg aggregate. +Five secrets declarations are refreshed against complete ordinary inventories: +run **79**, gate **68**, chat **127**, preserving every witness and strict guard. +The usage permanent cohort contains **440**, rather than 435, cases; all 23 +mechanisms and 71 native fixture lifetimes in the original failed capture retain +their exact partial scope. A generated temporary editor script was correctly +refused by the inherited physical source fence. Both editor tests now use one +tracked ESM fixture with explicit readiness/release and watchdog-failure evidence; +no source-fence, NODE_OPTIONS or acceptance timeout is weakened. + +The original Windows worker exits 1 with empty stderr before the launcher can +read its private report; the failing case/mode is therefore still unknown. A +failure-only, bounded synthetic-test diagnostic now projects canonical identities, +failed cases and native cleanup facts to stderr after existing cleanup. It grants +no success or receipt and preserves actual worker join and installation removal. +The advisory Windows filesystem test separately exceeds its unchanged five-second +bound; no product defect or timeout-relaxation justification is inferred. + +The original native capture completes **23 controls / 69 phases / 1,380 repeated +cases** and is independently physically audited. Original forwarding capture +remains **failed overall**, after seven completed controls: three existing budget +cases declare 20 seconds and correctly refuse the causal guard's 15-second ceiling. +The corrected private declaration uses the complete 68-case gate file to prove +its shared physical human/budget forwarding site; all 38 ordinary budget cases, +including their existing bounds, remain mandatory. No successful replacement +capture is claimed. Final correction source requires all **24 native controls** +(including cancellation reentry), all **23 forwarding controls**, fresh author +checks, actual platform checks, two NEW complete cumulative clean reviews and a +NEW clean separate security sitting. **Step 8b and whole Step 8 remain open.** +No ADR approval, user decision, provider, keychain, billing or credential is pending. + + +### Step 8b first-correction author gates — 2026-10-16 + +The corrected uncommitted body above parent `e6e91039` passes serialized workspace +checks: **664 files / 14,945 passing tests / 21 declared local/platform skips**; +CLI's **287 files / 5,016 passes / ten skips** run freshly, while ten other test +tasks reuse valid caches. All 17 lint/typecheck tasks, seven build/format tasks, +**505 acceptance-tool tests**, tools lint/typecheck, schema sync, isolation, +seam/dependency/finite-bundle guards and compiled CLI/media/overflow/replay smokes +pass. Fresh complete native/forwarding ordinary selection passes **15 files / +572 tests / zero skips**, including all 38 budget cases with unchanged bounds. +The editor fixture's first lint attempt correctly refuses implicit Node globals; +explicit built-in imports fix it without weakening lint. Original failed runs and +NONCLEAN reviews retain their scope. No new causal, actual-Windows or independent +correction-review acceptance is inferred from these author gates. From 3fbf1971307868cd7034dfb6c86ffc5f4d9e913e Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 15:56:53 +0300 Subject: [PATCH 095/222] test(cli): stabilize complete chat control case identities Name the plain and JSON cases explicitly instead of stringifying transformed functions. Keep both regression bodies, assertions and time bounds unchanged so ordinary and isolated complete-file inventories can match exactly. Refs: ADR-0110, Wave 3 Step 8b Co-Authored-By: Codex --- apps/cli/src/commands/chat.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index 3b5babb5..8276b8e4 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -2810,9 +2810,12 @@ describe('drivePlain', () => { expect(processed).toEqual(['hello', '/exit']); }); - it.each([drivePlain, driveJson])( - 'ends without waiting for input when activation synchronously stops the session (%s)', - async (drive) => { + it.each([ + { label: 'plain', drive: drivePlain }, + { label: 'json', drive: driveJson }, + ])( + 'ends without waiting for input when activation synchronously stops the session ($label)', + async ({ drive }) => { const stdin = new PassThrough(); // deliberately never sends a line or EOF before driver completion. const { ctx, processed } = await plainCtx(stdin); const completed = vi.fn(); From caca3e2fdcd447e8178e516a7afaf6cf17e0cbe6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 15:57:10 +0300 Subject: [PATCH 096/222] fix(repo): isolate the windows facility refusal control Preserve the real launching SystemRoot. Inject an explicitly synthetic EACCES only at the exact canonical taskkill access boundary after bootstrap, through the shipped verifier. Keep healthy native access identity and unrelated-path callthrough, and require no target/helper entry before the fixed refusal. Prove actual fs promises ESM synchronization and restoration with two native access regressions. Retain bounded failure diagnostics and unchanged product verification; actual Windows and same-tarball consumers remain required. Refs: ADR-0110, ADR-0111, Wave 3 Step 8b Co-Authored-By: Codex --- tools/cli-package/smoke/README.md | 10 ++- .../cli-package/smoke/failure-diagnostic.mjs | 2 + tools/cli-package/smoke/native-process.mjs | 10 +-- .../smoke/native-spawn-witness.mjs | 16 ++++ .../smoke/native-spawn-witness.test.mjs | 87 ++++++++++++++++++- tools/cli-package/smoke/preload.mjs | 3 + 6 files changed, 120 insertions(+), 8 deletions(-) diff --git a/tools/cli-package/smoke/README.md b/tools/cli-package/smoke/README.md index 0709342a..0960f987 100644 --- a/tools/cli-package/smoke/README.md +++ b/tools/cli-package/smoke/README.md @@ -48,8 +48,14 @@ aggregate. Each result has `id`, `passed`, and evidence or an error. Overall an error/request from the owned native acknowledgement. POSIX additionally covers natural leader exit while a descendant holds inherited pipes. Windows runs the verified absolute system taskkill with exact `/PID /T /F`, joins its - actual helper independently, ignores an executable hostile PATH shim, and refuses - an invalid system facility before target spawn. An unrelated owned sibling must + actual helper independently, and ignores an executable hostile PATH shim. Its + refusal mode preserves the launching SystemRoot and injects a synthetic EACCES + only at the exact canonical taskkill `fs.promises.access` boundary after bootstrap, + through the actual product verifier. It requires one fixed refusal observation, + zero target/helper entry, the fixed public refusal, exit 1 and a completed turn; + it is not an actual OS permission-fault claim. All other access paths call the + original native function, and healthy modes keep that function's identity. + An unrelated owned sibling must survive; external watchdogs and exact created PIDs bound and join cleanup. Each mode owns a fresh sibling with its own finite watchdog; watchdog cleanup remains failure. A copied Homebrew macOS Node also receives its fixed adjacent diff --git a/tools/cli-package/smoke/failure-diagnostic.mjs b/tools/cli-package/smoke/failure-diagnostic.mjs index 4fdfbbcc..6b8e1dbe 100644 --- a/tools/cli-package/smoke/failure-diagnostic.mjs +++ b/tools/cli-package/smoke/failure-diagnostic.mjs @@ -36,6 +36,7 @@ const operations = new Set([ 'native-captured-exit-reap-invoked', 'native-witness-gate-timeout', 'native-witness-readiness-refused', + 'native-facility-access-refused', ]); const object = (value) => (value !== null && typeof value === 'object' ? value : {}); const list = (value, maximum) => (Array.isArray(value) ? value.slice(0, maximum) : []); @@ -72,6 +73,7 @@ function observation(raw) { signal: signal(value.signal), detached: bool(value.detached), shell: bool(value.shell), + synthetic: bool(value.synthetic), error: errorText(value.reason, 128), }; } diff --git a/tools/cli-package/smoke/native-process.mjs b/tools/cli-package/smoke/native-process.mjs index 45660032..4993e2c2 100644 --- a/tools/cli-package/smoke/native-process.mjs +++ b/tools/cli-package/smoke/native-process.mjs @@ -255,7 +255,6 @@ export async function assertInstalledNativeProcesses(context) { PATH: [bin, process.env.PATH].filter(Boolean).join(delimiter), ...(process.platform === 'win32' ? { PATHEXT: '.exe;.cmd;.bat;.com' } : {}), RELAVIUM_PACKAGE_SMOKE_NATIVE: configuration, - ...(mode === 'facility-refusal' ? { SystemRoot: join(cwd, 'missing-system-root') } : {}), }); // Each independently bounded mode owns a fresh sibling and its full finite lifetime. sibling = await createFixtureSentinel(cwd, environment(home)); @@ -351,10 +350,10 @@ export async function assertInstalledNativeProcesses(context) { ); sibling.assertAlive(); if (mode === 'facility-refusal') { - assert.equal( - observed.length, - 0, - 'Invalid Windows facility must refuse before target entry', + assert.deepEqual( + observed, + [{ operation: 'native-facility-access-refused', synthetic: true }], + 'One synthetic facility access refusal must precede any target/helper entry', ); assert.ok( (result.stdout + result.stderr).includes('Windows subprocess teardown is unavailable'), @@ -415,6 +414,7 @@ export async function assertInstalledNativeProcesses(context) { Object.assign(outcome, { actualNativeClose: mode !== 'facility-refusal', syntheticNativeError: mode === 'injected-error', + syntheticFacilityAccessRefusal: mode === 'facility-refusal', ownedDescendantClosed: mode === 'injected-error' || mode === 'leader-exit', siblingSurvived: true, observed, diff --git a/tools/cli-package/smoke/native-spawn-witness.mjs b/tools/cli-package/smoke/native-spawn-witness.mjs index f9b0a94d..4b763363 100644 --- a/tools/cli-package/smoke/native-spawn-witness.mjs +++ b/tools/cli-package/smoke/native-spawn-witness.mjs @@ -62,6 +62,7 @@ export function createNativeSpawnWitness(configuration, spawn) { let helperEntered = false; let exitReaper; let triggered = false; + let facilityAccessRefused = false; let pending = []; let gateTimer; let gateTicks = 0; @@ -106,6 +107,21 @@ export function createNativeSpawnWitness(configuration, spawn) { }; } return Object.freeze({ + /** Synthetic verifier-boundary refusal; healthy modes retain the original native function. */ + wrapFacilityAccess(access) { + if (config.mode !== 'facility-refusal') return access; + assert.notEqual(config.taskkill, null); + return async function (path, ...args) { + if (path !== config.taskkill) return Reflect.apply(access, this, [path, ...args]); + if (!facilityAccessRefused) { + facilityAccessRefused = true; + record({ operation: 'native-facility-access-refused', synthetic: true }); + } + throw Object.assign(new Error('Synthetic taskkill facility access refusal'), { + code: 'EACCES', + }); + }; + }, /** Undefined means refusal: the surrounding existing preload keeps its deny-all spawn rule. */ trySpawn(executable, args, options) { if (executable === config.executable) { diff --git a/tools/cli-package/smoke/native-spawn-witness.test.mjs b/tools/cli-package/smoke/native-spawn-witness.test.mjs index fcab5362..510665c5 100644 --- a/tools/cli-package/smoke/native-spawn-witness.test.mjs +++ b/tools/cli-package/smoke/native-spawn-witness.test.mjs @@ -3,22 +3,28 @@ import { Buffer } from 'node:buffer'; import { EventEmitter } from 'node:events'; import { appendFileSync, + constants, existsSync, mkdtempSync, + promises as fsPromisesViaFs, readFileSync, + realpathSync, rmSync, writeFileSync, } from 'node:fs'; +import fsPromises, { access as nativeAccess } from 'node:fs/promises'; +import { syncBuiltinESMExports } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { setImmediate } from 'node:timers'; import { setTimeout as sleep } from 'node:timers/promises'; import { assertNativeFixtureReady, createNativeSpawnWitness } from './native-spawn-witness.mjs'; +import { formatInstalledWorkerFailure } from './failure-diagnostic.mjs'; /** Explicit simulated event/port tests. These execute neither product code nor Windows taskkill. */ function harness(mode = 'injected-error', gate = 'none') { - const root = mkdtempSync(join(tmpdir(), 'relavium-native-witness-test-')); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-native-witness-test-'))); const config = { cwd: root, executable: join(root, 'git'), @@ -224,6 +230,85 @@ test('invalid Windows facility mode refuses target entry rather than manufacturi } }); +test('healthy native modes preserve the original facility access function identity', async () => { + for (const mode of ['injected-error', 'exit-fallback', 'leader-exit']) { + const h = harness(mode); + try { + writeFileSync(h.config.taskkill, 'owned ordinary access fixture', { mode: 0o700 }); + const access = h.witness.wrapFacilityAccess(fsPromises.access); + assert.equal(access, fsPromises.access); + await access(h.config.taskkill, constants.X_OK); + assert.deepEqual(h.log(), []); + assert.equal(h.calls.length, 0); + } finally { + await h.close(); + } + } +}); + +test('synthetic facility refusal reaches real fs promises ESM access and preserves foreign native access', async () => { + const h = harness('facility-refusal'); + const original = fsPromises.access; + const foreign = join(h.config.cwd, 'unrelated-ordinary-file'); + try { + writeFileSync(h.config.taskkill, 'owned ordinary access fixture', { mode: 0o700 }); + writeFileSync(foreign, 'unrelated ordinary access fixture', { mode: 0o700 }); + assert.equal(nativeAccess, original); + assert.equal(fsPromisesViaFs.access, original); + await nativeAccess(h.config.taskkill, constants.X_OK); + await nativeAccess(foreign, constants.X_OK); + + const wrapped = h.witness.wrapFacilityAccess(original); + fsPromises.access = wrapped; + syncBuiltinESMExports(); + assert.equal(nativeAccess, wrapped); + assert.equal(fsPromisesViaFs.access, wrapped); + for (let attempt = 0; attempt < 2; attempt++) + await assert.rejects(nativeAccess(h.config.taskkill, constants.X_OK), (error) => { + assert.equal(error.code, 'EACCES'); + assert.equal(error.message, 'Synthetic taskkill facility access refusal'); + return true; + }); + // Call the actual native API on other paths/forms, not a successful replacement port. + await nativeAccess(foreign, constants.X_OK); + await nativeAccess(Buffer.from(h.config.taskkill), constants.X_OK); + await original(h.config.taskkill, constants.X_OK); + await assert.rejects(nativeAccess(join(h.config.cwd, 'absent-file')), { code: 'ENOENT' }); + assert.throws( + () => h.witness.trySpawn(h.config.executable, ['status'], h.options), + /Invalid facility/, + ); + assert.equal(h.calls.length, 0); + const observed = [{ operation: 'native-facility-access-refused', synthetic: true }]; + assert.deepEqual(h.log(), observed); + const diagnostic = JSON.parse( + formatInstalledWorkerFailure({ + cases: [ + { + id: 'actual-native-process-owner', + passed: false, + evidence: { outcomes: [{ mode: 'facility-refusal', observed: h.log() }] }, + }, + ], + }), + ); + assert.deepEqual( + diagnostic.cases.find((item) => item.id === 'actual-native-process-owner').native[0].observed, + observed, + ); + } finally { + fsPromises.access = original; + syncBuiltinESMExports(); + try { + assert.equal(nativeAccess, original); + assert.equal(fsPromisesViaFs.access, original); + await nativeAccess(h.config.taskkill, constants.X_OK); + } finally { + await h.close(); + } + } +}); + test('simulated Windows guard requires verified absolute helper, current root and fixed argv/options', async () => { const saved = Object.getOwnPropertyDescriptor(process, 'platform'); Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); diff --git a/tools/cli-package/smoke/preload.mjs b/tools/cli-package/smoke/preload.mjs index 8ffcf521..c748fa65 100644 --- a/tools/cli-package/smoke/preload.mjs +++ b/tools/cli-package/smoke/preload.mjs @@ -8,6 +8,7 @@ import https from 'node:https'; import dns from 'node:dns'; import child from 'node:child_process'; import dgram from 'node:dgram'; +import fsPromises from 'node:fs/promises'; import { record, denied } from './guard-log.mjs'; import { createNativeSpawnWitness } from './native-spawn-witness.mjs'; @@ -37,6 +38,8 @@ const originalSpawn = child.spawn; const nativeWitness = process.env.RELAVIUM_PACKAGE_SMOKE_NATIVE ? createNativeSpawnWitness(process.env.RELAVIUM_PACKAGE_SMOKE_NATIVE, originalSpawn) : undefined; +if (nativeWitness !== undefined) + fsPromises.access = nativeWitness.wrapFacilityAccess(fsPromises.access); child.spawn = (...args) => { const native = nativeWitness?.trySpawn(...args); if (native !== undefined) { From ab50be1110d2950886dcfe821c8d4d5c106679ed Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 15:57:20 +0300 Subject: [PATCH 097/222] docs(docs): record step 8b evidence corrections and author gates Retain the nonclean review scope and failed Windows/forwarding evidence at 76fb1397. Record the exact adverse-fixture and stable-identity corrections, fresh complete author inventories and the remaining platform, causal and independent-review gates without granting premature acceptance. Refs: ADR-0110, ADR-0111, Wave 3 Step 8b Co-Authored-By: Codex --- AGENTS.md | 9 ++-- CLAUDE.md | 9 ++-- .../phase-2.5.5-hardening-and-remediation.md | 41 +++++++++++++++++++ 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5441042f..4df0bfdc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,10 +97,11 @@ Linux/macOS/Windows plus Node 22.0.0 consumers. Broader CI retains its separatel attributed status. Step 8a implements origin adapters and an inert native foundation; it is independently accepted at `e0a5ae19` after two NEW complete cumulative clean rounds and a separate clean security sitting. -Step 8b's first complete cumulative review and separate security sitting are -NONCLEAN: helper cancellation reentry and required-control inventory/fixture -corrections are in progress. Required actual-Windows package evidence and two -NEW complete cumulative clean reviews plus a NEW clean security sitting remain open. +Step 8b's helper cancellation and required workload corrections are independently +verified. Its latest complete cumulative review and separate security sitting remain +NONCLEAN at `76fb1397`; corrections to the Windows adverse fixture and unstable +causal case identities are implemented. Renewed platform/control evidence, two NEW +complete cumulative clean reviews and a NEW clean security sitting remain open. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index cd48deb1..9697742f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,10 +186,11 @@ Linux/macOS/Windows plus Node 22.0.0 consumers. Broader CI retains its separatel attributed status. Step 8a implements origin adapters and an inert native foundation; it is independently accepted at `e0a5ae19` after two NEW complete cumulative clean rounds and a separate clean security sitting. -Step 8b's first complete cumulative review and separate security sitting are -NONCLEAN: helper cancellation reentry and required-control inventory/fixture -corrections are in progress. Required actual-Windows package evidence and two -NEW complete cumulative clean reviews plus a NEW clean security sitting remain open. +Step 8b's helper cancellation and required workload corrections are independently +verified. Its latest complete cumulative review and separate security sitting remain +NONCLEAN at `76fb1397`; corrections to the Windows adverse fixture and unstable +causal case identities are implemented. Renewed platform/control evidence, two NEW +complete cumulative clean reviews and a NEW clean security sitting remain open. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index a152c835..cecb1df6 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -5700,3 +5700,44 @@ The editor fixture's first lint attempt correctly refuses implicit Node globals; explicit built-in imports fix it without weakening lint. Original failed runs and NONCLEAN reviews retain their scope. No new causal, actual-Windows or independent correction-review acceptance is inferred from these author gates. + +### Step 8b adverse-fixture and case-identity correction — 2026-10-16 + +The complete cumulative review and separate security sitting at **`76fb1397`** +remain NONCLEAN. They independently verify the original helper correction, five +secrets inventory corrections and physical **24-control / 72-phase / 1,557-case** +native capture, including **74 semantic failures / 72 zero-unhandled receipts**. +The parent independently audits the same exact source/runtime/dependency bodies, +restoration and graph removal: **1,173 retained files / 31,502,006 bytes**. This +selected local evidence does not certify platform or whole-wave acceptance. + +The new Windows diagnostic isolates two passing actual-native modes and one +failed adverse control: `facility-refusal` exits **134** before any native witness. +The fixture changes launching SystemRoot before Node starts and therefore does +not isolate the product verifier; the deeper abort cause is unproven. The correction +preserves the launching environment and targets the exact taskkill access boundary +with an explicitly labelled synthetic native fault after bootstrap. The production +verifier and pre-entry refusal stay intact. Actual Windows success remains required. + +Forwarding capture **`otgxYP` remains failed overall** after nine completed +controls. Its rejected chat fixed phase passes all **127** cases but two identities +embed transformed function source. Stable `plain`/`json` labels now preserve those +same two assertions and bounds; exact case-inventory guards are unchanged. Fresh +final-source ordinary inventories, all 24 native controls and all 23 forwarding +controls remain required. +CI **38900819422** at `76fb1397` passes the complete secrets family, unchanged +Windows advisory test, wire, coverage and the three other installed legs; the +required Windows consumer and aggregate remain failed. Broader running jobs retain +their eventual separately attributed result. Neither NONCLEAN sitting counts as a +new clean round; final corrections require two NEW complete cumulative clean +reviews and a NEW clean security sitting. No user decision or new ADR is pending. + +The subsequent adverse-fixture correction has fresh author verification: the full +package-tools cohort passes **82/82**, the full acceptance-tool suite **507/507**, +and the complete ordinary fifteen-file causal inventory **572/572**, with no +skips or function-source case names. Workspace lint/typecheck passes **17/17**; +all **11** test tasks pass (ten valid cache hits), including a fresh CLI run of +**287 files / 5,016 passes / 10 established skips**. The seven-task build/format, +tools lint/typecheck and source-fence checks retain their separately recorded +results. These are author checks before the new source freeze; actual-platform, +causal and independent final-correction acceptance are not inferred. From e16a7659d5855dcd0cb75e659573cc218152e139 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 16:41:24 +0300 Subject: [PATCH 098/222] docs(docs): reconcile implemented cli failure classifications Correct the surviving provider-header corruption and unexpected Home failure references to the already-approved fixed cli_internal/exit9 projection. Preserve invalid invocation2, known operational1 and staged caught/fatal integration. Append the ADR landing correction and retain the nonclean R2 scope and required new cumulative reviews without runtime acceptance claims. Only documentation changes. Retain physical runtime/platform evidence at its executed ab50be11 source and require explicit byte-equivalence reconciliation. Refs: ADR-0110, Wave 3 Step 8b Co-Authored-By: Codex --- AGENTS.md | 11 ++--- CLAUDE.md | 11 ++--- ...d-discovery-have-explicit-compatibility.md | 12 ++++++ docs/reference/cli/home.md | 12 +++++- docs/reference/shared-core/database-schema.md | 6 ++- docs/roadmap/current.md | 8 ++-- .../phase-2.5.5-hardening-and-remediation.md | 41 +++++++++++++++++++ 7 files changed, 84 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4df0bfdc..2dbc3b7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,11 +97,12 @@ Linux/macOS/Windows plus Node 22.0.0 consumers. Broader CI retains its separatel attributed status. Step 8a implements origin adapters and an inert native foundation; it is independently accepted at `e0a5ae19` after two NEW complete cumulative clean rounds and a separate clean security sitting. -Step 8b's helper cancellation and required workload corrections are independently -verified. Its latest complete cumulative review and separate security sitting remain -NONCLEAN at `76fb1397`; corrections to the Windows adverse fixture and unstable -causal case identities are implemented. Renewed platform/control evidence, two NEW -complete cumulative clean reviews and a NEW clean security sitting remain open. +Step 8b's native controls and required same-tarball platform consumers pass at +`ab50be11`. Its new cumulative round 2 identifies two canonical exit-classification +reference drifts; their documentation correction is implemented. Two NEW complete +cumulative clean reviews after that correction remain required. Earlier clean +correctness/security sittings retain their exact reviewed source; whole Step 8 +and product fatal activation remain open. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index 9697742f..bb6d4bc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,11 +186,12 @@ Linux/macOS/Windows plus Node 22.0.0 consumers. Broader CI retains its separatel attributed status. Step 8a implements origin adapters and an inert native foundation; it is independently accepted at `e0a5ae19` after two NEW complete cumulative clean rounds and a separate clean security sitting. -Step 8b's helper cancellation and required workload corrections are independently -verified. Its latest complete cumulative review and separate security sitting remain -NONCLEAN at `76fb1397`; corrections to the Windows adverse fixture and unstable -causal case identities are implemented. Renewed platform/control evidence, two NEW -complete cumulative clean reviews and a NEW clean security sitting remain open. +Step 8b's native controls and required same-tarball platform consumers pass at +`ab50be11`. Its new cumulative round 2 identifies two canonical exit-classification +reference drifts; their documentation correction is implemented. Two NEW complete +cumulative clean reviews after that correction remain required. Earlier clean +correctness/security sittings retain their exact reviewed source; whole Step 8 +and product fatal activation remain open. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index c3e26f47..ab4eeaba 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -226,3 +226,15 @@ The [implementation checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-reme keeps author, installed-host, causal and review evidence separately attributed. Production fatal activation, workflow/session health integration and whole Step 8 remain staged in 8c–8d. This note grants no acceptance or safe-store-close ACK. + +## Canonical classification landing correction — 2026-10-16 + +The second new cumulative Step 8b review identifies two surviving reference +claims of unexpected CLI failure exiting 1. The +[provider-header reference](../reference/shared-core/database-schema.md#interim-provider-header-policy) +and [Home reference](../reference/cli/home.md#the-tty-gate-when-the-home-opens) +now follow the already-approved +[CLI failure classification](../reference/cli/commands.md#failure-classification-and-remedies) +implemented by the origin/classification work. Known operational and invocation +dispositions remain distinct. This repairs documentation drift, changes no runtime +behaviour or decision, and grants no Step 8b or whole-Step-8 acceptance. diff --git a/docs/reference/cli/home.md b/docs/reference/cli/home.md index 02ae7841..851c8781 100644 --- a/docs/reference/cli/home.md +++ b/docs/reference/cli/home.md @@ -1,6 +1,6 @@ # `relavium` — Bare-invocation interactive Home -> Last updated: 2026-07-02 +> Last updated: 2026-10-16 - **Status**: Reference — the Home surface is delivered by **2.5.B** (the bare-invocation Home, the read-only management strip over `history.db`, the single-ink-tree mode machine, one SIGINT/SIGTERM lifecycle, and bracketed paste); the in-app slash palette / `/help` / `/doctor` + the context-aware footer landed in **2.5.C**; and the reseat-less mode keymap (`Shift+Tab` / `/mode`) + the fail-closed per-tool approval + the `Esc` mid-turn abort landed in **2.5.E** ([ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md)) — all **extend** this same canonical home. The Home-side **`/models` picker** landed in **2.5.G**: in the bare Home an accepted pick writes the global `[preferences].default_model` (+ the effort tier) for the *next* session ([ADR-0063](../../decisions/0063-cli-config-write-contract.md) §1, [ADR-0064](../../decisions/0064-live-model-catalog.md)); in the in-Home **chat** the same picker **reseats** the live session instead ([chat-session.md](chat-session.md#model-reseat-models)). - **Surface**: CLI (a bare `relavium` with no subcommand, on a TTY) @@ -23,7 +23,15 @@ stdoutIsTty && stdinIsTty && !json && !isCiEnv(env) - `--json` never opens the Home (the machine contract wins). - The CI guard reuses the existing `isCiEnv` helper ([output-mode.ts](../../../apps/cli/src/process/output-mode.ts)), which treats any **non-empty** `CI` other than `false`/`0` as CI (`CI=true`/`CI=1` count; `CI=false`/`CI=0`/empty opt out) — so a runner that sets `CI=1` or allocates a pseudo-TTY cannot accidentally open an interactive Home and stall the pipeline. -**Every non-interactive path is byte-for-byte unchanged**: a piped, `--json`, or CI bare invocation prints `program.helpInformation()` and exits `0`, exactly as before ([ADR-0049](../../decisions/0049-cli-machine-output-contract.md) — `--help` / `--version` / a bare no-command invocation are exit-`0` meta-operations). The Home adds a TTY-gated branch and **no** new IO surface. A Home build/config fault renders like any command fault (a config/invocation `CliError` → exit `2`; any unexpected throw, or an `internal` fault, → exit `1` with a generic message, no raw leak). +**Every non-interactive path is byte-for-byte unchanged**: a piped, `--json`, or CI bare invocation prints `program.helpInformation()` and exits `0`, exactly as before ([ADR-0049](../../decisions/0049-cli-machine-output-contract.md) — `--help` / `--version` / a bare no-command invocation are exit-`0` meta-operations). The Home adds a TTY-gated branch and **no** new IO surface. + +A Home build/config fault uses the same [CLI failure classification](commands.md#failure-classification-and-remedies) +as a command: invalid config/invocation remains exit `2`, known operational faults +retain exit `1`, and an unexpected escaping throw uses the fixed `cli_internal` +projection and exit `9`, without its incoming message, cause or stack in that +projection. The legacy broad `internal` code is not remapped wholesale. Connecting +caught Home/session faults and the complete fatal coordinator remains Step 8d; +this does not turn a classified failed chat turn into a fatal CLI failure. ## First-run onboarding wizard (2.5.G S8) diff --git a/docs/reference/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index 8a41e469..234ed9a6 100644 --- a/docs/reference/shared-core/database-schema.md +++ b/docs/reference/shared-core/database-schema.md @@ -349,8 +349,10 @@ projection. Stored JSON must be an object with string members; malformed JSON, arrays, null and non-string members throw a content-free `TypeError` with message `Stored provider headers are invalid.` and no original parse cause. Valid own keys, including `__proto__`, remain own data without mutating the output prototype. These -corruption errors remain unexpected CLI `internal` failures (exit 1), not policy -refusals or silent empty maps. +At the CLI boundary, these corruption errors use the fixed `cli_internal` +projection (exit 9), following the [CLI failure classification](../cli/commands.md#failure-classification-and-remedies). +They are not policy refusals or silent empty maps. The fixed projection contains +no stored header names or values. The provider-list human and NDJSON paths, with or without `--verify`, omit the header map and its names/values. Diagnostic projections must also omit them. The diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index c55415a2..c9f5cd95 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1135,9 +1135,11 @@ foundation](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-imple [its independent acceptance](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-acceptance--2026-10-16) at `e0a5ae19` includes two NEW complete cumulative clean rounds and a separate clean security sitting. [The same reviewed source's full CI subsequently completed successfully](phases/phase-2.5.5-hardening-and-remediation.md#step-8a-completed-full-ci-follow-up--2026-10-16), -with remote totals attributed separately. [Step 8b is addressing its first review and CI corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8b-first-review-and-ci-corrections--2026-10-16) and its -acceptance remains open; product fatal activation remains in 8d and whole Step 8 -stays open. Steps 8c–19 remain staged. The +with remote totals attributed separately. [Step 8b is correcting canonical exit-classification drift](phases/phase-2.5.5-hardening-and-remediation.md#step-8b-canonical-exit-classification-correction--2026-10-16) +identified by its new cumulative round 2. Native controls and required platform +consumers pass at `ab50be11`; two NEW complete cumulative clean reviews after the +documentation correction remain required. Product fatal activation remains in 8d, +whole Step 8 stays open, and Steps 8c–19 remain staged. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index cecb1df6..3112ee5d 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -5741,3 +5741,44 @@ all **11** test tasks pass (ten valid cache hits), including a fresh CLI run of tools lint/typecheck and source-fence checks retain their separately recorded results. These are author checks before the new source freeze; actual-platform, causal and independent final-correction acceptance are not inferred. + +### Step 8b canonical exit-classification correction — 2026-10-16 + +The new complete cumulative correctness round 1 and separate security sitting at +**`ab50be11`** are clean, but new cumulative round 2 is **NONCLEAN**: the +[provider-header reference](../../reference/shared-core/database-schema.md#interim-provider-header-policy) +and [Home reference](../../reference/cli/home.md#the-tty-gate-when-the-home-opens) +still describe unexpected failures as exit 1. The parent verifies both against +actual classification and the permanent provider-header/Home regressions, then +corrects the references to the already-approved CLI contract. Known operational 1, +invalid invocation 2 and staged caught/fatal integration remain distinct. No +runtime, test, tool, dependency or decision changes in this correction. + +All three sittings cover **120 paths / 485 hunks / 36 complete added files** from +the unchanged whole-Step-7 base `d9625ff0`; each independently executes all +**48 changed test files / 1,100 passes / one actual-Windows-only local skip** and +inspects both complete retained raw captures. The parent reads the full reports +and ledgers before accepting their dispositions. The earlier clean sittings do +not accept this subsequent documentation correction; two NEW complete cumulative +clean rounds remain required. + +Executed native/control and platform evidence remains attributed to `ab50be11`: +**24 native controls / 72 phases / 1,557 repeated cases / 74 semantic failures**, +**23 forwarding controls / 69 phases / 4,383 repeated cases / 39 semantic failures**, +and **141 zero-unhandled completed receipts**. The parent and all three reviewers +independently inspect actual source/dependency/runtime pins, exact restoration, +strict full inventories, bounded retention and removed graphs. Native retains +**1,173 files / 31,501,966 bytes**; forwarding **972 / 56,727,016**, with 207 MCP +fixture pin checks and no fabricated per-phase fixture-body custody. + +CI **38904219484** executes the same **425,556-byte** tarball +`cc269b86836876e4cb931bf62ad5dddf79591263d12c2ca1863ec7a2af58c0c6` on current +Windows/macOS/Linux and exact Node 22.0.0 Linux; all four consumers and the required +aggregate pass **175 bootstrap controls / seven installed cases** each. The parent +and reviewers independently bind all five downloaded ZIPs to API digests and +inspect all 71 public tar members and strict reports. Public success receipts do +not export remote private per-mode journals. Broader CI completion retains its +separate actual status; no overall-CI-green or documentation-head execution is +inferred. Runtime byte equivalence must be independently checked before reusing +this attributed evidence. Step 8b acceptance, 8c–8d, whole Step 8 and later Wave 3 +work remain open; no new ADR or user decision is needed. From 7c1428a69c4ef60c0f85839b57cf4d9c7c2a17ed Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 17:37:10 +0300 Subject: [PATCH 099/222] docs(docs): accept step 8b native process ownership Record two new complete cumulative clean correctness reviews and the separate clean security sitting after the canonical-reference correction. Preserve ab50be11 runtime and platform execution attribution, verified source/dependency equivalence, full CI success and remaining 8c/8d scope. Refs: ADR-0110, ADR-0111 Co-Authored-By: Codex --- AGENTS.md | 13 ++-- CLAUDE.md | 13 ++-- ...d-discovery-have-explicit-compatibility.md | 12 ++++ docs/reference/shared-core/database-schema.md | 3 +- docs/roadmap/current.md | 11 ++-- .../phase-2.5.5-hardening-and-remediation.md | 59 +++++++++++++++++++ docs/standards/security-review.md | 27 +++++++++ 7 files changed, 120 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2dbc3b7c..e08173db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,12 +97,13 @@ Linux/macOS/Windows plus Node 22.0.0 consumers. Broader CI retains its separatel attributed status. Step 8a implements origin adapters and an inert native foundation; it is independently accepted at `e0a5ae19` after two NEW complete cumulative clean rounds and a separate clean security sitting. -Step 8b's native controls and required same-tarball platform consumers pass at -`ab50be11`. Its new cumulative round 2 identifies two canonical exit-classification -reference drifts; their documentation correction is implemented. Two NEW complete -cumulative clean reviews after that correction remain required. Earlier clean -correctness/security sittings retain their exact reviewed source; whole Step 8 -and product fatal activation remain open. +Step 8b (creation-owned native processes) is independently accepted at +`e16a7659` after two NEW complete cumulative clean rounds and a separate clean +security sitting following its canonical-reference correction. Its unchanged +runtime/control evidence and successful full CI plus all four same-tarball +platform consumers remain attributed to `ab50be11`, with independently verified +source/dependency equivalence. Whole Step 8, workflow/terminal integration in 8c +and product fatal activation in 8d remain open. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index bb6d4bc7..49c5266f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,12 +186,13 @@ Linux/macOS/Windows plus Node 22.0.0 consumers. Broader CI retains its separatel attributed status. Step 8a implements origin adapters and an inert native foundation; it is independently accepted at `e0a5ae19` after two NEW complete cumulative clean rounds and a separate clean security sitting. -Step 8b's native controls and required same-tarball platform consumers pass at -`ab50be11`. Its new cumulative round 2 identifies two canonical exit-classification -reference drifts; their documentation correction is implemented. Two NEW complete -cumulative clean reviews after that correction remain required. Earlier clean -correctness/security sittings retain their exact reviewed source; whole Step 8 -and product fatal activation remain open. +Step 8b (creation-owned native processes) is independently accepted at +`e16a7659` after two NEW complete cumulative clean rounds and a separate clean +security sitting following its canonical-reference correction. Its unchanged +runtime/control evidence and successful full CI plus all four same-tarball +platform consumers remain attributed to `ab50be11`, with independently verified +source/dependency equivalence. Whole Step 8, workflow/terminal integration in 8c +and product fatal activation in 8d remain open. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index ab4eeaba..f010177b 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -238,3 +238,15 @@ now follow the already-approved implemented by the origin/classification work. Known operational and invocation dispositions remain distinct. This repairs documentation drift, changes no runtime behaviour or decision, and grants no Step 8b or whole-Step-8 acceptance. + + +## Step 8b independent acceptance — 2026-10-16 + +The [Step 8b acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8b-acceptance--2026-10-16) +records two NEW complete cumulative clean correctness rounds and a NEW separate +clean security sitting at `e16a7659`, after the canonical-reference correction. +Unchanged runtime, raw-control and successful full-CI/same-tarball platform +evidence remain attributed to `ab50be11` with independently verified equivalence. +Creation-owned native process integration is accepted; workflow/terminal owners +in 8c and all-surface fatal activation in 8d remain open. This note adds no new +decision and does not claim whole-Step-8 or whole-Wave-3 completion. diff --git a/docs/reference/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index 234ed9a6..28b8fd45 100644 --- a/docs/reference/shared-core/database-schema.md +++ b/docs/reference/shared-core/database-schema.md @@ -348,7 +348,8 @@ Trusted `get`, `list` and `upsert` results still expose the validated legacy map projection. Stored JSON must be an object with string members; malformed JSON, arrays, null and non-string members throw a content-free `TypeError` with message `Stored provider headers are invalid.` and no original parse cause. Valid own keys, -including `__proto__`, remain own data without mutating the output prototype. These +including `__proto__`, remain own data without mutating the output prototype. + At the CLI boundary, these corruption errors use the fixed `cli_internal` projection (exit 9), following the [CLI failure classification](../cli/commands.md#failure-classification-and-remedies). They are not policy refusals or silent empty maps. The fixed projection contains diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index c9f5cd95..bf9ac139 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1135,11 +1135,12 @@ foundation](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-imple [its independent acceptance](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-acceptance--2026-10-16) at `e0a5ae19` includes two NEW complete cumulative clean rounds and a separate clean security sitting. [The same reviewed source's full CI subsequently completed successfully](phases/phase-2.5.5-hardening-and-remediation.md#step-8a-completed-full-ci-follow-up--2026-10-16), -with remote totals attributed separately. [Step 8b is correcting canonical exit-classification drift](phases/phase-2.5.5-hardening-and-remediation.md#step-8b-canonical-exit-classification-correction--2026-10-16) -identified by its new cumulative round 2. Native controls and required platform -consumers pass at `ab50be11`; two NEW complete cumulative clean reviews after the -documentation correction remain required. Product fatal activation remains in 8d, -whole Step 8 stays open, and Steps 8c–19 remain staged. The +with remote totals attributed separately. [Step 8b is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8b-acceptance--2026-10-16) +at `e16a7659` after two NEW complete cumulative clean rounds and a separate clean +security sitting following the canonical-reference correction. Full CI and all +four required same-tarball consumers pass at `ab50be11`; independent equivalence +checks preserve that execution attribution. Workflow/terminal integration in 8c, +product fatal activation in 8d, whole Step 8 and Steps 9–19 remain open. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 3112ee5d..0e198486 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -5782,3 +5782,62 @@ separate actual status; no overall-CI-green or documentation-head execution is inferred. Runtime byte equivalence must be independently checked before reusing this attributed evidence. Step 8b acceptance, 8c–8d, whole Step 8 and later Wave 3 work remain open; no new ADR or user decision is needed. + + +## Wave 3 Step 8b acceptance — 2026-10-16 + +**Step 8b is independently accepted on `development` at reviewed source +`e16a7659d5855dcd0cb75e659573cc218152e139`.** After the canonical-reference +correction, two NEW complete cumulative correctness rounds and a NEW separate +security sitting are clean. Every sitting covers the unchanged whole-Step-7 base +`d9625ff0a641197c6ed45968df99094a25db88d6` through that source: **121 paths / +488 zero-context hunks / 36 whole added files**. Each independently executes all +**48 changed test files / 1,100 passes / one actual-Windows-only local skip**. +The parent reads every full report and per-file assessment and independently +verifies the immutable file, diff, Git and retained-artifact pins. Final round 2's +488 individual hunk bodies also match. Detailed reports remain local and +Git-ignored; their final report SHA-256 values are recorded below. + +| Sitting | Model / effort | Report SHA-256 | +|---------|----------------|---------------| +| New cumulative correctness round 1 | GPT-6 Astra / xhigh | `a54e5a2d5afef0c6b73627407f6afd78cd3d2eba3143cff04a077cc6f9edd4e2` | +| New cumulative correctness round 2 | GPT-6.1 Sol / xhigh | `5072e3563d694d9a9580e972d3e60ef24c2f6ab94a049fd500cd0ff6e53c8180` | +| New separate security sitting | GPT-6.1 Sol / xhigh | `ea42171a24f7015818b147f248ccc421f6b6213506acf4054c808ba508817aee` | + +Independent additional probes pass: round 1's 16 lifecycle and 21 negative +package-binding probes, round 2's four lifecycle/projection probes, and the +security sitting's six corrected boundary probes. These counts are attributed +separately, not presented as unique product-suite cases. The security probe's +initial source/dist identity setup failure remains recorded; explicit shared +source aliases correct that probe, without a product change or suppressed failure. +The dangling editorial word in the database reference is removed in this acceptance +metadata; this does not alter its already-reviewed classification contract. + +The parent and all three reviewers inspect the complete native and forwarding +raw captures and all five downloaded package/report archives described in the +[preceding evidence checkpoint](#step-8b-canonical-exit-classification-correction--2026-10-16). +Their execution remains **`ab50be11`**, never relabelled as a run of the +documentation-only `e16a7659`: all **1,270 source/tool entries**, **21,013 physical +dependency files / 457 packages**, and Node/Vitest runtime identities are +independently equivalent. Both complete captures retain 141 zero-unhandled phase +receipts, exact restoration and their bounded inventories. Earlier failed runs +and NONCLEAN reviews remain historical evidence, not current acceptance. + +Full push CI **38904219484 / attempt 1 / `ab50be11`** has now completed +successfully: **all 15 jobs pass**, including the full filesystem cohort and +required lint/typecheck/test/build/fences/smokes job. Actual current Windows, +macOS and Linux plus exact Node 22.0.0 Linux consume the same tarball, with all +four strict receipts and their aggregate passing. The parent and reviewers +independently inspect the physical archives and public receipt bindings; remote +private per-mode worker journals are not exported by those success receipts and +are not inferred. The actual-Windows adverse case injects a post-bootstrap native +access failure; it is not evidence of a real OS permission fault. + +The [separate security sitting](../../standards/security-review.md#sitting-wave-3-creation-owned-native-processes-2026-10-16) +retains the distinctions between fixed diagnostic projection and unchanged legacy +human verbose stacks, native close and receipt ACK, and owned process types and +universal descendant containment. Global native activation is still inert. +**Whole Step 8 is open:** 8c must connect workflow, preparation and terminal +lifetimes; 8d must connect the remaining surfaces and all caught/fatal exits. +Steps 9–19 and whole-wave acceptance remain staged. No new ADR, provider capture, +credential, billing or user decision is needed. Step 8c proceeds automatically. diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index 034b466f..274c4278 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -642,6 +642,33 @@ process-tree teardown and all-surface activation remain 8b–8d. The owns exact execution/platform limits; detailed review records remain local and Git-ignored. +### Sitting: Wave 3 creation-owned native processes (2026-10-16) + +NEW separate complete clean sitting at +`e16a7659d5855dcd0cb75e659573cc218152e139`, independent of both NEW cumulative +correctness rounds after the canonical-reference correction. The unchanged base +`d9625ff0` yields 121 complete paths / 488 zero-context hunks / 36 whole added +files; all 48 changed test files pass 1,100 cases with one actual-Windows-only +local skip. Six corrected independent boundary probes pass. The initial probe +source/dist identity mismatch remains qualified in its private record. The parent +reads the full report and all file assessments and verifies the retained pins. + +The sitting independently audits both complete raw native/forwarding captures, +the original package ZIP and all four required same-tarball consumer ZIPs. +Execution is `ab50be11`; current source/tool, dependency and runtime equivalence +is checked separately. Actual Windows, macOS, Linux and exact Node 22.0.0 Linux +receipts pass, and full CI 38904219484 has completed successfully. Public success +receipts do not provide remote private per-mode journals. No real credential, +provider call, GUI/editor session or payment is exercised. + +Fixed projection privacy does not certify unchanged legacy verbose stack +confidentiality; their final integration remains 8d. Actual native close is not +receipt ACK, synthetic native access failure is not an OS permission-fault +experiment, and owned group/PID semantics are not universal containment. No new +confirmed finding or deferral remains in 8b. The [acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8b-acceptance--2026-10-16) +joins exact pins and limits. Workflow/terminal integration in 8c, product native +activation in 8d and whole-Wave-3 acceptance remain open. + ## Sandbox and tool policy (`run_command`, node tools, secret inputs) This is the **binding home** for the tool-policy rules; the rationale is From 9e4cac777ac4264a6576fdb100cc8832afdf54f0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 19:35:37 +0300 Subject: [PATCH 100/222] fix(cli): own workflow preparation and terminal cleanup Register workflow and terminal lifetimes before asynchronous preparation. Keep cancellation provenance, actual receipt health, native input/output release and database closure behind their existing acknowledgement boundaries. Start the shared finite exit budget at the first owned infrastructure fault; leave product-wide activation and session integration staged for Step 8d. Update canonical ownership documentation and exact secrets-control inventories. Add ordinary, installed-library and actual Node/SQLite/MCP regressions. Author workspace lint/typecheck/tests, build, format and compiled CLI smoke pass. Independent cumulative review and fresh causal/platform gates remain open. Refs: ADR-0110, ADR-0111, Wave 3 Step 8c Co-Authored-By: Codex --- AGENTS.md | 2 + CLAUDE.md | 2 + apps/cli/src/commands/budget.test.ts | 52 ++- .../cli/src/commands/departure-native.test.ts | 59 +++- .../src/commands/descriptor-actions.test.ts | 4 + .../src/commands/drive-gate-lifecycle.test.ts | 16 +- apps/cli/src/commands/drive.test.ts | 56 +++- apps/cli/src/commands/drive.ts | 203 +++++++---- .../fixtures/workflow-cleanup-child.ts | 172 ++++++++++ .../fixtures/workflow-cleanup-register.mjs | 14 + .../commands/fixtures/workflow-tsx-hook.mjs | 35 ++ apps/cli/src/commands/gate.test.ts | 18 +- apps/cli/src/commands/gate.ts | 142 ++++---- apps/cli/src/commands/lazy-dispatch.test.ts | 1 + .../native-forwarding.test-support.ts | 12 +- .../src/commands/run-secret-consent.test.ts | 5 +- apps/cli/src/commands/run.test.ts | 130 ++++--- apps/cli/src/commands/run.ts | 152 +++++---- apps/cli/src/commands/safe-close.test.ts | 78 ++++- .../commands/workflow-cleanup-native.test.ts | 93 +++++ .../workflow-driver-ownership.test.ts | 125 +++++++ .../src/commands/workflow-lifetime.test.ts | 116 +++++++ apps/cli/src/commands/workflow-lifetime.ts | 190 +++++++++++ .../src/commands/workflow-preparation.test.ts | 317 ++++++++++++++++++ .../src/commands/workflow-terminal.test.ts | 267 +++++++++++++++ apps/cli/src/commands/workflow-terminal.ts | 293 ++++++++++++++++ .../engine/mcp-consent-cancellation.test.ts | 124 +++++++ apps/cli/src/engine/mcp-consent-gate.ts | 21 +- apps/cli/src/engine/mcp-servers.ts | 24 +- apps/cli/src/gate/clack-prompter.ts | 13 + apps/cli/src/gate/select-prompter.test.ts | 3 +- apps/cli/src/gate/select-prompter.ts | 4 +- apps/cli/src/home/drive-home.test.ts | 1 + apps/cli/src/mcp/consent-prompt.ts | 17 +- apps/cli/src/onboarding/wizard.test.ts | 1 + apps/cli/src/onboarding/wizard.ts | 11 +- .../process/invocation-finite-exit.test.ts | 231 +++++++++++++ .../cli/src/process/invocation-finite-exit.ts | 166 +++++++++ apps/cli/src/process/invocation.ts | 34 +- .../process/io-stdout-acknowledgement.test.ts | 71 ++++ apps/cli/src/process/io.ts | 13 +- apps/cli/src/process/native-invocation.ts | 30 +- apps/cli/src/process/native-prompt-io.test.ts | 137 ++++++++ apps/cli/src/process/native-prompt-io.ts | 13 + apps/cli/src/process/render-error.test.ts | 1 + apps/cli/src/render/renderer.ts | 2 + .../render/run-job-control-ownership.test.ts | 112 +++++++ apps/cli/src/render/run-job-control.test.ts | 11 +- apps/cli/src/render/run-job-control.ts | 192 ++++++----- apps/cli/src/render/select.test.ts | 84 ++++- apps/cli/src/render/select.ts | 18 +- .../render/tui/ink-renderer-ownership.test.ts | 115 +++++++ apps/cli/src/render/tui/ink-renderer.ts | 55 ++- apps/cli/src/run-lazy-boundary.test.ts | 5 + apps/cli/src/test-support.ts | 4 + ...d-discovery-have-explicit-compatibility.md | 14 + docs/reference/cli/commands.md | 51 ++- docs/roadmap/current.md | 3 +- .../phase-2.5.5-hardening-and-remediation.md | 67 ++++ tools/secrets-controls/README.md | 4 +- tools/secrets-controls/mutations.mjs | 4 +- 61 files changed, 3807 insertions(+), 403 deletions(-) create mode 100644 apps/cli/src/commands/fixtures/workflow-cleanup-child.ts create mode 100644 apps/cli/src/commands/fixtures/workflow-cleanup-register.mjs create mode 100644 apps/cli/src/commands/fixtures/workflow-tsx-hook.mjs create mode 100644 apps/cli/src/commands/workflow-cleanup-native.test.ts create mode 100644 apps/cli/src/commands/workflow-driver-ownership.test.ts create mode 100644 apps/cli/src/commands/workflow-lifetime.test.ts create mode 100644 apps/cli/src/commands/workflow-lifetime.ts create mode 100644 apps/cli/src/commands/workflow-preparation.test.ts create mode 100644 apps/cli/src/commands/workflow-terminal.test.ts create mode 100644 apps/cli/src/commands/workflow-terminal.ts create mode 100644 apps/cli/src/engine/mcp-consent-cancellation.test.ts create mode 100644 apps/cli/src/process/invocation-finite-exit.test.ts create mode 100644 apps/cli/src/process/invocation-finite-exit.ts create mode 100644 apps/cli/src/process/io-stdout-acknowledgement.test.ts create mode 100644 apps/cli/src/process/native-prompt-io.test.ts create mode 100644 apps/cli/src/process/native-prompt-io.ts create mode 100644 apps/cli/src/render/run-job-control-ownership.test.ts create mode 100644 apps/cli/src/render/tui/ink-renderer-ownership.test.ts diff --git a/AGENTS.md b/AGENTS.md index e08173db..53c44703 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,8 @@ runtime/control evidence and successful full CI plus all four same-tarball platform consumers remain attributed to `ab50be11`, with independently verified source/dependency equivalence. Whole Step 8, workflow/terminal integration in 8c and product fatal activation in 8d remain open. +Step 8c workflow/preparation/terminal integration is under author verification; +no independent acceptance is inferred from its focused tests. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index 49c5266f..09543c89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -193,6 +193,8 @@ runtime/control evidence and successful full CI plus all four same-tarball platform consumers remain attributed to `ab50be11`, with independently verified source/dependency equivalence. Whole Step 8, workflow/terminal integration in 8c and product fatal activation in 8d remain open. +Step 8c workflow/preparation/terminal integration is under author verification; +no independent acceptance is inferred from its focused tests. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/apps/cli/src/commands/budget.test.ts b/apps/cli/src/commands/budget.test.ts index 627f7ebb..e1ddcafd 100644 --- a/apps/cli/src/commands/budget.test.ts +++ b/apps/cli/src/commands/budget.test.ts @@ -1,4 +1,5 @@ import { createCliInvocation } from '../process/invocation.js'; +import { toUserFacing } from '../process/errors.js'; import { witnessNativeToolProcess } from './native-forwarding.test-support.js'; import { randomUUID } from 'node:crypto'; import { spawn } from 'node:child_process'; @@ -218,6 +219,7 @@ async function seed( function deps(io: ReturnType['io']): GateCommandDeps { return { io, + invocation: createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }), global: { json: false, color: false, @@ -259,7 +261,12 @@ for (const abort of [false, true]) { invocation, buildEngine: async (options) => { if (!abort) - await witnessNativeToolProcess(options?.toolEnv?.nativeProcesses, invocation, root); + await witnessNativeToolProcess( + options?.toolEnv?.nativeProcesses, + invocation, + root, + 2, + ); return buildEngine({ ...options, resolvePrice: prices }); }, }, @@ -856,7 +863,7 @@ it('MCP consent refusal precedes spawn, engine construction and decision writes' }); for (const priceRefusal of [false, true]) { - it(`MCP close fault preserves the primary budget result (priceRefusal=${priceRefusal})`, async () => { + it(`MCP close fault retains actual budget history and withholds DB retirement (priceRefusal=${priceRefusal})`, async () => { const paused = await seed({ mcp: true }); if (priceRefusal) prices.set(MODEL, { ...PRICE, inputPerMtokMicrocents: 2000000 }); const before = reader().loadRunEventLogForReplay(paused.runId); @@ -875,10 +882,12 @@ for (const priceRefusal of [false, true]) { return Promise.reject(new Error('PRIVATE fixture close path')); }, }; + const owned = deps(io); const command = budgetCommand( { runId: paused.runId, approveAmount: String(paused.amount) }, { - ...deps(io), + ...owned, + subscribeSignals: () => () => undefined, openDb: () => ({ db: client.db, close: () => { @@ -889,15 +898,25 @@ for (const priceRefusal of [false, true]) { startMcpClient: () => Promise.resolve(mcp), }, ); + let settled = false; + void command.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await vi.waitFor(() => expect(closes).toBe(1)); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + expect(owned.invocation?.registry.activeOwners).toBe(1); + expect(owned.invocation?.registry.observations.exitCode()).toBe(9); if (priceRefusal) { - await expect(command).rejects.toMatchObject({ - code: 'invalid_invocation', - }); expect(reader().loadRunEventLogForReplay(paused.runId)).toEqual(before); expect(calls).toBe(0); expect(keyReads).toBe(0); } else { - expect(await command).toBe(0); expect(calls).toBe(1); expect(keyReads).toBe(1); expect(reader().loadRunEventLogForReplay(paused.runId).at(-1)).toMatchObject({ @@ -905,7 +924,7 @@ for (const priceRefusal of [false, true]) { }); } expect(closes).toBe(1); - expect(dbCloses).toBe(1); + expect(dbCloses).toBe(0); expect(out() + err()).not.toContain('PRIVATE'); expect([process.listenerCount('SIGINT'), process.listenerCount('SIGTERM')]).toEqual(signals); expect(await createRunLeasePort(reader()).read(paused.runId)).toBeUndefined(); @@ -1206,7 +1225,7 @@ for (const phase of ['connect', 'build', 'build_without_mcp'] as const) { return Promise.resolve(); }, }; - const code = await budgetCommand( + const command = budgetCommand( { runId: paused.runId, approveAmount: String(paused.amount) }, { ...deps(io), @@ -1237,17 +1256,22 @@ for (const phase of ['connect', 'build', 'build_without_mcp'] as const) { }, }, ); - expect(code).toBe(1); + if (phase === 'connect') { + await expect(command).rejects.toThrow('PRIVATE aborted connect detail'); + const result = await command.catch((cause: unknown) => toUserFacing(cause)); + expect(result).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + } else expect(await command).toBe(1); expect(out()).toBe(''); - expect(err()).toContain( - 'resume interrupted before a decision was recorded; the gate remains pending', - ); + if (phase !== 'connect') + expect(err()).toContain( + 'resume interrupted before a decision was recorded; the gate remains pending', + ); expect(err()).not.toContain('PRIVATE'); expect(calls).toBe(0); expect(keyReads).toBe(0); expect(builds).toBe(phase === 'connect' ? 0 : 1); expect(dbCloses).toBe(1); - expect(closes).toBe(phase === 'build' ? 2 : 0); + expect(closes).toBe(phase === 'build' ? 1 : 0); expect(reader().loadRunEventLogForReplay(paused.runId)).toEqual(before); expect(await createRunLeasePort(reader()).read(paused.runId)).toBeUndefined(); expect([process.listenerCount('SIGINT'), process.listenerCount('SIGTERM')]).toEqual(signals); diff --git a/apps/cli/src/commands/departure-native.test.ts b/apps/cli/src/commands/departure-native.test.ts index 6f9b1b12..569c10b6 100644 --- a/apps/cli/src/commands/departure-native.test.ts +++ b/apps/cli/src/commands/departure-native.test.ts @@ -3,7 +3,9 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { setImmediate, setTimeout as delay } from 'node:timers/promises'; -import { expect, it } from 'vitest'; +import { expect, it, vi } from 'vitest'; +import { createCliInvocation } from '../process/invocation.js'; +import { toUserFacing } from '../process/errors.js'; import { WorkflowEngine, createAgentNodeExecutor, @@ -74,6 +76,7 @@ async function exercise( finish: Finish = 'completed', effectFinish: EffectFinish = 'reject-committed', inputOrder?: 'input-first' | 'receipt-first', + fault?: 'event' | 'finalize', ) { const dir = mkdtempSync(join(tmpdir(), 'departure-native-')); const dbPath = join(dir, 'history.db'); @@ -239,6 +242,12 @@ async function exercise( event.type === 'run:cancelled' ) primaryTerminal.resolve(); + if (fault === 'event' && event.type === 'run:' + finish) + throw new Error('PRIVATE_RENDERER_FAILURE'); + }, + finalize: async () => { + await renderer.finalize?.(); + if (fault === 'finalize') throw new Error('PRIVATE_FINALIZE_FAILURE'); }, }; }; @@ -394,6 +403,7 @@ async function exercise( return Promise.resolve(engine); }; const common = { + invocation: createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }), io: io.io, global: { json: mode === 'json', @@ -469,9 +479,21 @@ async function exercise( release.resolve(); inputRelease.resolve(); await child; - expect(await execution).toBe( - moneyFail ? 8 : effectFail ? 7 : terminalAck === 'ok' ? (finish === 'completed' ? 0 : 1) : 5, - ); + if (fault !== undefined && !moneyFail && !effectFail && terminalAck === 'ok') { + const result = await execution.catch((cause: unknown) => toUserFacing(cause)); + expect(result).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + } else + expect(await execution).toBe( + moneyFail + ? 8 + : effectFail + ? 7 + : terminalAck === 'ok' + ? finish === 'completed' + ? 0 + : 1 + : 5, + ); expect(closes).toBe(1); expect(process.listenerCount('SIGINT')).toBe(baseline); expect( @@ -563,3 +585,32 @@ for (const effectFinish of [ for (const order of ['input-first', 'receipt-first'] as const) it(`native Ink terminal and cleanup notice remain visible while ${order} barriers join`, () => exercise('run', 'both', 'ink', 'ok', 'completed', 'reject-committed', order)); + +for (const command of ['run', 'gate', 'budget'] as const) + for (const fault of ['event', 'finalize'] as const) + for (const health of ['healthy', 'money', 'effect'] as const) + it(`workflow ownership keeps real SQLite receipt health before ${fault} failure (${command}/${health})`, async () => { + await exercise( + command, + health, + 'json', + 'ok', + 'completed', + 'reject-committed', + undefined, + fault, + ); + }); +for (const command of ['run', 'gate', 'budget'] as const) + it(`actual terminal outbox uncertainty outranks unexpected finalization (${command})`, async () => { + await exercise( + command, + 'healthy', + 'json', + 'lost', + 'completed', + 'reject-committed', + undefined, + 'finalize', + ); + }); diff --git a/apps/cli/src/commands/descriptor-actions.test.ts b/apps/cli/src/commands/descriptor-actions.test.ts index fff1ad72..281a0b26 100644 --- a/apps/cli/src/commands/descriptor-actions.test.ts +++ b/apps/cli/src/commands/descriptor-actions.test.ts @@ -20,6 +20,10 @@ function capture(): { io: CliIo; out: string[]; err: string[] } { writeErr: (text) => { err.push(text); }, + writeOutAcknowledged: (text) => { + out.push(text); + return Promise.resolve(); + }, writeErrAcknowledged: (text) => { err.push(text); return Promise.resolve(); diff --git a/apps/cli/src/commands/drive-gate-lifecycle.test.ts b/apps/cli/src/commands/drive-gate-lifecycle.test.ts index c4cccb3a..f3f9384b 100644 --- a/apps/cli/src/commands/drive-gate-lifecycle.test.ts +++ b/apps/cli/src/commands/drive-gate-lifecycle.test.ts @@ -1,9 +1,11 @@ +import { toUserFacing } from '../process/errors.js'; import { randomUUID } from 'node:crypto'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { setImmediate as turn } from 'node:timers/promises'; -import { expect, it } from 'vitest'; +import { expect, it, vi } from 'vitest'; +import { createCliInvocation } from '../process/invocation.js'; import { reconstructCheckpointState, type RunStore } from '@relavium/core'; import { createClient, @@ -193,6 +195,7 @@ for (const mode of [ }, } : {}), + invocation: createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }), openRunStore: (workflow) => { const store = createRunHistoryStore(db.db, { uuid: randomUUID, @@ -361,7 +364,8 @@ for (const mode of [ .catch((error) => { commandError = error; settled = true; - if (mode === 'suspend-error') return 1; + if (mode === 'suspend-error' || mode === 'finalize-error-signal') + return toUserFacing(error).exitCode; throw error; }); if (terminalDuringPrompt) { @@ -475,11 +479,13 @@ for (const mode of [ const events = store.loadRunEventLogForReplay(runId); const terminal = events.at(-1)?.type; if (mode === 'suspend-error') expect(commandError).toBe(suspendError); + if (mode === 'finalize-error-signal') expect(commandError).toBe(stopError); expect(lateWrites).toBe(0); expect(closes).toBe(1); expect(await createRunLeasePort(store).read(runId)).toBeUndefined(); expect(process.listenerCount('SIGINT')).toBe(signalBaseline); - expect(summaries).toHaveLength(1); + const infrastructureFault = mode === 'suspend-error' || mode === 'finalize-error-signal'; + expect(summaries).toHaveLength(infrastructureFault ? 0 : 1); if (mode === 'stable-pause') { expect(exit).toBe(3); expect(terminal).toBe('run:paused'); @@ -514,10 +520,10 @@ for (const mode of [ ).toMatchObject({ settled: false, open: true, closes: 0, summaries: 0 }); } } else { - expect(exit).toBe(1); + expect(exit).toBe(mode === 'suspend-error' || mode === 'finalize-error-signal' ? 9 : 1); expect(calls).toBe(0); expect(terminal).toBe('run:cancelled'); - expect(summaries[0]).toContain('run cancelled'); + if (!infrastructureFault) expect(summaries[0]).toContain('run cancelled'); expect(trace.indexOf('close')).toBeGreaterThan(trace.indexOf('acked-run:cancelled')); if (signalDuringSuspend) expect(prompts, 'cancellation during Ink suspend must suppress first card').toBe(0); diff --git a/apps/cli/src/commands/drive.test.ts b/apps/cli/src/commands/drive.test.ts index 73999b92..ded6bf4e 100644 --- a/apps/cli/src/commands/drive.test.ts +++ b/apps/cli/src/commands/drive.test.ts @@ -1,3 +1,5 @@ +import { WorkflowLifetime } from './workflow-lifetime.js'; +import { createCliInvocation } from '../process/invocation.js'; import { EngineStateError, createInMemoryHost, @@ -116,7 +118,7 @@ describe('driveRun — interactive gate', () => { expect(events.at(-1)?.type).toBe('run:cancelled'); }); - it('re-mounts the TUI even if the prompt throws (finally), cancels the live run, then the error unwinds', async () => { + it('seals remount and summary on an unknown prompt failure, cancels the run, and preserves its cause', async () => { const { engine, handle } = await startGatedRun(); const { renderer } = recordingRenderer(); const prompter: GatePrompter = { @@ -125,15 +127,34 @@ describe('driveRun — interactive gate', () => { const { io } = captureIo(); const cancelSpy = vi.spyOn(handle, 'cancel'); + const lifetime = new WorkflowLifetime( + createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }), + io, + () => () => undefined, + ); await expect( - driveRun({ engine, handle, makeRenderer: () => renderer, gatePrompter: prompter, io }), + driveRun({ + lifetime, + engine, + handle, + makeRenderer: () => renderer, + gatePrompter: prompter, + io, + }), ).rejects.toThrow('prompt boom'); - expect(renderer.resume).toHaveBeenCalledTimes(1); // the suspend/prompt/resume finally restored the view - expect(renderer.finalize).toHaveBeenCalledTimes(1); // driveRun's finally still finalized + expect(renderer.resume).not.toHaveBeenCalled(); // The infrastructure fault seals future native entry. + expect(renderer.finalize).not.toHaveBeenCalled(); // Actual release is joined independently of summary. expect(cancelSpy).toHaveBeenCalledTimes(1); // ...and cancelled the still-live run (abnormal-unwind guard) + let retired = false; + void lifetime.finish().then(() => { + retired = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(retired).toBe(false); // A rejected prompt never proves terminal release. + expect(lifetime.invocation.registry.activeOwners).toBe(1); }); - it('a renderer.resume() throw does NOT mask the prompt error (logs the resume failure to stderr)', async () => { + it('never enters a faulty remount after a prompt infrastructure failure or echoes either private cause', async () => { const { engine, handle } = await startGatedRun(); const renderer: RunRenderer = { onEvent: () => {}, @@ -148,10 +169,31 @@ describe('driveRun — interactive gate', () => { }; const { io, err } = captureIo(); + const lifetime = new WorkflowLifetime( + createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }), + io, + () => () => undefined, + ); await expect( - driveRun({ engine, handle, makeRenderer: () => renderer, gatePrompter: prompter, io }), + driveRun({ + lifetime, + engine, + handle, + makeRenderer: () => renderer, + gatePrompter: prompter, + io, + }), ).rejects.toThrow('prompt boom'); // the PROMPT error survives — the resume throw did not replace it - expect(err()).toContain('failed to restore the live view'); // the resume failure went to stderr instead + expect(renderer.resume).not.toHaveBeenCalled(); + expect(err()).not.toContain('prompt boom'); + expect(err()).not.toContain('resume boom'); + let retired = false; + void lifetime.finish().then(() => { + retired = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(retired).toBe(false); // A rejected prompt never proves terminal release. + expect(lifetime.invocation.registry.activeOwners).toBe(1); }); it('without a prompter, a gate pause stops the loop (→ paused, exit 3)', async () => { diff --git a/apps/cli/src/commands/drive.ts b/apps/cli/src/commands/drive.ts index 14629adc..0d092514 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -21,8 +21,11 @@ import { budgetIdentifier, budgetPromptContext, type BudgetPromptContext } from import { sanitizeUntrustedInline, stringifyJsonLine } from '../render/sanitize.js'; import { CliError } from '../process/errors.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; +import { detectOutputMode, isCiEnv } from '../process/output-mode.js'; import type { CliIo } from '../process/io.js'; import type { RunRenderer } from '../render/renderer.js'; +import type { RendererCreationOwner } from './workflow-terminal.js'; +import { WorkflowLifetime } from './workflow-lifetime.js'; import { wireRunJobControl } from '../render/run-job-control.js'; /** @@ -54,14 +57,14 @@ function rethrowDriveFailure(original: unknown): never { } export interface DriveRunDeps { + readonly lifetime?: WorkflowLifetime; readonly engine: WorkflowEngine; readonly handle: RunHandle; /** - * Constructs the renderer — called INSIDE, after the SIGINT handler is registered, so a renderer-construction - * throw (e.g. `ink`'s `render()`) still unwinds through the same finally (cancel the live run, remove the - * listener) instead of leaving a running engine with no cooperative-cancel handler. + * Construct after the command reserves its workflow/terminal owners and signal subscription. The + * creation token transfers native rollback custody before a partially allocating constructor can throw. */ - readonly makeRenderer: () => RunRenderer; + readonly makeRenderer: (creation: RendererCreationOwner) => RunRenderer; /** Present only in the interactive TUI path; absent (or `undefined`) → a human-gate pause breaks to the * gate-paused exit `3` (the callers pass `selectGatePrompter(...)`, which is `GatePrompter | undefined`). */ readonly gatePrompter?: GatePrompter | undefined; @@ -77,28 +80,39 @@ export interface DriveRunDeps { * `engine.start`) and `relavium gate` (a resumed `engine.resumeFromCheckpoint`), so the two never fork the * event loop, the SIGINT contract, or the renderer teardown (2.G). * - * It: registers the cooperative-cancel SIGINT handler (`process.on`, never `once` — see below), feeds every - * event to the renderer, resolves an interactive human gate **inline** when a {@link GatePrompter} is present - * (suspend the TUI → prompt → `engine.resume` → re-mount, all on the same in-memory run), and on a - * non-interactive pause breaks to the gate-paused exit. The renderer is finalized even on a throw, and the - * SIGINT handler removed LAST — so `ink`'s `signal-exit` never becomes the sole SIGINT listener mid-teardown - * (which would re-raise → exit 130; the 2.E Ctrl-C contract). + * The command's existing lifetime owns signals through preparation and cleanup. This driver keeps one + * primary reader across inline gate prompts, temporary input release and actual departure. Cooperative + * cancellation may still paint the final outcome; finite/infrastructure shutdown permanently seals view + * entry and joins actual native release independently of summary rendering. The command removes its + * subscription only after actual cleanup, so Ink cannot become the sole signal listener mid-teardown. */ export async function driveRun(deps: DriveRunDeps): Promise { const { engine, handle, makeRenderer, io } = deps; + const lifetime = deps.lifetime ?? new WorkflowLifetime(undefined, io); + lifetime.adoptEngine(engine, handle.runId); + lifetime.adoptHandle(handle); + const terminal = lifetime.terminal; let gatePrompter = deps.gatePrompter; let outcome: RunOutcome | undefined; let cancelRequested = false; - let interrupted = false; let streamEnded = false; + let primaryDone = false; + let finalDurability: RunDurability | undefined; + let finalTerminalError: ErrorCode | undefined; + let healthRead = false; let inlineRefused = false; let inlineTerminating = false; let activePrompt: AbortController | undefined; let renderer: RunRenderer | undefined; - let driveError: unknown; + let driveError: { readonly cause: unknown } | undefined; + const recordFault = (cause: unknown): void => { + if (lifetime.isStoppedFailure(cause)) return; + driveError ??= { cause }; + lifetime.failure(cause); + }; let inputReleased = false; let inputReleaseFailed = false; - let departure: RunDeparture | undefined; + let departure: Exclude | undefined; let departureWork: Promise | undefined; let revision = 0; const waiters = new Set<() => void>(); @@ -110,7 +124,12 @@ export async function driveRun(deps: DriveRunDeps): Promise(); let pause: RunPausedEvent | undefined; const mayPrompt = (): boolean => - !cancelRequested && !inlineRefused && !inlineTerminating && !isTerminalOutcome(outcome); + !lifetime.stopped && + !terminal.sealed && + !cancelRequested && + !inlineRefused && + !inlineTerminating && + !isTerminalOutcome(outcome); const wake = (): void => { revision += 1; const pending = [...waiters]; @@ -133,7 +152,7 @@ export async function driveRun(deps: DriveRunDeps): Promise(work: Promise): Promise => { @@ -158,19 +177,18 @@ export async function driveRun(deps: DriveRunDeps): Promise(() => {}); }; - const requestCancellation = (): void => { - if (cancelRequested) return; + const onStop = (): void => { cancelRequested = true; activePrompt?.abort(); - handle.cancel(); wake(); }; - const onSigint = (): void => { - if (interrupted) process.exit(EXIT_CODES.workflowFailed); - interrupted = true; - requestCancellation(); + const requestCancellation = (): void => { + if (cancelRequested) return; + onStop(); + lifetime.stop(); }; - process.on('SIGINT', onSigint); + lifetime.signal.addEventListener('abort', onStop); + if (lifetime.stopped) requestCancellation(); let jobControl: ReturnType | undefined; let iterator: AsyncIterator | undefined; const beginDeparture = (): Promise => { @@ -178,12 +196,35 @@ export async function driveRun(deps: DriveRunDeps): Promise handle.depart()); departureWork = pending; // Observe rejection immediately, including while an input owner is still settling. - void pending.then( - () => {}, - () => {}, - ); + void pending.then((claim) => { + if (claim.kind !== 'continue') { + departure = claim; + publishHealth(); + } + }, recordFault); return pending; }; + const publishHealth = (): void => { + if (primaryDone && !healthRead) { + healthRead = true; + // Neither getter is authority until positive primary done; attempt both independently. + try { + finalDurability = handle.durability(); + } catch (cause) { + recordFault(cause); + } + try { + finalTerminalError = handle.terminalError(); + } catch (cause) { + recordFault(cause); + } + } + const exit = outcomeToExitCode(outcome, finalDurability, finalTerminalError, departure); + lifetime.publish( + exit === 8 || exit === 7 || exit === 5 || exit === 6 ? exit : undefined, + primaryDone && departure !== undefined, + ); + }; // Exactly one primary reader remains live across prompts, input ACKs and host joins. // There is no event spool: only the current pause and pending gate projections are retained. const primary = (async (): Promise => { @@ -191,7 +232,10 @@ export async function driveRun(deps: DriveRunDeps): Promise renderer?.releaseInput?.())); + await joined(terminal.releaseInput()); inputReleased = true; inputReleaseFailed = false; return true; - } catch { + } catch (cause) { + recordFault(cause); inputReleaseFailed = true; diagnostic( 'cleanup_failed', @@ -271,11 +321,22 @@ export async function driveRun(deps: DriveRunDeps): Promise io.writeOut(text) }); - if (deps.makeGatePrompter !== undefined) gatePrompter = deps.makeGatePrompter(); - renderer = makeRenderer(); + if (!lifetime.stopped && !terminal.sealed) { + if ( + detectOutputMode({ + stdoutIsTty: io.stdoutIsTty, + json: deps.json === true, + ci: isCiEnv(io.env), + }) === 'tui' + ) + jobControl = terminal.createJobControl((adopt) => + wireRunJobControl({ write: (text) => io.writeOut(text), adopt }), + ); + if (deps.makeGatePrompter !== undefined) gatePrompter = deps.makeGatePrompter(); + renderer = terminal.createRenderer(makeRenderer); + } } catch (error) { - driveError = error; + recordFault(error); requestCancellation(); } for (;;) { @@ -304,6 +365,10 @@ export async function driveRun(deps: DriveRunDeps): Promise terminal.runPrompt(invoke), + runViewOperation: (invoke, mayAcquire) => + terminal.runViewOperation(invoke, mayAcquire), + failed: recordFault, }, renderer, gatePrompter, @@ -317,7 +382,7 @@ export async function driveRun(deps: DriveRunDeps): Promise renderer?.resume?.()); inputReleased = false; } catch (error) { - driveError ??= error; + recordFault(error); requestCancellation(); } } } await progressAfter(seen); } + publishHealth(); try { - await renderer?.finalize?.(); - } catch { + if (!terminal.sealed) await terminal.runViewOperation(() => renderer?.finalize?.(), false); + } catch (cause) { + recordFault(cause); diagnostic('cleanup_failed', 'The run view could not write its final summary after cleanup.'); } if (departure !== undefined) { @@ -370,50 +440,55 @@ export async function driveRun(deps: DriveRunDeps): Promise void; readonly inputReleaseFailed: () => void; readonly signal: AbortSignal; + readonly runPrompt: ( + invoke: () => ReturnType, + ) => ReturnType; + readonly failed: (cause: unknown) => void; + readonly runViewOperation: ( + invoke: () => void | Promise, + mayAcquire?: boolean, + ) => Promise; } async function promptUntilStopped( @@ -451,7 +534,7 @@ async function resolveGateInline( ): Promise { let decision: Awaited>; try { - await renderer.suspend?.(); + await lifecycle.runViewOperation(() => renderer.suspend?.(), false); } catch (error) { lifecycle.inputReleaseFailed(); throw error; @@ -459,19 +542,23 @@ async function resolveGateInline( try { if (!lifecycle.mayPrompt()) return true; decision = await promptUntilStopped( - () => prompter.prompt(event, budget, lifecycle.signal), + () => lifecycle.runPrompt(() => prompter.prompt(event, budget, lifecycle.signal)), lifecycle.signal, ); + } catch (cause) { + // Preserve the prompt failure before a competing restoration failure enters its finally. + lifecycle.failed(cause); + throw cause; } finally { // Re-mount best-effort: a re-mount failure (ink's render() throwing) must NOT mask the prompt's decision // or its error — a throwing `finally` would replace the try's outcome. Surface it to stderr and move on, // exactly like driveRun's teardown (finalize) guard above. try { - if (lifecycle.mayPrompt()) await renderer.resume?.(); + if (lifecycle.mayPrompt()) await lifecycle.runViewOperation(() => renderer.resume?.()); } catch (resumeErr) { - io.writeErr( - `failed to restore the live view after the gate prompt: ${resumeErr instanceof Error ? resumeErr.message : String(resumeErr)}\n`, - ); + lifecycle.failed(resumeErr); + lifecycle.requestCancellation(); + io.writeErr('The live view could not be restored after the gate prompt.\n'); } } if (lifecycle.signal.aborted) return true; diff --git a/apps/cli/src/commands/fixtures/workflow-cleanup-child.ts b/apps/cli/src/commands/fixtures/workflow-cleanup-child.ts new file mode 100644 index 00000000..28e71b38 --- /dev/null +++ b/apps/cli/src/commands/fixtures/workflow-cleanup-child.ts @@ -0,0 +1,172 @@ +/** Inert native fixture: real command/engine/SQLite/MCP manager, synthetic connection, no provider or spawn. */ +import { randomUUID } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parseWorkflow } from '@relavium/core'; +import { + createClient, + createRunHistoryStore, + createRunLeasePort, + runMigrations, +} from '@relavium/db'; +import { startMcpClient, type McpConnection } from '@relavium/mcp'; +import { buildEngine } from '../../engine/build-engine.js'; +import { createCliHost } from '../../engine/host.js'; +import { createCliInvocation } from '../../process/invocation.js'; +import { captureIo } from '../../test-support.js'; +import { runCommand } from '../run.js'; +import { gateCommand } from '../gate.js'; + +const mode = process.argv[2]; +const root = process.argv[3]; +if (root === undefined || mode === undefined) throw new Error('Owned fixture arguments required.'); +const isGate = mode.startsWith('gate-'); +const heldClose = mode.endsWith('build-fault'); +const source = JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'workflow-native-cleanup', + agents: [ + { + id: 'unused', + provider: 'openai', + model: 'offline', + system_prompt: 'offline', + mcp_servers: [{ id: 'inert', transport: 'stdio', command: process.execPath }], + }, + ], + nodes: [ + { id: 'start', type: 'input' }, + { id: 'gate', type: 'human_gate', gate_type: 'approval' }, + { id: 'out', type: 'output' }, + ], + edges: [ + { from: 'start', to: 'gate' }, + { from: 'gate', to: 'out' }, + ], + }, +}); +const def = parseWorkflow(source); +const workflowPath = join(root, 'fixture.relavium.yaml'); +const configPath = join(root, 'config.toml'); +writeFileSync(workflowPath, source); +writeFileSync(configPath, ''); +const client = createClient(join(root, 'owned.sqlite')); +runMigrations(client.db); +const store = createRunHistoryStore(client.db, { + uuid: randomUUID, + now: Date.now, + projectRoot: root, + workflow: { slug: def.workflow.id, name: def.workflow.id, definitionJson: JSON.stringify(def) }, +}); +let runId = ''; +if (isGate) { + const seed = await buildEngine({ + host: createCliHost(store, { runLeases: createRunLeasePort(store) }), + }); + const handle = seed.start({ workflow: def, inputs: {}, eventConsumption: 'iterated' }); + runId = handle.runId; + const primary = handle.events[Symbol.asyncIterator](); + for (;;) { + const event = await primary.next(); + if (event.done) throw new Error('Fixture seed ended before pause.'); + if (event.value.type === 'run:paused') break; + } + const departed = await handle.depart(); + if (departed.kind !== 'detached') throw new Error('Fixture seed did not detach.'); + while (!(await primary.next()).done) { + /* Join the seeded primary before resuming in a new owner. */ + } + await primary.return?.(); +} +const io = captureIo(); +const invocation = createCliInvocation(); +let closeCount = 0; +let dbCloseCount = 0; +let elapsedFrom = 0; +let settled = false; +const keepalive = setInterval(() => undefined, 1000); +process.on('exit', () => { + writeFileSync( + join(root, 'exit.json'), + JSON.stringify({ + activeOwners: invocation.registry.activeOwners, + dbOpen: client.sqlite.open, + dbCloseCount, + closeCount, + settled, + observedExit: invocation.registry.observations.exitCode(), + elapsed: performance.now() - elapsedFrom, + stdout: io.out(), + stderr: io.err(), + }), + ); +}); +const connection: McpConnection = { + listTools: () => Promise.resolve([]), + callTool: () => Promise.resolve({ content: [], isError: false }), + close: () => { + closeCount++; + process.send?.({ type: 'cleanup-entered' }); + return heldClose + ? new Promise(() => {}) + : Promise.reject(new Error('PRIVATE_NATIVE_CONNECTION_CLOSE')); + }, +}; +const closeDb = (): void => { + dbCloseCount++; + client.sqlite.close(); +}; +const common = { + invocation, + io: io.io, + global: { json: true, color: false, verbosity: 'normal' as const, cwd: root, configPath }, + providers: { resolveProvider: () => undefined, keyFor: () => 'synthetic-unused' }, + consentGate: () => Promise.resolve(new Map()), + startMcpClient: () => startMcpClient([{ id: 'inert', open: () => Promise.resolve(connection) }]), + ...(heldClose ? { buildEngine: () => Promise.reject(new Error('PRIVATE_BUILD_FAILURE')) } : {}), +}; +process.on('message', (message: unknown) => { + if (typeof message !== 'object' || message === null) return; + const type: unknown = Object.getOwnPropertyDescriptor(message, 'type')?.value; + if (type === 'terminate') { + elapsedFrom = performance.now(); + process.emit('SIGTERM'); + return; + } + if (type !== 'go') return; + const pending = isGate + ? gateCommand( + { runId, approve: true }, + { ...common, openDb: () => ({ db: client.db, close: closeDb }) }, + ) + : runCommand( + { workflow: workflowPath, input: [], allowMcpStdio: [] }, + { + ...common, + openRunStore: () => ({ + db: client.db, + store, + terminalOutboxPath: join(root, 'outbox.ndjson'), + close: closeDb, + }), + }, + ); + void pending.then( + () => { + settled = true; + process.send?.({ type: 'unexpected-settlement' }); + }, + () => { + settled = true; + process.send?.({ type: 'unexpected-settlement' }); + }, + ); +}); +process.on('SIGTERM', () => { + if (elapsedFrom === 0) elapsedFrom = performance.now(); +}); +process.send?.({ type: 'ready' }); +await new Promise(() => { + void keepalive; +}); diff --git a/apps/cli/src/commands/fixtures/workflow-cleanup-register.mjs b/apps/cli/src/commands/fixtures/workflow-cleanup-register.mjs new file mode 100644 index 00000000..87baa3b1 --- /dev/null +++ b/apps/cli/src/commands/fixtures/workflow-cleanup-register.mjs @@ -0,0 +1,14 @@ +// The existing native resolver loads shipping TypeScript. These workflow paths additionally import TSX. +import { register } from 'node:module'; +import process from 'node:process'; +import { URL } from 'node:url'; + +if (process.env['RELAVIUM_USAGE_RUNTIME'] === undefined) { + register('../../engine/fixtures/ts-resolve-hook.mjs', import.meta.url); + register('./workflow-tsx-hook.mjs', import.meta.url); +} else { + // eslint-disable-next-line no-restricted-syntax -- exact local causal-only source/dependency fence; no provider module or developer dist borrowing + await import( + new URL('../../../../../tools/usage-coverage-controls/register.mjs', import.meta.url).href + ); +} diff --git a/apps/cli/src/commands/fixtures/workflow-tsx-hook.mjs b/apps/cli/src/commands/fixtures/workflow-tsx-hook.mjs new file mode 100644 index 00000000..3af62e5e --- /dev/null +++ b/apps/cli/src/commands/fixtures/workflow-tsx-hook.mjs @@ -0,0 +1,35 @@ +import { readFile } from 'node:fs/promises'; +import { URL } from 'node:url'; +import ts from 'typescript'; + +export async function resolve(specifier, context, nextResolve) { + try { + return await nextResolve(specifier, context); + } catch (cause) { + if (!specifier.endsWith('.js') || context.parentURL === undefined) throw cause; + const candidate = new URL(specifier.slice(0, -3) + '.tsx', context.parentURL); + try { + await readFile(candidate); + } catch { + throw cause; + } + return { url: candidate.href, shortCircuit: true }; + } +} + +export async function load(url, context, nextLoad) { + if (!url.endsWith('.tsx')) return await nextLoad(url, context); + const source = await readFile(new URL(url), 'utf8'); + return { + format: 'module', + shortCircuit: true, + source: ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + jsx: ts.JsxEmit.ReactJSX, + verbatimModuleSyntax: true, + }, + }).outputText, + }; +} diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index 6f5430d5..7fb2f1e0 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -54,7 +54,10 @@ import { const { consentPrompt } = vi.hoisted(() => ({ consentPrompt: vi.fn(() => Promise.resolve(false)), })); -vi.mock('../mcp/consent-prompt.js', () => ({ createConsentPrompter: () => consentPrompt })); +vi.mock('../mcp/consent-prompt.js', () => ({ + createConsentPrompter: () => consentPrompt, + createBoundConsentPrompter: () => consentPrompt, +})); /** * A WorkflowEngine stub for the closed-handle / EngineStateError paths that the real engine can't be driven @@ -265,7 +268,12 @@ describe('gateCommand', () => { /** The injected deps for a resume over the SHARED in-memory db (no-op close — the db spans the whole test). */ function deps(io: ReturnType['io']): GateCommandDeps { - return { io, global: globalOptions(), openDb: () => ({ db, close: () => {} }) }; + return { + io, + invocation: createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }), + global: globalOptions(), + openDb: () => ({ db, close: () => {} }), + }; } /** A read-only history store over the shared db (for asserting persisted events post-resume). */ @@ -332,6 +340,7 @@ describe('gateCommand', () => { options?.toolEnv?.nativeProcesses, invocation, options?.toolEnv?.workspaceDir ?? root, + 2, ); return buildEngine(options); }, @@ -913,7 +922,10 @@ describe('gateCommand', () => { expect( await gateCommand( { runId, approve: true, secretStdin: true, allowMcpStdio: [digest] }, - commandDeps, + { + ...commandDeps, + invocation: createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }), + }, ), ).toBe(EXIT_CODES.success); expect(startMcpClient).toHaveBeenCalledOnce(); diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index 4b352185..27f0133c 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -1,6 +1,9 @@ import type { CliInvocation } from '../process/invocation.js'; +import type { defaultSubscribeSignals } from '../process/signals.js'; +import { warnTeardown } from '../process/close-quietly.js'; import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; -import { closeQuietly, warnTeardown } from '../process/close-quietly.js'; +import { WorkflowLifetime } from './workflow-lifetime.js'; +import type { RendererCreationOwner } from './workflow-terminal.js'; import { EgressWorkScope } from '../engine/egress-work.js'; import { randomUUID } from 'node:crypto'; import { statSync } from 'node:fs'; @@ -66,9 +69,7 @@ import { type WorkflowMcpRuntime, } from '../engine/mcp-servers.js'; import { createConsentGate } from '../engine/mcp-consent-gate.js'; -import { guardMcpTeardown } from '../engine/mcp-signal-teardown.js'; -import { defaultSubscribeSignals } from '../process/signals.js'; -import { createConsentPrompter } from '../mcp/consent-prompt.js'; +import { createBoundConsentPrompter } from '../mcp/consent-prompt.js'; import { createMcpSecretResolver, type McpSecretResolver } from '../secrets/mcp-secret.js'; import type { GatePrompter } from '../gate/prompter.js'; import { selectGatePrompter } from '../gate/select-prompter.js'; @@ -142,6 +143,7 @@ export interface BudgetCommandArgs extends ResumeGateArgs, BudgetDecisionFlags { export interface GateCommandDeps { /** The original bootstrap invocation; native authority stays host-only. */ readonly invocation?: CliInvocation; + readonly subscribeSignals?: typeof defaultSubscribeSignals; readonly io: CliIo; readonly global: GlobalOptions; /** Injectable so tests drive a stub provider + the in-memory host engine. */ @@ -150,7 +152,11 @@ export interface GateCommandDeps { readonly providers?: ProviderResolver; /** Injectable history-db opener — tests pass an in-memory db; production opens `~/.relavium/history.db`. */ readonly openDb?: (homeDir: string) => { db: Db; close: () => void }; - readonly selectRenderer?: (io: CliIo, global: GlobalOptions) => RunRenderer; + readonly selectRenderer?: ( + io: CliIo, + global: GlobalOptions, + creation?: RendererCreationOwner, + ) => RunRenderer; readonly selectGatePrompter?: (io: CliIo, global: GlobalOptions) => GatePrompter | undefined; /** Injectable run-end host media GC (2.S/D-GC); defaults to {@link defaultSweepMedia}. Tests spy on it. */ readonly sweepMedia?: typeof defaultSweepMedia; @@ -270,7 +276,19 @@ async function resumeGateCommand( kind: 'human' | 'budget', decision: GateDecision, ): Promise { - const nativeProcesses = nativeProcessesForInvocation(deps.invocation); + const lifetime = new WorkflowLifetime(deps.invocation, deps.io, deps.subscribeSignals); + return lifetime.execute(() => resumeGateOwned(args, deps, kind, decision, lifetime)); +} + +async function resumeGateOwned( + args: ResumeGateArgs, + deps: GateCommandDeps, + kind: 'human' | 'budget', + decision: GateDecision, + lifetime: WorkflowLifetime, +): Promise { + const nativeProcesses = nativeProcessesForInvocation(lifetime.invocation); + lifetime.checkpoint(); const { config, homeDir } = loadResolvedConfig({ cwd: deps.global.cwd, configPath: deps.global.configPath, @@ -283,12 +301,13 @@ async function resumeGateCommand( } let mcpRuntime: WorkflowMcpRuntime | undefined; + let mcpAcknowledged = true; + const reportMcpCloseFailure = (_serverId: string, cause: unknown): void => { + mcpAcknowledged = false; + lifetime.cleanupFailed(cause); + warnTeardown(deps.io, 'MCP'); + }; const mcpWork = new EgressWorkScope(); - let unguardMcp = (): void => undefined; - let unguardResume = (): void => undefined; - let resumeEngine: WorkflowEngine | undefined; - const resumeCancel = new AbortController(); - let resumeStarted = false; const interruptedBeforeResume = (): ExitCode => { deps.io.writeErr( 'resume interrupted before a decision was recorded; the gate remains pending\n', @@ -296,6 +315,7 @@ async function resumeGateCommand( return EXIT_CODES.workflowFailed; }; try { + lifetime.checkpoint(); const snapshot = loadRunSnapshot(opened.db, args.runId); if (snapshot === undefined) { throw new CliError('invalid_invocation', `no run found with id ${args.runId}`); @@ -324,6 +344,7 @@ async function resumeGateCommand( } catch (err) { throwGateLoadFault(err, args.runId); } + lifetime.checkpoint(); if (checkpoint === undefined) { // A run row with a snapshot but no reconstructable checkpoint (no run:started in the log) — corrupt/partial. throw new CliError('invalid_invocation', `run ${args.runId} has no resumable state`); @@ -371,7 +392,9 @@ async function resumeGateCommand( args, deps, resumeInvocation, + lifetime, ); + lifetime.checkpoint(); // Consuming stdin removes prompt authority for this invocation, even with stale TTY metadata. const mayPrompt = args.secretStdin !== true; const promptIo = mayPrompt ? deps.io : { ...deps.io, stdinIsTty: false }; @@ -379,6 +402,7 @@ async function resumeGateCommand( kind === 'budget' && decision.decision === 'rejected' ? undefined : deps.resolveKeys?.(opened.db); + lifetime.checkpoint(); const providers = deps.providers ?? keys?.providers ?? createProviderResolver(deps.io.env); // Media host-wiring (2.S), the SAME helper `run` uses: a gate-resumed run that produces media must wire the // same CAS + retention + catalog as the original run (else it would be silently text-only). The checkpointer @@ -393,40 +417,18 @@ async function resumeGateCommand( // authored `output_modalities` against the CURRENT catalog, so a model that lost a capability between the // original run and this resume is rejected consistently (exit 2), not silently routed at runtime. assertWorkflowCatalogValid(workflow, wiring.workflowModelCatalog); - // Cancellation belongs to the resume command even when no MCP server is declared. During passive - // preparation Core latches cancel(runId); once registered it cancels the actual execution. - unguardResume = defaultSubscribeSignals(() => { - if (resumeCancel.signal.aborted) return; - resumeCancel.abort(); - if (!resumeStarted || resumeEngine === undefined) return; - try { - resumeEngine.cancel(args.runId); - } catch (error) { - if ( - !(error instanceof EngineStateError) || - (error.code !== 'unknown_run' && error.code !== 'run_already_terminal') - ) - throw error; - // The signal can precede registration or follow settlement. The latch is checked on handoff. - } - }); // A budget rejection is fatal and dispatches no agent. Do not spawn tools merely to reject it. if (workflowDeclaresMcp(workflow) && !(kind === 'budget' && decision.decision === 'rejected')) { - unguardMcp = guardMcpTeardown( - async () => { - await mcpRuntime?.client.close(); - }, - undefined, - { - reapOnly: true, - ...(deps.invocation === undefined ? {} : { invocation: deps.invocation }), - }, - ); mcpRuntime = await connectWorkflowMcp(workflow, { ...(nativeProcesses === undefined ? {} : { nativeProcesses }), cwd: saveToRoot, preserveFrozenGrants: true, - connectSignal: resumeCancel.signal, + connectSignal: lifetime.signal, + assertAdmitting: () => lifetime.checkpoint(), + onFailure: (cause) => { + if (!lifetime.isStoppedFailure(cause)) lifetime.failure(cause); + }, + onCloseError: reportMcpCloseFailure, work: mcpWork, registrations: config.mcpServers, resolveSecret: @@ -439,18 +441,27 @@ async function resumeGateCommand( global: deps.global, homeDir, allowedDigests: args.allowMcpStdio ?? [], - prompt: mayPrompt ? createConsentPrompter() : undefined, + signal: lifetime.signal, + prompt: mayPrompt + ? (subject, options) => { + const prompt = createBoundConsentPrompter(promptIo); + return lifetime.terminal.runPrompt(() => prompt(subject, options)); + } + : undefined, }), ...(deps.startMcpClient === undefined ? {} : { startMcpClient: deps.startMcpClient }), }); + lifetime.checkpoint(); if (mcpRuntime !== undefined) surfaceMcpSkipped(deps.io, mcpRuntime.client.skipped); } + lifetime.checkpoint(); // The ADR-0065 §2 user-pricing overlay (2.5.G S10) — read from the SAME durable `history.db`, so the resumed // workflow's post-gate continuation enforces `budget.max_cost_microcents` on a user-priced model exactly like // the original `run` did (pre-egress + realized). Without it a gated run would silently uncap that model on the // far side of the gate — the very ADR-0064 §6 gap this closes. Non-fatal read (an empty map ⇒ no user pricing). const resolvePrice = readUserPricingOverlay(opened.db); + lifetime.checkpoint(); const engine = await (deps.buildEngine ?? defaultBuildEngine)({ ...(mcpRuntime === undefined ? {} @@ -522,13 +533,12 @@ async function resumeGateCommand( : { mediaCostEstimate: wiring.mediaCostEstimate }), ...(resolvePrice.size === 0 ? {} : { resolvePrice }), }); - resumeEngine = engine; + lifetime.adoptEngine(engine, args.runId); // Same drain as the `run` path (ADR-0078 §4/§5) — a gate resume is equally "the next `relavium` start", // and it is the one a user reaches for after seeing the `durabilityUncertain` exit code on a gated run. - if (resumeCancel.signal.aborted) return interruptedBeforeResume(); + lifetime.checkpoint(); await engine.drainTerminalOutbox().catch(() => undefined); - if (resumeCancel.signal.aborted) return interruptedBeforeResume(); - resumeStarted = true; + lifetime.checkpoint(); const handle = await resumeOrFail(engine, { runId: args.runId, eventConsumption: 'iterated', @@ -538,12 +548,14 @@ async function resumeGateCommand( decision, }); - if (resumeCancel.signal.aborted) handle.cancel(); + lifetime.adoptHandle(handle); const outcome = await driveRun({ + lifetime, engine, handle, - makeRenderer: () => (deps.selectRenderer ?? selectRenderer)(deps.io, deps.global), + makeRenderer: (creation) => + (deps.selectRenderer ?? selectRenderer)(deps.io, deps.global, creation), // A later gate cannot read the stdin stream already consumed for re-supplied secrets. makeGatePrompter: () => mayPrompt @@ -615,21 +627,30 @@ async function resumeGateCommand( } catch (err) { // The pre-connect guard owns child cleanup; this command owns the run-style interruption exit. // Once resume has started, the handle/engine error remains authoritative. - if (!resumeStarted && resumeCancel.signal.aborted) return interruptedBeforeResume(); + if (lifetime.isStoppedFailure(err)) return interruptedBeforeResume(); + lifetime.failure(err); throw err; } finally { try { - try { - // Cleanup must not replace a primary refusal or a durably completed command result. - await mcpRuntime?.client.close().catch(() => warnTeardown(deps.io, 'MCP')); - } finally { - mcpWork.seal(); - await mcpWork.done; - closeQuietly(deps.io, 'history database', () => opened.close()); - } - } finally { - unguardMcp(); - unguardResume(); + await mcpRuntime?.client.close(reportMcpCloseFailure); + } catch (cause) { + mcpAcknowledged = false; + lifetime.cleanupFailed(cause); + warnTeardown(deps.io, 'MCP'); + } + mcpWork.seal(); + try { + await mcpWork.done; + } catch (cause) { + mcpAcknowledged = false; + lifetime.cleanupFailed(cause); + } + if (!mcpAcknowledged) await new Promise(() => {}); + try { + opened.close(); + } catch (cause) { + lifetime.cleanupFailed(storageBoundaryError(cause)); + warnTeardown(deps.io, 'history database'); } } } @@ -788,6 +809,7 @@ async function resolveSecretInputs( args: ResumeGateArgs, deps: GateCommandDeps, resumeInvocation: string | undefined, + lifetime: WorkflowLifetime, ): Promise> { const masked = Object.keys(inputs).filter( (key) => MaskedSecretSchema.safeParse(inputs[key]).success, @@ -812,7 +834,9 @@ async function resolveSecretInputs( deps.readSecretInput ?? ((): Promise => readSecretFromStdin(SECRET_STDIN_CONTEXT(args.runId, resumeInvocation))); - const supplied = parseSecretLines(await read(), `run ${args.runId}`); + const raw = await read(); + lifetime.checkpoint(); + const supplied = parseSecretLines(raw, `run ${args.runId}`); const missing = masked.filter((name) => !Object.hasOwn(supplied, name)); if (missing.length > 0) { throw new CliError( diff --git a/apps/cli/src/commands/lazy-dispatch.test.ts b/apps/cli/src/commands/lazy-dispatch.test.ts index 78c30dca..aa66fa50 100644 --- a/apps/cli/src/commands/lazy-dispatch.test.ts +++ b/apps/cli/src/commands/lazy-dispatch.test.ts @@ -45,6 +45,7 @@ function context(seed: () => Promise): DispatchContext { io: { writeOut: () => undefined, writeErr: () => undefined, + writeOutAcknowledged: () => Promise.resolve(), writeErrAcknowledged: () => Promise.resolve(), env: {}, stdoutIsTty: false, diff --git a/apps/cli/src/commands/native-forwarding.test-support.ts b/apps/cli/src/commands/native-forwarding.test-support.ts index 26b36f82..41bfbd03 100644 --- a/apps/cli/src/commands/native-forwarding.test-support.ts +++ b/apps/cli/src/commands/native-forwarding.test-support.ts @@ -15,6 +15,7 @@ export async function witnessNativeToolProcess( nativeProcesses: NativeProcessController | undefined, invocation: CliInvocation, workspaceDir: string, + expectedParentOwners = 0, ): Promise { expect(nativeProcesses, 'NATIVE_INVOCATION_FORWARDING').toBe( nativeProcessesForInvocation(invocation), @@ -26,7 +27,7 @@ export async function witnessNativeToolProcess( ...(nativeProcesses === undefined ? {} : { nativeProcesses }), }); if (host.process === undefined) throw new Error('the real process arm is missing'); - expect(invocation.registry.activeOwners).toBe(0); + expect(invocation.registry.activeOwners).toBe(expectedParentOwners); const pending = host.process.spawn( process.execPath, ['-e', 'setTimeout(() => process.stdout.write("native-witness"), 150)'], @@ -34,11 +35,16 @@ export async function witnessNativeToolProcess( { timeoutMs: 2_000 }, ); await vi.waitFor( - () => expect(invocation.registry.activeOwners, 'NATIVE_PROCESS_ASSEMBLY_OWNERSHIP').toBe(1), + () => + expect(invocation.registry.activeOwners, 'NATIVE_PROCESS_ASSEMBLY_OWNERSHIP').toBe( + expectedParentOwners + 1, + ), { interval: 1 }, ); expect(await pending).toMatchObject({ exitCode: 0, stdout: 'native-witness' }); - await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(0), { interval: 1 }); + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(expectedParentOwners), { + interval: 1, + }); } /** Keep an actual tool child alive across overlapping builders, bounded independently of their gates. */ diff --git a/apps/cli/src/commands/run-secret-consent.test.ts b/apps/cli/src/commands/run-secret-consent.test.ts index 31ca68a9..e4b5503a 100644 --- a/apps/cli/src/commands/run-secret-consent.test.ts +++ b/apps/cli/src/commands/run-secret-consent.test.ts @@ -15,7 +15,10 @@ import { captureIo } from '../test-support.js'; import { runCommand } from './run.js'; const { prompt } = vi.hoisted(() => ({ prompt: vi.fn(() => Promise.resolve(false)) })); -vi.mock('../mcp/consent-prompt.js', () => ({ createConsentPrompter: () => prompt })); +vi.mock('../mcp/consent-prompt.js', () => ({ + createConsentPrompter: () => prompt, + createBoundConsentPrompter: () => prompt, +})); let root: string; let priorHome: string | undefined; diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts index 9e317d62..4a1b3fe5 100644 --- a/apps/cli/src/commands/run.test.ts +++ b/apps/cli/src/commands/run.test.ts @@ -237,6 +237,7 @@ function deps( return { io, global, + invocation: createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }), buildEngine: () => buildEngine({ host: createInMemoryHost(), @@ -344,7 +345,12 @@ describe('runCommand', () => { invocation, ...(durable ? { openRunStore: historyOpenRunStore(client.db) } : {}), buildEngine: async (options) => { - await witnessNativeToolProcess(options?.toolEnv?.nativeProcesses, invocation, root); + await witnessNativeToolProcess( + options?.toolEnv?.nativeProcesses, + invocation, + root, + 2, + ); return buildEngine({ ...options, ...(durable ? {} : { host: createInMemoryHost() }), @@ -918,18 +924,20 @@ describe('runCommand', () => { expect(finalizeCalls).toBe(1); }); - it('does not let a renderer finalize() error mask the run outcome (logs to stderr instead)', async () => { + it('classifies an unexpected finalize() failure as infrastructure without leaking its cause', async () => { const path = writeWorkflow('happy.relavium.yaml', HAPPY); const { io, err } = captureIo(); const renderer: RunRenderer = { onEvent: () => {}, finalize: () => Promise.reject(new Error('unmount blew up')), }; - const code = await runCommand( + const pending = runCommand( { workflow: path, input: ['n=3'], allowMcpStdio: [] }, deps(io, globalOptions(), { selectRenderer: () => renderer }), ); - expect(code).toBe(EXIT_CODES.success); // the run outcome is preserved + await expect(pending).rejects.toThrow('unmount blew up'); + const failure = await pending.catch((cause: unknown) => toUserFacing(cause)); + expect(failure).toMatchObject({ code: 'cli_internal', exitCode: 9 }); expect(err()).toContain('The run view could not write its final summary after cleanup.'); expect(err()).not.toContain('unmount blew up'); }); @@ -963,6 +971,46 @@ describe('runCommand', () => { expect(seqs).toEqual([...seqs].sort((a, b) => a - b)); }); + it('keeps TTY --json native-free through terminal retirement, with no cursor bytes or job-control listeners', async () => { + const path = writeWorkflow('happy-json-tty.relavium.yaml', HAPPY); + const captured = captureIo(); + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); + const continued = process.listeners('SIGCONT'); + const acknowledge = vi.fn((text: string) => captured.io.writeOutAcknowledged(text)); + const code = await runCommand( + { workflow: path, input: ['n=3'], allowMcpStdio: [] }, + { + ...deps( + { + ...captured.io, + stdoutIsTty: true, + stdinIsTty: true, + writeOutAcknowledged: acknowledge, + }, + globalOptions({ json: true }), + ), + invocation, + }, + ); + expect(code).toBe(0); + expect(captured.out(), 'WORKFLOW_TTY_JSON_BYTES').not.toContain('\u001b'); + const events = captured + .out() + .trimEnd() + .split('\n') + .map((line) => { + const raw: unknown = JSON.parse(line); + expect(RunEventSchema.parse(raw)).toEqual(raw); + return RunEventSchema.parse(raw); + }); + expect(events[0]?.type).toBe('run:started'); + expect(events.at(-1)?.type).toBe('run:completed'); + expect(captured.out()).not.toContain('\u001b'); + expect(acknowledge).not.toHaveBeenCalled(); + expect(process.listeners('SIGCONT')).toEqual(continued); + expect(invocation.registry.activeOwners).toBe(0); + }); + it('keeps stdout a pure NDJSON stream ending in run:failed (the failure stays a RunEvent, not a stderr fault)', async () => { const path = writeWorkflow('fail.relavium.yaml', FAILING); const { io, out, err } = captureIo(); @@ -1113,34 +1161,32 @@ describe('runCommand', () => { expect(process.listeners('SIGINT')).toHaveLength(before.length); }, 15_000); // generous ceiling: the coordination is deterministic, but a cold/starved CI worker is slow - it('forces a clean exit-1 on a SECOND SIGINT while the cancel drains (never the bare-signal 130)', async () => { - // Stub process.exit to THROW (like a real exit halting the handler), so onSigint cannot fall through to a - // second handle.cancel() — mirroring production where process.exit never returns. - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number): never => { - throw new Error(`process.exit:${String(code)}`); - }) as typeof process.exit); - try { - const path = writeWorkflow('stall.relavium.yaml', STALL); - const { io } = captureIo(); - const before = process.listeners('SIGINT'); - const { buildEngine: buildStalling, reachedSlow } = makeStallingCancelEngine(); - const pending = runCommand( - { workflow: path, input: [], allowMcpStdio: [] }, - deps(io, globalOptions(), { buildEngine: buildStalling }), - ); - await reachedSlow; - const onSigint = process.listeners('SIGINT').filter((l) => !before.includes(l))[0]; - if (typeof onSigint !== 'function') { - throw new TypeError('expected run.ts to register a SIGINT handler'); - } - onSigint('SIGINT'); // first press → cooperative cancel (cancelRequested = true) - expect(() => onSigint('SIGINT')).toThrow('process.exit:1'); // second press → forced exit - expect(exitSpy).toHaveBeenCalledWith(EXIT_CODES.workflowFailed); - const code = await pending; // the run still drains to run:cancelled from the first press - expect(code).toBe(EXIT_CODES.workflowFailed); - } finally { - exitSpy.mockRestore(); - } + it('routes a SECOND SIGINT through shared finite exit 130 while the first cooperative cancel still drains', async () => { + const path = writeWorkflow('stall.relavium.yaml', STALL); + const { io } = captureIo(); + const before = process.listeners('SIGINT'); + const exit = vi.fn(); + const invocation = createCliInvocation({ exit, setExitCode: vi.fn() }); + const { buildEngine: buildStalling, reachedSlow } = makeStallingCancelEngine(); + const pending = runCommand( + { workflow: path, input: [], allowMcpStdio: [] }, + deps(io, globalOptions(), { buildEngine: buildStalling, invocation }), + ); + await reachedSlow; + const onSigint = process + .listeners('SIGINT') + .filter((listener) => !before.includes(listener))[0]; + if (typeof onSigint !== 'function') + throw new TypeError('expected a workflow SIGINT subscription'); + onSigint('SIGINT'); + expect(exit).not.toHaveBeenCalled(); + onSigint('SIGINT'); + expect(await invocation.finiteExit.exited).toBe(130); + expect(exit).toHaveBeenCalledExactlyOnceWith(130); + // The injected physical exit returns; it does not manufacture a cleanup ACK. + expect(await pending).toBe(EXIT_CODES.workflowFailed); + expect(invocation.registry.activeOwners).toBe(0); + expect(process.listeners('SIGINT')).toHaveLength(before.length); }, 15_000); it('cancels the still-live engine and propagates when renderer construction throws (abnormal unwind)', async () => { @@ -1155,7 +1201,8 @@ describe('runCommand', () => { { workflow: path, input: [], allowMcpStdio: [] }, deps(io, globalOptions(), { buildEngine: buildStalling, - selectRenderer: () => { + selectRenderer: (_io, _global, creation) => { + creation?.acknowledgeNoAcquisition(); throw new Error('render boom'); }, }), @@ -1462,6 +1509,7 @@ workflow: close: () => Promise.resolve(), }; const baselineSigint = process.listenerCount('SIGINT'); + const baselineExit = process.listenerCount('exit'); let sigintDuringConnect = -1; let exitDuringConnect = -1; @@ -1490,13 +1538,9 @@ workflow: expect(code).toBe(EXIT_CODES.success); // The cooperative half is armed during the connect — the window the SDK spawns children in. expect(sigintDuringConnect).toBeGreaterThan(baselineSigint); - // The synchronous reaper is PROCESS-scoped, so a per-guard delta is no longer the right claim (a guard - // built earlier in this file already installed it). That it exists, reads the live registry, and survives - // an `unguard` mid-teardown is pinned where it belongs: `mcp-signal-teardown.test.ts` for the mechanism, - // and `tools/cli-smoke/check.mjs` for the end-to-end orphan, which needs a real process to mean anything. - expect(exitDuringConnect).toBeGreaterThanOrEqual(1); - // The SIGNAL half is released on the normal path, where the command's own `finally` owns the teardown. - // The `exit` reaper is process-scoped by design and stays — see `mcp-signal-teardown.ts`. + // This injected connection owns no native PID/group. Do not fabricate an exit reaper. + // Actual native creation/retirement is covered by the physical forwarding and installed controls. + expect(exitDuringConnect).toBe(baselineExit); expect(process.listenerCount('SIGINT')).toBe(baselineSigint); }); @@ -1513,6 +1557,7 @@ workflow: const path = writeWorkflow('mcp-guard-teardown.relavium.yaml', MCP_WF); const { io } = captureIo(); const baselineSigint = process.listenerCount('SIGINT'); + const baselineExit = process.listenerCount('exit'); let exitDuringClose = -1; let sigintDuringClose = -1; const conn: McpConnection = { @@ -1547,9 +1592,8 @@ workflow: // The cooperative half is still armed while the children are being reaped — the ordering this test is // about. The synchronous reaper is process-scoped (see the note in the test above). expect(sigintDuringClose).toBeGreaterThan(baselineSigint); - expect(exitDuringClose).toBeGreaterThanOrEqual(1); - // …and the SIGNAL half released once the teardown is done. The exit reaper is process-scoped and stays, - // which is what keeps it armed through a teardown some other path's `unguard` would otherwise cut short. + expect(exitDuringClose).toBe(baselineExit); // No native process is created by this injected connection. + // The single command signal subscription retires only after the actual teardown. expect(process.listenerCount('SIGINT')).toBe(baselineSigint); }); diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index c1bf316b..8a6bbb52 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -1,6 +1,9 @@ import type { CliInvocation } from '../process/invocation.js'; +import type { defaultSubscribeSignals } from '../process/signals.js'; +import { warnTeardown } from '../process/close-quietly.js'; import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; -import { closeQuietly, warnTeardown } from '../process/close-quietly.js'; +import type { RendererCreationOwner } from './workflow-terminal.js'; +import { WorkflowLifetime } from './workflow-lifetime.js'; import { EgressWorkScope } from '../engine/egress-work.js'; import { randomUUID } from 'node:crypto'; @@ -32,7 +35,6 @@ import { sweepCommittedEffects } from '../engine/effect-retention.js'; import { createCliHost } from '../engine/host.js'; import { connectWorkflowMcp, - workflowDeclaresMcp, type StdioConsentGate, surfaceMcpSkipped, type WorkflowMcpRuntime, @@ -52,8 +54,7 @@ import type { GatePrompter } from '../gate/prompter.js'; import { selectGatePrompter } from '../gate/select-prompter.js'; import type { OpenedHistory } from '../history/open.js'; import { createConsentGate } from '../engine/mcp-consent-gate.js'; -import { guardMcpTeardown } from '../engine/mcp-signal-teardown.js'; -import { createConsentPrompter } from '../mcp/consent-prompt.js'; +import { createBoundConsentPrompter } from '../mcp/consent-prompt.js'; import { CliError } from '../process/errors.js'; import { storageBoundaryError } from '../process/owned-storage.js'; import { sourceLabel } from '../process/source-label.js'; @@ -99,6 +100,8 @@ export interface RunCommandArgs { export interface RunCommandDeps { /** The original bootstrap invocation; native authority stays host-only. */ readonly invocation?: CliInvocation; + /** Host-owned subscription, injectable without installing ambient OS listeners in retained-owner tests. */ + readonly subscribeSignals?: typeof defaultSubscribeSignals; readonly io: CliIo; readonly global: GlobalOptions; /** Injectable so tests (and the 2.K harness) drive a stub provider + the in-memory host. */ @@ -125,7 +128,11 @@ export interface RunCommandDeps { * Injectable renderer selector (TUI / json / plain). Defaults to the real {@link selectRenderer}; tests * inject a fake renderer (onEvent + finalize spies) to assert the finalize wiring without a TTY. */ - readonly selectRenderer?: (io: CliIo, global: GlobalOptions) => RunRenderer; + readonly selectRenderer?: ( + io: CliIo, + global: GlobalOptions, + creation?: RendererCreationOwner, + ) => RunRenderer; /** * Injectable interactive gate-prompter selector (2.G). Defaults to the real {@link selectGatePrompter} * (present only on an interactive TTY); tests inject a fake prompter to drive the inline gate-resolve path @@ -175,7 +182,17 @@ function parseOrRefuse(yaml: string, source: string): WorkflowDefinition { } export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Promise { - const nativeProcesses = nativeProcessesForInvocation(deps.invocation); + const lifetime = new WorkflowLifetime(deps.invocation, deps.io, deps.subscribeSignals); + return lifetime.execute(() => runOwned(args, deps, lifetime)); +} + +async function runOwned( + args: RunCommandArgs, + deps: RunCommandDeps, + lifetime: WorkflowLifetime, +): Promise { + const nativeProcesses = nativeProcessesForInvocation(lifetime.invocation); + lifetime.checkpoint(); if ((deps.global.allowFsRoots?.length ?? 0) > 0) { throw new CliError( 'invalid_invocation', @@ -197,7 +214,8 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr const def = parseOrRefuse(source.yaml, sourceLabel(deps.global.cwd, source.path)); assertOrdinaryInputsAllowed(def, rawInputs); - const secretInputs = await readRunSecrets(args, deps, def); + const secretInputs = await readRunSecrets(args, deps, def, lifetime); + lifetime.checkpoint(); const admitted = resolveAndValidateWorkflowInputs( def, resolveInputs(def, rawInputs, secretInputs), @@ -208,7 +226,9 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr } const inputs = admitted.inputs; warnSnapshotLiterals(def, deps.io); + lifetime.checkpoint(); const keys = deps.resolveKeys?.(); + lifetime.checkpoint(); // Preserve injected precedence, sharing the admitted run's resolver with provider preflight. const providers = deps.providers ?? keys?.providers ?? createProviderResolver(deps.io.env); const mayPrompt = args.secretInputsStdin !== true; @@ -221,6 +241,7 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr // conditional and still surfaces at runtime, so the pre-flight never false-fails a valid run. The key // is read only to confirm presence here — never logged, stored, or rendered. for (const id of neededProviderIds(def)) { + lifetime.checkpoint(); providers.keyFor(id); } @@ -228,38 +249,13 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr // the store cannot be opened until the definition it must freeze is known. let opened: OpenedHistory | undefined; let mcpRuntime: WorkflowMcpRuntime | undefined; + let mcpAcknowledged = true; + const reportMcpCloseFailure = (_serverId: string, cause: unknown): void => { + mcpAcknowledged = false; + lifetime.cleanupFailed(cause); + warnTeardown(deps.io, 'MCP'); + }; const mcpWork = new EgressWorkScope(); - /** - * The signal guard, armed BEFORE the connect (ADR-0088 §1.3). - * - * Arming it after `connectWorkflowMcp` resolved was the first attempt, and a review found the hole: the - * SDK spawns each stdio child inside `start()`, so for up to 120 s per server — precisely the silent window - * where a user reaches for Ctrl-C — the children existed with no guard armed. Two things follow from arming - * early: native ownership is registered during creation, and the connect itself is given a - * cancel signal so a Ctrl-C during it actually stops rather than merely being survived. - * - * `reapOnly`: `driveRun` owns this surface's documented signal contract — Ctrl-C drains to `run:cancelled` - * and exits `1` (commands.md). This guard reaps children; it must not race that exit code. - */ - const mcpConnectCancel = new AbortController(); - // **Only when there is something to guard.** Arming unconditionally was the first attempt, and this - // command's own SIGINT contract test caught it: `drive.ts` identifies its handler by set-delta and asserts - // exactly one was added, so a guard installed for a workflow that declares no MCP server is a second - // listener with nothing to reap — breaking a documented invariant to protect children that do not exist. - const unguardMcp = workflowDeclaresMcp(def) - ? guardMcpTeardown( - async () => { - mcpConnectCancel.abort(); // stops an in-flight connect; a no-op once one completed - await mcpRuntime?.client.close(); - }, - // The original invocation owns native creation during the pending connect as well as after it. - undefined, - { - reapOnly: true, - ...(deps.invocation === undefined ? {} : { invocation: deps.invocation }), - }, - ) - : (): void => undefined; try { // Inbound MCP (2.R Step 3b): aggregate the `mcp_servers` declared by the workflow's INLINE agents, start // them fail-loud (a connect/discovery failure is an exit-2 CliError, cause stripped), and rewrite the @@ -268,7 +264,12 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr mcpRuntime = await connectWorkflowMcp(def, { ...(nativeProcesses === undefined ? {} : { nativeProcesses }), cwd: deps.global.cwd, - connectSignal: mcpConnectCancel.signal, + connectSignal: lifetime.signal, + assertAdmitting: () => lifetime.checkpoint(), + onFailure: (cause) => { + if (!lifetime.isStoppedFailure(cause)) lifetime.failure(cause); + }, + onCloseError: reportMcpCloseFailure, work: mcpWork, resolveSecret: deps.mcpSecretResolver ?? keys?.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env), @@ -284,10 +285,17 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr global: deps.global, homeDir, allowedDigests: args.allowMcpStdio, - prompt: mayPrompt ? createConsentPrompter() : undefined, + signal: lifetime.signal, + prompt: mayPrompt + ? (subject, options) => { + const prompt = createBoundConsentPrompter(promptIo); + return lifetime.terminal.runPrompt(() => prompt(subject, options)); + } + : undefined, }), ...(deps.startMcpClient === undefined ? {} : { startMcpClient: deps.startMcpClient }), }); + lifetime.checkpoint(); if (mcpRuntime !== undefined) surfaceMcpSkipped(deps.io, mcpRuntime.client.skipped); const runWorkflow = mcpRuntime?.workflow ?? def; @@ -304,6 +312,7 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr // exact graph that ran" recorded a graph that did not run. MCP-discovered tool grants are part of // workflow identity: a server that returns a different tool set on resume IS a divergence, and it can // only be seen if what was frozen is what was executed. + lifetime.checkpoint(); try { opened = deps.openRunStore?.(runWorkflow, homeDir, deps.global.cwd); } catch (err) { @@ -423,7 +432,10 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr ...mcpOption, }; } + lifetime.checkpoint(); const engine = await build(engineOptions); + lifetime.adoptEngine(engine); + lifetime.checkpoint(); // **Drain the terminal outbox before starting (ADR-0078 §4/§5).** A prior process may have produced a // terminal its store would not take; it is held in `~/.relavium/terminal-outbox.ndjson` and this is // "the next `relavium` start" that the `durabilityUncertain` exit code tells the user to wait for. Draining @@ -437,6 +449,7 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr // agent's `tools` already includes what its servers discovered. That makes the plan-build narrowing // check correct here (ADR-0094) — a node listing a tool its agent lacks is refused before the run id // exists, rather than partway through, after upstream nodes have spent money. + lifetime.checkpoint(); const handle = engine.start({ workflow: runWorkflow, eventConsumption: 'iterated', @@ -444,15 +457,19 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr planOptions: { toolGrantsFinal: true }, }); - // Hand the live run to the shared driver (2.G): it owns the event loop, the SIGINT cooperative-cancel - // contract, the renderer lifecycle (constructed inside, after SIGINT registration — output mode per + lifetime.adoptHandle(handle); + + // Hand the live run and the already-registered command lifetime to the shared driver (2.G). It owns + // the primary event loop and the reserved renderer lifecycle (output mode per // commands.md "Output modes": ink TUI on a TTY, NDJSON under --json, plain otherwise), and the inline // human-gate prompt when an interactive prompter is present (CI / --json / no-TTY → no prompter → a gate // pause exits 3, resumable by `relavium gate`). const outcome = await driveRun({ + lifetime, engine, handle, - makeRenderer: () => (deps.selectRenderer ?? selectRenderer)(deps.io, deps.global), + makeRenderer: (creation) => + (deps.selectRenderer ?? selectRenderer)(deps.io, deps.global, creation), makeGatePrompter: () => mayPrompt ? (deps.selectGatePrompter ?? selectGatePrompter)(deps.io, deps.global) @@ -507,30 +524,33 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr ); } return exitCode; + } catch (cause) { + // Record before an entered MCP/DB cleanup tail can hold the rejection from execute(). + if (!lifetime.isStoppedFailure(cause)) lifetime.failure(cause); + throw cause; } finally { - // Guarantee the MCP teardown runs EVEN IF the db close throws — a nested finally so neither resource leaks. - // Present only when an inline agent declared a server; idempotent. A teardown error must never mask the run - // outcome (closeAll swallows per-connection; the db close is best-effort here too). + // Cleanup is joined directly, never via a registry promise that depends on this finally. + // Neither cancellation nor a deadline certifies the engine/MCP/DB or terminal released. + try { + await mcpRuntime?.client.close(reportMcpCloseFailure); + } catch (cause) { + mcpAcknowledged = false; + lifetime.cleanupFailed(cause); + warnTeardown(deps.io, 'MCP'); + } + mcpWork.seal(); + try { + await mcpWork.done; + } catch (cause) { + mcpAcknowledged = false; + lifetime.cleanupFailed(cause); + } + if (!mcpAcknowledged) await new Promise(() => {}); try { - try { - await mcpRuntime?.client.close().catch(() => warnTeardown(deps.io, 'MCP')); - } finally { - mcpWork.seal(); - await mcpWork.done; - closeQuietly(deps.io, 'history database', () => opened?.close()); - } - } finally { - // **Released LAST, after the MCP close has finished** — and it was released first, which left the one - // window that matters uncovered. `client.close()` runs the SDK's ladder against a trapping child - // (`stdin.end()` → 2 s → SIGTERM → 2 s → SIGKILL, ~4 s), and for all of it the children are alive. A - // `SIGTERM` arriving there with the guard already gone is default-handled: the process dies without - // `process.on('exit')`, and the children it was mid-way through reaping are orphaned at `ppid 1` — the - // exact failure `guardMcpTeardown` was measured against and exists to close. - // - // Safe because `closeAll` clears its connection map before awaiting, so the guard's own close racing - // this one finds nothing left to do. `agent run` already had this order: its MCP teardown happens inside - // `runOneShotTurn`, i.e. before its `unguardMcp()`. This makes the two surfaces agree. - unguardMcp(); + opened?.close(); + } catch (cause) { + lifetime.cleanupFailed(storageBoundaryError(cause)); + warnTeardown(deps.io, 'history database'); } } } @@ -547,6 +567,7 @@ async function readRunSecrets( args: RunCommandArgs, deps: RunCommandDeps, def: WorkflowDefinition, + lifetime: WorkflowLifetime, ): Promise>> { if (!args.secretInputsStdin) return {}; if (!(def.workflow.inputs ?? []).some((decl) => decl.type === 'secret')) { @@ -556,5 +577,6 @@ async function readRunSecrets( ); } const raw = await (deps.readSecretInput?.() ?? readSecretFromStdin(RUN_SECRET_STDIN_CONTEXT)); + lifetime.checkpoint(); return parseSecretLines(raw, 'run --secret-inputs-stdin'); } diff --git a/apps/cli/src/commands/safe-close.test.ts b/apps/cli/src/commands/safe-close.test.ts index 65d20f15..c3c02b1e 100644 --- a/apps/cli/src/commands/safe-close.test.ts +++ b/apps/cli/src/commands/safe-close.test.ts @@ -23,6 +23,7 @@ import { loadUserPricingOverlay } from '../engine/pricing-overlay.js'; import { buildChatSession } from '../chat/session-host.js'; import { scriptedResolver, textTurn } from '../chat/test-support.js'; import { EXIT_CODES } from '../process/exit-codes.js'; +import { createCliInvocation } from '../process/invocation.js'; import type { GlobalOptions } from '../process/options.js'; import { captureIo, parseNdjson } from '../test-support.js'; import { chatExportCommand } from './chat-export.js'; @@ -86,6 +87,27 @@ describe('command teardown preserves primary outcomes (#22)', () => { expect(warning).not.toContain('\x1b'); } + async function assertWorkflowCloseUnacknowledged( + command: Promise, + invocation: ReturnType, + ): Promise { + let settled = false; + void command.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + expect(invocation.registry.activeOwners).toBe(1); + expect(invocation.registry.observations.exitCode()).toBe(9); + expect(invocation.registry.budget.deadline()).toBeTypeOf('number'); + } + function seedSession(): void { opened.store.createSession({ id: 'session-safe-close', @@ -228,19 +250,23 @@ describe('command teardown preserves primary outcomes (#22)', () => { assertClosed(err()); }); - it('run retains a real durable successful result after native close throws', async () => { + it('run retains its durable successful result while an opaque native close fault withholds retirement', async () => { const { io, out, err } = captureIo(); - expect( - await runCommand( + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); + await assertWorkflowCloseUnacknowledged( + runCommand( { workflow: 'flow.yaml', input: [], allowMcpStdio: [] }, { io, + invocation, + subscribeSignals: () => () => undefined, global: global(true), openRunStore: historyOpener(), sweepMedia: () => Promise.resolve(undefined), }, ), - ).toBe(EXIT_CODES.success); + invocation, + ); expect(parseNdjson(out())).toEqual( expect.arrayContaining([expect.objectContaining({ type: 'run:completed' })]), ); @@ -249,7 +275,7 @@ describe('command teardown preserves primary outcomes (#22)', () => { assertClosed(err()); }); - it('run preserves an engine-build failure identity even when close and its warning sink fail', async () => { + it('run retains the first infrastructure failure through opaque close and warning-sink faults', async () => { const primary = new Error('engine build failed'); const captured = captureIo(); const io = { @@ -258,34 +284,46 @@ describe('command teardown preserves primary outcomes (#22)', () => { throw new Error('stderr closed'); }, }; - await expect( + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); + await assertWorkflowCloseUnacknowledged( runCommand( { workflow: 'flow.yaml', input: [], allowMcpStdio: [] }, { io, + invocation, + subscribeSignals: () => () => undefined, global: global(true), openRunStore: historyOpener(), buildEngine: () => Promise.reject(primary), }, ), - ).rejects.toBe(primary); + invocation, + ); assertClosed(captured.err()); }); - it('gate preserves a missing-run invocation refusal after native close throws', async () => { + it('gate cannot certify release from a native close throw after missing-run refusal', async () => { const { io, out, err } = captureIo(); - await expect( + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); + await assertWorkflowCloseUnacknowledged( gateCommand( { runId: 'absent', approve: true }, - { io, global: global(true), openDb: () => ({ db: client.db, close }) }, + { + io, + invocation, + subscribeSignals: () => () => undefined, + global: global(true), + openDb: () => ({ db: client.db, close }), + }, ), - ).rejects.toMatchObject({ code: 'invalid_invocation', message: 'no run found with id absent' }); + invocation, + ); expect(out()).toBe(''); expect(err()).toBe('warning: history database teardown failed\n'); assertClosed(err()); }); - it('gate retains idempotent success for a genuinely completed run after native close throws', async () => { + it('gate preserves a genuinely completed run while retaining opaque native close uncertainty', async () => { const first = captureIo(); expect( await runCommand( @@ -306,12 +344,20 @@ describe('command teardown preserves primary outcomes (#22)', () => { ); if (started === undefined) throw new Error('expected durable run identity'); const { io, out, err } = captureIo(); - expect( - await gateCommand( + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); + await assertWorkflowCloseUnacknowledged( + gateCommand( { runId: started.runId, approve: true }, - { io, global: global(true), openDb: () => ({ db: client.db, close }) }, + { + io, + invocation, + subscribeSignals: () => () => undefined, + global: global(true), + openDb: () => ({ db: client.db, close }), + }, ), - ).toBe(EXIT_CODES.success); + invocation, + ); expect(out()).toBe(''); expect(err()).toContain('warning: history database teardown failed\n'); assertClosed(err()); diff --git a/apps/cli/src/commands/workflow-cleanup-native.test.ts b/apps/cli/src/commands/workflow-cleanup-native.test.ts new file mode 100644 index 00000000..f9915f86 --- /dev/null +++ b/apps/cli/src/commands/workflow-cleanup-native.test.ts @@ -0,0 +1,93 @@ +import { spawn } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const register = fileURLToPath( + new URL('./fixtures/workflow-cleanup-register.mjs', import.meta.url), +); +const fixture = fileURLToPath(new URL('./fixtures/workflow-cleanup-child.ts', import.meta.url)); + +describe('actual workflow cleanup failures behind the shared physical signal deadline', () => { + it.each(['run-close-fault', 'gate-close-fault', 'run-build-fault', 'gate-build-fault'] as const)( + '%s retains real SQLite and actual MCP close obligations and exits with the prior infrastructure failure', + async (mode) => { + const root = mkdtempSync(join(tmpdir(), 'relavium-workflow-cleanup-')); + const child = spawn( + process.execPath, + ['--no-warnings', '--experimental-strip-types', '--import', register, fixture, mode, root], + { + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + env: { ...process.env, HOME: root, USERPROFILE: root }, + }, + ); + let stderr = ''; + let stdout = ''; + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString('utf8'); + if (stderr.length > 32768) child.kill('SIGKILL'); + }); + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString('utf8'); + if (stdout.length > 32768) child.kill('SIGKILL'); + }); + const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve) => child.once('close', (code, signal) => resolve({ code, signal })), + ); + const messages: string[] = []; + child.on('message', (message: unknown) => { + const type: unknown = + typeof message === 'object' && message !== null + ? Object.getOwnPropertyDescriptor(message, 'type')?.value + : undefined; + if (typeof type !== 'string') return; + messages.push(type); + if (type === 'ready') child.send({ type: 'go' }); + if (type === 'cleanup-entered') { + // Windows does not deliver POSIX SIGTERM: enter the exact registered signal callback there. + // POSIX legs deliver the real OS signal after observing actual connection-close entry. + if (process.platform === 'win32') child.send({ type: 'terminate' }); + else child.kill('SIGTERM'); + } + }); + let watchdogFired = false; + const watchdog = setTimeout(() => { + watchdogFired = true; + child.kill('SIGKILL'); + }, 9000); + try { + const exit = await closed; + expect(watchdogFired, stderr).toBe(false); + expect(messages.slice(0, 2), stderr).toEqual(['ready', 'cleanup-entered']); + expect(stdout).toBe(''); + expect(stderr).toBe(''); // Runtime/setup/native-child contamination cannot supply the semantic witness. + expect(exit, 'WORKFLOW_NATIVE_CLOSE_ACK').toEqual({ code: 9, signal: null }); + expect(messages, stderr).toEqual(['ready', 'cleanup-entered']); + const witness: unknown = JSON.parse(readFileSync(join(root, 'exit.json'), 'utf8')); + expect(witness).toMatchObject({ + dbOpen: true, + dbCloseCount: 0, + closeCount: 1, + settled: false, + observedExit: 9, + }); + const elapsed: unknown = + typeof witness === 'object' && witness !== null + ? Object.getOwnPropertyDescriptor(witness, 'elapsed')?.value + : undefined; + expect(elapsed).toBeGreaterThanOrEqual(2700); + expect(elapsed).toBeLessThan(4500); + expect(stdout).toBe(''); + expect(stderr).toBe(''); + } finally { + clearTimeout(watchdog); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await closed; + rmSync(root, { recursive: true, force: true }); + } + }, + 12000, + ); +}); diff --git a/apps/cli/src/commands/workflow-driver-ownership.test.ts b/apps/cli/src/commands/workflow-driver-ownership.test.ts new file mode 100644 index 00000000..8dc820e7 --- /dev/null +++ b/apps/cli/src/commands/workflow-driver-ownership.test.ts @@ -0,0 +1,125 @@ +import { WorkflowEngine, createInMemoryHost, type RunHandle } from '@relavium/core'; +import type { RunEvent } from '@relavium/shared'; +import { describe, expect, it, vi } from 'vitest'; +import { captureIo } from '../test-support.js'; +import { createInvocationFiniteExit } from '../process/invocation-finite-exit.js'; +import { InvocationRegistry } from '../process/invocation-registry.js'; +import { WorkflowLifetime } from './workflow-lifetime.js'; +import { driveRun } from './drive.js'; + +function deferred() { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} +const turn = () => new Promise((resolve) => setImmediate(resolve)); + +describe('primary reader and already-available health behind held input', () => { + it.each(['done', 'rejected'] as const)( + '%s starts the one departure join independently of held prompt release', + async (finish) => { + let time = 0; + const timers = new Set<{ at: number; fire: () => void }>(); + const clock = { + now: () => time, + schedule: (ms: number, fire: () => void) => { + const timer = { at: time + ms, fire }; + timers.add(timer); + return () => { + timers.delete(timer); + }; + }, + }; + const registry = new InvocationRegistry(clock); + const physicalExit = vi.fn(); + const finiteExit = createInvocationFiniteExit(registry, clock, { + exit: physicalExit, + setExitCode: vi.fn(), + }); + const invocation = { registry, finiteExit, seedFileCatalogOnce: () => Promise.resolve() }; + const captured = captureIo(); + const lifetime = new WorkflowLifetime(invocation, captured.io, () => () => undefined); + const entered = deferred(); + const decision = deferred(); + const durability = vi.fn(() => 'durable' as const); + const terminalError = vi.fn(() => undefined); + const departed = vi.fn(() => + Promise.resolve({ + kind: 'closed' as const, + moneyDurability: 'uncertain' as const, + effectNeedsAttention: false, + }), + ); + const handle: RunHandle = { + runId: 'reader-boundary', + events: (async function* (): AsyncGenerator { + yield { + type: 'human_gate:paused', + runId: 'reader-boundary', + timestamp: '2026-10-16T00:00:00Z', + sequenceNumber: 1, + nodeId: 'gate', + gateId: 'held-gate', + gateType: 'approval', + message: 'offline', + }; + await entered.promise; + if (finish === 'rejected') throw new Error('PRIVATE_PRIMARY_FAILURE'); + })(), + subscribe: () => () => undefined, + cancel: vi.fn(), + depart: departed, + whenConsumersReady: () => Promise.resolve(), + highWaterMark: 256, + bufferedCount: 0, + durability, + terminalError, + }; + const engine = new WorkflowEngine({ + host: createInMemoryHost(), + executor: { execute: () => Promise.resolve({ kind: 'completed', output: null }) }, + }); + let finished = false; + const pending = driveRun({ + lifetime, + engine, + handle, + io: captured.io, + makeRenderer: () => ({ onEvent: () => undefined }), + gatePrompter: { + prompt: () => { + entered.resolve(); + return decision.promise; + }, + }, + }); + void pending.then( + () => { + finished = true; + }, + () => { + finished = true; + }, + ); + await entered.promise; + await turn(); + expect(departed, 'WORKFLOW_AVAILABLE_HEALTH').toHaveBeenCalledOnce(); + expect(registry.observations.exitCode()).toBe(8); + expect(durability).toHaveBeenCalledTimes(finish === 'done' ? 1 : 0); + expect(terminalError).toHaveBeenCalledTimes(finish === 'done' ? 1 : 0); + expect(finished).toBe(false); + finiteExit.signal(15); + time = 3000; + for (const timer of [...timers]) if (timer.at <= time && timers.delete(timer)) timer.fire(); + expect(await finiteExit.exited).toBe(8); + expect(physicalExit).toHaveBeenCalledExactlyOnceWith(8); + expect(registry.activeOwners).toBeGreaterThan(0); + expect(finished).toBe(false); + decision.resolve(null); // Late input cannot certify the rejected reader or restart terminal work. + await turn(); + expect(departed).toHaveBeenCalledOnce(); + }, + ); +}); diff --git a/apps/cli/src/commands/workflow-lifetime.test.ts b/apps/cli/src/commands/workflow-lifetime.test.ts new file mode 100644 index 00000000..ff10bc2a --- /dev/null +++ b/apps/cli/src/commands/workflow-lifetime.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest'; +import { captureIo } from '../test-support.js'; +import { createCliInvocation } from '../process/invocation.js'; +import { CliError } from '../process/cli-error.js'; +import { WorkflowLifetime } from './workflow-lifetime.js'; + +function fixture() { + const captured = captureIo(); + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); + let signal: (signo: number) => void = () => undefined; + const remove = vi.fn(); + const lifetime = new WorkflowLifetime(invocation, captured.io, (listener) => { + signal = listener; + return remove; + }); + return { ...captured, invocation, lifetime, remove, signal: (signo: number) => signal(signo) }; +} + +describe('workflow command lifetime and bounded shared observations', () => { + it('reserves both owners before the first preparation, and retires them after actual body/terminal cleanup', async () => { + const f = fixture(); + expect(f.invocation.registry.activeOwners).toBe(2); + expect(await f.lifetime.execute(() => Promise.resolve(0))).toBe(0); + expect(f.invocation.registry.activeOwners).toBe(0); + expect(f.remove).toHaveBeenCalledOnce(); + expect(f.out()).toBe(''); + }); + it('a first SIGINT cooperatively stops preparation with an identity-owned marker, without starting the finite exit', async () => { + const f = fixture(); + expect( + await f.lifetime.execute(() => { + f.signal(2); + expect(f.lifetime.signal.aborted).toBe(true); + expect(f.invocation.registry.termination).toBeUndefined(); + f.lifetime.checkpoint(); + return Promise.resolve(0); + }), + ).toBe(1); + expect(f.invocation.registry.activeOwners).toBe(0); + }); + it('an unrelated failure racing cancellation keeps its own fixed infrastructure classification', async () => { + const f = fixture(); + const fault = new Error('PRIVATE_FAILURE_AFTER_ABORT'); + await expect( + f.lifetime.execute(() => { + f.signal(2); + throw fault; + }), + ).rejects.toBe(fault); + expect(f.invocation.registry.observations.exitCode()).toBe(9); + expect(f.invocation.registry.activeOwners).toBe(0); + }); + it.each([8, 7, 5, 6] as const)( + 'actual published health %s outranks a later opaque/falsy throw and survives retirement', + async (safety) => { + const f = fixture(); + expect( + await f.lifetime.execute(() => { + f.lifetime.publish(safety, true); + // Hostile embeddings may throw undefined; presence must not be tested by truthiness. + return new Promise((_resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Adversarial boundary regression: a foreign promise can reject undefined. + reject(undefined); + }); + }), + ).toBe(safety); + expect(f.invocation.registry.activeOwners).toBe(0); + expect(f.invocation.registry.observations.exitCode()).toBe(safety); + }, + ); + it('never downgrades stronger earlier health and preserves safe outcomes from a later success', async () => { + const f = fixture(); + expect( + await f.lifetime.execute(() => { + f.lifetime.publish(7, true); + f.lifetime.publish(5, true); + return Promise.resolve(0); + }), + ).toBe(7); + expect(f.invocation.registry.observations.exitCode()).toBe(7); + }); + it('a competing infrastructure fault outranks an escaping operational refusal without losing its private cause', async () => { + const f = fixture(); + const primary = new CliError('invalid_invocation', 'bounded fixture refusal'); + await expect( + f.lifetime.execute(() => { + f.lifetime.failure(new Error('PRIVATE_SECONDARY_FAULT')); + throw primary; + }), + ).rejects.toMatchObject({ code: 'cli_internal', exitCode: 9, cause: primary }); + expect(f.invocation.registry.observations.exitCode()).toBe(9); + }); + it('a cleanup rejection does not retire a workflow or remove its terminating-signal capability', async () => { + const f = fixture(); + let finished = false; + const work = f.lifetime.execute(() => { + f.lifetime.cleanupFailed(new Error('PRIVATE_CLOSE')); + return Promise.resolve(0); + }); + void work.then(() => { + finished = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(finished).toBe(false); + expect(f.remove).not.toHaveBeenCalled(); + expect(f.invocation.registry.activeOwners).toBe(1); + expect(f.invocation.registry.observations.exitCode()).toBe(9); + }); +}); + +it('publishes a returned infrastructure exit before owner retirement instead of turning it into success', async () => { + const f = fixture(); + expect(await f.lifetime.execute(() => Promise.resolve(9))).toBe(9); + expect(f.invocation.registry.observations.exitCode()).toBe(9); + expect(f.invocation.registry.activeOwners).toBe(0); +}); diff --git a/apps/cli/src/commands/workflow-lifetime.ts b/apps/cli/src/commands/workflow-lifetime.ts new file mode 100644 index 00000000..4050bb9f --- /dev/null +++ b/apps/cli/src/commands/workflow-lifetime.ts @@ -0,0 +1,190 @@ +import { EngineStateError, type RunHandle, type WorkflowEngine } from '@relavium/core'; +import { toUserFacing } from '../process/errors.js'; +import { CliError } from '../process/cli-error.js'; +import type { CliInvocation } from '../process/invocation.js'; +import { createCliInvocation } from '../process/invocation.js'; +import type { WorkflowSafetyExit } from '../process/exit-observations.js'; +import type { OwnerRetirement } from '../process/invocation-registry.js'; +import type { CliIo } from '../process/io.js'; +import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; +import { defaultSubscribeSignals } from '../process/signals.js'; +import { WorkflowTerminal } from './workflow-terminal.js'; + +/** Single command owner: registration/signals precede its first owned asynchronous preparation. */ +export class WorkflowLifetime { + readonly invocation: CliInvocation; + readonly terminal: WorkflowTerminal; + readonly #cancel = new AbortController(); + readonly #stoppedMarker = new Error('Workflow command stopped.'); + readonly #retired: Promise; + #resolveRetired: (value: OwnerRetirement) => void = () => undefined; + readonly #removeSignals: () => void; + #engine: WorkflowEngine | undefined; + #runId: string | undefined; + #handle: RunHandle | undefined; + #cancelledHandle: RunHandle | undefined; + #engineCancelRequested = false; + #stopped = false; + #interrupted = false; + #safety: WorkflowSafetyExit | undefined; + #hostSafe = true; + #cleanupAcknowledged = true; + + constructor( + invocation: CliInvocation | undefined, + io: CliIo, + subscribeSignals = defaultSubscribeSignals, + ) { + Object.freeze(this.#stoppedMarker); + this.invocation = invocation ?? createCliInvocation(); + this.#retired = new Promise((resolve) => { + this.#resolveRetired = resolve; + }); + this.invocation.registry.register({ + kind: 'workflow', + retired: this.#retired, + stop: () => this.stop(), + join: () => this.#retired.then(() => undefined), + readSafetyExit: () => this.#safety, + classifyFailure: () => 'cli_internal', + }); + this.terminal = new WorkflowTerminal( + this.invocation.registry, + io, + () => this.checkpoint(), + (cause) => this.failure(cause), + ); + this.#removeSignals = subscribeSignals((signo) => { + if (signo === 2 && !this.#interrupted && this.invocation.registry.termination === undefined) { + this.#interrupted = true; + this.stop(); + } else { + this.invocation.finiteExit.signal(signo); + } + }); + } + get signal(): AbortSignal { + return this.#cancel.signal; + } + get stopped(): boolean { + return this.#stopped; + } + checkpoint(): void { + if (this.#stopped) throw this.#stoppedMarker; + } + isStoppedFailure(cause: unknown): boolean { + return cause === this.#stoppedMarker; + } + stop(): void { + this.#stopped = true; + this.#cancel.abort(this.#stoppedMarker); + try { + if (this.#handle !== undefined && this.#cancelledHandle !== this.#handle) { + this.#cancelledHandle = this.#handle; + this.#handle.cancel(); + } else if ( + this.#handle === undefined && + this.#engine !== undefined && + this.#runId !== undefined && + !this.#engineCancelRequested + ) { + this.#engineCancelRequested = true; + this.#engine.cancel(this.#runId); + } + } catch (cause) { + if ( + !(cause instanceof EngineStateError) || + (cause.code !== 'unknown_run' && cause.code !== 'run_already_terminal') + ) + this.failure(cause); + } + } + adoptEngine(engine: WorkflowEngine, runId?: string): void { + this.#engine = engine; + this.#runId = runId; + if (this.#stopped) this.stop(); + } + adoptHandle(handle: RunHandle): void { + this.#handle = handle; + this.#hostSafe = false; + if (this.#stopped) this.stop(); + } + publish(safety: WorkflowSafetyExit | undefined, hostSafe: boolean): void { + this.invocation.registry.observations.observeWorkflowSafety(safety); + const rank: Readonly> = { 8: 4, 7: 3, 5: 2, 6: 1 }; + if (safety !== undefined && (this.#safety === undefined || rank[safety] > rank[this.#safety])) + this.#safety = safety; + this.#hostSafe ||= hostSafe; + } + failure(cause: unknown): void { + if (this.isStoppedFailure(cause)) return; + const classified = toUserFacing(cause); + this.invocation.registry.observations.observeFailure(classified.code); + if (classified.code === 'cli_internal' || classified.code === 'output_unavailable') + this.invocation.finiteExit.fail(classified.code); + } + cleanupFailed(cause: unknown): void { + this.#cleanupAcknowledged = false; + this.failure(cause); + } + async execute(body: () => Promise): Promise { + let result: ExitCode = EXIT_CODES.workflowFailed; + let failed: { readonly cause: unknown } | undefined; + try { + result = await body(); + // Publish the authoritative return before either owner can retire and unblock finite exit. + if (result === 8 || result === 7 || result === 5 || result === 6) + this.invocation.registry.observations.observeWorkflowSafety(result); + else if (result === 9) this.invocation.finiteExit.fail('cli_internal'); + else this.invocation.registry.observations.observeOutcome(result); + } catch (cause) { + if (!this.isStoppedFailure(cause)) { + failed = { cause }; + this.failure(cause); + } else this.invocation.registry.observations.observeOutcome(EXIT_CODES.workflowFailed); + } finally { + await this.finish(); + } + const combined = this.invocation.registry.observations.exitCode(); + if ( + failed !== undefined && + (this.#safety === undefined || toUserFacing(failed.cause).exitCode === this.#safety) + ) { + if (combined === 9 && toUserFacing(failed.cause).exitCode !== 9) + throw new CliError( + this.invocation.registry.observations.infrastructureCode() ?? 'cli_internal', + '', + { cause: failed.cause }, + ); + throw failed.cause; + } + // A terminating signal already routes physical exit through the shared finite capability. + return combined >= 0 && combined <= 9 ? combinedExit(combined) : result; + } + /** Called only after the command's actual cleanup tail, never by a deadline or cancellation. */ + async finish(cleanupAcknowledged = true): Promise { + if (!cleanupAcknowledged || !this.#cleanupAcknowledged) this.#hostSafe = false; + if (this.#hostSafe) this.#resolveRetired({ kind: 'settled' }); + await this.terminal.finish(); + if (!this.#hostSafe) await new Promise(() => {}); + this.#removeSignals(); + } +} + +function combinedExit(value: number): ExitCode { + switch (value) { + case 0: + case 1: + case 2: + case 3: + case 4: + case 5: + case 6: + case 7: + case 8: + case 9: + return value; + default: + return EXIT_CODES.infrastructureFailed; + } +} diff --git a/apps/cli/src/commands/workflow-preparation.test.ts b/apps/cli/src/commands/workflow-preparation.test.ts new file mode 100644 index 00000000..c344e8ff --- /dev/null +++ b/apps/cli/src/commands/workflow-preparation.test.ts @@ -0,0 +1,317 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { WorkflowEngine, createInMemoryHost } from '@relavium/core'; +import { McpAbortedError, startMcpClient, type McpConnection } from '@relavium/mcp'; +import { createCliInvocation } from '../process/invocation.js'; +import { toUserFacing } from '../process/errors.js'; +import { captureIo } from '../test-support.js'; +import { runCommand, type RunCommandDeps } from './run.js'; +import type { ResolvedStdioSpawn } from '../engine/mcp-consent.js'; + +function deferred() { + let resolve: (value: T) => void = () => undefined; + let reject: (cause: unknown) => void = () => undefined; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +const turn = () => new Promise((resolve) => setImmediate(resolve)); +function fixture(kind: 'secret' | 'plain' | 'mcp') { + const root = mkdtempSync(join(tmpdir(), 'relavium-owned-preparation-')); + const path = join(root, 'workflow.relavium.yaml'); + const configPath = join(root, 'config.toml'); + writeFileSync(configPath, ''); // Never read the operator global config. + writeFileSync( + path, + JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'owned-preparation', + nodes: [{ id: 'input', type: 'input' }], + edges: [], + ...(kind === 'secret' + ? { inputs: [{ name: 'token', type: 'secret', required: true }] } + : {}), + ...(kind === 'mcp' + ? { + agents: [ + { + id: 'unused', + provider: 'openai', + model: 'offline', + system_prompt: 'offline', + mcp_servers: [ + { + id: 'offline', + transport: 'stdio', + command: process.execPath, + env: { TOKEN: '{{secrets.token}}' }, + }, + ], + }, + ], + } + : {}), + }, + }), + ); + const captured = captureIo(); + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); + const engine = new WorkflowEngine({ + host: createInMemoryHost(), + executor: { + execute: () => Promise.resolve({ kind: 'completed', output: 'offline' }), + }, + }); + const keyFor = vi.fn(() => 'synthetic-unused'); + const resolveSecret = vi.fn(() => 'synthetic-unused'); + const providers = { resolveProvider: () => undefined, keyFor }; + const build = vi.fn(() => Promise.resolve(engine)); + const keys = vi.fn(() => ({ providers, mcpSecretResolver: resolveSecret })); + const deps: RunCommandDeps = { + invocation, + io: captured.io, + global: { json: true, color: false, verbosity: 'normal', cwd: root, configPath }, + providers, + buildEngine: build, + resolveKeys: keys, + }; + const before = process.listeners('SIGINT'); + const interrupt = () => { + const listeners = process.listeners('SIGINT').filter((listener) => !before.includes(listener)); + expect(listeners).toHaveLength(1); + listeners[0]?.('SIGINT'); + }; + return { + root, + path, + invocation, + engine, + deps, + build, + keys, + keyFor, + resolveSecret, + interrupt, + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +} + +describe('early workflow preparation ownership', () => { + it('retains cooperative cancellation for a genuine typed MCP abort without hiding a competing fault', async () => { + const f = fixture('mcp'); + try { + const code = await runCommand( + { workflow: f.path, input: [], allowMcpStdio: [] }, + { + ...f.deps, + consentGate: () => Promise.resolve(new Map()), + startMcpClient: () => { + f.interrupt(); + return Promise.reject(new McpAbortedError('offline', 'connect')); + }, + }, + ); + expect(code).toBe(1); + expect(f.build).not.toHaveBeenCalled(); + expect(f.invocation.registry.activeOwners).toBe(0); + expect(f.invocation.registry.termination).toBeUndefined(); + expect(f.invocation.registry.observations.exitCode()).toBe(1); + } finally { + f.cleanup(); + } + }); + it('keeps synchronous MCP-warning cancellation outside storage error classification', async () => { + const f = fixture('mcp'); + const close = vi.fn(() => Promise.resolve()); + const open = vi.fn(() => { + throw new Error('Storage must not open after cancellation.'); + }); + const connection: McpConnection = { + listTools: () => Promise.resolve([{ name: 'blocked', inputSchema: { type: 'object' } }]), + callTool: () => Promise.resolve({ content: [], isError: false }), + close, + }; + let warnings = 0; + try { + const code = await runCommand( + { workflow: f.path, input: [], allowMcpStdio: [] }, + { + ...f.deps, + openRunStore: open, + io: { + ...f.deps.io, + writeErr: (text) => { + f.deps.io.writeErr(text); + warnings++; + f.interrupt(); + }, + }, + consentGate: () => Promise.resolve(new Map()), + startMcpClient: () => + startMcpClient([ + { id: 'offline', toolsAllowlist: [], open: () => Promise.resolve(connection) }, + ]), + }, + ).catch((cause: unknown) => toUserFacing(cause).exitCode); + expect(warnings).toBe(1); + expect(code, 'WORKFLOW_STOP_ORIGIN').toBe(1); + expect(f.invocation.registry.observations.exitCode()).toBe(1); + expect(open).not.toHaveBeenCalled(); + expect(f.build).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledOnce(); + expect(f.invocation.registry.activeOwners).toBe(0); + } finally { + f.cleanup(); + } + }); + it('holds secret stdin ownership and refuses later services after its real late settlement', async () => { + const f = fixture('secret'); + const read = deferred(); + let settled = false; + const pending = runCommand( + { workflow: f.path, input: [], allowMcpStdio: [], secretInputsStdin: true }, + { ...f.deps, readSecretInput: () => read.promise }, + ); + void pending.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + try { + expect(f.invocation.registry.activeOwners).toBe(2); + f.interrupt(); + await turn(); + expect(settled).toBe(false); + expect(f.keys).not.toHaveBeenCalled(); + expect(f.build).not.toHaveBeenCalled(); + read.resolve('token=synthetic'); + expect(await pending).toBe(1); + expect(f.keys).not.toHaveBeenCalled(); + expect(f.build).not.toHaveBeenCalled(); + expect(f.invocation.registry.activeOwners).toBe(0); + } finally { + read.resolve('token=synthetic'); + await pending.catch(() => undefined); + f.cleanup(); + } + }); + it('preserves an unrelated stdin failure racing a first Ctrl-C as infrastructure', async () => { + const f = fixture('secret'); + const read = deferred(); + const fault = new Error('PRIVATE_STDIN_AFTER_CANCEL'); + const pending = runCommand( + { workflow: f.path, input: [], allowMcpStdio: [], secretInputsStdin: true }, + { ...f.deps, readSecretInput: () => read.promise }, + ); + const refusal = expect(pending).rejects.toBe(fault); + try { + f.interrupt(); + read.reject(fault); + await refusal; + expect(toUserFacing(fault)).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + expect(f.invocation.registry.observations.exitCode()).toBe(9); + expect(f.keys).not.toHaveBeenCalled(); + expect(f.invocation.registry.activeOwners).toBe(0); + } finally { + read.resolve('token=synthetic'); + await pending.catch(() => undefined); + f.cleanup(); + } + }); + it.each(['build', 'outbox'] as const)( + 'does not start a run after cancellation during held %s preparation', + async (stage) => { + const f = fixture('plain'); + const entered = deferred(); + const release = deferred(); + const start = vi.spyOn(f.engine, 'start'); + if (stage === 'outbox') + vi.spyOn(f.engine, 'drainTerminalOutbox').mockImplementation(async () => { + entered.resolve(); + await release.promise; + return []; + }); + const pending = runCommand( + { workflow: f.path, input: [], allowMcpStdio: [] }, + { + ...f.deps, + buildEngine: async () => { + if (stage === 'build') { + entered.resolve(); + await release.promise; + } + return f.engine; + }, + }, + ); + try { + await entered.promise; + f.interrupt(); + await turn(); + expect(start).not.toHaveBeenCalled(); + expect(f.invocation.registry.activeOwners).toBe(2); + release.resolve(); + expect(await pending).toBe(1); + expect(start).not.toHaveBeenCalled(); + expect(f.invocation.registry.activeOwners).toBe(0); + } finally { + release.resolve(); + await pending.catch(() => undefined); + f.cleanup(); + } + }, + ); + it.each([false, true])( + 'a custom consent gate settling after stop cannot enter secrets or MCP (reject=%s)', + async (reject) => { + const f = fixture('mcp'); + const entered = deferred(); + const consent = deferred>(); + const start = vi.fn(() => Promise.reject(new Error('MCP must not be entered.'))); + const fault = new Error('PRIVATE_CONSENT_AFTER_CANCEL'); + const pending = runCommand( + { workflow: f.path, input: [], allowMcpStdio: [] }, + { + ...f.deps, + consentGate: () => { + entered.resolve(); + return consent.promise; + }, + startMcpClient: start, + }, + ); + // Attach rejection observation before releasing the custom factory. + const result = pending.then( + (code) => code, + (cause: unknown) => toUserFacing(cause), + ); + try { + await entered.promise; + f.interrupt(); + await turn(); + expect(start).not.toHaveBeenCalled(); + expect(f.resolveSecret).not.toHaveBeenCalled(); + if (reject) consent.reject(fault); + else consent.resolve(new Map()); + if (reject) expect(await result).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + else expect(await result).toBe(1); + expect(start).not.toHaveBeenCalled(); + expect(f.resolveSecret).not.toHaveBeenCalled(); + expect(f.build).not.toHaveBeenCalled(); + expect(f.invocation.registry.activeOwners).toBe(0); + } finally { + consent.resolve(new Map()); + await result; + f.cleanup(); + } + }, + ); +}); diff --git a/apps/cli/src/commands/workflow-terminal.test.ts b/apps/cli/src/commands/workflow-terminal.test.ts new file mode 100644 index 00000000..7468bcd6 --- /dev/null +++ b/apps/cli/src/commands/workflow-terminal.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it, vi } from 'vitest'; +import { captureIo } from '../test-support.js'; +import { InvocationRegistry } from '../process/invocation-registry.js'; +import { SHOW_CURSOR } from '../render/alt-screen.js'; +import { WorkflowTerminal, type RendererCreationOwner } from './workflow-terminal.js'; + +function deferred() { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} +const turn = () => new Promise((resolve) => setImmediate(resolve)); +function fixture(tty = false) { + const registry = new InvocationRegistry({ now: () => 0, schedule: () => () => undefined }); + const { io, out } = captureIo(); + const fail = vi.fn(); + const terminal = new WorkflowTerminal( + registry, + { ...io, stdoutIsTty: tty }, + () => undefined, + fail, + ); + return { registry, io, out, fail, terminal }; +} + +describe('reserved workflow terminal creation and actual restoration', () => { + it('retires a native-free preparation without writing cursor codes to NDJSON output', async () => { + const f = fixture(); + expect(f.registry.activeOwners).toBe(1); + await f.terminal.finish(); + expect(f.registry.activeOwners).toBe(0); + expect(f.out()).toBe(''); + }); + it('starts all restoration attempts while the prompt and native view are independently held', async () => { + const registry = new InvocationRegistry({ now: () => 0, schedule: () => () => undefined }); + const captured = captureIo(); + const writes: string[] = []; + const acknowledgements: (() => void)[] = []; + const prompt = deferred(); + const input = deferred(); + const release = vi.fn(() => input.promise); + const terminal = new WorkflowTerminal( + registry, + { + ...captured.io, + stdoutIsTty: true, + writeOutAcknowledged: (text) => { + writes.push(text); + const ack = deferred(); + acknowledgements.push(() => ack.resolve()); + return ack.promise; + }, + }, + () => undefined, + vi.fn(), + ); + const prompted = terminal.runPrompt(() => prompt.promise); + const dispose = vi.fn(); + terminal.createJobControl(() => ({ dispose })); + const seal = vi.fn(); + terminal.createRenderer(() => ({ onEvent: () => undefined, seal, releaseInput: release })); + let finished = false; + const done = terminal.finish().then(() => { + finished = true; + }); + await turn(); + expect(seal).toHaveBeenCalledOnce(); + expect(dispose).toHaveBeenCalledOnce(); + expect(release).toHaveBeenCalledOnce(); + expect(writes).toEqual([SHOW_CURSOR]); + expect(finished).toBe(false); + acknowledgements[0]?.(); + prompt.resolve(true); + await prompted; + await turn(); + expect(writes).toHaveLength(1); // The held Ink owner can still hide the cursor. + input.resolve(); + await turn(); + expect(writes).toEqual([SHOW_CURSOR, SHOW_CURSOR]); + expect(finished).toBe(false); + expect(registry.activeOwners).toBe(1); + acknowledgements[1]?.(); + await done; + expect(registry.activeOwners).toBe(0); + }); + it('reserves a prompt before synchronous reentry and never treats its rejection as release', async () => { + const f = fixture(); + const cause = new Error('PRIVATE_PROMPT_CAUSE'); + let finished = false; + const prompted = f.terminal.runPrompt(() => { + void f.terminal.finish().then(() => { + finished = true; + }); + throw cause; + }); + await expect(prompted).rejects.toBe(cause); + await turn(); + expect(finished).toBe(false); + expect(f.registry.activeOwners).toBe(1); + expect(f.fail).toHaveBeenCalledWith(cause); + }); + it('adopts late job-control return after a reentrant seal and disposes it before retirement', async () => { + const f = fixture(); + const dispose = vi.fn(() => f.terminal.seal()); + f.terminal.createJobControl(() => { + f.terminal.seal(); + return { dispose }; + }); + await f.terminal.finish(); + expect(dispose).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + }); + it.each([false, true])( + 'construction throw requires its own native-free declaration (%s)', + async (declared) => { + const f = fixture(); + expect(() => + f.terminal.createRenderer((creation) => { + if (declared) creation.acknowledgeNoAcquisition(); + throw new Error('PRIVATE_CONSTRUCTOR_CAUSE'); + }), + ).toThrow('PRIVATE_CONSTRUCTOR_CAUSE'); + let finished = false; + void f.terminal.finish().then(() => { + finished = true; + }); + await turn(); + expect(finished).toBe(declared); + expect(f.registry.activeOwners).toBe(declared ? 0 : 1); + }, + ); + it('pre-adopts actual constructor rollback and waits for its positive input ACK after throwing', async () => { + const f = fixture(); + const released = deferred(); + const releaseInput = vi.fn(() => released.promise); + const seal = vi.fn(); + expect(() => + f.terminal.createRenderer((creation: RendererCreationOwner) => { + creation.adopt({ seal, releaseInput }); + throw new Error('construction failed after acquisition'); + }), + ).toThrow(); + let finished = false; + const done = f.terminal.finish().then(() => { + finished = true; + }); + await turn(); + expect(seal).toHaveBeenCalledOnce(); + expect(releaseInput).toHaveBeenCalledOnce(); + expect(finished).toBe(false); + released.resolve(); + await done; + expect(f.registry.activeOwners).toBe(0); + }); + it('permanently refuses new terminal entry after seal', async () => { + const f = fixture(); + f.terminal.seal(); + const prompt = vi.fn(() => Promise.resolve(true)); + const renderer = vi.fn(() => ({ onEvent: () => undefined })); + const job = vi.fn(() => ({ dispose: () => undefined })); + expect(() => f.terminal.runPrompt(prompt)).toThrow(); + expect(() => f.terminal.createRenderer(renderer)).toThrow(); + expect(() => f.terminal.createJobControl(job)).toThrow(); + expect(prompt).not.toHaveBeenCalled(); + expect(renderer).not.toHaveBeenCalled(); + expect(job).not.toHaveBeenCalled(); + await f.terminal.finish(); + }); + + it('keeps custody of a view with native-free construction and a later input acquisition', async () => { + const f = fixture(true); + const input = deferred(); + const releaseInput = vi.fn(() => input.promise); + const resume = vi.fn(() => undefined); + const renderer = f.terminal.createRenderer((creation) => { + creation.acknowledgeNoAcquisition(); + return { onEvent: () => undefined, releaseInput, resume }; + }); + await f.terminal.runViewOperation(() => renderer.resume?.()); + let finished = false; + const done = f.terminal.finish().then(() => { + finished = true; + }); + await turn(); + expect(resume).toHaveBeenCalledOnce(); + expect(releaseInput).toHaveBeenCalledOnce(); + expect(finished).toBe(false); + expect(f.registry.activeOwners).toBe(1); + input.resolve(); + await done; + expect(finished).toBe(true); + expect(f.registry.activeOwners).toBe(0); + }); + + it.each(['deferred-entry', 'late-remount'] as const)( + 'does not start restoration helpers after expiry at %s', + async (boundary) => { + let now = 0; + const registry = new InvocationRegistry({ now: () => now, schedule: () => () => undefined }); + const io = captureIo(); + const cursor = vi.fn(() => Promise.resolve()); + const release = vi.fn(() => Promise.resolve()); + const remount = deferred(); + const terminal = new WorkflowTerminal( + registry, + { ...io.io, stdoutIsTty: true, writeOutAcknowledged: cursor }, + () => undefined, + vi.fn(), + ); + terminal.createRenderer(() => ({ onEvent: () => undefined, releaseInput: release })); + const entered = + boundary === 'late-remount' ? terminal.runViewOperation(() => remount.promise) : undefined; + void registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 100 }); + if (boundary === 'late-remount') { + await turn(); + expect(release).toHaveBeenCalledOnce(); + expect(cursor).toHaveBeenCalledOnce(); + } + now = 100; + let finished = false; + void terminal.finish().then(() => { + finished = true; + }); + remount.resolve(); + await entered; + await turn(); + expect(release, 'WORKFLOW_RESTORE_EXPIRY').toHaveBeenCalledTimes( + boundary === 'late-remount' ? 1 : 0, + ); + expect(cursor).toHaveBeenCalledTimes(boundary === 'late-remount' ? 1 : 0); + expect(finished).toBe(false); + expect(registry.activeOwners).toBe(1); + }, + ); +}); + +it('joins an already-entered asynchronous remount and re-releases its final input before retirement', async () => { + const f = fixture(); + const remount = deferred(); + const input = deferred(); + let mounted = false; + const release = vi.fn(() => (mounted ? input.promise : Promise.resolve())); + f.terminal.createRenderer(() => ({ onEvent: () => undefined, releaseInput: release })); + const entered = f.terminal.runViewOperation(() => + remount.promise.then(() => { + mounted = true; + }), + ); + let finished = false; + const done = f.terminal.finish().then(() => { + finished = true; + }); + await turn(); + expect(release).toHaveBeenCalledOnce(); + expect(finished).toBe(false); + remount.resolve(); + await entered; + await turn(); + expect(release).toHaveBeenCalledTimes(2); + expect(finished).toBe(false); + expect(f.registry.activeOwners).toBe(1); + input.resolve(); + await done; + expect(f.registry.activeOwners).toBe(0); +}); diff --git a/apps/cli/src/commands/workflow-terminal.ts b/apps/cli/src/commands/workflow-terminal.ts new file mode 100644 index 00000000..97c93d52 --- /dev/null +++ b/apps/cli/src/commands/workflow-terminal.ts @@ -0,0 +1,293 @@ +import { CliError } from '../process/cli-error.js'; +import type { InvocationRegistry, OwnerRetirement } from '../process/invocation-registry.js'; +import type { CliIo } from '../process/io.js'; +import { SHOW_CURSOR } from '../render/alt-screen.js'; +import type { RunJobControl } from '../render/run-job-control.js'; +import type { RunRenderer } from '../render/renderer.js'; + +/** An acquisition declaration belongs to the trusted constructor, never to a thrown error. */ +export interface RendererCreationOwner { + readonly acknowledgeNoAcquisition: () => void; + readonly adopt: (owner: Pick) => void; +} + +/** Reserved before preparation: a late handle never needs new authority after invocation stop. */ +export class WorkflowTerminal { + readonly #registry: InvocationRegistry; + readonly #io: CliIo; + readonly #fail: (cause: unknown) => void; + readonly #assertEntering: () => void; + readonly #prompts = new Set>(); + readonly #viewOperations = new Set>(); + #inputWork: Promise | undefined; + readonly #retired: Promise; + #resolveRetired: (value: OwnerRetirement) => void = () => undefined; + #sealed = false; + #promptUnacknowledged = false; + #viewUnacknowledged = false; + #jobUnacknowledged = false; + #view: Pick | undefined; + #disposeJobControl: (() => void) | undefined; + #jobReleased: Promise | undefined; + #restoring: Promise | undefined; + #cursorOwned = false; + + constructor( + registry: InvocationRegistry, + io: CliIo, + assertEntering: () => void, + fail: (cause: unknown) => void, + ) { + this.#registry = registry; + this.#io = io; + this.#fail = fail; + this.#assertEntering = assertEntering; + this.#retired = new Promise((resolve) => { + this.#resolveRetired = resolve; + }); + registry.register({ + kind: 'terminal', + retired: this.#retired, + stop: () => this.seal(), + join: () => this.#retired.then(() => undefined), + restore: () => this.#restore(), + classifyFailure: () => 'cli_internal', + }); + } + get sealed(): boolean { + return this.#sealed; + } + #enter(): void { + this.#assertEntering(); + if (this.#sealed) throw new CliError('cli_internal', ''); + } + + /** Fulfillment is the prompt's release contract. Rejection/abort alone never supplies that ACK. */ + runPrompt(factory: () => Promise): Promise { + this.#enter(); + this.#cursorOwned ||= this.#io.stdoutIsTty; + let settle: () => void = () => undefined; + const settled = new Promise((resolve) => { + settle = resolve; + }); + this.#prompts.add(settled); // Reservation precedes factory entry, including synchronous reentry. + const failed = (cause: unknown): never => { + this.#promptUnacknowledged = true; + this.#prompts.delete(settled); + settle(); + this.#fail(cause); + throw cause; + }; + let raw: Promise; + try { + raw = factory(); + } catch (cause) { + this.#promptUnacknowledged = true; + this.#prompts.delete(settled); + settle(); + this.#fail(cause); + return new Promise((_resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Preserve the opaque constructor failure without forging terminal release. + reject(cause); + }); + } + return raw.then((value) => { + this.#prompts.delete(settled); + settle(); + return value; + }, failed); + } + + /** Track suspend/remount/finalize entry separately from an early input-release attempt. */ + runViewOperation(factory: () => T | Promise, mayAcquire = true): Promise { + if (mayAcquire) this.#enter(); + else if (this.#sealed) throw new CliError('cli_internal', ''); + let settle: () => void = () => undefined; + const settled = new Promise((resolve) => { + settle = resolve; + }); + this.#viewOperations.add(settled); + const finish = (): void => { + if (mayAcquire) this.#inputWork = undefined; + this.#viewOperations.delete(settled); + settle(); + }; + let raw: T | Promise; + try { + raw = factory(); + } catch (cause) { + finish(); + return Promise.resolve().then(() => { + throw cause; + }); + } + return Promise.resolve(raw).then( + (value) => { + finish(); + return value; + }, + (cause: unknown) => { + finish(); + throw cause; + }, + ); + } + + /** Reuse the same already-entered release; only a later entered view operation invalidates its ACK. */ + releaseInput(): Promise { + if (this.#inputWork !== undefined) return this.#inputWork; + const pending = Promise.resolve().then(() => { + if (!this.#canRestore()) return this.#retired.then(() => undefined); + return this.#view?.releaseInput?.(); + }); + this.#inputWork = pending; + void pending.catch(() => { + if (this.#inputWork === pending) this.#inputWork = undefined; + }); + return pending; + } + + createRenderer(factory: (creation: RendererCreationOwner) => RunRenderer): RunRenderer { + this.#enter(); + this.#viewUnacknowledged = true; + let declared = false; + const creation: RendererCreationOwner = Object.freeze({ + acknowledgeNoAcquisition: () => { + if (declared) throw new CliError('cli_internal', ''); + declared = true; + this.#viewUnacknowledged = false; + }, + adopt: (owner: Pick) => { + if (declared) throw new CliError('cli_internal', ''); + declared = true; + this.#view = owner; + this.#viewUnacknowledged = false; + if (owner.releaseInput !== undefined) this.#cursorOwned ||= this.#io.stdoutIsTty; + if (this.#sealed) owner.seal?.(); + }, + }); + try { + const renderer = factory(creation); + if (!declared) creation.adopt(renderer); + // A native-free constructor can still return a view that acquires input on a later resume. + if (this.#view === undefined) this.#view = renderer; + if (renderer.releaseInput !== undefined) this.#cursorOwned ||= this.#io.stdoutIsTty; + if (this.#sealed) renderer.seal?.(); + return renderer; + } catch (cause) { + this.#fail(cause); + throw cause; + } + } + + createJobControl( + factory: (adopt: (control: RunJobControl) => void) => RunJobControl, + ): RunJobControl { + this.#enter(); + this.#jobUnacknowledged = true; + let adopted: RunJobControl | undefined; + const adopt = (control: RunJobControl): void => { + if (adopted !== undefined && adopted !== control) throw new CliError('cli_internal', ''); + if (adopted === control) return; + adopted = control; + this.#cursorOwned ||= this.#io.stdoutIsTty; + this.#disposeJobControl = control.dispose; + this.#jobReleased = control.released; + this.#jobUnacknowledged = false; + if (control.released !== undefined) + void control.released.then(() => { + if (this.#disposeJobControl === control.dispose) this.#disposeJobControl = undefined; + }, this.#fail); + if (this.#sealed) this.#releaseJobControl(); + }; + try { + const control = factory(adopt); + adopt(control); + return control; + } catch (cause) { + this.#fail(cause); + throw cause; + } + } + #releaseJobControl(): void { + const dispose = this.#disposeJobControl; + if (dispose === undefined) return; + dispose(); + if (this.#jobReleased === undefined && this.#disposeJobControl === dispose) + this.#disposeJobControl = undefined; + } + + /** Finite stop only; asynchronous restoration is separately deadline-gated by the registry. */ + seal(): void { + if (this.#sealed) return; + this.#sealed = true; // Publish before entering the native renderer callback. + try { + this.#view?.seal?.(); + } catch (cause) { + this.#fail(cause); + } + try { + this.#releaseJobControl(); + } catch (cause) { + this.#fail(cause); + } + } + + #canRestore(): boolean { + return this.#registry.budget.remainingMs() !== 0; + } + + #restore(): Promise { + if (this.#restoring !== undefined) return this.#restoring; + if (!this.#canRestore()) return this.#retired.then(() => undefined); + // Publish before invoking any native callback. Reentrant seal/finish cannot start another restoration. + const pending = Promise.resolve().then(async () => { + if (!this.#canRestore()) return await this.#retired.then(() => undefined); + const job = Promise.resolve().then(() => { + this.#releaseJobControl(); + return this.#jobReleased; + }); + const earlyCursor = Promise.resolve().then(() => { + if (!this.#cursorOwned) return; + if (!this.#canRestore()) return this.#retired.then(() => undefined); + return this.#io.writeOutAcknowledged(SHOW_CURSOR); + }); + const enteredViewOperations = [...this.#viewOperations]; + const view = this.releaseInput(); + // Attempt and observe independent operations before awaiting a held prompt/view/stdout callback. + const guarded = [earlyCursor, view, job].map((work) => + work.catch((cause: unknown) => { + this.#fail(cause); + throw cause; + }), + ); + for (const work of guarded) void work.catch(() => undefined); + await Promise.all([...this.#prompts, ...enteredViewOperations, ...guarded]); + if (enteredViewOperations.length > 0) { + // An entered async remount can finish after the early release; acknowledge its final native state. + if (!this.#canRestore()) return await this.#retired.then(() => undefined); + this.#inputWork = undefined; + await this.releaseInput(); + } + if (this.#promptUnacknowledged || this.#viewUnacknowledged || this.#jobUnacknowledged) + return await new Promise(() => {}); + // A late prompt/Ink write can hide the cursor after the early attempt. + if (this.#cursorOwned) { + if (!this.#canRestore()) return await this.#retired.then(() => undefined); + await this.#io.writeOutAcknowledged(SHOW_CURSOR); + } + this.#resolveRetired({ kind: 'settled' }); + }); + this.#restoring = pending; + void pending.catch(() => { + if (this.#restoring === pending) this.#restoring = undefined; + }); + return pending; + } + /** Seal only at the last preparation/driver boundary, then join actual restoration. */ + async finish(): Promise { + this.seal(); + void this.#restore().catch(this.#fail); + await this.#retired; + } +} diff --git a/apps/cli/src/engine/mcp-consent-cancellation.test.ts b/apps/cli/src/engine/mcp-consent-cancellation.test.ts new file mode 100644 index 00000000..82a30191 --- /dev/null +++ b/apps/cli/src/engine/mcp-consent-cancellation.test.ts @@ -0,0 +1,124 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { captureIo } from '../test-support.js'; +import { createConsentGate, mcpConsentPath, type ConsentSubject } from './mcp-consent-gate.js'; +import { createConsentPrompter } from '../mcp/consent-prompt.js'; + +function deferred() { + let resolve: (value: T) => void = () => undefined; + let reject: (cause: unknown) => void = () => undefined; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +const subject: ConsentSubject = { + serverId: 'offline', + provenance: 'inline', + artifact: undefined, + resolvedCommand: process.execPath, + authoredCommand: undefined, + args: [], + env: [], + cwd: process.cwd(), + digest: 'v1:synthetic', + total: 1, + index: 1, + previouslyApprovedIn: undefined, +}; + +describe('workflow-native consent cancellation and late decisions', () => { + it.each([false, true])('a late %s after cancellation never appends a grant', async (approved) => { + const home = mkdtempSync(join(tmpdir(), 'relavium-consent-cancel-')); + const controller = new AbortController(); + const stopped = new Error('Owned command stopped.'); + const entered = deferred(); + const answer = deferred(); + const captured = captureIo(); + const gate = createConsentGate({ + io: { ...captured.io, stdoutIsTty: true, stdinIsTty: true }, + global: { json: false, color: false, cwd: home, configPath: undefined, verbosity: 'normal' }, + homeDir: home, + signal: controller.signal, + prompt: (_subject, options) => { + expect(options?.signal).toBe(controller.signal); + entered.resolve(); + return answer.promise; + }, + }); + try { + const pending = gate( + [{ id: 'native', transport: 'stdio', command: process.execPath }], + home, + undefined, + ); + const refusal = expect(pending).rejects.toBe(stopped); + await entered.promise; + controller.abort(stopped); + answer.resolve(approved); + await refusal; + expect(existsSync(mcpConsentPath(home))).toBe(false); + } finally { + answer.resolve(false); + rmSync(home, { recursive: true, force: true }); + } + }); + it('a genuine prompt error after abort is preserved rather than relabelled as cooperative cancellation', async () => { + const home = mkdtempSync(join(tmpdir(), 'relavium-consent-fault-')); + const controller = new AbortController(); + const answer = deferred(); + const entered = deferred(); + const cause = new Error('PRIVATE_PROMPT_FAILURE'); + const captured = captureIo(); + const gate = createConsentGate({ + io: { ...captured.io, stdoutIsTty: true, stdinIsTty: true }, + global: { json: false, color: false, cwd: home, configPath: undefined, verbosity: 'normal' }, + homeDir: home, + signal: controller.signal, + prompt: () => { + entered.resolve(); + return answer.promise; + }, + }); + try { + const pending = gate( + [{ id: 'native', transport: 'stdio', command: process.execPath }], + home, + undefined, + ); + const refusal = expect(pending).rejects.toBe(cause); + await entered.promise; + controller.abort(); + answer.reject(cause); + await refusal; + expect(existsSync(mcpConsentPath(home))).toBe(false); + } finally { + answer.resolve(false); + rmSync(home, { recursive: true, force: true }); + } + }); + it('passes the exact native signal to Clack and refuses pre-aborted entry without acquiring a prompt', async () => { + const controller = new AbortController(); + const answer = deferred(); + const confirm = vi.fn(() => answer.promise); + const message = vi.fn(); + const prompter = createConsentPrompter({ + confirm, + log: { message }, + isCancel: (value): value is symbol => typeof value === 'symbol', + }); + const pending = prompter(subject, { signal: controller.signal }); + expect(confirm).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ signal: controller.signal, initialValue: false }), + ); + controller.abort(); + answer.resolve(Symbol('cancelled')); + expect(await pending).toBe(false); + expect(await prompter(subject, { signal: controller.signal })).toBe(false); + expect(confirm).toHaveBeenCalledOnce(); + expect(message).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/cli/src/engine/mcp-consent-gate.ts b/apps/cli/src/engine/mcp-consent-gate.ts index 28fab0aa..cb5b7165 100644 --- a/apps/cli/src/engine/mcp-consent-gate.ts +++ b/apps/cli/src/engine/mcp-consent-gate.ts @@ -58,7 +58,10 @@ export function mcpConsentPath(homeDir: string): string { } /** Ask the user about one server. `true` approves; anything else refuses. Injected so tests need no TTY. */ -export type ConsentPrompter = (subject: ConsentSubject) => Promise; +export type ConsentPrompter = ( + subject: ConsentSubject, + options?: { readonly signal?: AbortSignal }, +) => Promise; /** * What the prompt shows — the WHOLE decision, as separate fields (§7). @@ -94,6 +97,7 @@ export interface ConsentSubject { } export interface ConsentGateDeps { + readonly signal?: AbortSignal; readonly io: CliIo; readonly global: GlobalOptions; /** `~` — the grant log's home. */ @@ -126,17 +130,21 @@ export async function assertStdioConsent( cwd: string, deps: ConsentGateDeps, ): Promise> { + deps.signal?.throwIfAborted(); const stdio = refs.filter((ref) => ref.transport === 'stdio'); const resolved = new Map(); if (stdio.length === 0) return resolved; const subjects: Subject[] = []; for (const ref of stdio) { + deps.signal?.throwIfAborted(); const spawn = await resolveOrRefuse(ref, cwd, deps.artifact); + deps.signal?.throwIfAborted(); resolved.set(spawn.serverId, spawn); subjects.push({ spawn, digest: digestOrRefuse(spawn) }); } + deps.signal?.throwIfAborted(); const path = mcpConsentPath(deps.homeDir); const grants = readGrants(path); if (grants === undefined) { @@ -221,17 +229,24 @@ async function askAndRecord( const prompt = deps.prompt; const now = deps.now ?? ((): string => new Date().toISOString()); for (const [index, { spawn, digest }] of pending.entries()) { + deps.signal?.throwIfAborted(); const approved = prompt === undefined ? false - : await prompt(subjectOf(spawn, digest, index + 1, pending.length, grants)); + : await prompt( + subjectOf(spawn, digest, index + 1, pending.length, grants), + deps.signal === undefined ? undefined : { signal: deps.signal }, + ); + deps.signal?.throwIfAborted(); if (!approved) { throw new CliError( 'invalid_invocation', `MCP server '${safe(spawn.serverId)}' was not approved, so the run did not start.`, ); } - recordGrant(path, spawn, digest, now()); + const grantedAt = now(); + deps.signal?.throwIfAborted(); + recordGrant(path, spawn, digest, grantedAt); } } diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index e45850a1..68b74946 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -733,6 +733,7 @@ async function startMcpClientFailLoud( custom: ConnectAgentMcpOptions['startMcpClient'], connectSignal?: AbortSignalLike, work?: ToolHostCallOptions, + onOwnedCancellation?: () => void, ): Promise { // The shared build token is installed only at the actual MCP construction choke. setMcpClientVersion(CLI_VERSION); @@ -744,6 +745,9 @@ async function startMcpClientFailLoud( // A session builder without an external pre-handle owner must self-clean before propagating. owned?.seal(); await owned?.done; + // Only the MCP boundary's typed cancellation can recover the command's owned stop identity. + // A generic failure racing an aborted signal retains its independent classification. + if (err instanceof McpAbortedError && connectSignal?.aborted) onOwnedCancellation?.(); if (windowsTeardownRefusal(err)) { throw new CliError('internal', WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE); } @@ -845,6 +849,12 @@ export interface ConnectWorkflowMcpOptions { /** Resume compares the current discovered set with the frozen grant, without widening or reordering it. */ readonly preserveFrozenGrants?: boolean; readonly cwd: string; + /** Same invocation owner, checked again after consent and before resolving secrets/starting clients. */ + readonly assertAdmitting?: () => void; + /** Observe an assembly failure before joining its cleanup, without replacing its original cause. */ + readonly onFailure?: (cause: unknown) => void; + /** Actual manager close failures use this callback even when close() itself fulfills. */ + readonly onCloseError?: (serverId: string, cause: unknown) => void; readonly startMcpClient?: ( servers: readonly McpServerConfig[], signal?: AbortSignalLike, @@ -928,8 +938,11 @@ export async function connectWorkflowMcp( } if (byId.size === 0) return undefined; + opts.assertAdmitting?.(); requireGateForStdio([...byId.values()], opts.consentGate); const consented = await opts.consentGate?.([...byId.values()], opts.cwd, opts.artifact); + opts.assertAdmitting?.(); + if (opts.connectSignal?.aborted) throw new CliError('internal', 'MCP connection was cancelled.'); const configs = resolveServerConfigs( [...byId.values()], opts.cwd, @@ -938,14 +951,17 @@ export async function connectWorkflowMcp( consented, opts.nativeProcesses, ); + opts.assertAdmitting?.(); const client = await startMcpClientFailLoud( configs, opts.startMcpClient, opts.connectSignal, opts.work, + opts.assertAdmitting, ); try { + opts.assertAdmitting?.(); // Augment each inline agent's grant with ONLY its own servers' discovered ids (a `$ref` entry passes through). const agents = (def.workflow.agents ?? []).map((entry) => isInlineAgent(entry) @@ -961,7 +977,13 @@ export async function connectWorkflowMcp( // A resumed workflow's frozen-grant check can refuse after discovery. Tear down the live // connection before forwarding that refusal, just as the chat builders do on assembly failure. // Best-effort: a teardown rejection must NOT replace the original augmentation error (preserve the primary). - await client.close().catch(() => undefined); + opts.onFailure?.(err); + try { + await client.close(opts.onCloseError); + } catch (cause) { + if (opts.onCloseError !== undefined) opts.onCloseError('', cause); + else opts.onFailure?.(cause); + } throw err; } } diff --git a/apps/cli/src/gate/clack-prompter.ts b/apps/cli/src/gate/clack-prompter.ts index 89131306..72f37e82 100644 --- a/apps/cli/src/gate/clack-prompter.ts +++ b/apps/cli/src/gate/clack-prompter.ts @@ -1,3 +1,5 @@ +import type { CliIo } from '../process/io.js'; +import { nativePromptIo } from '../process/native-prompt-io.js'; import { confirm, isCancel, note, text } from '@clack/prompts'; import type { HumanGatePausedEvent } from '@relavium/shared'; @@ -41,6 +43,17 @@ const defaultDeps: ClackPromptDeps = { isCancel, }; +/** Workflow defaults retain the original invocation's terminal, including cards and prompt input. */ +export function createBoundClackGatePrompter(io: CliIo): GatePrompter { + const streams = nativePromptIo(io); + return createClackGatePrompter({ + note: (message, title) => note(message, title, { output: streams.output }), + confirm: (options) => confirm({ ...options, ...streams }), + text: (options) => text({ ...options, ...streams }), + isCancel, + }); +} + const GATE_TITLE: Record = { approval: 'Approval gate', review: 'Review gate', diff --git a/apps/cli/src/gate/select-prompter.test.ts b/apps/cli/src/gate/select-prompter.test.ts index ed3622aa..98fa2533 100644 --- a/apps/cli/src/gate/select-prompter.test.ts +++ b/apps/cli/src/gate/select-prompter.test.ts @@ -12,7 +12,7 @@ import { describe, expect, it } from 'vitest'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; -import { captureIo } from '../test-support.js'; +import { captureIo, OwnedTtyOutput } from '../test-support.js'; import { selectGatePrompter } from './select-prompter.js'; const global = (json = false): GlobalOptions => ({ @@ -27,6 +27,7 @@ const io = ( over: { stdoutIsTty?: boolean; stdinIsTty?: boolean; env?: Record } = {}, ): CliIo => ({ ...captureIo(over.env ?? {}).io, + nativeStdout: new OwnedTtyOutput(), stdoutIsTty: over.stdoutIsTty ?? true, stdinIsTty: over.stdinIsTty ?? true, }); diff --git a/apps/cli/src/gate/select-prompter.ts b/apps/cli/src/gate/select-prompter.ts index 0e000781..98734d01 100644 --- a/apps/cli/src/gate/select-prompter.ts +++ b/apps/cli/src/gate/select-prompter.ts @@ -1,7 +1,7 @@ import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; import { isInteractiveTerminal } from '../process/output-mode.js'; -import { createClackGatePrompter } from './clack-prompter.js'; +import { createBoundClackGatePrompter } from './clack-prompter.js'; import type { GatePrompter } from './prompter.js'; /** @@ -23,6 +23,6 @@ export function selectGatePrompter(io: CliIo, global: GlobalOptions): GatePrompt json: global.json, env: io.env, }) - ? createClackGatePrompter() + ? createBoundClackGatePrompter(io) : undefined; } diff --git a/apps/cli/src/home/drive-home.test.ts b/apps/cli/src/home/drive-home.test.ts index 7b960f6b..66e65cac 100644 --- a/apps/cli/src/home/drive-home.test.ts +++ b/apps/cli/src/home/drive-home.test.ts @@ -120,6 +120,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { const io: CliIo = { writeOut: () => undefined, writeErr: () => undefined, + writeOutAcknowledged: () => Promise.resolve(), writeErrAcknowledged: () => Promise.resolve(), env: {}, stdoutIsTty: true, diff --git a/apps/cli/src/mcp/consent-prompt.ts b/apps/cli/src/mcp/consent-prompt.ts index ac83405c..229f556f 100644 --- a/apps/cli/src/mcp/consent-prompt.ts +++ b/apps/cli/src/mcp/consent-prompt.ts @@ -1,3 +1,5 @@ +import type { CliIo } from '../process/io.js'; +import { nativePromptIo } from '../process/native-prompt-io.js'; import { confirm, isCancel, log } from '@clack/prompts'; import type { ConsentPrompter, ConsentSubject } from '../engine/mcp-consent-gate.js'; @@ -20,6 +22,7 @@ export interface ClackConsentDeps { readonly confirm: (opts: { message: string; initialValue?: boolean; + signal?: AbortSignal; }) => Promise; readonly log: { readonly message: (text: string) => void }; readonly isCancel: (value: unknown) => value is symbol; @@ -27,6 +30,16 @@ export interface ClackConsentDeps { const DEFAULT_DEPS: ClackConsentDeps = { confirm, log, isCancel }; +/** Workflow consent uses the same captured stdout as cards, Ink and acknowledged restoration. */ +export function createBoundConsentPrompter(io: CliIo): ConsentPrompter { + const streams = nativePromptIo(io); + return createConsentPrompter({ + confirm: (options) => confirm({ ...options, ...streams }), + log: { message: (message) => log.message(message, { output: streams.output }) }, + isCancel, + }); +} + /** * Render one decision and answer it. * @@ -37,11 +50,13 @@ const DEFAULT_DEPS: ClackConsentDeps = { confirm, log, isCancel }; * A cancel (Ctrl-C / ESC) is a refusal, not an absence: the gate treats anything but `true` as "no". */ export function createConsentPrompter(deps: ClackConsentDeps = DEFAULT_DEPS): ConsentPrompter { - return async (subject: ConsentSubject): Promise => { + return async (subject: ConsentSubject, options): Promise => { + if (options?.signal?.aborted) return false; deps.log.message(render(subject)); const answer = await deps.confirm({ message: `Allow MCP server '${subject.serverId}' to run this program?`, initialValue: false, + ...(options?.signal === undefined ? {} : { signal: options.signal }), }); return !deps.isCancel(answer) && answer === true; }; diff --git a/apps/cli/src/onboarding/wizard.test.ts b/apps/cli/src/onboarding/wizard.test.ts index 437a7935..7449dae8 100644 --- a/apps/cli/src/onboarding/wizard.test.ts +++ b/apps/cli/src/onboarding/wizard.test.ts @@ -113,6 +113,7 @@ const stubResolver: ProviderResolver = { const io: CliIo = { writeOut: () => undefined, writeErr: () => undefined, + writeOutAcknowledged: () => Promise.resolve(), writeErrAcknowledged: () => Promise.resolve(), env: {}, stdoutIsTty: true, diff --git a/apps/cli/src/onboarding/wizard.ts b/apps/cli/src/onboarding/wizard.ts index 878ff13d..6bbcd97d 100644 --- a/apps/cli/src/onboarding/wizard.ts +++ b/apps/cli/src/onboarding/wizard.ts @@ -175,7 +175,16 @@ export async function runOnboardingWizard(deps: OnboardingDeps): Promise { // Store via the TESTED providerSetKey path (keychain.set + the provider row + the keychain-ref, secret-free). Its // one stdout line is suppressed (a silent io) so the wizard's output stays uniformly clack-styled — the wizard // surfaces the outcome through a clack note/outro instead. - const silentIo: CliIo = { ...deps.io, writeOut: () => undefined, writeErr: () => undefined }; + const silentIo: CliIo = { + env: deps.io.env, + stdoutIsTty: false, + stdinIsTty: deps.io.stdinIsTty, + stdin: deps.io.stdin, + writeOut: () => undefined, + writeErr: () => undefined, + writeOutAcknowledged: () => Promise.resolve(), + writeErrAcknowledged: () => Promise.resolve(), + }; try { await runProviderCommand( { action: 'set-key', name: provider }, diff --git a/apps/cli/src/process/invocation-finite-exit.test.ts b/apps/cli/src/process/invocation-finite-exit.test.ts new file mode 100644 index 00000000..798935a6 --- /dev/null +++ b/apps/cli/src/process/invocation-finite-exit.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from 'vitest'; +import { OwnedTtyOutput } from '../test-support.js'; +import { createInvocationFiniteExit } from './invocation-finite-exit.js'; +import { + InvocationRegistry, + type InvocationClock, + type OwnerRetirement, +} from './invocation-registry.js'; +import { installNativeInvocation } from './native-invocation.js'; + +function deferred() { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} +class Clock implements InvocationClock { + time = 0; + readonly timers = new Set<{ at: number; fire: () => void }>(); + readonly now = () => this.time; + readonly schedule = (ms: number, fire: () => void) => { + const timer = { at: this.time + ms, fire }; + this.timers.add(timer); + return () => { + this.timers.delete(timer); + }; + }; + advance(time: number) { + this.time = time; + for (const timer of [...this.timers]) + if (timer.at <= time && this.timers.delete(timer)) timer.fire(); + } +} +function fixture() { + const clock = new Clock(); + const registry = new InvocationRegistry(clock); + const exit = vi.fn(); + const finite = createInvocationFiniteExit(registry, clock, { setExitCode: vi.fn(), exit }); + return { clock, registry, exit, finite }; +} +function heldOwner(registry: InvocationRegistry, stop = vi.fn()) { + const life = deferred(); + registry.register({ + kind: 'session', + surface: 'chat', + retired: life.promise, + stop, + join: () => undefined, + classifyFailure: () => 'cli_internal', + }); + return life; +} +const turn = () => new Promise((resolve) => setImmediate(resolve)); + +describe('invocation finite exit and explicit native handoff', () => { + it('starts the shared clock at the first owned fault and never exits before cleanup diagnostics can run', async () => { + const f = fixture(); + const life = heldOwner(f.registry); + f.finite.fail('cli_internal'); + expect(f.registry.budget.deadline(), 'WORKFLOW_FIRST_FAULT_BUDGET').toBe(3000); + f.clock.advance(1200); + f.finite.signal(15); + life.resolve({ kind: 'settled' }); + await turn(); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.budget.deadline()).toBe(3000); + expect(f.exit, 'WORKFLOW_FATAL_DIAGNOSTIC_ORDER').not.toHaveBeenCalled(); + f.clock.advance(2999); + await turn(); + expect(f.exit).not.toHaveBeenCalled(); + f.clock.advance(3000); + expect(await f.finite.exited).toBe(9); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + }); + + it('hands an owned fault to native ACK finalization without renewing or double-exiting', async () => { + const f = fixture(); + const life = heldOwner(f.registry); + f.finite.fail('output_unavailable'); + f.clock.advance(700); + const changed = vi.fn(); + const dispose = f.finite.handoff(f.registry, changed); + life.resolve({ kind: 'settled' }); + await turn(); + expect(changed).toHaveBeenCalled(); + expect(f.exit).not.toHaveBeenCalled(); + expect(f.registry.budget.deadline()).toBe(3000); + f.finite.exitNow(); // Represents the later native finalizer's positive diagnostic ACK. + expect(await f.finite.exited).toBe(9); + f.clock.advance(5000); + await turn(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + dispose(); + }); + it('constructs without taking the observer or arming timers', () => { + const f = fixture(); + expect(f.clock.timers.size).toBe(0); + expect(f.registry.termination).toBeUndefined(); + const remove = f.registry.observeTermination(() => undefined); + remove(); + expect(f.exit).not.toHaveBeenCalled(); + }); + it.each([2, 15, 1, 3])( + 'physically bounds signal %s without acknowledging a held owner', + async (signal) => { + const f = fixture(); + const life = heldOwner(f.registry); + f.finite.signal(signal); + f.clock.advance(2999); + await turn(); + expect(f.exit).not.toHaveBeenCalled(); + f.clock.advance(3000); + expect(await f.finite.exited).toBe(128 + signal); + expect(f.registry.activeOwners).toBe(1); + f.finite.exitNow(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(128 + signal); + life.resolve({ kind: 'settled' }); + }, + ); + it('requests every stop before physical expiry, preserves stronger real health and the earlier deadline', async () => { + const f = fixture(); + const life = deferred(); + const trace: string[] = []; + let safety: 5 | 8 = 5; + f.registry.register({ + kind: 'workflow', + retired: life.promise, + readSafetyExit: () => safety, + stop: () => { + trace.push('workflow'); + f.clock.advance(700); + }, + join: () => undefined, + classifyFailure: () => 'cli_internal', + }); + heldOwner( + f.registry, + vi.fn(() => trace.push('sibling')), + ); + f.exit.mockImplementation(() => trace.push('exit')); + void f.registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 700 }); + f.finite.signal(2); + safety = 8; + expect(f.registry.budget.deadline()).toBe(700); + expect(trace).toEqual(['workflow', 'sibling']); + expect(await f.finite.exited).toBe(8); + expect(trace).toEqual(['workflow', 'sibling', 'exit']); + life.resolve({ kind: 'settled' }); + }); + it('rejects malformed signals before partial activation and converts a broken clock to finite infrastructure exit', async () => { + const f = fixture(); + for (const value of [0, 128, NaN, Infinity, 2.5]) + expect(() => f.finite.signal(value)).toThrow(); + expect(f.registry.termination).toBeUndefined(); + f.clock.time = NaN; + expect(() => f.finite.signal(15)).not.toThrow(); + expect(await f.finite.exited).toBe(9); + }); + it('invalidates a queued fallback completion when custody is handed off, and never revives it on disposal', async () => { + const f = fixture(); + f.finite.signal(15); + await Promise.resolve(); + const notify = vi.fn(); + const dispose = f.finite.handoff(f.registry, notify); + await turn(); + expect(notify).toHaveBeenCalled(); + expect(f.exit).not.toHaveBeenCalled(); + expect(() => f.finite.handoff(new InvocationRegistry(f.clock), notify)).toThrow(); + expect(() => f.finite.handoff(f.registry, notify)).toThrow(); + dispose(); + f.clock.advance(9000); + await turn(); + expect(f.exit).not.toHaveBeenCalled(); + }); + it('hands an existing deadline to native diagnosis, holds its actual ACK and uses exactly the shared physical exit', async () => { + const f = fixture(); + const life = heldOwner(f.registry); + class Held extends OwnedTtyOutput { + readonly entered = deferred(); + acknowledge: () => void = () => undefined; + override _write( + chunk: unknown, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ) { + this.frames.push(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)); + this.acknowledge = callback; + this.entered.resolve(); + } + } + const stderr = new Held(); + const stdout = new OwnedTtyOutput(); + const wrongExit = vi.fn(); + f.finite.signal(15); + f.clock.advance(1200); + const native = installNativeInvocation( + f.registry, + { json: true, verbosity: 'normal' }, + { + stdout, + stderr, + schedule: f.clock.schedule, + onFatal: () => () => undefined, + onBackground: () => () => undefined, + readExitCode: () => undefined, + setExitCode: vi.fn(), + exit: wrongExit, + }, + f.finite, + ); + try { + native.fail('cli_internal'); + life.resolve({ kind: 'settled' }); + await stderr.entered.promise; + expect(f.registry.budget.deadline()).toBe(3000); + expect(f.exit).not.toHaveBeenCalled(); + f.clock.advance(3000); + expect(await native.finished).toBe(9); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(wrongExit).not.toHaveBeenCalled(); + stderr.acknowledge(); + await turn(); + expect(f.exit).toHaveBeenCalledOnce(); + } finally { + native.dispose(); + stdout.destroy(); + stderr.destroy(); + } + }); +}); diff --git a/apps/cli/src/process/invocation-finite-exit.ts b/apps/cli/src/process/invocation-finite-exit.ts new file mode 100644 index 00000000..589f882d --- /dev/null +++ b/apps/cli/src/process/invocation-finite-exit.ts @@ -0,0 +1,166 @@ +import { CliError } from './cli-error.js'; +import { assertTerminatingSignalNumber } from './exit-observations.js'; +import { + FATAL_TERMINATION_MS, + type InvocationClock, + type InvocationRegistry, +} from './invocation-registry.js'; + +export interface FiniteExitPorts { + readonly setExitCode: (code: number) => void; + readonly exit: (code: number) => void; +} + +/** One explicitly activated exit authority, shared by workflow signals and the later native adapter. */ +export interface InvocationFiniteExit { + readonly fail: (code: 'cli_internal' | 'output_unavailable') => void; + readonly signal: (signalNumber: number) => void; + readonly handoff: (registry: InvocationRegistry, changed: () => void) => () => void; + readonly exitNow: () => void; + readonly hasExited: () => boolean; + readonly exited: Promise; +} + +/** Construction is inert: no registry observer, timer, process listener or service access. */ +export function createInvocationFiniteExit( + registry: InvocationRegistry, + clock: InvocationClock, + ports: FiniteExitPorts, +): InvocationFiniteExit { + let mode: 'idle' | 'fallback' | 'native' | 'disposed' = 'idle'; + let generation = 0; + let exited = false; + let observing = false; + let following = false; + let removeObserver: (() => void) | undefined; + let notifyNative: (() => void) | undefined; + let disarm: (() => void) | undefined; + let scheduledDeadline: number | undefined; + let resolveExit: (code: number) => void = () => undefined; + const exitPromise = new Promise((resolve) => { + resolveExit = resolve; + }); + const clearTimer = (): void => { + const cancel = disarm; + disarm = undefined; + try { + cancel?.(); + } catch { + registry.observations.observeFailure('cli_internal'); + } + }; + const exitNow = (): void => { + if (exited || mode === 'disposed') return; + exited = true; + clearTimer(); + registry.sampleWorkflowHealth(); + const code = registry.observations.exitCode(); + resolveExit(code); + try { + ports.setExitCode(code); + } finally { + ports.exit(code); + } + }; + const backstop = (): void => { + if (exited || mode === 'disposed') return; + const remaining = registry.budget.remainingMs(); + // Includes a failed clock/scheduler with no usable absolute deadline. Stop-all runs first. + if (remaining === 0) { + queueMicrotask(exitNow); + return; + } + const deadline = registry.budget.deadline(); + if (deadline === undefined || remaining === undefined || scheduledDeadline === deadline) return; + scheduledDeadline = deadline; + clearTimer(); + try { + disarm = clock.schedule(remaining, () => queueMicrotask(exitNow)); + } catch { + registry.observations.observeFailure('cli_internal'); + queueMicrotask(exitNow); + } + }; + const changed = (): void => { + backstop(); + if (exited || mode === 'disposed') return; + if (mode === 'native') { + notifyNative?.(); + return; + } + const termination = registry.termination; + if (mode !== 'fallback' || following || termination === undefined) return; + following = true; + const observedGeneration = generation; + void termination.then(() => { + // A queued settled-cleanup continuation cannot bypass the native diagnostic ACK after handoff. + if ( + mode === 'fallback' && + generation === observedGeneration && + !registry.observations.hasInfrastructureFailure() + ) + exitNow(); + }); + }; + const observe = (): void => { + if (observing) return; + observing = true; + removeObserver = registry.observeTermination(changed); + }; + return Object.freeze({ + fail: (code: 'cli_internal' | 'output_unavailable') => { + if (code !== 'cli_internal' && code !== 'output_unavailable') + throw new CliError('cli_internal', ''); + if (exited || mode === 'disposed') return; + if (mode === 'idle') { + mode = 'fallback'; + generation += 1; + observe(); + } + // Fast cleanup leaves outer diagnostic rendering time; only the unchanged absolute backstop + // exits this staged fallback. The later native handoff owns positive diagnostic ACK/early exit. + void registry.terminate({ kind: 'infrastructure', code }); + }, + signal: (signalNumber: number) => { + assertTerminatingSignalNumber(signalNumber); + if (exited || mode === 'disposed') return; + if (mode === 'idle') { + mode = 'fallback'; + generation += 1; + observe(); + } + let deadline: number; + try { + deadline = clock.now() + FATAL_TERMINATION_MS; + if (!Number.isFinite(deadline)) throw new CliError('cli_internal', ''); + } catch { + // Do not let a clock fault escape an OS signal callback or certify any owner settled. + void registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + return; + } + void registry.terminate({ kind: 'signal', signalNumber, deadline }); + }, + handoff: (candidate: InvocationRegistry, callback: () => void) => { + if (candidate !== registry || mode === 'native' || mode === 'disposed' || exited) + throw new CliError('cli_internal', ''); + mode = 'native'; + generation += 1; + notifyNative = callback; + observe(); + // observeTermination replays only on first acquisition; a fallback already owns the observer. + if (registry.termination !== undefined) changed(); + return () => { + if (mode === 'disposed') return; + mode = 'disposed'; + generation += 1; + notifyNative = undefined; + clearTimer(); + removeObserver?.(); + removeObserver = undefined; + }; + }, + exitNow, + hasExited: () => exited, + exited: exitPromise, + }); +} diff --git a/apps/cli/src/process/invocation.ts b/apps/cli/src/process/invocation.ts index 01a8c0e0..135d3ac9 100644 --- a/apps/cli/src/process/invocation.ts +++ b/apps/cli/src/process/invocation.ts @@ -1,21 +1,39 @@ import { createFileCatalogSeed } from './file-catalog-seed.js'; -import { InvocationRegistry } from './invocation-registry.js'; +import { InvocationRegistry, type InvocationClock } from './invocation-registry.js'; +import { + createInvocationFiniteExit, + type InvocationFiniteExit, + type FiniteExitPorts, +} from './invocation-finite-exit.js'; export interface CliInvocation { readonly registry: InvocationRegistry; + readonly finiteExit: InvocationFiniteExit; readonly seedFileCatalogOnce: () => Promise; } /** Created once before guarded imports; authority is explicitly threaded, never module-global. */ -export function createCliInvocation(): CliInvocation { +export function createCliInvocation(finitePorts?: FiniteExitPorts): CliInvocation { + const clock: InvocationClock = { + now: () => performance.now(), + schedule: (milliseconds, fire) => { + const timer = setTimeout(fire, milliseconds); + return () => clearTimeout(timer); + }, + }; + const registry = new InvocationRegistry(clock); return Object.freeze({ - registry: new InvocationRegistry({ - now: () => performance.now(), - schedule: (milliseconds, fire) => { - const timer = setTimeout(fire, milliseconds); - return () => clearTimeout(timer); + registry, + finiteExit: createInvocationFiniteExit( + registry, + clock, + finitePorts ?? { + setExitCode: (code) => { + process.exitCode = code; + }, + exit: (code) => process.exit(code), }, - }), + ), seedFileCatalogOnce: createFileCatalogSeed(async () => { const { seedFileCatalog } = await import('../engine/catalog-seed.js'); seedFileCatalog(); diff --git a/apps/cli/src/process/io-stdout-acknowledgement.test.ts b/apps/cli/src/process/io-stdout-acknowledgement.test.ts new file mode 100644 index 00000000..dcb57fe2 --- /dev/null +++ b/apps/cli/src/process/io-stdout-acknowledgement.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { captureIo, OwnedTtyOutput } from '../test-support.js'; +import { processIo } from './io.js'; + +class HeldOutput extends OwnedTtyOutput { + acknowledge: (error?: Error) => void = () => undefined; + override _write( + chunk: unknown, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ) { + this.acknowledge = (error) => { + if (error === undefined) + this.frames.push(Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)); + callback(error); + }; + } +} +function capturedOutput(stdout: NodeJS.WriteStream) { + const descriptor = Object.getOwnPropertyDescriptor(process, 'stdout'); + if (descriptor === undefined) throw new Error('Expected stdout descriptor.'); + try { + Object.defineProperty(process, 'stdout', { configurable: true, get: () => stdout }); + return processIo(); + } finally { + Object.defineProperty(process, 'stdout', descriptor); + } +} + +describe('actual stdout restoration acknowledgement', () => { + it('waits for the native callback on the originally captured output', async () => { + const stdout = new HeldOutput(); + const io = capturedOutput(stdout); + let acknowledged = false; + try { + const done = io.writeOutAcknowledged('restore').then(() => { + acknowledged = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(acknowledged).toBe(false); + expect(stdout.frames).toEqual([]); + stdout.acknowledge(); + await done; + expect(stdout.frames).toEqual(['restore']); + } finally { + stdout.destroy(); + } + }); + it('rejects a native callback failure with fixed output classification and no copied cause', async () => { + const stdout = new HeldOutput(); + try { + const done = capturedOutput(stdout).writeOutAcknowledged('restore'); + const assertion = expect(done).rejects.toMatchObject({ + code: 'output_unavailable', + exitCode: 9, + }); + stdout.acknowledge(new Error('PRIVATE_STDOUT_CAUSE')); + await assertion; + expect(stdout.frames).toEqual([]); + } finally { + stdout.destroy(); + } + }); + it('acknowledges the same memory sink in captureIo', async () => { + const captured = captureIo(); + captured.io.writeOut('before:'); + await captured.io.writeOutAcknowledged('restore'); + expect(captured.out()).toBe('before:restore'); + expect(captured.err()).toBe(''); + }); +}); diff --git a/apps/cli/src/process/io.ts b/apps/cli/src/process/io.ts index e663cbf3..9301b845 100644 --- a/apps/cli/src/process/io.ts +++ b/apps/cli/src/process/io.ts @@ -5,8 +5,14 @@ import { CliError } from './cli-error.js'; * TTY, stdout, or environment. The `bin` entry wires this to `process`; tests pass a capture. */ export interface CliIo { + /** Captured native TUI/prompt sink. Memory-only hosts omit it and cannot silently acquire the real TTY. */ + readonly nativeStdout?: NodeJS.WriteStream; + /** Native Ink input from the same captured invocation; line-only hosts retain stdin without this field. */ + readonly nativeStdin?: NodeJS.ReadStream; writeOut(text: string): void; writeErr(text: string): void; + /** Actual delivery to the same stdout; enqueueing or cancellation is never a restoration ACK. */ + writeOutAcknowledged(text: string): Promise; /** Acknowledge delivery to the same sink as writeErr; rejection never contains the stream's error text. */ writeErrAcknowledged(text: string): Promise; readonly env: Readonly>; @@ -25,16 +31,19 @@ export interface CliIo { export function processIo(): CliIo { const { stdout, stderr, stdin } = process; return { + nativeStdout: stdout, + nativeStdin: stdin, writeOut: (text) => { stdout.write(text); }, writeErr: (text) => { stderr.write(text); }, + writeOutAcknowledged: (text) => acknowledgeWrite(stdout, text), writeErrAcknowledged: (text) => acknowledgeWrite(stderr, text), env: process.env, - stdoutIsTty: process.stdout.isTTY === true, - stdinIsTty: process.stdin.isTTY === true, + stdoutIsTty: stdout.isTTY === true, + stdinIsTty: stdin.isTTY === true, stdin, }; } diff --git a/apps/cli/src/process/native-invocation.ts b/apps/cli/src/process/native-invocation.ts index c024a4af..fb7fc29b 100644 --- a/apps/cli/src/process/native-invocation.ts +++ b/apps/cli/src/process/native-invocation.ts @@ -1,6 +1,7 @@ import { createBackgroundObserver } from './background-observer.js'; import type { DiagnosticPolicy } from './diagnostic-policy.js'; import { formatFixedDiagnostic } from './fixed-diagnostic.js'; +import type { InvocationFiniteExit } from './invocation-finite-exit.js'; import type { InvocationRegistry } from './invocation-registry.js'; import { acknowledgeWrite, canWrite } from './io.js'; @@ -58,6 +59,7 @@ export function installNativeInvocation( registry: InvocationRegistry, policy: DiagnosticPolicy, ports: NativeInvocationPorts = processPorts(), + sharedExit?: InvocationFiniteExit, ): NativeInvocation { let finalizationPending = false; let changedWhilePending = false; @@ -82,8 +84,14 @@ export function installNativeInvocation( registry.observations.observeFailure('cli_internal'); } }; + const hasExited = (): boolean => exited || sharedExit?.hasExited() === true; + if (sharedExit !== undefined) void sharedExit.exited.then((code) => finish(code)); const exitNow = (): void => { - if (exited || disposed) return; + if (hasExited() || disposed) return; + if (sharedExit !== undefined) { + sharedExit.exitNow(); + return; + } exited = true; clearTimer(); registry.sampleWorkflowHealth(); @@ -93,8 +101,9 @@ export function installNativeInvocation( ports.exit(code); }; const scheduleBackstop = (): void => { + if (sharedExit !== undefined) return; const deadline = registry.budget.deadline(); - if (exited || disposed || deadline === undefined || scheduledDeadline === deadline) return; + if (hasExited() || disposed || deadline === undefined || scheduledDeadline === deadline) return; scheduledDeadline = deadline; clearTimer(); const remaining = registry.budget.remainingMs(); @@ -111,14 +120,14 @@ export function installNativeInvocation( } }; const fail = (code: 'cli_internal' | 'output_unavailable'): void => { - if (exited || disposed) return; + if (hasExited() || disposed) return; void registry.terminate({ kind: 'infrastructure', code }); }; const finalize = async (): Promise => { const termination = registry.termination; - if (termination === undefined || exited || disposed) return; + if (termination === undefined || hasExited() || disposed) return; const result = await termination; - if (exited || disposed) return; + if (hasExited() || disposed) return; registry.sampleWorkflowHealth(); if (result.kind === 'deadline' || registry.budget.remainingMs() === 0) { exitNow(); @@ -134,7 +143,7 @@ export function installNativeInvocation( fail('output_unavailable'); } } - if (exited || disposed) return; + if (hasExited() || disposed) return; registry.sampleWorkflowHealth(); if ( registry.observations.hasInfrastructureFailure() || @@ -150,7 +159,7 @@ export function installNativeInvocation( }; const changed = (): void => { scheduleBackstop(); - if (exited || disposed) return; + if (hasExited() || disposed) return; if (finalizationPending) { changedWhilePending = true; return; @@ -168,7 +177,7 @@ export function installNativeInvocation( // complete() can resolve before this finally reaction runs. Retain any competing late fault. if ( changedWhilePending && - !exited && + !hasExited() && !disposed && (registry.observations.hasInfrastructureFailure() || registry.observations.hasTerminatingSignal()) @@ -177,7 +186,10 @@ export function installNativeInvocation( }); }); }; - const removeObserver = registry.observeTermination(changed); + const removeObserver = + sharedExit === undefined + ? registry.observeTermination(changed) + : sharedExit.handoff(registry, changed); const stdoutFailed = (): void => { stdoutUsable = false; fail('output_unavailable'); diff --git a/apps/cli/src/process/native-prompt-io.test.ts b/apps/cli/src/process/native-prompt-io.test.ts new file mode 100644 index 00000000..51904c21 --- /dev/null +++ b/apps/cli/src/process/native-prompt-io.test.ts @@ -0,0 +1,137 @@ +import { Readable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { createBoundClackGatePrompter } from '../gate/clack-prompter.js'; +import { createBoundConsentPrompter } from '../mcp/consent-prompt.js'; +import type { ConsentSubject } from '../engine/mcp-consent-gate.js'; +import { captureIo, OwnedTtyInput, OwnedTtyOutput } from '../test-support.js'; +import { nativePromptIo } from './native-prompt-io.js'; + +function fixture() { + const input = new OwnedTtyInput(); + const output = new OwnedTtyOutput(); + const captured = captureIo(); + const io = { + ...captured.io, + stdin: input, + nativeStdin: input, + nativeStdout: output, + stdinIsTty: true, + stdoutIsTty: true, + writeOut: (text: string) => { + output.write(text); + }, + writeOutAcknowledged: (text: string) => + new Promise((resolve, reject) => { + output.write(text, (error) => (error ? reject(error) : resolve())); + }), + }; + return { + input, + output, + captured, + io, + close: () => { + input.destroy(); + output.destroy(); + }, + }; +} +const turn = () => new Promise((resolve) => setImmediate(resolve)); + +describe('captured native prompt ports', () => { + it('refuses missing native output before entering a library prompt', () => { + const captured = captureIo(); + expect(() => nativePromptIo(captured.io)).toThrow(); + try { + nativePromptIo(captured.io); + } catch (cause) { + expect(cause).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + } + expect(captured.out()).toBe(''); + expect(captured.err()).toBe(''); + }); + + it('retains the captured input and output object identities', () => { + const f = fixture(); + try { + expect(nativePromptIo(f.io)).toEqual({ input: f.input, output: f.output }); + const lineInput = Readable.from([]); + expect(nativePromptIo({ ...f.io, stdin: lineInput }).input).toBe(lineInput); + } finally { + f.close(); + } + }); + + it.each(['approval', 'input'] as const)( + 'binds installed Clack %s card and acquisition to the owned ports, then joins abort release', + async (gateType) => { + const f = fixture(); + const controller = new AbortController(); + const before = f.input.listeners('keypress'); + try { + const prompter = createBoundClackGatePrompter(f.io); + const pending = prompter.prompt( + { + type: 'human_gate:paused', + runId: 'offline', + timestamp: '2026-10-16T00:00:00Z', + sequenceNumber: 1, + nodeId: 'gate', + gateId: 'owned-gate', + gateType, + message: 'OWNED_GATE_CARD', + }, + undefined, + controller.signal, + ); + await turn(); + expect(f.output.frames.join('')).toContain('OWNED_GATE_CARD'); + expect(f.input.isRaw).toBe(true); + expect(f.input.listeners('keypress').length).toBeGreaterThan(before.length); + controller.abort(); + expect(await pending).toBeNull(); + expect(f.input.isRaw).toBe(false); + // Node's shared emitKeypressEvents decoder retains an onData helper. Prompt-specific input + // authority is gone only after its keypress subscription, raw mode and flowing read stop. + expect(f.input.listeners('keypress')).toEqual(before); + expect(f.input.readableFlowing).toBe(false); + expect(f.captured.out()).toBe(''); + } finally { + controller.abort(); + f.close(); + } + }, + ); + + it('binds installed Clack consent log and confirm to the owned ports and acknowledges cancellation', async () => { + const f = fixture(); + const controller = new AbortController(); + const subject: ConsentSubject = { + serverId: 'OWNED_MCP_SERVER', + provenance: 'inline', + artifact: undefined, + authoredCommand: undefined, + resolvedCommand: '/synthetic/never-spawned', + args: [], + env: [], + cwd: '/synthetic', + digest: 'v1:offline', + total: 1, + index: 1, + previouslyApprovedIn: undefined, + }; + try { + const pending = createBoundConsentPrompter(f.io)(subject, { signal: controller.signal }); + await turn(); + expect(f.output.frames.join('')).toContain('OWNED_MCP_SERVER'); + expect(f.input.isRaw).toBe(true); + controller.abort(); + expect(await pending).toBe(false); + expect(f.input.isRaw).toBe(false); + expect(f.captured.out()).toBe(''); + } finally { + controller.abort(); + f.close(); + } + }); +}); diff --git a/apps/cli/src/process/native-prompt-io.ts b/apps/cli/src/process/native-prompt-io.ts new file mode 100644 index 00000000..86b0bde2 --- /dev/null +++ b/apps/cli/src/process/native-prompt-io.ts @@ -0,0 +1,13 @@ +import { Readable } from 'node:stream'; +import { CliError } from './cli-error.js'; +import type { CliIo } from './io.js'; + +/** Bind actual prompt acquisition to the invocation's captured streams, never later process globals. */ +export function nativePromptIo(io: CliIo): { + readonly input: Readable; + readonly output: NodeJS.WriteStream; +} { + if (io.nativeStdout === undefined || !(io.stdin instanceof Readable)) + throw new CliError('cli_internal', ''); + return { input: io.stdin, output: io.nativeStdout }; +} diff --git a/apps/cli/src/process/render-error.test.ts b/apps/cli/src/process/render-error.test.ts index 8f3ef655..240c2e08 100644 --- a/apps/cli/src/process/render-error.test.ts +++ b/apps/cli/src/process/render-error.test.ts @@ -63,6 +63,7 @@ function captureIo(): { readonly io: CliIo; readonly stderr: () => string } { writeErr: (text) => { writes.push(text); }, + writeOutAcknowledged: () => Promise.resolve(), writeErrAcknowledged: (text) => { writes.push(text); return Promise.resolve(); diff --git a/apps/cli/src/render/renderer.ts b/apps/cli/src/render/renderer.ts index e171988b..e0382eb3 100644 --- a/apps/cli/src/render/renderer.ts +++ b/apps/cli/src/render/renderer.ts @@ -23,6 +23,8 @@ import { sanitizeInline, stringifyJsonLine } from './sanitize.js'; */ export interface RunRenderer { onEvent: (event: RunEvent) => void; + /** Permanent shutdown seal: prevent repaint/remount; releaseInput still owes actual native ACK. */ + seal?: () => void; /** * Optional pause of the live view so an interactive `@clack/prompts` gate card (2.G) can own the terminal * mid-run: the `ink` TUI unmounts (releasing the terminal) WITHOUT writing its final summary; `resume` diff --git a/apps/cli/src/render/run-job-control-ownership.test.ts b/apps/cli/src/render/run-job-control-ownership.test.ts new file mode 100644 index 00000000..3d3a3546 --- /dev/null +++ b/apps/cli/src/render/run-job-control-ownership.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from 'vitest'; +import { InvocationRegistry } from '../process/invocation-registry.js'; +import { WorkflowTerminal } from '../commands/workflow-terminal.js'; +import { captureIo } from '../test-support.js'; +import { wireRunJobControl, type RunJobControl } from './run-job-control.js'; + +function fixture() { + const registry = new InvocationRegistry({ now: () => 0, schedule: () => () => undefined }); + const terminal = new WorkflowTerminal(registry, captureIo().io, () => undefined, vi.fn()); + return { registry, terminal }; +} + +describe('job-control acquisition and actual native removal custody', () => { + it.each(['write', 'detach'] as const)( + 'does not self-suspend after reentrant disposal inside %s', + async (boundary) => { + let suspend: () => void = () => undefined; + const suspendSelf = vi.fn(); + const removed = vi.fn(); + const control = wireRunJobControl({ + write: () => { + if (boundary === 'write') control.dispose(); + }, + lifecycle: { + supported: true, + onSuspend: (callback) => { + suspend = callback; + return () => { + removed(); + if (boundary === 'detach') control.dispose(); + }; + }, + onContinue: () => () => undefined, + suspendSelf, + }, + }); + suspend(); + expect(suspendSelf, 'WORKFLOW_JOB_REENTRY_STOP').not.toHaveBeenCalled(); + await control.released; + expect(removed).toHaveBeenCalled(); + }, + ); + it('pre-adopts before registration and never retires during reentrant stop inside registration', async () => { + const f = fixture(); + let control: RunJobControl | undefined; + let released = false; + let suspend: () => void = () => undefined; + const remove = vi.fn(); + const continueRegistration = vi.fn(() => () => undefined); + f.terminal.createJobControl((adopt) => + wireRunJobControl({ + write: vi.fn(), + adopt: (owner) => { + control = owner; + adopt(owner); + void owner.released?.then(() => { + released = true; + }); + }, + lifecycle: { + supported: true, + onSuspend: (listener) => { + suspend = listener; + expect(control).toBeDefined(); + f.terminal.seal(); + expect(released).toBe(false); + expect(f.registry.activeOwners).toBe(1); + return remove; + }, + onContinue: continueRegistration, + suspendSelf: vi.fn(), + }, + }), + ); + await f.terminal.finish(); + expect(remove).toHaveBeenCalledOnce(); + expect(continueRegistration).not.toHaveBeenCalled(); + expect(released).toBe(true); + expect(f.registry.activeOwners).toBe(0); + suspend(); + expect(remove).toHaveBeenCalledOnce(); + }); + it('retains unknown acquisition after a throwing native registration while still removing the known sibling', async () => { + const f = fixture(); + const remove = vi.fn(); + expect(() => + f.terminal.createJobControl((adopt) => + wireRunJobControl({ + write: vi.fn(), + adopt, + lifecycle: { + supported: true, + onSuspend: () => remove, + onContinue: () => { + throw new Error('PRIVATE_NATIVE_REGISTRATION'); + }, + suspendSelf: vi.fn(), + }, + }), + ), + ).toThrow('PRIVATE_NATIVE_REGISTRATION'); + expect(remove).toHaveBeenCalledOnce(); + let finished = false; + void f.terminal.finish().then(() => { + finished = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(finished).toBe(false); + expect(f.registry.activeOwners).toBe(1); + expect(remove).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/cli/src/render/run-job-control.test.ts b/apps/cli/src/render/run-job-control.test.ts index e4eec036..be7c9e9f 100644 --- a/apps/cli/src/render/run-job-control.test.ts +++ b/apps/cli/src/render/run-job-control.test.ts @@ -157,7 +157,16 @@ describe('wireRunJobControl (G0, the run/gate path)', () => { throw new Error('suspend removal failed'); }); const jc = wireRunJobControl({ write: h.write, lifecycle: h.lifecycle }); - expect(() => jc.dispose()).toThrow('Run job-control cleanup failed.'); + try { + jc.dispose(); + expect.unreachable('The failed native removal must remain classified.'); + } catch (error) { + expect(error).toMatchObject({ + code: 'cli_internal', + exitCode: 9, + cause: { message: 'suspend removal failed' }, + }); + } expect(h.removeContinue).toHaveBeenCalledTimes(1); h.fireContinue(); h.fireSuspend(); diff --git a/apps/cli/src/render/run-job-control.ts b/apps/cli/src/render/run-job-control.ts index 1fe91801..ec189c4a 100644 --- a/apps/cli/src/render/run-job-control.ts +++ b/apps/cli/src/render/run-job-control.ts @@ -22,9 +22,12 @@ import { CliError } from '../process/errors.js'; export interface RunJobControl { /** Detach the listeners. Idempotent; safe to call from a `finally` that also runs on the throw path. */ readonly dispose: () => void; + /** Actual completed registration/removal, independently of an early dispose request. */ + readonly released?: Promise; } export interface WireRunJobControlOptions { + readonly adopt?: (control: RunJobControl) => void; /** Where the cursor codes go — the same seam the renderers write through, so tests capture them. */ readonly write: (text: string) => void; /** Injectable signal registration (`defaultJobControlLifecycle` in production; a fake in tests). */ @@ -33,101 +36,138 @@ export interface WireRunJobControlOptions { export function wireRunJobControl(opts: WireRunJobControlOptions): RunJobControl { const lifecycle = opts.lifecycle ?? defaultJobControlLifecycle; - // Windows has no job control: `chmod`-style no-op rather than a half-wired handler (ADR-0050's precedent for - // a documented per-platform divergence). - if (!lifecycle.supported) { - return { dispose: () => undefined }; - } - let disposed = false; - let cleanupComplete = false; - let continueRemoved = false; - let removeSuspend: () => void = () => undefined; - /** - * Is the suspend listener currently attached? - * - * Load-bearing, not bookkeeping. Both re-attach sites are reachable without an intervening detach — a second - * SIGCONT with no SIGTSTP between them, or a `suspendSelf` that throws and is then followed by one — and an - * unconditional re-attach would register a SECOND listener while OVERWRITING `removeSuspend` with the new - * handle. The first listener then leaks for the process's life and the next Ctrl-Z runs the handler twice, - * writing `SHOW_CURSOR` and raising SIGTSTP twice over. - */ + let constructing = true; + let unknownRegistration = false; + let registrations = 0; let attached = false; - const attach = (): void => { - if (disposed || attached) return; - removeSuspend = lifecycle.onSuspend(onSuspend); - attached = true; + let continueRegistered = false; + let removeSuspend: () => void = () => undefined; + let removeContinue: () => void = () => undefined; + let acknowledge: () => void = () => undefined; + const released = new Promise((resolve) => { + acknowledge = resolve; + }); + const acknowledgeReleased = (): void => { + if ( + disposed && + !constructing && + registrations === 0 && + !unknownRegistration && + !attached && + !continueRegistered + ) + acknowledge(); }; - const detach = (): void => { + const detached = (): void => { if (!attached) return; - removeSuspend(); attached = false; + registrations += 1; // Removal reentry cannot recurse into or acknowledge the same entered remover. + try { + removeSuspend(); + } catch (cause) { + attached = true; + throw cause; + } finally { + registrations -= 1; + } + acknowledgeReleased(); + }; + const dispose = (): void => { + disposed = true; // Callback and future-registration admission closes before either native remover. + let failed: { readonly cause: unknown } | undefined; + try { + detached(); + } catch (cause) { + failed = { cause }; + } + try { + if (continueRegistered) { + continueRegistered = false; + registrations += 1; + try { + removeContinue(); + } catch (cause) { + continueRegistered = true; + throw cause; + } finally { + registrations -= 1; + } + } + } catch (cause) { + failed ??= { cause }; + } + if (failed !== undefined) throw new CliError('cli_internal', '', { cause: failed.cause }); + acknowledgeReleased(); }; const onSuspend = (): void => { if (disposed) return; - // Restore the cursor BEFORE the process stops. Once stopped we run no code, so there is no later chance — - // and the shell the user lands in inherits whatever state we left. opts.write(SHOW_CURSOR); - // DETACH before re-raising. `suspendSelf` sends SIGTSTP to our own pid, and a signal with a handler - // installed runs the handler instead of taking the default stop action — so raising while still attached - // re-enters this function forever and the process never actually stops. Removing the listener lets the - // default action run; SIGCONT reattaches it below. - detach(); + if (disposed) return; + detached(); + if (disposed) return; try { - // Re-raise so the process actually stops. Without this the handler swallows Ctrl-Z and the run keeps - // going, which is worse than the bug being fixed: the user pressed a key that did nothing. lifecycle.suspendSelf(); } catch { - // The raise failed, so we are still running and still need Ctrl-Z to work — put the handler back. attach(); } }; - attach(); - let removeContinue: () => void; - try { - removeContinue = lifecycle.onContinue(() => { - if (disposed) return; - // Reattach FIRST: the suspend handler detached itself before raising, so without this a second Ctrl-Z - // would take the default action with no cursor restore — exactly the bug this file exists to fix. - attach(); - // Foregrounded: ink is drawing again, so hide the cursor as it expects. If the run already finished while - // we were stopped, the extra hide is harmless — the renderer's own teardown shows it again. - opts.write(HIDE_CURSOR); - }); - } catch (error) { - disposed = true; + const attach = (): void => { + if (disposed || attached) return; + registrations += 1; // A reentrant stop cannot retire a registration whose remover is not returned yet. + let acquired = false; try { - detach(); - } catch (cleanupError) { - throw new CliError('internal', 'Run job-control setup failed.', { - cause: { registrationError: error, cleanupError }, - }); + removeSuspend = lifecycle.onSuspend(onSuspend); + acquired = true; + attached = true; + if (disposed) detached(); + } catch (cause) { + if (!acquired) unknownRegistration = true; + throw cause; + } finally { + registrations -= 1; } - throw error; - } - - return { - dispose: () => { - if (cleanupComplete) return; - // Retire callbacks before either removal. A failed removal retains its capability for retry. - disposed = true; - let failure: { readonly cause: unknown } | undefined; - try { - detach(); - } catch (cause) { - failure = { cause }; + if (disposed) dispose(); + }; + const control = Object.freeze({ dispose, released }); + opts.adopt?.(control); // Ownership precedes the first potentially allocating native registration. + let primaryFailure: { readonly cause: unknown } | undefined; + let cleanupFailure: { readonly cause: unknown } | undefined; + try { + if (lifecycle.supported && !disposed) { + attach(); + if (!disposed) { + registrations += 1; + try { + removeContinue = lifecycle.onContinue(() => { + if (disposed) return; + attach(); + if (!disposed) opts.write(HIDE_CURSOR); + }); + continueRegistered = true; + } catch (cause) { + unknownRegistration = true; + throw cause; + } finally { + registrations -= 1; + } } + } + } catch (cause) { + primaryFailure = { cause }; + disposed = true; + } finally { + constructing = false; + if (disposed || !lifecycle.supported) { try { - if (!continueRemoved) { - removeContinue(); - continueRemoved = true; - } + dispose(); } catch (cause) { - failure ??= { cause }; + cleanupFailure = { cause }; } - if (failure !== undefined) - throw new CliError('internal', 'Run job-control cleanup failed.', { cause: failure.cause }); - cleanupComplete = true; - }, - }; + } + } + if (primaryFailure !== undefined && cleanupFailure === undefined) throw primaryFailure.cause; + if (primaryFailure !== undefined || cleanupFailure !== undefined) + throw new CliError('cli_internal', '', { cause: { primaryFailure, cleanupFailure } }); + return control; } diff --git a/apps/cli/src/render/select.test.ts b/apps/cli/src/render/select.test.ts index d88267cb..caa22206 100644 --- a/apps/cli/src/render/select.test.ts +++ b/apps/cli/src/render/select.test.ts @@ -3,7 +3,10 @@ import { describe, expect, it } from 'vitest'; import type { GlobalOptions } from '../process/options.js'; import { detectOutputMode } from '../process/output-mode.js'; -import { captureIo } from '../test-support.js'; +import { captureIo, OwnedTtyInput, OwnedTtyOutput } from '../test-support.js'; +import { processIo } from '../process/io.js'; +import { InvocationRegistry } from '../process/invocation-registry.js'; +import { WorkflowTerminal } from '../commands/workflow-terminal.js'; import { selectRenderer } from './select.js'; const TS = '2026-06-23T12:00:00.000Z'; @@ -53,4 +56,83 @@ describe('selectRenderer', () => { expect(detectOutputMode({ stdoutIsTty: true, json: false, ci: true })).toBe('plain'); // CI wins expect(detectOutputMode({ stdoutIsTty: false, json: false, ci: false })).toBe('plain'); // no TTY }); + + it('refuses a miswired TUI before acquisition and releases its declared native-free owner', async () => { + const captured = captureIo(); + const io = { ...captured.io, stdoutIsTty: true }; + const registry = new InvocationRegistry({ now: () => 0, schedule: () => () => undefined }); + const terminal = new WorkflowTerminal( + registry, + io, + () => undefined, + () => undefined, + ); + let refused: unknown; + try { + terminal.createRenderer((creation) => selectRenderer(io, globalOptions({}), creation)); + } catch (cause) { + refused = cause; + } + expect(refused).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + await terminal.finish(); + expect(registry.activeOwners).toBe(0); + expect(captured.out()).toBe(''); + }); + + it('mounts installed Ink on the invocation streams even after process globals change', async () => { + const aInput = new OwnedTtyInput(); + const aOutput = new OwnedTtyOutput(); + const bInput = new OwnedTtyInput(); + const bOutput = new OwnedTtyOutput(); + const inputDescriptor = Object.getOwnPropertyDescriptor(process, 'stdin'); + const outputDescriptor = Object.getOwnPropertyDescriptor(process, 'stdout'); + if (inputDescriptor === undefined || outputDescriptor === undefined) + throw new Error('Expected process stream descriptors.'); + const registry = new InvocationRegistry({ now: () => 0, schedule: () => () => undefined }); + let terminal: WorkflowTerminal | undefined; + try { + try { + Object.defineProperty(process, 'stdin', { configurable: true, get: () => aInput }); + Object.defineProperty(process, 'stdout', { configurable: true, get: () => aOutput }); + const capture = captureIo(); + const io = { + ...processIo(), + env: {}, + writeErr: (text: string) => capture.io.writeErr(text), + writeErrAcknowledged: (text: string) => capture.io.writeErrAcknowledged(text), + }; + expect(io.nativeStdout).toBe(aOutput); + expect(io.nativeStdin).toBe(aInput); + Object.defineProperty(process, 'stdin', { configurable: true, get: () => bInput }); + Object.defineProperty(process, 'stdout', { configurable: true, get: () => bOutput }); + terminal = new WorkflowTerminal( + registry, + io, + () => undefined, + () => undefined, + ); + const renderer = terminal.createRenderer((creation) => + selectRenderer(io, globalOptions({}), creation), + ); + renderer.onEvent(COMPLETED); + } finally { + // Both alternatives are handle-free owned streams; no regression can acquire the operator's TTY. + Object.defineProperty(process, 'stdin', inputDescriptor); + Object.defineProperty(process, 'stdout', outputDescriptor); + } + await terminal?.finish(); + expect(aOutput.frames.join('')).toContain('cost $0.0000 known; usage unverified'); + expect(aOutput.frames.join('')).toContain('\u001b[?25l'); + expect(bOutput.frames).toEqual([]); + expect(bInput.isRaw).toBe(false); + expect(bInput.listeners('keypress')).toEqual([]); + expect(registry.activeOwners).toBe(0); + } finally { + await terminal?.finish(); + aInput.destroy(); + aOutput.destroy(); + bInput.destroy(); + bOutput.destroy(); + } + }); }); diff --git a/apps/cli/src/render/select.ts b/apps/cli/src/render/select.ts index a59fbbca..7c441e3f 100644 --- a/apps/cli/src/render/select.ts +++ b/apps/cli/src/render/select.ts @@ -1,3 +1,5 @@ +import { CliError } from '../process/cli-error.js'; +import type { RendererCreationOwner } from '../commands/workflow-terminal.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; import { detectOutputMode, isCiEnv } from '../process/output-mode.js'; @@ -10,21 +12,33 @@ import { createJsonRenderer, createPlainRenderer, type RunRenderer } from './ren * attached, the NDJSON renderer under `--json`, and the plain line renderer otherwise (no-TTY / `CI=true`). * All three are the same `onEvent` seam over one bus — "renderer, not a fork" (2.F / 2.K). */ -export function selectRenderer(io: CliIo, global: GlobalOptions): RunRenderer { +export function selectRenderer( + io: CliIo, + global: GlobalOptions, + creation?: RendererCreationOwner, +): RunRenderer { const mode = detectOutputMode({ stdoutIsTty: io.stdoutIsTty, json: global.json, ci: isCiEnv(io.env), }); if (mode === 'tui') { + if (io.nativeStdout === undefined || io.nativeStdin === undefined) { + creation?.acknowledgeNoAcquisition(); + throw new CliError('cli_internal', ''); + } // The ink renderer takes the real stdout stream directly (not the CliIo text seam): ink needs a // NodeJS.WriteStream for cursor control / raw mode / terminal dimensions, which `writeOut(text)` cannot - // represent. This path is only reached on an interactive TTY (never in tests — captureIo is not a TTY). + // represent. Production reaches this only in TUI mode; native fixtures supply their own inert TTY ports. return createInkRenderer({ color: global.color, + stdout: io.nativeStdout, + stdin: io.nativeStdin, + ...(creation === undefined ? {} : { creation }), writeTerminalNotice: (text) => io.writeErr(text), }); } // 'plain' covers --json (NDJSON), CI, and no-TTY: NDJSON only under the explicit --json opt-in (ADR-0049). + creation?.acknowledgeNoAcquisition(); return global.json ? createJsonRenderer(io) : createPlainRenderer(io); } diff --git a/apps/cli/src/render/tui/ink-renderer-ownership.test.ts b/apps/cli/src/render/tui/ink-renderer-ownership.test.ts new file mode 100644 index 00000000..fbfad24a --- /dev/null +++ b/apps/cli/src/render/tui/ink-renderer-ownership.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { RunRenderer } from '../renderer.js'; +import { createInkRenderer } from './ink-renderer.js'; + +const turn = () => new Promise((resolve) => setImmediate(resolve)); +function deferred() { + let resolve: () => void = () => undefined; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} + +describe('Ink construction custody and permanent shutdown seal', () => { + it('pre-adopts the native owner before a partially allocating mount can throw', async () => { + let owner: Pick | undefined; + let ownedBeforeMount = false; + const mount = vi.fn(() => { + ownedBeforeMount = typeof owner?.releaseInput === 'function'; + throw new Error('PRIVATE_INK_CONSTRUCTION'); + }); + expect(() => + createInkRenderer({ + color: false, + mount, + creation: { + acknowledgeNoAcquisition: () => { + throw new Error('Ink cannot attest no acquisition.'); + }, + adopt: (value) => { + owner = value; + }, + }, + }), + ).toThrow('PRIVATE_INK_CONSTRUCTION'); + expect(ownedBeforeMount, 'INK_PREENTRY_CUSTODY').toBe(true); + expect(mount).toHaveBeenCalledOnce(); + let released = false; + owner?.seal?.(); + void Promise.resolve(owner?.releaseInput?.()).then(() => { + released = true; + }); + await turn(); + expect(released).toBe(false); // A construction throw and absent handle are not native ACK. + }); + it('retains an unacknowledged remount instead of treating missing instance as released', async () => { + let mounts = 0; + const renderer = createInkRenderer({ + color: false, + mount: () => { + if (++mounts === 2) throw new Error('PRIVATE_REMOUNT'); + return { unmount: () => undefined, waitUntilExit: () => Promise.resolve() }; + }, + }); + await renderer.suspend?.(); + expect(() => renderer.resume?.()).toThrow('PRIVATE_REMOUNT'); + let released = false; + renderer.seal?.(); + void Promise.resolve(renderer.releaseInput?.()).then(() => { + released = true; + }); + await turn(); + expect(released).toBe(false); + expect(mounts).toBe(2); + }); + it('attempts unmount even when acquiring waitUntilExit itself throws, then requires both actual ACKs on retry', async () => { + const unmount = vi.fn(); + const waitUntilExit = vi + .fn(() => Promise.resolve()) + .mockImplementationOnce(() => { + throw new Error('ACK setup failed'); + }); + const renderer = createInkRenderer({ color: false, mount: () => ({ unmount, waitUntilExit }) }); + renderer.seal?.(); + await expect(renderer.releaseInput?.()).rejects.toThrow('ACK setup failed'); + expect(unmount).toHaveBeenCalledOnce(); + await renderer.releaseInput?.(); + expect(unmount).toHaveBeenCalledTimes(2); + expect(waitUntilExit).toHaveBeenCalledTimes(2); + }); + it('seals event rendering, notices, remount and summary while preserving actual input release', async () => { + const life = deferred(); + const unmount = vi.fn(); + const mount = vi.fn(() => ({ unmount, waitUntilExit: () => life.promise })); + const summary = vi.fn(); + const notice = vi.fn(); + const renderer = createInkRenderer({ + color: false, + mount, + writeSummary: summary, + writeTerminalNotice: notice, + }); + renderer.seal?.(); + renderer.onEvent({ + type: 'run:cancelled', + runId: 'sealed', + timestamp: '2026-10-16T00:00:00Z', + sequenceNumber: 1, + }); + await renderer.resume?.(); + let finalized = false; + const done = Promise.resolve(renderer.finalize?.()).then(() => { + finalized = true; + }); + await turn(); + expect(mount).toHaveBeenCalledOnce(); + expect(unmount).toHaveBeenCalledOnce(); + expect(notice).not.toHaveBeenCalled(); + expect(summary).not.toHaveBeenCalled(); + expect(finalized).toBe(false); + life.resolve(); + await done; + expect(summary).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/cli/src/render/tui/ink-renderer.ts b/apps/cli/src/render/tui/ink-renderer.ts index 0c56803c..09992267 100644 --- a/apps/cli/src/render/tui/ink-renderer.ts +++ b/apps/cli/src/render/tui/ink-renderer.ts @@ -1,6 +1,7 @@ import { render } from 'ink'; import { createElement } from 'react'; +import type { RendererCreationOwner } from '../../commands/workflow-terminal.js'; import type { RunRenderer } from '../renderer.js'; import { RunApp } from './RunApp.js'; import { createRunStore, type RunStore } from './run-store.js'; @@ -28,10 +29,12 @@ export interface InkMountInstance { } export interface InkRendererOptions { + readonly creation?: RendererCreationOwner; /** Whether to render with color (`false` under `--no-color`). */ readonly color: boolean; /** The TTY stream `ink` renders to — defaults to `process.stdout` (a real terminal in the `tui` path). */ readonly stdout?: NodeJS.WriteStream; + readonly stdin?: NodeJS.ReadStream; /** Where the persistent final summary is written after unmount — defaults to `stdout`. Injectable for tests. */ readonly writeSummary?: (text: string) => void; /** Immediate fixed human outcome on stderr; independent of input and host-cleanup ACKs. */ @@ -46,6 +49,7 @@ export interface InkRendererOptions { */ export function createInkRenderer(options: InkRendererOptions): RunRenderer { const stdout = options.stdout ?? process.stdout; + const stdin = options.stdin ?? process.stdin; const store = createRunStore(options.color); const mount = @@ -53,6 +57,10 @@ export function createInkRenderer(options: InkRendererOptions): RunRenderer { ((s: RunStore): InkMountInstance => render(createElement(RunApp, { store: s }), { stdout, + stdin, + // The CLI selector already chose TUI mode. A second global CI detector must not change + // that captured invocation decision or turn a live mount into a final-frame-only renderer. + interactive: true, // OUR SIGINT handler (run.ts) drives the cooperative cancel — not ink's process.exit. RunApp uses no // `useInput`/`useFocus`, so ink stays in COOKED mode (never raw): the kernel keeps translating Ctrl-C // → SIGINT, which reaches run.ts. (If a future RunApp adds input, re-verify cancel on a real TTY.) @@ -65,11 +73,14 @@ export function createInkRenderer(options: InkRendererOptions): RunRenderer { // The live view is a (frame loop + ink mount) pair that can be torn down and re-created: `start` after // construction and on `resume`, `stop` on `suspend` and `finalize`. The store outlives every cycle, so a // re-mount re-projects the exact same state (2.E: ink is a thin projection of an ink-free store). + let sealed = false; + let unacknowledgedMount = false; let frame: ReturnType | undefined; let instance: InkMountInstance | undefined; let stopping: Promise | undefined; const start = (): void => { + if (sealed) return; // The frame loop drives the throttle (coalesced repaints) + the spinner animation. unref() so a pending // tick never holds the process open past the run's end. const f = setInterval(() => { @@ -77,7 +88,9 @@ export function createInkRenderer(options: InkRendererOptions): RunRenderer { }, FRAME_MS); f.unref(); try { + unacknowledgedMount = true; instance = mount(store); + unacknowledgedMount = false; } catch (err) { clearInterval(f); // mount threw — don't leak the frame loop (finalize won't run) throw err; @@ -92,14 +105,27 @@ export function createInkRenderer(options: InkRendererOptions): RunRenderer { frame = undefined; } const live = instance; - if (live === undefined) return Promise.resolve(); + if (live === undefined) + return unacknowledgedMount ? new Promise(() => {}) : Promise.resolve(); // Retain the actual capability until BOTH native acknowledgements succeed. const pending = Promise.resolve().then(async () => { // Ink registers its beforeExit handler in waitUntilExit and removes it during unmount. // Acquire the ACK first; acquiring it after unmount would leak one listener per mount. - const exiting = live.waitUntilExit(); - void exiting.catch(() => undefined); // observe rejection even if unmount itself throws - live.unmount(); + let failed: { readonly cause: unknown } | undefined; + let exiting: Promise | undefined; + try { + exiting = live.waitUntilExit(); + void exiting.catch(() => undefined); + } catch (cause) { + failed = { cause }; + } + try { + live.unmount(); + } catch (cause) { + failed ??= { cause }; + } + if (failed !== undefined) throw failed.cause; + if (exiting === undefined) return await new Promise(() => {}); await exiting; if (instance === live) instance = undefined; }); @@ -115,14 +141,20 @@ export function createInkRenderer(options: InkRendererOptions): RunRenderer { return pending; }; - start(); // initial mount (a construction throw propagates, leaving no timer — see `start`) - let finalized = false; let finalizing: Promise | undefined; let terminalNoticed = false; - return { + const renderer: RunRenderer = { + seal: () => { + sealed = true; + if (frame !== undefined) { + clearInterval(frame); + frame = undefined; + } + }, onEvent: (event) => { + if (sealed) return; store.apply(event); if ( !terminalNoticed && @@ -155,7 +187,7 @@ export function createInkRenderer(options: InkRendererOptions): RunRenderer { suspend: async () => { // Release the terminal so a `@clack/prompts` gate card (2.G) can render. Paint the latest live frame // first, then unmount WITHOUT writing the summary (that is finalize's job — the run is not over). - store.flush(); + if (!sealed) store.flush(); await stop(); }, resume: () => { @@ -169,9 +201,10 @@ export function createInkRenderer(options: InkRendererOptions): RunRenderer { if (finalized) return Promise.resolve(); if (finalizing !== undefined) return finalizing; const pending = Promise.resolve().then(async () => { - store.flush(); + if (!sealed) store.flush(); await stop(); finalized = true; + if (sealed) return; // Input and host-safe ACKs precede the irreversible summary. A cosmetic write fault // cannot revoke those ACKs or authorize a second summary attempt. const write = @@ -193,4 +226,8 @@ export function createInkRenderer(options: InkRendererOptions): RunRenderer { return pending; }, }; + // Transfer cleanup custody before Ink's constructor can allocate native work and then throw. + options.creation?.adopt(renderer); + start(); + return renderer; } diff --git a/apps/cli/src/run-lazy-boundary.test.ts b/apps/cli/src/run-lazy-boundary.test.ts index 7fec0b09..59152798 100644 --- a/apps/cli/src/run-lazy-boundary.test.ts +++ b/apps/cli/src/run-lazy-boundary.test.ts @@ -38,6 +38,10 @@ function fixture(interactive = false) { writeErr: (text) => { err += text; }, + writeOutAcknowledged: (text) => { + out += text; + return Promise.resolve(); + }, writeErrAcknowledged: (text) => { err += text; return Promise.resolve(); @@ -50,6 +54,7 @@ function fixture(interactive = false) { const base = createCliInvocation(); const invocation: CliInvocation = Object.freeze({ registry: base.registry, + finiteExit: base.finiteExit, seedFileCatalogOnce: () => { state.trace.push('seed'); return Promise.resolve(); diff --git a/apps/cli/src/test-support.ts b/apps/cli/src/test-support.ts index 3e741952..11f9f3f5 100644 --- a/apps/cli/src/test-support.ts +++ b/apps/cli/src/test-support.ts @@ -66,6 +66,10 @@ export function captureIo(env: Readonly> = {} writeErr: (text) => { errChunks.push(text); }, + writeOutAcknowledged: (text) => { + outChunks.push(text); + return Promise.resolve(); + }, writeErrAcknowledged: (text) => { errChunks.push(text); return Promise.resolve(); diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index f010177b..52df699e 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -250,3 +250,17 @@ evidence remain attributed to `ab50be11` with independently verified equivalence Creation-owned native process integration is accepted; workflow/terminal owners in 8c and all-surface fatal activation in 8d remain open. This note adds no new decision and does not claim whole-Step-8 or whole-Wave-3 completion. + +## Step 8c partial implementation — 2026-10-16 + +Workflow/preparation/driver and terminal ownership are implemented on +`development`; author and independent acceptance gates remain open. The +[canonical workflow ownership contract](../reference/cli/commands.md#workflow-and-terminal-ownership) +owns first-fault escalation, captured input/output, actual release and +return/throw precedence. The +[implementation checkpoint](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8c-implementation-checkpoint--2026-10-16) +separates ordinary regressions, native fixture evidence and pending cumulative +reviews. Earlier Step 1 best-effort close warnings never granted retirement from +a close throw; this integration retains unknown close obligations without +rewriting durable workflow results. Global fatal installation and session owners +remain 8d. No new decision, dependency, release or whole-step acceptance is added. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index efa20e5b..21391e44 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -894,9 +894,54 @@ it cannot launch a new async helper; a returned promise may only observe work already started. Joins, restoration and reap helpers do not start after expiry. This attempt-all cancellation rule grants no retirement or safe-close ACK. +### Workflow and terminal ownership + +**Step 8c implementation on `development`; independent acceptance pending.** +Workflow `run`, human-gate resume and budget resume reserve their workflow and +terminal owners before asynchronous preparation. One signal subscription covers +secret input, consent, MCP discovery, engine construction, execution and actual +cleanup. First SIGINT requests cooperative cancellation; a second SIGINT or +TERM/HUP/QUIT uses the invocation's single finite-exit capability and unchanged +absolute budget. A late engine/handle is adopted before further work and drains +without acquiring new input after stop. A late consent answer cannot append a +grant or enter secrets, MCP or business work. + +The driver keeps one primary reader across prompts. Actual departure health can +progress while input is held; a rejected reader is never positive primary done. +Publish actual money/effect/terminal health before rendering, finalization and +maintenance can throw. Typed ownership-loss diagnostics retain their existing +remedy. Stronger actual health wins over an opaque CLI fault; a competing +infrastructure fault wins over ordinary success or invocation refusal. No timer, +cancel request or iterator rejection grants permission to close history. + +Native prompt and Ink acquisition uses the invocation's captured input and +stdout. A partially allocating renderer or job-control constructor transfers +cleanup custody before native entry. Permanent stop prevents remount, redraw and +SIGCONT reclaim, then independently attempts actual prompt/input/listener release +and acknowledged cursor restoration. Prompt rejection or a missing Ink handle +after a failed mount is not a release ACK. A remount already entered before stop +must settle and release its final native state. JSON/CI/plain output acquires no +TTY job-control or cursor custody merely because stdout has a TTY flag. + +MCP manager close failures can arrive through its callback even when `close()` +fulfills. They retain the MCP/workflow obligation and keep DB close behind actual +cleanup. An opaque database-close throw also supplies no release ACK; already +durable workflow results remain intact while the CLI records its lifetime fault. +Secondary warnings remain fixed and value-free. Known operational errors retain +their classifier; unknown cleanup failures do not silently become success. + +The first owned infrastructure fault starts the common three-second backstop +immediately, including when cleanup holds the rejection from the outer catch. +The staged workflow fallback permits that catch to render after actual cleanup; +infrastructure does not use its signal-only early-exit continuation. A later +native handoff keeps the same observer, deadline and physical-exit latch and +invalidates queued fallback completion. This claims no diagnostic delivery ACK. +Global fatal/background/sink installation, session owners and final acknowledged +diagnostic ordering remain Step 8d. Whole Step 8 is still open. + ### Native crash teardown scope -**Step 8b implementation; independent acceptance pending.** Actual process, +**Step 8b independently accepted on `development`.** Actual process, OS-helper, editor and stdio MCP creation paths now receive the original invocation's native controller through run/resume, standalone chat, one-shot agent and Home builders, including pending/replaced builds. Construction is inert. Each actual @@ -908,8 +953,8 @@ the already-captured MCP consent gate. One removable invocation-bound exit callback iterates the existing registry's active native capabilities. There is no completed PID/group history or independent -product MCP reaper collection. The compatibility signal guard retains its existing -cooperative/grace/outcome routing until 8c/8d; connecting native creation does not +product MCP reaper collection. Workflow routing follows the ownership section +above; session compatibility signal guards remain staged until 8d. Connecting native creation does not activate the complete fatal coordinator. Source fixtures explicitly install that foundation when testing fatal cleanup. Workflow/session/terminal retirement and safe DB close remain their separate integration obligations. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index bf9ac139..91b12368 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1139,7 +1139,8 @@ with remote totals attributed separately. [Step 8b is independently accepted](ph at `e16a7659` after two NEW complete cumulative clean rounds and a separate clean security sitting following the canonical-reference correction. Full CI and all four required same-tarball consumers pass at `ab50be11`; independent equivalence -checks preserve that execution attribution. Workflow/terminal integration in 8c, +checks preserve that execution attribution. [Step 8c workflow/terminal integration is being verified](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8c-implementation-checkpoint--2026-10-16); +its author, cumulative-review and security gates remain open. Workflow/terminal acceptance in 8c, product fatal activation in 8d, whole Step 8 and Steps 9–19 remain open. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 0e198486..987818ba 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -5841,3 +5841,70 @@ universal descendant containment. Global native activation is still inert. lifetimes; 8d must connect the remaining surfaces and all caught/fatal exits. Steps 9–19 and whole-wave acceptance remain staged. No new ADR, provider capture, credential, billing or user decision is needed. Step 8c proceeds automatically. + +### Wave 3 Step 8c implementation checkpoint — 2026-10-16 + +**Author verification in progress; not independently accepted.** The approved +workflow/render increment connects `run`, human-gate resume and budget resume to +the original invocation's pre-registered workflow and terminal owners. The +[canonical CLI contract](../../reference/cli/commands.md#workflow-and-terminal-ownership) +owns exact cancellation, health precedence, native acquisition and cleanup +semantics. Product-wide fatal/background/sink activation and session lifetimes +remain 8d; no whole-Step-8 or whole-wave completion is implied. + +The command subscription precedes secret stdin, consent, discovery and engine +construction. An identity-owned stop checkpoint refuses later service/business +entry, while an unrelated failure racing cancellation keeps its own classifier. +Late handles enter the same primary driver without new UI ownership. Actual +primary done and actual non-continue departure stay distinct; available receipt +health progresses even while a prompt holds input. Stronger actual health +survives render/finalize/maintenance errors and typed ownership-loss remedies are +preserved. Neither rejected iteration nor a cleanup timer authorizes DB close. + +Terminal constructors pre-adopt actual native cleanup capabilities. Missing +handles after failed mount, rejected prompts and incomplete registrations retain +their obligation. A permanent seal blocks remount, redraw and job-control reclaim +before independent input/listener/cursor restoration. All deferred and post-await +restoration entries recheck the original remaining budget. Captured input/stdout +are passed to installed Ink/Clack; the selected CLI mode explicitly controls Ink's +interactive mode. JSON on a TTY remains native-free and contains no restoration +control bytes. + +The first owned infrastructure fault now starts the same finite backstop before +cleanup can hold it from the root catch. Signal and later native finalization +share one observer/deadline/physical-exit latch. The staged infrastructure fallback +permits outer diagnosis after actual cleanup; it claims no diagnostic delivery +ACK. MCP callback-reported close failures retain the obligation even when its +manager's `close()` fulfills. An unknown database-close throw supplies no ACK. +Earlier Step 1 warning tests now distinguish preserved durable workflow events +from CLI lifetime success; this is the approved later process integration, not a +new deferral or decision. + +Fresh focused execution includes actual engine/SQLite health regressions, native +Node processes around a real MCP manager with synthetic connection faults, and +installed Ink/Clack bound to handle-free owned TTY fixtures. The native cleanup +fixture joins actual process/pipe close, requires DB-close count zero while its +obligation is held and rejects external-watchdog cleanup. Its local POSIX leg +delivers real SIGTERM; the Windows source leg explicitly uses the registered +callback because Node does not deliver that POSIX signal there. No actual Windows +execution is inferred. No provider, keychain, editor or interactive operator +terminal is used. + +A narrow read-only mechanics advisor found preparation, release, callback and +first-fault ordering gaps; verified fixes have permanent regressions. That advice +is **not** either complete cumulative review or the separate security sitting. +Both NEW complete cumulative clean rounds must still cover the entire Step 8 +range from `d9625ff0a641197c6ed45968df99094a25db88d6`, after the final material fix. +Fresh author lint/typecheck and workspace tests pass: the CLI executes 298 files / +5,108 passing cases / 10 established skips; unchanged workspace results retain +their cache attribution. Build and format pass. The compiled CLI smoke also +passes, including actual MCP-child reaping and SIGINT during budget-resume +connection. Its first run exposed a genuine typed MCP-cancellation classification +regression; the fix and permanent regression distinguish owned cancellation from +a competing opaque failure. The independent ordinary causal inventory is eight +complete files / 125 passing cases, without skips. None of these author runs is +an independent review. Fresh causal, package/platform and independent review +gates remain open at this checkpoint. Historical 8b captures and platform +receipts retain their original source; none is relabelled as execution of these +changed runtime bytes. No new ADR, credential, billing, provider capture or user +decision is pending. diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index 951d4b28..458f8d0f 100644 --- a/tools/secrets-controls/README.md +++ b/tools/secrets-controls/README.md @@ -68,7 +68,7 @@ The eleven Step 4 controls remove separate production mechanisms: The Step 4 declarations require 51 additional named assertion witnesses. Every selected workload remains a complete test file: 33 input cases, 14 action-forwarding -cases, 32 dispatch cases, 79 run-command cases, three run-consent cases, 68 gate cases, +cases, 32 dispatch cases, 80 run-command cases, three run-consent cases, 68 gate cases, 28 top-level parser cases, or 131 native integration cases, as declared per control. The coordinator independently enforces exact case counts, unique complete case identities and identical fixed/removal/restored inventories. It accepts only a @@ -121,7 +121,7 @@ permanent negative cases; no notice is produced before a successful export. Four snapshot controls remove the finite scrubber detection site and the actual `create`, `import`, and `run` warning calls. Each has a named value-free assertion in -its complete 10-case detector, 11-case create, 11-case import or 79-case run file. +its complete 10-case detector, 11-case create, 11-case import or 80-case run file. The adjacent tests check preserved authored files and admitted run snapshots, JSON stdout, and scan-budget/incomplete notices. These controls certify warning delivery for the planted supported shapes at these entry points, not universal secret diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index aaedfbdf..cdb9b456 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -347,7 +347,7 @@ export const mutations = [ ' const raw = await (deps.readSecretInput?.() ?? readSecretFromStdin(RUN_SECRET_STDIN_CONTEXT));', " const raw = await (deps.readSecretInput?.() ?? Promise.resolve(''));", ['apps/cli/src/commands/run.test.ts'], - 79, + 80, 'uses the real default reader exactly once and suppresses even an injected gate selector', 'SECRETS_CAUSAL_RUN_DEFAULT_READER', ), @@ -581,7 +581,7 @@ export const mutations = [ ' warnSnapshotLiterals(def, deps.io);\n', '', ['apps/cli/src/commands/run.test.ts'], - 79, + 80, 'warns after admission before services and preserves the actual augmented frozen workflow', 'SNAPSHOT_CAUSAL_RUN_CALLER', ), From dbab25d85a2c4ae97c252cdaaf47921648e245af Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 20:03:22 +0300 Subject: [PATCH 101/222] fix(cli): close autonomous workflow shutdown gaps Activate the single finite-exit observer before workflow owners enter, without starting a timer for normal work or exiting for ordinary cleanup. Refuse new cooperative MCP close after common expiry while preserving finite owned cancellation and actual native-close uncertainty. Make callback removal fail at its real premature-settlement witness and keep intentional unit faults off the test worker's physical exit port. Correct the stale MCP acceptance qualifier and record nonclean reviews. Workspace lint/typecheck/tests, build, format and compiled CLI smoke pass; new complete-cohort ordinary controls pass171 cases without skips. Fresh causal/platform gates and two NEW full cumulative clean reviews plus a NEW security sitting remain required before acceptance. Refs: ADR-0110, Wave 3 Step 8c Co-Authored-By: Codex --- apps/cli/src/commands/gate.test.ts | 1 + .../commands/workflow-cleanup-native.test.ts | 17 ++++- .../src/commands/workflow-lifetime.test.ts | 75 +++++++++++++++++++ apps/cli/src/commands/workflow-lifetime.ts | 1 + .../process/invocation-finite-exit.test.ts | 14 ++++ .../cli/src/process/invocation-finite-exit.ts | 32 +++++--- .../src/process/native-process-owner.test.ts | 46 ++++++++++++ apps/cli/src/process/native-process-owner.ts | 6 +- ...d-discovery-have-explicit-compatibility.md | 14 ++++ docs/reference/cli/commands.md | 6 +- docs/reference/shared-core/mcp-integration.md | 2 +- .../phase-2.5.5-hardening-and-remediation.md | 34 +++++++++ 12 files changed, 232 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index 7fb2f1e0..852f1663 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -246,6 +246,7 @@ describe('gateCommand', () => { { io: captureIo().io, global: globalOptions(), + invocation: createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }), openDb: () => { throw cause; }, diff --git a/apps/cli/src/commands/workflow-cleanup-native.test.ts b/apps/cli/src/commands/workflow-cleanup-native.test.ts index f9915f86..264a8fbc 100644 --- a/apps/cli/src/commands/workflow-cleanup-native.test.ts +++ b/apps/cli/src/commands/workflow-cleanup-native.test.ts @@ -36,6 +36,10 @@ describe('actual workflow cleanup failures behind the shared physical signal dea const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( (resolve) => child.once('close', (code, signal) => resolve({ code, signal })), ); + let reportPremature = (): void => undefined; + const premature = new Promise((resolve) => { + reportPremature = resolve; + }); const messages: string[] = []; child.on('message', (message: unknown) => { const type: unknown = @@ -44,6 +48,7 @@ describe('actual workflow cleanup failures behind the shared physical signal dea : undefined; if (typeof type !== 'string') return; messages.push(type); + if (type === 'unexpected-settlement') reportPremature(); if (type === 'ready') child.send({ type: 'go' }); if (type === 'cleanup-entered') { // Windows does not deliver POSIX SIGTERM: enter the exact registered signal callback there. @@ -58,12 +63,20 @@ describe('actual workflow cleanup failures behind the shared physical signal dea child.kill('SIGKILL'); }, 9000); try { - const exit = await closed; + const result = await Promise.race([ + closed.then((exit) => ({ kind: 'closed' as const, exit })), + premature.then(() => ({ kind: 'premature' as const })), + ]); expect(watchdogFired, stderr).toBe(false); expect(messages.slice(0, 2), stderr).toEqual(['ready', 'cleanup-entered']); expect(stdout).toBe(''); expect(stderr).toBe(''); // Runtime/setup/native-child contamination cannot supply the semantic witness. - expect(exit, 'WORKFLOW_NATIVE_CLOSE_ACK').toEqual({ code: 9, signal: null }); + // Premature command settlement is the semantic failure itself. The finally below joins + // this already-owned fixture; its watchdog/kill can never supply the acceptance witness. + expect(result, 'WORKFLOW_NATIVE_CLOSE_ACK').toEqual({ + kind: 'closed', + exit: { code: 9, signal: null }, + }); expect(messages, stderr).toEqual(['ready', 'cleanup-entered']); const witness: unknown = JSON.parse(readFileSync(join(root, 'exit.json'), 'utf8')); expect(witness).toMatchObject({ diff --git a/apps/cli/src/commands/workflow-lifetime.test.ts b/apps/cli/src/commands/workflow-lifetime.test.ts index ff10bc2a..7794b623 100644 --- a/apps/cli/src/commands/workflow-lifetime.test.ts +++ b/apps/cli/src/commands/workflow-lifetime.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; +import { ChildProcess } from 'node:child_process'; import { captureIo } from '../test-support.js'; import { createCliInvocation } from '../process/invocation.js'; import { CliError } from '../process/cli-error.js'; import { WorkflowLifetime } from './workflow-lifetime.js'; +import { InvocationRegistry } from '../process/invocation-registry.js'; +import { createInvocationFiniteExit } from '../process/invocation-finite-exit.js'; +import { createNativeProcessController } from '../process/native-process-owner.js'; function fixture() { const captured = captureIo(); @@ -17,6 +21,77 @@ function fixture() { } describe('workflow command lifetime and bounded shared observations', () => { + it('physically bounds an autonomous native helper fault without a later workflow failure or signal', async () => { + let now = 0; + const timers = new Map<() => void, number>(); + const clock = { + now: () => now, + schedule: (ms: number, fire: () => void) => { + timers.set(fire, now + ms); + return () => { + timers.delete(fire); + }; + }, + }; + const advance = (time: number): void => { + now = time; + for (const [fire, at] of [...timers]) if (at <= now && timers.delete(fire)) fire(); + }; + const registry = new InvocationRegistry(clock); + const exit = vi.fn(); + const finiteExit = createInvocationFiniteExit(registry, clock, { exit, setExitCode: vi.fn() }); + const lifetime = new WorkflowLifetime( + { registry, finiteExit, seedFileCatalogOnce: () => Promise.resolve() }, + captureIo().io, + () => () => undefined, + ); + let releaseBody: (code: 0) => void = () => undefined; + const body = new Promise<0>((resolve) => { + releaseBody = resolve; + }); + let settled = false; + const work = lifetime.execute(() => body); + void work.then(() => { + settled = true; + }); + const target = new ChildProcess(); + Object.defineProperty(target, 'pid', { value: 70 }); + vi.spyOn(target, 'kill').mockReturnValue(true); + const helper = new ChildProcess(); + vi.spyOn(helper, 'kill').mockReturnValue(true); + const controller = createNativeProcessController(registry, { + platform: 'win32', + now: clock.now, + schedule: clock.schedule, + signal: () => undefined, + verifyWindows: () => Promise.resolve({ spawn: () => helper }), + subscribeExit: () => () => undefined, + }); + const native = await controller.reserve('pid'); + native.bindChild(target); + void native.stop(); + advance(500); + helper.emit('error', new Error('PRIVATE_AUTONOMOUS_HELPER_FAULT')); + expect(lifetime.signal.aborted).toBe(true); + expect(registry.budget.deadline()).toBe(3500); + advance(3499); + await new Promise((resolve) => setImmediate(resolve)); + expect(exit).not.toHaveBeenCalled(); + expect(settled).toBe(false); + advance(3500); + await new Promise((resolve) => setImmediate(resolve)); + expect(exit, 'WORKFLOW_AUTONOMOUS_NATIVE_EXIT').toHaveBeenCalledExactlyOnceWith(9); + expect(registry.budget.deadline()).toBe(3500); + expect(registry.activeOwners).toBeGreaterThanOrEqual(2); + expect(settled).toBe(false); + target.emit('close', null); + helper.emit('close', 0); + await native.whenClosed; + releaseBody(0); + expect(await work).toBe(9); + expect(registry.activeOwners).toBe(0); + expect(exit).toHaveBeenCalledOnce(); + }); it('reserves both owners before the first preparation, and retires them after actual body/terminal cleanup', async () => { const f = fixture(); expect(f.invocation.registry.activeOwners).toBe(2); diff --git a/apps/cli/src/commands/workflow-lifetime.ts b/apps/cli/src/commands/workflow-lifetime.ts index 4050bb9f..7349d1fc 100644 --- a/apps/cli/src/commands/workflow-lifetime.ts +++ b/apps/cli/src/commands/workflow-lifetime.ts @@ -37,6 +37,7 @@ export class WorkflowLifetime { ) { Object.freeze(this.#stoppedMarker); this.invocation = invocation ?? createCliInvocation(); + this.invocation.finiteExit.activateFallback(); this.#retired = new Promise((resolve) => { this.#resolveRetired = resolve; }); diff --git a/apps/cli/src/process/invocation-finite-exit.test.ts b/apps/cli/src/process/invocation-finite-exit.test.ts index 798935a6..55574b86 100644 --- a/apps/cli/src/process/invocation-finite-exit.test.ts +++ b/apps/cli/src/process/invocation-finite-exit.test.ts @@ -101,6 +101,20 @@ describe('invocation finite exit and explicit native handoff', () => { remove(); expect(f.exit).not.toHaveBeenCalled(); }); + it('explicit fallback activation stays timer-free and never exits for ordinary termination', async () => { + const f = fixture(); + f.finite.activateFallback(); + f.finite.activateFallback(); + expect(f.clock.timers.size).toBe(0); + expect(f.registry.termination).toBeUndefined(); + await f.registry.terminate({ kind: 'normal' }); + await turn(); + expect(f.exit, 'WORKFLOW_ORDINARY_NO_EXIT').not.toHaveBeenCalled(); + expect(f.clock.timers.size).toBe(0); + f.finite.signal(15); + expect(await f.finite.exited).toBe(143); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(143); + }); it.each([2, 15, 1, 3])( 'physically bounds signal %s without acknowledging a held owner', async (signal) => { diff --git a/apps/cli/src/process/invocation-finite-exit.ts b/apps/cli/src/process/invocation-finite-exit.ts index 589f882d..0c447b48 100644 --- a/apps/cli/src/process/invocation-finite-exit.ts +++ b/apps/cli/src/process/invocation-finite-exit.ts @@ -13,6 +13,8 @@ export interface FiniteExitPorts { /** One explicitly activated exit authority, shared by workflow signals and the later native adapter. */ export interface InvocationFiniteExit { + /** A real command activates the sole observer before native owners can fail autonomously. */ + readonly activateFallback: () => void; readonly fail: (code: 'cli_internal' | 'output_unavailable') => void; readonly signal: (signalNumber: number) => void; readonly handoff: (registry: InvocationRegistry, changed: () => void) => () => void; @@ -89,7 +91,14 @@ export function createInvocationFiniteExit( return; } const termination = registry.termination; - if (mode !== 'fallback' || following || termination === undefined) return; + if ( + mode !== 'fallback' || + following || + termination === undefined || + (!registry.observations.hasInfrastructureFailure() && + !registry.observations.hasTerminatingSignal()) + ) + return; following = true; const observedGeneration = generation; void termination.then(() => { @@ -97,6 +106,7 @@ export function createInvocationFiniteExit( if ( mode === 'fallback' && generation === observedGeneration && + registry.observations.hasTerminatingSignal() && !registry.observations.hasInfrastructureFailure() ) exitNow(); @@ -107,16 +117,20 @@ export function createInvocationFiniteExit( observing = true; removeObserver = registry.observeTermination(changed); }; + const activateFallback = (): void => { + if (exited || mode === 'disposed') throw new CliError('cli_internal', ''); + if (mode !== 'idle') return; // A native handoff keeps the same observer and exit authority. + mode = 'fallback'; + generation += 1; + observe(); + }; return Object.freeze({ + activateFallback, fail: (code: 'cli_internal' | 'output_unavailable') => { if (code !== 'cli_internal' && code !== 'output_unavailable') throw new CliError('cli_internal', ''); if (exited || mode === 'disposed') return; - if (mode === 'idle') { - mode = 'fallback'; - generation += 1; - observe(); - } + activateFallback(); // Fast cleanup leaves outer diagnostic rendering time; only the unchanged absolute backstop // exits this staged fallback. The later native handoff owns positive diagnostic ACK/early exit. void registry.terminate({ kind: 'infrastructure', code }); @@ -124,11 +138,7 @@ export function createInvocationFiniteExit( signal: (signalNumber: number) => { assertTerminatingSignalNumber(signalNumber); if (exited || mode === 'disposed') return; - if (mode === 'idle') { - mode = 'fallback'; - generation += 1; - observe(); - } + activateFallback(); let deadline: number; try { deadline = clock.now() + FATAL_TERMINATION_MS; diff --git a/apps/cli/src/process/native-process-owner.test.ts b/apps/cli/src/process/native-process-owner.test.ts index b05f71a1..1224e355 100644 --- a/apps/cli/src/process/native-process-owner.test.ts +++ b/apps/cli/src/process/native-process-owner.test.ts @@ -496,6 +496,52 @@ describe('creation-owned native processes', () => { await vi.waitFor(() => expect(h.registry.activeOwners).toBe(0)); }); + it.each( + (['linux', 'win32'] as const).flatMap((platform) => + (['attached', 'late-attach', 'liveness-reentry'] as const).map((boundary) => ({ + platform, + boundary, + })), + ), + )( + 'keeps finite cancellation without a fresh MCP close after expiry on $platform at $boundary', + async ({ platform, boundary }) => { + const h = harness(platform); + const observer = await h.controller.observeMcp(); + const nativeClosed = deferred(); + let closed = false; + const close = vi.fn(() => Promise.resolve()); + const lifetime = { + readPid: () => 70, + isClosed: () => closed, + whenClosed: nativeClosed.promise, + close, + }; + if (boundary !== 'late-attach') observer.attach(lifetime); + if (boundary === 'liveness-reentry') + h.signal.mockImplementation((_pid, signal) => { + if (signal === 0) + void h.registry.terminate({ kind: 'signal', signalNumber: 15, deadline: 0 }); + }); + const termination = h.registry.terminate( + boundary === 'liveness-reentry' + ? { kind: 'normal' } + : { kind: 'signal', signalNumber: 15, deadline: 0 }, + ); + if (boundary === 'late-attach') observer.attach(lifetime); + expect(close, 'NATIVE_COOPERATIVE_BUDGET').not.toHaveBeenCalled(); + expect(h.registry.budget.remainingMs()).toBe(0); + expect(h.request).not.toHaveBeenCalled(); + expect(h.signal).toHaveBeenCalledWith(70, 'SIGKILL'); + expect(h.signal.mock.calls.every(([pid]) => pid > 0)).toBe(true); + expect(await termination).toMatchObject({ kind: 'deadline', unacknowledgedOwners: 1 }); + expect(h.registry.activeOwners).toBe(1); + closed = true; + nativeClosed.resolve(); + await vi.waitFor(() => expect(h.registry.activeOwners).toBe(0)); + }, + ); + it('releases a bridge refusal before SDK attachment without losing an attached native obligation', async () => { const h = harness(); const observer = await h.controller.observeMcp(); diff --git a/apps/cli/src/process/native-process-owner.ts b/apps/cli/src/process/native-process-owner.ts index 7d5e077d..7899d672 100644 --- a/apps/cli/src/process/native-process-owner.ts +++ b/apps/cli/src/process/native-process-owner.ts @@ -318,7 +318,11 @@ class NativeCreation { else if (this.#kind === 'group') this.#signalGroup(); else this.#signalPid(); } - if (!this.#cooperativeEntered && this.#target.cooperativeClose !== undefined) { + if ( + !this.#cooperativeEntered && + this.#target.cooperativeClose !== undefined && + this.#remaining() !== 0 + ) { this.#cooperativeEntered = true; try { void this.#target.cooperativeClose().catch(() => this.#failed()); diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index 52df699e..4df4dd92 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -264,3 +264,17 @@ reviews. Earlier Step 1 best-effort close warnings never granted retirement from a close throw; this integration retains unknown close obligations without rewriting durable workflow results. Global fatal installation and session owners remain 8d. No new decision, dependency, release or whole-step acceptance is added. + +## Step 8c first cumulative review corrections — 2026-10-16 + +The [correction register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8c-first-cumulative-review-corrections--2026-10-16) +records two verified runtime gaps in the first implementation: MCP cooperative +close entered after common expiry, and an autonomous native fault lacked the +workflow's physical-exit observer. The corrections fulfil the approved +[workflow ownership contract](../reference/cli/commands.md#workflow-and-terminal-ownership), +preserve inert construction, actual cleanup uncertainty and the single native +handoff, and add no new policy. Test-witness/exit-port and canonical-status +corrections remain separately attributed. The old NONCLEAN audits and rejected +capture grant no acceptance; two NEW complete cumulative clean reviews and a NEW +clean security sitting are required after the final correction. Step 8d remains +staged. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 21391e44..7a102608 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -898,7 +898,11 @@ This attempt-all cancellation rule grants no retirement or safe-close ACK. **Step 8c implementation on `development`; independent acceptance pending.** Workflow `run`, human-gate resume and budget resume reserve their workflow and -terminal owners before asynchronous preparation. One signal subscription covers +terminal owners before asynchronous preparation. The command explicitly activates +the existing single fallback observer before native creation; an autonomous native +fault needs no later business rejection or signal to start physical termination. +Activation alone starts no timer, and ordinary retirement grants no physical-exit +authority. One signal subscription covers secret input, consent, MCP discovery, engine construction, execution and actual cleanup. First SIGINT requests cooperative cancellation; a second SIGINT or TERM/HUP/QUIT uses the invocation's single finite-exit capability and unchanged diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index ed7e0735..d7f74140 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -168,7 +168,7 @@ per supplied reporter; its default remains best-effort reporting, not semantic r ### Creation-owned native stdio lifetime -**Wave 3 Step 8b implementation; independent acceptance pending.** +**Wave 3 Step 8b independently accepted on `development`.** `StdioServerSpec.nativeObserver` is an optional trusted, SDK-free host projection. Its synchronous `attach` receives a creation-latched PID reader, known-close reader, actual native `whenClosed` promise and the existing idempotent full-owner diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 987818ba..9e7c1b15 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -5908,3 +5908,37 @@ gates remain open at this checkpoint. Historical 8b captures and platform receipts retain their original source; none is relabelled as execution of these changed runtime bytes. No new ADR, credential, billing, provider capture or user decision is pending. + +### Wave 3 Step 8c first cumulative review corrections — 2026-10-16 + +**Not accepted; new complete cumulative reviews required after the corrections.** +The first complete cumulative correctness and separate security audits cover +`d9625ff0a641197c6ed45968df99094a25db88d6` through `9e4cac77`: 164 paths / +770 zero-context hunks / 54 whole added files. Their NONCLEAN findings are +independently verified; neither audit is counted as clean implementation acceptance. + +| Verified concern | Correction and evidence boundary | +|---|---| +| MCP asynchronous close at zero common budget | Recheck the live remaining budget immediately before first cooperative close. Keep finite owned PID cancellation and retain actual native-close uncertainty. Six separate Linux/Windows protocol cases cover attached, late-attach and liveness-reentrant expiry; simulated ports claim no actual Windows execution. | +| Autonomous native fault lacks workflow physical exit | Workflow creation explicitly activates the same fallback observer before owner/native entry. Activation alone starts no timer; ordinary termination cannot exit. An actual WorkflowLifetime with simulated Windows native handles pins autonomous helper failure, the original deadline, physical exit 9 exactly once and retained obligations until actual closure. Global/session activation remains 8d. | +| Callback removal reaches watchdog before its semantic witness | The native test observes actual premature command-settlement IPC alongside actual child close, validates readiness/stdio before the named assertion, then joins its owned fixture in finally. The watchdog cannot witness acceptance. Both run/gate callback removal controls require fresh complete-cohort execution. | +| Intentional gate unit fault uses real exit ports | Inject explicit fake physical-exit ports for the opaque opener-failure unit case. Actual child-process exit controls stay intact. Slower CI exposed the old fixture via one unhandled process.exit(9); the red coverage/Node-floor jobs remain attributed to `9e4cac77`. | +| Canonical MCP reference calls accepted 8b pending | Promote the stale current-status qualifier to accepted 8b; no security contract or 8c/8d acceptance changes. | + +The rejected first causal capture retains eight completed mechanisms and the +failed ninth removal: the coordinator correctly refuses a missing semantic +marker, restores bytes and removes the copied graph. Overall acceptance remains +false. It is not relabelled as final corrected-source evidence. The first +clean-source freeze and its local records remain immutable. + +Focused correction execution passes 129 cases across five complete files. Fresh +workspace checks pass, including the CLI's 298 files / 5,116 passing cases / +10 established skips; unchanged workspaces retain cache attribution. Final +compiled/package/platform, corrected causal and NEW independent review gates +remain open. The first source's four same-tarball platform consumers pass and +all 71 members are independently verified, but its overall CI is red and none of +that execution is transferred to these changed runtime bytes. + +These corrections implement the approved deadline/ownership contract without a +new decision, dependency or deferral. Source and evidence scopes remain explicit; +whole Step 8, 8d and Steps 9–19 are still open. No user action is pending. From 50d9891e1d72d40014c034c9ac1b9732108dd8af Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 21:14:54 +0300 Subject: [PATCH 102/222] docs(docs): accept wave 3 workflow and terminal integration Record two new clean cumulative reviews, a separate security sitting, corrected causal controls and all 15 successful CI jobs at dbab25d8. Preserve exact platform and raw-capture limits; session integration and all-surface fatal activation remain Step 8d. Refs: ADR-0110, Wave 3 Step 8c Co-Authored-By: Codex --- AGENTS.md | 9 ++- CLAUDE.md | 9 ++- ...d-discovery-have-explicit-compatibility.md | 13 +++ docs/reference/cli/commands.md | 4 +- docs/roadmap/current.md | 8 +- .../phase-2.5.5-hardening-and-remediation.md | 81 +++++++++++++++++++ docs/standards/security-review.md | 33 ++++++++ 7 files changed, 145 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 53c44703..63d9c895 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,10 +102,11 @@ Step 8b (creation-owned native processes) is independently accepted at security sitting following its canonical-reference correction. Its unchanged runtime/control evidence and successful full CI plus all four same-tarball platform consumers remain attributed to `ab50be11`, with independently verified -source/dependency equivalence. Whole Step 8, workflow/terminal integration in 8c -and product fatal activation in 8d remain open. -Step 8c workflow/preparation/terminal integration is under author verification; -no independent acceptance is inferred from its focused tests. +source/dependency equivalence. Step 8c workflow/preparation/terminal integration +is independently accepted at `dbab25d8` after two NEW complete cumulative clean +rounds, a separate clean security sitting and successful full CI. All four +same-tarball platform consumers pass at that reviewed source. Whole Step 8, +session/terminal integration and product fatal activation in 8d remain open. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index 09543c89..6c5ba512 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -191,10 +191,11 @@ Step 8b (creation-owned native processes) is independently accepted at security sitting following its canonical-reference correction. Its unchanged runtime/control evidence and successful full CI plus all four same-tarball platform consumers remain attributed to `ab50be11`, with independently verified -source/dependency equivalence. Whole Step 8, workflow/terminal integration in 8c -and product fatal activation in 8d remain open. -Step 8c workflow/preparation/terminal integration is under author verification; -no independent acceptance is inferred from its focused tests. +source/dependency equivalence. Step 8c workflow/preparation/terminal integration +is independently accepted at `dbab25d8` after two NEW complete cumulative clean +rounds, a separate clean security sitting and successful full CI. All four +same-tarball platform consumers pass at that reviewed source. Whole Step 8, +session/terminal integration and product fatal activation in 8d remain open. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index 4df4dd92..c9996237 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -278,3 +278,16 @@ corrections remain separately attributed. The old NONCLEAN audits and rejected capture grant no acceptance; two NEW complete cumulative clean reviews and a NEW clean security sitting are required after the final correction. Step 8d remains staged. + +## Step 8c independent acceptance — 2026-10-16 + +The [Step 8c acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8c-acceptance--2026-10-16) +records two NEW complete cumulative clean correctness rounds and a NEW separate +clean security sitting after the verified first-round corrections. All cover +`d9625ff0` through `dbab25d8`. Corrected causal controls, full CI and all four +same-tarball platform consumers pass at that exact reviewed source; the register +preserves their distinct execution and actual-platform limits. Workflow, +preparation, driver and terminal ownership are independently accepted for 8c. +Session lifetimes, final diagnostic acknowledgement and all-surface activation +remain 8d. Whole Step 8 and later Wave 3 work remain open; no new decision, +dependency or deferral is added. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 7a102608..217478a3 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -896,7 +896,9 @@ This attempt-all cancellation rule grants no retirement or safe-close ACK. ### Workflow and terminal ownership -**Step 8c implementation on `development`; independent acceptance pending.** +**Step 8c independently accepted on `development`.** The +[acceptance register](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8c-acceptance--2026-10-16) +records exact reviewed-source, causal, CI and platform evidence. Workflow `run`, human-gate resume and budget resume reserve their workflow and terminal owners before asynchronous preparation. The command explicitly activates the existing single fallback observer before native creation; an autonomous native diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 91b12368..bef61597 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1139,9 +1139,11 @@ with remote totals attributed separately. [Step 8b is independently accepted](ph at `e16a7659` after two NEW complete cumulative clean rounds and a separate clean security sitting following the canonical-reference correction. Full CI and all four required same-tarball consumers pass at `ab50be11`; independent equivalence -checks preserve that execution attribution. [Step 8c workflow/terminal integration is being verified](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8c-implementation-checkpoint--2026-10-16); -its author, cumulative-review and security gates remain open. Workflow/terminal acceptance in 8c, -product fatal activation in 8d, whole Step 8 and Steps 9–19 remain open. The +checks preserve that execution attribution. [Step 8c workflow/terminal integration is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8c-acceptance--2026-10-16) +at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean +security sitting and successful full CI plus all four same-tarball platform +consumers. Step 8d session/terminal integration and product fatal activation, +whole Step 8 and Steps 9–19 remain open. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 9e7c1b15..0f650086 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -5942,3 +5942,84 @@ that execution is transferred to these changed runtime bytes. These corrections implement the approved deadline/ownership contract without a new decision, dependency or deferral. Source and evidence scopes remain explicit; whole Step 8, 8d and Steps 9–19 are still open. No user action is pending. + +## Wave 3 Step 8c acceptance — 2026-10-16 + +**Step 8c is independently accepted on `development` at reviewed source +`dbab25d85a2c4ae97c252cdaaf47921648e245af`.** Following the verified first-round +corrections, two NEW complete cumulative correctness rounds and a NEW separate +security sitting are clean. Each covers the mandatory whole-Step-7 base +`d9625ff0a641197c6ed45968df99094a25db88d6` through that source: **164 paths / +770 zero-context hunks / 54 whole added files**. Detailed reports remain local +and Git-ignored; their final report digests are recorded below. + +| Sitting | Model / effort | Fresh execution | Report SHA-256 | +|---|---|---|---| +| New cumulative correctness round 1 | GPT-6 Astra / xhigh | 65 files / 1,396 passes / one actual-Windows-only local skip; 50 tooling tests and four independent inert probes | `e786051ba38f3cf840ae15a73111d3b4ed25a03203835184ce834f692fcfc8f7` | +| New cumulative correctness round 2 | GPT-6.1 Sol / xhigh | 65 files / 1,396 passes / one actual-Windows-only local skip; 50 tooling tests and four independent inert probes | `0f272955baf1b2a1176a7160f75436710548ce34cf38e472393d6c7c55d9716e` | +| New separate security sitting | GPT-6.1 Sol / xhigh | 26 files / 380 passes / one actual-Windows-only local skip; 22 tooling tests | `f9776b8f4499877d085400069bedfa7c82adcc153cb643f2c1fed61e15ade2e7` | + +The parent reads all three complete reports and every substantive path assessment, +then independently verifies every current file pin and all 770 individual hunk +bodies and hashes. Round 2's final 147-file report inventory and seal also match. +Its draft assessment inaccuracies are corrected after source reinspection; the +parent reads all 39 corrected assessments. This report correction changes no +product bytes, test execution or finding disposition. Independent test totals +above are separate executions, not a deduplicated product-suite total. + +Fresh author lint/typecheck and workspace tests pass, with the CLI executing +298 files / 5,116 passing cases / 10 established skips; unchanged workspace +results retain cache attribution. Build, formatting and the compiled CLI smoke +pass. The final complete ordinary ownership cohort passes 10 files / 171 cases +without skips. The last assertion-label correction is included in that cohort +and both fresh correctness-review cohorts; no new full local coverage execution +is inferred. + +The corrected-source selected causal capture is complete: **13 mechanisms / +39 fixed-removal-restored phases / 597 repeated executions / 21 named semantic +witnesses / 22 actual removal assertion failures**. Every completed phase has a +zero-unhandled runner receipt, the exact full inventory and byte-identical +restoration. The freeze digest is +`f0e60563dbe4ae4df95ff2a9a83a843b9f8be7895ba4e3b3ea99a3ca919178a4`. +The parent and all three reviewers independently inspect the actual raw receipts, +1,288 source/tool pins, 21,013 dependency files / 457 packages, runtime identities +and retained graph bodies. The bounded retained inventory is **825 files / +43,528,270 bytes**; copied graphs and caches are removed. Six run/gate callback +phases have actual target-process SQLite-addon load receipts. The selected capture +does not claim whole-wave acceptance or fresh reviewer mutation execution. + +The rejected first capture, its failed ninth removal and its immutable source +freeze remain historical, with acceptance false. Earlier NONCLEAN reviews are +not reused as clean sittings. Their first-source CI has two real failed jobs and +was subsequently cancelled by the corrected-source push; its four successful +package consumers do not become execution evidence for the changed runtime. + +Full push CI **38936742126 / attempt 1 / `dbab25d8`** completes successfully: +all **15 jobs** pass, including required lint/typecheck/test/build/fences/smokes, +coverage, filesystem, secrets, wire and usage controls. Current Windows, macOS +and Linux and exact Node 22.0.0 Linux consume the same **429,116-byte** tarball, +SHA-256 `529942465b79c6d147c4f45ea5b22dea1efe14770f12715f2487ff0b69e34c54`. +All four consumers and their required aggregate pass **175 bootstrap controls / +seven installed cases** each. The current-runtime consumers use Node 24.21.0. +The parent and reviewers independently inspect all five physical package/report +ZIPs, all 71 public tar members and matching installed inventories, all 26 +reachable JavaScript outputs and seventeen declared external dependencies. +No SonarCloud result, merge-commit execution or remote private worker journal is +inferred from this push run or its public success receipts. + +The [separate security sitting](../../standards/security-review.md#sitting-wave-3-workflow-and-terminal-ownership-2026-10-16) +retains exact evidence limits: local simulated Windows protocol controls do not +claim actual OS execution, actual platform packaging does not prove 8d global +activation, and the workflow-cleanup fixture uses a real SQLite/MCP manager with +synthetic connections rather than an actual MCP subprocess. The installed native +case exercises creation-owned offline agent tooling, not the new autonomous +WorkflowLifetime fault on actual Windows. Actual native close and persistence +ACK remain distinct. Fixed diagnostics do not certify unchanged legacy verbose +stack confidentiality. + +The [workflow ownership contract](../../reference/cli/commands.md#workflow-and-terminal-ownership) +is accepted for 8c. **Whole Step 8 remains open:** Step 8d connects all remaining +session/preparation/terminal lifetimes and caught/fatal/background/sink paths, +then supplies its own cumulative and actual-platform acceptance. Steps 9–19 and +whole-wave publication remain staged. No new ADR, deferral, provider capture, +credential, billing or user decision is needed. Step 8d proceeds automatically. diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index 274c4278..e858c764 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -669,6 +669,39 @@ confirmed finding or deferral remains in 8b. The [acceptance register](../roadma joins exact pins and limits. Workflow/terminal integration in 8c, product native activation in 8d and whole-Wave-3 acceptance remain open. +### Sitting: Wave 3 workflow and terminal ownership (2026-10-16) + +A NEW separate complete cumulative sitting is **clean** at +`dbab25d85a2c4ae97c252cdaaf47921648e245af`, independent of both NEW clean +correctness rounds after the first implementation's verified corrections. The +mandatory base `d9625ff0` yields **164 paths / 770 zero-context hunks / 54 whole +added files**. Fresh execution passes **26 files / 380 tests**, with one +actual-Windows-only local skip, and **22 tooling tests**. The parent reads the +complete report and every substantive assessment, and independently verifies all +file/hunk and retained-artifact pins. The report SHA-256 is +`f9776b8f4499877d085400069bedfa7c82adcc153cb643f2c1fed61e15ade2e7`. + +The sitting independently audits all **13 mechanisms / 39 completed raw causal +phases**, their zero-unhandled receipts, source/dependency/runtime identities, +exact restoration and bounded physical inventory. It also inspects the original +package and all four same-tarball consumer ZIPs. Full CI **38936742126** now +passes all fifteen jobs at the reviewed source. Actual current Windows, macOS, +Linux and exact Node 22.0.0 Linux installed consumers pass; local simulated +Windows protocol controls remain separately attributed. Public success receipts +provide no remote private per-mode worker journal. + +The [acceptance register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8c-acceptance--2026-10-16) +records exact counts, digests and limits. The native workflow-cleanup fixture +uses actual SQLite and an MCP manager with synthetic connections, not a real MCP +subprocess. The installed native case proves creation-owned offline agent +tooling, not the autonomous WorkflowLifetime fault on actual Windows. Actual +native closure is not persistence ACK; expiry never grants store-close permission. +Fixed projections do not certify unchanged legacy verbose stacks. No real +provider, keychain, editor, operator TTY or payment is exercised. No confirmed +finding or new deferral remains in 8c. Session/terminal integration, global native +activation and final acknowledged diagnosis remain Step 8d; whole Step 8 and +whole-wave acceptance are still open. + ## Sandbox and tool policy (`run_command`, node tools, secret inputs) This is the **binding home** for the tool-policy rules; the rationale is From 25e310a61c5b9c54b8719093e98833d264c1981c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 22:32:56 +0300 Subject: [PATCH 103/222] fix(mcp): retain failed-start cleanup uncertainty Forward an optional SDK-free startup lifecycle into failed-open cleanup. Observe callback faults before held siblings, keep raw close work owed, and check the original remaining budget before each cleanup factory. Document the partial Step 8d API without claiming session or whole-step acceptance. The full MCP suite passes 242 tests; workspace gates pass. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- docs/reference/shared-core/mcp-integration.md | 27 +++ packages/mcp/src/index.ts | 1 + .../mcp/src/manager-startup-cleanup.test.ts | 192 ++++++++++++++++++ packages/mcp/src/manager.ts | 86 ++++++-- 4 files changed, 293 insertions(+), 13 deletions(-) create mode 100644 packages/mcp/src/manager-startup-cleanup.test.ts diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index d7f74140..7c1997c3 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -201,6 +201,33 @@ owns all run actors and final receipt health. MCP connection close supplies its acknowledgement; a bounded public failure does not substitute for it. Consolidated independent acceptance is recorded in [current.md](../../roadmap/current.md). +### Failed-start cleanup lifecycle + +**Wave 3 Step 8d partial implementation on `development`; independent acceptance +is pending.** `startMcpClient` accepts an optional fourth `McpStartupLifecycle` +argument, separately from its existing third work-transfer argument. Its +`onCloseError(serverId, cause)` observes each failed-start connection close +refusal immediately, including while a different close is held. Its read-only +`budget.remainingMs()` uses the invocation's existing remaining budget. The +manager checks it before each new close; zero remaining time enters no new +asynchronous close and supplies no cleanup acknowledgement. Ordinary startup +without these ports retains its existing actual joins and typed refusal. + +Failed-start cleanup transfers its own work scope before server construction. +All exact entered closes remain joined even if the lifecycle reporter throws. +A reporter/budget-port failure surfaces promptly as a fixed generic +infrastructure error, with the original startup failure and reporter cause kept +private; it cannot become an ordinary configuration refusal or erase a held +sibling. A successful reporter preserves the original typed startup failure. +Neither a close refusal nor an exhausted budget retires a connection. + +The CLI forwards this lifecycle through the actual default manager. Custom +starters that delegate to `startMcpClient` must forward the fourth argument to +retain the same failed-start observations. No vendor handle, PID authority, +receipt success or new engine error code is carried by this channel. Final +session/terminal integration and global activation remain Step 8d in the +[execution plan](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments). + ### Discovery and result ingress bounds A server's own output is untrusted input, and it is bounded diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index fc293374..b39da5ed 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -78,4 +78,5 @@ export { type McpClient, type McpServerConfig, type ManagerSkippedTool, + type McpStartupLifecycle, } from './manager.js'; diff --git a/packages/mcp/src/manager-startup-cleanup.test.ts b/packages/mcp/src/manager-startup-cleanup.test.ts new file mode 100644 index 00000000..44809484 --- /dev/null +++ b/packages/mcp/src/manager-startup-cleanup.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { McpError } from './errors.js'; +import type { McpConnection } from './connection.js'; +import { startMcpClient, type McpServerConfig } from './manager.js'; +import { McpWorkScope } from './work-scope.js'; + +function gate() { + let release: () => void = () => undefined; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +function server( + id: string, + close: McpConnection['close'], + discoveryFailure?: McpError, +): McpServerConfig { + return { + id, + open: () => + Promise.resolve({ + listTools: () => + discoveryFailure === undefined ? Promise.resolve([]) : Promise.reject(discoveryFailure), + callTool: () => Promise.resolve({ content: [], isError: false }), + close, + }), + }; +} + +describe('failed-start cleanup has independent actual lifetime and prompt failure observation', () => { + it.each([true, false])( + 'preserves the typed startup refusal and reports actual close failure only when present (failed=%s)', + async (failed) => { + const primary = new McpError('synthetic discovery refusal'); + const cleanup = new Error('PRIVATE_CLOSE_FAILURE'); + const close = vi.fn(() => (failed ? Promise.reject(cleanup) : Promise.resolve())); + const report = vi.fn(); + const work = new McpWorkScope(); + await expect( + startMcpClient([server('one', close, primary)], undefined, work, { onCloseError: report }), + ).rejects.toBe(primary); + work.seal(); + await work.done; + expect(close).toHaveBeenCalledOnce(); + if (failed) + expect( + report, + 'MCP_FAILED_START_CLOSE_FAILURE_IS_OBSERVED', + ).toHaveBeenCalledExactlyOnceWith('one', cleanup); + else expect(report).not.toHaveBeenCalled(); + }, + ); + + it('reports one failed close before a different connection actually finishes cleanup', async () => { + const primary = new McpError('synthetic discovery refusal'); + const failedClose = new Error('PRIVATE_CLOSE_FAILURE'); + const other = gate(); + const report = vi.fn(); + const work = new McpWorkScope(); + let settled = false; + const opening = startMcpClient( + [ + server('one', () => Promise.reject(failedClose), primary), + server('two', () => other.promise), + ], + undefined, + work, + { onCloseError: report }, + ); + const result = opening.catch((cause: unknown) => { + settled = true; + return cause; + }); + work.seal(); + await reactions(); + expect(report, 'MCP_FAILED_START_REPORT_BEFORE_HELD_SIBLING').toHaveBeenCalledExactlyOnceWith( + 'one', + failedClose, + ); + expect(settled).toBe(false); + expect(work.isComplete).toBe(false); + other.release(); + expect(await result).toBe(primary); + await work.done; + }); + + it('surfaces a throwing observer promptly and still retains every exact entered sibling close', async () => { + const primary = new McpError('synthetic discovery refusal'); + const callbackFault = new Error('PRIVATE_REPORTER_FAILURE'); + const other = gate(); + const closedOther = vi.fn(() => other.promise); + const work = new McpWorkScope(); + const opening = startMcpClient( + [ + server('one', () => Promise.reject(new Error('PRIVATE_CLOSE_FAILURE')), primary), + server('two', closedOther), + ], + undefined, + work, + { + onCloseError: () => { + throw callbackFault; + }, + }, + ); + work.seal(); + const failure: unknown = await opening.catch((cause: unknown) => cause); + expect(failure).toMatchObject({ + message: 'MCP startup cleanup observation failed.', + cause: { primary, observer: callbackFault }, + }); + expect(failure).not.toBeInstanceOf(McpError); + expect(closedOther).toHaveBeenCalledOnce(); + expect(work.isComplete, 'MCP_REPORTER_FAILURE_RETAINS_RAW_CLEANUP').toBe(false); + other.release(); + await work.done; + }); + + it('never enters a new async close at zero common budget or acknowledges expiry as cleanup', async () => { + const close = vi.fn(() => Promise.resolve()); + const work = new McpWorkScope(); + let settled = false; + void startMcpClient( + [server('one', close, new McpError('synthetic discovery refusal'))], + undefined, + work, + { budget: { remainingMs: () => 0 } }, + ).then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + work.seal(); + await reactions(); + expect(close, 'MCP_FAILED_START_NO_NEW_CLOSE_AFTER_EXPIRY').not.toHaveBeenCalled(); + expect(settled).toBe(false); + expect(work.isComplete).toBe(false); + }); + + it('rechecks expiry for each close when an earlier synchronous close fault exhausts the budget', async () => { + let remaining = 1; + const first = vi.fn(() => { + throw new Error('PRIVATE_SYNCHRONOUS_CLOSE_FAILURE'); + }); + const second = vi.fn(() => Promise.resolve()); + const work = new McpWorkScope(); + const report = vi.fn(() => { + remaining = 0; + }); + void startMcpClient( + [server('one', first, new McpError('synthetic discovery refusal')), server('two', second)], + undefined, + work, + { onCloseError: report, budget: { remainingMs: () => remaining } }, + ).catch(() => undefined); + work.seal(); + await reactions(); + expect(first).toHaveBeenCalledOnce(); + expect(report).toHaveBeenCalledOnce(); + expect(second, 'MCP_FAILED_START_RECHECKS_EACH_CLOSE_BUDGET').not.toHaveBeenCalled(); + expect(work.isComplete).toBe(false); + }); + + it('a faulty budget port is infrastructure failure and leaves all unknown cleanup owed', async () => { + const primary = new McpError('synthetic discovery refusal'); + const close = vi.fn(() => Promise.resolve()); + const budgetFailure = new Error('PRIVATE_BUDGET_FAILURE'); + const work = new McpWorkScope(); + const opening = startMcpClient([server('one', close, primary)], undefined, work, { + budget: { + remainingMs: () => { + throw budgetFailure; + }, + }, + }); + work.seal(); + await expect(opening).rejects.toMatchObject({ + message: 'MCP startup cleanup observation failed.', + cause: { primary, observer: budgetFailure }, + }); + expect(close).not.toHaveBeenCalled(); + expect(work.isComplete).toBe(false); + }); +}); diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index fd4e7d5b..21ff8a26 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -80,26 +80,24 @@ export interface McpClient { readonly childPids: readonly number[]; } +/** Host-only failed-start cleanup observations; no SDK/native handle or receipt authority crosses here. */ +export interface McpStartupLifecycle { + /** Called immediately for each actual close refusal, even while another connection is still closing. */ + readonly onCloseError?: (serverId: string, cause: unknown) => void; + /** The original live budget. Expiry permits no new async close and grants no cleanup acknowledgement. */ + readonly budget?: { readonly remainingMs: () => number | undefined }; +} + export async function startMcpClient( servers: readonly McpServerConfig[], /** Cancels the connect AND the discovery walk — see {@link McpServerConfig.open}. */ signal?: AbortSignalLike, options?: ToolHostCallOptions, + lifecycle?: McpStartupLifecycle, ): Promise { const connections = new Map(); const closeFailures = new Map(); const reported = new WeakSet<(serverId: string, cause: unknown) => void>(); - let closing: Promise | undefined; - const close = (onCloseError?: (serverId: string, cause: unknown) => void): Promise => { - // Assign the shared join before entering a custom close factory; reentrant close/call - // observes retirement immediately rather than starting a second cleanup. - closing ??= Promise.resolve().then(() => closeAll(connections, closeFailures)); - return closing.then(() => { - if (onCloseError === undefined || reported.has(onCloseError)) return; - reported.add(onCloseError); - for (const [serverId, cause] of closeFailures) onCloseError(serverId, cause); - }); - }; const toolDefs: ToolDef[] = []; const toolIdsByServer = new Map(); const skipped: ManagerSkippedTool[] = []; @@ -121,6 +119,39 @@ export async function startMcpClient( // connection as soon as `open()` resolves, so a later `listTools()` failure still has it in `connections` for // the fail-loud teardown; and each wraps its own failure into a typed, secret-free error carrying the server id. const startup = new McpWorkScope(options); + // Transfer failed-start cleanup before any producer enters. Its raw closes can outlive an + // unexpected reporter failure without disappearing from the caller's actual lifetime join. + const failedCleanup = startup.fork(); + let failedStartup = false; + let startupFailure: { readonly cause: unknown } | undefined; + let closing: Promise | undefined; + let rejectObserver: (cause: Error) => void = () => undefined; + const observerFailure = new Promise((_resolve, reject) => { + rejectObserver = reject; + }); + void observerFailure.catch(() => undefined); + const close = (onCloseError?: (serverId: string, cause: unknown) => void): Promise => { + // Publish the shared join before entering custom close factories, including reentry. + closing ??= Promise.resolve().then(() => + closeAll( + connections, + closeFailures, + failedStartup ? failedCleanup : undefined, + failedStartup ? lifecycle : undefined, + (cause) => + rejectObserver( + new Error('MCP startup cleanup observation failed.', { + cause: { primary: startupFailure?.cause, observer: cause }, + }), + ), + ), + ); + return closing.then(() => { + if (onCloseError === undefined || reported.has(onCloseError)) return; + reported.add(onCloseError); + for (const [serverId, cause] of closeFailures) onCloseError(serverId, cause); + }); + }; const settled = await Promise.allSettled( servers.map(async (server) => { const work = startup.fork(); @@ -139,9 +170,16 @@ export async function startMcpClient( const failure = settled.find((r): r is PromiseRejectedResult => r.status === 'rejected'); if (failure !== undefined) { // Fail-loud: tear down everything opened, then surface the (already typed + secret-free) first failure. - await close(); + startupFailure = { cause: failure.reason }; + failedStartup = true; + try { + await Promise.race([close(), observerFailure]); + } finally { + failedCleanup.seal(); + } throw failure.reason; } + failedCleanup.seal(); // Assemble the tool defs in DECLARATION order (not connect-completion order) so the cross-server namespacing + // collision resolution (shared `seenToolIds`, first-wins) stays deterministic regardless of who connected first. for (const result of settled) { @@ -203,17 +241,39 @@ export async function startMcpClient( async function closeAll( connections: Map, failures: Map, + scope?: McpWorkScope, + lifecycle?: McpStartupLifecycle, + observerFailed: (cause: unknown) => void = () => undefined, ): Promise { const all = [...connections.entries()]; await Promise.all( all.map(async ([serverId, connection]) => { + const enter = (factory: () => Promise): Promise => + scope === undefined ? factory() : scope.retainWork(factory); + try { + if (lifecycle?.budget?.remainingMs() === 0) { + await enter(() => new Promise(() => {})); + return; + } + } catch (cause) { + observerFailed(cause); + await enter(() => new Promise(() => {})); + return; + } try { - await connection.close(); + await enter(() => connection.close()); if (connections.get(serverId) === connection) connections.delete(serverId); } catch (cause) { // Preserve the opaque fault before any caller can catch/ignore its reporting result. // Native acknowledgement, where available, belongs to the connection's own raw scope. failures.set(serverId, cause); + try { + lifecycle?.onCloseError?.(serverId, cause); + } catch (observerCause) { + // The caller sees this independent infrastructure fault promptly, while the scope still + // retains every actual sibling close. Never turn reporter failure into a clean startup refusal. + observerFailed(observerCause); + } } }), ); From 63bd601053e003f6c1f82cc9fc937ac5a8aa21a1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Fri, 16 Oct 2026 22:33:21 +0300 Subject: [PATCH 104/222] fix(cli): own session preparation and one-shot cleanup Reserve per-surface, per-host and terminal acquisition lifetimes before entry. Keep producer/accounting/MCP joins and actual database cleanup distinct from cancellation, callback errors and the common fatal timer. Connect one-shot input cancellation, late consent refusal and warning delivery. Retain accounting failures across repeated barriers and keep classified output failures. Add actual SQLite, child-process and installed Clack regressions alongside deterministic ownership controls. Record partial Step 8d progress. Chat/Home, wizard and global native activation plus complete cumulative/security/platform acceptance remain open. Final CI-environment workspace gates pass all 23 tasks, including fresh CLI lint/typecheck and 5,179 tests with 11 established skips. Refs: ADR-0110, ADR-0103, Wave 3 Step 8d Co-Authored-By: Codex --- apps/cli/src/chat/persister.ts | 8 +- apps/cli/src/chat/session-host-owner.test.ts | 307 ++++++++ apps/cli/src/chat/session-host-owner.ts | 96 +++ apps/cli/src/chat/session-host.ts | 129 +++- apps/cli/src/chat/session-prompt.test.ts | 58 ++ apps/cli/src/chat/session-prompt.ts | 44 ++ .../src/chat/session-surface-lifetime.test.ts | 207 ++++++ apps/cli/src/chat/session-surface-lifetime.ts | 70 ++ .../src/chat/session-terminal-owner.test.ts | 302 ++++++++ apps/cli/src/chat/session-terminal-owner.ts | 160 +++++ apps/cli/src/chat/session-usage-ack.test.ts | 8 +- apps/cli/src/chat/session-work-owner.test.ts | 322 +++++++++ apps/cli/src/chat/session-work-owner.ts | 153 ++++ apps/cli/src/commands/agent-run.test.ts | 335 ++++++++- apps/cli/src/commands/agent-run.ts | 658 ++++++++++-------- apps/cli/src/commands/gate.ts | 1 + apps/cli/src/commands/run.ts | 1 + apps/cli/src/commands/safe-close.test.ts | 39 +- .../fixtures/usage-coverage-crash-worker.ts | 16 +- apps/cli/src/engine/mcp-servers.ts | 71 +- .../engine/mcp-session-preparation.test.ts | 340 +++++++++ .../src/process/invocation.test-support.ts | 35 + docs/reference/cli/commands.md | 28 + .../phase-2.5.5-hardening-and-remediation.md | 46 ++ 24 files changed, 3071 insertions(+), 363 deletions(-) create mode 100644 apps/cli/src/chat/session-host-owner.test.ts create mode 100644 apps/cli/src/chat/session-host-owner.ts create mode 100644 apps/cli/src/chat/session-prompt.test.ts create mode 100644 apps/cli/src/chat/session-prompt.ts create mode 100644 apps/cli/src/chat/session-surface-lifetime.test.ts create mode 100644 apps/cli/src/chat/session-surface-lifetime.ts create mode 100644 apps/cli/src/chat/session-terminal-owner.test.ts create mode 100644 apps/cli/src/chat/session-terminal-owner.ts create mode 100644 apps/cli/src/chat/session-work-owner.test.ts create mode 100644 apps/cli/src/chat/session-work-owner.ts create mode 100644 apps/cli/src/engine/mcp-session-preparation.test.ts create mode 100644 apps/cli/src/process/invocation.test-support.ts diff --git a/apps/cli/src/chat/persister.ts b/apps/cli/src/chat/persister.ts index 765d37ee..686d3c5b 100644 --- a/apps/cli/src/chat/persister.ts +++ b/apps/cli/src/chat/persister.ts @@ -458,11 +458,9 @@ export function createSessionPersister(deps: SessionPersisterDeps): SessionPersi // self-detach so the bus listener does not leak if the REPL's close() is skipped on an early exit. // // Through `persistDurably`, like every other write here (`CR-01`) — but be precise about WHAT that - // buys, because the obvious reading is wrong. The latch half is inert on this arm: `session:cancelled` - // is emitted only by `AgentSession.cancel()`, which sets `#status = 'cancelled'`, and every later - // egress entry point is already refused by `#assertSendable()` with `not_active`. Nothing reads - // `durabilityFailure` again. It is wrapped for SYMMETRY with its siblings — one arm reaching the store - // bare is how the next reader concludes the wrapper is optional. + // buys: `session:cancelled` is emitted by `AgentSession.cancel()`, so later egress is already + // refused. The retained latch still matters to the session's actual cleanup owner: a failed + // final write must survive terminal cancellation and cannot become a clean close verdict. // // **The `finally` is the load-bearing half.** Before it, a throwing write jumped straight over the two // unsubscribe lines and left this persister attached to the bus — so every later event re-entered a diff --git a/apps/cli/src/chat/session-host-owner.test.ts b/apps/cli/src/chat/session-host-owner.test.ts new file mode 100644 index 00000000..7f31fa94 --- /dev/null +++ b/apps/cli/src/chat/session-host-owner.test.ts @@ -0,0 +1,307 @@ +import { + createClient, + createSessionStore, + createSessionUsageStore, + runMigrations, +} from '@relavium/db'; +import { describe, expect, it, vi } from 'vitest'; + +import { resolveConfig } from '../config/resolve.js'; +import { createInvocationFiniteExit } from '../process/invocation-finite-exit.js'; +import { InvocationRegistry } from '../process/invocation-registry.js'; +import { createSessionPersister } from './persister.js'; +import { + buildChatSession, + type BuildChatSessionOptions, + type SessionUsageWriter, +} from './session-host.js'; +import { SessionHostOwner } from './session-host-owner.js'; +import { scriptedProvider, scriptedResolver, textTurn } from './test-support.js'; + +function gate() { + let release: () => void = () => undefined; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +function invocationFixture() { + const clock = { now: () => 0, schedule: () => () => undefined }; + const registry = new InvocationRegistry(clock); + return { + registry, + finiteExit: createInvocationFiniteExit(registry, clock, { + exit: vi.fn(), + setExitCode: vi.fn(), + }), + seedFileCatalogOnce: () => Promise.resolve(), + }; +} + +function options(): BuildChatSessionOptions { + return { + chat: resolveConfig({}).chat, + agentRef: undefined, + cwd: '/tmp', + projectConfigDir: undefined, + now: () => Date.parse('2026-10-16T00:00:00Z'), + uuid: () => 'owned-session', + providers: scriptedResolver([textTurn('synthetic reply')]), + onListenerError: () => undefined, + }; +} + +async function fixture( + configure: (opts: BuildChatSessionOptions) => BuildChatSessionOptions = (opts) => opts, + wrap: (writer: SessionUsageWriter) => SessionUsageWriter = (writer) => writer, +) { + const invocation = invocationFixture(); + const owner = new SessionHostOwner(invocation, 'chat'); + const built = await owner.build((workOwner) => + buildChatSession({ ...configure(options()), workOwner }), + ); + const client = createClient(); + runMigrations(client.db); + const store = createSessionStore(client.db); + let ordinal = 0; + const persister = owner.createPersister(() => + createSessionPersister({ + mode: 'fresh', + usageStore: createSessionUsageStore(client.db), + attachUsageWriter: (writer) => built.attachUsageWriter(wrap(writer)), + store, + handle: built.handle, + governor: built.governor, + attachDurabilityProbe: built.attachDurabilityProbe, + attachEffectTurnAllocator: built.attachEffectTurnAllocator, + sessionId: built.sessionId, + agent: built.agent, + context: built.context, + now: () => Date.parse('2026-10-16T00:00:00Z'), + uuid: () => `owned-message-${++ordinal}`, + }), + ); + persister.start(); + built.session.start(); + return { invocation, owner, built, client, store, persister }; +} + +describe('actual session builder, persister and host cleanup ownership', () => { + it('joins the complete real session and records its final terminal before releasing the host', async () => { + const f = await fixture(); + try { + expect(f.built.workOwner).toBe(f.owner.work); + f.persister.beginUserTurn('synthetic prompt'); + await f.owner.work.enter(() => f.built.session.sendMessage('synthetic prompt')); + expect(f.store.loadMessages(f.built.sessionId)).toHaveLength(2); + expect(f.invocation.registry.activeOwners).toBe(1); + await f.owner.work.close(); + await reactions(); + expect(f.store.loadSession(f.built.sessionId)?.status).toBe('ended'); + expect(f.persister.durabilityFailure).toBeUndefined(); + expect(f.invocation.registry.activeOwners).toBe(0); + expect(f.invocation.registry.observations.exitCode()).toBe(0); + } finally { + f.persister.close(); + f.client.sqlite.close(); + } + }); + + it('retains actual provider work after cancellation instead of treating the session terminal as quiescence', async () => { + const started = gate(); + const producer = gate(); + const provider = { + ...scriptedProvider([]), + stream: async function* () { + started.release(); + await producer.promise; + yield* textTurn('late synthetic reply'); + }, + }; + const f = await fixture((opts) => ({ + ...opts, + providers: { resolveProvider: () => provider, keyFor: () => 'synthetic-test-key' }, + })); + let turn: Promise | undefined; + try { + f.persister.beginUserTurn('synthetic prompt'); + turn = f.owner.work.enter(() => f.built.session.sendMessage('synthetic prompt')); + await started.promise; + let retired = false; + const closing = f.owner.work.close(); + void closing.then(() => { + retired = true; + }); + await reactions(); + expect(f.store.loadSession(f.built.sessionId)?.status).toBe('ended'); + expect(retired, 'SESSION_NATIVE_ROOT_OUTLIVES_CANCEL_TERMINAL').toBe(false); + expect(f.invocation.registry.activeOwners).toBe(1); + producer.release(); + await turn; + await reactions(); + expect(f.owner.work.firstFailure).toBeUndefined(); + await closing; + await reactions(); + expect(f.invocation.registry.activeOwners).toBe(0); + expect(f.store.loadMessages(f.built.sessionId)).toHaveLength(0); + } finally { + producer.release(); + await turn; + f.persister.close(); + f.client.sqlite.close(); + } + }); + + it('retains a real terminal persistence refusal through actual cleanup and repeated barriers', async () => { + const f = await fixture(); + try { + f.client.sqlite.exec( + "CREATE TRIGGER refuse_owned_terminal BEFORE UPDATE ON agent_sessions WHEN NEW.status = 'ended' BEGIN SELECT RAISE(ABORT, 'synthetic_terminal_refusal'); END", + ); + let retired = false; + void f.owner.work.close().then(() => { + retired = true; + }); + await reactions(); + expect(f.persister.durabilityFailure).toBeDefined(); + expect(f.store.loadSession(f.built.sessionId)?.status).toBe('active'); + await expect(f.built.whenHostSafeToClose()).rejects.toBe(f.persister.durabilityFailure); + await expect(f.built.whenHostSafeToClose()).rejects.toBe(f.persister.durabilityFailure); + expect(retired, 'SESSION_TERMINAL_PERSISTENCE_FAILURE_RETAINED').toBe(false); + expect(f.invocation.registry.activeOwners).toBe(1); + expect(f.invocation.registry.observations.exitCode()).toBe(9); + } finally { + // Administrative fixture disposal after actual work quiescence, not product retirement permission. + f.persister.close(); + f.client.sqlite.close(); + } + }); + + it('starts the common fatal budget for a refused terminal write before a held provider tail joins', async () => { + const started = gate(); + const producer = gate(); + const provider = { + ...scriptedProvider([]), + stream: async function* () { + started.release(); + await producer.promise; + yield* textTurn('late synthetic reply'); + }, + }; + const f = await fixture((opts) => ({ + ...opts, + providers: { resolveProvider: () => provider, keyFor: () => 'synthetic-test-key' }, + })); + let turn: Promise | undefined; + try { + f.client.sqlite.exec( + "CREATE TRIGGER refuse_held_terminal BEFORE UPDATE ON agent_sessions WHEN NEW.status = 'ended' BEGIN SELECT RAISE(ABORT, 'synthetic_held_terminal_refusal'); END", + ); + f.persister.beginUserTurn('synthetic prompt'); + turn = f.owner.work.enter(() => f.built.session.sendMessage('synthetic prompt')); + await started.promise; + void f.owner.work.close(); + await reactions(); + expect(f.persister.durabilityFailure).toBeDefined(); + expect( + f.invocation.registry.budget.deadline(), + 'SESSION_TERMINAL_FAILURE_BEFORE_HELD_PROVIDER_JOIN', + ).toBe(3000); + expect(f.invocation.registry.observations.exitCode()).toBe(9); + expect(f.invocation.registry.activeOwners).toBe(1); + producer.release(); + await turn; + await reactions(); + expect(f.invocation.registry.activeOwners).toBe(1); + } finally { + producer.release(); + await turn; + // Actual test-owned work has settled; this administrative close grants no product ACK. + f.persister.close(); + f.client.sqlite.close(); + } + }); + + it('preserves a committed native charge when its post-ACK publication fails', async () => { + let acknowledged = false; + let records = 0; + const f = await fixture(undefined, (writer) => ({ + ...writer, + readSnapshot: () => { + if (acknowledged) throw new Error('PRIVATE_POST_ACK_PROJECTION_FAILURE'); + if (writer.readSnapshot === undefined) throw new Error('Fixture snapshot is absent.'); + return writer.readSnapshot(); + }, + record: async (draft, cumulative) => { + await writer.record(draft, cumulative); + records += 1; + acknowledged = true; + }, + })); + try { + f.persister.beginUserTurn('synthetic prompt'); + await f.owner.work.enter(() => f.built.session.sendMessage('synthetic prompt')); + await reactions(); + expect(records).toBe(1); + expect( + f.store.loadSessionCostBreakdown(f.built.sessionId).totalCostMicrocents, + ).toBeGreaterThan(0); + expect(f.invocation.registry.observations.exitCode()).toBe(9); + expect(f.invocation.registry.activeOwners, 'SESSION_POST_ACK_PUBLICATION_RETAINS_OWNER').toBe( + 1, + ); + await expect(f.built.whenHostSafeToClose()).rejects.toThrow( + 'session accounting publication failed', + ); + } finally { + // The fixture can inspect/dispose its native database; the product owner remains unacknowledged. + f.persister.close(); + f.client.sqlite.close(); + } + }); + + it('adopts a late builder before cleanup and does not publish it after its owner stopped', async () => { + const invocation = invocationFixture(); + const owner = new SessionHostOwner(invocation, 'home'); + const built = await buildChatSession(options()); + const publish = gate(); + const close = gate(); + const closeMcp = vi.fn(() => close.promise); + const opening = owner.build(async () => { + await publish.promise; + return { ...built, closeMcp }; + }); + const result = opening.catch((cause: unknown) => cause); + owner.work.stop(); + let retired = false; + const closing = owner.work.close(); + void closing.then(() => { + retired = true; + }); + await reactions(); + expect(closeMcp).not.toHaveBeenCalled(); + publish.release(); + expect(owner.work.isStoppedFailure(await result)).toBe(true); + await reactions(); + expect(closeMcp).toHaveBeenCalledOnce(); + expect(retired, 'SESSION_LATE_BUILD_ADOPTION_BEFORE_RETIREMENT').toBe(false); + close.release(); + await closing; + await reactions(); + expect(invocation.registry.activeOwners).toBe(0); + }); + + it('cannot overwrite an existing construction with a second builder on the same reservation', async () => { + const invocation = invocationFixture(); + const owner = new SessionHostOwner(invocation, 'home'); + const first = await owner.build((workOwner) => buildChatSession({ ...options(), workOwner })); + const second = vi.fn(() => buildChatSession(options())); + expect(() => owner.build(second)).toThrow('Session construction is already owned.'); + expect(second).not.toHaveBeenCalled(); + expect(first.workOwner).toBe(owner.work); + await owner.work.close(); + }); +}); diff --git a/apps/cli/src/chat/session-host-owner.ts b/apps/cli/src/chat/session-host-owner.ts new file mode 100644 index 00000000..9f163acf --- /dev/null +++ b/apps/cli/src/chat/session-host-owner.ts @@ -0,0 +1,96 @@ +import type { CliInvocation } from '../process/invocation.js'; +import type { SessionOwner } from '../process/invocation-registry.js'; +import type { SessionPersister } from './persister.js'; +import type { BuiltChatSession } from './session-host.js'; +import { SessionWorkOwner } from './session-work-owner.js'; + +/** One reservation per actual builder invocation, including late and superseded Home instances. */ +export class SessionHostOwner { + readonly work: SessionWorkOwner; + #built: BuiltChatSession | undefined; + #cancelled: BuiltChatSession | undefined; + #persister: SessionPersister | undefined; + #constructionEntered = false; + + constructor(invocation: CliInvocation, surface: SessionOwner['surface']) { + this.work = new SessionWorkOwner(invocation, surface, { + cancel: () => this.#cancel(), + cleanup: () => this.#cleanup(), + }); + } + + /** The retained root includes adoption, so raw builder settlement cannot retire a late resource first. */ + build(factory: (owner: SessionWorkOwner) => Promise): Promise { + this.work.assertAdmitting(); + if (this.#constructionEntered) throw new Error('Session construction is already owned.'); + this.#constructionEntered = true; + return this.work.enter(async () => { + const built = await factory(this.work); + this.#built = built; + if (this.work.signal.aborted) { + try { + this.#cancel(); + } catch (cause) { + this.work.cleanupUncertain(cause); + } + } + this.work.assertAdmitting(); + return { ...built, workOwner: this.work }; + }); + } + + /** Adopt before the caller starts the persister or any session event can require it. */ + createPersister(factory: () => SessionPersister): SessionPersister { + this.work.assertAdmitting(); + if (this.#persister !== undefined) throw new Error('Session persistence is already attached.'); + const persister = factory(); + this.#persister = persister; + this.work.assertAdmitting(); + return persister; + } + + #cancel(): void { + const built = this.#built; + if (built === undefined || this.#cancelled === built) return; + this.#cancelled = built; + try { + built.session.cancel(); + } finally { + // The terminal write is synchronous and its bus listener retains refusal without throwing + // through cancel(). Observe that owned failure before a held provider can delay cleanup. + const failure = this.#persister?.durabilityFailure; + if (failure !== undefined) this.work.cleanupUncertain(failure); + } + } + + async #cleanup(): Promise { + const built = this.#built; + if (built === undefined) return; + // Every root and transferred child has actually settled before this callback enters. The + // runtime additionally joins accounting/host work. A rejected barrier is retained uncertainty, + // never DB-close permission; sibling teardown is still attempted within the same budget. + try { + await built.whenHostSafeToClose(); + } catch (cause) { + this.work.cleanupUncertain(cause); + } + const persister = this.#persister; + if (persister !== undefined) { + if (persister.durabilityFailure !== undefined) + this.work.cleanupUncertain(persister.durabilityFailure); + try { + persister.close(); + } catch (cause) { + this.work.cleanupUncertain(cause); + } + } + if (built.closeMcp === undefined) return; + if (this.work.invocation.registry.budget.remainingMs() === 0) + await new Promise(() => {}); + try { + await built.closeMcp((_id, cause) => this.work.cleanupUncertain(cause)); + } catch (cause) { + this.work.cleanupUncertain(cause); + } + } +} diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index 385bb5ee..a6b341b4 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -34,7 +34,7 @@ import { type PricingOverlay, type ProviderId, } from '@relavium/llm'; -import type { ManagerSkippedTool, McpClient, McpServerConfig } from '@relavium/mcp'; +import type { ManagerSkippedTool, McpClient } from '@relavium/mcp'; import { UsageCoverageFold, type UsageCoverageSnapshot } from '@relavium/shared'; import type { AbortSignalLike, @@ -51,6 +51,7 @@ import type { import type { ResolvedChatConfig } from '../config/resolve.js'; import { + closePreparedMcp, connectAgentMcp, type ConnectAgentMcpOptions, type StdioConsentGate, @@ -73,6 +74,7 @@ import { import { resolveChatAgentSource, type ResolvedChatAgent } from './agent-source.js'; import { sanitizeUntrustedInline } from '../render/sanitize.js'; import { hostDeadlineTimer, hostSleep } from '../process/sleep.js'; +import type { SessionWorkOwner } from './session-work-owner.js'; const SESSION_DURABILITY_FAILURE = 'this session could not be saved, so it will not send anything further — the transcript and cost on disk are behind what you see'; @@ -89,6 +91,8 @@ const SESSION_DURABILITY_FAILURE = */ export interface BuildChatSessionOptions { + /** Creation owner reserved by the surface before preparation; never persisted or reconstructed by ID. */ + readonly workOwner?: SessionWorkOwner; /** Host-only native authority for both tool processes and MCP startup. */ readonly nativeProcesses?: NativeProcessController; /** The resolved `[chat]` block (default model, fs scope, turn cap, cost cap). */ @@ -122,7 +126,7 @@ export interface BuildChatSessionOptions { * `@relavium/mcp` `startMcpClient`. Threads through to {@link connectAgentMcp} so the agent's inline stdio * `mcp_servers` discover their tools without a live server in the unit path. */ - readonly startMcpClient?: (servers: readonly McpServerConfig[]) => Promise; + readonly startMcpClient?: NonNullable; /** * The consent gate ([ADR-0084](../../../../docs/decisions/0084-consent-before-a-local-mcp-spawn.md) §1) — * threaded to {@link connectAgentMcp} so a chat session's declared stdio server is not spawned until the @@ -228,6 +232,7 @@ export interface SessionUsageWriter { } export interface BuiltChatSession { + readonly workOwner?: SessionWorkOwner; readonly attachUsageWriter: (writer: SessionUsageWriter) => void; readonly whenHostSafeToClose: () => Promise; /** Host-authoritative base-model window; present undefined for custom/unknown metadata. */ @@ -320,6 +325,7 @@ type SessionRuntimeOptions = Pick< | 'toolHost' | 'fsPolicy' | 'nativeProcesses' + | 'workOwner' | 'afterTurnCompaction' | 'onCompactionStart' | 'onBudgetWarning' @@ -478,8 +484,23 @@ function buildSessionRuntime( const hostWork = new HostWorkRegistry(sessionId); let cumulativeCost = 0; let accountingSnapshot: SessionAccountingSnapshot | undefined; + let accountingFailure: { readonly cause: unknown } | undefined; let publicationFailure: Error | undefined; let publicationUncertain = false; + const rememberAccountingFailure = (cause: unknown): void => { + accountingFailure ??= { + cause: new Error('Session cost ledger could not be made durable.', { cause }), + }; + opts.workOwner?.failure(accountingFailure.cause); + }; + const observeAccounting = async (operation: () => Promise): Promise => { + try { + return await operation(); + } catch (cause) { + rememberAccountingFailure(cause); + throw cause; + } + }; const refreshAccounting = (): void => { accountingSnapshot = usageWriter?.readSnapshot?.(); publicationUncertain = false; @@ -505,11 +526,12 @@ function buildSessionRuntime( } catch (cause) { publicationUncertain = true; publicationFailure ??= new Error('session accounting publication failed', { cause }); + opts.workOwner?.failure(publicationFailure); } }; const money = new MoneyDurability({ beginUsageInvocation: async (draft) => { - const invocation = await requiredUsageWriter().begin(draft); + const invocation = await observeAccounting(() => requiredUsageWriter().begin(draft)); publishAcknowledged(() => { usageCoverage?.pending(draft.model, invocation); emit({ @@ -523,7 +545,7 @@ function buildSessionRuntime( return invocation; }, emitUsageDisposition: async (draft) => { - await requiredUsageWriter().disposition(draft); + await observeAccounting(async () => requiredUsageWriter().disposition(draft)); publishAcknowledged(() => { usageCoverage?.disposition( draft.model, @@ -539,7 +561,7 @@ function buildSessionRuntime( }); }, emit: async (draft, cumulative) => { - await requiredUsageWriter().record(draft, cumulative); + await observeAccounting(async () => requiredUsageWriter().record(draft, cumulative)); publishAcknowledged(() => { if (draft.invocation !== undefined) usageCoverage?.complete(draft.model, draft.invocation); emit({ @@ -552,12 +574,35 @@ function buildSessionRuntime( }, ...(governor === undefined ? {} - : { flushConservative: () => governor.flushBudgetCommitments() }), + : { + flushConservative: () => observeAccounting(() => governor.flushBudgetCommitments()), + }), }); const moneyPort = money.turnPort(() => cumulativeCost); + const joinMoney = async (): Promise => { + await observeAccounting(() => money.join()); + if (accountingFailure !== undefined) throw accountingFailure.cause; + }; + const beginUsage = moneyPort.beginUsageInvocation; const deps: SessionDeps = { - money: moneyPort, - retainWork: (work) => hostWork.invoke(() => work()), + money: { + ...moneyPort, + join: joinMoney, + accountingFailure: (cause) => { + rememberAccountingFailure(cause); + moneyPort.accountingFailure?.(cause); + }, + ...(beginUsage === undefined + ? {} + : { + beginUsageInvocation: (draft: PendingUsageDraft) => + observeAccounting(() => beginUsage(draft)), + }), + }, + retainWork: (work) => + opts.workOwner === undefined + ? hostWork.invoke(() => work()) + : opts.workOwner.work.retainWork(() => hostWork.invoke(() => work())), ...(opts.maxTokensEstimate === undefined ? {} : { maxTokensEstimate: opts.maxTokensEstimate }), reserveEffectTurnKey: (id) => { if (effectTurnAllocator === undefined) @@ -661,7 +706,10 @@ function buildSessionRuntime( // persister latches a write failure, no further egress is admitted. It composes with the governor rather // than replacing it, and it runs FIRST — a session whose record is already lost must not spend more. preEgress: (info) => { + opts.workOwner?.assertAdmitting(); if (publicationFailure !== undefined) throw publicationFailure; + if (accountingFailure !== undefined) + throw new Error(SESSION_DURABILITY_FAILURE, { cause: accountingFailure.cause }); const failure = durabilityProbe(); if (failure !== undefined) { // A plain Error, deliberately: the chain classifies a `preAttempt` throw as a fatal (non-retryable) @@ -679,7 +727,7 @@ function buildSessionRuntime( // ADR-0074 §4: resume restores BOTH totals into the same governor a fresh session uses. restoreConservativeCost: governor.restoreConservativeCost, // ADR-0074 §2's turn-boundary half — the session's equivalent of the engine's node-boundary flush. - flushBudgetCommitments: governor.flushBudgetCommitments, + flushBudgetCommitments: () => observeAccounting(() => governor.flushBudgetCommitments()), }), // The realized-cost overlay (2.5.G S10, ADR-0065 §2) — so the CostTracker prices a user-priced (otherwise // unknown) model instead of throwing UnknownModelError. Same map the governor uses; both fill an UNKNOWN id @@ -695,8 +743,10 @@ function buildSessionRuntime( whenHostSafeToClose: async () => { await hostWork.join(); await money.waitForWrites(); - await money.join(); + await joinMoney(); if (publicationFailure !== undefined) throw publicationFailure; + const failure = durabilityProbe(); + if (failure !== undefined) throw failure; }, bus, deps, @@ -769,6 +819,7 @@ type McpConnectInputs = Pick< | 'mcpRegistrations' | 'mcpConnectSignal' | 'nativeProcesses' + | 'workOwner' >; /** @@ -786,6 +837,15 @@ function mcpOptionsFor( ): ConnectAgentMcpOptions { return { cwd, + ...(opts.workOwner === undefined + ? {} + : { + assertAdmitting: opts.workOwner.assertAdmitting, + onFailure: opts.workOwner.failure, + onCloseError: (_id: string, cause: unknown) => opts.workOwner?.cleanupUncertain(cause), + work: opts.workOwner.work, + budget: opts.workOwner.invocation.registry.budget, + }), ...(opts.nativeProcesses === undefined ? {} : { nativeProcesses: opts.nativeProcesses }), ...(opts.consentGate === undefined ? {} : { consentGate: opts.consentGate }), // The caller's label wins (`agent run` names the ref the user typed); otherwise the path the agent was @@ -794,7 +854,11 @@ function mcpOptionsFor( ...(opts.startMcpClient === undefined ? {} : { startMcpClient: opts.startMcpClient }), ...(opts.mcpSecretResolver === undefined ? {} : { resolveSecret: opts.mcpSecretResolver }), ...(opts.mcpRegistrations === undefined ? {} : { registrations: opts.mcpRegistrations }), - ...(opts.mcpConnectSignal === undefined ? {} : { connectSignal: opts.mcpConnectSignal }), + ...(opts.workOwner !== undefined + ? { connectSignal: opts.workOwner.signal } + : opts.mcpConnectSignal === undefined + ? {} + : { connectSignal: opts.mcpConnectSignal }), }; } @@ -824,6 +888,7 @@ function assertSessionFsPolicy( } export async function buildChatSession(opts: BuildChatSessionOptions): Promise { + opts.workOwner?.assertAdmitting(); assertSessionFsPolicy(opts.fsPolicy, opts.cwd, opts.chat); const sessionId = opts.uuid(); const { agent, artifact } = bindChatAgent(opts); @@ -849,6 +914,7 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise undefined); + opts.workOwner?.failure(err); + if (mcp !== undefined) { + try { + await closePreparedMcp( + mcp, + opts.workOwner === undefined + ? undefined + : (_id, cause) => opts.workOwner?.cleanupUncertain(cause), + opts.workOwner?.invocation.registry.budget, + ); + } catch (cause) { + opts.workOwner?.cleanupUncertain(cause); + } + } throw err; } } @@ -991,7 +1071,7 @@ export interface BuiltResumedChatSession extends BuiltChatSession { export interface BuildResumedChatSessionOptions extends Pick< BuildChatSessionOptions, - 'afterTurnCompaction' | 'onCompactionStart' | 'fsPolicy' | 'nativeProcesses' + 'afterTurnCompaction' | 'onCompactionStart' | 'fsPolicy' | 'nativeProcesses' | 'workOwner' > { /** The resolved `[chat]` block (turn cap, cost cap) — applied to the resumed session's deps. */ readonly chat: ResolvedChatConfig; @@ -1033,10 +1113,7 @@ export interface BuildResumedChatSessionOptions extends Pick< * shape as the dropped `open: () =>` closure ADR-0088 §1.3 records: a signal that type-checks its way out * of existing. Kept identical to the fresh declaration so the two paths cannot disagree again. */ - readonly startMcpClient?: ( - servers: readonly McpServerConfig[], - signal?: AbortSignalLike, - ) => Promise; + readonly startMcpClient?: NonNullable; /** * The consent gate ([ADR-0084](../../../../docs/decisions/0084-consent-before-a-local-mcp-spawn.md) §1) — * threaded to {@link connectAgentMcp} so a chat session's declared stdio server is not spawned until the @@ -1075,6 +1152,7 @@ export interface BuildResumedChatSessionOptions extends Pick< export async function buildResumedChatSession( opts: BuildResumedChatSessionOptions, ): Promise { + opts.workOwner?.assertAdmitting(); const { record, messages } = opts; assertSessionFsPolicy(opts.fsPolicy, record.context.workingDir, opts.chat); const agent = record.agentSnapshot; @@ -1109,6 +1187,7 @@ export async function buildResumedChatSession( ); try { + opts.workOwner?.assertAdmitting(); const { attachUsageWriter, whenHostSafeToClose, @@ -1158,6 +1237,7 @@ export async function buildResumedChatSession( sessionId: record.id, agent, context, + ...(opts.workOwner === undefined ? {} : { workOwner: opts.workOwner }), ...(opts.fsPolicy === undefined ? {} : { fsPolicy: opts.fsPolicy }), contextWindowTokens: sessionContextWindow(agent, deps), tools: deps.tools, @@ -1181,7 +1261,20 @@ export async function buildResumedChatSession( } catch (err) { // Self-clean: a post-connect fault must not leak the just-spawned MCP children (see {@link buildChatSession}). // Best-effort: a teardown rejection must NOT mask the original resume error (preserve the primary). - await mcp?.close().catch(() => undefined); + opts.workOwner?.failure(err); + if (mcp !== undefined) { + try { + await closePreparedMcp( + mcp, + opts.workOwner === undefined + ? undefined + : (_id, cause) => opts.workOwner?.cleanupUncertain(cause), + opts.workOwner?.invocation.registry.budget, + ); + } catch (cause) { + opts.workOwner?.cleanupUncertain(cause); + } + } throw err; } } diff --git a/apps/cli/src/chat/session-prompt.test.ts b/apps/cli/src/chat/session-prompt.test.ts new file mode 100644 index 00000000..0f3c0a1c --- /dev/null +++ b/apps/cli/src/chat/session-prompt.test.ts @@ -0,0 +1,58 @@ +import { PassThrough } from 'node:stream'; + +import { confirm } from '@clack/prompts'; +import { describe, expect, it } from 'vitest'; + +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import { runSessionPrompt } from './session-prompt.js'; + +describe('installed Clack session prompt release', () => { + it('does not close a submitted installed prompt again when terminal custody finishes', async () => { + const f = invocationFixture(); + const input = new PassThrough(); + const output = new PassThrough(); + const chunks: string[] = []; + output.on('data', (chunk: Buffer) => chunks.push(chunk.toString('utf8'))); + const captured = captureIo(); + const answer = runSessionPrompt( + f.invocation, + { + ...captured.io, + stdoutIsTty: true, + writeOutAcknowledged: (text) => + new Promise((resolve, reject) => { + output.write(text, (error) => + error === undefined || error === null ? resolve() : reject(error), + ); + }), + }, + () => f.registry.assertAdmitting(), + () => f.invocation.finiteExit.fail('cli_internal'), + (signal) => + confirm({ + message: 'Synthetic offline consent', + initialValue: true, + input, + output, + signal, + }), + ); + try { + expect(input.listenerCount('keypress')).toBeGreaterThan(0); + input.emit('keypress', '', { name: 'return' }); + expect(await answer).toBe(true); + expect( + chunks.filter((chunk) => chunk === '\n'), + 'SESSION_INSTALLED_CLACK_SINGLE_CLOSE', + ).toHaveLength(1); + expect(input.listenerCount('keypress')).toBe(0); + expect(output.listenerCount('resize')).toBe(0); + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); + } finally { + input.destroy(); + output.destroy(); + } + }); +}); diff --git a/apps/cli/src/chat/session-prompt.ts b/apps/cli/src/chat/session-prompt.ts new file mode 100644 index 00000000..13cb1b7a --- /dev/null +++ b/apps/cli/src/chat/session-prompt.ts @@ -0,0 +1,44 @@ +import type { CliInvocation } from '../process/invocation.js'; +import type { CliIo } from '../process/io.js'; +import { SHOW_CURSOR } from '../render/alt-screen.js'; +import { SessionTerminalOwner } from './session-terminal-owner.js'; + +/** Fulfilment of a bound Clack prompt is its input-release contract; rejection/cancellation alone is not. */ +export async function runSessionPrompt( + invocation: CliInvocation, + io: CliIo, + assertEntering: () => void, + failure: (cause: unknown) => void, + factory: (signal: AbortSignal) => Promise, +): Promise { + const cancel = new AbortController(); + const owner = new SessionTerminalOwner(invocation, assertEntering, failure); + let raw: Promise | undefined; + let fulfilled = false; + try { + owner.acquire((acquisition) => { + acquisition.adopt({ + // Installed Clack retains its abort listener after submission. Aborting a fulfilled + // prompt would run close a second time and write to a terminal it already released. + seal: () => { + if (!fulfilled) cancel.abort(); + }, + release: async () => { + if (raw === undefined) throw new Error('Prompt acquisition was not acknowledged.'); + await raw; + if (io.stdoutIsTty) { + if (invocation.registry.budget.remainingMs() === 0) await new Promise(() => {}); + await io.writeOutAcknowledged(SHOW_CURSOR); + } + }, + }); + raw = owner.enter(() => factory(cancel.signal)); + }); + if (raw === undefined) throw new Error('Prompt acquisition was not acknowledged.'); + const answer = await raw; + fulfilled = true; + return answer; + } finally { + await owner.close(); + } +} diff --git a/apps/cli/src/chat/session-surface-lifetime.test.ts b/apps/cli/src/chat/session-surface-lifetime.test.ts new file mode 100644 index 00000000..5bea43ab --- /dev/null +++ b/apps/cli/src/chat/session-surface-lifetime.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CliError } from '../process/cli-error.js'; +import { createInvocationFiniteExit } from '../process/invocation-finite-exit.js'; +import { InvocationRegistry } from '../process/invocation-registry.js'; +import { SessionSurfaceLifetime } from './session-surface-lifetime.js'; +import { SessionWorkOwner } from './session-work-owner.js'; + +function gate() { + let release: () => void = () => undefined; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +function invocationFixture() { + let now = 0; + const timers = new Map<() => void, number>(); + const clock = { + now: () => now, + schedule: (ms: number, fire: () => void) => { + timers.set(fire, now + ms); + return () => { + timers.delete(fire); + }; + }, + }; + const exit = vi.fn(); + const registry = new InvocationRegistry(clock); + const invocation = { + registry, + finiteExit: createInvocationFiniteExit(registry, clock, { exit, setExitCode: vi.fn() }), + seedFileCatalogOnce: () => Promise.resolve(), + }; + return { + invocation, + registry, + exit, + timers, + advance: (time: number) => { + now = time; + for (const [fire, at] of [...timers]) if (at <= time && timers.delete(fire)) fire(); + }, + }; +} + +function fixture() { + const f = invocationFixture(); + let signal: (signo: number) => void = () => undefined; + const remove = vi.fn(); + const lifetime = new SessionSurfaceLifetime(f.invocation, 'home', (listener) => { + signal = listener; + return remove; + }); + return { ...f, lifetime, remove, signal: (signo: number) => signal(signo) }; +} + +describe('surface roots outlive their shared preparation and database tails', () => { + it.each([0, 1, 2, 4] as const)( + 'preserves actual surface outcome %s without creating a fatal timer or physical exit', + async (outcome) => { + const f = fixture(); + expect(f.registry.activeOwners).toBe(1); + expect(await f.lifetime.execute(() => Promise.resolve(outcome))).toBe(outcome); + await reactions(); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.exitCode()).toBe(outcome); + expect(f.remove).toHaveBeenCalledOnce(); + expect(f.exit).not.toHaveBeenCalled(); + expect(f.timers.size).toBe(0); + }, + ); + + it('does not turn a consumed inline refusal into the final Home outcome', async () => { + const f = fixture(); + const host = new SessionWorkOwner(f.invocation, 'home', { + cancel: () => undefined, + cleanup: () => Promise.resolve(), + }); + expect( + await f.lifetime.execute(async () => { + await expect( + host.enter(() => Promise.reject(new CliError('invalid_invocation', 'inline refusal'))), + ).rejects.toMatchObject({ code: 'invalid_invocation' }); + await host.close(); + return 0; + }), + ).toBe(0); + expect(f.registry.observations.exitCode()).toBe(0); + }); + + it('retains a known escaping refusal and removes signals only after actual root cleanup', async () => { + const f = fixture(); + const cause = new CliError('invalid_invocation', 'synthetic refusal'); + await expect(f.lifetime.execute(() => Promise.reject(cause))).rejects.toBe(cause); + expect(f.registry.observations.exitCode()).toBe(2); + expect(f.remove).toHaveBeenCalledOnce(); + expect(f.exit).not.toHaveBeenCalled(); + }); + + it('keeps a competing infrastructure fault ahead of a later known refusal', async () => { + const f = fixture(); + const refusal = new CliError('invalid_invocation', 'synthetic refusal'); + await expect( + f.lifetime.execute(() => { + f.lifetime.work.failure(new Error('PRIVATE_COMPETING_FAILURE')); + return Promise.reject(refusal); + }), + ).rejects.toMatchObject({ code: 'cli_internal', exitCode: 9, cause: refusal }); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.remove).toHaveBeenCalledOnce(); + }); + + it.each([5, 7, 8] as const)( + 'does not let a session invent workflow-only health %s', + async (value) => { + const f = fixture(); + expect(await f.lifetime.execute(() => Promise.resolve(value))).toBe(9); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.observations.hasInfrastructureFailure()).toBe(true); + f.advance(3000); + await reactions(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + }, + ); + + it('keeps terminating-signal exit behind the shared database tail after the host itself retired', async () => { + const f = fixture(); + const database = gate(); + const hostClosed = gate(); + const host = new SessionWorkOwner(f.invocation, 'home', { + cancel: () => undefined, + cleanup: () => Promise.resolve(), + }); + let databaseClosed = false; + const command = f.lifetime.execute(async () => { + await host.close(); + hostClosed.release(); + await database.promise; + databaseClosed = true; + return 4; + }); + await hostClosed.promise; + f.signal(15); + await reactions(); + expect(f.registry.activeOwners).toBe(1); + expect(databaseClosed).toBe(false); + expect(f.remove).not.toHaveBeenCalled(); + expect(f.exit, 'SESSION_SIGNAL_WAITS_SHARED_DATABASE_ROOT').not.toHaveBeenCalled(); + expect(f.registry.observations.exitCode()).toBe(143); + database.release(); + expect(await command).toBe(4); + await reactions(); + expect(databaseClosed).toBe(true); + expect(f.registry.activeOwners).toBe(0); + expect(f.remove).toHaveBeenCalledOnce(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(143); + }); + + it('uses the original signal budget while held preparation survives competing signals', async () => { + const f = fixture(); + const input = gate(); + const command = f.lifetime.execute(async () => { + await input.promise; + f.lifetime.work.assertAdmitting(); + return 0; + }); + f.signal(2); + expect(f.registry.budget.deadline()).toBe(3000); + f.advance(500); + f.signal(15); + expect(f.registry.budget.deadline(), 'SESSION_SIGNALS_DO_NOT_RENEW_BUDGET').toBe(3000); + expect(f.registry.observations.exitCode()).toBe(130); + f.advance(3000); + await reactions(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(130); + expect(f.registry.activeOwners).toBe(1); + expect(f.remove).not.toHaveBeenCalled(); + input.release(); + // After actual expiry no new async cleanup enters and no positive retirement is fabricated. + void command; + await reactions(); + expect(f.registry.activeOwners).toBe(1); + expect(f.remove).not.toHaveBeenCalled(); + }); + + it('does not acknowledge partially acquired signal custody when registration throws', async () => { + const f = invocationFixture(); + const cause = new Error('PRIVATE_PARTIAL_SIGNAL_REGISTRATION'); + expect( + () => + new SessionSurfaceLifetime(f.invocation, 'agent', () => { + throw cause; + }), + ).toThrow(cause); + await reactions(); + expect(f.registry.activeOwners).toBe(1); + expect(f.registry.observations.exitCode()).toBe(9); + f.advance(3000); + await reactions(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(f.registry.activeOwners).toBe(1); + }); +}); diff --git a/apps/cli/src/chat/session-surface-lifetime.ts b/apps/cli/src/chat/session-surface-lifetime.ts new file mode 100644 index 00000000..4938aad1 --- /dev/null +++ b/apps/cli/src/chat/session-surface-lifetime.ts @@ -0,0 +1,70 @@ +import { CliError, toUserFacing } from '../process/errors.js'; +import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; +import { createCliInvocation, type CliInvocation } from '../process/invocation.js'; +import type { SessionOwner } from '../process/invocation-registry.js'; +import { defaultSubscribeSignals } from '../process/signals.js'; +import { SessionWorkOwner } from './session-work-owner.js'; + +/** + * The surface root includes preparation and the shared database's actual cleanup. Per-host + * retirement alone cannot release this owner or end a signal path ahead of the database tail. + */ +export class SessionSurfaceLifetime { + readonly invocation: CliInvocation; + readonly work: SessionWorkOwner; + readonly #removeSignals: () => void; + + constructor( + invocation: CliInvocation | undefined, + surface: SessionOwner['surface'], + subscribeSignals = defaultSubscribeSignals, + ) { + this.invocation = invocation ?? createCliInvocation(); + this.work = new SessionWorkOwner(this.invocation, surface, { + cancel: () => undefined, + cleanup: () => { + this.#removeSignals(); + return Promise.resolve(); + }, + }); + try { + this.#removeSignals = subscribeSignals((signo) => this.invocation.finiteExit.signal(signo)); + } catch (cause) { + this.work.cleanupUncertain(cause); + throw cause; + } + } + + async execute(body: () => Promise): Promise { + let result: ExitCode = EXIT_CODES.chatEnded; + let failed: { readonly cause: unknown } | undefined; + try { + result = await this.work.enter(body); + if (result === EXIT_CODES.infrastructureFailed) + this.invocation.finiteExit.fail('cli_internal'); + else if (result === 5 || result === 7 || result === 8) { + // Session surfaces do not possess workflow receipt/effect health authority. + this.invocation.finiteExit.fail('cli_internal'); + } else this.invocation.registry.observations.observeOutcome(result); + } catch (cause) { + if (!this.work.isStoppedFailure(cause)) { + failed = { cause }; + this.invocation.registry.observations.observeFailure(toUserFacing(cause).code); + } + } finally { + await this.work.close(); + } + const combined = this.invocation.registry.observations.exitCode(); + if (failed !== undefined) { + if (combined === EXIT_CODES.infrastructureFailed && toUserFacing(failed.cause).exitCode !== 9) + throw new CliError( + this.invocation.registry.observations.infrastructureCode() ?? 'cli_internal', + '', + { cause: failed.cause }, + ); + throw failed.cause; + } + // Native signal exit is already owned by the common finite capability; do not manufacture a session outcome. + return combined === EXIT_CODES.infrastructureFailed ? EXIT_CODES.infrastructureFailed : result; + } +} diff --git a/apps/cli/src/chat/session-terminal-owner.test.ts b/apps/cli/src/chat/session-terminal-owner.test.ts new file mode 100644 index 00000000..1fac6f50 --- /dev/null +++ b/apps/cli/src/chat/session-terminal-owner.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CliError } from '../process/cli-error.js'; +import { createInvocationFiniteExit } from '../process/invocation-finite-exit.js'; +import { InvocationRegistry } from '../process/invocation-registry.js'; +import { captureIo } from '../test-support.js'; +import { runSessionPrompt } from './session-prompt.js'; +import { SessionTerminalOwner } from './session-terminal-owner.js'; + +function gate() { + let release: () => void = () => undefined; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} +const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +function fixture() { + let now = 0; + const timers = new Map<() => void, number>(); + const clock = { + now: () => now, + schedule: (ms: number, fire: () => void) => { + timers.set(fire, now + ms); + return () => { + timers.delete(fire); + }; + }, + }; + const registry = new InvocationRegistry(clock); + const exit = vi.fn(); + const invocation = { + registry, + finiteExit: createInvocationFiniteExit(registry, clock, { exit, setExitCode: vi.fn() }), + seedFileCatalogOnce: () => Promise.resolve(), + }; + const failure = vi.fn(() => invocation.finiteExit.fail('cli_internal')); + const owner = new SessionTerminalOwner(invocation, () => registry.assertAdmitting(), failure); + return { + owner, + invocation, + registry, + failure, + exit, + advance: (time: number) => { + now = time; + for (const [fire, at] of [...timers]) if (at <= now && timers.delete(fire)) fire(); + }, + }; +} + +describe('session terminal acquisition and actual release', () => { + it('joins an entered suspension before native final release and waits for actual exit', async () => { + const f = fixture(); + const suspension = gate(); + const nativeExit = gate(); + const seal = vi.fn(); + const release = vi.fn(() => nativeExit.promise); + f.owner.acquire((creation) => creation.adopt({ seal, release })); + const raw = f.owner.enter(() => suspension.promise); + expect(raw).toBe(suspension.promise); + const closing = f.owner.close(); + expect(f.owner.close()).toBe(closing); + await reactions(); + expect(seal).toHaveBeenCalledOnce(); + expect(release, 'SESSION_SUSPENSION_JOINS_BEFORE_FINAL_UNMOUNT').not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + suspension.release(); + await raw; + await reactions(); + expect(release).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners, 'SESSION_NATIVE_EXIT_IS_NOT_UNMOUNT_RETURN').toBe(1); + nativeExit.release(); + await closing; + await reactions(); + expect(f.registry.activeOwners).toBe(0); + expect(f.failure).not.toHaveBeenCalled(); + }); + + it('never treats a thrown constructor with no handle as no acquisition', async () => { + const f = fixture(); + const cause = new Error('PRIVATE_PARTIAL_RENDER'); + expect(() => + f.owner.acquire(() => { + throw cause; + }), + ).toThrow(cause); + let settled = false; + void f.owner.close().then(() => { + settled = true; + }); + await reactions(); + expect(f.failure).toHaveBeenCalledWith(cause); + expect(f.registry.activeOwners).toBe(1); + f.advance(3000); + await reactions(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(settled, 'SESSION_PARTIAL_RENDER_NEVER_FORGES_RELEASE').toBe(false); + }); + + it('accepts only an explicit trusted no-acquisition declaration', async () => { + const f = fixture(); + f.owner.acquire((creation) => { + creation.acknowledgeNoAcquisition(); + return 123; + }); + await f.owner.close(); + await reactions(); + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); + }); + + it('retains unknown acquisition if capturing a partially acquired native lease throws', async () => { + const f = fixture(); + const cause = new Error('PRIVATE_LEASE_CAPTURE_FAILURE'); + const release = vi.fn(async () => {}); + expect(() => + f.owner.acquire((creation) => + creation.adopt({ + get seal(): () => void { + throw cause; + }, + release, + }), + ), + ).toThrow(cause); + let settled = false; + void f.owner.close().then(() => { + settled = true; + }); + await reactions(); + expect(release).not.toHaveBeenCalled(); + expect(f.registry.activeOwners, 'SESSION_LEASE_CAPTURE_PRECEDES_ACQUISITION_ACK').toBe(1); + expect(settled).toBe(false); + f.advance(3000); + await reactions(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + }); + + it('does not infer no-acquisition from an undeclared constructor return', async () => { + const f = fixture(); + expect(() => f.owner.acquire(() => undefined)).toThrow(); + void f.owner.close(); + await reactions(); + expect(f.registry.activeOwners).toBe(1); + expect(f.registry.observations.exitCode()).toBe(9); + }); + + it('adopts a late handle after reentrant stop and releases it once', async () => { + const f = fixture(); + const seal = vi.fn(); + const release = vi.fn(async () => {}); + f.owner.acquire((creation) => { + f.owner.seal(); + creation.adopt({ seal, release }); + }); + expect(seal).toHaveBeenCalledOnce(); + await f.owner.close(); + expect(release).toHaveBeenCalledOnce(); + expect(() => f.owner.enter(() => Promise.resolve())).toThrow(); + }); + + it('retains a rejected native release and never retries its factory', async () => { + const f = fixture(); + const release = vi.fn(() => Promise.reject(new Error('PRIVATE_EXIT_REFUSAL'))); + f.owner.acquire((creation) => creation.adopt({ seal: () => undefined, release })); + const closing = f.owner.close(); + await reactions(); + expect(f.owner.close()).toBe(closing); + expect(release).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(1); + expect(f.registry.observations.exitCode()).toBe(9); + }); + + it('checks the unchanged budget after held suspension settlement before starting release', async () => { + const f = fixture(); + const suspension = gate(); + const release = vi.fn(async () => {}); + f.owner.acquire((creation) => creation.adopt({ seal: () => undefined, release })); + void f.owner.enter(() => suspension.promise); + f.invocation.finiteExit.fail('cli_internal'); + f.advance(3000); + await reactions(); + suspension.release(); + await reactions(); + expect(release, 'SESSION_EXPIRED_TERMINAL_CANNOT_ENTER_ASYNC_RELEASE').not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + }); + + it('retains a synchronous seal refusal despite a later fulfilled native release', async () => { + const f = fixture(); + const release = vi.fn(async () => {}); + f.owner.acquire((creation) => + creation.adopt({ + seal: () => { + throw new Error('PRIVATE_SEAL_REFUSAL'); + }, + release, + }), + ); + void f.owner.close(); + await reactions(); + expect(release).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(1); + expect(f.registry.observations.exitCode()).toBe(9); + }); + + it('waits for prompt completion and captured cursor delivery before releasing terminal custody', async () => { + const f = fixture(); + await f.owner.close(); // This fixture's empty mount is independent from the new prompt reservation. + const prompt = gate(); + const delivered = gate(); + const io = captureIo(); + const write = vi.fn(() => delivered.promise); + const abortCompletedPrompt = vi.fn(); + const answer = runSessionPrompt( + f.invocation, + { ...io.io, stdoutIsTty: true, writeOutAcknowledged: write }, + () => f.registry.assertAdmitting(), + f.failure, + async (signal) => { + signal.addEventListener('abort', abortCompletedPrompt); + await prompt.promise; + return true; + }, + ); + let settled = false; + void answer.then(() => { + settled = true; + }); + await reactions(); + expect(write).not.toHaveBeenCalled(); + prompt.release(); + await reactions(); + expect(write).toHaveBeenCalledExactlyOnceWith('\u001b[?25h'); + expect(f.registry.activeOwners).toBe(1); + expect(settled).toBe(false); + delivered.release(); + expect(await answer).toBe(true); + expect( + abortCompletedPrompt, + 'SESSION_FULFILLED_PROMPT_IS_NOT_ABORTED_AGAIN', + ).not.toHaveBeenCalled(); + await reactions(); + expect(f.registry.activeOwners).toBe(0); + }); + + it('a prompt refusal is neither input release nor cursor-delivery permission', async () => { + const f = fixture(); + await f.owner.close(); + const io = captureIo(); + const write = vi.fn(async () => {}); + let settled = false; + void runSessionPrompt( + f.invocation, + { ...io.io, stdoutIsTty: true, writeOutAcknowledged: write }, + () => f.registry.assertAdmitting(), + f.failure, + () => Promise.reject(new CliError('cli_internal', '')), + ).then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await reactions(); + expect(write).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + f.advance(3000); + await reactions(); + expect(settled).toBe(false); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + }); + + it('retains a refused captured cursor delivery with its output-unavailable diagnosis', async () => { + const f = fixture(); + await f.owner.close(); + const io = captureIo(); + const write = vi.fn(() => Promise.reject(new CliError('output_unavailable', ''))); + void runSessionPrompt( + f.invocation, + { ...io.io, stdoutIsTty: true, writeOutAcknowledged: write }, + () => f.registry.assertAdmitting(), + () => undefined, + () => Promise.resolve(true), + ); + await reactions(); + expect(write).toHaveBeenCalledOnce(); + expect( + f.registry.observations.infrastructureCode(), + 'SESSION_TERMINAL_OUTPUT_FAILURE_STAYS_CLASSIFIED', + ).toBe('output_unavailable'); + expect(f.registry.activeOwners).toBe(1); + f.advance(3000); + await reactions(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + }); +}); diff --git a/apps/cli/src/chat/session-terminal-owner.ts b/apps/cli/src/chat/session-terminal-owner.ts new file mode 100644 index 00000000..56a9c512 --- /dev/null +++ b/apps/cli/src/chat/session-terminal-owner.ts @@ -0,0 +1,160 @@ +import { EgressWorkScope } from '../engine/egress-work.js'; +import { CliError } from '../process/cli-error.js'; +import { toUserFacing } from '../process/errors.js'; +import type { CliInvocation } from '../process/invocation.js'; +import type { OwnerRetirement } from '../process/invocation-registry.js'; + +const nativeThen: unknown = Object.getOwnPropertyDescriptor(Promise.prototype, 'then')?.value; + +export interface SessionTerminalLease { + /** Seal future input/acquisition synchronously, without unmounting an entered suspension. */ + readonly seal: () => void; + /** Join entered native work, unmount once, then acknowledge actual exit/control delivery. */ + readonly release: () => Promise; +} + +export interface SessionTerminalAcquisition { + readonly adopt: (lease: SessionTerminalLease) => void; + /** Trusted constructor fact, never inferred from a missing handle or a thrown error. */ + readonly acknowledgeNoAcquisition: () => void; +} + +/** One actual mount/prompt/control lifetime; independent from the controller's current UI pointer. */ +export class SessionTerminalOwner { + readonly #invocation: CliInvocation; + readonly #assertEntering: () => void; + readonly #failure: (cause: unknown) => void; + readonly #work = new EgressWorkScope(); + readonly #retired: Promise; + #resolveRetired: (value: OwnerRetirement) => void = () => undefined; + #lease: SessionTerminalLease | undefined; + #acquisition: 'not_entered' | 'unknown' | 'acknowledged' = 'not_entered'; + #sealed = false; + #cleanupUncertain = false; + #closing: Promise | undefined; + + constructor( + invocation: CliInvocation, + assertEntering: () => void, + failure: (cause: unknown) => void, + ) { + this.#invocation = invocation; + this.#assertEntering = assertEntering; + this.#failure = failure; + invocation.finiteExit.activateFallback(); + this.#retired = new Promise((resolve) => { + this.#resolveRetired = resolve; + }); + invocation.registry.register({ + kind: 'terminal', + retired: this.#retired, + stop: this.seal, + join: this.close, + restore: this.close, + classifyFailure: () => 'cli_internal', + }); + } + + #assertAdmitting(): void { + this.#assertEntering(); + if (this.#sealed) throw new CliError('cli_internal', ''); + } + + #acquisitionAcknowledged(): boolean { + return this.#acquisition === 'acknowledged'; + } + + acquire(factory: (acquisition: SessionTerminalAcquisition) => T): T { + this.#assertAdmitting(); + if (this.#acquisition !== 'not_entered') throw new CliError('cli_internal', ''); + this.#acquisition = 'unknown'; + const declare = (): void => { + if (this.#acquisition !== 'unknown') throw new CliError('cli_internal', ''); + this.#acquisition = 'acknowledged'; + }; + try { + const value = factory( + Object.freeze({ + adopt: (lease: SessionTerminalLease) => { + // Capturing native cleanup callbacks can itself throw. Until that completes the + // creation remains unknown, rather than an acknowledged lease with no release port. + const captured = Object.freeze({ ...lease }); + declare(); + this.#lease = captured; + if (this.#sealed) { + try { + this.#lease.seal(); + } catch (cause) { + this.#uncertain(cause); + throw cause; + } + } + }, + acknowledgeNoAcquisition: declare, + }), + ); + if (!this.#acquisitionAcknowledged()) throw new CliError('cli_internal', ''); + return value; + } catch (cause) { + this.#failure(cause); + throw cause; + } + } + + /** Exact raw operations include the full installed Ink suspension/publication continuation. */ + enter(factory: () => Promise): Promise { + this.#assertAdmitting(); + try { + const raw = this.#work.retainWork(factory); + if (this.#work.observationFailed) this.#uncertain(new CliError('cli_internal', '')); + if (typeof nativeThen !== 'function') throw new CliError('cli_internal', ''); + Reflect.apply(nativeThen, raw, [undefined, this.#failure]); + return raw; + } catch (cause) { + this.#failure(cause); + throw cause; + } + } + + readonly seal = (): void => { + if (this.#sealed) return; + this.#sealed = true; + this.#work.seal(); + try { + this.#lease?.seal(); + } catch (cause) { + this.#uncertain(cause); + } + }; + + #uncertain(cause: unknown): void { + this.#cleanupUncertain = true; + const code = + toUserFacing(cause).code === 'output_unavailable' ? 'output_unavailable' : 'cli_internal'; + this.#invocation.finiteExit.fail(code); + this.#failure(new CliError(code, '', { cause })); + } + + readonly close = (): Promise => { + if (this.#closing !== undefined) return this.#closing; + let complete: () => void = () => undefined; + this.#closing = new Promise((resolve) => { + complete = resolve; + }); + this.seal(); + void Promise.resolve() + .then(async () => { + // Ink resumes captured input when its suspension resolves, even after unmount. Only an + // actual join BEFORE final release can prevent that continuation from reactivating input. + await this.#work.done; + if (this.#acquisition === 'unknown' || this.#invocation.registry.budget.remainingMs() === 0) + return; + await this.#lease?.release(); + if (this.#cleanupUncertain) return; + this.#resolveRetired({ kind: 'settled' }); + complete(); + }) + .catch((cause: unknown) => this.#uncertain(cause)); + return this.#closing; + }; +} diff --git a/apps/cli/src/chat/session-usage-ack.test.ts b/apps/cli/src/chat/session-usage-ack.test.ts index afe41849..5cbf9de9 100644 --- a/apps/cli/src/chat/session-usage-ack.test.ts +++ b/apps/cli/src/chat/session-usage-ack.test.ts @@ -122,7 +122,13 @@ it.each([0, 10])( name: 'LedgerDurabilityError', }); f.built.session.cancel(); - await f.built.whenHostSafeToClose(); + await expect(f.built.whenHostSafeToClose()).rejects.toThrow( + 'Session cost ledger could not be made durable.', + ); + await expect( + f.built.whenHostSafeToClose(), + 'SESSION_SETTLEMENT_FAILURE_REMAINS_AFTER_FIRST_JOIN', + ).rejects.toThrow('Session cost ledger could not be made durable.'); f.view.flush(); expect(f.snapshot().coverage.pendingInvocations).toBe(1); expect(f.snapshot().totalCostMicrocents).toBe(0); diff --git a/apps/cli/src/chat/session-work-owner.test.ts b/apps/cli/src/chat/session-work-owner.test.ts new file mode 100644 index 00000000..358f0940 --- /dev/null +++ b/apps/cli/src/chat/session-work-owner.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CliError } from '../process/cli-error.js'; +import { createInvocationFiniteExit } from '../process/invocation-finite-exit.js'; +import { InvocationRegistry } from '../process/invocation-registry.js'; +import { SessionWorkOwner, type SessionWorkOwnerPorts } from './session-work-owner.js'; + +function gate() { + let release: () => void = () => undefined; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +function fixture(ports: Partial = {}) { + let now = 0; + const timers = new Map<() => void, number>(); + const clock = { + now: () => now, + schedule: (ms: number, fire: () => void) => { + timers.set(fire, now + ms); + return () => { + timers.delete(fire); + }; + }, + }; + const exit = vi.fn(); + const registry = new InvocationRegistry(clock); + const invocation = { + registry, + finiteExit: createInvocationFiniteExit(registry, clock, { exit, setExitCode: vi.fn() }), + seedFileCatalogOnce: () => Promise.resolve(), + }; + const cancel = ports.cancel ?? vi.fn(); + const cleanup = ports.cleanup ?? vi.fn(() => Promise.resolve()); + const owner = new SessionWorkOwner(invocation, 'chat', { cancel, cleanup }); + return { + owner, + invocation, + registry, + exit, + timers, + cancel, + cleanup, + advance: (time: number) => { + now = time; + for (const [fire, at] of [...timers]) if (at <= time && timers.delete(fire)) fire(); + }, + }; +} + +describe('creation-owned session work and actual cleanup', () => { + it('reserves before construction without arming an ordinary timer, and returns exact raw work', async () => { + const f = fixture(); + const entered = gate(); + expect(f.registry.activeOwners).toBe(1); + expect(f.timers.size).toBe(0); + const factory = vi.fn(() => { + expect(f.registry.activeOwners).toBe(1); + return entered.promise; + }); + expect(f.owner.enter(factory)).toBe(entered.promise); + const closing = f.owner.close(); + await reactions(); + expect(factory).toHaveBeenCalledOnce(); + expect(f.cancel).toHaveBeenCalledOnce(); + expect(f.cleanup).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + entered.release(); + await closing; + await reactions(); + expect(f.cleanup).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); + expect(f.timers.size).toBe(0); + }); + + it('keeps transferred descendants owed after their root settles and admission seals', async () => { + const f = fixture(); + const child = gate(); + await f.owner.enter(() => { + void f.owner.work.retainWork(() => child.promise); + return Promise.resolve(); + }); + const closing = f.owner.close(); + await reactions(); + expect(f.cleanup).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + expect(() => f.owner.enter(() => Promise.resolve())).toThrow('Session operation stopped.'); + expect(() => f.owner.work.retainWork(() => Promise.resolve())).toThrow(); + child.release(); + await closing; + await reactions(); + expect(f.registry.activeOwners).toBe(0); + }); + + it('does not promote a descendant transport rejection into an unexpected root failure', async () => { + const f = fixture(); + const cause = new Error('PRIVATE_TRANSPORT_CANCELLATION'); + const transport = f.owner.work.retainWork(() => Promise.reject(cause)); + await expect(transport).rejects.toBe(cause); + expect(f.registry.termination).toBeUndefined(); + expect(f.owner.firstFailure).toBeUndefined(); + await f.owner.close(); + }); + + it('keeps cleanup-tail transfer open until already-entered roots actually settle', async () => { + const f = fixture(); + const rootGate = gate(); + const tail = gate(); + const raw = f.owner.enter(async () => { + await rootGate.promise; + void f.owner.work.retainWork(() => tail.promise); + }); + const closing = f.owner.close(); + rootGate.release(); + await raw; + await reactions(); + expect(f.owner.firstFailure).toBeUndefined(); + expect(f.cleanup, 'SESSION_ADMITTED_ROOT_CAN_TRANSFER_CLEANUP_TAIL').not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + expect(() => f.owner.work.retainWork(() => Promise.resolve())).toThrow(); + tail.release(); + await closing; + await reactions(); + expect(f.registry.activeOwners).toBe(0); + }); + + it('does not let an overridden raw then fabricate settlement', async () => { + const f = fixture(); + const work = gate(); + const fakeThen = vi.fn(() => { + throw new Error('PRIVATE_FAKE_THEN'); + }); + void Object.defineProperty(work.promise, 'then', { value: fakeThen }); + expect(f.owner.enter(() => work.promise)).toBe(work.promise); + const closing = f.owner.close(); + await reactions(); + expect(f.cleanup).not.toHaveBeenCalled(); + expect(fakeThen).not.toHaveBeenCalled(); + work.release(); + await closing; + expect(fakeThen).not.toHaveBeenCalled(); + }); + + it('starts the original budget before a rejected root can be held behind cleanup', async () => { + const cleanup = gate(); + const f = fixture({ cleanup: () => cleanup.promise }); + const cause = new Error('PRIVATE_ROOT_FAILURE'); + f.advance(500); + await expect(f.owner.enter(() => Promise.reject(cause))).rejects.toBe(cause); + await reactions(); + expect(f.owner.firstFailure?.cause).toBe(cause); + expect(f.owner.signal.aborted).toBe(true); + expect(f.registry.budget.deadline(), 'SESSION_ROOT_FAULT_STARTS_BUDGET').toBe(3500); + expect(f.registry.activeOwners).toBe(1); + f.advance(1000); + f.owner.failure(new Error('PRIVATE_LATER_FAILURE')); + expect(f.registry.budget.deadline()).toBe(3500); + expect(f.owner.firstFailure?.cause).toBe(cause); + f.advance(3499); + await reactions(); + expect(f.exit).not.toHaveBeenCalled(); + f.advance(3500); + await reactions(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(f.registry.activeOwners).toBe(1); + cleanup.release(); + await f.owner.close(); + await reactions(); + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).toHaveBeenCalledOnce(); + }); + + it('retains an opaque root rejection even when its value is undefined', async () => { + const f = fixture(); + const rejected = new Promise((_resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Foreign promises can reject undefined; presence cannot be tested by truthiness. + reject(undefined); + }); + await expect(f.owner.enter(() => rejected)).rejects.toBeUndefined(); + await f.owner.close(); + expect(f.owner.firstFailure).toEqual({ cause: undefined }); + expect(f.registry.observations.exitCode()).toBe(9); + }); + + it('keeps a known inline refusal out of the eventual surface outcome and future admission', async () => { + const f = fixture(); + const refusal = new CliError('invalid_invocation', 'fixture refusal'); + await expect(f.owner.enter(() => Promise.reject(refusal))).rejects.toBe(refusal); + expect(f.registry.termination).toBeUndefined(); + expect(f.registry.observations.exitCode()).toBe(0); + expect(f.owner.firstFailure).toBeUndefined(); + expect(await f.owner.enter(() => Promise.resolve(42))).toBe(42); + await f.owner.close(); + expect(f.registry.observations.exitCode()).toBe(0); + }); + + it('shares actual cleanup before synchronous cancellation callback reentry', async () => { + const slot: { owner?: SessionWorkOwner } = {}; + let reentered: Promise | undefined; + const cleanup = gate(); + const f = fixture({ + cancel: () => { + reentered = slot.owner?.close(); + }, + cleanup: () => cleanup.promise, + }); + const owner = f.owner; + slot.owner = owner; + const first = owner.close(); + expect(reentered).toBe(first); + expect(owner.close()).toBe(first); + let settled = false; + void first.then(() => { + settled = true; + }); + await reactions(); + expect(settled, 'SESSION_SHARED_CLEANUP_IS_ACTUAL').toBe(false); + expect(f.registry.activeOwners).toBe(1); + cleanup.release(); + await first; + await reactions(); + expect(settled).toBe(true); + expect(f.registry.activeOwners).toBe(0); + }); + + it('does not retire a callback-reported failure when its manager close fulfils', async () => { + const cleanup = vi.fn(() => Promise.resolve()); + const f = fixture({ cleanup }); + f.owner.cleanupUncertain(new Error('PRIVATE_MANAGER_CALLBACK_FAILURE')); + let retired = false; + void f.owner.close().then(() => { + retired = true; + }); + await reactions(); + expect(cleanup).toHaveBeenCalledOnce(); + expect(retired, 'SESSION_CALLBACK_FAULT_WITHHOLDS_RETIREMENT').toBe(false); + expect(f.registry.activeOwners).toBe(1); + expect(f.registry.observations.exitCode()).toBe(9); + f.advance(3000); + await reactions(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(f.registry.activeOwners).toBe(1); + expect(retired).toBe(false); + }); + + it('keeps cleanup rejection distinct from an actual fulfilled cleanup acknowledgement', async () => { + const cause = new Error('PRIVATE_CLEANUP_FAILURE'); + const f = fixture({ cleanup: () => Promise.reject(cause) }); + let retired = false; + void f.owner.close().then(() => { + retired = true; + }); + await reactions(); + expect(retired).toBe(false); + expect(f.owner.firstFailure?.cause).toBe(cause); + expect(f.registry.activeOwners).toBe(1); + expect(f.registry.observations.exitCode()).toBe(9); + f.advance(3000); + await reactions(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(retired).toBe(false); + }); + + it('does not enter a new async cleanup after a held root outlives the original deadline', async () => { + const f = fixture(); + const root = gate(); + void f.owner.enter(() => root.promise); + f.owner.failure(new Error('PRIVATE_FIRST_FAULT')); + const closing = f.owner.close(); + f.advance(3000); + await reactions(); + root.release(); + await reactions(); + expect(f.cleanup, 'SESSION_NO_NEW_CLEANUP_AFTER_EXPIRY').not.toHaveBeenCalled(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(f.registry.activeOwners).toBe(1); + expect(f.owner.close()).toBe(closing); + }); + + it('treats a synchronous cancellation fault as retained uncertainty without skipping cleanup', async () => { + const f = fixture({ + cancel: () => { + throw new Error('PRIVATE_CANCEL_FAILURE'); + }, + }); + void f.owner.close(); + await reactions(); + expect(f.cleanup).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(1); + expect(f.registry.observations.exitCode()).toBe(9); + f.advance(3000); + await reactions(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + }); + + it('tracks distinct pending/replaced host instances independently of a shared surface', async () => { + const first = gate(); + const second = gate(); + const f = fixture({ cleanup: () => first.promise }); + const other = new SessionWorkOwner(f.invocation, 'chat', { + cancel: vi.fn(), + cleanup: () => second.promise, + }); + expect(f.registry.activeOwners).toBe(2); + const one = f.owner.close(); + const two = other.close(); + first.release(); + await one; + await reactions(); + expect(f.registry.activeOwners, 'SESSION_DISTINCT_HOST_OWNERS').toBe(1); + second.release(); + await two; + await reactions(); + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/cli/src/chat/session-work-owner.ts b/apps/cli/src/chat/session-work-owner.ts new file mode 100644 index 00000000..3d1afa7a --- /dev/null +++ b/apps/cli/src/chat/session-work-owner.ts @@ -0,0 +1,153 @@ +import { EgressWorkScope } from '../engine/egress-work.js'; +import { toUserFacing } from '../process/errors.js'; +import type { CliInvocation } from '../process/invocation.js'; +import type { OwnerRetirement, SessionOwner } from '../process/invocation-registry.js'; + +const observePromise: unknown = Object.getOwnPropertyDescriptor(Promise.prototype, 'then')?.value; + +export interface SessionWorkOwnerPorts { + /** Finite cancellation only, including when the common termination budget is exhausted. */ + readonly cancel: () => void; + /** Fulfilment must mean actual complete cleanup, independently of any retained receipt failure. */ + readonly cleanup: () => Promise; +} + +/** + * Reserve each host instance before its asynchronous construction. UI pointers and session IDs do + * not identify its lifetime: a late, replaced or superseded build still owns its entered work. + */ +export class SessionWorkOwner { + readonly invocation: CliInvocation; + readonly #ports: SessionWorkOwnerPorts; + readonly #roots = new EgressWorkScope(); + readonly #work = new EgressWorkScope(); + /** Already-entered roots may still transfer cleanup/receipt tails. This grants no new egress authority. */ + readonly work = Object.freeze({ + retainWork: (factory: () => Promise): Promise => { + const raw = this.#work.retainWork(factory); + if (this.#work.observationFailed) + this.failure(new Error('Session work observation is unavailable.')); + return raw; + }, + }); + readonly #cancel = new AbortController(); + readonly #stoppedMarker = new Error('Session operation stopped.'); + readonly #retired: Promise; + #resolveRetired: (facts: OwnerRetirement) => void = () => undefined; + #stopped = false; + #cleanupUncertain = false; + #closing: Promise | undefined; + #firstFailure: { readonly cause: unknown } | undefined; + + constructor( + invocation: CliInvocation, + surface: SessionOwner['surface'], + ports: SessionWorkOwnerPorts, + ) { + this.invocation = invocation; + Object.freeze(this.#stoppedMarker); + this.#ports = Object.freeze({ ...ports }); + invocation.finiteExit.activateFallback(); + this.#retired = new Promise((resolve) => { + this.#resolveRetired = resolve; + }); + invocation.registry.register({ + kind: 'session', + surface, + retired: this.#retired, + stop: this.stop, + join: this.close, + classifyFailure: () => 'cli_internal', + }); + } + + get signal(): AbortSignal { + return this.#cancel.signal; + } + + get firstFailure(): { readonly cause: unknown } | undefined { + return this.#firstFailure; + } + + readonly isStoppedFailure = (cause: unknown): boolean => cause === this.#stoppedMarker; + + readonly assertAdmitting = (): void => { + if (this.#stopped || this.invocation.registry.termination !== undefined) + throw this.#stoppedMarker; + this.invocation.registry.assertAdmitting(); + }; + + /** Register before entry, observe escaping infrastructure immediately, and return the exact raw promise. */ + readonly enter = (operation: () => Promise): Promise => { + this.assertAdmitting(); + let raw: Promise; + try { + raw = this.#roots.retainWork(operation); + } catch (cause) { + this.failure(cause); + throw cause; + } + try { + if (typeof observePromise !== 'function') throw new Error('Promise observation unavailable.'); + Reflect.apply(observePromise, raw, [undefined, this.failure]); + } catch (cause) { + // A failed observer is not settlement; the independently retained scope still owes its slot. + this.failure(cause); + } + return raw; + }; + + /** Known inline refusals are not the eventual chat/Home outcome. Only unexpected faults stop the invocation. */ + readonly failure = (cause: unknown): void => { + if (this.isStoppedFailure(cause)) return; + const facing = toUserFacing(cause); + if (facing.code !== 'cli_internal' && facing.code !== 'output_unavailable') return; + this.#firstFailure ??= { cause }; + this.invocation.finiteExit.fail(facing.code); + }; + + /** A callback-reported close fault is retained even when the manager's close promise fulfils. */ + readonly cleanupUncertain = (cause: unknown): void => { + this.#cleanupUncertain = true; + this.#firstFailure ??= { cause }; + this.invocation.finiteExit.fail('cli_internal'); + }; + + readonly stop = (): void => { + if (this.#stopped) return; + this.#stopped = true; + this.#roots.seal(); + this.#cancel.abort(this.#stoppedMarker); + try { + this.#ports.cancel(); + } catch (cause) { + this.cleanupUncertain(cause); + } + }; + + /** Publish one shared cleanup promise before callbacks. Cancellation, rejection and expiry grant no retirement. */ + readonly close = (): Promise => { + if (this.#closing !== undefined) return this.#closing; + let complete: () => void = () => undefined; + const pending = new Promise((resolve) => { + complete = resolve; + }); + this.#closing = pending; + this.stop(); + void Promise.resolve() + .then(async () => { + // Cancellation seals future roots, not the cleanup continuations of roots already entered. + // Those roots can still register iterator.return/receipt tails before their actual settlement. + await this.#roots.done; + this.#work.seal(); + await this.#work.done; + if (this.invocation.registry.budget.remainingMs() === 0) return; + await this.#ports.cleanup(); + if (this.#cleanupUncertain) return; + this.#resolveRetired({ kind: 'settled' }); + complete(); + }) + .catch(this.cleanupUncertain); + return pending; + }; +} diff --git a/apps/cli/src/commands/agent-run.test.ts b/apps/cli/src/commands/agent-run.test.ts index c1cd9e35..db188b90 100644 --- a/apps/cli/src/commands/agent-run.test.ts +++ b/apps/cli/src/commands/agent-run.test.ts @@ -15,8 +15,13 @@ import { buildChatSession } from '../chat/session-host.js'; import { openSessionStore } from '../history/session-open.js'; import { scriptedResolver, textTurn, unresolvedResolver } from '../chat/test-support.js'; import type { ProviderResolver } from '../engine/providers.js'; -import { isCliError } from '../process/errors.js'; +import { CliError, isCliError } from '../process/errors.js'; import { EXIT_CODES } from '../process/exit-codes.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import * as signalPorts from '../process/signals.js'; +import * as sessionOpen from '../history/session-open.js'; +import * as consentPrompts from '../mcp/consent-prompt.js'; +import { mcpConsentPath } from '../engine/mcp-consent-gate.js'; import type { GlobalOptions } from '../process/options.js'; import { captureIo, parseNdjson } from '../test-support.js'; import { agentRunCommand, readAllStdin, type AgentRunCommandDeps } from './agent-run.js'; @@ -49,11 +54,13 @@ function globalOptions(cwd: string, json = false): GlobalOptions { return { json, color: false, cwd, configPath: undefined, verbosity: 'normal' }; } +const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); + describe('agentRunCommand (2.Q)', () => { - it('closes MCP after the lifetime join reports a durability failure', async () => { + it('attempts MCP cleanup but retains the database obligation after a refused durability join', async () => { const closeMcp = vi.fn(async () => {}); const failure = new Error('synthetic final usage write failure'); - const { d } = deps('synthetic prompt', { + const { d, control } = deps('synthetic prompt', { providers: scriptedResolver([textTurn('done')]), onBuilt: (built) => { const join = built.whenHostSafeToClose; @@ -64,13 +71,28 @@ describe('agentRunCommand (2.Q)', () => { }, }); const original = d.buildSession ?? buildChatSession; - await expect( - agentRunCommand( - { agent: agentPath(), input: [], allowMcpStdio: [] }, - { ...d, buildSession: async (options) => ({ ...(await original(options)), closeMcp }) }, - ), - ).rejects.toBe(failure); - expect(closeMcp).toHaveBeenCalledTimes(1); + let settled = false; + void agentRunCommand( + { agent: agentPath(), input: [], allowMcpStdio: [] }, + { ...d, buildSession: async (options) => ({ ...(await original(options)), closeMcp }) }, + ).then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await vi.waitFor(() => expect(closeMcp).toHaveBeenCalledOnce()); + expect(settled, 'AGENT_REFUSED_JOIN_IS_NOT_DATABASE_CLOSE_PERMISSION').toBe(false); + expect(control.invocation.registry.activeOwners).toBe(2); + expect(control.invocation.registry.observations.exitCode()).toBe(9); + expect(productStores).toHaveLength(1); + expect(productStores[0]?.db.$client.open).toBe(true); + control.advance(3000); + await reactions(); + expect(control.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(settled).toBe(false); }); it('keeps only a hidden durable identity and refuses reuse before provider egress', async () => { @@ -144,10 +166,18 @@ describe('agentRunCommand (2.Q)', () => { } }); - it.each(['success', 'failure', 'abort', 'teardown', 'setup-unwind'] as const)( + it.each([ + 'success', + 'failure', + 'abort', + 'setup-unwind', + 'teardown', + 'warning-held', + 'warning-refused', + ] as const)( 'sweeps only owned committed one-shot effects on %s, retaining unresolved evidence', async (mode) => { - const { d, out } = deps('synthetic prompt', { + const { d, out, control } = deps('synthetic prompt', { json: true, providers: mode === 'failure' ? unresolvedResolver() : scriptedResolver([textTurn('done')]), onBuilt: (built) => { @@ -187,6 +217,10 @@ describe('agentRunCommand (2.Q)', () => { if (state !== 'prepared') journal.settle(identity, state, 'synthetic private result'); } + if (mode === 'warning-held' || mode === 'warning-refused') + opened.db.$client.exec( + "CREATE TRIGGER refuse_effect_sweep BEFORE DELETE ON run_effects BEGIN SELECT RAISE(ABORT, 'synthetic_retention_refusal'); END", + ); } finally { opened.close(); } @@ -203,21 +237,73 @@ describe('agentRunCommand (2.Q)', () => { }); }, }); + let acknowledgeWarning: () => void = () => undefined; + const warning = new Promise((resolve) => { + acknowledgeWarning = resolve; + }); + const writeWarning = vi.fn(() => + mode === 'warning-refused' + ? Promise.reject(new CliError('output_unavailable', '')) + : warning, + ); const original = d.buildSession ?? buildChatSession; - const buildSession: typeof buildChatSession = async (options) => { - const built = await original(options); - return mode === 'teardown' - ? { - ...built, - closeMcp: () => Promise.reject(new Error('synthetic teardown refusal')), - } - : built; - }; + const closeMcp = vi.fn(() => Promise.reject(new Error('synthetic teardown refusal'))); const action = agentRunCommand( { agent: agentPath(), input: [], allowMcpStdio: [] }, - { ...d, buildSession }, + { + ...d, + ...(mode === 'teardown' + ? { + buildSession: async (options: Parameters[0]) => ({ + ...(await original(options)), + closeMcp, + }), + } + : {}), + ...(mode === 'warning-held' || mode === 'warning-refused' + ? { io: { ...d.io, writeErrAcknowledged: writeWarning } } + : {}), + }, ); - if (mode === 'setup-unwind') await expect(action).rejects.toThrow('synthetic setup refusal'); + if (mode === 'teardown') { + let settled = false; + void action.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await vi.waitFor(() => expect(closeMcp).toHaveBeenCalledOnce()); + expect(control.invocation.registry.activeOwners).toBe(2); + expect(control.invocation.registry.budget.deadline()).toBe(3000); + control.advance(3000); + await reactions(); + expect(control.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(settled, 'AGENT_CLOSE_REFUSAL_PREVENTS_RETENTION_AND_DATABASE_ACK').toBe(false); + } else if (mode === 'warning-held') { + let settled = false; + void action.then(() => { + settled = true; + }); + await vi.waitFor(() => expect(writeWarning).toHaveBeenCalledOnce()); + await reactions(); + expect(settled, 'AGENT_RETENTION_WARNING_IS_AN_OWNED_DELIVERY_TAIL').toBe(false); + expect(control.invocation.registry.activeOwners).toBe(1); + acknowledgeWarning(); + expect(await action).toBe(0); + } else if (mode === 'warning-refused') { + expect(await action).toBe(9); + expect(writeWarning).toHaveBeenCalledOnce(); + expect(control.invocation.registry.observations.infrastructureCode()).toBe( + 'output_unavailable', + ); + control.advance(3000); + await reactions(); + expect(control.exit).toHaveBeenCalledExactlyOnceWith(9); + } else if (mode === 'setup-unwind') + await expect(action).rejects.toThrow('synthetic setup refusal'); else expect(await action).toBe(mode === 'failure' ? 1 : 0); if (mode === 'abort') expect(out()).toContain('"stopReason":"aborted"'); const opened = openSessionStore(home); @@ -227,6 +313,9 @@ describe('agentRunCommand (2.Q)', () => { .prepare('SELECT state, result_json FROM run_effects ORDER BY slot') .all(), ).toEqual([ + ...(mode === 'teardown' || mode === 'warning-held' || mode === 'warning-refused' + ? [{ state: 'committed', result_json: null }] + : []), { state: 'ambiguous', result_json: null }, { state: 'prepared', result_json: null }, ]); @@ -236,11 +325,195 @@ describe('agentRunCommand (2.Q)', () => { } }, ); + it('observes a setup fault before a held host cleanup', async () => { + let release: () => void = () => undefined; + const closing = new Promise((resolve) => { + release = resolve; + }); + const closeMcp = vi.fn(() => closing); + const failure = new Error('PRIVATE_SETUP_FAILURE'); + const { d, control, out, err } = deps('synthetic prompt', { + providers: scriptedResolver([textTurn('must not egress')]), + onBuilt: (built) => { + vi.spyOn(built, 'attachEffectJournal').mockImplementation(() => { + throw failure; + }); + }, + }); + const original = d.buildSession ?? buildChatSession; + const result = agentRunCommand( + { agent: agentPath(), input: [], allowMcpStdio: [] }, + { ...d, buildSession: async (options) => ({ ...(await original(options)), closeMcp }) }, + ).catch((cause: unknown) => cause); + try { + await vi.waitFor(() => expect(closeMcp).toHaveBeenCalledOnce()); + expect( + control.invocation.registry.budget.deadline(), + 'AGENT_SETUP_FAULT_BEFORE_HELD_CLOSE', + ).toBe(3000); + expect(control.invocation.registry.observations.exitCode()).toBe(9); + expect(control.invocation.registry.activeOwners).toBe(2); + expect(productStores[0]?.db.$client.open).toBe(true); + expect(out()).not.toContain('must not egress'); + expect(err()).not.toContain('PRIVATE_SETUP_FAILURE'); + } finally { + release(); + } + expect(await result).toBe(failure); + control.advance(3000); + await reactions(); + }); + + it('does not disguise an unexpected clock fault at hidden-owner reservation', async () => { + let built = false; + const failure = new Error('PRIVATE_CLOCK_FAILURE'); + const { d, control } = deps('synthetic prompt', { + providers: scriptedResolver([textTurn('must not egress')]), + onBuilt: () => { + built = true; + }, + }); + await expect( + agentRunCommand( + { agent: agentPath(), input: [], allowMcpStdio: [] }, + { + ...d, + now: () => { + if (built) { + built = false; // This probe faults reservation; later native cleanup remains healthy. + throw failure; + } + return 0; + }, + }, + ), + ).rejects.toBe(failure); + expect(control.invocation.registry.observations.exitCode()).toBe(9); + control.advance(3000); + await reactions(); + expect(control.exit).toHaveBeenCalledExactlyOnceWith(9); + }); + + it('cancels a captured native stdin read and retains its actual delayed close before signal exit', async () => { + let acknowledgeDestroy: (error?: Error | null) => void = () => undefined; + let readEntered: () => void = () => undefined; + const reading = new Promise((resolve) => { + readEntered = resolve; + }); + const input = new Readable({ + read() { + readEntered(); + }, + destroy(_error, callback) { + acknowledgeDestroy = () => callback(_error); + }, + }); + const { d, control } = deps('unused'); + const keys = vi.fn(); + let returned = false; + const action = agentRunCommand( + { agent: agentPath(), input: [], allowMcpStdio: [] }, + { ...d, io: { ...d.io, stdin: input }, resolveKeys: keys }, + ); + void action.then( + () => { + returned = true; + }, + () => { + returned = true; + }, + ); + await reading; + control.invocation.finiteExit.signal(15); + await reactions(); + expect(input.destroyed).toBe(true); + expect(input.closed).toBe(false); + expect(keys).not.toHaveBeenCalled(); + expect(control.invocation.registry.activeOwners, 'AGENT_STDIN_CLOSE_IS_ACTUAL_NOT_ABORT').toBe( + 1, + ); + expect(returned).toBe(false); + expect(control.exit).not.toHaveBeenCalled(); + acknowledgeDestroy(); + expect(await action).toBe(4); // The fake port returns; the real signal outcome is owned below. + await reactions(); + expect(input.closed).toBe(true); + expect(control.invocation.registry.activeOwners).toBe(0); + expect(control.exit).toHaveBeenCalledExactlyOnceWith(143); + }); + + it.each([false, true])( + 'the default MCP gate owns a late consent answer (stopped=%s)', + async (stopped) => { + writeFileSync( + agentPath(), + `${AGENT_YAML}\nmcp_servers:\n - id: synthetic\n transport: stdio\n command: ${JSON.stringify(process.execPath)}\n`, + ); + let answer: (approved: boolean) => void = () => undefined; + const pendingAnswer = new Promise((resolve) => { + answer = resolve; + }); + let entered: () => void = () => undefined; + const prompted = new Promise((resolve) => { + entered = resolve; + }); + const prompt = vi.fn(async () => { + entered(); + return pendingAnswer; + }); + vi.spyOn(consentPrompts, 'createBoundConsentPrompter').mockReturnValue(prompt); + const { d, control } = deps('synthetic prompt', { + providers: scriptedResolver([textTurn('done')]), + }); + const start = vi.fn(() => realStartMcpClient([])); // A real empty manager; no native child or network. + const action = agentRunCommand( + { agent: agentPath(), input: [], allowMcpStdio: [] }, + { + ...d, + io: { ...d.io, stdinIsTty: true, stdoutIsTty: true }, + buildSession: (options) => buildChatSession({ ...options, startMcpClient: start }), + }, + ); + try { + await prompted; + expect(prompt).toHaveBeenCalledOnce(); + expect(start).not.toHaveBeenCalled(); + if (stopped) { + control.invocation.finiteExit.signal(15); + await reactions(); + expect(control.registry.activeOwners).toBe(3); + } + } finally { + answer(true); + } + expect(await action).toBe(stopped ? 4 : 0); + await reactions(); + expect(existsSync(mcpConsentPath(home)), 'AGENT_LATE_CONSENT_CANNOT_APPEND_GRANT').toBe( + !stopped, + ); + expect(start).toHaveBeenCalledTimes(stopped ? 0 : 1); + expect(control.registry.activeOwners).toBe(0); + if (stopped) expect(control.exit).toHaveBeenCalledExactlyOnceWith(143); + else expect(control.exit).not.toHaveBeenCalled(); + }, + ); + let cwd: string; let home: string; const savedHome = new Map(); + const productStores: ReturnType[] = []; beforeEach(() => { + productStores.length = 0; + const open = sessionOpen.openSessionStore; + vi.spyOn(sessionOpen, 'openSessionStore').mockImplementation((directory) => { + const opened = open(directory); + productStores.push(opened); + return opened; + }); + // These command units use explicit fake exit/clock ports. Actual OS signal/exit evidence belongs + // to the native child cohorts, not a Vitest worker's process-wide listeners. + vi.spyOn(signalPorts, 'defaultSubscribeSignals').mockReturnValue(() => undefined); cwd = mkdtempSync(join(tmpdir(), 'relavium-agentrun-cwd-')); home = mkdtempSync(join(tmpdir(), 'relavium-agentrun-home-')); for (const v of HOME_ENV_VARS) { @@ -250,6 +523,10 @@ describe('agentRunCommand (2.Q)', () => { writeFileSync(join(cwd, 'coder.agent.yaml'), AGENT_YAML); }); afterEach(() => { + // Fault fixtures deliberately retain their product obligation. Dispose only test-owned native + // handles after their entered work is quiet; this does not grant the product a retirement ACK. + for (const opened of productStores) if (opened.db.$client.open) opened.close(); + vi.restoreAllMocks(); for (const v of HOME_ENV_VARS) { const prev = savedHome.get(v); if (prev === undefined) delete process.env[v]; @@ -267,10 +544,17 @@ describe('agentRunCommand (2.Q)', () => { /** Observe the built session — wraps the REAL builder, so nothing is stubbed out. */ onBuilt?: (built: Awaited>) => void; } = {}, - ): { d: AgentRunCommandDeps; out: () => string; err: () => string } { + ): { + d: AgentRunCommandDeps; + out: () => string; + err: () => string; + control: ReturnType; + } { const { io, out, err } = captureIo(); + const control = invocationFixture(); return { d: { + invocation: control.invocation, io: { ...io, stdin: Readable.from([stdin]) }, global: globalOptions(cwd, opts.json ?? false), now: () => 0, @@ -288,6 +572,7 @@ describe('agentRunCommand (2.Q)', () => { }, out, err, + control, }; } @@ -311,7 +596,7 @@ describe('agentRunCommand (2.Q)', () => { ...d, invocation, buildSession: async (options) => { - await witnessNativeToolProcess(options.nativeProcesses, invocation, cwd); + await witnessNativeToolProcess(options.nativeProcesses, invocation, cwd, 2); expect(options.disableMcp).toBe(offline ? true : undefined); return buildChatSession(options); }, diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index 936107ba..1eff04dc 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -1,8 +1,11 @@ import type { CliInvocation } from '../process/invocation.js'; +import { SessionHostOwner } from '../chat/session-host-owner.js'; +import { SessionSurfaceLifetime } from '../chat/session-surface-lifetime.js'; +import { runSessionPrompt } from '../chat/session-prompt.js'; import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; -import { closeQuietly } from '../process/close-quietly.js'; import { randomUUID } from 'node:crypto'; import { StringDecoder } from 'node:string_decoder'; +import { Readable } from 'node:stream'; import type { SessionStreamHandleEvent } from '@relavium/core'; @@ -13,9 +16,8 @@ import { onceEffortNotice } from '../chat/effort-notice.js'; import { buildChatSession, type BuiltChatSession } from '../chat/session-host.js'; import { createConsentGate } from '../engine/mcp-consent-gate.js'; -import { guardMcpTeardown } from '../engine/mcp-signal-teardown.js'; import type { StdioConsentGate } from '../engine/mcp-servers.js'; -import { createConsentPrompter } from '../mcp/consent-prompt.js'; +import { createBoundConsentPrompter } from '../mcp/consent-prompt.js'; import { loadResolvedConfig } from '../config/load.js'; import { admitFsInvocation, admitChatFsPolicy } from '../engine/tool-host/fs-admission.js'; import { surfaceMcpSkipped } from '../engine/mcp-servers.js'; @@ -27,11 +29,12 @@ import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; import { createMcpSecretResolver, type McpSecretResolver } from '../secrets/mcp-secret.js'; import { makePlainPrinter } from './chat.js'; -import { sanitizeInline, sanitizeUntrustedInline, stringifyJsonLine } from '../render/sanitize.js'; +import { stringifyJsonLine } from '../render/sanitize.js'; import { createEffectJournalPort, createEffectJournalStore, createSessionUsageStore, + SessionEffectTurnError, type SessionUsageReceipt, } from '@relavium/db'; import { openSessionStore } from '../history/session-open.js'; @@ -45,8 +48,9 @@ import type { UsageInvocation } from '@relavium/shared'; * deterministic + offline. `--json` emits the NDJSON `session:*` stream; otherwise the assistant reply streams * in human form. Its transcript is not persisted or resumable; hidden owner metadata records usage coverage, * realized cost and effects (ADR-0104). Exit: the turn's outcome — `0` on - * success, `1` on a turn error; an invocation fault (no prompt / unknown agent / bad cassette / `--input`) is - * `2`. `--input` is **reserved** — rejected until session `{{ctx.*}}` prompt interpolation lands (deferred-tasks.md). + * success, `1` on a classified turn error; an invocation fault (no prompt / unknown agent / bad cassette / + * `--input`) is `2`. Unexpected infrastructure failure is `9`, with cleanup owned by the original invocation. + * `--input` is **reserved** — rejected until session `{{ctx.*}}` prompt interpolation lands (deferred-tasks.md). */ export interface AgentRunCommandArgs { @@ -90,278 +94,300 @@ export async function agentRunCommand( args: AgentRunCommandArgs, deps: AgentRunCommandDeps, ): Promise { - const nativeProcesses = nativeProcessesForInvocation(deps.invocation); - const grants = admitFsInvocation(deps.global); - const now = deps.now ?? Date.now; - const uuid = deps.uuid ?? randomUUID; - const { config, projectConfigDir, homeDir } = loadResolvedConfig({ - cwd: deps.global.cwd, - configPath: deps.global.configPath, - }); + const lifetime = new SessionSurfaceLifetime(deps.invocation, 'agent'); + return lifetime.execute(() => + agentRunOwned( + args, + { + ...deps, + invocation: lifetime.invocation, + io: { + ...deps.io, + // Best-effort retention may consume a warning refusal, but actual output remains an + // invocation-owned tail. Neither a pending delivery nor a broken sink is a completion ACK. + writeErrAcknowledged: (text) => + lifetime.work.work.retainWork(async () => { + try { + await deps.io.writeErrAcknowledged(text); + } catch (cause) { + lifetime.work.failure(cause); + throw cause; + } + }), + }, + }, + lifetime, + ), + ); +} - const fsPolicy = admitChatFsPolicy(grants, deps.global.cwd, config.chat.extraRoots); +async function agentRunOwned( + args: AgentRunCommandArgs, + deps: AgentRunCommandDeps, + lifetime: SessionSurfaceLifetime, +): Promise { + const hostOwner = new SessionHostOwner(lifetime.invocation, 'agent'); + let openedJournal: ReturnType | undefined; + try { + const nativeProcesses = nativeProcessesForInvocation(deps.invocation); + const grants = admitFsInvocation(deps.global); + const now = deps.now ?? Date.now; + const uuid = deps.uuid ?? randomUUID; + const { config, projectConfigDir, homeDir } = loadResolvedConfig({ + cwd: deps.global.cwd, + configPath: deps.global.configPath, + }); - // Validate the invocation + read the one-shot prompt from stdin (the two pre-run faults live in the helper). - const message = await resolveOneShotInput(args, deps); + const fsPolicy = admitChatFsPolicy(grants, deps.global.cwd, config.chat.extraRoots); - // A `--fixture` replays a cassette (offline, no keychain) and takes precedence over any injected/real seam; - // otherwise tests inject `providers`, and production resolves keys via the env/keychain (like `relavium run`). - const offline = args.fixture !== undefined; - const keys = offline ? undefined : deps.resolveKeys?.(); - const providers = - args.fixture === undefined - ? (deps.providers ?? keys?.providers ?? createProviderResolver(deps.io.env)) - : cassetteResolver(loadCassette(args.fixture, deps.global.cwd)); - // The ADR-0065 §2 user-pricing overlay (2.5.G S10) — so a one-shot live turn enforces + tracks a user-priced - // model. SKIPPED under `--fixture`: cassette pricing stays deterministic and fully offline. Usage coverage - // and realized attribution still use the local database, without consulting the user pricing overlay. - const resolvePrice = offline ? undefined : loadUserPricingOverlay(homeDir, deps.io); + // Validate the invocation + read the one-shot prompt from stdin (the two pre-run faults live in the helper). + const message = await resolveOneShotInput(args, deps, lifetime); + lifetime.work.assertAdmitting(); - // An unknown `` (path or id) throws a typed CliError here (exit 2), before any turn. The build is - // async (2.R): it connects the agent's inline stdio `mcp_servers` (a connect failure is a fail-loud exit-2 - // CliError, cause stripped) before the one-shot turn runs. In `--fixture` (cassette) mode the run must be - // FULLY offline: no `[[mcp_servers]]` registrations and an env-only secret resolver (never the keychain). - /** - * The signal guard, armed BEFORE the session build (ADR-0088 §1.3). - * - * `buildChatSession` is where the stdio children are spawned, and a cold `npx` can hold that call for up to - * 120 s with the terminal silent — the highest-probability window for a Ctrl-C. Arming afterwards, which was - * the first attempt, left exactly that window uncovered. Two things follow: native ownership is registered during creation - * and the connect is given a cancel signal, so a Ctrl-C during it stops rather than being - * merely survived. - * - * Unlike `relavium run`, this surface has no other signal handler, so the guard DOES own the exit code. - */ - const mcpConnectCancel = new AbortController(); - let session: BuiltChatSession | undefined; - const unguardMcp = guardMcpTeardown( - async () => { - mcpConnectCancel.abort(); - await session?.closeMcp?.(); - }, - // Native creation is registered before the builder resolves, under this original invocation. - undefined, - { - ...(deps.invocation === undefined ? {} : { invocation: deps.invocation }), - }, - ); + // A `--fixture` replays a cassette (offline, no keychain) and takes precedence over any injected/real seam; + // otherwise tests inject `providers`, and production resolves keys via the env/keychain (like `relavium run`). + const offline = args.fixture !== undefined; + const keys = offline ? undefined : deps.resolveKeys?.(); + const providers = + args.fixture === undefined + ? (deps.providers ?? keys?.providers ?? createProviderResolver(deps.io.env)) + : cassetteResolver(loadCassette(args.fixture, deps.global.cwd)); + // The ADR-0065 §2 user-pricing overlay (2.5.G S10) — so a one-shot live turn enforces + tracks a user-priced + // model. SKIPPED under `--fixture`: cassette pricing stays deterministic and fully offline. Usage coverage + // and realized attribution still use the local database, without consulting the user pricing overlay. + const resolvePrice = offline ? undefined : loadUserPricingOverlay(homeDir, deps.io); - const built = await (async (): Promise => { - const b = await (deps.buildSession ?? buildChatSession)({ - ...(nativeProcesses === undefined ? {} : { nativeProcesses }), - chat: config.chat, - fsPolicy, - afterTurnCompaction: false, // One-shot output has no next turn; retain active summary permission. - ...(config.maxTokensEstimate === undefined - ? {} - : { maxTokensEstimate: config.maxTokensEstimate }), - // **Consent before any stdio MCP spawn** (ADR-0084 §1). A one-shot `agent run` opens an agent artifact - // — often an imported one — which is exactly the case the gate exists for; `--fixture` replays offline - // and declares no servers, so the gate never fires there. - mcpArtifact: args.agent, - consentGate: - deps.consentGate ?? - createConsentGate({ - io: deps.io, - global: deps.global, - homeDir, - allowedDigests: args.allowMcpStdio, - prompt: createConsentPrompter(), - }), - // ADR-0071 §6: a one-shot invoke has no transcript and no picker, so a withheld tier would otherwise vanish - // completely — the turn runs, the authored knob does nothing, and the bill arrives at the provider's default. - // STDERR, never stdout: `--json` owns stdout, and a warning line mid-stream is a parse error downstream. - onEffortWithheld: onceEffortNotice((note) => deps.io.writeErr(`warning: ${note}\n`)), - // stderr, never stdout: `--json` owns stdout and a notice must not corrupt the NDJSON stream. - onListenerError: (note) => deps.io.writeErr(`warning: ${note}\n`), - onUnpriced: (note) => deps.io.writeErr(`warning: ${note}\n`), - agentRef: args.agent, - cwd: deps.global.cwd, - projectConfigDir, - now, - uuid, - providers, - mcpSecretResolver: offline - ? createMcpSecretResolver(deps.io.env) - : (deps.mcpSecretResolver ?? - keys?.mcpSecretResolver ?? - createMcpSecretResolver(deps.io.env)), - mcpRegistrations: offline ? [] : config.mcpServers, - ...(resolvePrice === undefined ? {} : { resolvePrice }), - // FULLY offline in `--fixture` (cassette) mode: disable inbound MCP entirely so an agent's inline - // `mcp_servers` are never connected (no config build, no spawn, no dial). The cassette already carries any - // recorded tool results, so the replay needs no live MCP. - ...(offline ? { disableMcp: true } : {}), - mcpConnectSignal: mcpConnectCancel.signal, - }); - session = b; // now the guard's lazy reads see a live client - return b; - })().catch((err: unknown) => { - // The build threw — nothing was returned, so the `finally` below never runs. Release the guard here or it - // outlives the command it was protecting. - unguardMcp(); - throw err; - }); + // An unknown `` (path or id) throws a typed CliError here (exit 2), before any turn. The build is + // async (2.R): it connects the agent's inline stdio `mcp_servers` (a connect failure is a fail-loud exit-2 + // CliError, cause stripped) before the one-shot turn runs. In `--fixture` (cassette) mode the run must be + // FULLY offline: no `[[mcp_servers]]` registrations and an env-only secret resolver (never the keychain). + const built = await hostOwner.build((workOwner) => + (deps.buildSession ?? buildChatSession)({ + workOwner, + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), + chat: config.chat, + fsPolicy, + afterTurnCompaction: false, // One-shot output has no next turn; retain active summary permission. + ...(config.maxTokensEstimate === undefined + ? {} + : { maxTokensEstimate: config.maxTokensEstimate }), + // **Consent before any stdio MCP spawn** (ADR-0084 §1). A one-shot `agent run` opens an agent artifact + // — often an imported one — which is exactly the case the gate exists for; `--fixture` replays offline + // and declares no servers, so the gate never fires there. + mcpArtifact: args.agent, + consentGate: + deps.consentGate ?? + createConsentGate({ + io: deps.io, + global: deps.global, + homeDir, + allowedDigests: args.allowMcpStdio, + signal: workOwner.signal, + prompt: (subject) => { + const prompt = createBoundConsentPrompter(deps.io); + return runSessionPrompt( + lifetime.invocation, + deps.io, + workOwner.assertAdmitting, + workOwner.failure, + (signal) => prompt(subject, { signal }), + ); + }, + }), + // ADR-0071 §6: a one-shot invoke has no transcript and no picker, so a withheld tier would otherwise vanish + // completely — the turn runs, the authored knob does nothing, and the bill arrives at the provider's default. + // STDERR, never stdout: `--json` owns stdout, and a warning line mid-stream is a parse error downstream. + onEffortWithheld: onceEffortNotice((note) => deps.io.writeErr(`warning: ${note}\n`)), + // stderr, never stdout: `--json` owns stdout and a notice must not corrupt the NDJSON stream. + onListenerError: (note) => deps.io.writeErr(`warning: ${note}\n`), + onUnpriced: (note) => deps.io.writeErr(`warning: ${note}\n`), + agentRef: args.agent, + cwd: deps.global.cwd, + projectConfigDir, + now, + uuid, + providers, + mcpSecretResolver: offline + ? createMcpSecretResolver(deps.io.env) + : (deps.mcpSecretResolver ?? + keys?.mcpSecretResolver ?? + createMcpSecretResolver(deps.io.env)), + mcpRegistrations: offline ? [] : config.mcpServers, + ...(resolvePrice === undefined ? {} : { resolvePrice }), + // FULLY offline in `--fixture` (cassette) mode: disable inbound MCP entirely so an agent's inline + // `mcp_servers` are never connected (no config build, no spawn, no dial). The cassette already carries any + // recorded tool results, so the replay needs no live MCP. + ...(offline ? { disableMcp: true } : {}), + mcpConnectSignal: workOwner.signal, + }), + ); - // ADR-0074 §4: `agent run` has no resumable transcript or restored cap. Its hidden ADR-0104 owner records - // coverage and realized money, while conservative commitments retain the explicit one-shot no-op. Attach an - // explicit no-op so the governor's emit resolves: without it every commitment would reject, mark the session - // durability-broken, and the §2 barrier would fail a turn for a session nobody was ever going to resume. The - // in-memory debit still consumes cap capacity for this process, which is the whole of what a one-shot needs. - built.governor?.attachConservativeWriter(() => Promise.resolve()); + // ADR-0074 §4: `agent run` has no resumable transcript or restored cap. Its hidden ADR-0104 owner records + // coverage and realized money, while conservative commitments retain the explicit one-shot no-op. Attach an + // explicit no-op so the governor's emit resolves: without it every commitment would reject, mark the session + // durability-broken, and the §2 barrier would fail a turn for a session nobody was ever going to resume. The + // in-memory debit still consumes cap capacity for this process, which is the whole of what a one-shot needs. + built.governor?.attachConservativeWriter(() => Promise.resolve()); - // **ADR-0074 §4's no-op precedent does NOT transfer to effects, and the difference is the whole point.** - // A conservative commitment has nowhere to go because nothing will ever resume this invocation. An external - // effect is carried forward by the TARGET, not by the run — a ticket filed here still exists tomorrow — so a - // no-op journal would be fail-open on exactly the guarantee CR-12 exists to give, and leaving it unattached - // would refuse every effectful tool on this surface. - // - // The same hidden owner supplies effect correlation and durable usage coverage. It never grants transcript - // discovery or resume permission; an exact opaque owner receipt is required to attach usage writes. - // Opened INSIDE a guard that owns `built`: from `buildChatSession` onward the process may hold live MCP - // child processes, and `closeMcp` is otherwise only reachable through `runOneShotTurn`'s `finally`. A store - // open that throws here (a locked or unwritable `history.db`) would take the one path that skips it, - // orphaning those children for the lifetime of the shell. - let journalStore: ReturnType; - try { - journalStore = openSessionStore(homeDir); - } catch (cause) { - await built.closeMcp?.().catch(() => undefined); - unguardMcp(); - throw cause; - } - let handedOff = false; - let ownedEffectSessionId: string | undefined; - const usageStore = createSessionUsageStore(journalStore.db); - const receipts = new Map(); - try { - // A fresh hidden owner retains usage uncertainty without a transcript or resume privilege. - const owner = (() => { - try { - return usageStore.createFreshOneShotOwner({ sessionId: built.sessionId, now: now() }); - } catch (cause) { - throw new CliError( - 'invalid_invocation', - 'one-shot session identity could not be reserved', - { cause }, - ); - } - })(); - ownedEffectSessionId = owner.sessionId; - const producerId = randomUUID(); - let dispatch = 0; - let failure: Error | undefined; - const write = (operation: () => void): void => { - if (failure !== undefined) throw failure; - try { - operation(); - } catch (cause) { - failure = cause instanceof Error ? cause : new Error('One-shot usage persistence failed'); - throw failure; - } - }; - built.attachDurabilityProbe(() => failure); - built.attachUsageWriter({ - readSnapshot: () => { - const snapshot = journalStore.store.loadSessionCostBreakdown(owner.sessionId); - return { - cumulativeCostMicrocents: snapshot.totalCostMicrocents, - cumulativeUnpricedCalls: snapshot.rows.reduce((sum, row) => sum + row.unpricedCalls, 0), - usageCoverage: snapshot.coverage, - }; - }, - initialCoverage: () => { - const snapshot = usageStore.loadCoverage(owner.sessionId); - if (snapshot === undefined) throw new Error('One-shot usage owner unavailable'); - return { ...snapshot, started: true, highWater: 0 }; - }, - begin: (draft) => { - let receipt: SessionUsageReceipt | undefined; - write(() => { - receipt = usageStore.beginInvocation( - { + // **ADR-0074 §4's no-op precedent does NOT transfer to effects, and the difference is the whole point.** + // A conservative commitment has nowhere to go because nothing will ever resume this invocation. An external + // effect is carried forward by the TARGET, not by the run — a ticket filed here still exists tomorrow — so a + // no-op journal would be fail-open on exactly the guarantee CR-12 exists to give, and leaving it unattached + // would refuse every effectful tool on this surface. + // + // The same hidden owner supplies effect correlation and durable usage coverage. It never grants transcript + // discovery or resume permission; an exact opaque owner receipt is required to attach usage writes. + // The host reservation already owns every entered builder/MCP operation. The outer surface root also + // owns this database from acquisition through actual host cleanup, including setup failures before a turn. + lifetime.work.assertAdmitting(); + const journalStore = openSessionStore(homeDir); + openedJournal = journalStore; + let ownedEffectSessionId: string | undefined; + const usageStore = createSessionUsageStore(journalStore.db); + const receipts = new Map(); + try { + // A fresh hidden owner retains usage uncertainty without a transcript or resume privilege. + const owner = (() => { + const timestamp = now(); + if (!Number.isSafeInteger(timestamp) || timestamp < 0) + throw new CliError('cli_internal', ''); + try { + return usageStore.createFreshOneShotOwner({ sessionId: built.sessionId, now: timestamp }); + } catch (cause) { + if (!(cause instanceof SessionEffectTurnError) || cause.code !== 'history_invalid') + throw cause; + throw new CliError( + 'invalid_invocation', + 'one-shot session identity could not be reserved', + { cause }, + ); + } + })(); + ownedEffectSessionId = owner.sessionId; + const producerId = randomUUID(); + let dispatch = 0; + let failure: Error | undefined; + const write = (operation: () => void): void => { + if (failure !== undefined) throw failure; + try { + operation(); + } catch (cause) { + failure = cause instanceof Error ? cause : new Error('One-shot usage persistence failed'); + throw failure; + } + }; + built.attachDurabilityProbe(() => failure); + built.attachUsageWriter({ + readSnapshot: () => { + const snapshot = journalStore.store.loadSessionCostBreakdown(owner.sessionId); + return { + cumulativeCostMicrocents: snapshot.totalCostMicrocents, + cumulativeUnpricedCalls: snapshot.rows.reduce((sum, row) => sum + row.unpricedCalls, 0), + usageCoverage: snapshot.coverage, + }; + }, + initialCoverage: () => { + const snapshot = usageStore.loadCoverage(owner.sessionId); + if (snapshot === undefined) throw new Error('One-shot usage owner unavailable'); + return { ...snapshot, started: true, highWater: 0 }; + }, + begin: (draft) => { + let receipt: SessionUsageReceipt | undefined; + write(() => { + receipt = usageStore.beginInvocation( + { + sessionId: owner.sessionId, + model: draft.model, + producerId, + // The producer scopes this ordinal; authored agent IDs are unbounded business identity. + dispatchId: String(++dispatch), + round: draft.round, + chainAttempt: draft.chainAttempt, + }, + owner, + ); + }); + if (receipt === undefined) throw new Error('One-shot usage receipt unavailable'); + receipts.set(receipt.invocation, receipt); + return Promise.resolve(receipt.invocation); + }, + disposition: (draft) => { + const receipt = receipts.get(draft.invocation); + if (receipt === undefined) throw new Error('One-shot usage receipt unavailable'); + write(() => { + usageStore.disposition(receipt, draft.disposition); + if (draft.receiptPending !== true) { + usageStore.retire(receipt); + receipts.delete(draft.invocation); + } + }); + }, + record: (draft) => { + const receipt = + draft.invocation === undefined ? undefined : receipts.get(draft.invocation); + if (receipt === undefined) + throw new Error('One-shot actual cost requires its live receipt'); + write(() => { + const accepted = journalStore.store.recordSessionCost({ + id: randomUUID(), sessionId: owner.sessionId, model: draft.model, - producerId, - // The producer scopes this ordinal; authored agent IDs are unbounded business identity. - dispatchId: String(++dispatch), - round: draft.round, - chainAttempt: draft.chainAttempt, - }, - owner, - ); - }); - if (receipt === undefined) throw new Error('One-shot usage receipt unavailable'); - receipts.set(receipt.invocation, receipt); - return Promise.resolve(receipt.invocation); - }, - disposition: (draft) => { - const receipt = receipts.get(draft.invocation); - if (receipt === undefined) throw new Error('One-shot usage receipt unavailable'); - write(() => { - usageStore.disposition(receipt, draft.disposition); - if (draft.receiptPending !== true) { - usageStore.retire(receipt); - receipts.delete(draft.invocation); - } - }); - }, - record: (draft) => { - const receipt = draft.invocation === undefined ? undefined : receipts.get(draft.invocation); - if (receipt === undefined) - throw new Error('One-shot actual cost requires its live receipt'); - write(() => { - const accepted = journalStore.store.recordSessionCost({ - id: randomUUID(), - sessionId: owner.sessionId, - model: draft.model, - inputTokens: draft.inputTokens, - outputTokens: draft.outputTokens, - costMicrocents: draft.costMicrocents, - priced: draft.priced, - ts: now(), - usageReceipt: receipt, + inputTokens: draft.inputTokens, + outputTokens: draft.outputTokens, + costMicrocents: draft.costMicrocents, + priced: draft.priced, + ts: now(), + usageReceipt: receipt, + }); + if (!accepted) throw new Error('One-shot actual cost acknowledgement refused'); + receipts.delete(receipt.invocation); }); - if (!accepted) throw new Error('One-shot actual cost acknowledgement refused'); - receipts.delete(receipt.invocation); - }); - }, - }); - built.attachEffectTurnAllocator((id) => { - if (id !== owner.sessionId) throw new Error('One-shot effect owner mismatch'); - return owner.effectTurnKey; - }); - built.attachEffectJournal((correlation) => - createEffectJournalPort( - createEffectJournalStore(journalStore.db, { uuid: randomUUID, now: Date.now }), - correlation, - { providerAttempt: 1, toolCallId: 'agent-run' }, - ), - ); - // Render the live stream + run the single turn + tear down — a classified turn failure maps to exit 1. - handedOff = true; - const turnErrorCode = await runOneShotTurn(built, message, deps); - return turnErrorCode === undefined ? EXIT_CODES.success : EXIT_CODES.workflowFailed; - } finally { - // Removed first: from here `runOneShotTurn`'s own teardown owns the children on every unwinding path. - unguardMcp(); - try { - if (!handedOff) await built.closeMcp?.(); + }, + }); + built.attachEffectTurnAllocator((id) => { + if (id !== owner.sessionId) throw new Error('One-shot effect owner mismatch'); + return owner.effectTurnKey; + }); + built.attachEffectJournal((correlation) => + createEffectJournalPort( + createEffectJournalStore(journalStore.db, { uuid: randomUUID, now: Date.now }), + correlation, + { providerAttempt: 1, toolCallId: 'agent-run' }, + ), + ); + // Render the live stream + run the single turn + tear down — a classified turn failure maps to exit 1. + hostOwner.work.assertAdmitting(); + const turnErrorCode = await hostOwner.work.enter(() => runOneShotTurn(built, message, deps)); + return turnErrorCode === undefined ? EXIT_CODES.success : EXIT_CODES.workflowFailed; + } catch (cause) { + // Setup can fail before a turn root exists. Observe the original fault BEFORE awaiting host cleanup: + // a held MCP close must not delay starting the common fatal budget. Known refusals stay classified. + hostOwner.work.failure(cause); + throw cause; } finally { + // Real root/producer/accounting/MCP cleanup precedes retention, receipt retirement and DB close. + await hostOwner.work.close(); + if ( + lifetime.invocation.registry.budget.remainingMs() !== 0 && + ownedEffectSessionId !== undefined + ) + sweepOneShotSessionEffects(deps.io, journalStore.db, ownedEffectSessionId); + for (const receipt of receipts.values()) usageStore.retire(receipt); + receipts.clear(); + } + } catch (cause) { + // Recover only the exact preparation owner's cancellation identity. An unrelated fault racing + // an aborted signal keeps its own classification and starts the original fatal budget. + if (hostOwner.work.isStoppedFailure(cause)) lifetime.work.assertAdmitting(); + if (!lifetime.work.isStoppedFailure(cause)) lifetime.work.failure(cause); + throw cause; + } finally { + await hostOwner.work.close(); + if (openedJournal !== undefined) { try { - if (ownedEffectSessionId !== undefined) - sweepOneShotSessionEffects(deps.io, journalStore.db, ownedEffectSessionId); - } finally { - try { - await built.whenHostSafeToClose(); - } finally { - try { - for (const receipt of receipts.values()) usageStore.retire(receipt); - receipts.clear(); - } finally { - closeQuietly(deps.io, 'effect journal', () => journalStore.close()); - } - } + openedJournal.close(); + } catch (cause) { + lifetime.work.cleanupUncertain(cause); } } } @@ -371,6 +397,7 @@ export async function agentRunCommand( async function resolveOneShotInput( args: AgentRunCommandArgs, deps: AgentRunCommandDeps, + lifetime: SessionSurfaceLifetime, ): Promise { // `--input k=v` is REJECTED for now: a session does not yet interpolate `{{ctx.*}}` into the agent prompt // (the engine passes `system_prompt` verbatim; wiring `resolveTemplate` into the session turn core is a @@ -383,7 +410,12 @@ async function resolveOneShotInput( ); } // The one-shot prompt is the piped stdin; an empty stdin is a clean invocation fault (nothing to run). - const message = (await readAllStdin(deps.io.stdin)).trim(); + const message = ( + await readAllStdin(deps.io.stdin, { + signal: lifetime.work.signal, + onFailure: lifetime.work.failure, + }) + ).trim(); if (message.length === 0) { throw new CliError( 'invalid_invocation', @@ -395,9 +427,9 @@ async function resolveOneShotInput( /** * Render the live stream (NDJSON under `--json`, else the plain token/tool printer), run the single turn, and - * tear down — returning the classified turn-error code (or `undefined` on success). The whole post-build region - * is inside the try so any fault still hits the finally (the session OWNS the MCP connections; teardown there is - * best-effort and must never override the computed exit code) rather than orphaning the spawned children. + * return the classified turn-error code (or `undefined` on success). Cancellation and subscription release + * belong to this turn; the creation owner separately joins actual producer/accounting/MCP cleanup. An + * unexpected failure is reported before cancellation and never converted to a classified turn outcome. */ async function runOneShotTurn( built: BuiltChatSession, @@ -436,55 +468,71 @@ async function runOneShotTurn( applyChatMode(modeEnv, 'ask'); built.session.start(); await built.session.sendMessage(message); - } catch (err) { - // An UNCLASSIFIED turn error re-raised by the turn core (e.g. an under-recorded `--fixture` cassette whose - // next `stream()` call is unscripted) rejects `sendMessage`. Map it to a clean exit 1 here rather than - // letting a raw rejection surface as an opaque boundary "internal error". The detail goes to stderr (never - // a stack as primary output); under --json the failing `session:turn_completed.error` is already on stdout. - turnErrorCode ??= 'internal'; - if (!deps.global.json) { - deps.io.writeErr(`turn failed: ${err instanceof Error ? err.message : String(err)}\n`); - } + } catch (cause) { + built.workOwner?.failure(cause); + throw cause; } finally { - built.session.cancel(); // the session's terminal (session:cancelled) — closes the one-shot cleanly try { - await built.whenHostSafeToClose(); + built.session.cancel(); + } catch (cause) { + built.workOwner?.cleanupUncertain(cause); } finally { unsubscribe(); - // Tear down the MCP connections (2.R) after the one-shot turn; present only when `mcp_servers` is declared. - // Best-effort: a teardown rejection must NOT override the computed one-shot exit code (warn, don't throw). - // **Per-server, through the manager's own reporter.** The `.catch` below is a backstop and used to be the - // only mechanism — which meant it never fired: `closeAll` swallows each connection's rejection unless a - // reporter is supplied, so a wedged transport produced silence. `sanitizeUntrustedInline` because the - // cause is the SDK's, and it can carry a spawn command or a path. - await built - .closeMcp?.((serverId, cause) => { - deps.io.writeErr( - `warning: MCP server '${sanitizeInline(serverId)}' teardown failed: ${sanitizeUntrustedInline( - cause instanceof Error ? cause.message : String(cause), - )}\n`, - ); - }) - .catch((e: unknown) => { - deps.io.writeErr( - `warning: MCP teardown failed: ${sanitizeUntrustedInline(e instanceof Error ? e.message : String(e))}\n`, - ); - }); } } return turnErrorCode; } /** Read the whole input stream to EOF as UTF-8 text (the one-shot prompt). Exported for a focused unit test. */ -export async function readAllStdin(stream: NodeJS.ReadableStream): Promise { +export async function readAllStdin( + stream: NodeJS.ReadableStream, + options?: { readonly signal: AbortSignal; readonly onFailure: (cause: unknown) => void }, +): Promise { // Decode binary chunks through a StringDecoder so a multi-byte UTF-8 character split ACROSS a chunk boundary // is buffered (not mangled into replacement chars); `decoder.end()` flushes any trailing partial sequence. A // test stream yields strings (a Buffer is itself a Uint8Array subclass), handled by the String fallback. const decoder = new StringDecoder('utf8'); let data = ''; - for await (const chunk of stream) { - data += chunk instanceof Uint8Array ? decoder.write(Buffer.from(chunk)) : String(chunk); + const signal = options?.signal; + signal?.throwIfAborted(); + const native = stream instanceof Readable ? stream : undefined; + const cancelled = new Error('One-shot input stopped.'); + let cancellationEntered = false; + let closed: Promise | undefined; + let acknowledgeClose: () => void = () => undefined; + const onClose = (): void => acknowledgeClose(); + const abort = (): void => { + if (native === undefined || cancellationEntered) return; + cancellationEntered = true; + closed = new Promise((resolve) => { + acknowledgeClose = resolve; + }); + native.once('close', onClose); + if (native.closed) acknowledgeClose(); + try { + native.destroy(cancelled); + } catch (cause) { + // A throwing cancellation is not close ACK. Report it before waiting on the retained read. + options?.onFailure(cause); + } + }; + signal?.addEventListener('abort', abort, { once: true }); + try { + for await (const chunk of stream) { + data += chunk instanceof Uint8Array ? decoder.write(Buffer.from(chunk)) : String(chunk); + } + signal?.throwIfAborted(); + data += decoder.end(); + return data; + } catch (cause) { + // Only this owned native destroy identity can recover the invocation's cancellation reason. + // An unrelated read fault racing an aborted signal keeps its original infrastructure diagnosis. + if (cause === cancelled) signal?.throwIfAborted(); + options?.onFailure(cause); + throw cause; + } finally { + signal?.removeEventListener('abort', abort); + await closed; + native?.removeListener('close', onClose); } - data += decoder.end(); - return data; } diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index 27f0133c..1b169ebe 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -429,6 +429,7 @@ async function resumeGateOwned( if (!lifetime.isStoppedFailure(cause)) lifetime.failure(cause); }, onCloseError: reportMcpCloseFailure, + budget: lifetime.invocation.registry.budget, work: mcpWork, registrations: config.mcpServers, resolveSecret: diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 8a6bbb52..0a821bd6 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -270,6 +270,7 @@ async function runOwned( if (!lifetime.isStoppedFailure(cause)) lifetime.failure(cause); }, onCloseError: reportMcpCloseFailure, + budget: lifetime.invocation.registry.budget, work: mcpWork, resolveSecret: deps.mcpSecretResolver ?? keys?.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env), diff --git a/apps/cli/src/commands/safe-close.test.ts b/apps/cli/src/commands/safe-close.test.ts index c3c02b1e..ba6c4bef 100644 --- a/apps/cli/src/commands/safe-close.test.ts +++ b/apps/cli/src/commands/safe-close.test.ts @@ -24,6 +24,8 @@ import { buildChatSession } from '../chat/session-host.js'; import { scriptedResolver, textTurn } from '../chat/test-support.js'; import { EXIT_CODES } from '../process/exit-codes.js'; import { createCliInvocation } from '../process/invocation.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import * as signalPorts from '../process/signals.js'; import type { GlobalOptions } from '../process/options.js'; import { captureIo, parseNdjson } from '../test-support.js'; import { chatExportCommand } from './chat-export.js'; @@ -364,7 +366,7 @@ describe('command teardown preserves primary outcomes (#22)', () => { }); it.each([false, true])( - 'agent-run joins host work before closing its journal and preserves its outcome (join verdict fails=%s)', + 'agent-run retains failed cleanup without treating it as database close permission (join verdict fails=%s)', async (verdictFails) => { const primary = new Error('final money publication failed'); let joined = false; @@ -380,9 +382,12 @@ describe('command teardown preserves primary outcomes (#22)', () => { 'id: chatter\nprovider: anthropic\nmodel: claude-sonnet-4-6\nsystem_prompt: Be concise.\n', ); const { io, out, err } = captureIo(); + const control = invocationFixture(); + vi.spyOn(signalPorts, 'defaultSubscribeSignals').mockReturnValue(() => undefined); const command = agentRunCommand( { agent: 'agent.yaml', input: [], allowMcpStdio: [] }, { + invocation: control.invocation, io: { ...io, stdin: Readable.from(['hello']) }, global: global(true), providers: scriptedResolver([textTurn('done')]), @@ -400,15 +405,37 @@ describe('command teardown preserves primary outcomes (#22)', () => { }, }, ); - if (verdictFails) await expect(command).rejects.toBe(primary); - else { - expect(await command).toBe(EXIT_CODES.success); + let settled = false; + void command.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await vi.waitFor(() => expect(joined).toBe(true)); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled, 'AGENT_CLEANUP_REFUSAL_RETAINS_OWNERSHIP').toBe(false); + expect(control.registry.observations.exitCode()).toBe(9); + if (verdictFails) { + expect(close).not.toHaveBeenCalled(); + expect(client.sqlite.open).toBe(true); + expect(control.registry.activeOwners).toBe(2); + } else { + expect(close).toHaveBeenCalledOnce(); + expect(client.sqlite.open).toBe(false); + expect(control.registry.activeOwners).toBe(1); expect(parseNdjson(out())).toEqual( expect.arrayContaining([expect.objectContaining({ type: 'session:turn_completed' })]), ); } - expect(err()).toContain('warning: effect journal teardown failed\n'); - assertClosed(err()); + control.advance(3000); + await new Promise((resolve) => setImmediate(resolve)); + expect(control.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(err()).not.toContain('secret-token'); + expect(err()).not.toContain('/private/operator'); + expect(err()).not.toContain('final money publication failed'); }, ); }); diff --git a/apps/cli/src/engine/fixtures/usage-coverage-crash-worker.ts b/apps/cli/src/engine/fixtures/usage-coverage-crash-worker.ts index 3effa9a0..6a0a6352 100644 --- a/apps/cli/src/engine/fixtures/usage-coverage-crash-worker.ts +++ b/apps/cli/src/engine/fixtures/usage-coverage-crash-worker.ts @@ -349,7 +349,21 @@ async function runSession() { await built.session.compact('manual'); } if (late) announce('INTERRUPTED'); - await built.whenHostSafeToClose(); + if (cut === 'refuse-pending' || cut === 'lock-hold') { + // This direct-host fixture owns administrative disposal after actual producer joins. The + // production invocation must retain this refused durability barrier, including on repeated reads. + const refused = await built.whenHostSafeToClose().then( + () => undefined, + (cause: unknown) => ({ cause }), + ); + if (refused === undefined) throw new Error('expected retained session accounting refusal'); + const repeated = await built.whenHostSafeToClose().then( + () => undefined, + (cause: unknown) => ({ cause }), + ); + if (repeated === undefined || repeated.cause !== refused.cause || invocations !== 0) + throw new Error('session accounting refusal was cleared or permitted provider egress'); + } else await built.whenHostSafeToClose(); if ( late && client.sqlite diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index 68b74946..d19f8233 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -20,6 +20,7 @@ import { type McpClient, type McpConnection, type McpServerConfig, + type McpStartupLifecycle, type SseServerSpec, type StdioServerSpec, type WebSocketServerSpec, @@ -46,6 +47,7 @@ import { CliError } from '../process/errors.js'; import { createMcpFetch, type McpFetch, type McpFetchConfig } from './mcp-fetch.js'; import type { ResolvedStdioSpawn } from './mcp-consent.js'; import type { CliIo } from '../process/io.js'; +import type { RemainingBudget } from '../process/invocation-registry.js'; import { sanitizeInline } from '../render/tui/chat-projection.js'; import type { McpSecretResolver } from '../secrets/mcp-secret.js'; import { @@ -76,11 +78,20 @@ import { export interface ConnectAgentMcpOptions { /** The session/run working directory — the spawned server's `cwd` (relative server paths resolve here). */ readonly cwd: string; + /** The preparation owner is rechecked after consent and before secrets, native entry or publication. */ + readonly assertAdmitting?: () => void; + /** Observe a construction fault before its actual cleanup can hold propagation. */ + readonly onFailure?: (cause: unknown) => void; + /** A fulfilled manager close does not erase callback-reported transport uncertainty. */ + readonly onCloseError?: (serverId: string, cause: unknown) => void; + /** The existing invocation budget; an expired cleanup cannot launch another asynchronous close. */ + readonly budget?: RemainingBudget; /** Injectable connect-all (tests pass a fake that never spawns); defaults to the real `startMcpClient`. */ readonly startMcpClient?: ( servers: readonly McpServerConfig[], signal?: AbortSignalLike, options?: ToolHostCallOptions, + lifecycle?: McpStartupLifecycle, ) => Promise; /** * Cancels the connect and the discovery walk (ADR-0088 §1.1). @@ -700,6 +711,7 @@ export async function connectAgentMcp( mcpServers: readonly McpServerRef[] | undefined, opts: ConnectAgentMcpOptions, ): Promise { + opts.assertAdmitting?.(); // Resolve any by-name `ref` entries to inline against the config registrations (Step 4b) BEFORE building the // stdio configs — so the rest of the pipeline always sees a self-contained, inline server. const inline = (mcpServers ?? []).map((entry) => @@ -709,6 +721,8 @@ export async function connectAgentMcp( // declare a server with no `[[mcp_servers]]` registration at all — the imported-artifact case. requireGateForStdio(inline, opts.consentGate); const consented = await opts.consentGate?.(inline, opts.cwd, opts.artifact); + opts.assertAdmitting?.(); + if (opts.connectSignal?.aborted) throw new CliError('internal', 'MCP connection was cancelled.'); const configs = resolveServerConfigs( inline, opts.cwd, @@ -718,7 +732,39 @@ export async function connectAgentMcp( opts.nativeProcesses, ); if (configs.length === 0) return undefined; - return startMcpClientFailLoud(configs, opts.startMcpClient, opts.connectSignal, opts.work); + opts.assertAdmitting?.(); + const client = await startMcpClientFailLoud( + configs, + opts.startMcpClient, + opts.connectSignal, + opts.work, + opts.assertAdmitting, + opts.onFailure, + startupLifecycle(opts), + ); + try { + opts.assertAdmitting?.(); + return client; + } catch (cause) { + opts.onFailure?.(cause); + await closePreparedMcp(client, opts.onCloseError, opts.budget); + throw cause; + } +} + +/** Expiry is no permission to enter another cleanup or to pretend this client has closed. */ +export async function closePreparedMcp( + client: McpClient, + onCloseError?: (serverId: string, cause: unknown) => void, + budget?: RemainingBudget, +): Promise { + if (budget?.remainingMs() === 0) await new Promise(() => {}); + try { + await client.close(onCloseError); + } catch (cause) { + if (onCloseError === undefined) throw cause; + onCloseError('', cause); + } } /** @@ -734,14 +780,21 @@ async function startMcpClientFailLoud( connectSignal?: AbortSignalLike, work?: ToolHostCallOptions, onOwnedCancellation?: () => void, + onFailure?: (cause: unknown) => void, + lifecycle?: McpStartupLifecycle, ): Promise { // The shared build token is installed only at the actual MCP construction choke. setMcpClientVersion(CLI_VERSION); const start = custom ?? defaultStartMcpClient; const owned = work === undefined ? new EgressWorkScope() : undefined; try { - return await start(configs, connectSignal, work ?? owned); + return await (lifecycle === undefined + ? start(configs, connectSignal, work ?? owned) + : start(configs, connectSignal, work ?? owned, lifecycle)); } catch (err) { + // Typed MCP refusals retain their existing public classifier below. An opaque failure must + // reach its owner before a self-cleanup lifetime can hold this rejection indefinitely. + if (!(err instanceof McpError) && !windowsTeardownRefusal(err)) onFailure?.(err); // A session builder without an external pre-handle owner must self-clean before propagating. owned?.seal(); await owned?.done; @@ -855,10 +908,12 @@ export interface ConnectWorkflowMcpOptions { readonly onFailure?: (cause: unknown) => void; /** Actual manager close failures use this callback even when close() itself fulfills. */ readonly onCloseError?: (serverId: string, cause: unknown) => void; + readonly budget?: RemainingBudget; readonly startMcpClient?: ( servers: readonly McpServerConfig[], signal?: AbortSignalLike, options?: ToolHostCallOptions, + lifecycle?: McpStartupLifecycle, ) => Promise; /** Cancels the connect and the discovery walk (ADR-0088 §1.1) — see {@link ConnectAgentMcpOptions.connectSignal}. */ readonly connectSignal?: AbortSignalLike; @@ -958,6 +1013,8 @@ export async function connectWorkflowMcp( opts.connectSignal, opts.work, opts.assertAdmitting, + opts.onFailure, + startupLifecycle(opts), ); try { @@ -988,6 +1045,16 @@ export async function connectWorkflowMcp( } } +function startupLifecycle( + opts: Pick, +): McpStartupLifecycle | undefined { + if (opts.onCloseError === undefined && opts.budget === undefined) return undefined; + return { + ...(opts.onCloseError === undefined ? {} : { onCloseError: opts.onCloseError }), + ...(opts.budget === undefined ? {} : { budget: opts.budget }), + }; +} + /** True for an inline agent definition (carries an `id`), false for a `{ $ref }` external reference. */ function isInlineAgent(entry: Agent | AgentRef): entry is Agent { return 'id' in entry; diff --git a/apps/cli/src/engine/mcp-session-preparation.test.ts b/apps/cli/src/engine/mcp-session-preparation.test.ts new file mode 100644 index 00000000..2b6acace --- /dev/null +++ b/apps/cli/src/engine/mcp-session-preparation.test.ts @@ -0,0 +1,340 @@ +import { + McpAbortedError, + McpError, + startMcpClient as actualStartMcpClient, + type McpClient, +} from '@relavium/mcp'; +import type { McpServerRef } from '@relavium/shared'; +import { describe, expect, it, vi } from 'vitest'; + +import { SessionWorkOwner } from '../chat/session-work-owner.js'; +import { SessionHostOwner } from '../chat/session-host-owner.js'; +import { createInvocationFiniteExit } from '../process/invocation-finite-exit.js'; +import { InvocationRegistry } from '../process/invocation-registry.js'; +import { closePreparedMcp, connectAgentMcp, type ConnectAgentMcpOptions } from './mcp-servers.js'; + +function gate() { + let release: () => void = () => undefined; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +function client(close: McpClient['close'] = () => Promise.resolve()): McpClient { + return { + capability: { call: () => Promise.resolve({ content: [], isError: false }) }, + toolDefs: [], + toolIdsByServer: new Map(), + childPids: [], + skipped: [], + close, + }; +} + +const refs: readonly McpServerRef[] = [ + { + id: 'owned', + transport: 'stdio', + command: 'synthetic-never-spawned', + env: { AUTH: '{{secrets.synthetic}}' }, + }, +]; + +function ownerFixture() { + const clock = { now: () => 0, schedule: () => () => undefined }; + const registry = new InvocationRegistry(clock); + const invocation = { + registry, + finiteExit: createInvocationFiniteExit(registry, clock, { + exit: vi.fn(), + setExitCode: vi.fn(), + }), + seedFileCatalogOnce: () => Promise.resolve(), + }; + const owner = new SessionWorkOwner(invocation, 'chat', { + cancel: () => undefined, + cleanup: () => Promise.resolve(), + }); + const options = { + cwd: '/tmp', + assertAdmitting: owner.assertAdmitting, + connectSignal: owner.signal, + onFailure: owner.failure, + onCloseError: (_id: string, cause: unknown) => owner.cleanupUncertain(cause), + work: owner.work, + budget: registry.budget, + }; + return { owner, registry, options }; +} + +describe('session preparation keeps its original owner across MCP consent and startup', () => { + it('refuses a late consent answer before resolving a secret or constructing a client', async () => { + const f = ownerFixture(); + const consent = gate(); + const resolveSecret = vi.fn(() => 'SYNTHETIC_SECRET'); + const start = vi.fn(() => Promise.resolve(client())); + const opening = f.owner.enter(() => + connectAgentMcp(refs, { + ...f.options, + consentGate: async () => { + await consent.promise; + return new Map(); + }, + resolveSecret, + startMcpClient: start, + }), + ); + const refused = expect(opening).rejects.toSatisfy(f.owner.isStoppedFailure); + f.owner.stop(); + consent.release(); + await refused; + expect(resolveSecret, 'SESSION_LATE_CONSENT_BEFORE_SECRET').not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + expect(f.registry.observations.exitCode()).toBe(0); + await f.owner.close(); + }); + + it('preserves the corresponding approved path and forwards the exact lifetime transfer', async () => { + const f = ownerFixture(); + const live = client(); + const resolveSecret = vi.fn(() => 'SYNTHETIC_SECRET'); + const start = vi.fn>(() => + Promise.resolve(live), + ); + expect( + await f.owner.enter(() => + connectAgentMcp(refs, { + ...f.options, + consentGate: () => Promise.resolve(new Map()), + resolveSecret, + startMcpClient: start, + }), + ), + ).toBe(live); + expect(resolveSecret).toHaveBeenCalledExactlyOnceWith('synthetic'); + expect(start).toHaveBeenCalledOnce(); + expect(start.mock.calls[0]?.[1]).toBe(f.owner.signal); + expect(start.mock.calls[0]?.[2]).toBe(f.owner.work); + await closePreparedMcp(live, f.options.onCloseError, f.options.budget); + await f.owner.close(); + }); + + it('joins the actual close of a client returned after cancellation before rejecting its construction', async () => { + const f = ownerFixture(); + const started = gate(); + const publish = gate(); + const closed = gate(); + const close = vi.fn(() => closed.promise); + const opening = f.owner.enter(() => + connectAgentMcp(refs, { + ...f.options, + consentGate: () => Promise.resolve(new Map()), + resolveSecret: () => 'SYNTHETIC_SECRET', + startMcpClient: async () => { + started.release(); + await publish.promise; + return client(close); + }, + }), + ); + let finished = false; + const result = opening.then( + () => { + finished = true; + throw new Error('Late MCP client was published.'); + }, + (cause: unknown) => { + finished = true; + return cause; + }, + ); + await started.promise; + f.owner.stop(); + publish.release(); + await reactions(); + expect(close).toHaveBeenCalledOnce(); + expect(finished, 'SESSION_LATE_CLIENT_ACTUAL_CLOSE').toBe(false); + expect(f.registry.activeOwners).toBe(1); + closed.release(); + expect(f.owner.isStoppedFailure(await result)).toBe(true); + await f.owner.close(); + }); + + it('retains callback-reported uncertainty after the late client manager close fulfils', async () => { + const f = ownerFixture(); + const started = gate(); + const publish = gate(); + const cause = new Error('PRIVATE_CLOSE_CALLBACK'); + const opening = f.owner.enter(() => + connectAgentMcp(refs, { + ...f.options, + consentGate: () => Promise.resolve(new Map()), + resolveSecret: () => 'SYNTHETIC_SECRET', + startMcpClient: async () => { + started.release(); + await publish.promise; + return client((onError) => { + onError?.('owned', cause); + return Promise.resolve(); + }); + }, + }), + ); + const refused = expect(opening).rejects.toSatisfy(f.owner.isStoppedFailure); + await started.promise; + f.owner.stop(); + publish.release(); + await refused; + await reactions(); + expect(f.owner.firstFailure?.cause).toBe(cause); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.activeOwners, 'SESSION_PREPARATION_CALLBACK_UNCERTAINTY').toBe(1); + }); + + it('observes an opaque startup failure before its private failed-open lifetime can join', async () => { + const child = gate(); + const failure = new Error('PRIVATE_STARTUP_FAILURE'); + const observed = vi.fn(); + let finished = false; + const opening = connectAgentMcp(refs, { + cwd: '/tmp', + consentGate: () => Promise.resolve(new Map()), + resolveSecret: () => 'SYNTHETIC_SECRET', + onFailure: observed, + startMcpClient: (_configs, _signal, work) => { + if (work?.retainWork === undefined) throw new Error('Startup lifetime was not supplied.'); + void work.retainWork(() => child.promise); + return Promise.reject(failure); + }, + }); + const result = opening.catch((cause: unknown) => { + finished = true; + return cause; + }); + await reactions(); + expect( + observed, + 'SESSION_STARTUP_FAULT_BEFORE_FAILED_OPEN_JOIN', + ).toHaveBeenCalledExactlyOnceWith(failure); + expect(finished).toBe(false); + child.release(); + expect(await result).toBe(failure); + }); + + it.each([true, false])( + 'only the typed owned cancellation can recover the stopped marker (typed=%s)', + async (typed) => { + const f = ownerFixture(); + const started = gate(); + const rejectStartup = gate(); + const failure = typed + ? new McpAbortedError('owned', 'connect') + : new Error('PRIVATE_FAILURE_RACING_STOP'); + const opening = f.owner.enter(() => + connectAgentMcp(refs, { + ...f.options, + consentGate: () => Promise.resolve(new Map()), + resolveSecret: () => 'SYNTHETIC_SECRET', + startMcpClient: async () => { + started.release(); + await rejectStartup.promise; + throw failure; + }, + }), + ); + const result = opening.catch((cause: unknown) => cause); + await started.promise; + f.owner.stop(); + rejectStartup.release(); + const cause = await result; + expect(f.owner.isStoppedFailure(cause)).toBe(typed); + expect(f.registry.observations.exitCode()).toBe(typed ? 0 : 9); + if (!typed) expect(cause).toBe(failure); + await f.owner.close(); + }, + ); + + it('never starts a new asynchronous manager close after the original budget expires', async () => { + const close = vi.fn(() => Promise.resolve()); + let settled = false; + void closePreparedMcp(client(close), undefined, { + deadline: () => 0, + remainingMs: () => 0, + }).then(() => { + settled = true; + }); + await reactions(); + expect(close, 'SESSION_MCP_NO_NEW_CLOSE_AFTER_EXPIRY').not.toHaveBeenCalled(); + expect(settled).toBe(false); + }); + + it.each([true, false])( + 'actual failed-start manager cleanup retains its missing ACK before any host is adopted (failed=%s)', + async (failed) => { + const f = ownerFixture(); + const invocation = f.owner.invocation; + await f.owner.close(); + const reservation = new SessionHostOwner(invocation, 'chat'); + const closeFailure = new Error('PRIVATE_FAILED_START_CLOSE'); + const close = vi.fn(() => (failed ? Promise.reject(closeFailure) : Promise.resolve())); + const onCloseError = vi.fn((_id: string, cause: unknown) => + reservation.work.cleanupUncertain(cause), + ); + await expect( + reservation.build(async (work) => { + await connectAgentMcp(refs, { + cwd: '/tmp', + assertAdmitting: work.assertAdmitting, + onFailure: work.failure, + onCloseError, + budget: invocation.registry.budget, + work: work.work, + connectSignal: work.signal, + consentGate: () => Promise.resolve(new Map()), + resolveSecret: () => 'SYNTHETIC_SECRET', + startMcpClient: (_configs, signal, options, lifecycle) => + actualStartMcpClient( + [ + { + id: 'owned', + open: () => + Promise.resolve({ + listTools: () => + Promise.reject(new McpError('synthetic discovery refusal')), + callTool: () => Promise.resolve({ content: [], isError: false }), + close, + }), + }, + ], + signal, + options, + lifecycle, + ), + }); + throw new Error('The fixture starter must refuse.'); + }), + ).rejects.toMatchObject({ code: 'invalid_invocation' }); + expect(close).toHaveBeenCalledOnce(); + if (failed) { + void reservation.work.close(); + await reactions(); + expect( + onCloseError, + 'SESSION_ACTUAL_MANAGER_FAILED_START_CLOSE_IS_RETAINED', + ).toHaveBeenCalledExactlyOnceWith('owned', closeFailure); + expect(invocation.registry.budget.deadline()).toBe(3000); + expect(invocation.registry.observations.exitCode()).toBe(9); + expect(invocation.registry.activeOwners).toBe(1); + } else { + expect(onCloseError).not.toHaveBeenCalled(); + await reservation.work.close(); + await reactions(); + expect(invocation.registry.activeOwners).toBe(0); + expect(invocation.registry.observations.exitCode()).toBe(0); + } + }, + ); +}); diff --git a/apps/cli/src/process/invocation.test-support.ts b/apps/cli/src/process/invocation.test-support.ts new file mode 100644 index 00000000..6132d6a3 --- /dev/null +++ b/apps/cli/src/process/invocation.test-support.ts @@ -0,0 +1,35 @@ +import { vi } from 'vitest'; + +import { createInvocationFiniteExit } from './invocation-finite-exit.js'; +import { InvocationRegistry } from './invocation-registry.js'; + +/** Explicit unit-test exit/clock ports; a pending product obligation cannot exit the Vitest worker. */ +export function invocationFixture() { + let now = 0; + const timers = new Map<() => void, number>(); + const clock = { + now: () => now, + schedule: (ms: number, fire: () => void) => { + timers.set(fire, now + ms); + return () => { + timers.delete(fire); + }; + }, + }; + const exit = vi.fn(); + const registry = new InvocationRegistry(clock); + const invocation = { + registry, + finiteExit: createInvocationFiniteExit(registry, clock, { exit, setExitCode: vi.fn() }), + seedFileCatalogOnce: () => Promise.resolve(), + }; + return { + invocation, + registry, + exit, + advance: (time: number) => { + now = time; + for (const [fire, at] of [...timers]) if (at <= time && timers.delete(fire)) fire(); + }, + }; +} diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 217478a3..47dbbbfd 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -894,6 +894,34 @@ it cannot launch a new async helper; a returned promise may only observe work already started. Joins, restoration and reap helpers do not start after expiry. This attempt-all cancellation rule grants no retirement or safe-close ACK. +### Session ownership implementation checkpoint + +**Partial Step 8d implementation on `development`; not independently accepted.** +Standalone `agent run` now reserves its surface and host owners before asynchronous +input, consent or session construction. The original invocation owns captured +stdin cancellation, late builder adoption, producer/accounting joins, MCP cleanup +and the shared database tail. A rejected durability barrier, callback-reported +MCP close fault or database-close throw retains cleanup uncertainty; cancellation +and the common fatal deadline do not acknowledge those obligations. Classified +turn failures retain exit 1, invocation refusals retain exit 2, and unexpected +infrastructure failures use exit 9. This surface has no workflow health authority +to invent exits 5–8. + +Bound MCP consent uses captured prompt streams and the host cancellation signal. +A late answer cannot append a grant. Prompt custody joins actual completion and +acknowledged cursor delivery; a fulfilled Clack prompt is not aborted again during +normal cleanup. Best-effort retention warnings retain their actual delivery tail +and output-failure classification. The optional builder ownership port preserves +accounting/publication failures across repeated host-safe-close checks. + +The new per-mount terminal owner requires explicit acquisition custody and joins +entered suspension/publication work before final native release. Its actual +chat/Home Ink, persistent alternate-screen and job-control adapters remain staged. +Fresh/resumed/replaced chat/Home hosts, wizard custody and global native/bootstrap +activation still require integration and complete cumulative/platform acceptance. +The [implementation register](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-session-substrate-checkpoint--2026-10-16) +records this limited scope; whole Step 8 remains open. + ### Workflow and terminal ownership **Step 8c independently accepted on `development`.** The diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 0f650086..e0572374 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -6023,3 +6023,49 @@ session/preparation/terminal lifetimes and caught/fatal/background/sink paths, then supplies its own cumulative and actual-platform acceptance. Steps 9–19 and whole-wave publication remain staged. No new ADR, deferral, provider capture, credential, billing or user decision is needed. Step 8d proceeds automatically. + +## Wave 3 Step 8d session substrate checkpoint — 2026-10-16 + +**Implementation checkpoint on `development`, not Step 8d acceptance.** +The session work/surface/host owners and a per-acquisition terminal owner are +implemented. Standalone one-shot `agent run` connects its input, consent, +construction, actual producer/accounting/MCP cleanup, warning delivery and shared +database lifetime. The [canonical CLI contract](../../reference/cli/commands.md#session-ownership-implementation-checkpoint) +describes this partial scope. The SDK-free optional failed-start MCP lifecycle +reports close uncertainty before waiting on siblings and preserves their actual +raw lifetimes; its [canonical home](../../reference/shared-core/mcp-integration.md#failed-start-cleanup-lifecycle) +records compatibility and the common budget. + +Scoped read-only implementation advice found terminal-write observation behind +a held provider, lost failed-start MCP close uncertainty, pre-cleanup setup fault +ordering, late consent writes, lease capture before acknowledgement, repeated +Clack close and lost output-failure classification. Each is corrected with +permanent regressions. These advisory reads are **not** either required complete +cumulative clean round or the separate security sitting. Earlier failed author +attempts remain failed evidence; no warning/timeout is relabelled as cleanup ACK. + +At the frozen author source, before the final two late-consent integration +controls, the fresh ordinary CLI suite passes **304 files / 5,178 tests / +10 established skips**. Production bytes remain unchanged when those two +controls are added; the focused agent cohort passes all **37 tests**. The complete +MCP suite passes **20 files / 242 tests**. +Workspace lint/typecheck passes **17 tasks**, with twelve existing cache hits and +five fresh executions. The CLI build, seam/dependency fences, finite bundle closure, +compiled bootstrap and compiled CLI smoke pass. Closure remains **26 JavaScript +outputs / seventeen external dependencies**. The installed-Clack regression uses +synthetic streams; terminal-owner controls use captured clocks/deferred work. +Neither proves operator TTY restoration or actual Windows session shutdown. +The final `CI=true` workspace lint/typecheck/test gate passes **23 tasks**: +twenty valid cache hits and fresh CLI lint, typecheck and tests. That fresh CLI +execution includes both late-consent controls: **304 files / 5,179 tests / +11 established CI-projection skips**. Other packages' replayed test inventories +remain cache-attributed, rather than a fresh whole-repository execution. No new +remote CI, packed-platform, whole-wave or merge execution is inferred. + +Remaining Step 8d work is actual fresh/resume/clear/reseat and superseded chat/Home +host integration; entered Ink suspension, persistent alternate-screen/job-control +and wizard custody; then native/bootstrap/caught/fatal/background/sink activation. +Two NEW complete cumulative clean rounds from the original `d9625ff0` boundary, +a separate NEW clean security sitting and actual required platform evidence are +still owed after final integration. **Whole Step 8 and Steps 9–19 remain open.** +No new ADR, deferral, credential, provider call, billing or user decision is needed. From f39a360dc0847523a86388f69bfba9de4afa189a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 00:41:27 +0300 Subject: [PATCH 105/222] fix(cli): retain chat and home native session lifetimes Join every actual session host and entered terminal, prompt, catalog and output operation before shared database cleanup. Preserve original signal custody, control-delivery acknowledgement and the single finite failure budget across rebuilds and held native work. Add regressions for advisory findings. Update the two affected secrets-control chat inventories atomically; both selected fixed/removal/restored cycles pass. Fresh CLI tests pass 314 files / 5266 tests / 10 established skips; workspace and build gates pass with cache attribution recorded in the implementation register. This is a partial Step 8d checkpoint. Product-wide bootstrap activation and complete cumulative, security and actual platform acceptance remain open. Refs: ADR-0110, ADR-0103, ADR-0068 Co-Authored-By: Codex --- apps/cli/src/chat/persister.ts | 7 +- .../src/chat/session-chat-lifetime.test.ts | 135 ++ apps/cli/src/chat/session-chat-lifetime.ts | 110 ++ .../src/chat/session-chat-terminal.test.ts | 194 ++ apps/cli/src/chat/session-chat-terminal.ts | 190 ++ apps/cli/src/chat/session-consent.ts | 29 + .../src/chat/session-control-writes.test.ts | 51 + apps/cli/src/chat/session-control-writes.ts | 67 + apps/cli/src/chat/session-host-owner.ts | 2 +- apps/cli/src/chat/session-host.ts | 3 + apps/cli/src/chat/session-hosts.test.ts | 152 ++ apps/cli/src/chat/session-hosts.ts | 63 + apps/cli/src/chat/session-ink-input.test.ts | 213 +++ apps/cli/src/chat/session-ink-input.ts | 142 ++ apps/cli/src/chat/session-ink-mount.test.ts | 261 +++ apps/cli/src/chat/session-ink-mount.ts | 145 ++ apps/cli/src/chat/session-ink-native.test.ts | 263 +++ apps/cli/src/chat/session-prompt.test.ts | 52 +- apps/cli/src/chat/session-prompt.ts | 15 +- apps/cli/src/chat/session-surface-lifetime.ts | 7 +- apps/cli/src/chat/session-terminal-owner.ts | 4 + .../src/chat/session-view-lifetime.test.ts | 100 ++ apps/cli/src/chat/session-view-lifetime.ts | 68 + apps/cli/src/chat/session-work-owner.ts | 6 + apps/cli/src/commands/chat.test.ts | 160 +- apps/cli/src/commands/chat.ts | 1562 ++++++++++------- apps/cli/src/commands/provider.test.ts | 21 + apps/cli/src/commands/provider.ts | 3 + .../cli/src/engine/model-catalog-port.test.ts | 154 +- apps/cli/src/engine/model-catalog-port.ts | 31 +- apps/cli/src/engine/model-refresh.ts | 74 +- apps/cli/src/engine/providers.test.ts | 38 + apps/cli/src/engine/providers.ts | 27 +- apps/cli/src/home/drive-home.test.ts | 90 +- apps/cli/src/home/drive-home.tsx | 942 ++++++---- .../src/home/session-home-lifetime.test.ts | 46 + apps/cli/src/home/session-home-lifetime.ts | 73 + apps/cli/src/onboarding/wizard-native.test.ts | 52 + apps/cli/src/onboarding/wizard.test.ts | 49 + apps/cli/src/onboarding/wizard.ts | 94 +- apps/cli/src/process/exit-observations.ts | 12 + .../process/invocation-finite-exit.test.ts | 74 + .../cli/src/process/invocation-finite-exit.ts | 20 +- apps/cli/src/process/invocation-registry.ts | 13 + apps/cli/src/process/io.ts | 3 + apps/cli/src/process/native-invocation.ts | 6 +- apps/cli/src/render/hatches.test.ts | 46 +- apps/cli/src/render/hatches.ts | 40 +- apps/cli/src/render/scrollback.test.ts | 115 +- apps/cli/src/render/scrollback.ts | 100 +- apps/cli/src/render/suspend.test.ts | 295 +++- apps/cli/src/render/suspend.ts | 399 ++++- apps/cli/src/render/tui/chat-ink.tsx | 222 ++- apps/cli/src/render/tui/home-app.test.tsx | 35 + apps/cli/src/render/tui/home-app.tsx | 7 +- tools/secrets-controls/mutations.mjs | 4 +- 56 files changed, 5981 insertions(+), 1105 deletions(-) create mode 100644 apps/cli/src/chat/session-chat-lifetime.test.ts create mode 100644 apps/cli/src/chat/session-chat-lifetime.ts create mode 100644 apps/cli/src/chat/session-chat-terminal.test.ts create mode 100644 apps/cli/src/chat/session-chat-terminal.ts create mode 100644 apps/cli/src/chat/session-consent.ts create mode 100644 apps/cli/src/chat/session-control-writes.test.ts create mode 100644 apps/cli/src/chat/session-control-writes.ts create mode 100644 apps/cli/src/chat/session-hosts.test.ts create mode 100644 apps/cli/src/chat/session-hosts.ts create mode 100644 apps/cli/src/chat/session-ink-input.test.ts create mode 100644 apps/cli/src/chat/session-ink-input.ts create mode 100644 apps/cli/src/chat/session-ink-mount.test.ts create mode 100644 apps/cli/src/chat/session-ink-mount.ts create mode 100644 apps/cli/src/chat/session-ink-native.test.ts create mode 100644 apps/cli/src/chat/session-view-lifetime.test.ts create mode 100644 apps/cli/src/chat/session-view-lifetime.ts create mode 100644 apps/cli/src/home/session-home-lifetime.test.ts create mode 100644 apps/cli/src/home/session-home-lifetime.ts create mode 100644 apps/cli/src/onboarding/wizard-native.test.ts diff --git a/apps/cli/src/chat/persister.ts b/apps/cli/src/chat/persister.ts index 686d3c5b..57633109 100644 --- a/apps/cli/src/chat/persister.ts +++ b/apps/cli/src/chat/persister.ts @@ -23,6 +23,7 @@ import type { } from '@relavium/shared'; import type { GovernorWiring, SessionUsageWriter } from './session-host.js'; +import type { SessionHostOwner } from './session-host-owner.js'; import { deriveSessionTitle } from './session-title.js'; /** @@ -142,7 +143,11 @@ export interface SessionPersister { close(): void; } -export function createSessionPersister(deps: SessionPersisterDeps): SessionPersister { +export function createSessionPersister( + deps: SessionPersisterDeps, + owner?: SessionHostOwner, +): SessionPersister { + if (owner !== undefined) return owner.createPersister(() => createSessionPersister(deps)); const iso = (): string => new Date(deps.now()).toISOString(); // Frozen at persister construction (≈ session start) — the row's creation time never changes, so it is a diff --git a/apps/cli/src/chat/session-chat-lifetime.test.ts b/apps/cli/src/chat/session-chat-lifetime.test.ts new file mode 100644 index 00000000..420af52f --- /dev/null +++ b/apps/cli/src/chat/session-chat-lifetime.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from 'vitest'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { SessionChatLifetime } from './session-chat-lifetime.js'; + +function gate() { + let release: () => void = () => undefined; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} +const turn = (): Promise => new Promise((resolve) => setImmediate(resolve)); +function fixture() { + const f = invocationFixture(); + let interrupt: () => void = () => undefined; + let termination: (signo: number) => void = () => undefined; + const removeInterrupt = vi.fn(); + const removeTermination = vi.fn(); + const chat = new SessionChatLifetime(f.invocation, { + onInterrupt: (callback) => { + interrupt = callback; + return removeInterrupt; + }, + onTerminationSignal: (callback) => { + termination = callback; + return removeTermination; + }, + }); + return { + ...f, + chat, + interrupt: () => interrupt(), + termination: (signo: number) => termination(signo), + removeInterrupt, + removeTermination, + }; +} + +describe('original chat invocation and shared database tail', () => { + it('joins all actual host roots before closing the shared database once', async () => { + const f = fixture(); + const held = gate(); + const closeDb = vi.fn(); + f.chat.adoptStore({ close: closeDb }); + const run = f.chat.execute(async () => { + await f.chat.hosts.construct((host) => { + void host.work.enter(() => held.promise); + return Promise.resolve(); + }); + return 4; + }); + await turn(); + expect(closeDb, 'CHAT_ALL_HOSTS_BEFORE_SHARED_DB_CLOSE').not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(2); + held.release(); + expect(await run).toBe(4); + expect(closeDb).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + expect(f.removeInterrupt).toHaveBeenCalledOnce(); + expect(f.removeTermination).toHaveBeenCalledOnce(); + }); + + it('keeps cooperative force-end4 after the old driver listener was removed during held cleanup', async () => { + const f = fixture(); + const held = gate(); + const first = vi.fn(); + const remove = f.chat.subscribeInterrupt(first); + const run = f.chat.execute(async () => { + await held.promise; + return 4; + }); + f.interrupt(); + expect(first).toHaveBeenCalledOnce(); + expect(f.registry.termination).toBeUndefined(); + remove(); + f.interrupt(); + expect(f.registry.observations.hasTerminatingSignal()).toBe(false); + expect(f.registry.observations.exitCode()).toBe(4); + expect(f.registry.budget.deadline()).toBe(3000); + expect(f.exit, 'CHAT_POST_DRIVER_FORCE_END_RETAINS_COOPERATIVE_4').not.toHaveBeenCalled(); + held.release(); + expect(await run).toBe(4); + expect(await f.invocation.finiteExit.exited).toBe(4); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(4); + }); + + it('yields SIGINT to an actual entered hatch without starting shutdown or consuming cooperative cancellation', async () => { + const f = fixture(); + const body = gate(); + const listener = vi.fn(); + const remove = f.chat.subscribeInterrupt(listener); + f.chat.suspendPort.attach(async (callback) => callback()); + const suspend = f.chat.suspendPort.current()?.(() => body.promise); + f.interrupt(); + expect(listener).not.toHaveBeenCalled(); + expect(f.chat.interrupted).toBe(false); + expect(f.registry.termination).toBeUndefined(); + body.release(); + await suspend; + f.chat.suspendPort.attach(undefined); + remove(); + await f.chat.execute(() => Promise.resolve(4)); + }); + + it('a shared DB close refusal retains surface uncertainty and the same finite deadline', async () => { + const f = fixture(); + const primary = new Error('PRIVATE_DB_CLOSE'); + f.chat.adoptStore({ + close: () => { + throw primary; + }, + }); + let settled = false; + void f.chat + .execute(() => Promise.resolve(4)) + .then(() => { + settled = true; + }); + await turn(); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.activeOwners).toBe(1); + expect(settled).toBe(false); + expect( + f.removeInterrupt, + 'SESSION_SIGNAL_CUSTODY_THROUGH_DATABASE_RELEASE', + ).not.toHaveBeenCalled(); + expect(f.removeTermination).not.toHaveBeenCalled(); + f.termination(15); + expect(f.registry.budget.deadline()).toBe(3000); + expect(f.registry.observations.exitCode()).toBe(9); + f.advance(3000); + expect(await f.invocation.finiteExit.exited).toBe(9); + expect(settled).toBe(false); + }); +}); diff --git a/apps/cli/src/chat/session-chat-lifetime.ts b/apps/cli/src/chat/session-chat-lifetime.ts new file mode 100644 index 00000000..8efe64ee --- /dev/null +++ b/apps/cli/src/chat/session-chat-lifetime.ts @@ -0,0 +1,110 @@ +import type { CliInvocation } from '../process/invocation.js'; +import type { ExitCode } from '../process/exit-codes.js'; +import { CliError } from '../process/cli-error.js'; +import { defaultSubscribeSignals } from '../process/signals.js'; +import { createSuspendPort } from '../render/suspend.js'; +import { SessionHosts } from './session-hosts.js'; +import { SessionSurfaceLifetime } from './session-surface-lifetime.js'; + +interface ChatSignalLifecycle { + readonly onInterrupt: (callback: () => void) => () => void; + readonly onTerminationSignal: (callback: (signo: number) => void) => () => void; +} + +/** One invocation across fresh/resume/clear/reseat; the shared database follows every actual host. */ +export class SessionChatLifetime { + readonly surface: SessionSurfaceLifetime; + readonly hosts: SessionHosts; + readonly suspendPort = createSuspendPort(); + #interrupt: (() => void) | undefined; + #cooperativeRequested = false; + #store: { readonly close: () => void } | undefined; + + constructor(invocation: CliInvocation, lifecycle?: ChatSignalLifecycle) { + this.surface = new SessionSurfaceLifetime( + invocation, + 'chat', + (terminate) => { + const signal = (signo: number): void => { + if (signo === 2) { + if (this.suspendPort.isSuspended()) return; + if (this.#cooperativeRequested) { + invocation.finiteExit.cooperativeCancel(4); + return; + } + if (this.#interrupt !== undefined) { + this.#cooperativeRequested = true; + try { + this.#interrupt(); + } catch { + invocation.finiteExit.fail('cli_internal'); + } + return; + } + } + terminate(signo); + }; + if (lifecycle === undefined) return defaultSubscribeSignals(signal); + const removeInterrupt = lifecycle.onInterrupt(() => signal(2)); + let removeTermination: () => void; + try { + removeTermination = lifecycle.onTerminationSignal(signal); + } catch (cause) { + removeInterrupt(); + throw cause; + } + return () => { + try { + removeInterrupt(); + } finally { + removeTermination(); + } + }; + }, + async () => { + // This stage runs after both surface roots and transferred warning/control tails join. + this.#store?.close(); + await Promise.resolve(); + }, + ); + this.hosts = new SessionHosts(this.surface, 'chat'); + } + + get interrupted(): boolean { + return this.#cooperativeRequested; + } + + /** The driver consumes cooperative SIGINT; all other signals still use the one finite authority. */ + readonly subscribeInterrupt = (listener: () => void): (() => void) => { + this.surface.work.assertAdmitting(); + if (this.#interrupt !== undefined) throw new CliError('cli_internal', ''); + this.#interrupt = listener; + return () => { + if (this.#interrupt === listener) this.#interrupt = undefined; + }; + }; + + adoptStore(store: { readonly close: () => void }): void { + if (this.#store !== undefined) throw new CliError('cli_internal', ''); + this.#store = store; + try { + this.#store = Object.freeze({ close: store.close.bind(store) }); + } catch (cause) { + this.surface.work.cleanupUncertain(cause); + throw cause; + } + } + + execute(body: () => Promise): Promise { + return this.surface.execute(async () => { + try { + return await body(); + } catch (cause) { + this.surface.work.failure(cause); + throw cause; + } finally { + await this.hosts.close(); + } + }); + } +} diff --git a/apps/cli/src/chat/session-chat-terminal.test.ts b/apps/cli/src/chat/session-chat-terminal.test.ts new file mode 100644 index 00000000..9796c3ee --- /dev/null +++ b/apps/cli/src/chat/session-chat-terminal.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi } from 'vitest'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import { EXIT_ALT_SCREEN } from '../render/alt-screen.js'; +import { SessionChatLifetime } from './session-chat-lifetime.js'; +import { SessionChatTerminal } from './session-chat-terminal.js'; +import { SessionInkMount } from './session-ink-mount.js'; +import { CliError } from '../process/cli-error.js'; + +const turn = () => new Promise((resolve) => setImmediate(resolve)); +function gate() { + let release: () => void = () => undefined; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} +function fixture(write?: (text: string) => Promise) { + const f = invocationFixture(); + const lifetime = new SessionChatLifetime(f.invocation, { + onInterrupt: () => () => undefined, + onTerminationSignal: () => () => undefined, + }); + const captured = captureIo(); + const writes: string[] = []; + const terminal = new SessionChatTerminal({ + lifetime, + active: true, + mouse: true, + io: { + ...captured.io, + writeOutAcknowledged: (text) => { + writes.push(text); + return write?.(text) ?? Promise.resolve(); + }, + }, + }); + const remove = vi.fn(); + terminal.enter(() => remove); + return { ...f, lifetime, terminal, writes, remove }; +} + +describe('persistent chat terminal and actual child mounts', () => { + it('preserves output failure immediately while a sibling native mount remains held', async () => { + const held = gate(); + const f = fixture(() => Promise.reject(new CliError('output_unavailable', ''))); + const nativeExit = gate(); + const mount = new SessionInkMount({ + invocation: f.invocation, + assertEntering: f.lifetime.surface.work.assertAdmitting, + failure: f.lifetime.surface.work.failure, + suspendPort: f.lifetime.suspendPort, + stopInput: () => undefined, + drainNative: () => held.promise, + }); + f.terminal.adoptMount(mount); + mount.acquire(() => ({ unmount: nativeExit.release, waitUntilExit: () => nativeExit.promise })); + await turn(); + expect( + f.registry.observations.infrastructureCode(), + 'CHAT_CONTROL_OUTPUT_FAILURE_BEFORE_HELD_JOIN', + ).toBe('output_unavailable'); + expect(f.registry.activeOwners).toBe(3); + held.release(); + }); + + it('refuses final publication when an already-entered restoration ACK arrives after expiry', async () => { + const delivery = gate(); + const f = fixture((text) => + text.includes(EXIT_ALT_SCREEN) ? delivery.promise : Promise.resolve(), + ); + f.invocation.finiteExit.fail('cli_internal'); + const closing = f.terminal.close(); + await turn(); + expect(f.writes.join('')).toContain(EXIT_ALT_SCREEN); + f.advance(3000); + delivery.release(); + await closing; + const prior = [...f.writes]; + expect( + () => f.terminal.publishOut('late summary'), + 'CHAT_NO_FINAL_PUBLICATION_AFTER_EXPIRY', + ).toThrow(CliError); + expect(() => f.terminal.publishErr('late error')).toThrow(CliError); + expect(f.writes).toEqual(prior); + }); + it('drains the full native mount before delivering final alternate-screen restoration', async () => { + const f = fixture(); + const held = gate(); + const nativeExit = gate(); + const unmount = vi.fn(nativeExit.release); + const mount = new SessionInkMount({ + invocation: f.invocation, + assertEntering: f.lifetime.surface.work.assertAdmitting, + failure: f.lifetime.surface.work.failure, + suspendPort: f.lifetime.suspendPort, + stopInput: () => undefined, + drainNative: () => held.promise, + }); + f.terminal.adoptMount(mount); + mount.acquire(() => ({ unmount, waitUntilExit: () => nativeExit.promise })); + const closing = f.terminal.close(); + await turn(); + expect(unmount).not.toHaveBeenCalled(); + expect(f.writes.join(''), 'CHAT_FINAL_RESTORE_AFTER_ALL_NATIVE_MOUNTS').not.toContain( + EXIT_ALT_SCREEN, + ); + held.release(); + await closing; + expect(unmount).toHaveBeenCalledOnce(); + expect(f.writes.join('')).toContain(EXIT_ALT_SCREEN); + expect(f.remove).toHaveBeenCalledOnce(); + await f.lifetime.surface.work.close(); + expect(f.registry.activeOwners).toBe(0); + }); + + it('keeps final delivery owed after the control sequence was enqueued', async () => { + const delivered = gate(); + const f = fixture((text) => + text.includes(EXIT_ALT_SCREEN) ? delivered.promise : Promise.resolve(), + ); + let restored = false; + const closing = f.terminal.close().then(() => { + restored = true; + }); + await turn(); + expect(f.writes.join('')).toContain(EXIT_ALT_SCREEN); + expect(restored, 'CHAT_FINAL_CONTROL_DELIVERY_REQUIRED').toBe(false); + expect(f.remove).not.toHaveBeenCalled(); + delivered.release(); + await closing; + expect(f.remove).toHaveBeenCalledOnce(); + await f.lifetime.surface.work.close(); + }); + + it('starts every job finish before waiting and retains a failed restore under the original budget', async () => { + const cause = new Error('PRIVATE_CONTROL_DELIVERY'); + const f = fixture((text) => + text.includes(EXIT_ALT_SCREEN) ? Promise.reject(cause) : Promise.resolve(), + ); + const finishes: ReturnType[] = []; + for (let index = 0; index < 2; index++) { + const native = gate(); + const finish = vi.fn(() => { + native.release(); + return native.promise; + }); + finishes.push(finish); + f.terminal.adoptJob({ + requestSuspend: vi.fn(), + dispose: vi.fn(), + finish, + released: native.promise, + }); + } + let acknowledged = false; + void f.terminal.close().then(() => { + acknowledged = true; + }); + await turn(); + for (const finish of finishes) expect(finish).toHaveBeenCalledOnce(); + expect(acknowledged).toBe(false); + expect(f.registry.observations.exitCode()).toBe(9); + f.advance(3000); + await turn(); + expect(f.exit).toHaveBeenCalledWith(9); + }); + + it('does not start a new asynchronous restore after the original budget expires', async () => { + const f = fixture(); + const held = gate(); + const native = gate(); + f.terminal.adoptJob({ + requestSuspend: vi.fn(), + dispose: vi.fn(), + finish: () => { + native.release(); + return held.promise; + }, + released: native.promise, + }); + f.invocation.finiteExit.fail('cli_internal'); + const closing = f.terminal.close(); + void closing; + await turn(); + f.advance(3000); + held.release(); + await turn(); + expect(f.writes.join(''), 'CHAT_NO_NEW_CONTROL_AFTER_ORIGINAL_EXPIRY').not.toContain( + EXIT_ALT_SCREEN, + ); + expect(f.exit).toHaveBeenCalledWith(9); + }); +}); diff --git a/apps/cli/src/chat/session-chat-terminal.ts b/apps/cli/src/chat/session-chat-terminal.ts new file mode 100644 index 00000000..01c2c0be --- /dev/null +++ b/apps/cli/src/chat/session-chat-terminal.ts @@ -0,0 +1,190 @@ +import { SessionControlWrites } from './session-control-writes.js'; +import { CliError } from '../process/cli-error.js'; +import type { CliIo } from '../process/io.js'; +import { + createAltScreenController, + DISABLE_MOUSE, + EXIT_ALT_SCREEN, + SHOW_CURSOR, +} from '../render/alt-screen.js'; +import { DISABLE_BRACKETED_PASTE } from '../render/tui/home-input.js'; +import type { JobControlBinding } from '../render/suspend.js'; +import type { SessionInkMount } from './session-ink-mount.js'; +import type { SessionChatLifetime } from './session-chat-lifetime.js'; +import { SessionTerminalOwner } from './session-terminal-owner.js'; + +/** One persistent chat buffer; child mounts and reversible handoffs retire before final control delivery. */ +export class SessionChatTerminal { + readonly #lifetime: SessionChatLifetime; + readonly #io: CliIo; + readonly #owner: SessionTerminalOwner; + readonly #writes: SessionControlWrites; + readonly #mounts = new Set(); + readonly #jobs = new Set(); + readonly alt: ReturnType; + #removeExit: (() => void) | undefined; + #exitRegistrationUnknown = false; + #sealed = false; + #failed: { readonly cause: unknown } | undefined; + + constructor(options: { + readonly lifetime: SessionChatLifetime; + readonly io: CliIo; + readonly active: boolean; + readonly mouse: boolean; + }) { + this.#lifetime = options.lifetime; + this.#io = options.io; + this.#writes = new SessionControlWrites({ + invocation: options.lifetime.surface.invocation, + write: (text) => options.io.writeOutAcknowledged(text), + failure: this.#report, + }); + this.alt = createAltScreenController({ + active: options.active, + mouse: options.mouse, + write: (sequence) => { + void this.writeControl(sequence); + }, + }); + this.#owner = new SessionTerminalOwner( + options.lifetime.surface.invocation, + options.lifetime.surface.work.assertAdmitting, + this.#report, + ); + this.#owner.acquire((creation) => + creation.adopt({ + seal: () => { + this.#sealed = true; + for (const job of this.#jobs) { + try { + job.dispose(); + } catch (cause) { + this.#report(cause); + } + } + for (const mount of this.#mounts) { + try { + void mount.close(); + } catch (cause) { + this.#report(cause); + } + } + }, + release: async () => { + // Finish releases an entered SIGCONT waiter while its exact Ink tree is still mounted. + const closing: Promise[] = []; + for (const job of this.#jobs) { + try { + closing.push(job.finish()); + } catch (cause) { + this.#report(cause); + } + } + for (const mount of this.#mounts) { + try { + closing.push(mount.close()); + } catch (cause) { + this.#report(cause); + } + } + const joined = await Promise.allSettled(closing); + for (const result of joined) + if (result.status === 'rejected') this.#report(result.reason); + if (this.#lifetime.surface.invocation.registry.budget.remainingMs() === 0) + await new Promise(() => {}); + this.alt.restore(); // Enqueue is not ACK: all actual delivery promises remain in #writes. + await this.#writes.close(); + try { + this.#removeExit?.(); + } catch (cause) { + this.#report(cause); + } + if (this.#exitRegistrationUnknown) throw new CliError('cli_internal', ''); + if (this.#failed !== undefined) throw this.#failed.cause; + }, + }), + ); + } + + readonly #report = (cause: unknown): void => { + this.#failed ??= { cause }; + this.#lifetime.surface.work.failure(cause); // Preserve classified output failure before a held sibling join. + this.#lifetime.surface.work.cleanupUncertain(cause); + }; + + #assertPublication(): void { + if (this.#lifetime.surface.invocation.registry.budget.remainingMs() === 0) + throw new CliError('cli_internal', ''); + this.#lifetime.surface.work.assertAdmitting(); + } + + readonly publishOut = (text: string): Promise => { + this.#assertPublication(); + return this.#io.writeOutAcknowledged(text); + }; + + readonly publishErr = (text: string): Promise => { + this.#assertPublication(); + return this.#io.writeErrAcknowledged(text); + }; + + /** Ordinary call sites retain their own admission; already-entered native reclaim can finish after sealing. */ + readonly writeControl = (sequence: string): Promise => this.#writes.write(sequence); + + readonly releaseForSuspend = async (): Promise => { + const released = this.alt.releaseForSuspend(); + await this.#writes.joinEntered(); + return released; + }; + + readonly reclaimAfterSuspend = async (): Promise => { + this.alt.reclaimAfterSuspend(); + await this.#writes.joinEntered(); + }; + + enter(onProcessExit: (callback: () => void) => () => void): void { + this.#lifetime.surface.work.assertAdmitting(); + if (this.#sealed) throw new CliError('cli_internal', ''); + try { + this.alt.enter(); + this.#exitRegistrationUnknown = true; + this.#removeExit = onProcessExit(() => { + // Process death cannot join asynchronous work. This finite captured fallback grants no ACK. + try { + this.#io.nativeStdin?.setRawMode(false); + this.#io.nativeStdout?.write( + DISABLE_MOUSE + + DISABLE_BRACKETED_PASTE + + (this.alt.isEntered() ? EXIT_ALT_SCREEN : '') + + SHOW_CURSOR, + ); + } catch { + /* The process is already exiting; no asynchronous recovery or retirement is inferred. */ + } + }); + this.#exitRegistrationUnknown = false; + } catch (cause) { + this.#report(cause); + throw cause; + } + } + + readonly adoptMount = (mount: SessionInkMount): void => { + this.#mounts.add(mount); + void mount.retired.then(() => { + this.#mounts.delete(mount); + }); + if (this.#sealed) void mount.close(); + }; + + readonly adoptJob = (job: JobControlBinding): void => { + this.#jobs.add(job); + void job.released.then(() => { + this.#jobs.delete(job); + }, this.#report); + if (this.#sealed) job.dispose(); + }; + + readonly close = (): Promise => this.#owner.close(); +} diff --git a/apps/cli/src/chat/session-consent.ts b/apps/cli/src/chat/session-consent.ts new file mode 100644 index 00000000..03552a00 --- /dev/null +++ b/apps/cli/src/chat/session-consent.ts @@ -0,0 +1,29 @@ +import type { CliIo } from '../process/io.js'; +import type { GlobalOptions } from '../process/options.js'; +import { createConsentGate } from '../engine/mcp-consent-gate.js'; +import { createBoundConsentPrompter } from '../mcp/consent-prompt.js'; +import type { BuildChatSessionOptions } from './session-host.js'; +import { runSessionPrompt } from './session-prompt.js'; +import type { SessionWorkOwner } from './session-work-owner.js'; + +/** Captured native consent belongs to the same actual host as the MCP construction it authorizes. */ +export function createOwnedSessionConsentGate( + deps: { readonly io: CliIo; readonly global: GlobalOptions; readonly homeDir: string }, + owner: SessionWorkOwner, +): NonNullable { + return createConsentGate({ + ...deps, + signal: owner.signal, + prompt: (subject) => { + const prompt = createBoundConsentPrompter(deps.io); + return runSessionPrompt( + owner.invocation, + deps.io, + owner.assertAdmitting, + owner.failure, + (signal) => prompt(subject, { signal }), + owner.signal, + ); + }, + }); +} diff --git a/apps/cli/src/chat/session-control-writes.test.ts b/apps/cli/src/chat/session-control-writes.test.ts new file mode 100644 index 00000000..f64f446c --- /dev/null +++ b/apps/cli/src/chat/session-control-writes.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from 'vitest'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { SessionControlWrites } from './session-control-writes.js'; +import { CliError } from '../process/cli-error.js'; + +const turn = () => new Promise((resolve) => setImmediate(resolve)); +describe('captured session control delivery', () => { + it('joins the actual callback independently of enqueue and rejects future writes after close', async () => { + const f = invocationFixture(); + let deliver: () => void = () => undefined; + const raw = new Promise((resolve) => { + deliver = resolve; + }); + const write = vi.fn(() => raw); + const controls = new SessionControlWrites({ + invocation: f.invocation, + write, + failure: vi.fn(), + }); + expect(controls.write('synthetic control')).toBe(raw); + let joined = false; + const joining = controls.joinEntered().then(() => { + joined = true; + }); + const closing = controls.close(); + await turn(); + expect(joined, 'SESSION_CONTROL_DELIVERY_NOT_ENQUEUE').toBe(false); + deliver(); + await joining; + await closing; + expect(() => controls.write('late')).toThrow(); + expect(write).toHaveBeenCalledOnce(); + }); + + it('a throwing failure observer cannot create an unhandled rejection or lose the finite backstop', async () => { + const f = invocationFixture(); + const cause = new CliError('output_unavailable', ''); + const controls = new SessionControlWrites({ + invocation: f.invocation, + write: () => Promise.reject(cause), + failure: () => { + throw new Error('synthetic observer failure'); + }, + }); + await expect(controls.write('control')).rejects.toBe(cause); + await controls.close(); + await turn(); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.budget.deadline()).toBe(3000); + }); +}); diff --git a/apps/cli/src/chat/session-control-writes.ts b/apps/cli/src/chat/session-control-writes.ts new file mode 100644 index 00000000..6657833b --- /dev/null +++ b/apps/cli/src/chat/session-control-writes.ts @@ -0,0 +1,67 @@ +import type { EgressWorkOptions } from '@relavium/db'; +import { EgressWorkScope } from '../engine/egress-work.js'; +import { CliError } from '../process/cli-error.js'; +import type { CliInvocation } from '../process/invocation.js'; + +/** Actual captured control deliveries, including native reclaim entered before the terminal seals. */ +export class SessionControlWrites { + readonly #scope: EgressWorkScope; + readonly #active = new Set>(); + readonly #invocation: CliInvocation; + readonly #write: (text: string) => Promise; + readonly #failure: (cause: unknown) => void; + + constructor(options: { + readonly invocation: CliInvocation; + readonly write: (text: string) => Promise; + readonly failure: (cause: unknown) => void; + readonly parent?: EgressWorkOptions; + }) { + this.#scope = new EgressWorkScope(options.parent); + this.#invocation = options.invocation; + this.#write = options.write; + this.#failure = options.failure; + } + + readonly write = (text: string): Promise => { + if (this.#invocation.registry.budget.remainingMs() === 0) { + const cause = new CliError('cli_internal', ''); + this.#report(cause); + throw cause; + } + try { + const raw = this.#scope.retainWork(() => this.#write(text)); + if (this.#scope.observationFailed) this.#report(new CliError('cli_internal', '')); + this.#active.add(raw); + void raw.then( + () => { + this.#active.delete(raw); + }, + (cause: unknown) => { + this.#active.delete(raw); + this.#report(cause); + }, + ); + return raw; + } catch (cause) { + this.#report(cause); + throw cause; + } + }; + + #report(cause: unknown): void { + try { + this.#failure(cause); + } catch { + this.#invocation.finiteExit.fail('cli_internal'); + } + } + + /** Snapshot only already-entered delivery; this grants no new input/publication authority. */ + readonly joinEntered = (): Promise => Promise.all([...this.#active]).then(() => undefined); + + readonly close = (): Promise => { + this.#scope.seal(); + return this.#scope.done; + }; +} diff --git a/apps/cli/src/chat/session-host-owner.ts b/apps/cli/src/chat/session-host-owner.ts index 9f163acf..7b9b0e12 100644 --- a/apps/cli/src/chat/session-host-owner.ts +++ b/apps/cli/src/chat/session-host-owner.ts @@ -35,7 +35,7 @@ export class SessionHostOwner { } } this.work.assertAdmitting(); - return { ...built, workOwner: this.work }; + return { ...built, workOwner: this.work, hostOwner: this }; }); } diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index a6b341b4..cc009b99 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -75,6 +75,7 @@ import { resolveChatAgentSource, type ResolvedChatAgent } from './agent-source.j import { sanitizeUntrustedInline } from '../render/sanitize.js'; import { hostDeadlineTimer, hostSleep } from '../process/sleep.js'; import type { SessionWorkOwner } from './session-work-owner.js'; +import type { SessionHostOwner } from './session-host-owner.js'; const SESSION_DURABILITY_FAILURE = 'this session could not be saved, so it will not send anything further — the transcript and cost on disk are behind what you see'; @@ -232,6 +233,8 @@ export interface SessionUsageWriter { } export interface BuiltChatSession { + /** Creation owner for surface persistence/teardown; absent in an explicitly unowned embedding. */ + readonly hostOwner?: SessionHostOwner; readonly workOwner?: SessionWorkOwner; readonly attachUsageWriter: (writer: SessionUsageWriter) => void; readonly whenHostSafeToClose: () => Promise; diff --git a/apps/cli/src/chat/session-hosts.test.ts b/apps/cli/src/chat/session-hosts.test.ts new file mode 100644 index 00000000..f28aacbc --- /dev/null +++ b/apps/cli/src/chat/session-hosts.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { resolveConfig } from '../config/resolve.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { buildChatSession } from './session-host.js'; +import { SessionHosts } from './session-hosts.js'; +import { SessionSurfaceLifetime } from './session-surface-lifetime.js'; +import { scriptedResolver, textTurn } from './test-support.js'; + +function gate() { + let release: () => void = () => undefined; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} +const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +function fixture() { + const f = invocationFixture(); + const surface = new SessionSurfaceLifetime(f.invocation, 'home', () => () => undefined); + return { ...f, surface, hosts: new SessionHosts(surface, 'home') }; +} + +const options = () => ({ + chat: resolveConfig({}).chat, + agentRef: undefined, + cwd: '/tmp', + projectConfigDir: undefined, + now: () => 0, + uuid: () => 'equal-session-id', + disableMcp: true, + providers: scriptedResolver([textTurn('synthetic')]), +}); + +describe('actual host instance collection, independent of UI selection', () => { + it('registers before construction and joins both equal-id hosts without retaining completed history', async () => { + const f = fixture(); + const closes: ReturnType[] = []; + const built = []; + for (let index = 0; index < 2; index++) { + const close = vi.fn(async () => {}); + closes.push(close); + built.push( + await f.hosts.construct((owner) => { + expect(f.registry.activeOwners).toBe(index + 2); + return owner.build(async (workOwner) => ({ + ...(await buildChatSession({ ...options(), workOwner })), + closeMcp: close, + })); + }), + ); + } + expect(built[0]?.sessionId).toBe(built[1]?.sessionId); + expect(built[0]?.hostOwner).not.toBe(built[1]?.hostOwner); + await built[0]?.hostOwner?.work.close(); + await reactions(); + expect(f.registry.activeOwners).toBe(2); + const closing = f.hosts.close(); + expect(f.hosts.close()).toBe(closing); + await closing; + await f.surface.work.close(); + await reactions(); + expect(closes.map((close) => close.mock.calls.length)).toEqual([1, 1]); + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); + }); + + it('holds superseded builders and every late MCP cleanup until actual acknowledgement', async () => { + const f = fixture(); + const construction = [gate(), gate()]; + const cleanup = [gate(), gate()]; + const closes: ReturnType[] = []; + const opening = construction.map((ready, index) => + f.hosts + .construct((owner) => + owner.build(async (workOwner) => { + const built = await buildChatSession({ ...options(), workOwner }); + const close = vi.fn( + () => cleanup[index]?.promise ?? Promise.reject(new Error('missing fixture gate')), + ); + closes.push(close); + await ready.promise; + return { ...built, closeMcp: close }; + }), + ) + .catch((cause: unknown) => cause), + ); + let acknowledged = false; + const closing = f.hosts.close(); + void closing.then(() => { + acknowledged = true; + }); + for (const ready of construction) ready.release(); + await reactions(); + expect(closes.map((close) => close.mock.calls.length)).toEqual([1, 1]); + expect(f.registry.activeOwners, 'SESSION_ALL_LATE_INSTANCES_RETAIN_NATIVE_CLOSE').toBe(3); + expect(acknowledged).toBe(false); + cleanup[0]?.release(); + await reactions(); + expect(acknowledged).toBe(false); + cleanup[1]?.release(); + await closing; + const results = await Promise.all(opening); + expect(results.every((result) => result instanceof Error)).toBe(true); + await f.surface.work.close(); + await reactions(); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.exitCode()).toBe(0); + }); + + it('reports post-build setup failure before held cleanup rather than waiting on its own root', async () => { + const f = fixture(); + const cleanup = gate(); + const close = vi.fn(() => cleanup.promise); + const cause = new Error('PRIVATE_POST_BUILD_FAILURE'); + const opening = f.hosts + .construct(async (owner) => { + await owner.build(async (workOwner) => ({ + ...(await buildChatSession({ ...options(), workOwner })), + closeMcp: close, + })); + throw cause; + }) + .catch((error: unknown) => error); + try { + await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.budget.deadline(), 'SESSION_POST_BUILD_FAILURE_PRECEDES_CLEANUP_JOIN').toBe( + 3000, + ); + expect(f.registry.activeOwners).toBe(1); + } finally { + cleanup.release(); + } + expect(await opening).toBe(cause); + await f.hosts.close(); + f.advance(3000); + await reactions(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(f.registry.activeOwners).toBe(0); + }); + + it('refuses future construction after closing without entering its factory', async () => { + const f = fixture(); + await f.hosts.close(); + const factory = vi.fn(() => Promise.resolve()); + await expect(f.hosts.construct(factory)).rejects.toMatchObject({ code: 'cli_internal' }); + expect(factory).not.toHaveBeenCalled(); + await f.surface.work.close(); + }); +}); diff --git a/apps/cli/src/chat/session-hosts.ts b/apps/cli/src/chat/session-hosts.ts new file mode 100644 index 00000000..74fe6215 --- /dev/null +++ b/apps/cli/src/chat/session-hosts.ts @@ -0,0 +1,63 @@ +import { CliError } from '../process/cli-error.js'; +import type { SessionOwner } from '../process/invocation-registry.js'; +import { SessionHostOwner } from './session-host-owner.js'; +import type { SessionSurfaceLifetime } from './session-surface-lifetime.js'; + +/** All actual instances, including pending/late/superseded builds; UI pointers confer no close authority. */ +export class SessionHosts { + readonly #surface: SessionSurfaceLifetime; + readonly #kind: SessionOwner['surface']; + readonly #hosts = new Set(); + #closing: Promise | undefined; + + constructor(surface: SessionSurfaceLifetime, kind: SessionOwner['surface']) { + this.#surface = surface; + this.#kind = kind; + } + + /** Includes post-build persistence/seeding/publication. Failure cleanup runs OUTSIDE this retained root. */ + async construct(factory: (host: SessionHostOwner) => Promise): Promise { + this.#surface.work.assertAdmitting(); + if (this.#closing !== undefined) throw new CliError('cli_internal', ''); + const host = new SessionHostOwner(this.#surface.invocation, this.#kind); + this.#hosts.add(host); + void host.work.retired.then(() => { + this.#hosts.delete(host); + }); + try { + return await host.work.enter(async () => { + const value = await factory(host); + host.work.assertAdmitting(); + return value; + }); + } catch (cause) { + // Observe before waiting on any held MCP/producer close. Never close from inside the same root. + host.work.failure(cause); + await host.work.close(); + if (host.work.isStoppedFailure(cause)) this.#surface.work.assertAdmitting(); + throw cause; + } + } + + /** Attempt every finite stop before awaiting any real host close. No new construction can enter afterward. */ + close(): Promise { + if (this.#closing !== undefined) return this.#closing; + let complete: () => void = () => undefined; + const pending = new Promise((resolve) => { + complete = resolve; + }); + this.#closing = pending; + const hosts = [...this.#hosts]; + for (const host of hosts) { + try { + host.work.stop(); + } catch (cause) { + host.work.cleanupUncertain(cause); + } + } + void Promise.all(hosts.map((host) => host.work.close())).then(complete, (cause: unknown) => { + this.#surface.work.cleanupUncertain(cause); + }); + return pending; + } +} diff --git a/apps/cli/src/chat/session-ink-input.test.ts b/apps/cli/src/chat/session-ink-input.test.ts new file mode 100644 index 00000000..b836732a --- /dev/null +++ b/apps/cli/src/chat/session-ink-input.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CliError } from '../process/cli-error.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { OwnedTtyInput } from '../test-support.js'; +import { SessionInkInput } from './session-ink-input.js'; + +const turn = (): Promise => new Promise((resolve) => setImmediate(resolve)); +function fixture(acknowledge = vi.fn(() => Promise.resolve())) { + const f = invocationFixture(); + const input = new OwnedTtyInput(); + const failure = vi.fn(() => f.invocation.finiteExit.fail('cli_internal')); + const lease = new SessionInkInput({ input, invocation: f.invocation, acknowledge, failure }); + return { ...f, input, failure, lease, acknowledge }; +} + +describe('captured native Ink input final release', () => { + it('rechecks callback multiplicity when a native remover consumes a sibling occurrence', async () => { + const f = fixture(); + const shared = vi.fn(); + f.input.on('readable', shared); + f.lease.enter(); + f.input.on('readable', shared); + f.input.on('readable', shared); + f.input.once('removeListener', (event, listener: unknown) => { + if (event === 'readable' && listener === shared) f.input.removeListener('readable', shared); + }); + await f.lease.close(); + expect(f.input.listeners('readable'), 'SESSION_INK_INPUT_REMOVAL_REENTRY_BASELINE').toEqual([ + shared, + ]); + f.input.destroy(); + }); + it('joins reentrant close during native observer registration before removing the late observer', async () => { + const f = fixture(); + let closing: Promise | undefined; + const hook = (event: string | symbol): void => { + if (event === 'newListener') closing = f.lease.close(); + }; + f.input.on('newListener', hook); + f.lease.enter(); + expect(closing).toBeDefined(); + await closing; + expect(f.input.listeners('newListener'), 'SESSION_INK_INPUT_REGISTRATION_REENTRY').toEqual([ + hook, + ]); + expect(f.acknowledge).toHaveBeenCalledOnce(); + f.input.destroy(); + }); + + it('a throwing failure reporter cannot skip independent release or replace the primary remover failure', async () => { + const f = invocationFixture(); + const input = new OwnedTtyInput(); + const primary = new Error('PRIVATE_PRIMARY_REMOVER'); + const observer = new Error('PRIVATE_FAILURE_OBSERVER'); + const acknowledge = vi.fn(() => Promise.resolve()); + const lease = new SessionInkInput({ + input, + invocation: f.invocation, + acknowledge, + failure: () => { + throw observer; + }, + }); + lease.enter(); + const first = vi.fn(); + input.on('readable', first); + input.on('readable', vi.fn()); + input.setRawMode(true); + input.on('removeListener', (event, listener: unknown) => { + if (event === 'readable' && listener === first) throw primary; + }); + await expect(lease.close()).rejects.toBe(primary); + expect(input.listenerCount('readable')).toBe(0); + expect(input.isRaw).toBe(false); + expect(acknowledge).toHaveBeenCalledOnce(); + expect(f.registry.observations.exitCode()).toBe(9); + input.destroy(); + }); + + it('removes only introduced occurrences when Ink and a prior reader share a callback identity', async () => { + const f = fixture(); + const shared = vi.fn(); + f.input.on('readable', shared); + f.lease.enter(); + f.input.on('readable', shared); + await f.lease.close(); + expect(f.input.listeners('readable'), 'SESSION_INK_INPUT_BASELINE_MULTIPLICITY').toEqual([ + shared, + ]); + f.input.destroy(); + }); + + it('refuses an extra baseline callback introduced after observer removal', async () => { + const f = fixture(); + const shared = vi.fn(); + f.input.on('readable', shared); + f.lease.enter(); + f.input.on('removeListener', (event) => { + if (event === 'newListener') f.input.on('readable', shared); + }); + await expect(f.lease.close()).rejects.toBeInstanceOf(CliError); + expect(f.input.listeners('readable')).toEqual([shared, shared]); + f.input.destroy(); + }); + + it('removes only the mount callbacks, preserving pre-existing captured-stream readers', async () => { + const f = fixture(); + const unrelated = vi.fn(); + const native = vi.fn(); + f.input.on('readable', unrelated); + f.lease.enter(); + f.input.on('readable', native); + f.input.setRawMode(true); + await f.lease.close(); + expect(f.input.listeners('readable')).toEqual([unrelated]); + expect(f.input.listenerCount('newListener')).toBe(0); + expect(f.input.isRaw).toBe(false); + expect(f.input.isPaused()).toBe(true); + expect(f.failure).not.toHaveBeenCalled(); + f.input.destroy(); + }); + + it('waits for actual final control delivery and publishes shared close before reentrant callbacks', async () => { + let deliver: () => void = () => undefined; + const delivery = new Promise((resolve) => { + deliver = resolve; + }); + const f = fixture(vi.fn(() => delivery)); + f.lease.enter(); + const native = vi.fn(); + f.input.on('readable', native); + let reentered: Promise | undefined; + f.input.on('removeListener', (event) => { + if (event === 'readable') reentered = f.lease.close(); + }); + const close = f.lease.close(); + let acknowledged = false; + void close.then(() => { + acknowledged = true; + }); + await turn(); + expect(reentered).toBe(close); + expect(f.lease.close()).toBe(close); + expect(f.acknowledge).toHaveBeenCalledOnce(); + expect(acknowledged, 'SESSION_INK_CONTROL_DELIVERY_ACK_REQUIRED').toBe(false); + deliver(); + await close; + f.input.destroy(); + }); + + it('independently removes sibling input callbacks and pauses input after a remover throws', async () => { + const f = fixture(); + const primary = new Error('PRIVATE_NATIVE_REMOVER'); + f.lease.enter(); + const first = vi.fn(); + const second = vi.fn(); + f.input.on('readable', first); + f.input.on('readable', second); + f.input.setRawMode(true); + f.input.on('removeListener', (event, listener: unknown) => { + if (event === 'readable' && listener === first) throw primary; + }); + await expect(f.lease.close()).rejects.toBe(primary); + expect(f.input.listenerCount('readable')).toBe(0); + expect(f.input.isRaw).toBe(false); + expect(f.input.isPaused()).toBe(true); + expect(f.acknowledge).toHaveBeenCalledOnce(); + f.input.destroy(); + }); + + it('withholds release if removal reentry leaves an input callback after the observer is detached', async () => { + const f = fixture(); + f.lease.enter(); + const late = vi.fn(); + f.input.on('removeListener', (event) => { + if (event === 'newListener') f.input.on('readable', late); + }); + await expect(f.lease.close()).rejects.toBeInstanceOf(CliError); + expect(f.input.listeners('readable')).toEqual([late]); + expect(f.failure).toHaveBeenCalled(); + f.input.destroy(); + }); + + it('does not start a final asynchronous control write after the original budget expired', async () => { + const f = fixture(); + f.lease.enter(); + f.input.on('readable', vi.fn()); + f.input.setRawMode(true); + f.invocation.finiteExit.fail('cli_internal'); + f.advance(3000); + let acknowledged = false; + void f.lease.close().then(() => { + acknowledged = true; + }); + await turn(); + expect(f.input.isRaw).toBe(false); + expect(f.input.listenerCount('readable')).toBe(0); + expect(f.acknowledge).not.toHaveBeenCalled(); + expect(acknowledged).toBe(false); + f.input.destroy(); + }); + + it('a final delivery refusal remains the fixed output disposition', async () => { + const primary = new CliError('output_unavailable', ''); + const f = fixture(vi.fn(() => Promise.reject(primary))); + f.lease.enter(); + await expect(f.lease.close()).rejects.toBe(primary); + expect(f.failure).toHaveBeenCalledWith(primary); + expect(f.input.listenerCount('newListener')).toBe(0); + f.input.destroy(); + }); +}); diff --git a/apps/cli/src/chat/session-ink-input.ts b/apps/cli/src/chat/session-ink-input.ts new file mode 100644 index 00000000..00e134ea --- /dev/null +++ b/apps/cli/src/chat/session-ink-input.ts @@ -0,0 +1,142 @@ +import type { CliInvocation } from '../process/invocation.js'; +import { CliError } from '../process/cli-error.js'; +import { SHOW_CURSOR } from '../render/alt-screen.js'; +import { DISABLE_BRACKETED_PASTE } from '../render/tui/home-input.js'; + +/** + * Exact captured input for one exclusive native mount. Ink 7.1 can reattach its readable + * callback after autonomous unmount; the last native release must therefore remain ours. + */ +export class SessionInkInput { + readonly #input: NodeJS.ReadStream; + readonly #invocation: CliInvocation; + readonly #acknowledge: (text: string) => Promise; + readonly #failure: (cause: unknown) => void; + readonly #listeners = new Set(); + #baseline = new Map(); + readonly #remove: NodeJS.ReadStream['removeListener']; + readonly #readable: () => readonly unknown[]; + #entered = false; + #finishEntering: () => void = () => undefined; + readonly #enteredReady = new Promise((resolve) => { + this.#finishEntering = resolve; + }); + #closing: Promise | undefined; + + constructor(options: { + readonly input: NodeJS.ReadStream; + readonly invocation: CliInvocation; + readonly acknowledge: (text: string) => Promise; + readonly failure: (cause: unknown) => void; + }) { + this.#input = options.input; + this.#invocation = options.invocation; + this.#acknowledge = options.acknowledge; + this.#failure = options.failure; + this.#remove = options.input.removeListener.bind(options.input); + this.#readable = () => options.input.listeners('readable'); + } + + readonly #added = (event: string | symbol, listener: unknown): void => { + if (event === 'readable' && typeof listener === 'function') this.#listeners.add(listener); + }; + + /** Enter before render, retain the exact callbacks introduced by this exclusive mount. */ + enter(): void { + if (this.#entered || this.#closing !== undefined) throw new CliError('cli_internal', ''); + this.#entered = true; + try { + for (const listener of this.#readable()) + this.#baseline.set(listener, (this.#baseline.get(listener) ?? 0) + 1); + this.#input.on('newListener', this.#added); + } finally { + this.#finishEntering(); + } + } + + #report(cause: unknown): void { + try { + this.#failure(cause); + } catch { + this.#invocation.finiteExit.fail('cli_internal'); + } + } + + /** Invoked only AFTER full entered native continuations and the last unmount attempt. */ + close(): Promise { + if (this.#closing !== undefined) return this.#closing; + let resolve: () => void = () => undefined; + let reject: (cause: unknown) => void = () => undefined; + this.#closing = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + void this.#close().then(resolve, reject); + return this.#closing; + } + + async #close(): Promise { + if (!this.#entered) return; + await this.#enteredReady; + let failed: { readonly cause: unknown } | undefined; + const attempt = (body: () => void): void => { + try { + body(); + } catch (cause) { + failed ??= { cause }; + this.#report(cause); + } + }; + // Keep the observer through native cleanup so reentrant additions are retained too. Two + // bounded passes tolerate a late native reattachment without an unbounded callback loop. + for (let pass = 0; pass < 2; pass++) { + for (const listener of [...this.#listeners]) { + const introduced = + this.#readable().filter((value) => value === listener).length - + (this.#baseline.get(listener) ?? 0); + for (let remaining = introduced; remaining > 0; remaining--) { + // A native removal hook may have consumed a sibling occurrence. Never spend + // the preserved baseline using a surplus measured before that reentry. + if ( + this.#readable().filter((value) => value === listener).length <= + (this.#baseline.get(listener) ?? 0) + ) + break; + attempt(() => { + Reflect.apply(this.#remove, this.#input, ['readable', listener]); + }); + } + } + attempt(() => { + this.#input.pause(); + }); + attempt(() => { + if (this.#input.isRaw) this.#input.setRawMode(false); + }); + attempt(() => { + this.#input.unref(); + }); + } + attempt(() => { + Reflect.apply(this.#remove, this.#input, ['newListener', this.#added]); + }); + attempt(() => { + const remaining = new Map(); + for (const listener of this.#readable()) + remaining.set(listener, (remaining.get(listener) ?? 0) + 1); + if ( + this.#input.isRaw || + [...remaining].some(([listener, count]) => count > (this.#baseline.get(listener) ?? 0)) + ) + throw new CliError('cli_internal', ''); + }); + if (this.#invocation.registry.budget.remainingMs() === 0) await new Promise(() => {}); + try { + await this.#acknowledge(DISABLE_BRACKETED_PASTE + SHOW_CURSOR); + } catch (cause) { + failed ??= { cause }; + this.#report(cause); + } + if (failed !== undefined) throw failed.cause; + } +} diff --git a/apps/cli/src/chat/session-ink-mount.test.ts b/apps/cli/src/chat/session-ink-mount.test.ts new file mode 100644 index 00000000..7300907b --- /dev/null +++ b/apps/cli/src/chat/session-ink-mount.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { invocationFixture } from '../process/invocation.test-support.js'; +import { createSuspendPort } from '../render/suspend.js'; +import { SessionInkMount } from './session-ink-mount.js'; +import { SessionInkInput } from './session-ink-input.js'; +import { OwnedTtyInput } from '../test-support.js'; + +function gate() { + let release: () => void = () => undefined; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} +const turn = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +function fixture( + overrides: { + readonly stopInput?: () => void; + readonly drainNative?: () => Promise; + } = {}, +) { + const f = invocationFixture(); + const failure = vi.fn(() => f.invocation.finiteExit.fail('cli_internal')); + const port = createSuspendPort(); + const mount = new SessionInkMount({ + invocation: f.invocation, + assertEntering: () => f.registry.assertAdmitting(), + failure, + suspendPort: port, + stopInput: overrides.stopInput ?? vi.fn(), + drainNative: overrides.drainNative ?? (() => Promise.resolve()), + }); + return { ...f, mount, port, failure }; +} + +describe('actual session Ink mount custody', () => { + it('refuses native render after input registration reenters termination', async () => { + const f = invocationFixture(); + const input = new OwnedTtyInput(); + const failure = () => f.invocation.finiteExit.fail('cli_internal'); + const mount = new SessionInkMount({ + invocation: f.invocation, + assertEntering: () => f.registry.assertAdmitting(), + failure, + suspendPort: createSuspendPort(), + stopInput: () => undefined, + drainNative: () => Promise.resolve(), + input: new SessionInkInput({ + input, + invocation: f.invocation, + failure, + acknowledge: () => Promise.resolve(), + }), + }); + const hook = (event: string | symbol): void => { + if (event === 'newListener') failure(); + }; + input.on('newListener', hook); + const create = vi.fn(() => ({ + unmount: () => undefined, + waitUntilExit: () => Promise.resolve(), + })); + expect(() => mount.acquire(create)).toThrow(); + await turn(); + expect(create, 'SESSION_INK_REGISTRATION_REENTRY_NO_NATIVE_ENTRY').not.toHaveBeenCalled(); + expect(input.listeners('newListener')).toEqual([hook]); + expect(f.registry.activeOwners).toBe(1); + input.destroy(); + }); + + it('releases captured input even when the native exit acknowledgement is still held', async () => { + const f = invocationFixture(); + const input = new OwnedTtyInput(); + const exit = gate(); + const mount = new SessionInkMount({ + invocation: f.invocation, + assertEntering: () => f.registry.assertAdmitting(), + failure: () => f.invocation.finiteExit.fail('cli_internal'), + suspendPort: createSuspendPort(), + stopInput: () => undefined, + drainNative: () => Promise.resolve(), + input: new SessionInkInput({ + input, + invocation: f.invocation, + acknowledge: () => Promise.resolve(), + failure: () => f.invocation.finiteExit.fail('cli_internal'), + }), + }); + mount.acquire(() => ({ unmount: () => undefined, waitUntilExit: () => exit.promise })); + input.setRawMode(true); + input.on('readable', vi.fn()); + const closing = mount.close(); + await turn(); + expect(input.isRaw, 'SESSION_INK_HELD_EXIT_CANNOT_DELAY_INPUT_RELEASE').toBe(false); + expect(input.listenerCount('readable')).toBe(0); + expect(f.registry.activeOwners).toBe(1); + exit.release(); + await closing; + await turn(); + expect(f.registry.activeOwners).toBe(0); + input.destroy(); + }); + + it('captures the native exit before unmount and waits for its actual acknowledgement', async () => { + const f = fixture(); + const exit = gate(); + let unmounted = false; + const unmount = vi.fn(() => { + unmounted = true; + }); + const waitUntilExit = vi.fn(() => { + expect(unmounted).toBe(false); + return exit.promise; + }); + f.mount.acquire(() => ({ unmount, waitUntilExit })); + const close = f.mount.close(); + expect(f.mount.close()).toBe(close); + await turn(); + expect(unmount).toHaveBeenCalledOnce(); + expect(waitUntilExit).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners, 'SESSION_INK_NATIVE_EXIT_ACK_REQUIRED').toBe(1); + exit.release(); + await close; + await turn(); + expect(f.registry.activeOwners).toBe(0); + }); + + it('finishes job control and full endSuspend before final unmount', async () => { + const endSuspend = gate(); + const resumeJob = gate(); + const trace: string[] = []; + const f = fixture({ + stopInput: () => trace.push('seal'), + drainNative: async () => { + trace.push('finish-job'); + resumeJob.release(); + await endSuspend.promise; + }, + }); + f.mount.acquire(() => ({ + unmount: () => trace.push('unmount'), + waitUntilExit: () => Promise.resolve(), + })); + f.mount.port.attach(async (body) => { + await body(); + trace.push('native-resume'); + await endSuspend.promise; + trace.push('native-redraw'); + }); + const raw = f.port.current()?.(() => resumeJob.promise); + const close = f.mount.close(); + await turn(); + expect(trace).toEqual(['seal', 'finish-job', 'native-resume']); + expect(f.port.current()).toBeUndefined(); + expect(f.registry.activeOwners).toBe(1); + endSuspend.release(); + await raw; + await close; + expect(trace).toEqual(['seal', 'finish-job', 'native-resume', 'native-redraw', 'unmount']); + }); + + it('retains constructor uncertainty and uses the original finite budget without forging no acquisition', async () => { + const f = fixture(); + const primary = new Error('PRIVATE_PARTIAL_INK'); + expect(() => + f.mount.acquire(() => { + throw primary; + }), + ).toThrow(primary); + let released = false; + void f.mount.close().then(() => { + released = true; + }); + await turn(); + expect(f.failure).toHaveBeenCalledWith(primary); + expect(f.registry.activeOwners).toBe(1); + f.advance(3000); + await turn(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(released).toBe(false); + }); + + it('seals already-published attachment authority when a partial native constructor throws', async () => { + const stopInput = vi.fn(); + const drainNative = vi.fn(() => Promise.resolve()); + const f = fixture({ stopInput, drainNative }); + const native = vi.fn(async (body: () => Promise) => body()); + const primary = new Error('PRIVATE_ATTACHED_PARTIAL_INK'); + expect(() => + f.mount.acquire(() => { + f.mount.port.attach(native); + throw primary; + }), + ).toThrow(primary); + await turn(); + expect(stopInput).toHaveBeenCalledOnce(); + expect(drainNative).toHaveBeenCalledOnce(); + expect(f.port.current(), 'SESSION_PARTIAL_INK_PREENTRY_SEAL').toBeUndefined(); + f.mount.port.attach(native); + expect(f.port.current()).toBeUndefined(); + expect(native).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + f.advance(3000); + await turn(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + }); + + it('still attempts attachment/job-control seals when input sealing throws', async () => { + const primary = new Error('PRIVATE_INPUT_SEAL'); + const finish = vi.fn(() => Promise.resolve()); + const f = fixture({ + stopInput: () => { + throw primary; + }, + drainNative: finish, + }); + const unmount = vi.fn(); + f.mount.acquire(() => ({ unmount, waitUntilExit: () => Promise.resolve() })); + f.mount.port.attach(async (body) => body()); + let released = false; + void f.mount.close().then(() => { + released = true; + }); + await turn(); + expect(f.port.current()).toBeUndefined(); + expect(finish).toHaveBeenCalledOnce(); + expect(unmount).toHaveBeenCalledOnce(); + expect(released).toBe(false); + expect(f.registry.activeOwners).toBe(1); + }); + + it('observes autonomous Ink failure before an independently held suspension can finish', async () => { + let rejectExit: (cause: unknown) => void = () => undefined; + const exit = new Promise((_resolve, reject) => { + rejectExit = reject; + }); + const f = fixture(); + const endSuspend = gate(); + const unmount = vi.fn(); + f.mount.acquire(() => ({ unmount, waitUntilExit: () => exit })); + f.mount.port.attach(async (body) => { + await body(); + await endSuspend.promise; + }); + const raw = f.port.current()?.(() => Promise.resolve()); + const primary = new Error('PRIVATE_INK_FAILURE'); + rejectExit(primary); + await turn(); + expect(f.failure).toHaveBeenCalledWith(primary); + expect(f.registry.budget.remainingMs()).toBe(3000); + expect(unmount).not.toHaveBeenCalled(); + endSuspend.release(); + await raw; + f.advance(3000); + await turn(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(f.registry.activeOwners).toBe(1); + }); +}); diff --git a/apps/cli/src/chat/session-ink-mount.ts b/apps/cli/src/chat/session-ink-mount.ts new file mode 100644 index 00000000..558ff261 --- /dev/null +++ b/apps/cli/src/chat/session-ink-mount.ts @@ -0,0 +1,145 @@ +import { CliError } from '../process/cli-error.js'; +import type { CliInvocation } from '../process/invocation.js'; +import type { SuspendAttachment, SuspendPort } from '../render/suspend.js'; +import { SessionTerminalOwner } from './session-terminal-owner.js'; +import type { SessionInkInput } from './session-ink-input.js'; + +/** The actual installed renderer's cleanup ports; acquisition never follows an ambient TTY fallback. */ +export interface SessionInkHandle { + readonly unmount: () => void; + readonly waitUntilExit: () => Promise; +} + +/** One actual Ink mount, including its complete suspension continuations and native exit acknowledgement. */ +export class SessionInkMount { + readonly #owner: SessionTerminalOwner; + readonly #attachment: SuspendAttachment; + readonly #invocation: CliInvocation; + readonly #failure: (cause: unknown) => void; + readonly #stopInput: () => void; + readonly #drainNative: () => Promise; + readonly #input: SessionInkInput | undefined; + readonly #assertEntering: () => void; + #sealed = false; + #exited: Promise | undefined; + #suspensions: Promise | undefined; + #nativeDrain: Promise | undefined; + + constructor(options: { + readonly invocation: CliInvocation; + readonly assertEntering: () => void; + readonly failure: (cause: unknown) => void; + readonly suspendPort: SuspendPort; + /** Synchronous input/dispatch seal, without unmounting Ink beneath an entered suspension. */ + readonly stopInput: () => void; + /** Finish already-entered job-control handoffs while Ink remains mounted. */ + readonly drainNative: () => Promise; + /** Present for native Ink; a handle-only embedding cannot assert captured native input release. */ + readonly input?: SessionInkInput; + }) { + this.#invocation = options.invocation; + this.#failure = options.failure; + this.#stopInput = options.stopInput; + this.#drainNative = options.drainNative; + this.#input = options.input; + this.#assertEntering = options.assertEntering; + this.#attachment = options.suspendPort.reserveAttachment(); + this.#owner = new SessionTerminalOwner( + options.invocation, + options.assertEntering, + this.#report, + ); + } + + get port(): SuspendPort { + return this.#attachment.port; + } + + get retired() { + return this.#owner.retired; + } + + /** Captured while the native handle is live, BEFORE unmount can change waitUntilExit's behaviour. */ + get exited(): Promise { + if (this.#exited === undefined) throw new CliError('cli_internal', ''); + return this.#exited; + } + + readonly #report = (cause: unknown): void => { + try { + this.#failure(cause); + } catch { + this.#invocation.finiteExit.fail('cli_internal'); + } + }; + + acquire(create: () => T): T { + return this.#owner.acquire((creation) => { + const native: { unmount: (() => void) | undefined } = { unmount: undefined }; + // Known seals exist BEFORE native entry, even when a partial render never returns a handle. + // Adopting these ports is not a native release fact: release still requires that exact handle/exit. + creation.adopt({ + seal: () => { + this.#sealed = true; + // Attempt independent seals even if an input adapter throws. No callback may leave + // attachment authority live merely because a preceding native cleanup failed. + try { + this.#stopInput(); + } finally { + this.#suspensions = this.#attachment.sealAndJoin(); + try { + this.#nativeDrain = this.#drainNative(); + } catch (cause) { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Preserve the exact native rejection; even undefined is an observed failure, never release acknowledgement. + this.#nativeDrain = Promise.reject(cause); + } + void this.#nativeDrain.catch(this.#report); + } + }, + release: async () => { + // finish() releases an entered SIGCONT waiter; both full native continuations must + // actually settle before Ink is unmounted, since Ink resumes input in endSuspend(). + let failed: { readonly cause: unknown } | undefined; + const drained = await Promise.allSettled([this.#suspensions, this.#nativeDrain]); + for (const result of drained) { + if (result.status === 'rejected') failed ??= { cause: result.reason }; + } + if (this.#invocation.registry.budget.remainingMs() === 0) + await new Promise(() => {}); + try { + if (native.unmount === undefined) throw new CliError('cli_internal', ''); + native.unmount(); + } catch (cause) { + failed ??= { cause }; + this.#report(cause); + } + // Autonomous Ink exit already unmounted its tree; a later endSuspend can still + // reattach raw input. Repeating unmount alone is a no-op in that native state. + // Start finite input restoration independently of a held native exit/output ACK. + const inputClosed = this.#input?.close(); + const released = await Promise.allSettled([ + this.#exited ?? Promise.reject(new CliError('cli_internal', '')), + inputClosed, + ]); + for (const result of released) { + if (result.status === 'rejected') { + failed ??= { cause: result.reason }; + this.#report(result.reason); + } + } + if (failed !== undefined) throw failed.cause; + }, + }); + this.#input?.enter(); + this.#assertEntering(); + if (this.#sealed) throw new CliError('cli_internal', ''); + const handle = create(); + native.unmount = handle.unmount.bind(handle); + this.#exited = handle.waitUntilExit(); + void this.#exited.catch(this.#report); + return handle; + }); + } + + readonly close = (): Promise => this.#owner.close(); +} diff --git a/apps/cli/src/chat/session-ink-native.test.ts b/apps/cli/src/chat/session-ink-native.test.ts new file mode 100644 index 00000000..84ddbf98 --- /dev/null +++ b/apps/cli/src/chat/session-ink-native.test.ts @@ -0,0 +1,263 @@ +import { createElement, useLayoutEffect } from 'react'; +import { render, Text, useApp, useInput } from 'ink'; +import { describe, expect, it, vi } from 'vitest'; + +import { invocationFixture } from '../process/invocation.test-support.js'; +import { createSuspendPort, type SuspendPort } from '../render/suspend.js'; +import { OwnedTtyInput, OwnedTtyOutput } from '../test-support.js'; +import { SessionInkMount } from './session-ink-mount.js'; +import { SessionInkInput } from './session-ink-input.js'; +import { ChatApp } from '../render/tui/chat-ink.js'; +import { createChatStore } from '../render/tui/chat-store.js'; +import { INLINE_TRANSCRIPT_BOUND } from '../render/tui/session-view-model.js'; + +function Mounted({ + port, + exitReady, +}: { + readonly port: SuspendPort; + readonly exitReady?: (exit: (error: Error) => void) => void; +}) { + const app = useApp(); + useInput(() => undefined); + useLayoutEffect(() => { + port.attach((body) => app.suspendTerminal(body)); + exitReady?.((error) => app.exit(error)); + return () => port.attach(undefined); + }, [app, port, exitReady]); + return createElement(Text, undefined, 'Synthetic owned session'); +} + +describe('installed Ink session suspension and final native release', () => { + it('seals mounted ChatApp input while native teardown remains pending', async () => { + const f = invocationFixture(); + const input = new OwnedTtyInput(); + const output = new OwnedTtyOutput(); + const stderr = new OwnedTtyOutput(); + const store = createChatStore(false, undefined, INLINE_TRANSCRIPT_BOUND); + let enabled = true; + let drain: () => void = () => undefined; + const drained = new Promise((resolve) => { + drain = resolve; + }); + const suspend = vi.fn(); + const submit = vi.fn(() => Promise.resolve()); + const mode = vi.fn(); + const failure = vi.fn(() => f.invocation.finiteExit.fail('cli_internal')); + const mount = new SessionInkMount({ + invocation: f.invocation, + assertEntering: () => f.registry.assertAdmitting(), + failure, + suspendPort: createSuspendPort(), + stopInput: () => { + enabled = false; + }, + drainNative: () => drained, + input: new SessionInkInput({ + input, + invocation: f.invocation, + failure, + acknowledge: () => Promise.resolve(), + }), + }); + const instance = mount.acquire(() => + render( + createElement(ChatApp, { + store, + inputEnabled: () => enabled, + onSubmit: submit, + onModeChange: mode, + onSuspend: suspend, + onExit: () => undefined, + onError: failure, + shouldStop: () => false, + suspendPort: mount.port, + }), + { + stdin: input, + stdout: output, + stderr, + interactive: true, + exitOnCtrlC: false, + patchConsole: false, + }, + ), + ); + try { + await instance.waitUntilRenderFlush(); + input.push('\x1a'); + await new Promise((resolve) => setImmediate(resolve)); + expect(suspend).toHaveBeenCalledOnce(); // Positive proof that the installed input route is live. + const closing = mount.close(); + await new Promise((resolve) => setImmediate(resolve)); + expect(input.isRaw).toBe(true); // Native teardown deliberately remains held with the tree mounted. + input.push('\x1a'); + input.push('\x1b[Z'); + input.push('\x1b[200~SEALED_PASTE\x1b[201~'); + input.push('SEALED_SUBMIT\r'); + await new Promise((resolve) => setImmediate(resolve)); + await instance.waitUntilRenderFlush(); + expect(suspend, 'SESSION_CHAT_MOUNTED_INPUT_SEAL').toHaveBeenCalledOnce(); + expect(mode).not.toHaveBeenCalled(); + expect(submit).not.toHaveBeenCalled(); + expect(output.frames.join('')).not.toContain('SEALED_PASTE'); + drain(); + await closing; + expect(input.isRaw).toBe(false); + expect(f.registry.activeOwners).toBe(0); + } finally { + drain(); + instance.unmount(); + input.destroy(); + output.destroy(); + stderr.destroy(); + } + }); + it('restores captured input after installed Ink exits autonomously during a suspension', async () => { + const f = invocationFixture(); + const input = new OwnedTtyInput(); + const output = new OwnedTtyOutput(); + const stderr = new OwnedTtyOutput(); + const parent = createSuspendPort(); + let exit: (error: Error) => void = () => undefined; + let finishBody: () => void = () => undefined; + const body = new Promise((resolve) => { + finishBody = resolve; + }); + const failure = vi.fn(() => f.invocation.finiteExit.fail('cli_internal')); + const mount = new SessionInkMount({ + invocation: f.invocation, + assertEntering: () => f.registry.assertAdmitting(), + failure, + suspendPort: parent, + stopInput: () => undefined, + drainNative: () => Promise.resolve(), + input: new SessionInkInput({ + input, + invocation: f.invocation, + failure, + acknowledge: (text) => + new Promise((resolve, reject) => { + output.write(text, (cause) => + cause === null || cause === undefined ? resolve() : reject(cause), + ); + }), + }), + }); + const instance = mount.acquire(() => + render( + createElement(Mounted, { + port: mount.port, + exitReady: (callback) => { + exit = callback; + }, + }), + { + stdin: input, + stdout: output, + stderr, + interactive: true, + exitOnCtrlC: false, + patchConsole: false, + alternateScreen: false, + }, + ), + ); + try { + await instance.waitUntilRenderFlush(); + const suspended = parent.current()?.(() => body); + await new Promise((resolve) => setImmediate(resolve)); + expect(input.isRaw).toBe(false); + const primary = new Error('PRIVATE_INSTALLED_INK_EXIT'); + exit(primary); + await new Promise((resolve) => setImmediate(resolve)); + expect(failure).toHaveBeenCalledWith(primary); + finishBody(); + await suspended; + await new Promise((resolve) => setImmediate(resolve)); + expect(input.isRaw, 'SESSION_INSTALLED_INK_AUTONOMOUS_EXIT_FINAL_RELEASE').toBe(false); + expect(input.listenerCount('readable')).toBe(0); + expect(f.registry.activeOwners).toBe(1); // Rejection itself is not a positive native exit ACK. + } finally { + finishBody(); + instance.unmount(); + input.destroy(); + output.destroy(); + stderr.destroy(); + } + }); + + it('joins installed endSuspend before unmount restores synthetic input for the last time', async () => { + const f = invocationFixture(); + const input = new OwnedTtyInput(); + const output = new OwnedTtyOutput(); + const stderr = new OwnedTtyOutput(); + const raw = vi.spyOn(input, 'setRawMode'); + const parent = createSuspendPort(); + let finishBody: () => void = () => undefined; + const body = new Promise((resolve) => { + finishBody = resolve; + }); + const failure = vi.fn(() => f.invocation.finiteExit.fail('cli_internal')); + const mount = new SessionInkMount({ + invocation: f.invocation, + assertEntering: () => f.registry.assertAdmitting(), + failure, + suspendPort: parent, + stopInput: () => undefined, + drainNative: () => { + finishBody(); + return Promise.resolve(); + }, + input: new SessionInkInput({ + input, + invocation: f.invocation, + failure, + acknowledge: (text) => + new Promise((resolve, reject) => { + output.write(text, (cause) => + cause === null || cause === undefined ? resolve() : reject(cause), + ); + }), + }), + }); + const instance = mount.acquire(() => + render(createElement(Mounted, { port: mount.port }), { + stdin: input, + stdout: output, + stderr, + interactive: true, + exitOnCtrlC: false, + patchConsole: false, + alternateScreen: false, + }), + ); + try { + await instance.waitUntilRenderFlush(); + expect(input.isRaw).toBe(true); + const suspend = parent.current(); + expect(suspend).toBeDefined(); + const suspended = suspend?.(() => body); + await new Promise((resolve) => setImmediate(resolve)); + expect(input.isRaw).toBe(false); + await mount.close(); + await suspended; + await mount.exited; + expect( + raw.mock.calls.map(([enabled]) => enabled), + 'SESSION_INSTALLED_INK_FINAL_RAW_RELEASE', + ).toEqual([true, false, true, false]); + expect(input.isRaw).toBe(false); + expect(input.listenerCount('readable')).toBe(0); + expect(parent.current()).toBeUndefined(); + expect(f.registry.activeOwners).toBe(0); + expect(failure).not.toHaveBeenCalled(); + expect(f.exit).not.toHaveBeenCalled(); + } finally { + instance.unmount(); + input.destroy(); + output.destroy(); + stderr.destroy(); + } + }); +}); diff --git a/apps/cli/src/chat/session-prompt.test.ts b/apps/cli/src/chat/session-prompt.test.ts index 0f3c0a1c..4c8cee26 100644 --- a/apps/cli/src/chat/session-prompt.test.ts +++ b/apps/cli/src/chat/session-prompt.test.ts @@ -1,13 +1,60 @@ import { PassThrough } from 'node:stream'; import { confirm } from '@clack/prompts'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { invocationFixture } from '../process/invocation.test-support.js'; import { captureIo } from '../test-support.js'; import { runSessionPrompt } from './session-prompt.js'; describe('installed Clack session prompt release', () => { + it('a stopped host aborts the installed pending prompt and joins its actual input release', async () => { + const f = invocationFixture(); + const input = new PassThrough(); + const output = new PassThrough(); + const parent = new AbortController(); + const answer = runSessionPrompt( + f.invocation, + captureIo().io, + () => f.registry.assertAdmitting(), + () => f.invocation.finiteExit.fail('cli_internal'), + (signal) => confirm({ message: 'Synthetic host consent', input, output, signal }), + parent.signal, + ); + try { + expect(input.listenerCount('keypress')).toBeGreaterThan(0); + parent.abort(); + expect(typeof (await answer)).toBe('symbol'); + expect(input.listenerCount('keypress'), 'SESSION_PROMPT_PARENT_ABORT_RELEASE').toBe(0); + expect(output.listenerCount('resize')).toBe(0); + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); + } finally { + input.destroy(); + output.destroy(); + } + }); + + it('does not enter the native prompt after its host was already stopped', async () => { + const f = invocationFixture(); + const parent = new AbortController(); + const reason = new Error('synthetic host stop'); + parent.abort(reason); + const native = vi.fn(() => Promise.resolve(true)); + await expect( + runSessionPrompt( + f.invocation, + captureIo().io, + () => parent.signal.throwIfAborted(), + () => undefined, + native, + parent.signal, + ), + ).rejects.toBe(reason); + expect(native).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(0); + }); + it('does not close a submitted installed prompt again when terminal custody finishes', async () => { const f = invocationFixture(); const input = new PassThrough(); @@ -15,6 +62,7 @@ describe('installed Clack session prompt release', () => { const chunks: string[] = []; output.on('data', (chunk: Buffer) => chunks.push(chunk.toString('utf8'))); const captured = captureIo(); + const parent = new AbortController(); const answer = runSessionPrompt( f.invocation, { @@ -37,11 +85,13 @@ describe('installed Clack session prompt release', () => { output, signal, }), + parent.signal, ); try { expect(input.listenerCount('keypress')).toBeGreaterThan(0); input.emit('keypress', '', { name: 'return' }); expect(await answer).toBe(true); + parent.abort(); // A later host shutdown must not abort an already-released installed prompt again. expect( chunks.filter((chunk) => chunk === '\n'), 'SESSION_INSTALLED_CLACK_SINGLE_CLOSE', diff --git a/apps/cli/src/chat/session-prompt.ts b/apps/cli/src/chat/session-prompt.ts index 13cb1b7a..9c76a3c4 100644 --- a/apps/cli/src/chat/session-prompt.ts +++ b/apps/cli/src/chat/session-prompt.ts @@ -10,11 +10,16 @@ export async function runSessionPrompt( assertEntering: () => void, failure: (cause: unknown) => void, factory: (signal: AbortSignal) => Promise, + parent?: AbortSignal, ): Promise { const cancel = new AbortController(); const owner = new SessionTerminalOwner(invocation, assertEntering, failure); let raw: Promise | undefined; let fulfilled = false; + let promptEntered = false; + const parentAborted = (): void => { + if (!fulfilled) cancel.abort(parent?.reason); + }; try { owner.acquire((acquisition) => { acquisition.adopt({ @@ -24,6 +29,8 @@ export async function runSessionPrompt( if (!fulfilled) cancel.abort(); }, release: async () => { + parent?.removeEventListener('abort', parentAborted); + if (!promptEntered) return; // Trusted fact: the native prompt factory was never entered. if (raw === undefined) throw new Error('Prompt acquisition was not acknowledged.'); await raw; if (io.stdoutIsTty) { @@ -32,7 +39,13 @@ export async function runSessionPrompt( } }, }); - raw = owner.enter(() => factory(cancel.signal)); + parent?.addEventListener('abort', parentAborted, { once: true }); + if (parent?.aborted === true) parentAborted(); + raw = owner.enter(() => { + if (cancel.signal.aborted) cancel.signal.throwIfAborted(); + promptEntered = true; + return factory(cancel.signal); + }); }); if (raw === undefined) throw new Error('Prompt acquisition was not acknowledged.'); const answer = await raw; diff --git a/apps/cli/src/chat/session-surface-lifetime.ts b/apps/cli/src/chat/session-surface-lifetime.ts index 4938aad1..211dfadd 100644 --- a/apps/cli/src/chat/session-surface-lifetime.ts +++ b/apps/cli/src/chat/session-surface-lifetime.ts @@ -18,13 +18,16 @@ export class SessionSurfaceLifetime { invocation: CliInvocation | undefined, surface: SessionOwner['surface'], subscribeSignals = defaultSubscribeSignals, + cleanup?: () => Promise, ) { this.invocation = invocation ?? createCliInvocation(); this.work = new SessionWorkOwner(this.invocation, surface, { cancel: () => undefined, - cleanup: () => { + cleanup: async () => { + // Signal custody must survive a failed database release. A later signal still + // belongs to the original finite authority while cleanup remains uncertain. + await cleanup?.(); this.#removeSignals(); - return Promise.resolve(); }, }); try { diff --git a/apps/cli/src/chat/session-terminal-owner.ts b/apps/cli/src/chat/session-terminal-owner.ts index 56a9c512..423d0c3b 100644 --- a/apps/cli/src/chat/session-terminal-owner.ts +++ b/apps/cli/src/chat/session-terminal-owner.ts @@ -60,6 +60,10 @@ export class SessionTerminalOwner { if (this.#sealed) throw new CliError('cli_internal', ''); } + get retired(): Promise { + return this.#retired; + } + #acquisitionAcknowledged(): boolean { return this.#acquisition === 'acknowledged'; } diff --git a/apps/cli/src/chat/session-view-lifetime.test.ts b/apps/cli/src/chat/session-view-lifetime.test.ts new file mode 100644 index 00000000..7fd90f05 --- /dev/null +++ b/apps/cli/src/chat/session-view-lifetime.test.ts @@ -0,0 +1,100 @@ +import { EventEmitter } from 'node:events'; +import { describe, expect, it, vi } from 'vitest'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { SessionWorkOwner } from './session-work-owner.js'; +import { SessionViewLifetime } from './session-view-lifetime.js'; + +function fixture() { + const f = invocationFixture(); + const cleanup = vi.fn(() => Promise.resolve()); + const owner = new SessionWorkOwner(f.invocation, 'home', { cancel: () => undefined, cleanup }); + return { ...f, owner, cleanup, view: new SessionViewLifetime(owner) }; +} +const turn = () => new Promise((resolve) => setImmediate(resolve)); + +describe('actual Home view retirement', () => { + it('refuses construction after stop without transferring an unreachable empty lifetime', async () => { + const f = fixture(); + f.owner.stop(); + expect(() => new SessionViewLifetime(f.owner)).toThrow('Session operation stopped.'); + await f.owner.close(); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.exitCode()).toBe(0); + }); + it('releases each native view before the host database/persister tail can enter', async () => { + const f = fixture(); + const source = new EventEmitter(); + const callback = vi.fn(); + f.view.acquire(() => { + source.on('frame', callback); + return () => { + source.off('frame', callback); + }; + }); + expect(source.listenerCount('frame')).toBe(1); + await f.owner.close(); + expect(source.listenerCount('frame')).toBe(0); + expect(f.cleanup).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + }); + + it('joins registration reentry and removes a callback installed after stop was requested', async () => { + const f = fixture(); + const source = new EventEmitter(); + const callback = vi.fn(); + source.once('newListener', () => f.owner.stop()); + expect(() => + f.view.acquire(() => { + source.on('frame', callback); + return () => { + source.off('frame', callback); + }; + }), + ).toThrow('Session operation stopped.'); + await f.owner.close(); + expect(source.listenerCount('frame'), 'HOME_VIEW_REGISTRATION_REENTRY_JOIN').toBe(0); + expect(f.registry.observations.exitCode()).toBe(0); + }); + + it('attempts sibling releases while a failed removal keeps the original owner unretired', async () => { + const f = fixture(); + const cause = new Error('PRIVATE_VIEW_RELEASE'); + const sibling = vi.fn(); + f.view.acquire(() => () => { + throw cause; + }); + f.view.acquire(() => sibling); + let retired = false; + void f.owner.close().then(() => { + retired = true; + }); + await turn(); + expect(sibling).toHaveBeenCalledOnce(); + expect(retired).toBe(false); + expect(f.cleanup).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + f.advance(3000); + await turn(); + expect(f.exit).toHaveBeenCalledWith(9); + }); + + it('never treats a throwing subscription factory as proof of no allocation', async () => { + const f = fixture(); + const source = new EventEmitter(); + expect(() => + f.view.acquire(() => { + source.on('frame', vi.fn()); + throw new Error('PRIVATE_PARTIAL_SUBSCRIBE'); + }), + ).toThrow('PRIVATE_PARTIAL_SUBSCRIBE'); + let released = false; + void f.view.done.then(() => { + released = true; + }); + await turn(); + expect(released).toBe(false); + expect(source.listenerCount('frame')).toBe(1); + expect(f.registry.observations.exitCode()).toBe(9); + source.removeAllListeners(); + }); +}); diff --git a/apps/cli/src/chat/session-view-lifetime.ts b/apps/cli/src/chat/session-view-lifetime.ts new file mode 100644 index 00000000..f82c9998 --- /dev/null +++ b/apps/cli/src/chat/session-view-lifetime.ts @@ -0,0 +1,68 @@ +import { CliError } from '../process/cli-error.js'; +import type { SessionWorkOwner } from './session-work-owner.js'; + +/** A chat view's actual subscription/timer releases, retained independently of the selected UI pointer. */ +export class SessionViewLifetime { + readonly #owner: SessionWorkOwner; + readonly #removers: Array<() => void> = []; + readonly done: Promise; + #complete: () => void = () => undefined; + #constructing = 0; + #sealed = false; + #uncertain = false; + #released = false; + + constructor(owner: SessionWorkOwner, releaseOnStop = true) { + owner.assertAdmitting(); // A refused, unreturned view must not transfer an unreachable empty lifetime. + this.#owner = owner; + this.done = new Promise((resolve) => { + this.#complete = resolve; + }); + void owner.work.retainWork(() => this.done); + if (releaseOnStop) { + this.#removers.push(() => owner.signal.removeEventListener('abort', this.release)); + this.acquire(() => { + owner.signal.addEventListener('abort', this.release, { once: true }); + return () => undefined; + }); + if (owner.signal.aborted) this.release(); + } + } + + /** Publish construction custody before entering registration; a throw never proves no acquisition. */ + acquire(factory: () => () => void): void { + this.#owner.assertAdmitting(); + if (this.#sealed) throw new CliError('cli_internal', ''); + this.#constructing += 1; + try { + this.#removers.push(factory()); + } catch (cause) { + this.#uncertain = true; + this.#owner.cleanupUncertain(cause); + throw cause; + } finally { + this.#constructing -= 1; + if (this.#sealed) this.#finish(); + } + this.#owner.assertAdmitting(); + } + + readonly release = (): void => { + this.#sealed = true; + this.#finish(); + }; + + #finish(): void { + if (this.#constructing !== 0 || this.#released) return; + this.#released = true; // Removers may reenter release(). + for (const remove of this.#removers) { + try { + remove(); + } catch (cause) { + this.#uncertain = true; + this.#owner.cleanupUncertain(cause); + } + } + if (!this.#uncertain) this.#complete(); + } +} diff --git a/apps/cli/src/chat/session-work-owner.ts b/apps/cli/src/chat/session-work-owner.ts index 3d1afa7a..511f0e16 100644 --- a/apps/cli/src/chat/session-work-owner.ts +++ b/apps/cli/src/chat/session-work-owner.ts @@ -69,6 +69,11 @@ export class SessionWorkOwner { return this.#firstFailure; } + /** Positive actual cleanup fact, shared with the invocation registry; expiry/rejection never resolves it. */ + get retired(): Promise { + return this.#retired; + } + readonly isStoppedFailure = (cause: unknown): boolean => cause === this.#stoppedMarker; readonly assertAdmitting = (): void => { @@ -108,6 +113,7 @@ export class SessionWorkOwner { /** A callback-reported close fault is retained even when the manager's close promise fulfils. */ readonly cleanupUncertain = (cause: unknown): void => { + this.failure(cause); // Keep an observed output disposition before any cleanup join can hold. this.#cleanupUncertain = true; this.#firstFailure ??= { cause }; this.invocation.finiteExit.fail('cli_internal'); diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index 8276b8e4..0c3f4c81 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -1,4 +1,5 @@ import { createCliInvocation } from '../process/invocation.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; import { witnessNativeToolProcess } from './native-forwarding.test-support.js'; import { appendGrant, fingerprint, resolveStdioSpawn } from '../engine/mcp-consent.js'; import { mcpConsentPath } from '../engine/mcp-consent-gate.js'; @@ -279,6 +280,135 @@ describe('chatCommand', () => { expect(full?.messages[1]?.content[0]).toEqual({ type: 'text', text: 'hi there' }); }); + it('keeps no-Ink job-control custody while the old host cleanup remains pending', async () => { + const f = invocationFixture(); + const { d, out } = deps([], []); + let release: () => void = () => undefined; + const held = new Promise((resolve) => { + release = resolve; + }); + let suspend: (() => void) | undefined; + let continuation: (() => void) | undefined; + const selfStop = vi.fn(() => continuation?.()); + const done = chatCommand( + { agent: undefined }, + { + ...d, + ...INERT_HOIST, + invocation: f.invocation, + io: { ...d.io, stdoutIsTty: true }, + jobControlLifecycle: { + supported: true, + onSuspend: (callback) => { + suspend = callback; + return () => { + if (suspend === callback) suspend = undefined; + }; + }, + onContinue: (callback) => { + continuation = callback; + return () => { + if (continuation === callback) continuation = undefined; + }; + }, + suspendSelf: selfStop, + }, + buildSession: async (options) => ({ + ...(await buildChatSession(options)), + whenHostSafeToClose: () => held, + }), + drive: async (ctx) => { + ctx.startSession(); + await ctx.processLine('/exit'); + return { kind: 'exit' }; + }, + }, + ); + try { + await vi.waitFor(() => expect(f.registry.activeOwners).toBe(3)); + await new Promise((resolve) => setImmediate(resolve)); + expect(suspend, 'CHAT_JOB_CUSTODY_DURING_OLD_HOST_JOIN').toBeTypeOf('function'); + suspend?.(); + await vi.waitFor(() => expect(selfStop).toHaveBeenCalledOnce()); + expect(out()).toContain(EXIT_ALT_SCREEN); + expect(out().split(ENTER_ALT_SCREEN)).toHaveLength(3); // Initial enter and reversible gap reclaim. + } finally { + release(); + } + expect(await done).toBe(EXIT_CODES.chatEnded); + expect(f.registry.activeOwners).toBe(0); + }); + + it('owns a real SQLite chat turn through host retirement before the shared DB tail', async () => { + const f = invocationFixture(); + const { d, store, sessionId } = deps(['hello', '/exit'], [textTurn('owned reply')]); + const close = vi.fn(() => { + expect(f.registry.activeOwners, 'CHAT_NATIVE_HOST_RETIRES_BEFORE_SHARED_DB').toBe(1); + }); + const result = await chatCommand( + { agent: undefined }, + { + ...d, + ...INERT_HOIST, + invocation: f.invocation, + openSessionStore: () => ({ store, db: client.db, close }), + buildSession: (options) => { + expect(options.workOwner).toBeDefined(); + expect(options.workOwner?.invocation).toBe(f.invocation); + return buildChatSession(options); + }, + drive: async (ctx) => { + expect(ctx.workOwner?.invocation).toBe(f.invocation); + expect(ctx.subscribeInterrupt).toBeDefined(); + return linesDriver(['hello', '/exit'])(ctx); + }, + }, + ); + expect(result).toBe(4); + expect(store.loadFull(sessionId)?.messages.map((message) => message.role)).toEqual([ + 'user', + 'assistant', + ]); + expect(close).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); + }); + + it('observes an escaping driver fault before held actual-host cleanup and keeps the shared DB open', async () => { + const f = invocationFixture(); + const { d, store } = deps([], []); + const primary = new Error('PRIVATE_OWNED_DRIVER_FAULT'); + let release: () => void = () => undefined; + const safe = new Promise((resolve) => { + release = resolve; + }); + const close = vi.fn(); + const run = chatCommand( + { agent: undefined }, + { + ...d, + ...INERT_HOIST, + invocation: f.invocation, + openSessionStore: () => ({ store, db: client.db, close }), + buildSession: async (options) => ({ + ...(await buildChatSession(options)), + whenHostSafeToClose: () => safe, + }), + drive: () => { + throw primary; + }, + }, + ); + const refused = expect(run).rejects.toBe(primary); + await vi.waitFor(() => expect(f.registry.budget.deadline()).toBe(3000)); + expect(close, 'CHAT_DRIVER_FAULT_PRECEDES_HELD_HOST_JOIN').not.toHaveBeenCalled(); + expect(f.registry.observations.exitCode()).toBe(9); + release(); + await refused; + expect(close).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + }); + it('routes chat SIGTSTP/SIGCONT through reversible job control, never the termination lifecycle (G0)', async () => { const { d } = deps([], [textTurn('unused')]); const writes: string[] = []; @@ -1877,7 +2007,7 @@ describe('chatCommand', () => { } it('binds the default standalone editor to the original native registry', async () => { - const invocation = createCliInvocation(); + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); const marker = join(cwd, 'editor-started'); const release = join(cwd, 'editor-release'); const script = fileURLToPath(new URL('../render/fixtures/native-editor.mjs', import.meta.url)); @@ -1891,7 +2021,7 @@ describe('chatCommand', () => { try { await vi.waitFor( () => { - expect(invocation.registry.activeOwners, 'NATIVE_STANDALONE_EDITOR_FORWARDING').toBe(1); + expect(invocation.registry.activeOwners, 'NATIVE_STANDALONE_EDITOR_FORWARDING').toBe(4); expect(readFileSync(marker, 'utf8')).toBe('ready\n'); }, { interval: 5, timeout: 3000 }, @@ -1901,7 +2031,7 @@ describe('chatCommand', () => { await editing; ctx.suspendPort.attach(undefined); } - await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(0)); + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(3)); expect(readFileSync(marker, 'utf8')).toBe('ready\nreleased\n'); await ctx.processLine('/exit'); return { kind: ctx.stopReason() }; @@ -1948,7 +2078,7 @@ describe('chatCommand', () => { grantedAt: '2026-10-16T00:00:00.000Z', }); const { d, err, store } = deps([], []); - const invocation = createCliInvocation(); + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); let starts = 0; let nativeCreations = 0; const connect: NonNullable[0]['startMcpClient']> = ( @@ -1992,12 +2122,22 @@ describe('chatCommand', () => { drive, // Keep the production consent gate untouched. Fake only the post-consent connection. buildSession: async (options) => { - await witnessNativeToolProcess(options.nativeProcesses, invocation, cwd); + await witnessNativeToolProcess( + options.nativeProcesses, + invocation, + cwd, + drives === 0 ? 2 : 3, + ); nativeCreations++; return buildChatSession({ ...options, startMcpClient: connect }); }, buildResumedSession: async (options) => { - await witnessNativeToolProcess(options.nativeProcesses, invocation, cwd); + await witnessNativeToolProcess( + options.nativeProcesses, + invocation, + cwd, + drives === 0 ? 2 : 3, + ); nativeCreations++; return buildResumedChatSession({ ...options, startMcpClient: connect }); }, @@ -2461,7 +2601,7 @@ describe('chatResumeCommand (2.N)', () => { it('/clear from a resumed session rebinds the SNAPSHOT agent into a fresh session (ADR-0062 §7)', async () => { writeOutputEstimate(cwd); - const invocation = createCliInvocation(); + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); const resumedEstimates: (number | undefined)[] = []; const freshEstimates: (number | undefined)[] = []; const store = createSessionStore(client.db); @@ -2489,7 +2629,7 @@ describe('chatResumeCommand (2.N)', () => { invocation, drive: clearThenExit, buildSession: async (options) => { - await witnessNativeToolProcess(options.nativeProcesses, invocation, options.cwd); + await witnessNativeToolProcess(options.nativeProcesses, invocation, options.cwd, 2); freshEstimates.push(options.maxTokensEstimate); return buildChatSession(options); }, @@ -2498,6 +2638,7 @@ describe('chatResumeCommand (2.N)', () => { options.nativeProcesses, invocation, options.record.context.workingDir, + 2, ); resumedEstimates.push(options.maxTokensEstimate); return buildResumedChatSession(options); @@ -2519,7 +2660,7 @@ describe('chatResumeCommand (2.N)', () => { it('preserves the configured estimate through a resumed-session model reseat', async () => { writeOutputEstimate(cwd); - const invocation = createCliInvocation(); + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); const store = createSessionStore(client.db); await chatCommand({ agent: undefined }, freshDeps(['hello', '/exit'], [textTurn('hi')], store)); seedCatalogModel(client.db, 'anthropic', 'claude-sonnet-4-6'); @@ -2551,6 +2692,7 @@ describe('chatResumeCommand (2.N)', () => { options.nativeProcesses, invocation, options.record.context.workingDir, + estimates.length === 0 ? 2 : 3, ); estimates.push(options.maxTokensEstimate); return buildResumedChatSession(options); diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 7a821778..72681b0a 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -1,4 +1,9 @@ +import { SessionChatTerminal } from '../chat/session-chat-terminal.js'; +import type { SessionInkMount } from '../chat/session-ink-mount.js'; import type { CliInvocation } from '../process/invocation.js'; +import type { SessionWorkOwner } from '../chat/session-work-owner.js'; +import type { SessionHostOwner } from '../chat/session-host-owner.js'; +import { SessionChatLifetime } from '../chat/session-chat-lifetime.js'; import { nativeProcessesForInvocation, type NativeProcessController, @@ -25,6 +30,7 @@ import { import { exportSession, SESSION_EXPORT_REVIEW_NOTICE } from '../chat/export.js'; import { createConsentGate } from '../engine/mcp-consent-gate.js'; import { createConsentPrompter } from '../mcp/consent-prompt.js'; +import { createOwnedSessionConsentGate } from '../chat/session-consent.js'; import { formatDoctorReport, runDoctorChecks, type DoctorProbes } from '../chat/doctor.js'; import { assembleDoctorProbes } from '../chat/doctor-host.js'; import { @@ -86,7 +92,7 @@ import { loadUserPricingOverlay, readUserPricingOverlay } from '../engine/pricin import { mcpSkippedLines, surfaceMcpSkipped } from '../engine/mcp-servers.js'; import { createProviderResolver, type ProviderResolver } from '../engine/providers.js'; import { openSessionStore, type OpenedSessionStore } from '../history/session-open.js'; -import { CliError } from '../process/errors.js'; +import { CliError, toUserFacing } from '../process/errors.js'; import type { CliIo } from '../process/io.js'; import { closeQuietly, warnTeardown } from '../process/close-quietly.js'; import type { GlobalOptions } from '../process/options.js'; @@ -195,12 +201,28 @@ function buildChatModelsPort( boundModel: string, now: () => number, uuid: () => string, + workOwner?: SessionWorkOwner, ): ChatModelsPort { - return { ...createModelCatalogPort({ db: opened.db, providers, now, uuid }), boundModel }; + return { + ...createModelCatalogPort({ + db: opened.db, + providers, + now, + uuid, + ...(workOwner === undefined ? {} : { workOwner }), + }), + boundModel, + }; } /** What an interactive driver receives — the command core's seam, so a driver never touches the session directly. */ export interface ChatDriveContext { + /** Actual host lifetime, present on owned production construction paths. */ + readonly workOwner?: SessionWorkOwner; + /** Join the current native job-control binding before this mount's final unmount. */ + readonly finishJobControl?: () => Promise; + readonly adoptTerminalMount?: (mount: SessionInkMount) => void; + readonly subscribeInterrupt?: (listener: () => void) => () => void; /** * Open the session — call this AS THE FIRST ACT inside the driver, AFTER it has wired its stream * subscription, so the synchronous `session:started` (which carries the model) is observed, not raced. @@ -411,6 +433,8 @@ export interface ChatResumeCommandDeps { /** The subset the shared {@link runReplLoop} needs — satisfied by both {@link ChatCommandDeps} and {@link ChatResumeCommandDeps}. */ interface ChatReplDeps { + readonly afterTerminalRelease?: () => Promise; + readonly chatLifetime?: SessionChatLifetime; /** The original bootstrap invocation, shared across clear and reseat. */ readonly invocation?: CliInvocation; readonly io: CliIo; @@ -426,6 +450,8 @@ interface ChatReplDeps { readonly jobControlLifecycle?: JobControlLifecycle; /** The current loop's raw Ctrl-Z request. Constructed beside the hoisted terminal state, not by a plain driver. */ readonly onSuspend?: (() => void) | undefined; + readonly finishJobControl?: () => Promise; + readonly adoptTerminalMount?: (mount: SessionInkMount) => void; /** * `[preferences].copy_on_select`, ALREADY resolved against the mouse decision (`resolveCopyOnSelect`). `false` (or * absent, on a non-interactive path) means the ink tree gets no `clipboard` prop: a released drag still highlights, @@ -552,7 +578,17 @@ export function budgetWarningText(warning: ChatBudgetWarning): string { return `budget warning: ~${warning.thresholdPct}% of the ${warning.limitMicrocents}µ¢ cap reached`; } -export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): Promise { +export function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): Promise { + if (deps.invocation === undefined) return chatCommandBody(args, deps); + const ownership = new SessionChatLifetime(deps.invocation, deps.lifecycle); + return ownership.execute(() => chatCommandBody(args, deps, ownership)); +} + +async function chatCommandBody( + args: ChatCommandArgs, + deps: ChatCommandDeps, + ownership?: SessionChatLifetime, +): Promise { const nativeProcesses = nativeProcessesForInvocation(deps.invocation); const grants = admitFsInvocation(deps.global); const now = deps.now ?? Date.now; @@ -587,166 +623,214 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): // An unknown --agent / un-inferrable default model throws a typed CliError here (exit 2), before any session. // The build is async (2.R): it connects the agent's inline stdio `mcp_servers` (a connect failure is a // fail-loud exit-2 CliError, cause stripped) before the session is live. - const built = await (deps.buildSession ?? buildChatSession)({ - chat: config.chat, - fsPolicy, - ...(nativeProcesses === undefined ? {} : { nativeProcesses }), - ...(config.maxTokensEstimate === undefined - ? {} - : { maxTokensEstimate: config.maxTokensEstimate }), - // **Consent before any stdio MCP spawn** (ADR-0084 §1). `chat --agent` is the ordinary way an imported - // agent is opened, which is the case the gate exists for — and the one the first wiring missed by - // covering only `relavium run`. No `--allow-mcp-stdio` here: a chat is interactive by construction, so - // the refusal directs a scripted caller at `agent run`, which has the flag. - consentGate, - agentRef: args.agent, - cwd: deps.global.cwd, - projectConfigDir, - now, - uuid, - providers, - mcpSecretResolver, - mcpRegistrations: config.mcpServers, - ...(resolvePrice === undefined ? {} : { resolvePrice }), - onBudgetWarning: (warning) => emitLiveNotice(deps.io, budgetWarningText(warning)), - onCompactionStart: compactionStartNotice(deps.io), - // ADR-0071 §6: a tier the bound model does not take is WITHHELD at send — and said out loud. Without this the - // turn runs, the field is gone, and the user is billed at the provider's default tier with nothing to explain - // why the knob they set did nothing. - onEffortWithheld: onceEffortNotice((note) => emitLiveNotice(deps.io, note)), - onListenerError: (note: string) => emitLiveNotice(deps.io, note), - onUnpriced: (note) => emitLiveNotice(deps.io, note), - }); - // The session now OWNS the live MCP connections (built.closeMcp). `runReplLoop`'s finally is the steady-state - // teardown, but the build→loop window (opening history.db can throw) runs first — guard it so a pre-loop fault - // tears the connections down rather than orphaning the spawned children (ADR-0052 §2 teardown-on-terminal). - surfaceMcpSkipped(deps.io, built.mcpSkipped); - // Acquire the store first; on failure tear the MCP children down (best-effort — never mask the open error). - let opened: OpenedSessionStore; - try { - opened = (deps.openSessionStore ?? openSessionStore)(homeDir); - } catch (err) { - await built.closeMcp?.().catch(() => undefined); - throw err; - } - // Then the persister; on failure tear down EVERY acquired resource (the store too) before rethrowing. - let persister: SessionPersister; - try { - persister = createSessionPersister({ - usageStore: createSessionUsageStore(opened.db), - mode: 'fresh', - attachUsageWriter: built.attachUsageWriter, - governor: built.governor, - attachDurabilityProbe: built.attachDurabilityProbe, - attachEffectTurnAllocator: built.attachEffectTurnAllocator, + const prepare = async (host?: SessionHostOwner) => { + const build = (workOwner?: SessionWorkOwner) => + (deps.buildSession ?? buildChatSession)({ + ...(workOwner === undefined ? {} : { workOwner }), + chat: config.chat, + fsPolicy, + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), + ...(config.maxTokensEstimate === undefined + ? {} + : { maxTokensEstimate: config.maxTokensEstimate }), + // **Consent before any stdio MCP spawn** (ADR-0084 §1). `chat --agent` is the ordinary way an imported + // agent is opened, which is the case the gate exists for — and the one the first wiring missed by + // covering only `relavium run`. No `--allow-mcp-stdio` here: a chat is interactive by construction, so + // the refusal directs a scripted caller at `agent run`, which has the flag. + consentGate: + workOwner === undefined + ? consentGate + : createOwnedSessionConsentGate( + { + io: deps.io, + global: deps.global, + homeDir, + }, + workOwner, + ), + agentRef: args.agent, + cwd: deps.global.cwd, + projectConfigDir, + now, + uuid, + providers, + mcpSecretResolver, + mcpRegistrations: config.mcpServers, + ...(resolvePrice === undefined ? {} : { resolvePrice }), + onBudgetWarning: (warning) => emitLiveNotice(deps.io, budgetWarningText(warning)), + onCompactionStart: compactionStartNotice(deps.io), + // ADR-0071 §6: a tier the bound model does not take is WITHHELD at send — and said out loud. Without this the + // turn runs, the field is gone, and the user is billed at the provider's default tier with nothing to explain + // why the knob they set did nothing. + onEffortWithheld: onceEffortNotice((note) => emitLiveNotice(deps.io, note)), + onListenerError: (note: string) => emitLiveNotice(deps.io, note), + onUnpriced: (note) => emitLiveNotice(deps.io, note), + }); + const built = host === undefined ? await build() : await host.build(build); + // The session now OWNS the live MCP connections (built.closeMcp). `runReplLoop`'s finally is the steady-state + // teardown, but the build→loop window (opening history.db can throw) runs first — guard it so a pre-loop fault + // tears the connections down rather than orphaning the spawned children (ADR-0052 §2 teardown-on-terminal). + surfaceMcpSkipped(deps.io, built.mcpSkipped); + // Acquire the store first; on failure tear the MCP children down (best-effort — never mask the open error). + let opened: OpenedSessionStore; + try { + opened = (deps.openSessionStore ?? openSessionStore)(homeDir); + ownership?.adoptStore(opened); + } catch (err) { + if (host !== undefined) { + host.work.failure(err); + throw err; + } + await built.closeMcp?.().catch(() => undefined); + throw err; + } + // Then the persister; on failure tear down EVERY acquired resource (the store too) before rethrowing. + let persister: SessionPersister; + try { + persister = createSessionPersister( + { + usageStore: createSessionUsageStore(opened.db), + mode: 'fresh', + attachUsageWriter: built.attachUsageWriter, + governor: built.governor, + attachDurabilityProbe: built.attachDurabilityProbe, + attachEffectTurnAllocator: built.attachEffectTurnAllocator, - store: opened.store, - handle: built.handle, - sessionId: built.sessionId, + store: opened.store, + handle: built.handle, + sessionId: built.sessionId, + agent: built.agent, + context: built.context, + now, + uuid, + // ADR-0059 per-message/session model attribution — resolve a model string → its `model_catalog.id` (the FK + // target) over the SAME db, degrading to NULL when uncataloged. Shared across every persister site. + resolveModelCatalogId: makeCatalogIdResolver(opened.db, { uuid, now }), + }, + built.hostOwner, + ); + + // The durable effect journal (ADR-0080), wired where `history.db` is open. A site that forgets is + // NOT silent: the forwarding port refuses the first effect loudly, which is exactly why + // `unwiredEffectJournal()` rejects instead of no-opping. + built.attachEffectJournal((correlation) => + createEffectJournalPort(createEffectJournalStore(opened.db, { uuid, now }), correlation, { + providerAttempt: 1, + toolCallId: 'session', + }), + ); + } catch (err) { + if (host !== undefined) { + host.work.failure(err); + throw err; + } + closeQuietly(deps.io, 'session store', () => opened.close()); + await built.closeMcp?.().catch(() => undefined); + throw err; + } + + const doctorProbes = + deps.doctorProbes ?? + assembleDoctorProbes({ + cwd: deps.global.cwd, + ...(deps.global.configPath === undefined ? {} : { configPath: deps.global.configPath }), + resolver: providers, + // The MCP tier REPORTS the live session's status (read-only) — the bound agent's declared servers (all + // connected, since this session is live) + the tools the manager dropped. It never re-connects/spawns. + agentMcpServers: built.agent.mcp_servers ?? [], + mcpSkipped: built.mcpSkipped, + }); + + // The `/clear` (ADR-0062 §7) re-drive rebuild — a FRESH session bound to the SAME agent (`built.agent`, reused + // verbatim, invariant across swaps), MCP reconnected, store/totals empty, over the SHARED db. The contract is + // shared with `chat-resume` via createClearRebuild so it cannot drift. + const rebuild = createClearRebuild({ + homeDir, + ...(ownership === undefined ? {} : { ownership }), + chat: config.chat, + fsPolicy, + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), + consentGate, + ...(config.maxTokensEstimate === undefined + ? {} + : { maxTokensEstimate: config.maxTokensEstimate }), agent: built.agent, - context: built.context, + projectConfigDir, now, uuid, - // ADR-0059 per-message/session model attribution — resolve a model string → its `model_catalog.id` (the FK - // target) over the SAME db, degrading to NULL when uncataloged. Shared across every persister site. - resolveModelCatalogId: makeCatalogIdResolver(opened.db, { uuid, now }), + providers, + mcpSecretResolver, + mcpRegistrations: config.mcpServers, + opened, + buildSession: deps.buildSession ?? buildChatSession, + io: deps.io, + global: deps.global, + altScreen: config.altScreen, + // A `/clear` rebuild re-reads the user-pricing overlay FRESH from the shared db (buildFreshChatWiring), so a + // mid-session `models pricing` write applies to the next cleared session — no captured value to thread here. }); - // The durable effect journal (ADR-0080), wired where `history.db` is open. A site that forgets is - // NOT silent: the forwarding port refuses the first effect loudly, which is exactly why - // `unwiredEffectJournal()` rejects instead of no-opping. - built.attachEffectJournal((correlation) => - createEffectJournalPort(createEffectJournalStore(opened.db, { uuid, now }), correlation, { - providerAttempt: 1, - toolCallId: 'session', - }), - ); - } catch (err) { - closeQuietly(deps.io, 'session store', () => opened.close()); - await built.closeMcp?.().catch(() => undefined); - throw err; - } - - const doctorProbes = - deps.doctorProbes ?? - assembleDoctorProbes({ - cwd: deps.global.cwd, - ...(deps.global.configPath === undefined ? {} : { configPath: deps.global.configPath }), - resolver: providers, - // The MCP tier REPORTS the live session's status (read-only) — the bound agent's declared servers (all - // connected, since this session is live) + the tools the manager dropped. It never re-connects/spawns. - agentMcpServers: built.agent.mcp_servers ?? [], - mcpSkipped: built.mcpSkipped, + // The `/models` reseat rebuild (ADR-0059) — a NEW-model session carrying the transcript. It reloads the + // just-ended session from the SHARED db, so it does not close over `built.agent` (the reseat swaps the model). + const reseatRebuild = createReseatRebuild({ + homeDir, + ...(ownership === undefined ? {} : { ownership }), + chat: config.chat, + fsPolicy, + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), + consentGate, + ...(config.maxTokensEstimate === undefined + ? {} + : { maxTokensEstimate: config.maxTokensEstimate }), + now, + uuid, + providers, + mcpSecretResolver, + mcpRegistrations: config.mcpServers, + opened, + buildResumedSession: deps.buildResumedSession ?? buildResumedChatSession, + io: deps.io, + global: deps.global, + altScreen: config.altScreen, }); - // The `/clear` (ADR-0062 §7) re-drive rebuild — a FRESH session bound to the SAME agent (`built.agent`, reused - // verbatim, invariant across swaps), MCP reconnected, store/totals empty, over the SHARED db. The contract is - // shared with `chat-resume` via createClearRebuild so it cannot drift. - const rebuild = createClearRebuild({ - chat: config.chat, - fsPolicy, - ...(nativeProcesses === undefined ? {} : { nativeProcesses }), - consentGate, - ...(config.maxTokensEstimate === undefined - ? {} - : { maxTokensEstimate: config.maxTokensEstimate }), - agent: built.agent, - projectConfigDir, - now, - uuid, - providers, - mcpSecretResolver, - mcpRegistrations: config.mcpServers, - opened, - buildSession: deps.buildSession ?? buildChatSession, - io: deps.io, - global: deps.global, - altScreen: config.altScreen, - // A `/clear` rebuild re-reads the user-pricing overlay FRESH from the shared db (buildFreshChatWiring), so a - // mid-session `models pricing` write applies to the next cleared session — no captured value to thread here. - }); - - // The `/models` reseat rebuild (ADR-0059) — a NEW-model session carrying the transcript. It reloads the - // just-ended session from the SHARED db, so it does not close over `built.agent` (the reseat swaps the model). - const reseatRebuild = createReseatRebuild({ - chat: config.chat, - fsPolicy, - ...(nativeProcesses === undefined ? {} : { nativeProcesses }), - consentGate, - ...(config.maxTokensEstimate === undefined - ? {} - : { maxTokensEstimate: config.maxTokensEstimate }), - now, - uuid, - providers, - mcpSecretResolver, - mcpRegistrations: config.mcpServers, - opened, - buildResumedSession: deps.buildResumedSession ?? buildResumedChatSession, - io: deps.io, - global: deps.global, - altScreen: config.altScreen, - }); - - return runReplLoop( + return { + wiring: { + built, + opened, + store, + persister, + doctorProbes, + startSession: () => built.session.start(), + modelPicker: buildChatModelsPort( + opened, + providers, + built.agent.model, + now, + uuid, + built.workOwner, + ), + altScreen: config.altScreen, + mouse: config.mouse, + copyOnSelect: config.copyOnSelect, + ...(config.chat.maxMessages === undefined + ? {} + : { chatMaxMessages: config.chat.maxMessages }), + } satisfies ReplWiring, + rebuild, + reseatRebuild, + }; + }; + const prepared = + ownership === undefined ? await prepare() : await ownership.hosts.construct(prepare); + return await runReplLoop( + prepared.wiring, { - built, - opened, - store, - persister, - doctorProbes, - startSession: () => built.session.start(), - modelPicker: buildChatModelsPort(opened, providers, built.agent.model, now, uuid), - altScreen: config.altScreen, - mouse: config.mouse, - copyOnSelect: config.copyOnSelect, - ...(config.chat.maxMessages === undefined - ? {} - : { chatMaxMessages: config.chat.maxMessages }), + ...deps, + ...(ownership === undefined ? {} : { chatLifetime: ownership }), }, - deps, - rebuild, - reseatRebuild, + prepared.rebuild, + prepared.reseatRebuild, ); } @@ -758,9 +842,19 @@ export async function chatCommand(args: ChatCommandArgs, deps: ChatCommandDeps): * {@link runReplLoop}. An unknown `sessionId` (or a session with no stored agent snapshot) is a clean exit-2 * invocation fault. Like `chat`, it ends with **exit code 4**. */ -export async function chatResumeCommand( +export function chatResumeCommand( args: ChatResumeCommandArgs, deps: ChatResumeCommandDeps, +): Promise { + if (deps.invocation === undefined) return chatResumeCommandBody(args, deps); + const ownership = new SessionChatLifetime(deps.invocation, deps.lifecycle); + return ownership.execute(() => chatResumeCommandBody(args, deps, ownership)); +} + +async function chatResumeCommandBody( + args: ChatResumeCommandArgs, + deps: ChatResumeCommandDeps, + ownership?: SessionChatLifetime, ): Promise { const nativeProcesses = nativeProcessesForInvocation(deps.invocation); const now = deps.now ?? Date.now; @@ -769,239 +863,280 @@ export async function chatResumeCommand( const grants = admitFsInvocation(deps.global); const homeDir = resolveHomeDir({}); const opened = (deps.openSessionStore ?? openSessionStore)(homeDir); - let config: LoadedConfig['config']; - let projectConfigDir: LoadedConfig['projectConfigDir']; - let fsPolicy: FsPathPolicy; - let providers: ProviderResolver; - let mcpSecretResolver: McpSecretResolver; - let consentGate: NonNullable; - - let built: BuiltChatSession; - let store: ChatStoreController; - let persister: SessionPersister; - let intro: string; - // The ADR-0065 §2 user-pricing overlay (2.5.G S10) — hoisted so the post-try `/clear` rebuild reuses it. - let resolvePrice: BuildChatSessionOptions['resolvePrice']; - // The just-built resumed session OWNS its MCP connections; if a pre-loop step after a SUCCESSFUL build throws, - // the catch must tear them down (the steady-state teardown is runReplLoop's finally, not yet entered). Undefined - // when no server was declared OR the build self-cleaned its own post-connect fault (see buildResumedChatSession). - let closeMcp: (() => Promise) | undefined; - try { - // The current `[chat]` config governs the resumed turn/cost caps; the agent, model, and context are the - // frozen originals from the record. An absent session is a clean exit-2 invocation fault. - const loaded = opened.store.loadFull(args.sessionId); - if (loaded === undefined) { - throw new CliError('invalid_invocation', `no session found with id ${args.sessionId}`); - } - if (loaded.session.agentSnapshot === undefined) { - throw new CliError( - 'invalid_invocation', - 'The session has no stored agent snapshot and cannot be resumed.', - ); - } - // Recover the original workspace before reading project/workspace selectors. Launch cwd cannot substitute it. - const originalCwd = loaded.session.context.workingDir; - if (!isAbsolute(originalCwd)) { - throw new CliError( - 'invalid_invocation', - 'This legacy session has no absolute workspace anchor and cannot be safely resumed.', - ); - } - let canonicalCwd: string; + ownership?.adoptStore(opened); + const prepare = async (host?: SessionHostOwner) => { + let config: LoadedConfig['config']; + let projectConfigDir: LoadedConfig['projectConfigDir']; + let fsPolicy: FsPathPolicy; + let providers: ProviderResolver; + let mcpSecretResolver: McpSecretResolver; + let consentGate: NonNullable; + + let built: BuiltChatSession; + let store: ChatStoreController; + let persister: SessionPersister; + let intro: string; + // The ADR-0065 §2 user-pricing overlay (2.5.G S10) — hoisted so the post-try `/clear` rebuild reuses it. + let resolvePrice: BuildChatSessionOptions['resolvePrice']; + // The just-built resumed session OWNS its MCP connections; if a pre-loop step after a SUCCESSFUL build throws, + // the catch must tear them down (the steady-state teardown is runReplLoop's finally, not yet entered). Undefined + // when no server was declared OR the build self-cleaned its own post-connect fault (see buildResumedChatSession). + let closeMcp: (() => Promise) | undefined; try { - canonicalCwd = realpathSync.native(originalCwd); - } catch { - throw new CliError('invalid_invocation', 'The original session workspace is unavailable.'); - } - if (canonicalCwd !== originalCwd) { - throw new CliError( - 'invalid_invocation', - 'This legacy session has an ambiguous workspace alias and cannot be safely resumed.', - ); + // The current `[chat]` config governs the resumed turn/cost caps; the agent, model, and context are the + // frozen originals from the record. An absent session is a clean exit-2 invocation fault. + const loaded = opened.store.loadFull(args.sessionId); + if (loaded === undefined) { + throw new CliError('invalid_invocation', `no session found with id ${args.sessionId}`); + } + if (loaded.session.agentSnapshot === undefined) { + throw new CliError( + 'invalid_invocation', + 'The session has no stored agent snapshot and cannot be resumed.', + ); + } + // Recover the original workspace before reading project/workspace selectors. Launch cwd cannot substitute it. + const originalCwd = loaded.session.context.workingDir; + if (!isAbsolute(originalCwd)) { + throw new CliError( + 'invalid_invocation', + 'This legacy session has no absolute workspace anchor and cannot be safely resumed.', + ); + } + let canonicalCwd: string; + try { + canonicalCwd = realpathSync.native(originalCwd); + } catch { + throw new CliError('invalid_invocation', 'The original session workspace is unavailable.'); + } + if (canonicalCwd !== originalCwd) { + throw new CliError( + 'invalid_invocation', + 'This legacy session has an ambiguous workspace alias and cannot be safely resumed.', + ); + } + ({ config, projectConfigDir } = loadResolvedConfig({ + cwd: originalCwd, + configPath: deps.global.configPath, + })); + fsPolicy = admitChatFsPolicy(grants, originalCwd, config.chat.extraRoots); + deps = { ...deps, global: Object.freeze({ ...deps.global, cwd: originalCwd }) }; + const keys = deps.resolveKeys?.(opened.db); + providers = deps.providers ?? keys?.providers ?? createProviderResolver(deps.io.env); + mcpSecretResolver = + deps.mcpSecretResolver ?? keys?.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env); + // The ADR-0065 §2 user-pricing overlay (2.5.G S10), read from the ALREADY-OPEN session db — so a resumed + // session enforces + tracks a user-priced model exactly like a fresh `chat`. Non-fatal (an empty map on a + // read fault): a corrupt pricing row must not fail an otherwise-valid resume. + resolvePrice = readUserPricingOverlay(opened.db); + consentGate = createConsentGate({ + io: deps.io, + global: deps.global, + homeDir, + prompt: createConsentPrompter(), + }); + const build = (workOwner?: SessionWorkOwner) => + (deps.buildResumedSession ?? buildResumedChatSession)({ + ...(workOwner === undefined ? {} : { workOwner }), + chat: config.chat, + fsPolicy, + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), + ...(config.maxTokensEstimate === undefined + ? {} + : { maxTokensEstimate: config.maxTokensEstimate }), + // Consent before any stdio MCP spawn (ADR-0084 §1) — every path that opens an agent, not only `run`. + consentGate: + workOwner === undefined + ? consentGate + : createOwnedSessionConsentGate( + { + io: deps.io, + global: deps.global, + homeDir, + }, + workOwner, + ), + record: loaded.session, + messages: loaded.messages, + now, + providers, + mcpSecretResolver, + mcpRegistrations: config.mcpServers, + ...(resolvePrice === undefined ? {} : { resolvePrice }), + onBudgetWarning: (warning) => emitLiveNotice(deps.io, budgetWarningText(warning)), + onCompactionStart: compactionStartNotice(deps.io), + // ADR-0071 §6: a tier the bound model does not take is WITHHELD at send — and said out loud. Without this the + // turn runs, the field is gone, and the user is billed at the provider's default tier with nothing to explain + // why the knob they set did nothing. + onEffortWithheld: onceEffortNotice((note) => emitLiveNotice(deps.io, note)), + onListenerError: (note: string) => emitLiveNotice(deps.io, note), + onUnpriced: (note) => emitLiveNotice(deps.io, note), + }); + const resumed = host === undefined ? await build() : await host.build(build); + closeMcp = resumed.closeMcp; + surfaceMcpSkipped(deps.io, resumed.mcpSkipped); + built = resumed; + // Seed the view header + persister via the SHARED resumed-wiring assembly (the same the `/models` reseat uses): + // a resumed session never re-emits `session:started`, so the seeded store shows the model + carried cost/turns + // from the first frame, and the persister continues past the last durable sequence number. + ({ store, persister } = seedResumedWiring( + resumed, + opened, + deps.global.color, + now, + uuid, + transcriptBoundFor(chatAltActive(deps, config.altScreen)), + // `chat-resume` carries NOTHING: it has never repainted prior turns, in any mode or version — there is no + // session_messages -> TranscriptEntry projection anywhere. That is a separate, tracked gap (deferred-tasks.md), + // NOT this reseat's business, and projecting it here would silently change resume's behaviour. + [], + )); + const turns = resumed.resumeState.turnCount; + // `sessionId` is only schema-constrained to a non-empty string (the CLI mints a UUID, but `history.db` is + // shared with other surfaces) — sanitize it before it reaches the TTY, exactly as `chat-list` does (the + // agent id is kebab-constrained, so safe raw). Without this the resume banner is the one chat output path + // that could carry a terminal escape from a crafted stored id. + intro = `Resuming session ${sanitizeInline(resumed.sessionId)} — ${resumed.agent.id}, ${turns} prior ${turns === 1 ? 'turn' : 'turns'}. Type a message, or /exit to quit.`; + // Pre-flight the hard turn cap: a session resumed under a config whose `[chat].max_turns` is now at/below + // its prior turn count would have EVERY new turn blocked loudly as `turn_limit` (the engine cap counts the + // carried turns) with no way forward but /exit. Warn up front (stderr, non-blocking) so the user isn't + // silently trapped — distinct from the per-turn engine error. The effective cap mirrors the engine's + // `undefined/≤0 ⇒ default` rule (config validation already rejects a non-positive max_turns). + const cap = config.chat.maxTurns ?? DEFAULT_SESSION_MAX_TURNS; + if (turns >= cap) { + deps.io.writeErr( + `note: this session has ${turns} turns, at or over the ${cap}-turn cap — new turns will be refused (turn_limit). Raise [chat].max_turns to continue it.\n`, + ); + } + } catch (err) { + // A pre-loop fault (not-found, no snapshot, build failure, or a post-build setup throw) must not strand the + // open db handle NOR the spawned MCP children — tear BOTH down (a reject in one must not skip the other), and + // never let a cleanup fault mask the primary error (best-effort; closeMcp is idempotent + a no-op when unset). + if (host !== undefined) { + host.work.failure(err); + throw err; + } + await closeMcp?.().catch(() => warnTeardown(deps.io, 'MCP')); + closeQuietly(deps.io, 'session store', () => opened.close()); + throw err; } - ({ config, projectConfigDir } = loadResolvedConfig({ - cwd: originalCwd, - configPath: deps.global.configPath, - })); - fsPolicy = admitChatFsPolicy(grants, originalCwd, config.chat.extraRoots); - deps = { ...deps, global: Object.freeze({ ...deps.global, cwd: originalCwd }) }; - const keys = deps.resolveKeys?.(opened.db); - providers = deps.providers ?? keys?.providers ?? createProviderResolver(deps.io.env); - mcpSecretResolver = - deps.mcpSecretResolver ?? keys?.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env); - // The ADR-0065 §2 user-pricing overlay (2.5.G S10), read from the ALREADY-OPEN session db — so a resumed - // session enforces + tracks a user-priced model exactly like a fresh `chat`. Non-fatal (an empty map on a - // read fault): a corrupt pricing row must not fail an otherwise-valid resume. - resolvePrice = readUserPricingOverlay(opened.db); - consentGate = createConsentGate({ - io: deps.io, - global: deps.global, + + const doctorProbes = + deps.doctorProbes ?? + assembleDoctorProbes({ + cwd: deps.global.cwd, + ...(deps.global.configPath === undefined ? {} : { configPath: deps.global.configPath }), + resolver: providers, + // The MCP tier REPORTS the live session's status (read-only) — the bound agent's declared servers (all + // connected, since this session is live) + the tools the manager dropped. It never re-connects/spawns. + agentMcpServers: built.agent.mcp_servers ?? [], + mcpSkipped: built.mcpSkipped, + }); + + // The `/clear` (ADR-0062 §7) re-drive rebuild: a FRESH session (NOT a re-resume) bound to the resumed agent — + // `built.agent` is the frozen snapshot agent (no on-disk ref), passed verbatim. Same shared contract as `chat`. + const rebuild = createClearRebuild({ homeDir, - prompt: createConsentPrompter(), - }); - const resumed = await (deps.buildResumedSession ?? buildResumedChatSession)({ + ...(ownership === undefined ? {} : { ownership }), chat: config.chat, fsPolicy, + consentGate, ...(nativeProcesses === undefined ? {} : { nativeProcesses }), ...(config.maxTokensEstimate === undefined ? {} : { maxTokensEstimate: config.maxTokensEstimate }), - // Consent before any stdio MCP spawn (ADR-0084 §1) — every path that opens an agent, not only `run`. - consentGate, - record: loaded.session, - messages: loaded.messages, + agent: built.agent, + projectConfigDir, now, + uuid, providers, mcpSecretResolver, mcpRegistrations: config.mcpServers, - resolvePrice, - onBudgetWarning: (warning) => emitLiveNotice(deps.io, budgetWarningText(warning)), - onCompactionStart: compactionStartNotice(deps.io), - // ADR-0071 §6: a tier the bound model does not take is WITHHELD at send — and said out loud. Without this the - // turn runs, the field is gone, and the user is billed at the provider's default tier with nothing to explain - // why the knob they set did nothing. - onEffortWithheld: onceEffortNotice((note) => emitLiveNotice(deps.io, note)), - onListenerError: (note: string) => emitLiveNotice(deps.io, note), - onUnpriced: (note) => emitLiveNotice(deps.io, note), - }); - closeMcp = resumed.closeMcp; - surfaceMcpSkipped(deps.io, resumed.mcpSkipped); - built = resumed; - // Seed the view header + persister via the SHARED resumed-wiring assembly (the same the `/models` reseat uses): - // a resumed session never re-emits `session:started`, so the seeded store shows the model + carried cost/turns - // from the first frame, and the persister continues past the last durable sequence number. - ({ store, persister } = seedResumedWiring( - resumed, opened, - deps.global.color, + buildSession: deps.buildSession ?? buildChatSession, + io: deps.io, + global: deps.global, + altScreen: config.altScreen, + // A `/clear` rebuild re-reads the user-pricing overlay FRESH from the shared db (buildFreshChatWiring). + }); + + // The `/models` reseat rebuild (ADR-0059) — a NEW-model session carrying the transcript, over the SHARED db. + // Reloads the just-ended session (which may itself already be a resume), so it needs no captured agent. + const reseatRebuild = createReseatRebuild({ + homeDir, + ...(ownership === undefined ? {} : { ownership }), + chat: config.chat, + fsPolicy, + consentGate, + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), + ...(config.maxTokensEstimate === undefined + ? {} + : { maxTokensEstimate: config.maxTokensEstimate }), now, uuid, - transcriptBoundFor(chatAltActive(deps, config.altScreen)), - // `chat-resume` carries NOTHING: it has never repainted prior turns, in any mode or version — there is no - // session_messages -> TranscriptEntry projection anywhere. That is a separate, tracked gap (deferred-tasks.md), - // NOT this reseat's business, and projecting it here would silently change resume's behaviour. - [], - )); - const turns = resumed.resumeState.turnCount; - // `sessionId` is only schema-constrained to a non-empty string (the CLI mints a UUID, but `history.db` is - // shared with other surfaces) — sanitize it before it reaches the TTY, exactly as `chat-list` does (the - // agent id is kebab-constrained, so safe raw). Without this the resume banner is the one chat output path - // that could carry a terminal escape from a crafted stored id. - intro = `Resuming session ${sanitizeInline(resumed.sessionId)} — ${resumed.agent.id}, ${turns} prior ${turns === 1 ? 'turn' : 'turns'}. Type a message, or /exit to quit.`; - // Pre-flight the hard turn cap: a session resumed under a config whose `[chat].max_turns` is now at/below - // its prior turn count would have EVERY new turn blocked loudly as `turn_limit` (the engine cap counts the - // carried turns) with no way forward but /exit. Warn up front (stderr, non-blocking) so the user isn't - // silently trapped — distinct from the per-turn engine error. The effective cap mirrors the engine's - // `undefined/≤0 ⇒ default` rule (config validation already rejects a non-positive max_turns). - const cap = config.chat.maxTurns ?? DEFAULT_SESSION_MAX_TURNS; - if (turns >= cap) { - deps.io.writeErr( - `note: this session has ${turns} turns, at or over the ${cap}-turn cap — new turns will be refused (turn_limit). Raise [chat].max_turns to continue it.\n`, - ); - } - } catch (err) { - // A pre-loop fault (not-found, no snapshot, build failure, or a post-build setup throw) must not strand the - // open db handle NOR the spawned MCP children — tear BOTH down (a reject in one must not skip the other), and - // never let a cleanup fault mask the primary error (best-effort; closeMcp is idempotent + a no-op when unset). - await closeMcp?.().catch(() => warnTeardown(deps.io, 'MCP')); - closeQuietly(deps.io, 'session store', () => opened.close()); - throw err; - } - - const doctorProbes = - deps.doctorProbes ?? - assembleDoctorProbes({ - cwd: deps.global.cwd, - ...(deps.global.configPath === undefined ? {} : { configPath: deps.global.configPath }), - resolver: providers, - // The MCP tier REPORTS the live session's status (read-only) — the bound agent's declared servers (all - // connected, since this session is live) + the tools the manager dropped. It never re-connects/spawns. - agentMcpServers: built.agent.mcp_servers ?? [], - mcpSkipped: built.mcpSkipped, + providers, + mcpSecretResolver, + mcpRegistrations: config.mcpServers, + opened, + buildResumedSession: deps.buildResumedSession ?? buildResumedChatSession, + io: deps.io, + global: deps.global, + altScreen: config.altScreen, }); - // The `/clear` (ADR-0062 §7) re-drive rebuild: a FRESH session (NOT a re-resume) bound to the resumed agent — - // `built.agent` is the frozen snapshot agent (no on-disk ref), passed verbatim. Same shared contract as `chat`. - const rebuild = createClearRebuild({ - chat: config.chat, - fsPolicy, - consentGate, - ...(nativeProcesses === undefined ? {} : { nativeProcesses }), - ...(config.maxTokensEstimate === undefined - ? {} - : { maxTokensEstimate: config.maxTokensEstimate }), - agent: built.agent, - projectConfigDir, - now, - uuid, - providers, - mcpSecretResolver, - mcpRegistrations: config.mcpServers, - opened, - buildSession: deps.buildSession ?? buildChatSession, - io: deps.io, - global: deps.global, - altScreen: config.altScreen, - // A `/clear` rebuild re-reads the user-pricing overlay FRESH from the shared db (buildFreshChatWiring). - }); - - // The `/models` reseat rebuild (ADR-0059) — a NEW-model session carrying the transcript, over the SHARED db. - // Reloads the just-ended session (which may itself already be a resume), so it needs no captured agent. - const reseatRebuild = createReseatRebuild({ - chat: config.chat, - fsPolicy, - consentGate, - ...(nativeProcesses === undefined ? {} : { nativeProcesses }), - ...(config.maxTokensEstimate === undefined - ? {} - : { maxTokensEstimate: config.maxTokensEstimate }), - now, - uuid, - providers, - mcpSecretResolver, - mcpRegistrations: config.mcpServers, - opened, - buildResumedSession: deps.buildResumedSession ?? buildResumedChatSession, - io: deps.io, - global: deps.global, - altScreen: config.altScreen, - }); - - // A resumed session already landed at idle inside `AgentSession.resume`; calling start() would throw and - // re-emitting `session:started` would double a lifecycle event. Disclose only once the driver is active. - return runReplLoop( + // A resumed session already landed at idle inside `AgentSession.resume`; calling start() would throw and + // re-emitting `session:started` would double a lifecycle event. Disclose only once the driver is active. + return { + wiring: { + built, + opened, + store, + persister, + doctorProbes, + startSession: () => undefined, + onActivated: (isActive, flushNotice) => + reconcileResumedSessionEffects({ + io: deps.io, + db: opened.db, + sessionId: built.sessionId, + sanitize: sanitizeInline, + isActive, + ...(flushNotice === undefined ? {} : { flushNotice }), + deliverNotice: (text) => { + if (chatIsInteractive(deps.io, deps.global)) return store.notice(text); + else return deps.io.writeErrAcknowledged(`${text}\n`); + }, + }), + intro, + modelPicker: buildChatModelsPort( + opened, + providers, + built.agent.model, + now, + uuid, + built.workOwner, + ), + altScreen: config.altScreen, + mouse: config.mouse, + copyOnSelect: config.copyOnSelect, + ...(config.chat.maxMessages === undefined + ? {} + : { chatMaxMessages: config.chat.maxMessages }), + } satisfies ReplWiring, + rebuild, + reseatRebuild, + }; + }; + const prepared = + ownership === undefined ? await prepare() : await ownership.hosts.construct(prepare); + return await runReplLoop( + prepared.wiring, { - built, - opened, - store, - persister, - doctorProbes, - startSession: () => undefined, - onActivated: (isActive, flushNotice) => - reconcileResumedSessionEffects({ - io: deps.io, - db: opened.db, - sessionId: built.sessionId, - sanitize: sanitizeInline, - isActive, - ...(flushNotice === undefined ? {} : { flushNotice }), - deliverNotice: (text) => { - if (chatIsInteractive(deps.io, deps.global)) return store.notice(text); - else return deps.io.writeErrAcknowledged(`${text}\n`); - }, - }), - intro, - modelPicker: buildChatModelsPort(opened, providers, built.agent.model, now, uuid), - altScreen: config.altScreen, - mouse: config.mouse, - copyOnSelect: config.copyOnSelect, - ...(config.chat.maxMessages === undefined - ? {} - : { chatMaxMessages: config.chat.maxMessages }), + ...deps, + ...(ownership === undefined ? {} : { chatLifetime: ownership }), }, - deps, - rebuild, - reseatRebuild, + prepared.rebuild, + prepared.reseatRebuild, ); } @@ -1218,6 +1353,7 @@ export function createChatLineHandler( deps: ChatReplDeps, ): ChatLineHandler { const { built, opened, store, persister, doctorProbes } = wiring; + const workOwner = built.workOwner; store.setMemoryPolicy(built.session.memoryPolicy); if (Object.hasOwn(built, 'contextWindowTokens')) store.setContextWindow?.(built.contextWindowTokens); @@ -1233,7 +1369,8 @@ export function createChatLineHandler( const cancelOnce = (): void => { if (!cancelled) { cancelled = true; - built.session.cancel(); // the session's sole terminal (session:cancelled) — persister marks it 'ended' + if (built.hostOwner === undefined) built.session.cancel(); + else built.hostOwner.work.stop(); // Observe an owned terminal-write refusal before producer joins. } }; // Whether an interactive prompt (the ink UI / Home controller) can answer an approval — the ink view is @@ -1266,6 +1403,7 @@ export function createChatLineHandler( ...(deps.hatchPorts ?? inertHatchPorts()), transcript: () => store.getSnapshot().state.transcript, note: emitOutput, + ...(workOwner === undefined ? {} : { onFailure: workOwner.failure }), }); // The lifecycle capabilities the curated REPL commands (repl-commands.ts) run over — the slash names and the @@ -1306,7 +1444,14 @@ export function createChatLineHandler( deps.io.writeErr(`exported session to ${result.path}\n`); } } catch (err) { - deps.io.writeErr(`export failed: ${err instanceof Error ? err.message : String(err)}\n`); + workOwner?.failure(err); + const message = + workOwner === undefined + ? err instanceof Error + ? err.message + : String(err) + : toUserFacing(err).message; + deps.io.writeErr(`export failed: ${message}\n`); } }, // The curated command list — a clean in-view notice in the ink TTY, a stderr list on the plain / `--json` paths. @@ -1332,6 +1477,7 @@ export function createChatLineHandler( discoverCatalog({ projectConfigDir, cwd: deps.global.cwd, kind }); emitOutput(catalogNotice(scan('workflows'), scan('agents'))); } catch (err) { + workOwner?.failure(err); // Surface the failure by its CODE — a raw catalog CliError message embeds the absolute `.relavium/` path, // which the REPL transcript / a screenshare need not carry. const reason = err instanceof CliError ? err.code : 'unexpected error'; @@ -1369,9 +1515,9 @@ export function createChatLineHandler( }), ); } catch (err) { - // A read-only info command must never end the conversation. `handleSlashCommand` awaits `run` unguarded, and - // on the ink driver a rejection routes to `onError` → teardown; every sibling info handler is guarded for - // exactly this reason. Report by CODE, never a raw message (a DB error can carry a path). + workOwner?.failure(err); + // Known info refusals remain notices. An owned infrastructure fault is reported above before this + // presentation catch consumes it. Report by CODE, never a raw message (a DB error can carry a path). emitOutput( `could not read the session cost: ${err instanceof CliError ? err.code : 'unexpected error'}`, ); @@ -1385,7 +1531,8 @@ export function createChatLineHandler( if (deep) emitOutput('doctor: validating providers + MCP…'); try { emitOutput(formatDoctorReport(await runDoctorChecks(deep, doctorProbes))); - } catch { + } catch (cause) { + workOwner?.failure(cause); // runDoctorChecks should not throw (every probe catches its own faults), so this is a defensive net. // Keep it generic + consistent with the Home (home-controller.ts) — never surface an internal code. emitOutput('doctor: check failed'); @@ -1497,9 +1644,10 @@ export function createChatLineHandler( // A first-pass budget refusal opens none and is reported by this command's typed result. try { emitOutput(compactionNotice(await built.session.compact('manual'))); - } catch { - // compact() RE-THROWS an unclassified error (a bug) rather than returning {kind:'failed'} — never crash - // the REPL (the discipline every slash command obeys); surface a static, secret-free notice instead. + } catch (cause) { + workOwner?.failure(cause); + // compact() rethrows an unclassified fault rather than returning a known failed outcome. + // Preserve the owned fault above; its presentation remains fixed and content-free. emitOutput('compaction failed unexpectedly — the conversation is unchanged.'); } finally { // Idempotent fallback for a command/observer fault; admitted engine work closes its own moment. @@ -1607,7 +1755,10 @@ export function createChatLineHandler( }; return { - processLine, + processLine: + workOwner === undefined + ? processLine + : (raw, display) => workOwner.enter(() => processLine(raw, display)), cancelOnce, shouldStop: () => stop, // Priority: a reseat is a swap-to-new-model, a clear is a swap-to-fresh, else the REPL ends. A `/models` reseat @@ -1637,6 +1788,7 @@ export function createChatLineHandler( * function's to close — the caller's outer {@link runReplLoop} finally owns it across every swap. */ interface FreshChatWiringDeps { + readonly homeDir: string; readonly chat: BuildChatSessionOptions['chat']; readonly fsPolicy?: FsPathPolicy; readonly nativeProcesses?: NativeProcessController; @@ -1667,34 +1819,49 @@ interface FreshChatWiringDeps { readonly altScreen?: boolean | undefined; } -async function buildFreshChatWiring(deps: FreshChatWiringDeps, intro: string): Promise { +async function buildFreshChatWiring( + deps: FreshChatWiringDeps, + intro: string, + host?: SessionHostOwner, +): Promise { // Re-read the ADR-0065 §2 user-pricing overlay FRESH from the shared db on each `/clear` rebuild (2.5.G S10) — so // a `models pricing` write made in another terminal mid-session takes effect on the very next `/clear` session, // the SAME freshness guarantee `readEffectiveDefault()` gives the default model (and the Home already gives // pricing). Non-fatal (empty map on a read fault). Empty ⇒ omitted (unknown models degrade to allow, unchanged). const resolvePrice = readUserPricingOverlay(deps.opened.db); - const built = await deps.buildSession({ - chat: deps.chat, - ...(deps.fsPolicy === undefined ? {} : { fsPolicy: deps.fsPolicy }), - ...(deps.nativeProcesses === undefined ? {} : { nativeProcesses: deps.nativeProcesses }), - ...(deps.consentGate === undefined ? {} : { consentGate: deps.consentGate }), - ...(deps.maxTokensEstimate === undefined ? {} : { maxTokensEstimate: deps.maxTokensEstimate }), - agent: deps.agent, - agentRef: deps.agent.id, // ignored when `agent` is set, but the option key is required - cwd: deps.cwd, - projectConfigDir: deps.projectConfigDir, - now: deps.now, - uuid: deps.uuid, - providers: deps.providers, - mcpSecretResolver: deps.mcpSecretResolver, - ...(deps.mcpRegistrations === undefined ? {} : { mcpRegistrations: deps.mcpRegistrations }), - ...(resolvePrice.size === 0 ? {} : { resolvePrice }), - onBudgetWarning: deps.onBudgetWarning, - ...(deps.onCompactionStart === undefined ? {} : { onCompactionStart: deps.onCompactionStart }), - onEffortWithheld: deps.onEffortWithheld, - onListenerError: deps.onListenerError, - onUnpriced: deps.onUnpriced, - }); + const build = (workOwner?: SessionWorkOwner) => + deps.buildSession({ + ...(workOwner === undefined ? {} : { workOwner }), + chat: deps.chat, + ...(deps.fsPolicy === undefined ? {} : { fsPolicy: deps.fsPolicy }), + ...(deps.nativeProcesses === undefined ? {} : { nativeProcesses: deps.nativeProcesses }), + ...(workOwner === undefined + ? deps.consentGate === undefined + ? {} + : { consentGate: deps.consentGate } + : { consentGate: createOwnedSessionConsentGate(deps, workOwner) }), + ...(deps.maxTokensEstimate === undefined + ? {} + : { maxTokensEstimate: deps.maxTokensEstimate }), + agent: deps.agent, + agentRef: deps.agent.id, // ignored when `agent` is set, but the option key is required + cwd: deps.cwd, + projectConfigDir: deps.projectConfigDir, + now: deps.now, + uuid: deps.uuid, + providers: deps.providers, + mcpSecretResolver: deps.mcpSecretResolver, + ...(deps.mcpRegistrations === undefined ? {} : { mcpRegistrations: deps.mcpRegistrations }), + ...(resolvePrice.size === 0 ? {} : { resolvePrice }), + onBudgetWarning: deps.onBudgetWarning, + ...(deps.onCompactionStart === undefined + ? {} + : { onCompactionStart: deps.onCompactionStart }), + onEffortWithheld: deps.onEffortWithheld, + onListenerError: deps.onListenerError, + onUnpriced: deps.onUnpriced, + }); + const built = host === undefined ? await build() : await host.build(build); // The SAME signals `runReplLoop` used for the hoist (`deps.altScreen` is `[preferences].alt_screen`, carried here // precisely so a `/clear` re-drive keeps the mode) — so a rebuilt session cannot silently re-acquire the 4000-char // transcript bound mid-conversation. @@ -1714,26 +1881,29 @@ async function buildFreshChatWiring(deps: FreshChatWiringDeps, intro: string): P for (const line of mcpSkippedLines(built.mcpSkipped)) store.notice(line); let persister: SessionPersister; try { - persister = createSessionPersister({ - usageStore: createSessionUsageStore(deps.opened.db), - mode: 'fresh', - attachUsageWriter: built.attachUsageWriter, - governor: built.governor, - attachDurabilityProbe: built.attachDurabilityProbe, - attachEffectTurnAllocator: built.attachEffectTurnAllocator, - store: deps.opened.store, - handle: built.handle, - sessionId: built.sessionId, - agent: built.agent, - context: built.context, - now: deps.now, - uuid: deps.uuid, - // ADR-0059 attribution — resolved over the SAME shared db (a `/clear` rebuild re-reads it fresh). - resolveModelCatalogId: makeCatalogIdResolver(deps.opened.db, { - uuid: deps.uuid, + persister = createSessionPersister( + { + usageStore: createSessionUsageStore(deps.opened.db), + mode: 'fresh', + attachUsageWriter: built.attachUsageWriter, + governor: built.governor, + attachDurabilityProbe: built.attachDurabilityProbe, + attachEffectTurnAllocator: built.attachEffectTurnAllocator, + store: deps.opened.store, + handle: built.handle, + sessionId: built.sessionId, + agent: built.agent, + context: built.context, now: deps.now, - }), - }); + uuid: deps.uuid, + // ADR-0059 attribution — resolved over the SAME shared db (a `/clear` rebuild re-reads it fresh). + resolveModelCatalogId: makeCatalogIdResolver(deps.opened.db, { + uuid: deps.uuid, + now: deps.now, + }), + }, + built.hostOwner, + ); // The durable effect journal (ADR-0080), wired where `history.db` is open. A site that forgets is // NOT silent: the forwarding port refuses the first effect loudly, which is exactly why @@ -1748,7 +1918,8 @@ async function buildFreshChatWiring(deps: FreshChatWiringDeps, intro: string): P } catch (err) { // Acquire-then-guard: the fresh MCP children are already spawned — reclaim them before the failure propagates // so a persister-construction throw never orphans a stdio child (best-effort; never mask the primary error). - await built.closeMcp?.().catch(() => undefined); + if (host !== undefined) host.work.failure(err); + else await built.closeMcp?.().catch(() => undefined); throw err; } const doctorProbes = assembleDoctorProbes({ @@ -1772,6 +1943,7 @@ async function buildFreshChatWiring(deps: FreshChatWiringDeps, intro: string): P built.agent.model, deps.now, deps.uuid, + built.workOwner, ), ...(deps.altScreen === undefined ? {} : { altScreen: deps.altScreen }), ...(deps.chat.maxMessages === undefined ? {} : { chatMaxMessages: deps.chat.maxMessages }), @@ -1786,6 +1958,8 @@ async function buildFreshChatWiring(deps: FreshChatWiringDeps, intro: string): P * the {@link clearedNotice} naming the prior (still-resumable) session. */ function createClearRebuild(params: { + readonly homeDir: string; + readonly ownership?: SessionChatLifetime; readonly chat: BuildChatSessionOptions['chat']; readonly fsPolicy?: FsPathPolicy; readonly nativeProcesses?: NativeProcessController; @@ -1806,6 +1980,7 @@ function createClearRebuild(params: { readonly altScreen?: boolean | undefined; }): (oldSessionId: string) => Promise { const wiringDeps: FreshChatWiringDeps = { + homeDir: params.homeDir, chat: params.chat, ...(params.fsPolicy === undefined ? {} : { fsPolicy: params.fsPolicy }), ...(params.nativeProcesses === undefined ? {} : { nativeProcesses: params.nativeProcesses }), @@ -1833,7 +2008,12 @@ function createClearRebuild(params: { onListenerError: (note: string) => emitLiveNotice(params.io, note), onUnpriced: (note) => emitLiveNotice(params.io, note), }; - return (oldSessionId) => buildFreshChatWiring(wiringDeps, clearedNotice(oldSessionId)); + return (oldSessionId) => + params.ownership === undefined + ? buildFreshChatWiring(wiringDeps, clearedNotice(oldSessionId)) + : params.ownership.hosts.construct((host) => + buildFreshChatWiring(wiringDeps, clearedNotice(oldSessionId), host), + ); } /** @@ -1877,26 +2057,29 @@ function seedResumedWiring( }, transcriptBound, ); - const persister = createSessionPersister({ - usageStore: createSessionUsageStore(opened.db), - mode: 'adopt', - attachUsageWriter: resumed.attachUsageWriter, - governor: resumed.governor, - attachDurabilityProbe: resumed.attachDurabilityProbe, - attachEffectTurnAllocator: resumed.attachEffectTurnAllocator, - - store: opened.store, - handle: resumed.handle, - sessionId: resumed.sessionId, - agent: resumed.agent, - context: resumed.context, - now, - uuid, - // Continue the durable transcript past its last sequence number (start() adopts the row + its totals). - initialSequenceNumber: resumed.nextSequenceNumber, - // ADR-0059 attribution — resolved over the SAME db; a reseat's new persister records the switched model. - resolveModelCatalogId: makeCatalogIdResolver(opened.db, { uuid, now }), - }); + const persister = createSessionPersister( + { + usageStore: createSessionUsageStore(opened.db), + mode: 'adopt', + attachUsageWriter: resumed.attachUsageWriter, + governor: resumed.governor, + attachDurabilityProbe: resumed.attachDurabilityProbe, + attachEffectTurnAllocator: resumed.attachEffectTurnAllocator, + + store: opened.store, + handle: resumed.handle, + sessionId: resumed.sessionId, + agent: resumed.agent, + context: resumed.context, + now, + uuid, + // Continue the durable transcript past its last sequence number (start() adopts the row + its totals). + initialSequenceNumber: resumed.nextSequenceNumber, + // ADR-0059 attribution — resolved over the SAME db; a reseat's new persister records the switched model. + resolveModelCatalogId: makeCatalogIdResolver(opened.db, { uuid, now }), + }, + resumed.hostOwner, + ); // The durable effect journal (ADR-0080), wired where `history.db` is open. A site that forgets is NOT // silent: the forwarding port refuses the first effect loudly, which is exactly why // `unwiredEffectJournal()` rejects instead of no-opping. @@ -1913,6 +2096,7 @@ function seedResumedWiring( * over the SHARED db. Mirrors {@link FreshChatWiringDeps} but RESUMES (carries the transcript) instead of starting * fresh, and swaps the bound model (so it has no fixed `agent` — the agent is loaded from the just-ended record). */ interface ReseatWiringDeps { + readonly homeDir: string; readonly chat: BuildChatSessionOptions['chat']; readonly fsPolicy?: FsPathPolicy; readonly nativeProcesses?: NativeProcessController; @@ -1957,6 +2141,7 @@ async function buildReseatWiring( /** The OUTGOING store's rendered transcript (2.6.C / F1) — what the reseated store opens with, so the full-screen * viewport does not go blank. The gate drops it on the inline renderer (its lines are already printed). */ carriedTranscript: readonly TranscriptEntry[], + host?: SessionHostOwner, ): Promise { const loaded = deps.opened.store.loadFull(oldSessionId); if (loaded === undefined || loaded.session.agentSnapshot === undefined) { @@ -1982,25 +2167,36 @@ async function buildReseatWiring( // Re-read the user-pricing overlay FRESH from the shared db (mirrors buildFreshChatWiring) so a mid-session // `models pricing` write applies to the reseated session. Empty ⇒ omitted (unknown models degrade to allow). const resolvePrice = readUserPricingOverlay(deps.opened.db); - const resumed = await deps.buildResumedSession({ - chat: deps.chat, - ...(deps.fsPolicy === undefined ? {} : { fsPolicy: deps.fsPolicy }), - ...(deps.nativeProcesses === undefined ? {} : { nativeProcesses: deps.nativeProcesses }), - ...(deps.consentGate === undefined ? {} : { consentGate: deps.consentGate }), - ...(deps.maxTokensEstimate === undefined ? {} : { maxTokensEstimate: deps.maxTokensEstimate }), - record, - messages: loaded.messages, - now: deps.now, - providers: deps.providers, - mcpSecretResolver: deps.mcpSecretResolver, - ...(deps.mcpRegistrations === undefined ? {} : { mcpRegistrations: deps.mcpRegistrations }), - ...(resolvePrice.size === 0 ? {} : { resolvePrice }), - onBudgetWarning: deps.onBudgetWarning, - ...(deps.onCompactionStart === undefined ? {} : { onCompactionStart: deps.onCompactionStart }), - onEffortWithheld: deps.onEffortWithheld, - onListenerError: deps.onListenerError, - onUnpriced: deps.onUnpriced, - }); + const build = (workOwner?: SessionWorkOwner) => + deps.buildResumedSession({ + ...(workOwner === undefined ? {} : { workOwner }), + chat: deps.chat, + ...(deps.fsPolicy === undefined ? {} : { fsPolicy: deps.fsPolicy }), + ...(deps.nativeProcesses === undefined ? {} : { nativeProcesses: deps.nativeProcesses }), + ...(workOwner === undefined + ? deps.consentGate === undefined + ? {} + : { consentGate: deps.consentGate } + : { consentGate: createOwnedSessionConsentGate(deps, workOwner) }), + ...(deps.maxTokensEstimate === undefined + ? {} + : { maxTokensEstimate: deps.maxTokensEstimate }), + record, + messages: loaded.messages, + now: deps.now, + providers: deps.providers, + mcpSecretResolver: deps.mcpSecretResolver, + ...(deps.mcpRegistrations === undefined ? {} : { mcpRegistrations: deps.mcpRegistrations }), + ...(resolvePrice.size === 0 ? {} : { resolvePrice }), + onBudgetWarning: deps.onBudgetWarning, + ...(deps.onCompactionStart === undefined + ? {} + : { onCompactionStart: deps.onCompactionStart }), + onEffortWithheld: deps.onEffortWithheld, + onListenerError: deps.onListenerError, + onUnpriced: deps.onUnpriced, + }); + const resumed = host === undefined ? await build() : await host.build(build); let seeded: { store: ChatStoreController; persister: SessionPersister }; try { seeded = seedResumedWiring( @@ -2015,7 +2211,8 @@ async function buildReseatWiring( } catch (err) { // Acquire-then-guard: the resumed session's MCP children are already spawned — reclaim them before the failure // propagates so a persister-construction throw never orphans a stdio child (best-effort; never mask the primary). - await resumed.closeMcp?.().catch(() => undefined); + if (host !== undefined) host.work.failure(err); + else await resumed.closeMcp?.().catch(() => undefined); throw err; } // A reseat re-drive runs INSIDE the hoisted alt buffer — route the MCP-skipped diagnostic to the reseated session's @@ -2058,6 +2255,7 @@ async function buildReseatWiring( resumed.agent.model, deps.now, deps.uuid, + resumed.workOwner, ), ...(deps.altScreen === undefined ? {} : { altScreen: deps.altScreen }), ...(deps.chat.maxMessages === undefined ? {} : { chatMaxMessages: deps.chat.maxMessages }), @@ -2071,6 +2269,8 @@ async function buildReseatWiring( * under a new-model agent over the SHARED db. */ function createReseatRebuild(params: { + readonly homeDir: string; + readonly ownership?: SessionChatLifetime; readonly chat: BuildChatSessionOptions['chat']; readonly fsPolicy?: FsPathPolicy; readonly nativeProcesses?: NativeProcessController; @@ -2093,6 +2293,7 @@ function createReseatRebuild(params: { carriedTranscript: readonly TranscriptEntry[], ) => Promise { const wiringDeps: ReseatWiringDeps = { + homeDir: params.homeDir, chat: params.chat, ...(params.fsPolicy === undefined ? {} : { fsPolicy: params.fsPolicy }), ...(params.nativeProcesses === undefined ? {} : { nativeProcesses: params.nativeProcesses }), @@ -2118,7 +2319,11 @@ function createReseatRebuild(params: { onUnpriced: (note) => emitLiveNotice(params.io, note), }; return (oldSessionId, target, carriedTranscript) => - buildReseatWiring(wiringDeps, oldSessionId, target, carriedTranscript); + params.ownership === undefined + ? buildReseatWiring(wiringDeps, oldSessionId, target, carriedTranscript) + : params.ownership.hosts.construct((host) => + buildReseatWiring(wiringDeps, oldSessionId, target, carriedTranscript, host), + ); } /** The `!`-shell runner (2.5.D step 5, ADR-0061) — a thin wrapper over the session's `runUserCommand`, wired ONLY on @@ -2128,7 +2333,10 @@ function buildInteractiveShellRunner( built: ReplWiring['built'], ): ((command: string, args: readonly string[]) => Promise) | undefined { if (!interactive) return undefined; - return (command, args) => built.session.runUserCommand(command, args); + const workOwner = built.workOwner; + return workOwner === undefined + ? (command, args) => built.session.runUserCommand(command, args) + : (command, args) => workOwner.enter(() => built.session.runUserCommand(command, args)); } /** @@ -2198,6 +2406,22 @@ async function driveOneSession(wiring: ReplWiring, deps: ChatReplDeps): Promise< const runShellCommand = buildInteractiveShellRunner(interactive, built); // Captured once: `deps.hatchPorts` is the per-REPL port bundle that also owns `writeControl`. const hatchPortsForClipboard = deps.hatchPorts; + const activate = async (isActive: () => boolean, flushNotice?: NoticeFlush): Promise => { + liveCompactionNotice = interactive + ? async (text, isCancelled) => { + if (isCancelled()) return; + if (!isActive() || flushNotice === undefined) + throw new Error('The chat renderer is not ready.'); + await flushNotice(() => { + if (isCancelled()) return; + if (!isActive()) throw new Error('The chat renderer is not ready.'); + store.notice(text); + }); + if (!isCancelled() && !isActive()) throw new Error('The chat renderer is not ready.'); + } + : undefined; + await wiring.onActivated?.(isActive, flushNotice); + }; // persister.start() subscribes for the turn events + adopts/inserts the session row; it does NOT consume // session:started, so it is safe before the driver. The session-open action (fresh start() / resume no-op) @@ -2210,23 +2434,19 @@ async function driveOneSession(wiring: ReplWiring, deps: ChatReplDeps): Promise< // plain / `--json` drivers the sink stays `undefined` so the warning keeps reaching stderr. Cleared in the finally. liveSessionNotice = liveNoticeSinkFor(interactive, store); const outcome = await (deps.drive ?? drivePlain)({ + ...(deps.chatLifetime === undefined + ? {} + : { subscribeInterrupt: deps.chatLifetime.subscribeInterrupt }), + ...(built.workOwner === undefined ? {} : { workOwner: built.workOwner }), + ...(deps.finishJobControl === undefined ? {} : { finishJobControl: deps.finishJobControl }), + ...(deps.adoptTerminalMount === undefined + ? {} + : { adoptTerminalMount: deps.adoptTerminalMount }), startSession, - onActivated: async (isActive, flushNotice) => { - liveCompactionNotice = interactive - ? async (text, isCancelled) => { - if (isCancelled()) return; - if (!isActive() || flushNotice === undefined) - throw new Error('The chat renderer is not ready.'); - await flushNotice(() => { - if (isCancelled()) return; - if (!isActive()) throw new Error('The chat renderer is not ready.'); - store.notice(text); - }); - if (!isCancelled() && !isActive()) throw new Error('The chat renderer is not ready.'); - } - : undefined; - await wiring.onActivated?.(isActive, flushNotice); - }, + onActivated: (isActive, flushNotice) => + built.workOwner === undefined + ? activate(isActive, flushNotice) + : built.workOwner.enter(() => activate(isActive, flushNotice)), processLine, shouldStop, stopReason, @@ -2271,13 +2491,21 @@ async function driveOneSession(wiring: ReplWiring, deps: ChatReplDeps): Promise< : { clipboard: (text: string): ClipboardOutcome => copyToClipboard( - { writeControl: hatchPortsForClipboard.writeControl, env: deps.io.env }, + { + writeControl: (sequence) => { + void hatchPortsForClipboard.writeControl(sequence); + }, + env: deps.io.env, + }, text, ), }), }); // A `/models` reseat attaches the captured target here (the one place holding the line handler); see the helper. return finalizeReseatOutcome(outcome, reseatTarget); + } catch (cause) { + built.workOwner?.failure(cause); + throw cause; } finally { liveCompactionNotice = undefined; liveSessionNotice = undefined; // the session is ending — never route a notice to a torn-down store @@ -2285,14 +2513,22 @@ async function driveOneSession(wiring: ReplWiring, deps: ChatReplDeps): Promise< // Attempt EVERY teardown step (a reject in one must not skip the next) and never let a cleanup fault mask the // outcome — each is best-effort, surfacing a warning rather than throwing. MCP tears down LAST, AFTER the // session terminal, so no tool call can race the close (idempotent; present only with `mcp_servers`). - try { - await built.whenHostSafeToClose(); - } finally { - // The lifetime join finishes before its durability verdict can reject. A failed final write - // must still release the now-quiescent persister and MCP clients. - closeQuietly(deps.io, 'persister', () => persister.close()); - await built.closeMcp?.().catch(() => warnTeardown(deps.io, 'MCP')); - } + if (built.hostOwner !== undefined) { + try { + await deps.afterTerminalRelease?.(); + } catch (cause) { + built.workOwner?.cleanupUncertain(cause); + } + await built.hostOwner.work.close(); + } else + try { + await built.whenHostSafeToClose(); + } finally { + // The lifetime join finishes before its durability verdict can reject. A failed final write + // must still release the now-quiescent persister and MCP clients. + closeQuietly(deps.io, 'persister', () => persister.close()); + await built.closeMcp?.().catch(() => warnTeardown(deps.io, 'MCP')); + } } } @@ -2365,8 +2601,8 @@ export async function withHoistedAltScreen( readonly mouse?: boolean; readonly write: (sequence: string) => void; readonly lifecycle: ReplLifecycle; - readonly writeOut: (text: string) => void; - readonly writeErr: (text: string) => void; + readonly writeOut: (text: string) => void | Promise; + readonly writeErr: (text: string) => void | Promise; /** * Permanently disarm a reversible terminal handoff before this wrapper restores its final terminal state. The * job-control coordinator intentionally leaves a pending SIGCONT waiter unresolved on an external termination: @@ -2376,15 +2612,19 @@ export async function withHoistedAltScreen( /** The ONE suspend port for the loop. `current() === undefined` means NO ink tree is mounted — the only window in * which SIGINT is unowned. Absent ⇒ no SIGINT net (a caller with no ink, e.g. a unit test). */ readonly suspendPort?: SuspendPort; + readonly externallyOwnedSignals?: boolean; + readonly terminalOwner?: SessionChatTerminal; }, runLoop: (alt: AltScreenController) => Promise, ): Promise { const noop = (): void => undefined; - const alt = createAltScreenController({ - write: opts.write, - active: opts.active, - ...(opts.mouse === undefined ? {} : { mouse: opts.mouse }), - }); + const alt = + opts.terminalOwner?.alt ?? + createAltScreenController({ + write: opts.write, + active: opts.active, + ...(opts.mouse === undefined ? {} : { mouse: opts.mouse }), + }); // The removers are `let`, and `alt.enter()` runs INSIDE the try: `enter()`'s write can throw on a dead TTY, and the // `finally` must still run with valid bindings (a `const` declared after a throwing `enter` would be in the temporal // dead zone). `restore()` is a no-op when nothing was entered, and each remover defaults to `noop`. @@ -2394,13 +2634,15 @@ export async function withHoistedAltScreen( const suspendPortForSigint = opts.suspendPort; let result: HoistedLoopResult = {}; try { - alt.enter(); - removeExitNet = opts.active - ? opts.lifecycle.onProcessExit(() => { - opts.beforeTerminalRestore?.(); - alt.restore(); - }) - : noop; + if (opts.terminalOwner === undefined) alt.enter(); + else opts.terminalOwner.enter(opts.lifecycle.onProcessExit); + removeExitNet = + opts.terminalOwner === undefined && opts.active + ? opts.lifecycle.onProcessExit(() => { + opts.beforeTerminalRestore?.(); + alt.restore(); + }) + : noop; // SIGINT belongs to ink: while a tree is mounted, `driveInk`'s `onSigintGated` runs the cooperative `/cancel`, and // during a `/scrollback` or `/edit` hatch it deliberately DROPS the signal so the suspension can reclaim. But a // `/clear` or `/models` rebuild unmounts ink and mounts a fresh tree, and in that window NOTHING listens for @@ -2408,7 +2650,7 @@ export async function withHoistedAltScreen( // runs and the alt buffer, mouse reporting and the hidden cursor are stranded on the user's shell. This net covers // exactly that window, and defers to ink whenever a tree is attached. removeInterruptNet = - opts.active && suspendPortForSigint !== undefined + opts.externallyOwnedSignals !== true && opts.active && suspendPortForSigint !== undefined ? opts.lifecycle.onInterrupt(() => { if (suspendPortForSigint.current() !== undefined) return; // ink owns it (mounted, or suspended) opts.beforeTerminalRestore?.(); @@ -2422,19 +2664,20 @@ export async function withHoistedAltScreen( opts.lifecycle.exit(130); // conventional 128 + SIGINT(2) }) : noop; - removeSignalNet = opts.active - ? opts.lifecycle.onTerminationSignal((signo) => { - opts.beforeTerminalRestore?.(); - alt.restore(); - opts.write(DISABLE_BRACKETED_PASTE); - try { - opts.lifecycle.setRawMode(false); - } catch { - // best-effort — a non-TTY / already-cooked stdin must not mask the exit - } - opts.lifecycle.exit(128 + signo); // conventional 143 (SIGTERM) / 130+ (SIGHUP/SIGQUIT) - }) - : noop; + removeSignalNet = + opts.externallyOwnedSignals !== true && opts.active + ? opts.lifecycle.onTerminationSignal((signo) => { + opts.beforeTerminalRestore?.(); + alt.restore(); + opts.write(DISABLE_BRACKETED_PASTE); + try { + opts.lifecycle.setRawMode(false); + } catch { + // best-effort — a non-TTY / already-cooked stdin must not mask the exit + } + opts.lifecycle.exit(128 + signo); // conventional 143 (SIGTERM) / 130+ (SIGHUP/SIGQUIT) + }) + : noop; result = await runLoop(alt); } finally { // Exit the alt buffer FIRST (restores the primary buffer + scrollback), THEN emit the summary / error, so BOTH @@ -2442,13 +2685,16 @@ export async function withHoistedAltScreen( // DECRST-1049 restore (the Step-4b-3 Opus-review regression: the rebuild-failure `chat-resume ` hint vanished // once alt became the default). `restore()` is idempotent — the `onProcessExit` net may also have fired. Remove // the nets so they cannot outlive the loop. - opts.beforeTerminalRestore?.(); - alt.restore(); + if (opts.terminalOwner !== undefined) await opts.terminalOwner.close(); + else { + opts.beforeTerminalRestore?.(); + alt.restore(); + } removeExitNet(); removeSignalNet(); removeInterruptNet(); - if (result.summaryText !== undefined) opts.writeOut(`${result.summaryText}\n`); - if (result.errorText !== undefined) opts.writeErr(result.errorText); + if (result.summaryText !== undefined) await opts.writeOut(`${result.summaryText}\n`); + if (result.errorText !== undefined) await opts.writeErr(result.errorText); } } @@ -2503,46 +2749,93 @@ export async function runReplLoop( noMouseFlag: deps.global.noMouse === true, configMouse: wiring.mouse, }); + const terminalOwner = + deps.chatLifetime === undefined || !chatIsInteractive(deps.io, deps.global) + ? undefined + : new SessionChatTerminal({ + lifetime: deps.chatLifetime, + io: deps.io, + active: altActive, + mouse: mouseEnabled, + }); const writeControl = + terminalOwner?.writeControl ?? deps.writeControl ?? ((sequence: string): void => { - process.stdout.write(sequence); + if (deps.chatLifetime === undefined) process.stdout.write(sequence); + else { + if (deps.io.nativeStdout === undefined) throw new CliError('cli_internal', ''); + deps.io.nativeStdout.write(sequence); + } }); // ONE suspend port for the whole REPL: ink remounts per session (a `/clear` / reseat re-drive), and each mount // re-attaches. The hatch ports read the terminal facts at CALL time — `alt.isEntered()` rather than the resolved // `altActive`, because a hatch must reflect the buffer's LIVE state, not the mode we resolved at startup. - const suspendPort = deps.hatchPorts?.suspendPort ?? createSuspendPort(); - const hatchPorts: Omit = deps.hatchPorts ?? { - suspendPort, - writeControl, - // The factory's NAME says which surface it is for; `inkOwnsAltScreen` is decided there, once, never at a call site. - // Both predicates read the hoisted controller LIVE, so a hatch reflects the terminal's real state — not the startup - // decision. `mouseActive` is asked separately from `altActive` because `--no-mouse` decouples them (Step 5e). - terminal: hoistedTerminal( - () => altScreenController?.isEntered() ?? false, - () => altScreenController?.isMouseEnabled() ?? false, - () => process.stdout.columns, - ), - dump: { - writeOut: nodeWriteOut(process.stdout), - waitForContinue: nodeWaitForContinue(process.stdin), - }, - // `/copy` writes OSC 52 through the SAME control sink the alt-buffer and mouse toggles use. - clipboard: (text: string) => copyToClipboard({ writeControl, env: deps.io.env }, text), - editor: { - env: deps.io.env, - spawnEditor: createNodeSpawnEditor(nativeProcessesForInvocation(deps.invocation)), - createTempDocument: nodeCreateTempDocument, - onDisposeFailed: (path, error) => { - deps.io.writeErr( - `warning: transcript temp file teardown failed (${path}): ${ - error instanceof Error ? error.message : String(error) - }\n`, - ); - }, - }, - }; + const suspendPort = + deps.chatLifetime?.suspendPort ?? deps.hatchPorts?.suspendPort ?? createSuspendPort(); + const hatchPorts: Omit = + deps.hatchPorts === undefined + ? { + suspendPort, + writeControl, + // The factory's NAME says which surface it is for; `inkOwnsAltScreen` is decided there, once, never at a call site. + // Both predicates read the hoisted controller LIVE, so a hatch reflects the terminal's real state — not the startup + // decision. `mouseActive` is asked separately from `altActive` because `--no-mouse` decouples them (Step 5e). + terminal: hoistedTerminal( + () => altScreenController?.isEntered() ?? false, + () => altScreenController?.isMouseEnabled() ?? false, + () => + deps.chatLifetime === undefined + ? process.stdout.columns + : deps.io.nativeStdout?.columns, + ), + dump: { + writeOut: + deps.chatLifetime === undefined + ? nodeWriteOut(process.stdout) + : (text) => deps.io.writeOutAcknowledged(text), + waitForContinue: + deps.chatLifetime === undefined + ? nodeWaitForContinue(process.stdin) + : () => { + if (deps.io.nativeStdin === undefined) throw new CliError('cli_internal', ''); + return nodeWaitForContinue( + deps.io.nativeStdin, + process, + deps.chatLifetime?.surface.work.signal, + )(); + }, + }, + // `/copy` writes OSC 52 through the SAME control sink the alt-buffer and mouse toggles use. + clipboard: (text: string) => copyToClipboard({ writeControl, env: deps.io.env }, text), + editor: { + env: deps.io.env, + spawnEditor: createNodeSpawnEditor(nativeProcessesForInvocation(deps.invocation)), + createTempDocument: nodeCreateTempDocument, + onDisposeFailed: (path, error) => { + if (deps.chatLifetime !== undefined) { + const work = deps.chatLifetime.surface.work; + work.cleanupUncertain(error); + if (work.invocation.registry.budget.remainingMs() !== 0) + void work.work + .retainWork(() => + deps.io.writeErrAcknowledged( + 'warning: transcript temporary cleanup could not be acknowledged.\n', + ), + ) + .catch(work.failure); + return; + } + deps.io.writeErr( + `warning: transcript temp file teardown failed (${path}): ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + }, + }, + } + : { ...deps.hatchPorts, suspendPort }; // The hoisted controller, captured so `hatchPorts.terminal()` can read the LIVE buffer state (it is created inside // `withHoistedAltScreen` below, after `hatchPorts` is built — hence the mutable binding read lazily). let altScreenController: AltScreenController | undefined; @@ -2556,7 +2849,12 @@ export async function runReplLoop( mouseEnabled, configCopyOnSelect: wiring.copyOnSelect, }); - const replDeps: ChatReplDeps = { ...deps, hatchPorts, copyOnSelect }; + const replDeps: ChatReplDeps = { + ...deps, + hatchPorts, + copyOnSelect, + ...(terminalOwner === undefined ? {} : { adoptTerminalMount: terminalOwner.adoptMount }), + }; try { await withHoistedAltScreen( @@ -2565,48 +2863,83 @@ export async function runReplLoop( mouse: mouseEnabled, write: writeControl, lifecycle: deps.lifecycle ?? defaultReplLifecycle, - writeOut: (text) => deps.io.writeOut(text), - writeErr: (text) => deps.io.writeErr(text), + writeOut: (text) => + terminalOwner === undefined ? deps.io.writeOut(text) : terminalOwner.publishOut(text), + writeErr: (text) => + terminalOwner === undefined ? deps.io.writeErr(text) : terminalOwner.publishErr(text), beforeTerminalRestore: () => disposeJobControl(), suspendPort, + ...(deps.chatLifetime === undefined ? {} : { externallyOwnedSignals: true }), + ...(terminalOwner === undefined ? {} : { terminalOwner }), }, async (alt): Promise => { altScreenController = alt; // so `hatchPorts.terminal()` reads the LIVE buffer state, not the startup decision // The raw-mode Ink surface needs job control even in inline render mode; only plain/non-TTY drivers omit // these listeners. During a `/clear`/reseat gap there is no Ink tree, so the hoisted controller supplies its // reversible 1049/mouse release instead of calling its FINAL `restore()`. - const jobControl = chatIsInteractive(deps.io, deps.global) - ? wireJobControl({ - suspendPort, - lifecycle: deps.jobControlLifecycle ?? defaultJobControlLifecycle, - run: (suspendTerminal, waitForContinue) => - suspendFullScreen( - { - suspendTerminal, - writeControl, - inkOwnsAltScreen: false, - altActive: alt.isEntered(), - mouseActive: alt.isMouseEnabled(), - }, - waitForContinue, - ), - releaseWithoutInk: () => alt.releaseForSuspend(), - reclaimWithoutInk: () => alt.reclaimAfterSuspend(), - }) - : undefined; - disposeJobControl = jobControl?.dispose ?? (() => undefined); - const sessionDeps = - jobControl === undefined - ? replDeps - : { ...replDeps, onSuspend: jobControl.requestSuspend }; + const createJobControl = () => + chatIsInteractive(deps.io, deps.global) + ? wireJobControl({ + suspendPort, + ...(terminalOwner === undefined + ? {} + : { + adopt: terminalOwner.adoptJob, + onFailure: (cause: unknown) => + deps.chatLifetime?.surface.work.cleanupUncertain(cause), + }), + lifecycle: deps.jobControlLifecycle ?? defaultJobControlLifecycle, + run: (suspendTerminal, waitForContinue) => + suspendFullScreen( + { + suspendTerminal, + writeControl, + inkOwnsAltScreen: false, + altActive: alt.isEntered(), + mouseActive: alt.isMouseEnabled(), + }, + waitForContinue, + ), + releaseWithoutInk: () => alt.releaseForSuspend(), + reclaimWithoutInk: () => alt.reclaimAfterSuspend(), + ...(terminalOwner === undefined + ? {} + : { + releaseWithoutInkAcknowledged: terminalOwner.releaseForSuspend, + reclaimWithoutInkAcknowledged: terminalOwner.reclaimAfterSuspend, + }), + }) + : undefined; + let jobControl = createJobControl(); + disposeJobControl = () => jobControl?.dispose(); try { let current = wiring; let summaryText: string | undefined; // the final `/exit` summary, printed after the single alt-exit for (;;) { + const mountedJob = jobControl; + const afterTerminalRelease = async (): Promise => { + await mountedJob?.finish(); + // Install gap custody BEFORE driveOneSession waits on host receipts/MCP cleanup. + // A terminating invocation never creates a new native signal registration. + if (deps.chatLifetime?.surface.invocation.registry.termination !== undefined) return; + deps.chatLifetime?.surface.work.assertAdmitting(); + jobControl = createJobControl(); + }; + const sessionDeps = + mountedJob === undefined + ? replDeps + : { + ...replDeps, + onSuspend: mountedJob.requestSuspend, + ...(terminalOwner === undefined + ? {} + : { finishJobControl: mountedJob.finish, afterTerminalRelease }), + }; const outcome = await driveOneSession(current, sessionDeps); // The end-of-session summary is LIFTED out of driveInk (ADR-0068 §c): the wrapper prints it after the single // alt-exit, on the PRIMARY buffer. Only a final `'exit'` carries one; a `/clear` / reseat swap carries none. if (outcome.kind === 'exit') summaryText = outcome.summaryText; + if (deps.chatLifetime?.interrupted === true) return { summaryText }; // The old session is ALREADY torn down (driveOneSession's finally fired its terminal → the row is 'ended' + // resumable). Resolve the rebuild for this swap kind, or `undefined` to END the REPL (see resolveSwapRebuild). const oldSessionId = current.built.sessionId; @@ -2630,18 +2963,28 @@ export async function runReplLoop( try { current = await next(); } catch (err) { + if (deps.chatLifetime !== undefined) { + deps.chatLifetime.surface.work.failure(err); + if (toUserFacing(err).exitCode === EXIT_CODES.infrastructureFailed) throw err; + } const errorText = // Sanitize the error text too (not just the id beside it) — a rebuild fault can rethrow an unclassified // message verbatim (session-host.ts), which could carry an ANSI/OSC escape from a spawned MCP server's // error text; strip it exactly as the id + every other display string on this surface is stripped. `could not start a new session after ${outcome.kind === 'reseat' ? 'a model switch' : '/clear'}: ` + - `${sanitizeInline(err instanceof Error ? err.message : String(err))}. ` + + `${sanitizeInline( + deps.chatLifetime === undefined + ? err instanceof Error + ? err.message + : String(err) + : toUserFacing(err).message, + )}. ` + `Your previous conversation is saved — resume it with \`relavium chat-resume ${sanitizeInline(oldSessionId)}\`.\n`; return { summaryText, errorText }; } } } finally { - jobControl?.dispose(); + if (terminalOwner === undefined) jobControl?.dispose(); disposeJobControl = (): void => undefined; } }, @@ -2649,7 +2992,8 @@ export async function runReplLoop( } finally { // The shared db closes exactly once, AFTER the alt-exit + summary — never per-swap (that would strand the next // session), never skipped (best-effort; a close fault warns rather than masking the loop outcome). - closeQuietly(deps.io, 'session store', () => opened.close()); + if (deps.chatLifetime === undefined) + closeQuietly(deps.io, 'session store', () => opened.close()); } // `/exit`, `/cancel`, an input EOF, and a `/clear` whose rebuild failed all END the chat — the canonical code. return EXIT_CODES.chatEnded; @@ -2673,14 +3017,34 @@ export async function drivePlain(ctx: ChatDriveContext): Promise((resolve) => { interrupt = resolve; }); - const onSigint = (): void => { + const stopInput = (): void => { active = false; interrupt(); rl.close(); }; - process.once('SIGINT', onSigint); + const onSigint = (): void => { + if (!active) { + ctx.workOwner?.invocation.finiteExit.cooperativeCancel(EXIT_CODES.chatEnded); + return; + } + stopInput(); + ctx.finalize?.(); + }; + let removeInterrupt = (): void => undefined; try { + removeInterrupt = + ctx.subscribeInterrupt === undefined + ? (() => { + process.once('SIGINT', onSigint); + return () => { + process.removeListener('SIGINT', onSigint); + }; + })() + : ctx.subscribeInterrupt(onSigint); + ctx.workOwner?.signal.addEventListener('abort', stopInput, { once: true }); + if (ctx.workOwner?.signal.aborted === true) stopInput(); ctx.io.writeOut(`${ctx.intro ?? 'relavium chat — type a message, or /exit to quit.'}\n`); + ctx.workOwner?.assertAdmitting(); ctx.startSession(); // subscription wired above ⇒ session:started is observed (fresh), or a no-op (resume) // A full output pipe must not hold cancellation/teardown hostage. The race observes late // delivery failures; the permanently disarmed activity predicate prevents a late sweep. @@ -2694,7 +3058,8 @@ export async function drivePlain(ctx: ChatDriveContext): Promise((resolve) => { interrupt = resolve; }); - const onSigint = (): void => { + const stopInput = (): void => { active = false; interrupt(); rl.close(); }; - process.once('SIGINT', onSigint); + const onSigint = (): void => { + if (!active) { + ctx.workOwner?.invocation.finiteExit.cooperativeCancel(EXIT_CODES.chatEnded); + return; + } + stopInput(); + ctx.finalize?.(); + }; + let removeInterrupt = (): void => undefined; try { + removeInterrupt = + ctx.subscribeInterrupt === undefined + ? (() => { + process.once('SIGINT', onSigint); + return () => { + process.removeListener('SIGINT', onSigint); + }; + })() + : ctx.subscribeInterrupt(onSigint); + ctx.workOwner?.signal.addEventListener('abort', stopInput, { once: true }); + if (ctx.workOwner?.signal.aborted === true) stopInput(); + ctx.workOwner?.assertAdmitting(); ctx.startSession(); // subscription wired above ⇒ the synchronous session:started is the first NDJSON line // Keep terminal finalization and resource teardown independent of a blocked diagnostic sink. // Late activation settlement stays observed and cannot recover this driver's activity. @@ -2742,7 +3127,8 @@ export async function driveJson(ctx: ChatDriveContext): Promise { expect(io.out()).not.toContain(RAW_KEY); }); + it('rechecks invocation admission after secret read before any credential or database mutation', async () => { + const keychain = memKeychain(); + let stopped = false; + const d = deps({ + keychain, + readSecret: () => { + stopped = true; + return Promise.resolve(RAW_KEY); + }, + assertAdmitting: () => { + if (stopped) throw new CliError('cli_internal', ''); + }, + }); + await expect( + runProviderCommand({ action: 'set-key', name: 'anthropic' }, d), + ).rejects.toMatchObject({ code: 'cli_internal' }); + expect(keychain.map.size, 'ONBOARDING_POST_SECRET_READ_ADMISSION').toBe(0); + expect(d.store.get('anthropic')).toBeUndefined(); + expect(io.out()).toBe(''); + }); + it('list shows registered providers and whether a key is set (no key echoed)', async () => { const keychain = memKeychain(); const d = deps({ keychain }); diff --git a/apps/cli/src/commands/provider.ts b/apps/cli/src/commands/provider.ts index 2f2e6568..f119b3e0 100644 --- a/apps/cli/src/commands/provider.ts +++ b/apps/cli/src/commands/provider.ts @@ -48,6 +48,8 @@ export interface ProviderCommandArgs { } export interface ProviderCommandDeps { + /** Recheck original invocation admission after asynchronous secret reading, before credential/DB writes. */ + readonly assertAdmitting?: () => void; readonly io: CliIo; readonly store: ProviderStore; readonly keychain: KeychainStore; @@ -276,6 +278,7 @@ async function providerSetKey(args: ProviderCommandArgs, deps: ProviderCommandDe // a single pasted API key, by contrast, routinely arrives with a stray newline or space from a dashboard // copy, and the onboarding wizard already trims its own value to persist a byte-identical credential. const key = (await deps.readSecret()).trim(); // from stdin — never an argv flag + deps.assertAdmitting?.(); const account = keychainAccount(id); deps.keychain.set(account, key); // KeychainUnavailableError surfaces (no silent plaintext fallback) // Register the row only if it's new — never overwrite a base URL the user set via `provider add --base-url`. diff --git a/apps/cli/src/engine/model-catalog-port.test.ts b/apps/cli/src/engine/model-catalog-port.test.ts index 6fcaf16c..d6ed161d 100644 --- a/apps/cli/src/engine/model-catalog-port.test.ts +++ b/apps/cli/src/engine/model-catalog-port.test.ts @@ -1,5 +1,10 @@ import { createClient, runMigrations, type DbClient } from '@relavium/db'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { LlmProvider, ModelListing } from '@relavium/llm'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { CHAT_TEXT_CAPABILITY_FLAGS } from '../test-support.js'; +import { SessionHomeLifetime } from '../home/session-home-lifetime.js'; +import { CliError } from '../process/cli-error.js'; import { createModelCatalogPort } from './model-catalog-port.js'; import type { ProviderResolver } from './providers.js'; @@ -29,6 +34,153 @@ describe('createModelCatalogPort', () => { hasKey: () => hasKey, }); + it('retains the existing recoverable cache-read policy while joining its actual work', async () => { + const f = invocationFixture(); + const home = new SessionHomeLifetime(f.invocation, () => () => undefined); + const close = vi.fn(); + home.adoptStore({ close }); + const port = createModelCatalogPort({ + db: client.db, + providers: resolver(true), + now: () => 0, + uuid: () => 'synthetic-read', + workOwner: home.surface.work, + }); + const cause = new Error('PRIVATE_OPTIONAL_CACHE_READ'); + const read = vi.spyOn(client.sqlite, 'prepare').mockImplementation(() => { + throw cause; + }); + try { + const drive = home.execute(async () => { + await expect(port.refreshIfStale()).rejects.toBe(cause); + expect(f.registry.observations.exitCode(), 'CATALOG_OPTIONAL_READ_STAYS_RECOVERABLE').toBe( + 0, + ); + return 0; + }); + expect(await drive).toBe(0); + expect(close).toHaveBeenCalledOnce(); + } finally { + read.mockRestore(); + } + }); + + it.each(['clock', 'store'] as const)( + 'observes a local %s failure before a held sibling discovery settles', + async (kind) => { + const f = invocationFixture(); + const home = new SessionHomeLifetime(f.invocation, () => () => undefined); + const close = vi.fn(); + home.adoptStore({ close }); + const primary = new Error('PRIVATE_CATALOG_LOCAL_FAILURE'); + let finish: (models: ModelListing[]) => void = () => undefined; + const listing = new Promise((resolve) => { + finish = resolve; + }); + const provider = (id: 'anthropic' | 'openai'): LlmProvider => ({ + id, + supports: CHAT_TEXT_CAPABILITY_FLAGS, + generate: () => { + throw new Error('unexpected generate'); + }, + stream: () => { + throw new Error('unexpected stream'); + }, + listModels: () => (id === 'anthropic' ? listing : Promise.resolve([])), + }); + let clockReads = 0; + const port = createModelCatalogPort({ + db: client.db, + workOwner: home.surface.work, + providers: { + resolveProvider: (id) => + id === 'anthropic' || id === 'openai' ? provider(id) : undefined, + keyFor: (id) => { + if (id !== 'anthropic' && id !== 'openai') + throw new CliError('invalid_invocation', 'no synthetic key'); + return 'synthetic-catalog-key'; + }, + }, + uuid: () => { + if (kind === 'store') throw primary; + return 'synthetic-row'; + }, + now: () => { + if (kind === 'clock' && ++clockReads === 2) throw primary; + return 0; + }, + }); + let settled = false; + const drive = home + .execute(async () => { + await port.refresh(); + return 0; + }) + .then((code) => { + settled = true; + return code; + }); + await vi.waitFor(() => expect(home.surface.work.firstFailure?.cause).toBe(primary)); + expect(f.registry.observations.exitCode(), 'CATALOG_LOCAL_FAILURE_BEFORE_SIBLING_JOIN').toBe( + 9, + ); + expect(f.registry.budget.deadline()).toBe(3000); + expect(close).not.toHaveBeenCalled(); + expect(settled).toBe(false); + finish([]); + expect(await drive).toBe(9); + expect(close).toHaveBeenCalledOnce(); + }, + ); + + it('holds the shared Home database through raw discovery and refuses its post-stop write', async () => { + const f = invocationFixture(); + const home = new SessionHomeLifetime(f.invocation, () => () => undefined); + const close = vi.fn(); + home.adoptStore({ close }); + let finish: (models: ModelListing[]) => void = () => undefined; + const listing = new Promise((resolve) => { + finish = resolve; + }); + const provider: LlmProvider = { + id: 'anthropic', + supports: CHAT_TEXT_CAPABILITY_FLAGS, + generate: () => { + throw new Error('unexpected generate'); + }, + stream: () => { + throw new Error('unexpected stream'); + }, + listModels: () => listing, + }; + const port = createModelCatalogPort({ + db: client.db, + providers: { + resolveProvider: (id) => (id === 'anthropic' ? provider : undefined), + keyFor: () => 'synthetic-catalog-key', + }, + now: () => 0, + uuid: () => 'catalog-id', + workOwner: home.surface.work, + }); + let refresh: ReturnType | undefined; + const drive = home.execute(() => { + refresh = port.refresh(); + return Promise.resolve(0); + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(close, 'HOME_DISCOVERY_BEFORE_SHARED_DATABASE_CLOSE').not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + finish([{ id: 'synthetic-late-model' }]); + await drive; + await refresh; + expect(close).toHaveBeenCalledOnce(); + expect(client.sqlite.prepare('SELECT COUNT(*) AS count FROM llm_providers').get()).toEqual({ + count: 0, + }); + expect(f.registry.activeOwners).toBe(0); + }); + it('load() surfaces the static registry as AVAILABLE when the provider has a key', () => { const port = createModelCatalogPort({ db: client.db, diff --git a/apps/cli/src/engine/model-catalog-port.ts b/apps/cli/src/engine/model-catalog-port.ts index 829d4e84..bf687209 100644 --- a/apps/cli/src/engine/model-catalog-port.ts +++ b/apps/cli/src/engine/model-catalog-port.ts @@ -2,6 +2,7 @@ import { createModelCatalogStore, createProviderStore, type Db } from '@relavium import { buildMergedCatalog, type MergedCatalogView } from './model-catalog-view.js'; import { createModelRefreshService, type RefreshReport } from './model-refresh.js'; +import type { SessionWorkOwner } from '../chat/session-work-owner.js'; import { KNOWN_PROVIDERS, KNOWN_PROVIDER_IDS, @@ -21,7 +22,7 @@ export interface ModelCatalogPort { readonly load: () => MergedCatalogView; /** TTL-bounded background refresh (ADR-0064 §5c) — refreshes empty/stale providers; `undefined` when none were. */ readonly refreshIfStale: () => Promise; - /** Unbounded, user-initiated refresh (Ctrl+R) — every connected provider, per-provider-isolated. Never rejects. */ + /** User-initiated refresh, per-provider-isolated; an owned invocation can refuse entry after stop. */ readonly refresh: () => Promise; } @@ -30,7 +31,9 @@ export function createModelCatalogPort(params: { readonly providers: ProviderResolver; readonly now: () => number; readonly uuid: () => string; + readonly workOwner?: SessionWorkOwner; }): ModelCatalogPort { + const workOwner = params.workOwner; const storeDeps = { uuid: params.uuid, now: params.now }; const providerStore = createProviderStore(params.db, storeDeps); const catalogStore = createModelCatalogStore(params.db, storeDeps); @@ -42,13 +45,27 @@ export function createModelCatalogPort(params: { knownProviderIds: KNOWN_PROVIDER_IDS, knownProviders: KNOWN_PROVIDERS, now: params.now, + ...(params.workOwner === undefined + ? {} + : { + assertAdmitting: params.workOwner.assertAdmitting, + onHostFailure: params.workOwner.failure, + }), }); // Memoized: a keychain read is a SYNC native N-API call (and can pop an OS ACL prompt on first access), so it must // not repeat on every `/models` open + every refresh completion on the live ink render thread. The resolvable key // set is fixed for the process (a mid-session key change ⇒ restart), the same posture as the startup key probe. let keyedProviders: ReadonlySet<(typeof KNOWN_PROVIDER_IDS)[number]> | undefined; + const retain = (operation: () => Promise): Promise => { + if (workOwner === undefined) return operation(); + workOwner.assertAdmitting(); + // Discovery/cache reads preserve their recoverable UI policy. Local publication, + // clock and key faults report through onHostFailure before any sibling join. + return workOwner.work.retainWork(operation); + }; return { load: () => { + params.workOwner?.assertAdmitting(); // Rebuild the UUID→slug map on every load (NOT memoized): a refresh may register a provider's FK row, and the // next load must resolve its live rows' provider — not drop them (mirrors the Home's load). const slugByUuid = new Map(providerStore.list().map((p) => [p.id, p.name] as const)); @@ -62,7 +79,15 @@ export function createModelCatalogPort(params: { now: params.now(), }); }, - refreshIfStale: () => refreshService.refreshIfStale(), - refresh: () => refreshService.refresh(), + refreshIfStale: () => + retain(() => + refreshService.refreshIfStale( + workOwner === undefined ? undefined : { signal: workOwner.signal }, + ), + ), + refresh: () => + retain(() => + refreshService.refresh(workOwner === undefined ? undefined : { signal: workOwner.signal }), + ), }; } diff --git a/apps/cli/src/engine/model-refresh.ts b/apps/cli/src/engine/model-refresh.ts index 6363c24b..6b376dee 100644 --- a/apps/cli/src/engine/model-refresh.ts +++ b/apps/cli/src/engine/model-refresh.ts @@ -89,6 +89,10 @@ export interface RefreshCatalogStore { } export interface ModelRefreshDeps { + /** Original long-lived session admission; recheck after discovery before any shared-database write. */ + readonly assertAdmitting?: () => void; + /** Observe local clock/key/store faults before sibling discovery can hold the refresh result. */ + readonly onHostFailure?: (cause: unknown) => void; /** The keyless `@relavium/llm` adapter for a provider id (from the injected {@link ProviderResolver}). */ readonly resolveProvider: (id: ProviderId) => LlmProvider | undefined; /** Resolve a provider's key (keychain → env → throw). Thrown ⇒ the provider is skipped (`skipped-no-key`). */ @@ -192,42 +196,61 @@ export function createModelRefreshService(deps: ModelRefreshDeps): ModelRefreshS ): Promise => { // Stamp the ATTEMPT before anything can throw — `refreshOne` runs only for a connected provider, and every // outcome (refreshed / failed / skipped-unsupported / empty) counts as an attempt for the TTL backoff (FIX 3). - lastAttemptAt.set(id, deps.now()); try { - const adapter = deps.resolveProvider(id); + lastAttemptAt.set(id, deps.now()); + } catch (cause) { + deps.onHostFailure?.(cause); + throw cause; + } + try { + deps.assertAdmitting?.(); + let adapter: LlmProvider | undefined; + try { + adapter = deps.resolveProvider(id); + } catch (cause) { + deps.onHostFailure?.(cause); + throw cause; + } if (adapter?.listModels === undefined) { // No adapter, or an adapter without the optional list capability — degrade to static (ADR-0064 §1/§5). return { provider: id, status: 'skipped-unsupported' }; } // The key reaches ONLY this call; the seam bounds + aborts + redacts it. const listings = await adapter.listModels(key, signal); + deps.assertAdmitting?.(); // Ensure the FK-target `llm_providers` row exists BEFORE writing model rows (FK ordering). The metadata is // non-secret; the row never carries a key. - const meta = deps.knownProviders[id]; - const providerUuid = deps.providerStore.upsert({ - name: id, - displayName: meta.displayName, - baseUrl: meta.baseUrl, - }).id; + try { + const meta = deps.knownProviders[id]; + const providerUuid = deps.providerStore.upsert({ + name: id, + displayName: meta.displayName, + baseUrl: meta.baseUrl, + }).id; - const rows = listings.map(toLiveModel); - // The store counts added/updated/deactivated ATOMICALLY inside its own transaction and returns them — no - // before/after `listByProvider` diff (which, reading a stale `before`, would double-count under two - // concurrent same-provider refreshes) and two fewer queries. The tallies are LIVE-only by construction. - const { added, updated, deactivated } = deps.catalogStore.replaceProviderModels( - providerUuid, - rows, - deps.now(), - ); + const rows = listings.map(toLiveModel); + // The store counts added/updated/deactivated ATOMICALLY inside its own transaction and returns them — no + // before/after `listByProvider` diff (which, reading a stale `before`, would double-count under two + // concurrent same-provider refreshes) and two fewer queries. The tallies are LIVE-only by construction. + const { added, updated, deactivated } = deps.catalogStore.replaceProviderModels( + providerUuid, + rows, + deps.now(), + ); - return { provider: id, status: 'refreshed', added, updated, deactivated }; + return { provider: id, status: 'refreshed', added, updated, deactivated }; + } catch (cause) { + deps.onHostFailure?.(cause); + throw cause; + } } catch (err) { return { provider: id, status: 'failed', error: secretFreeReason(err, key) }; } }; const refresh = async (opts?: RefreshOptions): Promise => { + deps.assertAdmitting?.(); const candidates = opts?.providers ?? deps.knownProviderIds; const signal = opts?.signal; @@ -239,7 +262,8 @@ export function createModelRefreshService(deps: ModelRefreshDeps): ModelRefreshS let key: string; try { key = deps.keyFor(id); - } catch { + } catch (cause) { + deps.onHostFailure?.(cause); results.push({ provider: id, status: 'skipped-no-key' }); continue; } @@ -266,14 +290,22 @@ export function createModelRefreshService(deps: ModelRefreshDeps): ModelRefreshS }; const refreshIfStale = async (opts?: RefreshOptions): Promise => { + deps.assertAdmitting?.(); const candidates = opts?.providers ?? deps.knownProviderIds; - const now = deps.now(); + let now: number; + try { + now = deps.now(); + } catch (cause) { + deps.onHostFailure?.(cause); + throw cause; + } const stale: ProviderId[] = []; for (const id of candidates) { // Only a CONNECTED provider can be refreshed — an unconnected one is never "stale" (nothing to fetch). try { deps.keyFor(id); - } catch { + } catch (cause) { + deps.onHostFailure?.(cause); continue; } const record = deps.providerStore.get(id); diff --git a/apps/cli/src/engine/providers.test.ts b/apps/cli/src/engine/providers.test.ts index 2634df7e..bd846252 100644 --- a/apps/cli/src/engine/providers.test.ts +++ b/apps/cli/src/engine/providers.test.ts @@ -15,6 +15,8 @@ import { validateProviderKey, } from './providers.js'; import type { FetchLike } from './validated-fetch.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { SessionWorkOwner } from '../chat/session-work-owner.js'; // A test key assembled at runtime (no contiguous secret literal — leakwatch). const TEST_KEY = ['sk', 'prov', '90ABCDEF'].join('-'); @@ -208,6 +210,42 @@ describe('resolver.hasKey / providerHasKey (2.5.G key-awareness)', () => { // The shared redaction seam (used by `provider test` AND the `/doctor --deep` probe) — its security contract is // tested DIRECTLY here, not only through its two callers. describe('validateProviderKey', () => { + it.each(['timeout', 'stop'] as const)( + 'retains actual provider work after the %s result wins', + async (end) => { + const f = invocationFixture(); + const owner = new SessionWorkOwner(f.invocation, 'home', { + cancel: () => undefined, + cleanup: () => Promise.resolve(), + }); + let settle: () => void = () => undefined; + const raw = new Promise((_resolve, reject) => { + settle = () => reject(new Error('synthetic probe completed')); + }); + const generate = vi.fn(() => raw); + const probe = validateProviderKey( + fakeProvider(generate), + TEST_KEY, + 'm-test', + end === 'timeout' ? 5 : 10000, + { signal: owner.signal, work: owner.work, assertAdmitting: owner.assertAdmitting }, + ); + expect(generate).toHaveBeenCalledOnce(); + if (end === 'stop') owner.stop(); + expect(await probe).toMatchObject({ ok: false, reason: 'network' }); + let closed = false; + const closing = owner.close().then(() => { + closed = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(closed, 'ONBOARDING_PROBE_TIMEOUT_IS_NOT_NATIVE_SETTLEMENT').toBe(false); + expect(f.registry.activeOwners).toBe(1); + settle(); + await closing; + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); + }, + ); it('reports ok with the test model on a successful ping', async () => { const generate = vi.fn().mockResolvedValue({}); const result = await validateProviderKey(fakeProvider(generate), TEST_KEY, 'm-test'); diff --git a/apps/cli/src/engine/providers.ts b/apps/cli/src/engine/providers.ts index 79c9de1b..9773baee 100644 --- a/apps/cli/src/engine/providers.ts +++ b/apps/cli/src/engine/providers.ts @@ -1,5 +1,5 @@ import type { WorkflowDefinition } from '@relavium/core'; -import type { ProviderStore } from '@relavium/db'; +import type { EgressWorkOptions, ProviderStore } from '@relavium/db'; import { createCustomOpenAiProvider, defaultProviders, @@ -213,7 +213,13 @@ export async function validateProviderKey( key: string, model: string, timeoutMs: number = VALIDATE_KEY_TIMEOUT_MS, + lifecycle?: { + readonly signal: AbortSignal; + readonly work: Required>; + readonly assertAdmitting: () => void; + }, ): Promise { + lifecycle?.assertAdmitting(); // Defensive: an empty key would make the redaction `raw.split('').join(keyHint(''))` split on every character // and garble the message (no secret leaks — the key is empty — but the detail becomes nonsense). All current // callers resolve a non-empty key (createProviderResolver rejects `''`); this closes the footgun at the seam. @@ -221,6 +227,15 @@ export async function validateProviderKey( return { ok: false, detail: 'key test failed — (no key)', reason: 'other' }; } const controller = new AbortController(); + let parentStopped: () => void = () => undefined; + const stopped = new Promise((resolve) => { + parentStopped = () => { + controller.abort(); + resolve({ ok: false, detail: 'key test stopped', reason: 'network' }); + }; + }); + lifecycle?.signal.addEventListener('abort', parentStopped, { once: true }); + if (lifecycle?.signal.aborted === true) parentStopped(); let timer: ReturnType | undefined; const timeout = new Promise((resolve) => { timer = setTimeout(() => { @@ -232,7 +247,8 @@ export async function validateProviderKey( }); }, timeoutMs); }); - const probe = (async (): Promise => { + const probeFactory = async (): Promise => { + lifecycle?.assertAdmitting(); try { await provider.generate( { @@ -254,11 +270,14 @@ export async function validateProviderKey( reason: classifyValidationFailure(err), }; } - })(); + }; try { - return await Promise.race([probe, timeout]); + // Register the real producer before entry. The UX timeout/abort result never retires it. + const probe = lifecycle?.work.retainWork(probeFactory) ?? probeFactory(); + return await Promise.race([probe, timeout, stopped]); } finally { if (timer !== undefined) clearTimeout(timer); + lifecycle?.signal.removeEventListener('abort', parentStopped); } } diff --git a/apps/cli/src/home/drive-home.test.ts b/apps/cli/src/home/drive-home.test.ts index 66e65cac..81aca183 100644 --- a/apps/cli/src/home/drive-home.test.ts +++ b/apps/cli/src/home/drive-home.test.ts @@ -1,4 +1,5 @@ import { createCliInvocation } from '../process/invocation.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; import { holdNativeToolProcess, witnessNativeToolProcess, @@ -166,7 +167,11 @@ describe('driveHome (2.5.B / ADR-0054)', () => { db: client.db, close: closeSpy, }; - const unmount = vi.fn(); + let acknowledgeExit: () => void = () => undefined; + const exited = new Promise((resolve) => { + acknowledgeExit = resolve; + }); + const unmount = vi.fn(() => acknowledgeExit()); const writeControl = vi.fn(); let uuidN = 0; const deps: HomeDeps = { @@ -184,7 +189,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { return { unmount, waitUntilRenderFlush: () => Promise.resolve(), - waitUntilExit: () => new Promise(() => {}), + waitUntilExit: () => exited, }; }, getSize: () => ({ cols: 120, rows: 40 }), @@ -225,6 +230,49 @@ describe('driveHome (2.5.B / ADR-0054)', () => { expect(closeSpy).toHaveBeenCalledTimes(1); }); + it('observes an owned renderer failure before held host cleanup and closes the shared DB only after actual join', async () => { + const f = invocationFixture(); + let release: () => void = () => undefined; + const held = new Promise((resolve) => { + release = resolve; + }); + let captured: RootAppProps | undefined; + let builtCount = 0; + const { deps, unmount } = makeDeps( + (props) => { + captured = props; + }, + { + invocation: f.invocation, + buildSession: async (options) => { + builtCount++; + return { ...(await buildChatSession(options)), whenHostSafeToClose: () => held }; + }, + }, + ); + const drive = driveHome(deps).catch((cause: unknown) => cause); + const props = captured; + if (props === undefined) throw new Error('Home not mounted'); + type(props, 'synthetic first turn'); + props.controller.handleKey('', ENTER); + await vi.waitFor(() => expect(props.controller.getSnapshot().submitBusy).toBe(false)); + await vi.waitFor(() => expect(props.controller.getSnapshot().mode).toBe('chat')); + const primary = new Error('PRIVATE_HOME_RENDERER_FAILURE'); + props.onRendererError?.(primary); + await vi.waitFor(() => expect(unmount).toHaveBeenCalledOnce()); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.budget.deadline()).toBe(3000); + expect(closeSpy, 'HOME_ACTUAL_HOST_JOIN_BEFORE_SHARED_DATABASE').not.toHaveBeenCalled(); + props.controller.handlePaste('/clear'); + props.controller.handleKey('', ENTER); + await flush(); + expect(builtCount).toBe(1); + release(); + expect(await drive).toBe(EXIT_CODES.infrastructureFailed); + expect(closeSpy).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + }); + it('a throwing unmount on a clean exit stays best-effort — still resolves 0 and closes the db once', async () => { let captured: RootAppProps | undefined; const { deps } = makeDeps(() => undefined, { @@ -411,7 +459,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { }); it('startChat builds the default-agent session and persists its row; a clean return ends it', async () => { - const invocation = createCliInvocation(); + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); mkdirSync(join(cwd, '.relavium'), { recursive: true }); writeFileSync(join(cwd, '.relavium', 'project.toml'), '[defaults]\nmax_tokens_estimate = 17\n'); const estimates: (number | undefined)[] = []; @@ -419,7 +467,12 @@ describe('driveHome (2.5.B / ADR-0054)', () => { const { deps } = makeDeps((p) => (captured = p), { invocation, buildSession: async (options) => { - await witnessNativeToolProcess(options.nativeProcesses, invocation, options.cwd); + await witnessNativeToolProcess( + options.nativeProcesses, + invocation, + options.cwd, + estimates.length === 0 ? 4 : 5, + ); estimates.push(options.maxTokensEstimate); return buildChatSession(options); }, @@ -431,7 +484,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { type(props, 'hello'); props.controller.handleKey('', ENTER); // submit ⇒ build + first turn await vi.waitFor(() => - expect(invocation.registry.activeOwners, 'NATIVE_HOME_FRESH_FORWARDING').toBe(1), + expect(invocation.registry.activeOwners, 'NATIVE_HOME_FRESH_FORWARDING').toBe(5), ); expect(props.controller.getSnapshot().mode).toBe('loading'); await vi.waitFor(() => expect(props.controller.getSnapshot().mode).toBe('chat')); @@ -446,7 +499,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { props.controller.handleKey('', { downArrow: true }); props.controller.handleKey('', ENTER); await vi.waitFor(() => - expect(invocation.registry.activeOwners, 'NATIVE_HOME_FRESH_FORWARDING').toBe(1), + expect(invocation.registry.activeOwners, 'NATIVE_HOME_FRESH_FORWARDING').toBe(6), ); await vi.waitFor(() => expect(estimates).toEqual([17, 17])); expect(props.controller.getSnapshot().mode).toBe('chat'); @@ -461,7 +514,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { }); it('retains both original native reservations when a pending clear build is superseded', async () => { - const invocation = createCliInvocation(); + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); let captured: RootAppProps | undefined; const permits: (() => void)[] = []; const children: { close: () => Promise }[] = []; @@ -496,22 +549,22 @@ describe('driveHome (2.5.B / ADR-0054)', () => { props.controller.handlePaste('/clear'); props.controller.handleKey('', ENTER); await vi.waitFor(() => expect(permits, 'NATIVE_HOME_PENDING_FORWARDING').toHaveLength(1)); - expect(invocation.registry.activeOwners).toBe(1); + expect(invocation.registry.activeOwners).toBe(6); expect(props.controller.getSnapshot().session).toBe(original); // first replacement still initializes props.controller.handleKey('c', CTRL_C); // sticky /clear starts a second replacement await vi.waitFor(() => expect(permits).toHaveLength(2)); - expect(invocation.registry.activeOwners).toBe(2); + expect(invocation.registry.activeOwners).toBe(8); permits[1]?.(); // second replacement wins while both native creations remain live await vi.waitFor(() => expect(props.controller.getSnapshot().session).not.toBe(original)); const winner = props.controller.getSnapshot().session; - expect(invocation.registry.activeOwners).toBe(2); + expect(invocation.registry.activeOwners).toBe(7); permits[0]?.(); // stale completion may close only its own native creation - await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(1)); + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(5)); expect(props.controller.getSnapshot().session).toBe(winner); await vi.waitFor(() => expect(props.controller.getSnapshot().submitBusy).toBe(false)); props.controller.handleKey('c', CTRL_C); await vi.waitFor(() => expect(props.controller.getSnapshot().mode).toBe('home')); - await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(0)); + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(3)); props.controller.handleKey('c', CTRL_C); expect(await drivePromise).toBe(EXIT_CODES.success); } finally { @@ -522,7 +575,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { }); it('binds the default Home editor to the original native registry', async () => { - const invocation = createCliInvocation(); + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); const marker = join(cwd, 'home-editor-started'); const release = join(cwd, 'home-editor-release'); const script = fileURLToPath(new URL('../render/fixtures/native-editor.mjs', import.meta.url)); @@ -545,14 +598,14 @@ describe('driveHome (2.5.B / ADR-0054)', () => { props.controller.handleKey('', ENTER); await vi.waitFor( () => { - expect(invocation.registry.activeOwners, 'NATIVE_HOME_EDITOR_FORWARDING').toBe(1); + expect(invocation.registry.activeOwners, 'NATIVE_HOME_EDITOR_FORWARDING').toBe(5); expect(readFileSync(marker, 'utf8')).toBe('ready\n'); }, { interval: 5, timeout: 3000 }, ); } finally { writeFileSync(release, 'release'); - await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(0), { timeout: 6000 }); + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(4), { timeout: 6000 }); await vi.waitFor(() => expect(props.controller.getSnapshot().submitBusy).toBe(false)); props.suspendPort.attach(undefined); } @@ -671,7 +724,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { }); it('in-Home /models reseat: the REAL reseatChat resumes the session under the switched model, carrying the transcript (ADR-0059)', async () => { - const invocation = createCliInvocation(); + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); mkdirSync(join(cwd, '.relavium'), { recursive: true }); writeFileSync(join(cwd, '.relavium', 'project.toml'), '[defaults]\nmax_tokens_estimate = 17\n'); const freshEstimates: (number | undefined)[] = []; @@ -683,7 +736,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { invocation, providers: scriptedResolver([textTurn('sonnet reply'), textTurn('opus reply')]), buildSession: async (options) => { - await witnessNativeToolProcess(options.nativeProcesses, invocation, options.cwd); + await witnessNativeToolProcess(options.nativeProcesses, invocation, options.cwd, 4); freshEstimates.push(options.maxTokensEstimate); return buildChatSession(options); }, @@ -692,6 +745,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { options.nativeProcesses, invocation, options.record.context.workingDir, + 5, ); resumedEstimates.push(options.maxTokensEstimate); return buildResumedChatSession(options); @@ -727,7 +781,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { expect(effortPicker?.pending?.modelId).toBe('claude-opus-4-8'); props.controller.handleKey('', ENTER); // apply the highlighted tier (default 'medium') ⇒ the REAL reseatChat await vi.waitFor(() => - expect(invocation.registry.activeOwners, 'NATIVE_HOME_RESEAT_FORWARDING').toBe(1), + expect(invocation.registry.activeOwners, 'NATIVE_HOME_RESEAT_FORWARDING').toBe(6), ); await vi.waitFor(() => expect(resumedEstimates).toEqual([17])); await vi.waitFor(() => expect(props.controller.getSnapshot().session).not.toBe(oldSession)); diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index 7160b34d..4645abb0 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -1,3 +1,12 @@ +import { SessionControlWrites } from '../chat/session-control-writes.js'; +import { SessionHomeLifetime } from './session-home-lifetime.js'; +import type { SessionHostOwner } from '../chat/session-host-owner.js'; +import type { SessionWorkOwner } from '../chat/session-work-owner.js'; +import { createOwnedSessionConsentGate } from '../chat/session-consent.js'; +import { SessionInkMount } from '../chat/session-ink-mount.js'; +import { SessionInkInput } from '../chat/session-ink-input.js'; +import { SessionTerminalOwner } from '../chat/session-terminal-owner.js'; +import { SessionViewLifetime } from '../chat/session-view-lifetime.js'; import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; import type { CliInvocation } from '../process/invocation.js'; import { closeQuietly, warnTeardown } from '../process/close-quietly.js'; @@ -194,7 +203,13 @@ export const defaultOnboardingTerminalLifecycle: OnboardingTerminalLifecycle = { }, }; -export async function driveHome(deps: HomeDeps): Promise { +export function driveHome(deps: HomeDeps): Promise { + if (deps.invocation === undefined) return driveHomeBody(deps); + const ownership = new SessionHomeLifetime(deps.invocation, deps.subscribeSignals); + return ownership.execute(() => driveHomeBody(deps, ownership)); +} + +async function driveHomeBody(deps: HomeDeps, ownership?: SessionHomeLifetime): Promise { const nativeProcesses = nativeProcessesForInvocation(deps.invocation); const grants = admitFsInvocation(deps.global); const now = deps.now ?? Date.now; @@ -215,6 +230,7 @@ export async function driveHome(deps: HomeDeps): Promise { const mcpSecretResolver = deps.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env, keychain); const opened = (deps.openSessionStore ?? openSessionStore)(homeDir); + ownership?.adoptStore(opened); // The provider resolver + `/doctor` probes are built INSIDE the try below — once `opened.db` + the `providerStore` // exist — so the resolver is STORE-AWARE (a custom `base_url` rebinds to its validated endpoint, 2.5.G S9); a test // still injects `deps.providers` / `deps.doctorProbes` to bypass the keychain/network. @@ -225,6 +241,12 @@ export async function driveHome(deps: HomeDeps): Promise { let instance: | Pick, 'unmount' | 'waitUntilRenderFlush' | 'waitUntilExit'> | undefined; + let ownedMount: SessionInkMount | undefined; + let ownedJob: SessionTerminalOwner | undefined; + let homeInputSealed = false; + let stopHomeInput = (): void => { + homeInputSealed = true; + }; let rendererActive = true; let flushVisible: NoticeFlush | undefined; let controller: HomeController | undefined; @@ -238,23 +260,57 @@ export async function driveHome(deps: HomeDeps): Promise { dbClosed = true; closeQuietly(deps.io, 'session store', () => opened.close()); }; - const writeControl = - deps.writeControl ?? - ((sequence: string) => { - process.stdout.write(sequence); - }); + const controls = + ownership === undefined + ? undefined + : new SessionControlWrites({ + invocation: ownership.surface.invocation, + write: (text) => deps.io.writeOutAcknowledged(text), + failure: ownership.surface.work.cleanupUncertain, + parent: ownership.surface.work.work, + }); + const writeControl = (sequence: string): void | Promise => { + if (controls !== undefined) return controls.write(sequence); + if (deps.writeControl !== undefined) deps.writeControl(sequence); + else process.stdout.write(sequence); + }; // Before Ink mounts, the first-run wizard owns raw input and cursor visibility through `@clack/prompts`. It is a // real full-screen-adjacent terminal owner: a raw Ctrl-Z becomes a SUB byte (not a kernel SIGTSTP), while an // external SIGTSTP would otherwise stop the process with Clack's raw mode and hidden cursor still live. Keep its // state separate from Ink's SuspendPort, then hand it off reversibly through the same job-control coordinator. - const onboardingTerminal = deps.onboardingTerminalLifecycle ?? defaultOnboardingTerminalLifecycle; + const onboardingTerminal = + deps.onboardingTerminalLifecycle ?? + (ownership === undefined + ? defaultOnboardingTerminalLifecycle + : { + onInput: (listener: (input: string) => void) => { + const input = deps.io.nativeStdin; + if (input === undefined) throw new CliError('cli_internal', ''); + const onData = (chunk: Buffer | string): void => + listener(typeof chunk === 'string' ? chunk : chunk.toString('utf8')); + input.on('data', onData); + return () => { + input.removeListener('data', onData); + }; + }, + isRawMode: () => deps.io.nativeStdin?.isRaw === true, + setRawMode: (enabled: boolean) => { + const input = deps.io.nativeStdin; + if (input === undefined) throw new CliError('cli_internal', ''); + input.setRawMode(enabled); + }, + }); let onboardingActive = false; let onboardingFinalized = false; let onboardingRawReleased = false; let onboardingCursorReleased = false; const reclaimOnboardingTerminal = (): void => { + if (ownership?.surface.work.signal.aborted === true) { + onboardingFinalized = true; + return; + } if (!onboardingActive || onboardingFinalized) return; // Restore raw input before Clack redraws/reads again. Each latch is independent: a transient cursor-write fault // must remain retryable and cannot make us incorrectly claim that the terminal is wholly reclaimed. @@ -268,7 +324,7 @@ export async function driveHome(deps: HomeDeps): Promise { } if (onboardingCursorReleased) { try { - writeControl(HIDE_CURSOR); + void writeControl(HIDE_CURSOR); onboardingCursorReleased = false; } catch { // A future recovery opportunity gets another exact retry. @@ -297,7 +353,7 @@ export async function driveHome(deps: HomeDeps): Promise { // conservatively re-hide the cursor in that case; a harmless extra HIDE is far safer than returning Clack to a // live prompt with its cursor visible. onboardingCursorReleased = true; - writeControl(SHOW_CURSOR); + void writeControl(SHOW_CURSOR); return true; } catch { // We did not stop the process, so put any partial release back immediately rather than leaving Clack half-owned. @@ -319,7 +375,7 @@ export async function driveHome(deps: HomeDeps): Promise { // The overlapping exit net/finally retries the exact operation. } try { - writeControl(SHOW_CURSOR); + void writeControl(SHOW_CURSOR); onboardingCursorReleased = false; } catch { // The overlapping exit net/finally retries the exact operation. @@ -357,7 +413,7 @@ export async function driveHome(deps: HomeDeps): Promise { } if (!pasteDisabled) { try { - writeControl(DISABLE_BRACKETED_PASTE); + void writeControl(DISABLE_BRACKETED_PASTE); pasteDisabled = true; } catch { // a later net retries @@ -365,7 +421,7 @@ export async function driveHome(deps: HomeDeps): Promise { } if (!mouseDisabled) { try { - writeControl(DISABLE_MOUSE); // restore native mouse text-selection (no-op if never enabled) + void writeControl(DISABLE_MOUSE); // restore native mouse text-selection (no-op if never enabled) mouseDisabled = true; } catch { // a later net retries @@ -377,7 +433,7 @@ export async function driveHome(deps: HomeDeps): Promise { // `wireHomeChatSession` hands these ports to `createChatLineHandler` — the SAME builder `relavium chat` uses — so // `/scrollback` and `/edit` are literally the same code on both surfaces. Unlike the chat, the bare Home mounts ink // with `alternateScreen: true`, so ink itself toggles DECSET-1049 across a suspension; only the mouse is ours. - const suspendPort = createSuspendPort(); + const suspendPort = ownership?.suspendPort ?? createSuspendPort(); let altScreenActive = false; // assigned once the render mode resolves; read LAZILY by `terminal()` below let mouseActive = false; // ditto — `--no-mouse` / `[preferences].mouse = false` leaves the alt buffer mouse-less // Whether the mouse is captured RIGHT NOW. Distinct from `mouseActive` (the resolved mode) since Step 6g: the Home @@ -386,7 +442,15 @@ export async function driveHome(deps: HomeDeps): Promise { // ONE clipboard closure over the SAME control-write sink as the alt-buffer + mouse toggles (Step 6). `/copy` (via // `hatchPorts`) and copy-on-select (via `RootApp`'s `clipboard` prop, when enabled) both use it. const clipboard = (text: string): ClipboardOutcome => - copyToClipboard({ writeControl, env: deps.io.env }, text); + copyToClipboard( + { + writeControl: (sequence) => { + void writeControl(sequence); + }, + env: deps.io.env, + }, + text, + ); const hatchPorts: Omit = { suspendPort, writeControl, @@ -396,18 +460,43 @@ export async function driveHome(deps: HomeDeps): Promise { terminal: inkOwnedTerminal( () => altScreenActive, () => mouseCaptured, - () => process.stdout.columns, + () => (ownership === undefined ? process.stdout.columns : deps.io.nativeStdout?.columns), ), clipboard, dump: { - writeOut: nodeWriteOut(process.stdout), - waitForContinue: nodeWaitForContinue(process.stdin), + writeOut: + ownership === undefined + ? nodeWriteOut(process.stdout) + : (text) => deps.io.writeOutAcknowledged(text), + waitForContinue: + ownership === undefined + ? nodeWaitForContinue(process.stdin) + : () => { + if (deps.io.nativeStdin === undefined) throw new CliError('cli_internal', ''); + return nodeWaitForContinue( + deps.io.nativeStdin, + process, + ownership.surface.work.signal, + )(); + }, }, editor: { env: deps.io.env, spawnEditor: createNodeSpawnEditor(nativeProcesses), createTempDocument: nodeCreateTempDocument, onDisposeFailed: (path, error) => { + if (ownership !== undefined) { + ownership.surface.work.cleanupUncertain(error); + if (ownership.surface.invocation.registry.budget.remainingMs() !== 0) + void ownership.surface.work.work + .retainWork(() => + deps.io.writeErrAcknowledged( + 'warning: transcript temporary cleanup could not be acknowledged.\n', + ), + ) + .catch(ownership.surface.work.failure); + return; + } deps.io.writeErr( `warning: transcript temp file teardown failed (${path}): ${ error instanceof Error ? error.message : String(error) @@ -416,6 +505,9 @@ export async function driveHome(deps: HomeDeps): Promise { }, }, }; + // Keep the last synchronous restore net until actual final output acknowledgement, even after stop. + const setupView = + ownership === undefined ? undefined : new SessionViewLifetime(ownership.surface.work, false); try { const homeStore = createHomeStore({ @@ -470,7 +562,13 @@ export async function driveHome(deps: HomeDeps): Promise { // accept action on top: `currentDefault`/`currentEffort` (the ✓ markers) + `writeDefault` (the next-session // default model + its effort tier, ADR-0063 §1 · ADR-0066 §6). const models: HomeModelsPort = { - ...createModelCatalogPort({ db: opened.db, providers, now, uuid }), + ...createModelCatalogPort({ + db: opened.db, + providers, + now, + uuid, + ...(ownership === undefined ? {} : { workOwner: ownership.surface.work }), + }), currentDefault: readEffectiveDefault, currentEffort: readEffectiveEffort, // Write to the SAME file the picker re-reads + the started session resolves (honors `--config`), so a `/models` @@ -518,29 +616,33 @@ export async function driveHome(deps: HomeDeps): Promise { let frame: ReturnType | undefined; let unsubscribe: (() => void) | undefined; let persister: SessionPersister | undefined; + const view = built.workOwner === undefined ? undefined : ownership?.view(built.workOwner); try { - persister = createSessionPersister({ - usageStore: createSessionUsageStore(opened.db), - mode: opts.open ? 'fresh' : 'adopt', - attachUsageWriter: built.attachUsageWriter, - governor: built.governor, - attachDurabilityProbe: built.attachDurabilityProbe, - attachEffectTurnAllocator: built.attachEffectTurnAllocator, - store: opened.store, - handle: built.handle, - sessionId: built.sessionId, - agent: built.agent, - context: built.context, - now, - uuid, - // ADR-0059 per-message/session model attribution — resolve a model string → its `model_catalog.id` over - // the SAME db (the Home's catalog is the one the picker refreshes), degrading to NULL when uncataloged. - resolveModelCatalogId: makeCatalogIdResolver(opened.db, { uuid, now }), - // A RESUMED session continues the durable transcript past its last sequence number; a fresh one starts at 0. - ...(opts.initialSequenceNumber === undefined - ? {} - : { initialSequenceNumber: opts.initialSequenceNumber }), - }); + persister = createSessionPersister( + { + usageStore: createSessionUsageStore(opened.db), + mode: opts.open ? 'fresh' : 'adopt', + attachUsageWriter: built.attachUsageWriter, + governor: built.governor, + attachDurabilityProbe: built.attachDurabilityProbe, + attachEffectTurnAllocator: built.attachEffectTurnAllocator, + store: opened.store, + handle: built.handle, + sessionId: built.sessionId, + agent: built.agent, + context: built.context, + now, + uuid, + // ADR-0059 per-message/session model attribution — resolve a model string → its `model_catalog.id` over + // the SAME db (the Home's catalog is the one the picker refreshes), degrading to NULL when uncataloged. + resolveModelCatalogId: makeCatalogIdResolver(opened.db, { uuid, now }), + // A RESUMED session continues the durable transcript past its last sequence number; a fresh one starts at 0. + ...(opts.initialSequenceNumber === undefined + ? {} + : { initialSequenceNumber: opts.initialSequenceNumber }), + }, + built.hostOwner, + ); // The durable effect journal (ADR-0080), wired where `history.db` is open. Without it every // effectful tool on the bare-`relavium` Home — `!ls`, an appending `write_file`, a non-GET // `http_request`, every MCP tool — is refused, which is exactly what a review found here. @@ -565,9 +667,20 @@ export async function driveHome(deps: HomeDeps): Promise { { ...deps, hatchPorts }, ); // Subscribe the view store BEFORE opening the session so the synchronous session:started is observed. - unsubscribe = built.handle.subscribe((event) => store.apply(event)); - frame = setInterval(() => store.tick(), FRAME_MS); - frame.unref(); + const subscribeView = (): (() => void) => + built.handle.subscribe((event) => store.apply(event)); + const startFrame = (): (() => void) => { + frame = setInterval(() => store.tick(), FRAME_MS); + frame.unref(); + return () => clearInterval(frame); + }; + if (view === undefined) { + unsubscribe = subscribeView(); + startFrame(); + } else { + view.acquire(subscribeView); + view.acquire(startFrame); + } persister.start(); // Open a FRESH session; a RESUMED session already landed at idle inside AgentSession.resume — start() would // throw and re-emitting session:started would double a terminal-less lifecycle event. @@ -587,9 +700,19 @@ export async function driveHome(deps: HomeDeps): Promise { command: string, args: readonly string[], ): ReturnType => - built.session.runUserCommand(command, args); + built.workOwner === undefined + ? built.session.runUserCommand(command, args) + : built.workOwner.enter(() => built.session.runUserCommand(command, args)); + const ownedTeardown = + built.hostOwner === undefined + ? undefined + : (): Promise => { + view?.release(); + return built.hostOwner?.work.close() ?? Promise.resolve(); + }; let torn = false; const teardown = async (): Promise => { + if (ownedTeardown !== undefined) return ownedTeardown(); if (torn) return; // idempotent — an error-path teardown racing an endChat must not double-close the MCP child torn = true; cancelOnce(); // the session's sole terminal — persister marks the row 'ended' @@ -615,20 +738,27 @@ export async function driveHome(deps: HomeDeps): Promise { ...(opts.open ? {} : { - onActivated: (isActive: () => boolean) => - reconcileResumedSessionEffects({ - io: deps.io, - db: opened.db, - sessionId: built.sessionId, - sanitize: sanitizeInline, - deliverNotice: (text) => store.notice(text), - isActive: () => rendererActive && isActive(), - flushNotice: async (publish) => { - if (flushVisible === undefined) - throw new Error('Home renderer is not ready.'); - await flushVisible(publish); - }, - }), + onActivated: (isActive: () => boolean) => { + const activate = () => + reconcileResumedSessionEffects({ + io: deps.io, + db: opened.db, + sessionId: built.sessionId, + sanitize: sanitizeInline, + deliverNotice: (text) => store.notice(text), + isActive: () => rendererActive && isActive(), + flushNotice: async (publish) => { + if (flushVisible === undefined) + throw new Error('Home renderer is not ready.'); + await flushVisible(publish); + }, + }); + return built.workOwner === undefined + ? activate() + : built.workOwner.enter(async () => { + await activate(); + }); + }, }), onAbort, onModeChange, @@ -638,6 +768,11 @@ export async function driveHome(deps: HomeDeps): Promise { runShellCommand, }; } catch (err) { + if (built.workOwner !== undefined) { + built.workOwner.failure(err); + view?.release(); + throw err; + } clearInterval(frame); // reclaim whatever the wiring managed to acquire before the throw unsubscribe?.(); try { @@ -650,88 +785,103 @@ export async function driveHome(deps: HomeDeps): Promise { }; // Build + wire + START a fresh chat session (the controller sends the first message on transition). - const startChat = async (): Promise => { - // `altScreenActive` is assigned when the render mode resolves, BEFORE the first submit that calls this. The - // full-screen viewport can hold a whole answer; the inline `` path keeps its trailing tail (ADR-0068 - // Decision (c)). Read lazily, like the hatch ports, so the mode is the LIVE one. - const store = createChatStore( - deps.global.color, - undefined, - transcriptBoundFor(altScreenActive), - ); - // TRIPWIRE (2.6.C): the store's transcript bound and the renderer both derive from `altScreenActive`, so they - // agree BY CONSTRUCTION here — this pins that, loudly, if a future edit ever gives them separate sources. - assertRenderStoreAgree(altScreenActive, store.getSnapshot().state.transcriptBound); - // The ADR-0065 §2 user-pricing overlay (2.5.G S10), read FRESH per chat from the SAME `history.db` (empty map - // on a read fault). The USER outranks the catalog (ADR-0071 §1). - const resolvePrice = readUserPricingOverlay(opened.db); - // Re-read the EFFECTIVE `[chat]` FRESH per chat (not the load-once `config` snapshot) so a same-session - // `/models` write — the model (2.5.G S7) + its effort (ADR-0066 §6) — lights up the next chat. Read it ONCE: - // the model and the provider persisted WITH it (ADR-0059) must come from the SAME snapshot, or a fresh model - // paired with a stale startup provider dials the wrong adapter → 404 (review M5). So couple them — when the - // fresh read supplies a model, take ITS provider (undefined ⇒ infer from the model id, NEVER back-filled from - // startup); only with no fresh model (a genuinely unset default, or a malformed-config read fault) fall back - // to the startup PAIR. The other `[chat]` settings keep the startup snapshot. - const effectiveChat = readEffectiveChat(); - const freshModel = effectiveChat?.defaultModel; - const [defaultModel, defaultProvider] = - freshModel !== undefined - ? [freshModel, effectiveChat?.defaultProvider] - : [config.chat.defaultModel, config.chat.defaultProvider]; - const built: BuiltChatSession = await (deps.buildSession ?? buildChatSession)({ - ...(nativeProcesses === undefined ? {} : { nativeProcesses }), - // Consent before any stdio MCP spawn (ADR-0084 §1) — the Home opens an agent like every other path. - consentGate: createConsentGate({ - io: deps.io, - global: deps.global, - homeDir, - prompt: createConsentPrompter(), - }), - fsPolicy, - chat: { - ...config.chat, - defaultModel, - defaultProvider, - reasoningEffort: effectiveChat?.reasoningEffort ?? config.chat.reasoningEffort, - }, - ...(config.maxTokensEstimate === undefined - ? {} - : { maxTokensEstimate: config.maxTokensEstimate }), - agentRef: undefined, // the built-in default agent (zero-config first run) - cwd: deps.global.cwd, - projectConfigDir, - now, - uuid, - providers, - mcpSecretResolver, - mcpRegistrations: config.mcpServers, - ...(resolvePrice.size === 0 ? {} : { resolvePrice }), - // Into the chat's TRANSCRIPT, never raw stderr. `relavium chat` routes this through `emitLiveNotice` for - // exactly this reason (Step-4b-3 Sonnet fix): a raw write lands on the alt buffer, where ink's next frame - // overwrites it — the user is warned about their spend on a line that survives a single frame. - onBudgetWarning: (warning) => store.notice(budgetWarningText(warning)), - onCompactionStart: async ({ windowUnknown, signal }) => { - if (!windowUnknown || signal.aborted) return; - if (!rendererActive || flushVisible === undefined) - throw new Error('Home renderer is not ready.'); - await flushVisible(() => { - if (signal.aborted) return; - const visibleStore = store; - if (!rendererActive || visibleStore === undefined) - throw new Error('Home renderer is not ready.'); - visibleStore.notice(COMPACTION_UNKNOWN_WINDOW_NOTICE); + const constructChat = ( + factory: (host?: SessionHostOwner) => Promise, + ): Promise => + ownership === undefined ? factory() : ownership.hosts.construct(factory); + const startChat = (): Promise => + constructChat(async (host) => { + // `altScreenActive` is assigned when the render mode resolves, BEFORE the first submit that calls this. The + // full-screen viewport can hold a whole answer; the inline `` path keeps its trailing tail (ADR-0068 + // Decision (c)). Read lazily, like the hatch ports, so the mode is the LIVE one. + const store = createChatStore( + deps.global.color, + undefined, + transcriptBoundFor(altScreenActive), + ); + // TRIPWIRE (2.6.C): the store's transcript bound and the renderer both derive from `altScreenActive`, so they + // agree BY CONSTRUCTION here — this pins that, loudly, if a future edit ever gives them separate sources. + assertRenderStoreAgree(altScreenActive, store.getSnapshot().state.transcriptBound); + // The ADR-0065 §2 user-pricing overlay (2.5.G S10), read FRESH per chat from the SAME `history.db` (empty map + // on a read fault). The USER outranks the catalog (ADR-0071 §1). + const resolvePrice = readUserPricingOverlay(opened.db); + // Re-read the EFFECTIVE `[chat]` FRESH per chat (not the load-once `config` snapshot) so a same-session + // `/models` write — the model (2.5.G S7) + its effort (ADR-0066 §6) — lights up the next chat. Read it ONCE: + // the model and the provider persisted WITH it (ADR-0059) must come from the SAME snapshot, or a fresh model + // paired with a stale startup provider dials the wrong adapter → 404 (review M5). So couple them — when the + // fresh read supplies a model, take ITS provider (undefined ⇒ infer from the model id, NEVER back-filled from + // startup); only with no fresh model (a genuinely unset default, or a malformed-config read fault) fall back + // to the startup PAIR. The other `[chat]` settings keep the startup snapshot. + const effectiveChat = readEffectiveChat(); + const freshModel = effectiveChat?.defaultModel; + const [defaultModel, defaultProvider] = + freshModel !== undefined + ? [freshModel, effectiveChat?.defaultProvider] + : [config.chat.defaultModel, config.chat.defaultProvider]; + const build = (workOwner?: SessionWorkOwner) => + (deps.buildSession ?? buildChatSession)({ + ...(workOwner === undefined ? {} : { workOwner }), + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), + // Consent before any stdio MCP spawn (ADR-0084 §1) — the Home opens an agent like every other path. + consentGate: + workOwner === undefined + ? createConsentGate({ + io: deps.io, + global: deps.global, + homeDir, + prompt: createConsentPrompter(), + }) + : createOwnedSessionConsentGate( + { io: deps.io, global: deps.global, homeDir }, + workOwner, + ), + fsPolicy, + chat: { + ...config.chat, + defaultModel, + defaultProvider, + reasoningEffort: effectiveChat?.reasoningEffort ?? config.chat.reasoningEffort, + }, + ...(config.maxTokensEstimate === undefined + ? {} + : { maxTokensEstimate: config.maxTokensEstimate }), + agentRef: undefined, // the built-in default agent (zero-config first run) + cwd: deps.global.cwd, + projectConfigDir, + now, + uuid, + providers, + mcpSecretResolver, + mcpRegistrations: config.mcpServers, + ...(resolvePrice.size === 0 ? {} : { resolvePrice }), + // Into the chat's TRANSCRIPT, never raw stderr. `relavium chat` routes this through `emitLiveNotice` for + // exactly this reason (Step-4b-3 Sonnet fix): a raw write lands on the alt buffer, where ink's next frame + // overwrites it — the user is warned about their spend on a line that survives a single frame. + onBudgetWarning: (warning) => store.notice(budgetWarningText(warning)), + onCompactionStart: async ({ windowUnknown, signal }) => { + if (!windowUnknown || signal.aborted) return; + if (!rendererActive || flushVisible === undefined) + throw new Error('Home renderer is not ready.'); + await flushVisible(() => { + if (signal.aborted) return; + const visibleStore = store; + if (!rendererActive || visibleStore === undefined) + throw new Error('Home renderer is not ready.'); + visibleStore.notice(COMPACTION_UNKNOWN_WINDOW_NOTICE); + }); + if (!signal.aborted && !rendererActive) + throw new Error('Home renderer is not ready.'); + }, + // Same channel, same reason (ADR-0071 §6): a tier the bound model will not take is withheld at send, and + // saying so on raw stderr would land on the alt buffer for one frame. `onceEffortNotice` keeps a standing + // condition — a stale `off` on a model that cannot disable thinking — from repeating every single turn. + onEffortWithheld: onceEffortNotice((note) => store.notice(note)), + onListenerError: (note) => store.notice(note), + onUnpriced: (note) => store.notice(note), }); - if (!signal.aborted && !rendererActive) throw new Error('Home renderer is not ready.'); - }, - // Same channel, same reason (ADR-0071 §6): a tier the bound model will not take is withheld at send, and - // saying so on raw stderr would land on the alt buffer for one frame. `onceEffortNotice` keeps a standing - // condition — a stale `off` on a model that cannot disable thinking — from repeating every single turn. - onEffortWithheld: onceEffortNotice((note) => store.notice(note)), - onListenerError: (note) => store.notice(note), - onUnpriced: (note) => store.notice(note), + const built = host === undefined ? await build() : await host.build(build); + return wireHomeChatSession(built, store, { open: true }); }); - return wireHomeChatSession(built, store, { open: true }); - }; // Reseat the in-Home chat onto a NEW model (ADR-0059) — the counterpart of the standalone `chat` reseat. Reload // the just-torn-down session's transcript from the SHARED db and RESUME it under a model-swapped agent (dropping @@ -741,119 +891,135 @@ export async function driveHome(deps: HomeDeps): Promise { sessionId: string, target: ReseatTarget, carriedTranscript: readonly TranscriptEntry[], - ): Promise => { - const loaded = opened.store.loadFull(sessionId); - if (loaded === undefined || loaded.session.agentSnapshot === undefined) { - throw new CliError( - 'invalid_invocation', - `cannot switch model: session ${sessionId} could not be reloaded for reseat`, + ): Promise => + constructChat(async (host) => { + const loaded = opened.store.loadFull(sessionId); + if (loaded === undefined || loaded.session.agentSnapshot === undefined) { + throw new CliError( + 'invalid_invocation', + `cannot switch model: session ${sessionId} could not be reloaded for reseat`, + ); + } + const newAgent = swapAgentModel( + loaded.session.agentSnapshot, + target.modelId, + target.provider, + target.reasoningEffort, ); - } - const newAgent = swapAgentModel( - loaded.session.agentSnapshot, - target.modelId, - target.provider, - target.reasoningEffort, - ); - const record: AgentSessionRecord = { ...loaded.session, agentSnapshot: newAgent }; - const resolvePrice = readUserPricingOverlay(opened.db); - // The store's SEED comes from the build (it needs `built.resumeState`), so it cannot exist before the build — - // yet a governor/effort callback could fire DURING it. Every notice sink here goes through this one indirection: - // render into the transcript once the store exists, otherwise BUFFER (review M5/bot) and flush after the store - // is wired. The old fallback wrote to stderr, which on the alt-screen renderer ink's next frame erases — so a - // withheld-effort notice that fired mid-build was lost, and `onceEffortNotice` had already marked it delivered - // (never to repeat). `onBudgetWarning` alone used to be guarded; the other two closed over the later `const - // store` and would TDZ-crash under the same condition it was protected against. - const storeRef: { current?: ChatStoreController } = {}; - const pendingNotes: string[] = []; - const noteToStore = (text: string): void => { - if (storeRef.current !== undefined) storeRef.current.notice(text); - else pendingNotes.push(text); - }; - const noteBudget = (warning: ChatBudgetWarning): void => - noteToStore(budgetWarningText(warning)); - const built = await (deps.buildResumedSession ?? buildResumedChatSession)({ - ...(nativeProcesses === undefined ? {} : { nativeProcesses }), - chat: config.chat, - fsPolicy, - ...(config.maxTokensEstimate === undefined - ? {} - : { maxTokensEstimate: config.maxTokensEstimate }), - consentGate: createConsentGate({ - io: deps.io, - global: deps.global, - homeDir, - prompt: createConsentPrompter(), - }), - record, - messages: loaded.messages, - now, - providers, - mcpSecretResolver, - mcpRegistrations: config.mcpServers, - ...(resolvePrice.size === 0 ? {} : { resolvePrice }), - onBudgetWarning: noteBudget, - onCompactionStart: async ({ windowUnknown, signal }) => { - if (!windowUnknown || signal.aborted) return; - if (!rendererActive || flushVisible === undefined) - throw new Error('Home renderer is not ready.'); - await flushVisible(() => { - if (signal.aborted) return; - const visibleStore = storeRef.current; - if (!rendererActive || visibleStore === undefined) - throw new Error('Home renderer is not ready.'); - visibleStore.notice(COMPACTION_UNKNOWN_WINDOW_NOTICE); + const record: AgentSessionRecord = { ...loaded.session, agentSnapshot: newAgent }; + const resolvePrice = readUserPricingOverlay(opened.db); + // The store's SEED comes from the build (it needs `built.resumeState`), so it cannot exist before the build — + // yet a governor/effort callback could fire DURING it. Every notice sink here goes through this one indirection: + // render into the transcript once the store exists, otherwise BUFFER (review M5/bot) and flush after the store + // is wired. The old fallback wrote to stderr, which on the alt-screen renderer ink's next frame erases — so a + // withheld-effort notice that fired mid-build was lost, and `onceEffortNotice` had already marked it delivered + // (never to repeat). `onBudgetWarning` alone used to be guarded; the other two closed over the later `const + // store` and would TDZ-crash under the same condition it was protected against. + const storeRef: { current?: ChatStoreController } = {}; + const pendingNotes: string[] = []; + const noteToStore = (text: string): void => { + if (storeRef.current !== undefined) storeRef.current.notice(text); + else pendingNotes.push(text); + }; + const noteBudget = (warning: ChatBudgetWarning): void => + noteToStore(budgetWarningText(warning)); + const build = (workOwner?: SessionWorkOwner) => + (deps.buildResumedSession ?? buildResumedChatSession)({ + ...(workOwner === undefined ? {} : { workOwner }), + ...(nativeProcesses === undefined ? {} : { nativeProcesses }), + chat: config.chat, + fsPolicy, + ...(config.maxTokensEstimate === undefined + ? {} + : { maxTokensEstimate: config.maxTokensEstimate }), + consentGate: + workOwner === undefined + ? createConsentGate({ + io: deps.io, + global: deps.global, + homeDir, + prompt: createConsentPrompter(), + }) + : createOwnedSessionConsentGate( + { io: deps.io, global: deps.global, homeDir }, + workOwner, + ), + record, + messages: loaded.messages, + now, + providers, + mcpSecretResolver, + mcpRegistrations: config.mcpServers, + ...(resolvePrice.size === 0 ? {} : { resolvePrice }), + onBudgetWarning: noteBudget, + onCompactionStart: async ({ windowUnknown, signal }) => { + if (!windowUnknown || signal.aborted) return; + if (!rendererActive || flushVisible === undefined) + throw new Error('Home renderer is not ready.'); + await flushVisible(() => { + if (signal.aborted) return; + const visibleStore = storeRef.current; + if (!rendererActive || visibleStore === undefined) + throw new Error('Home renderer is not ready.'); + visibleStore.notice(COMPACTION_UNKNOWN_WINDOW_NOTICE); + }); + if (!signal.aborted && !rendererActive) + throw new Error('Home renderer is not ready.'); + }, + // A RESEAT binds a different model — precisely when a tier that was fine a moment ago stops being accepted. + // Through `noteToStore`, like every sink here, so none can TDZ on the store declared below. + onEffortWithheld: onceEffortNotice(noteToStore), + onListenerError: noteToStore, + onUnpriced: noteToStore, }); - if (!signal.aborted && !rendererActive) throw new Error('Home renderer is not ready.'); - }, - // A RESEAT binds a different model — precisely when a tier that was fine a moment ago stops being accepted. - // Through `noteToStore`, like every sink here, so none can TDZ on the store declared below. - onEffortWithheld: onceEffortNotice(noteToStore), - onListenerError: noteToStore, - onUnpriced: noteToStore, - }); - // Seed the view store with the carried model + cost/turns — a resumed session never re-emits session:started, - // so without this the footer shows nothing until the first new turn (mirrors chatResumeCommand). - const costs = opened.store.loadSessionCostBreakdown(built.sessionId); - const store = createChatStore( - deps.global.color, - { - agentRef: built.agent.id, - model: built.agent.model, - // The amount and its qualifiers share this read even if another live host settled during build. - cumulativeCostMicrocents: costs.totalCostMicrocents, - usageCoverage: costs.coverage, - unpricedCalls: costs.rows.reduce((sum, row) => sum + row.unpricedCalls, 0), - turnCount: built.resumeState.turnCount, - // The OUTGOING store's rendered conversation (2.6.C / F1). On the full-screen renderer the store IS the - // scrollback, so a reseat that seeded `[]` blanked the screen. The gate drops this on the inline renderer, - // where ink's `` already printed the lines and re-seeding would double-print them. - transcript: carriedTranscript, - }, - transcriptBoundFor(altScreenActive), - ); - // TRIPWIRE (2.6.C): the store's transcript bound and the renderer both derive from `altScreenActive`, so they - // agree BY CONSTRUCTION here — this pins that, loudly, if a future edit ever gives them separate sources. - assertRenderStoreAgree(altScreenActive, store.getSnapshot().state.transcriptBound); - storeRef.current = store; // from here a budget warning renders in the transcript, not on the alt buffer - // Flush any notice that fired DURING the build (an effort-withheld or unpriced note on the reseated model) - // into the transcript now that the store exists — it would otherwise have been written to erased stderr. - for (const note of pendingNotes) store.notice(note); - pendingNotes.length = 0; - return wireHomeChatSession(built, store, { - open: false, - initialSequenceNumber: built.nextSequenceNumber, + const built = host === undefined ? await build() : await host.build(build); + // Seed the view store with the carried model + cost/turns — a resumed session never re-emits session:started, + // so without this the footer shows nothing until the first new turn (mirrors chatResumeCommand). + const costs = opened.store.loadSessionCostBreakdown(built.sessionId); + const store = createChatStore( + deps.global.color, + { + agentRef: built.agent.id, + model: built.agent.model, + // The amount and its qualifiers share this read even if another live host settled during build. + cumulativeCostMicrocents: costs.totalCostMicrocents, + usageCoverage: costs.coverage, + unpricedCalls: costs.rows.reduce((sum, row) => sum + row.unpricedCalls, 0), + turnCount: built.resumeState.turnCount, + // The OUTGOING store's rendered conversation (2.6.C / F1). On the full-screen renderer the store IS the + // scrollback, so a reseat that seeded `[]` blanked the screen. The gate drops this on the inline renderer, + // where ink's `` already printed the lines and re-seeding would double-print them. + transcript: carriedTranscript, + }, + transcriptBoundFor(altScreenActive), + ); + // TRIPWIRE (2.6.C): the store's transcript bound and the renderer both derive from `altScreenActive`, so they + // agree BY CONSTRUCTION here — this pins that, loudly, if a future edit ever gives them separate sources. + assertRenderStoreAgree(altScreenActive, store.getSnapshot().state.transcriptBound); + storeRef.current = store; // from here a budget warning renders in the transcript, not on the alt buffer + // Flush any notice that fired DURING the build (an effort-withheld or unpriced note on the reseated model) + // into the transcript now that the store exists — it would otherwise have been written to erased stderr. + for (const note of pendingNotes) store.notice(note); + pendingNotes.length = 0; + return wireHomeChatSession(built, store, { + open: false, + initialSequenceNumber: built.nextSequenceNumber, + }); }); - }; const getSize = deps.getSize ?? - (() => ({ cols: process.stdout.columns ?? 80, rows: process.stdout.rows ?? 24 })); + (() => ({ + cols: (ownership === undefined ? process.stdout : deps.io.nativeStdout)?.columns ?? 80, + rows: (ownership === undefined ? process.stdout : deps.io.nativeStdout)?.rows ?? 24, + })); const subscribeResize = deps.subscribeResize ?? ((onResize: () => void) => { - process.stdout.on('resize', onResize); - return () => process.stdout.off('resize', onResize); + const output = ownership === undefined ? process.stdout : deps.io.nativeStdout; + if (output === undefined) throw new CliError('cli_internal', ''); + output.on('resize', onResize); + return () => output.off('resize', onResize); }); const exitProcess = deps.exit ?? ((code: number) => process.exit(code)); @@ -903,37 +1069,108 @@ export async function driveHome(deps: HomeDeps): Promise { } }); }; - unsubscribeSignals = (deps.subscribeSignals ?? defaultSubscribeSignals)(onSignal); + if (ownership === undefined) + unsubscribeSignals = (deps.subscribeSignals ?? defaultSubscribeSignals)(onSignal); // The LAST net. `onSignal` covers the catchable kills; this covers everything that reaches Node's `'exit'` without // unwinding our `finally` — a `process.exit()` from a nested command, an uncaught throw, an unhandled rejection. // It must be synchronous, which `restoreTerminalControls` is; the latch makes the overlap with the other nets free. - unsubscribeProcessExit = (deps.subscribeProcessExit ?? defaultSubscribeProcessExit)(() => { - disposeJobControl?.(); - restoreTerminalControls(); - }); + const registerExitNet = () => + (deps.subscribeProcessExit ?? defaultSubscribeProcessExit)(() => { + if (ownership !== undefined) { + // Process death permits finite captured restoration only. Never start an async + // delivery or treat this last retry as positive terminal/database acknowledgement. + try { + disposeJobControl?.(); + } catch { + /* No asynchronous recovery during process exit. */ + } + try { + instance?.unmount(); + } catch { + /* Other finite restoration still runs. */ + } + try { + deps.io.nativeStdin?.setRawMode(false); + } catch { + /* Attempt the captured output too. */ + } + try { + deps.io.nativeStdout?.write(DISABLE_BRACKETED_PASTE + DISABLE_MOUSE + SHOW_CURSOR); + } catch { + /* The process is already exiting. */ + } + return; + } + disposeJobControl?.(); + restoreTerminalControls(); + }); + if (setupView === undefined) unsubscribeProcessExit = registerExitNet(); + else setupView.acquire(registerExitNet); // SIGTSTP/SIGCONT are a SEPARATE, reversible lifecycle. `suspendFullScreen` gives Ink ownership of raw mode and // Home's alt buffer while it temporarily releases our live mouse capture; on SIGCONT Ink restores both and forces // a redraw. It never reaches the termination handler above, so it cannot close the db or end a live chat. - const jobControl = wireJobControl({ - suspendPort, - lifecycle: deps.jobControlLifecycle ?? defaultJobControlLifecycle, - run: (suspendTerminal, waitForContinue) => - suspendFullScreen( - { - suspendTerminal, - writeControl, - // Home mounts Ink with its OWN alternate-screen render option. `suspendFullScreen` must never double - // toggle 1049 here, even on an inline Home where `altActive` is false. - inkOwnsAltScreen: true, - altActive: altScreenActive, - mouseActive: mouseCaptured, + const jobNative: { binding?: ReturnType } = {}; + const createJob = () => + wireJobControl({ + ...(ownership === undefined + ? {} + : { + adopt: (binding: ReturnType) => { + jobNative.binding = binding; + }, + onFailure: ownership.surface.work.cleanupUncertain, + }), + suspendPort, + lifecycle: deps.jobControlLifecycle ?? defaultJobControlLifecycle, + run: (suspendTerminal, waitForContinue) => + suspendFullScreen( + { + suspendTerminal, + writeControl, + // Home mounts Ink with its OWN alternate-screen render option. `suspendFullScreen` must never double + // toggle 1049 here, even on an inline Home where `altActive` is false. + inkOwnsAltScreen: true, + altActive: altScreenActive, + mouseActive: mouseCaptured, + }, + waitForContinue, + ), + releaseWithoutInk: releaseOnboardingTerminal, + reclaimWithoutInk: reclaimOnboardingTerminal, + ...(controls === undefined + ? {} + : { + releaseWithoutInkAcknowledged: async () => { + const released = releaseOnboardingTerminal(); + await controls.joinEntered(); + return released; + }, + reclaimWithoutInkAcknowledged: async () => { + reclaimOnboardingTerminal(); + await controls.joinEntered(); + }, + }), + }); + let jobControl: ReturnType; + if (ownership === undefined) jobControl = createJob(); + else { + ownedJob = new SessionTerminalOwner( + ownership.surface.invocation, + ownership.surface.work.assertAdmitting, + ownership.surface.work.failure, + ); + jobControl = ownedJob.acquire((creation) => { + creation.adopt({ + seal: () => jobNative.binding?.dispose(), + release: () => { + if (jobNative.binding === undefined) throw new CliError('cli_internal', ''); + return jobNative.binding.finish(); }, - waitForContinue, - ), - releaseWithoutInk: releaseOnboardingTerminal, - reclaimWithoutInk: reclaimOnboardingTerminal, - }); + }); + return createJob(); + }); + } disposeJobControl = jobControl.dispose; // First-run onboarding (2.5.G S8): a truly KEY-LESS bare Home offers a `@clack` wizard to connect a provider @@ -946,11 +1183,16 @@ export async function driveHome(deps: HomeDeps): Promise { // Clack's raw-mode listener is registered after this one, so this sees SUB first. Clack itself treats Ctrl-Z as // neither submit nor cancel, but consuming the byte here makes the intended POSIX handoff explicit and keeps it // out of every wizard prompt/secret field. - unsubscribeOnboardingInput = onboardingTerminal.onInput((input) => { - if (input.includes('\x1a')) jobControl.requestSuspend(); - }); + const onboardingView = ownership?.view(ownership.surface.work); + const registerInput = () => + onboardingTerminal.onInput((input) => { + if (input.includes('\x1a')) jobControl.requestSuspend(); + }); + if (onboardingView === undefined) unsubscribeOnboardingInput = registerInput(); + else onboardingView.acquire(registerInput); try { await runOnboardingWizard({ + ...(ownership === undefined ? {} : { workOwner: ownership.surface.work }), store: providerStore, keychain, resolver: providers, @@ -966,6 +1208,7 @@ export async function driveHome(deps: HomeDeps): Promise { ...(deps.onboardingPrompter === undefined ? {} : { prompter: deps.onboardingPrompter }), }); } finally { + onboardingView?.release(); unsubscribeOnboardingInput?.(); unsubscribeOnboardingInput = undefined; // A real process cannot execute this while stopped. It makes an injected/failing lifecycle safe, and is a @@ -975,6 +1218,7 @@ export async function driveHome(deps: HomeDeps): Promise { } } + ownership?.surface.work.assertAdmitting(); // Bracketed paste (DECSET 2004) is enabled by ink 7's `usePaste` on mount (home-app.tsx). The defensive // `DISABLE_BRACKETED_PASTE` writes in `restoreTerminalControls` are belt-and-suspenders (usePaste also // disables on unmount) so an external signal can never leave the terminal in bracketed-paste mode. @@ -997,6 +1241,18 @@ export async function driveHome(deps: HomeDeps): Promise { }); return await new Promise((resolve, reject) => { + stopHomeInput = (): void => { + if (homeInputSealed) return; + homeInputSealed = true; + rendererActive = false; + flushVisible = undefined; + ownership?.releaseViews(); + if (controller !== undefined) + void controller + .teardownActive() + .catch((cause: unknown) => ownership?.surface.work.failure(cause)); + resolve(EXIT_CODES.success); + }; controller = createHomeController({ startChat, reseatChat, @@ -1004,12 +1260,21 @@ export async function driveHome(deps: HomeDeps): Promise { doctorProbes, models, onExit: () => { + if (ownership !== undefined) stopHomeInput(); rendererActive = false; resolve(EXIT_CODES.success); }, // a clean Home exit is exit 0 onError: (err) => { rendererActive = false; - reject(err instanceof Error ? err : new Error(String(err))); + ownership?.surface.work.failure(err); + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Preserve the observed original owned cause; never format private unknown error text. + reject( + ownership === undefined + ? err instanceof Error + ? err + : new Error('Home controller failed.') + : err, + ); }, }); const alternateScreen = renderMode === 'alt'; @@ -1027,16 +1292,62 @@ export async function driveHome(deps: HomeDeps): Promise { mouseEnabled: mouseActive, configCopyOnSelect: config.copyOnSelect, }); + if (ownership !== undefined) { + if ( + deps.render === undefined && + (deps.io.nativeStdin === undefined || + deps.io.nativeStdout === undefined || + deps.io.nativeStderr === undefined) + ) + throw new CliError('cli_internal', ''); + ownedMount = new SessionInkMount({ + invocation: ownership.surface.invocation, + assertEntering: ownership.surface.work.assertAdmitting, + failure: ownership.surface.work.failure, + suspendPort, + stopInput: stopHomeInput, + drainNative: jobControl.finish, + ...(deps.render !== undefined + ? {} + : { + input: (() => { + if (deps.io.nativeStdin === undefined) throw new CliError('cli_internal', ''); + return new SessionInkInput({ + input: deps.io.nativeStdin, + invocation: ownership.surface.invocation, + failure: ownership.surface.work.failure, + acknowledge: (text) => deps.io.writeOutAcknowledged(text), + }); + })(), + }), + }); + } + const actualController = controller; + const inputController: HomeController = + ownership === undefined + ? actualController + : { + ...actualController, + handleKey: (char, key) => { + if (!homeInputSealed) actualController.handleKey(char, key); + }, + handlePaste: (text) => { + if (!homeInputSealed) actualController.handlePaste(text); + }, + }; const props: RootAppProps = { + inputEnabled: () => !homeInputSealed, onRendererReady: (flush) => { - flushVisible = flush; + if (!homeInputSealed) flushVisible = flush; }, onRendererError: (error) => { rendererActive = false; - if (controller !== undefined) void controller.teardownActive().catch(() => undefined); + ownership?.surface.work.failure(error); + if (ownership === undefined && controller !== undefined) + void controller.teardownActive().catch(() => undefined); reject(error); }, - controller, + controller: inputController, nowMs: now, color: deps.global.color, getSize, @@ -1044,10 +1355,12 @@ export async function driveHome(deps: HomeDeps): Promise { // The in-Home chat renders its transcript through the scroll viewport when mounted on the alt screen (Step 4b). alternateScreen, // `RootApp` attaches ink's `suspendTerminal` here while mounted (2.6.F Step 5d, ADR-0068 §e). - suspendPort, + suspendPort: ownedMount?.port ?? suspendPort, // Raw Ink mode delivers Ctrl-Z as a key event, not a process SIGTSTP. Route it into the same coordinator as // an external `kill -TSTP`, so both paths release/reclaim terminal state identically. - onSuspend: jobControl.requestSuspend, + onSuspend: () => { + if (!homeInputSealed) jobControl.requestSuspend(); + }, // The branded banner's durable switch (Step 5g); `HomeView` owns the empty-Home rule when it is absent. showBanner: config.showBanner, // Armed only while the in-Home chat owns the screen (Step 6g). `mouseActive` is the RESOLVED mode @@ -1055,8 +1368,9 @@ export async function driveHome(deps: HomeDeps): Promise { ...(mouseActive ? { setMouseCapture: (enabled: boolean) => { + if (enabled && homeInputSealed) return; mouseCaptured = enabled; // the hatch ports read this LIVE, like `altScreenActive` - writeControl(enabled ? ENABLE_MOUSE : DISABLE_MOUSE); + void writeControl(enabled ? ENABLE_MOUSE : DISABLE_MOUSE); }, } : {}), @@ -1065,9 +1379,16 @@ export async function driveHome(deps: HomeDeps): Promise { // `[preferences].copy_on_select = false`: the selection still highlights, and `/copy` still copies. ...(copyOnSelect ? { clipboard } : {}), }; - instance = + const mount = () => deps.render === undefined ? render(createElement(RootApp, props), { + ...(ownership === undefined + ? {} + : { + stdin: deps.io.nativeStdin, + stdout: deps.io.nativeStdout, + stderr: deps.io.nativeStderr, + }), exitOnCtrlC: false, // the controller drives Ctrl-C, not ink's process.exit patchConsole: false, maxFps: Math.max(1, Math.round(1000 / FRAME_MS)), @@ -1079,16 +1400,27 @@ export async function driveHome(deps: HomeDeps): Promise { alternateScreen, }) : deps.render(props, { alternateScreen, interactive }); - void instance.waitUntilExit().then( + instance = ownedMount === undefined ? mount() : ownedMount.acquire(mount); + void (ownedMount?.exited ?? instance.waitUntilExit()).then( () => { rendererActive = false; - if (controller !== undefined) void controller.teardownActive().catch(() => undefined); + if (ownership === undefined && controller !== undefined) + void controller.teardownActive().catch(() => undefined); resolve(EXIT_CODES.success); }, (err: unknown) => { rendererActive = false; - if (controller !== undefined) void controller.teardownActive().catch(() => undefined); - reject(err instanceof Error ? err : new Error('Home renderer failed.')); + ownership?.surface.work.failure(err); + if (ownership === undefined && controller !== undefined) + void controller.teardownActive().catch(() => undefined); + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Preserve the owned native renderer rejection after observing it. + reject( + ownership === undefined + ? err instanceof Error + ? err + : new Error('Home renderer failed.') + : err, + ); }, ); // Mouse reporting is armed by `RootApp` as the in-Home CHAT takes the screen (`setMouseCapture`), not here: @@ -1096,19 +1428,43 @@ export async function driveHome(deps: HomeDeps): Promise { // (2.6.F Step 6g). Disabled on EVERY teardown path below — the `DISABLE_MOUSE` writes are unconditional there // (a no-op when it was never enabled, like DISABLE_BRACKETED_PASTE). }); + } catch (cause) { + ownership?.surface.work.failure(cause); + throw cause; } finally { - rendererActive = false; - flushVisible = undefined; - // The clean-exit / error / INIT-FAULT path (NOT the signal path, which exits the process directly): undo the - // terminal state, reclaim a live session, and close the shared db ONCE. The terminal restore swallows its own - // throw, so it neither turns a clean exit into a failure nor skips the teardown + close below — a faulty - // terminal can never leak the session or the db handle. - unsubscribeSignals?.(); - unsubscribeProcessExit?.(); - unsubscribeOnboardingInput?.(); - disposeJobControl?.(); - restoreTerminalControls(); - await controller?.teardownActive().catch(() => undefined); // always reclaim a live session - closeDb(); // always close the shared db + if (ownership !== undefined) { + stopHomeInput(); + ownership.releaseViews(); + // Start every independent cleanup before awaiting any actual native acknowledgement. + const closing = [ + ownedMount?.close() ?? Promise.resolve(), + ownedJob?.close() ?? Promise.resolve(), + ownership.hosts.close(), + ]; + await Promise.all(closing); + if (ownership.surface.invocation.registry.budget.remainingMs() !== 0) { + try { + await controls?.write(DISABLE_BRACKETED_PASTE + DISABLE_MOUSE + SHOW_CURSOR); + await controls?.close(); + setupView?.release(); + } catch (cause) { + ownership.surface.work.cleanupUncertain(cause); + } + } + } else { + rendererActive = false; + flushVisible = undefined; + // The clean-exit / error / INIT-FAULT path (NOT the signal path, which exits the process directly): undo the + // terminal state, reclaim a live session, and close the shared db ONCE. The terminal restore swallows its own + // throw, so it neither turns a clean exit into a failure nor skips the teardown + close below — a faulty + // terminal can never leak the session or the db handle. + unsubscribeSignals?.(); + unsubscribeProcessExit?.(); + unsubscribeOnboardingInput?.(); + disposeJobControl?.(); + restoreTerminalControls(); + await controller?.teardownActive().catch(() => undefined); // always reclaim a live session + closeDb(); // always close the shared db + } } } diff --git a/apps/cli/src/home/session-home-lifetime.test.ts b/apps/cli/src/home/session-home-lifetime.test.ts new file mode 100644 index 00000000..b3aa09d1 --- /dev/null +++ b/apps/cli/src/home/session-home-lifetime.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { LlmProvider } from '@relavium/llm'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { CHAT_TEXT_CAPABILITY_FLAGS } from '../test-support.js'; +import { validateProviderKey } from '../engine/providers.js'; +import { SessionHomeLifetime } from './session-home-lifetime.js'; + +describe('Home shared database after actual onboarding work', () => { + it('a probe timeout cannot close the database before the actual producer settles', async () => { + const f = invocationFixture(); + const home = new SessionHomeLifetime(f.invocation, () => () => undefined); + const close = vi.fn(); + home.adoptStore({ close }); + let settle: () => void = () => undefined; + const raw = new Promise((_resolve, reject) => { + settle = () => reject(new Error('synthetic probe settled')); + }); + const provider: LlmProvider = { + id: 'anthropic', + supports: CHAT_TEXT_CAPABILITY_FLAGS, + generate: () => raw, + stream: () => { + throw new Error('unexpected stream'); + }, + }; + const work = home.surface.work; + const drive = home.execute(async () => { + const result = await validateProviderKey( + provider, + 'synthetic-probe-key', + 'synthetic-model', + 5, + { signal: work.signal, work: work.work, assertAdmitting: work.assertAdmitting }, + ); + expect(result.reason).toBe('network'); + return 0; + }); + await vi.waitFor(() => expect(work.signal.aborted).toBe(true)); + expect(close, 'HOME_PROBE_BEFORE_SHARED_DATABASE_CLOSE').not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + settle(); + expect(await drive).toBe(0); + expect(close).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + }); +}); diff --git a/apps/cli/src/home/session-home-lifetime.ts b/apps/cli/src/home/session-home-lifetime.ts new file mode 100644 index 00000000..a16ca1db --- /dev/null +++ b/apps/cli/src/home/session-home-lifetime.ts @@ -0,0 +1,73 @@ +import type { CliInvocation } from '../process/invocation.js'; +import type { ExitCode } from '../process/exit-codes.js'; +import { defaultSubscribeSignals } from '../process/signals.js'; +import { createSuspendPort } from '../render/suspend.js'; +import { SessionHosts } from '../chat/session-hosts.js'; +import { SessionSurfaceLifetime } from '../chat/session-surface-lifetime.js'; +import { SessionViewLifetime } from '../chat/session-view-lifetime.js'; +import type { SessionWorkOwner } from '../chat/session-work-owner.js'; +import { CliError } from '../process/cli-error.js'; + +/** The actual Home invocation owns pending, late and superseded chats and their shared database tail. */ +export class SessionHomeLifetime { + readonly surface: SessionSurfaceLifetime; + readonly hosts: SessionHosts; + readonly suspendPort = createSuspendPort(); + readonly #views = new Set(); + #store: { readonly close: () => void } | undefined; + + constructor(invocation: CliInvocation, subscribe = defaultSubscribeSignals) { + this.surface = new SessionSurfaceLifetime( + invocation, + 'home', + (signal) => + subscribe((signo) => { + if (signo === 2 && this.suspendPort.isSuspended()) return; + signal(signo); + }), + async () => { + this.#store?.close(); + await Promise.resolve(); + }, + ); + this.hosts = new SessionHosts(this.surface, 'home'); + } + + adoptStore(store: { readonly close: () => void }): void { + if (this.#store !== undefined) throw new CliError('cli_internal', ''); + this.#store = store; + try { + this.#store = { close: store.close.bind(store) }; + } catch (cause) { + this.surface.work.cleanupUncertain(cause); + throw cause; + } + } + + view(owner: SessionWorkOwner): SessionViewLifetime { + const view = new SessionViewLifetime(owner); + this.#views.add(view); + void view.done.then(() => { + this.#views.delete(view); + }); + return view; + } + + releaseViews(): void { + for (const view of this.#views) view.release(); + } + + execute(body: () => Promise): Promise { + return this.surface.execute(async () => { + try { + return await body(); + } catch (cause) { + this.surface.work.failure(cause); + throw cause; + } finally { + this.releaseViews(); + await this.hosts.close(); + } + }); + } +} diff --git a/apps/cli/src/onboarding/wizard-native.test.ts b/apps/cli/src/onboarding/wizard-native.test.ts new file mode 100644 index 00000000..dc44b28f --- /dev/null +++ b/apps/cli/src/onboarding/wizard-native.test.ts @@ -0,0 +1,52 @@ +import { PassThrough } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo, OwnedTtyOutput } from '../test-support.js'; +import { runSessionPrompt } from '../chat/session-prompt.js'; +import { createBoundOnboardingPrompter } from './wizard.js'; + +describe('installed onboarding prompts on captured invocation streams', () => { + it.each(['select', 'password'] as const)( + '%s releases captured native listeners on original host stop', + async (kind) => { + const f = invocationFixture(); + const input = new PassThrough(); + const output = new OwnedTtyOutput(); + const captured = captureIo(); + const parent = new AbortController(); + const io = { ...captured.io, stdin: input, nativeStdout: output }; + const prompt = createBoundOnboardingPrompter(io); + expect(prompt.spinner).toBeUndefined(); // Installed spinner would silently borrow ambient stdin. + const answer = runSessionPrompt( + f.invocation, + io, + () => f.registry.assertAdmitting(), + () => f.invocation.finiteExit.fail('cli_internal'), + (signal) => + kind === 'select' + ? prompt.select({ + message: 'Synthetic provider', + options: [{ value: 'synthetic', label: 'Synthetic' }], + signal, + }) + : prompt.password({ message: 'Synthetic hidden input', signal }), + parent.signal, + ); + try { + expect(input.listenerCount('keypress')).toBeGreaterThan(0); + expect(output.frames.join('')).toContain('Synthetic'); + parent.abort(); + expect(typeof (await answer)).toBe('symbol'); + expect(input.listenerCount('keypress'), 'ONBOARDING_CAPTURED_PROMPT_NATIVE_RELEASE').toBe( + 0, + ); + expect(output.listenerCount('resize')).toBe(0); + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); + } finally { + input.destroy(); + output.destroy(); + } + }, + ); +}); diff --git a/apps/cli/src/onboarding/wizard.test.ts b/apps/cli/src/onboarding/wizard.test.ts index 7449dae8..f34cf5ac 100644 --- a/apps/cli/src/onboarding/wizard.test.ts +++ b/apps/cli/src/onboarding/wizard.test.ts @@ -12,6 +12,8 @@ import { } from '../secrets/keychain.js'; import type { ProviderKeyValidation } from '../engine/providers.js'; import { isProviderKeyless, runOnboardingWizard, type ClackOnboardingDeps } from './wizard.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { SessionWorkOwner } from '../chat/session-work-owner.js'; const CANCEL = Symbol('clack-cancel'); @@ -156,6 +158,53 @@ describe('runOnboardingWizard', () => { const store = (): ReturnType => createProviderStore(client.db, { uuid: () => 'id-fixed', now: () => 1 }); + it.each(['select', 'password', 'validation'] as const)( + 'a stop during %s prevents the next prompt, key write and config write', + async (point) => { + const f = invocationFixture(); + const owner = new SessionWorkOwner(f.invocation, 'home', { + cancel: () => undefined, + cleanup: () => Promise.resolve(), + }); + const keychain = memKeychain(); + const writeDefaultModel = vi.fn(); + const fixture = scriptedPrompter({ provider: 'anthropic', key: 'synthetic-wizard-key' }); + const prompter = { + ...fixture.prompter, + select: () => { + if (point === 'select') owner.stop(); + return Promise.resolve('anthropic'); + }, + password: () => { + if (point === 'password') owner.stop(); + return Promise.resolve('synthetic-wizard-key'); + }, + }; + const validate = vi.fn(() => { + if (point === 'validation') owner.stop(); + return Promise.resolve({ ok: true, detail: 'ok', reason: 'ok' } as const); + }); + await expect( + runOnboardingWizard({ + workOwner: owner, + io, + store: store(), + keychain, + resolver: stubResolver, + prompter, + validate, + writeDefaultModel, + }), + ).rejects.toThrow('Session operation stopped.'); + await owner.close(); + expect(keychain.store.size, 'ONBOARDING_STOP_PREVENTS_CREDENTIAL_WRITE').toBe(0); + expect(writeDefaultModel).not.toHaveBeenCalled(); + if (point !== 'validation') expect(validate).not.toHaveBeenCalled(); + expect(fixture.outros).toEqual([]); + expect(f.registry.activeOwners).toBe(0); + }, + ); + it("stores the pasted key + registers the provider row + sets the chosen provider's starter model (secret-free)", async () => { const keychain = memKeychain(); const s = store(); diff --git a/apps/cli/src/onboarding/wizard.ts b/apps/cli/src/onboarding/wizard.ts index 6bbcd97d..5bfb39eb 100644 --- a/apps/cli/src/onboarding/wizard.ts +++ b/apps/cli/src/onboarding/wizard.ts @@ -16,6 +16,9 @@ import { import { CliError } from '../process/errors.js'; import type { CliIo } from '../process/io.js'; import { KeychainUnavailableError, type KeychainStore } from '../secrets/keychain.js'; +import { nativePromptIo } from '../process/native-prompt-io.js'; +import { runSessionPrompt } from '../chat/session-prompt.js'; +import type { SessionWorkOwner } from '../chat/session-work-owner.js'; /** * The first-run onboarding wizard (workstream **2.5.G S8**) — a `@clack/prompts` flow that turns a KEY-LESS Home @@ -50,11 +53,13 @@ export interface ClackOnboardingDeps { options: readonly { value: string; label: string; hint?: string }[]; /** Pre-highlight an option so a bare Enter takes it (2.5.G S8 — the "press Enter to continue" affordance). */ initialValue?: string; + signal?: AbortSignal; }) => Promise; /** A MASKED key prompt (clack `password`) — the hidden interactive key input. */ readonly password: (opts: { message: string; validate?: (value: string | undefined) => string | undefined; + signal?: AbortSignal; }) => Promise; /** Clack's cancel sentinel guard (Ctrl-C / ESC) — a real type guard so a non-cancel value narrows. */ readonly isCancel: (value: unknown) => value is symbol; @@ -91,8 +96,64 @@ const defaultPrompter: ClackOnboardingDeps = { }, }; +/** Native prompts use captured streams. Installed Clack spinner borrows ambient stdin, so omit it. */ +export function createBoundOnboardingPrompter(io: CliIo): ClackOnboardingDeps { + const streams = nativePromptIo(io); + return { + intro: (title) => intro(title, { output: streams.output }), + outro: (message) => outro(message, { output: streams.output }), + note: (message, title) => note(message, title, { output: streams.output }), + select: (options) => select({ ...options, ...streams, options: [...options.options] }), + password: (options) => password({ ...options, ...streams }), + isCancel, + }; +} + +function ownedPrompter(deps: OnboardingDeps): ClackOnboardingDeps { + const owner = deps.workOwner; + const native = + deps.prompter ?? + (owner === undefined ? defaultPrompter : createBoundOnboardingPrompter(deps.io)); + if (owner === undefined) return native; + const guarded = (operation: () => T): T => { + owner.assertAdmitting(); + return operation(); + }; + return { + intro: (title) => guarded(() => native.intro(title)), + outro: (message) => guarded(() => native.outro(message)), + note: (message, title) => guarded(() => native.note(message, title)), + select: async (options) => { + const answer = await runSessionPrompt( + owner.invocation, + deps.io, + owner.assertAdmitting, + owner.failure, + (signal) => native.select({ ...options, signal }), + owner.signal, + ); + owner.assertAdmitting(); + return answer; + }, + password: async (options) => { + const answer = await runSessionPrompt( + owner.invocation, + deps.io, + owner.assertAdmitting, + owner.failure, + (signal) => native.password({ ...options, signal }), + owner.signal, + ); + owner.assertAdmitting(); + return answer; + }, + isCancel: native.isCancel, + }; +} + /** The non-clack ports the wizard needs — the keychain-write path + the resolver (injected for tests). */ export interface OnboardingDeps { + readonly workOwner?: SessionWorkOwner; /** The clack slice — omit for the real prompts; a test injects a scripted one. */ readonly prompter?: ClackOnboardingDeps; readonly store: ProviderStore; @@ -134,7 +195,7 @@ const requireKey = (value: string | undefined): string | undefined => * (`select`/`password`/`note`/`outro`) propagates, and that is caught by the Home's cleanup `finally`. */ export async function runOnboardingWizard(deps: OnboardingDeps): Promise { - const p = deps.prompter ?? defaultPrompter; + const p = ownedPrompter(deps); p.intro('Welcome to Relavium'); p.note( 'Connect a model provider to start chatting.\nYour key is stored in your OS keychain — never on disk.', @@ -169,6 +230,7 @@ export async function runOnboardingWizard(deps: OnboardingDeps): Promise { // "connected" copy stays honest on the continue-anyway path. `keyToStore` is what we finally persist (the // originally-typed or a re-entered key). A `null` return ⇒ the user skipped mid-retry. const outcome = await validateWithRetry(p, deps, provider, key); + deps.workOwner?.assertAdmitting(); if (outcome === null) return; // skip already surfaced its note/outro const { keyToStore, verified } = outcome; @@ -194,6 +256,9 @@ export async function runOnboardingWizard(deps: OnboardingDeps): Promise { keychain: deps.keychain, resolver: deps.resolver, readSecret: () => Promise.resolve(keyToStore), + ...(deps.workOwner === undefined + ? {} + : { assertAdmitting: deps.workOwner.assertAdmitting }), // `global` is read only by `provider list --json`; `set-key` never touches it — a throwaway (the wizard is // always interactive, never `--json`). global: { @@ -205,7 +270,12 @@ export async function runOnboardingWizard(deps: OnboardingDeps): Promise { }, }, ); + deps.workOwner?.assertAdmitting(); } catch (err) { + if (deps.workOwner !== undefined) { + deps.workOwner.failure(err); + deps.workOwner.assertAdmitting(); + } // Distinguish the EXPECTED keychain-unavailable failure (recoverable → env-var guidance) from an UNEXPECTED // fault (e.g. a db write). `providerSetKey` writes to the keychain FIRST, so a keychain-unavailable failure // persists NOTHING; mislabeling a *post*-`keychain.set` fault (a later db upsert) as "keychain unavailable, key @@ -248,12 +318,17 @@ export async function runOnboardingWizard(deps: OnboardingDeps): Promise { ? `Verified and stored your ${provider} key (${keyHint(keyToStore)}) in the OS keychain.` : `Saved your ${provider} key (${keyHint(keyToStore)}) — it couldn't be verified now. Run /doctor to re-check.`; try { + deps.workOwner?.assertAdmitting(); deps.writeDefaultModel(starterModel, provider); p.note( `${storedLine}\nYour default model is ${starterModel} — change it anytime with /models.`, 'Connected', ); - } catch { + } catch (cause) { + if (deps.workOwner !== undefined) { + deps.workOwner.failure(cause); + deps.workOwner.assertAdmitting(); + } p.note(`${storedLine}\nPick a ${provider} model with /models to start chatting.`, 'Connected'); } p.outro("You're all set — starting Relavium."); @@ -281,11 +356,23 @@ async function validateWithRetry( // can't probe, so it treats the key as ok to avoid blocking the scripted flow; in production every first-run // key is genuinely probed, so the "Verified and stored" copy is never shown for an un-probed key. if (adapter === undefined) return { ok: true, detail: 'skipped', reason: 'ok' }; - return validateProviderKey(adapter, k, KNOWN_PROVIDERS[id].testModel); + const owner = deps.workOwner; + return validateProviderKey( + adapter, + k, + KNOWN_PROVIDERS[id].testModel, + undefined, + owner === undefined + ? undefined + : { signal: owner.signal, work: owner.work, assertAdmitting: owner.assertAdmitting }, + ); }); let keyToStore = key; for (;;) { + deps.workOwner?.assertAdmitting(); + if (deps.workOwner !== undefined) + p.note(`Checking your ${KNOWN_PROVIDERS[provider].displayName} key…`, 'Verification'); const sp = p.spinner?.(); sp?.start(`Checking your ${KNOWN_PROVIDERS[provider].displayName} key…`); // The spinner MUST stop even if the probe rejects unexpectedly (validateProviderKey never rejects, but a future @@ -294,6 +381,7 @@ async function validateWithRetry( let res: ProviderKeyValidation; try { res = await validate(provider, keyToStore); + deps.workOwner?.assertAdmitting(); } finally { sp?.stop('Key check finished.'); } diff --git a/apps/cli/src/process/exit-observations.ts b/apps/cli/src/process/exit-observations.ts index a38d3856..feabeaed 100644 --- a/apps/cli/src/process/exit-observations.ts +++ b/apps/cli/src/process/exit-observations.ts @@ -25,6 +25,7 @@ export class ExitObservations { #signal: number | undefined; #ordinary: OrdinaryExit = EXIT_CODES.success; #background = false; + #cooperative = false; observeWorkflowSafety(value: WorkflowSafetyExit | undefined): void { if (value !== undefined && value !== 8 && value !== 7 && value !== 5 && value !== 6) { @@ -76,6 +77,17 @@ export class ExitObservations { this.#signal ??= signalNumber; } + /** A surface's existing forced cooperative outcome, distinct from a terminating OS signal. */ + observeCooperativeCancel(outcome: 1 | 4): void { + if (outcome !== 1 && outcome !== 4) throw new CliError('cli_internal', ''); + this.#cooperative = true; + this.observeOutcome(outcome); + } + + hasCooperativeCancel(): boolean { + return this.#cooperative; + } + /** Keepalive backstop only. This neither stops admission nor overrides an established nonzero. */ observeBackgroundFailure(): void { this.#background = true; diff --git a/apps/cli/src/process/invocation-finite-exit.test.ts b/apps/cli/src/process/invocation-finite-exit.test.ts index 55574b86..5d3b4dbb 100644 --- a/apps/cli/src/process/invocation-finite-exit.test.ts +++ b/apps/cli/src/process/invocation-finite-exit.test.ts @@ -54,6 +54,80 @@ function heldOwner(registry: InvocationRegistry, stop = vi.fn()) { const turn = () => new Promise((resolve) => setImmediate(resolve)); describe('invocation finite exit and explicit native handoff', () => { + it('bounds forced cooperative chat end4 without recording a terminating signal or acknowledging held cleanup', async () => { + const f = fixture(); + const life = heldOwner(f.registry); + f.finite.cooperativeCancel(4); + expect(f.registry.observations.hasTerminatingSignal()).toBe(false); + expect(f.registry.observations.exitCode()).toBe(4); + expect(f.registry.budget.deadline()).toBe(3000); + f.clock.advance(900); + f.finite.cooperativeCancel(4); + expect(f.registry.budget.deadline(), 'CHAT_FORCE_END_SINGLE_FINITE_BUDGET').toBe(3000); + f.clock.advance(3000); + expect(await f.finite.exited).toBe(4); + expect(f.registry.activeOwners).toBe(1); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(4); + life.resolve({ kind: 'settled' }); + }); + + it('exits forced cooperative end4 early only after every real owner retires', async () => { + const f = fixture(); + const life = heldOwner(f.registry); + f.finite.cooperativeCancel(4); + await turn(); + expect(f.exit).not.toHaveBeenCalled(); + life.resolve({ kind: 'settled' }); + expect(await f.finite.exited).toBe(4); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(4); + }); + + it('a competing fatal upgrades cooperative end to9 under the same original deadline', async () => { + const f = fixture(); + const life = heldOwner(f.registry); + f.finite.cooperativeCancel(4); + f.clock.advance(700); + f.finite.fail('cli_internal'); + life.resolve({ kind: 'settled' }); + await turn(); + expect(f.registry.budget.deadline()).toBe(3000); + expect(f.exit, 'CHAT_FORCE_END_CANNOT_SKIP_FATAL_DIAGNOSTIC').not.toHaveBeenCalled(); + f.clock.advance(3000); + expect(await f.finite.exited).toBe(9); + }); + + it('hands cooperative end4 to native finalization without a signal or spurious diagnostic', async () => { + const f = fixture(); + const life = heldOwner(f.registry); + const stdout = new OwnedTtyOutput(); + const stderr = new OwnedTtyOutput(); + f.finite.cooperativeCancel(4); + const native = installNativeInvocation( + f.registry, + { json: true, verbosity: 'normal' }, + { + stdout, + stderr, + schedule: f.clock.schedule, + onFatal: () => () => undefined, + onBackground: () => () => undefined, + readExitCode: () => undefined, + setExitCode: vi.fn(), + exit: vi.fn(), + }, + f.finite, + ); + await turn(); + expect(f.exit).not.toHaveBeenCalled(); + life.resolve({ kind: 'settled' }); + expect(await f.finite.exited).toBe(4); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(4); + expect(stderr.frames).toEqual([]); + native.dispose(); + stdout.destroy(); + stderr.destroy(); + }); + it('starts the shared clock at the first owned fault and never exits before cleanup diagnostics can run', async () => { const f = fixture(); const life = heldOwner(f.registry); diff --git a/apps/cli/src/process/invocation-finite-exit.ts b/apps/cli/src/process/invocation-finite-exit.ts index 0c447b48..2c27027e 100644 --- a/apps/cli/src/process/invocation-finite-exit.ts +++ b/apps/cli/src/process/invocation-finite-exit.ts @@ -17,6 +17,8 @@ export interface InvocationFiniteExit { readonly activateFallback: () => void; readonly fail: (code: 'cli_internal' | 'output_unavailable') => void; readonly signal: (signalNumber: number) => void; + /** Bound an existing forced cooperative outcome without relabelling it as an external signal. */ + readonly cooperativeCancel: (outcome: 1 | 4) => void; readonly handoff: (registry: InvocationRegistry, changed: () => void) => () => void; readonly exitNow: () => void; readonly hasExited: () => boolean; @@ -96,7 +98,8 @@ export function createInvocationFiniteExit( following || termination === undefined || (!registry.observations.hasInfrastructureFailure() && - !registry.observations.hasTerminatingSignal()) + !registry.observations.hasTerminatingSignal() && + !registry.observations.hasCooperativeCancel()) ) return; following = true; @@ -106,7 +109,8 @@ export function createInvocationFiniteExit( if ( mode === 'fallback' && generation === observedGeneration && - registry.observations.hasTerminatingSignal() && + (registry.observations.hasTerminatingSignal() || + registry.observations.hasCooperativeCancel()) && !registry.observations.hasInfrastructureFailure() ) exitNow(); @@ -150,6 +154,18 @@ export function createInvocationFiniteExit( } void registry.terminate({ kind: 'signal', signalNumber, deadline }); }, + cooperativeCancel: (outcome: 1 | 4) => { + if (outcome !== 1 && outcome !== 4) throw new CliError('cli_internal', ''); + if (exited || mode === 'disposed') return; + activateFallback(); + try { + const deadline = clock.now() + FATAL_TERMINATION_MS; + if (!Number.isFinite(deadline)) throw new CliError('cli_internal', ''); + void registry.terminate({ kind: 'cooperative_cancel', outcome, deadline }); + } catch { + void registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + } + }, handoff: (candidate: InvocationRegistry, callback: () => void) => { if (candidate !== registry || mode === 'native' || mode === 'disposed' || exited) throw new CliError('cli_internal', ''); diff --git a/apps/cli/src/process/invocation-registry.ts b/apps/cli/src/process/invocation-registry.ts index 72db453e..5167083c 100644 --- a/apps/cli/src/process/invocation-registry.ts +++ b/apps/cli/src/process/invocation-registry.ts @@ -93,6 +93,7 @@ export interface OwnerCloseResult { export type TerminationRequest = | { readonly kind: 'normal' } | { readonly kind: 'infrastructure'; readonly code: 'cli_internal' | 'output_unavailable' } + | { readonly kind: 'cooperative_cancel'; readonly outcome: 1 | 4; readonly deadline: number } | { readonly kind: 'signal'; readonly signalNumber: number; readonly deadline?: number }; export interface InvocationCloseResult { @@ -232,6 +233,10 @@ export class InvocationRegistry { /** Publish the shared promise BEFORE entering callbacks, including reentrant failures. */ terminate(request: TerminationRequest): Promise { + if (request.kind === 'cooperative_cancel') { + if ((request.outcome !== 1 && request.outcome !== 4) || !Number.isFinite(request.deadline)) + throw new CliError('cli_internal', ''); + } if (request.kind === 'signal') { // Pure validation precedes sealing; a malformed internal request must not leave a pending coordinator. assertTerminatingSignalNumber(request.signalNumber); @@ -478,6 +483,14 @@ export class InvocationRegistry { this.observations.observeFailure('cli_internal'); this.#expire(); } + } else if (request.kind === 'cooperative_cancel') { + this.observations.observeCooperativeCancel(request.outcome); + try { + this.#tighten(request.deadline); + } catch { + this.observations.observeFailure('cli_internal'); + this.#expire(); + } } else if (request.kind === 'signal') { this.observations.observeTerminatingSignal(request.signalNumber); if (request.deadline !== undefined) { diff --git a/apps/cli/src/process/io.ts b/apps/cli/src/process/io.ts index 9301b845..365c3e47 100644 --- a/apps/cli/src/process/io.ts +++ b/apps/cli/src/process/io.ts @@ -7,6 +7,8 @@ import { CliError } from './cli-error.js'; export interface CliIo { /** Captured native TUI/prompt sink. Memory-only hosts omit it and cannot silently acquire the real TTY. */ readonly nativeStdout?: NodeJS.WriteStream; + /** Captured native renderer error sink; owned TUI acquisition never borrows a later process global. */ + readonly nativeStderr?: NodeJS.WriteStream; /** Native Ink input from the same captured invocation; line-only hosts retain stdin without this field. */ readonly nativeStdin?: NodeJS.ReadStream; writeOut(text: string): void; @@ -32,6 +34,7 @@ export function processIo(): CliIo { const { stdout, stderr, stdin } = process; return { nativeStdout: stdout, + nativeStderr: stderr, nativeStdin: stdin, writeOut: (text) => { stdout.write(text); diff --git a/apps/cli/src/process/native-invocation.ts b/apps/cli/src/process/native-invocation.ts index fb7fc29b..a9c5a83a 100644 --- a/apps/cli/src/process/native-invocation.ts +++ b/apps/cli/src/process/native-invocation.ts @@ -147,7 +147,8 @@ export function installNativeInvocation( registry.sampleWorkflowHealth(); if ( registry.observations.hasInfrastructureFailure() || - registry.observations.hasTerminatingSignal() + registry.observations.hasTerminatingSignal() || + registry.observations.hasCooperativeCancel() ) { exitNow(); return; @@ -180,7 +181,8 @@ export function installNativeInvocation( !hasExited() && !disposed && (registry.observations.hasInfrastructureFailure() || - registry.observations.hasTerminatingSignal()) + registry.observations.hasTerminatingSignal() || + registry.observations.hasCooperativeCancel()) ) changed(); }); diff --git a/apps/cli/src/render/hatches.test.ts b/apps/cli/src/render/hatches.test.ts index 3d19c5fb..b97831bb 100644 --- a/apps/cli/src/render/hatches.test.ts +++ b/apps/cli/src/render/hatches.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { CliError } from '../process/cli-error.js'; import { DISABLE_MOUSE, ENABLE_MOUSE, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN } from './alt-screen.js'; import { @@ -73,9 +74,13 @@ const harness = ( mouseActive: true, inkOwnsAltScreen: false, // the `relavium chat` shape }), - writeControl: (sequence) => trace.push(sequence), + writeControl: (sequence) => { + trace.push(sequence); + }, dump: { - writeOut: (text) => trace.push(`OUT:${text.split('\n').length}L`), + writeOut: (text) => { + trace.push(`OUT:${text.split('\n').length}L`); + }, waitForContinue: () => { trace.push('wait'); return Promise.resolve(); @@ -98,6 +103,41 @@ const harness = ( }; describe('/scrollback', () => { + it('observes failed transcript delivery before a held native reclaim can hide the failure', async () => { + let finishNative: () => void = () => undefined; + const native = new Promise((resolve) => { + finishNative = resolve; + }); + const primary = new CliError('output_unavailable', 'PRIVATE_OUTPUT_FAULT'); + const port = createSuspendPort(); + port.attach(async (body) => { + let failed: { cause: unknown } | undefined; + try { + await body(); + } catch (cause) { + failed = { cause }; + } + await native; + if (failed !== undefined) throw failed.cause; + }); + const failure = vi.fn(); + const wait = vi.fn(() => Promise.resolve()); + const { deps, notes } = harness({ + suspendPort: port, + onFailure: failure, + dump: { writeOut: () => Promise.reject(primary), waitForContinue: wait }, + }); + const dump = createHatches(deps).dumpScrollback(); + await vi.waitFor(() => + expect(failure, 'SESSION_HATCH_FAULT_BEFORE_NATIVE_RECLAIM').toHaveBeenCalledWith(primary), + ); + expect(wait).not.toHaveBeenCalled(); + expect(notes).toEqual([]); + finishNative(); + await dump; + expect(notes.join('')).not.toContain('PRIVATE_OUTPUT_FAULT'); + }); + it('suspends the renderer, dumps, waits, and restores every terminal mode — in order', async () => { const { deps, trace, notes } = harness(); await createHatches(deps).dumpScrollback(); diff --git a/apps/cli/src/render/hatches.ts b/apps/cli/src/render/hatches.ts index 983b7d91..26e58b87 100644 --- a/apps/cli/src/render/hatches.ts +++ b/apps/cli/src/render/hatches.ts @@ -1,4 +1,5 @@ import type { ClipboardOutcome } from './clipboard.js'; +import { toUserFacing } from '../process/errors.js'; import { openInEditor, type EditorOutcome, type OpenInEditorDeps } from './editor.js'; import { dumpToScrollback, type DumpToScrollbackDeps } from './scrollback.js'; import { createSuspendPort, suspendFullScreen, type SuspendPort } from './suspend.js'; @@ -21,9 +22,9 @@ import type { TranscriptEntry } from './tui/session-view-model.js'; * registry commands whose `run` calls a `ReplCommandContext` capability, dispatched through the one existing slash * path. The standalone chat and the in-Home chat therefore cannot drift. * - * NEVER CRASH THE REPL: every fault (no live renderer, an empty transcript, a rejected suspension, a missing - * `$EDITOR`, a dead editor) becomes a one-line transcript notice. The terminal is restored by `suspendFullScreen` - * regardless — see `suspend.ts` for the exit-safety contract. + * Known unavailable outcomes remain one-line transcript notices. An owned surface additionally observes + * infrastructure faults before a presentation catch consumes them. Native terminal release remains the + * creation owner's obligation; a settled suspension alone does not acknowledge complete restoration. */ /** The live terminal facts, read at CALL time — a resize, or a future `--no-mouse` toggle, must never be captured. */ @@ -41,6 +42,8 @@ export interface HatchTerminal { } export interface HatchDeps { + /** An owned surface observes unexpected failures before a presentation catch can consume them. */ + readonly onFailure?: (cause: unknown) => void; /** The React→core bridge carrying ink's `suspendTerminal`. `undefined` ⇒ no live full-screen renderer. */ readonly suspendPort: SuspendPort; /** The transcript at call time (the store's CURRENT snapshot — never a stale capture). */ @@ -50,7 +53,7 @@ export interface HatchDeps { /** The live terminal facts, read at call time. */ readonly terminal: () => HatchTerminal; /** Write a raw control sequence (the alt-buffer / mouse toggles). */ - readonly writeControl: (sequence: string) => void; + readonly writeControl: (sequence: string) => void | Promise; /** The scrollback dump's I/O (production: `nodeWriteOut` + `nodeWaitForContinue`). */ readonly dump: DumpToScrollbackDeps; /** The `$EDITOR` ports (production: `nodeSpawnEditor` + `nodeCreateTempDocument`). */ @@ -163,17 +166,38 @@ export function createHatches(deps: HatchDeps): Hatches { await suspendFullScreen( { suspendTerminal: suspend, - writeControl: deps.writeControl, + writeControl: async (sequence) => { + try { + await deps.writeControl(sequence); + } catch (cause) { + deps.onFailure?.(cause); + throw cause; + } + }, inkOwnsAltScreen: term.inkOwnsAltScreen, altActive: term.altActive, mouseActive: term.mouseActive, }, - body, + async () => { + try { + await body(); + } catch (cause) { + deps.onFailure?.(cause); + throw cause; + } + }, ); return true; } catch (error) { - // The terminal is already restored (suspend.ts's contract). Report the ROOT cause, never crash the REPL. - deps.note(`${label}: ${error instanceof Error ? error.message : String(error)}`); + deps.onFailure?.(error); + // Preserve the owned failure before its notice. Native release/ACK remains separate from presentation. + const message = + deps.onFailure === undefined + ? error instanceof Error + ? error.message + : String(error) + : toUserFacing(error).message; + deps.note(`${label}: ${message}`); return false; } finally { busy = false; diff --git a/apps/cli/src/render/scrollback.test.ts b/apps/cli/src/render/scrollback.test.ts index 5aa79226..bb4e8865 100644 --- a/apps/cli/src/render/scrollback.test.ts +++ b/apps/cli/src/render/scrollback.test.ts @@ -1,6 +1,6 @@ import { Writable } from 'node:stream'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { PassThrough } from 'node:stream'; @@ -26,7 +26,9 @@ const harness = ( ): { deps: DumpToScrollbackDeps; trace: string[] } => { const trace: string[] = []; const deps: DumpToScrollbackDeps = { - writeOut: (text) => trace.push(text), + writeOut: (text) => { + trace.push(text); + }, waitForContinue: () => { trace.push('waited'); return Promise.resolve(); @@ -37,6 +39,20 @@ const harness = ( }; describe('dumpToScrollback', () => { + it('waits for actual transcript delivery before acquiring the acknowledgement input', async () => { + let deliver: () => void = () => undefined; + const delivery = new Promise((resolve) => { + deliver = resolve; + }); + const wait = vi.fn(() => Promise.resolve()); + const { deps } = harness({ writeOut: () => delivery, waitForContinue: wait }); + const dump = dumpToScrollback(deps, ['synthetic transcript']); + expect(wait, 'SESSION_SCROLLBACK_DELIVERY_BEFORE_INPUT_ACQUISITION').not.toHaveBeenCalled(); + deliver(); + await dump; + expect(wait).toHaveBeenCalledOnce(); + }); + it('prints the banners around the transcript, then WAITS before the caller repaints', async () => { const { deps, trace } = harness(); await dumpToScrollback(deps, ['> hi', 'hello there']); @@ -107,6 +123,101 @@ const fakeInterrupts = (): InterruptSource & { raise: () => void; listeners: num * own stdin listeners and turned raw mode off, so it owns stdin for the duration and must hand it back clean. */ describe('nodeWaitForContinue', () => { + it('joins a reentrant native registration before acknowledging removal of its late listener', async () => { + const ref = vi.fn(); + const stdin = Object.assign(new PassThrough(), { ref }); + const interrupts = fakeInterrupts(); + stdin.on('newListener', (event: string | symbol) => { + if (event === 'end') stdin.emit('data', '\n'); + }); + await nodeWaitForContinue(stdin, interrupts)(); + expect(stdin.listenerCount('data')).toBe(0); + expect(stdin.listenerCount('end'), 'SESSION_HATCH_REGISTRATION_REENTRY_NO_LATE_LISTENER').toBe( + 0, + ); + expect(stdin.listenerCount('error')).toBe(0); + expect(interrupts.listeners).toBe(0); + expect(ref).not.toHaveBeenCalled(); + expect(stdin.isPaused()).toBe(true); + }); + + it('preserves a failed registration while independently releasing earlier native acquisitions', async () => { + const stdin = new PassThrough(); + const interrupts = fakeInterrupts(); + const primary = new Error('native registration failed'); + stdin.on('newListener', (event: string | symbol) => { + if (event === 'end') throw primary; + }); + await expect(nodeWaitForContinue(stdin, interrupts)()).rejects.toBe(primary); + expect(stdin.listenerCount('data')).toBe(0); + expect(stdin.listenerCount('end')).toBe(0); + expect(interrupts.listeners).toBe(0); + expect(stdin.isPaused()).toBe(true); + }); + + it('a failed remover refuses release but does not skip independent listeners or pausing input', async () => { + const stdin = new PassThrough(); + const interrupts = fakeInterrupts(); + const cancel = new AbortController(); + const primary = new Error('native removal failed'); + stdin.on('removeListener', (event: string | symbol) => { + if (event === 'data') throw primary; + }); + const wait = nodeWaitForContinue(stdin, interrupts, cancel.signal)(); + const refused = expect(wait).rejects.toBe(primary); + cancel.abort(); + await refused; + expect(stdin.listenerCount('data')).toBe(0); + expect(stdin.listenerCount('end')).toBe(0); + expect(stdin.listenerCount('error')).toBe(0); + expect(interrupts.listeners).toBe(0); + expect(stdin.isPaused()).toBe(true); + }); + + it('a failed ref releases registered listeners and preserves its primary failure', async () => { + const primary = new Error('native ref failed'); + const stdin = Object.assign(new PassThrough(), { + ref: () => { + throw primary; + }, + }); + const interrupts = fakeInterrupts(); + await expect(nodeWaitForContinue(stdin, interrupts)()).rejects.toBe(primary); + expect(stdin.listenerCount('data')).toBe(0); + expect(stdin.listenerCount('end')).toBe(0); + expect(stdin.listenerCount('error')).toBe(0); + expect(interrupts.listeners).toBe(0); + expect(stdin.isPaused()).toBe(true); + }); + + it('an owned abort releases actual hatch listeners and pauses captured input before Ink reclaims it', async () => { + const stdin = new PassThrough(); + const interrupts = fakeInterrupts(); + const cancel = new AbortController(); + const wait = nodeWaitForContinue(stdin, interrupts, cancel.signal)(); + expect(stdin.listenerCount('data')).toBe(1); + expect(interrupts.listeners).toBe(1); + cancel.abort(); + await wait; + expect(stdin.listenerCount('data')).toBe(0); + expect(stdin.listenerCount('end')).toBe(0); + expect(stdin.listenerCount('error')).toBe(0); + expect(interrupts.listeners).toBe(0); + expect(stdin.isPaused(), 'SESSION_HATCH_ABORT_RELEASES_CAPTURED_INPUT').toBe(true); + }); + + it('a pre-aborted hatch does not acquire or resume input', async () => { + const stdin = new PassThrough(); + const interrupts = fakeInterrupts(); + const cancel = new AbortController(); + cancel.abort(); + const resume = vi.spyOn(stdin, 'resume'); + await nodeWaitForContinue(stdin, interrupts, cancel.signal)(); + expect(resume).not.toHaveBeenCalled(); + expect(stdin.listenerCount('data')).toBe(0); + expect(interrupts.listeners).toBe(0); + }); + it('resolves on the first keypress and removes every listener it added', async () => { const stdin = new PassThrough(); const interrupts = fakeInterrupts(); diff --git a/apps/cli/src/render/scrollback.ts b/apps/cli/src/render/scrollback.ts index 450eac69..d6ae2349 100644 --- a/apps/cli/src/render/scrollback.ts +++ b/apps/cli/src/render/scrollback.ts @@ -40,7 +40,7 @@ export interface InterruptSource { export interface DumpToScrollbackDeps { /** Write to the PRIMARY buffer. MUST NOT throw and MUST NOT let the stream's async `'error'` event escape — see * {@link nodeWriteOut}, the production adapter. */ - readonly writeOut: (text: string) => void; + readonly writeOut: (text: string) => void | Promise; /** Resolve when the user acknowledges (production: one keypress/line on stdin). Injected: it is pure terminal I/O. */ readonly waitForContinue: () => Promise; } @@ -55,7 +55,7 @@ export async function dumpToScrollback( ): Promise { const body = lines.map((line) => stripTerminalControls(line)).join('\n'); // One write: a single syscall cannot be interleaved by another stdout writer mid-transcript. - deps.writeOut( + await deps.writeOut( `${DUMP_HEADER}\n${body}${body.length > 0 ? '\n' : ''}${DUMP_FOOTER}\n${DUMP_PROMPT}\n`, ); await deps.waitForContinue(); @@ -96,24 +96,86 @@ export const nodeWriteOut = * (a piped or closed stdin) resolves rather than hangs: the dump is already in the scrollback, which is the point. */ export const nodeWaitForContinue = - (stdin: Readable & { ref?: () => void }, interrupts: InterruptSource = process) => + ( + stdin: Readable & { ref?: () => void }, + interrupts: InterruptSource = process, + signal?: AbortSignal, + ) => (): Promise => - new Promise((resolve) => { - const done = (): void => { - stdin.removeListener('data', done); - stdin.removeListener('end', done); - stdin.removeListener('error', done); - interrupts.removeListener('SIGINT', done); - stdin.pause(); + new Promise((resolve, reject) => { + const isAborted = (): boolean => signal?.aborted === true; + if (isAborted()) { resolve(); + return; + } + let constructing = true; + let requested = false; + let finished = false; + let failure: { readonly cause: unknown } | undefined; + const removers: Array<() => void> = []; + const cleanup = (): void => { + if (constructing || !requested || finished) return; + finished = true; // Removal/pause callbacks may reenter done(). + for (const remove of removers) { + try { + remove(); + } catch (cause) { + failure ??= { cause }; + } + } + try { + stdin.pause(); + } catch (cause) { + failure ??= { cause }; + } + // A failed removal is a refusal, never a positive terminal-release acknowledgement. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Preserve the exact native removal failure; wrapping would replace the primary release refusal. + if (failure !== undefined) reject(failure.cause); + else resolve(); + }; + const done = (): void => { + requested = true; + cleanup(); + }; + const register = (remove: () => void, acquire: () => void): void => { + if (requested) return; + // Publish the exact cleanup before entering native registration. EventEmitter's + // newListener hook can reenter done BEFORE the new listener has actually been added. + removers.push(remove); + acquire(); }; - stdin.ref?.(); - stdin.resume(); - stdin.once('data', done); - stdin.once('end', done); - stdin.once('error', done); - // Raw mode is OFF for the whole suspension, so Ctrl-C at this prompt arrives as a real SIGINT rather than a - // `\x03` byte on `data`. Treat it as "return to Relavium": without this the wait would hang, the surface's own - // SIGINT handler is deliberately inert here (chat-ink.tsx), and the user would have no way back but Enter. - interrupts.once('SIGINT', done); + try { + register( + () => stdin.removeListener('data', done), + () => stdin.once('data', done), + ); + register( + () => stdin.removeListener('end', done), + () => stdin.once('end', done), + ); + register( + () => stdin.removeListener('error', done), + () => stdin.once('error', done), + ); + // Raw mode is OFF here: Ctrl-C returns from the hatch, while the surface's SIGINT handler yields. + register( + () => interrupts.removeListener('SIGINT', done), + () => interrupts.once('SIGINT', done), + ); + if (signal !== undefined) { + register( + () => signal.removeEventListener('abort', done), + () => signal.addEventListener('abort', done, { once: true }), + ); + } + if (isAborted()) requested = true; + if (!requested) stdin.ref?.(); + if (!requested) stdin.resume(); + } catch (cause) { + failure = { cause }; + requested = true; + } finally { + constructing = false; + cleanup(); + } }); diff --git a/apps/cli/src/render/suspend.test.ts b/apps/cli/src/render/suspend.test.ts index b25e76cf..92e9e705 100644 --- a/apps/cli/src/render/suspend.test.ts +++ b/apps/cli/src/render/suspend.test.ts @@ -40,7 +40,9 @@ const harness = ( trace.push('ink:end'); } }, - writeControl: (sequence) => trace.push(sequence), + writeControl: (sequence) => { + trace.push(sequence); + }, inkOwnsAltScreen: false, // the `relavium chat` default (the hoist owns 1049) altActive: true, mouseActive: true, @@ -55,6 +57,41 @@ const body = (trace: string[]) => (): Promise => { }; describe('suspendFullScreen — `relavium chat` (ink does NOT own the alt screen)', () => { + it('waits for actual release delivery before running a child and joins reclaim delivery before native resume', async () => { + let deliver: () => void = () => undefined; + const delivery = new Promise((resolve) => { + deliver = resolve; + }); + let reclaimed: () => void = () => undefined; + const reclaim = new Promise((resolve) => { + reclaimed = resolve; + }); + const body = vi.fn(() => Promise.resolve()); + const resumed = vi.fn(); + const running = suspendFullScreen( + { + suspendTerminal: async (callback) => { + await callback(); + resumed(); + }, + writeControl: (text) => + text === DISABLE_MOUSE ? delivery : text === ENABLE_MOUSE ? reclaim : Promise.resolve(), + inkOwnsAltScreen: false, + altActive: false, + mouseActive: true, + }, + body, + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(body, 'SESSION_NATIVE_RELEASE_DELIVERY_BEFORE_CHILD').not.toHaveBeenCalled(); + deliver(); + await new Promise((resolve) => setImmediate(resolve)); + expect(body).toHaveBeenCalledOnce(); + expect(resumed, 'SESSION_NATIVE_RECLAIM_DELIVERY_BEFORE_RESUME').not.toHaveBeenCalled(); + reclaimed(); + await running; + expect(resumed).toHaveBeenCalledOnce(); + }); it('exits + re-enters the alt buffer ITSELF, and does so INSIDE ink’s suspension window', async () => { const { opts, trace } = harness({ inkOwnsAltScreen: false }); await suspendFullScreen(opts, body(trace)); @@ -255,7 +292,9 @@ describe('suspendFullScreen — re-entrancy is the SURFACE’s job to gate', () const trace: string[] = []; const opts: SuspendFullScreenOptions = { suspendTerminal: () => Promise.reject(already), // beginSuspend threw before the callback ran - writeControl: (sequence) => trace.push(sequence), + writeControl: (sequence) => { + trace.push(sequence); + }, inkOwnsAltScreen: false, altActive: true, mouseActive: true, @@ -272,6 +311,132 @@ describe('suspendFullScreen — re-entrancy is the SURFACE’s job to gate', () * reclaim later re-enters the alt buffer on the user's SHELL. The flag is owned by the PORT, wrapped around the ink * call it hands out, so no caller can forget to maintain it. */ +describe('mount-specific suspend attachment lifetime', () => { + it('seals queued work but joins the complete entered native continuation after the body returned', async () => { + const port = createSuspendPort(); + const mount = port.reserveAttachment(); + let finishNative: () => void = () => undefined; + const native = new Promise((resolve) => { + finishNative = resolve; + }); + let bodyReturned = false; + mount.port.attach(async (body) => { + await body(); + bodyReturned = true; + await native; + }); + const suspension = mount.port.current()?.(() => Promise.resolve()); + const publish = vi.fn(() => Promise.resolve()); + const queued = port.withActiveTerminal(publish); + const refused = expect(queued).rejects.toThrow('no longer active'); + let joined = false; + const drain = mount.sealAndJoin(); + void drain.then(() => { + joined = true; + }); + await refused; + await new Promise((resolve) => setImmediate(resolve)); + expect(bodyReturned).toBe(true); + expect(port.current()).toBeUndefined(); + expect(port.isSuspended()).toBe(true); + expect(publish).not.toHaveBeenCalled(); + expect(joined, 'SESSION_SUSPEND_DRAIN_INCLUDES_NATIVE_END_CONTINUATION').toBe(false); + finishNative(); + await suspension; + await drain; + expect(port.isSuspended()).toBe(false); + }); + + it('keeps old detach and old access scoped to their own mount after a successor attached', async () => { + const port = createSuspendPort(); + const old = port.reserveAttachment(); + const successor = port.reserveAttachment(); + const oldNative = vi.fn(async (body: () => Promise) => body()); + const newNative = vi.fn(async (body: () => Promise) => body()); + old.port.attach(oldNative); + const stale = old.port.current(); + successor.port.attach(newNative); + old.port.attach(oldNative); + old.port.attach(undefined); + expect(old.port.current(), 'SESSION_OLD_MOUNT_CANNOT_ACQUIRE_SUCCESSOR').toBeUndefined(); + await expect(stale?.(() => Promise.resolve())).rejects.toThrow('no longer active'); + await port.current()?.(() => Promise.resolve()); + expect(oldNative).not.toHaveBeenCalled(); + expect(newNative).toHaveBeenCalledOnce(); + await old.sealAndJoin(); + await successor.sealAndJoin(); + }); + + it('revoking old attachment custody still joins its already-entered native continuation', async () => { + const port = createSuspendPort(); + const old = port.reserveAttachment(); + const successor = port.reserveAttachment(); + let finishNative: () => void = () => undefined; + const native = new Promise((resolve) => { + finishNative = resolve; + }); + old.port.attach(async (body) => { + await body(); + await native; + }); + const entered = old.port.current()?.(() => Promise.resolve()); + const next = vi.fn(async (body: () => Promise) => body()); + successor.port.attach(next); + old.port.attach(async (body) => body()); + let joined = false; + const drain = old.sealAndJoin().then(() => { + joined = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(joined, 'SESSION_SUPERSEDED_MOUNT_RETAINS_NATIVE_JOIN').toBe(false); + expect(old.port.current()).toBeUndefined(); + finishNative(); + await entered; + await drain; + await successor.port.current()?.(() => Promise.resolve()); + expect(next).toHaveBeenCalledOnce(); + await successor.sealAndJoin(); + }); + + it('joins actual in-flight publication acknowledgement and refuses late attachment after seal', async () => { + const port = createSuspendPort(); + const mount = port.reserveAttachment(); + mount.port.attach(async (body) => body()); + let acknowledge: () => void = () => undefined; + const acknowledgement = new Promise((resolve) => { + acknowledge = resolve; + }); + const publication = port.withActiveTerminal(() => acknowledgement); + let joined = false; + const drain = mount.sealAndJoin(); + void drain.then(() => { + joined = true; + }); + mount.port.attach(async (body) => body()); + await new Promise((resolve) => setImmediate(resolve)); + expect(joined, 'SESSION_PUBLICATION_ACK_PRECEDES_FINAL_UNMOUNT').toBe(false); + expect(port.current()).toBeUndefined(); + acknowledge(); + await publication; + await drain; + }); + + it('permits the next normal mount after the prior attachment was sealed and joined', async () => { + const port = createSuspendPort(); + const old = port.reserveAttachment(); + old.port.attach(async (body) => body()); + await old.sealAndJoin(); + const next = port.reserveAttachment(); + const publish = vi.fn(async () => {}); + next.port.attach(async (body) => body()); + await port.withActiveTerminal(publish); + expect(publish).toHaveBeenCalledOnce(); + old.port.attach(undefined); + expect(port.current()).toBeDefined(); + await next.sealAndJoin(); + }); +}); + describe('createSuspendPort — the suspension window', () => { it('keeps publication exclusive until acknowledgement and then services the queued suspension', async () => { const port = createSuspendPort(); @@ -380,6 +545,25 @@ describe('createSuspendPort — the suspension window', () => { * stop the Vitest worker, so the harness proves the release → self-stop → CONT → reclaim ordering without a shell. */ describe('wireJobControl (G0)', () => { + it('waits for a no-Ink release acknowledgement and joins reentry without self-stopping after disposal', async () => { + const h = harness(); + let delivered: (released: boolean) => void = () => undefined; + const delivery = new Promise((resolve) => { + delivered = resolve; + }); + const binding = wireJobControl({ + suspendPort: h.port, + lifecycle: h.lifecycle, + run: () => Promise.reject(new Error('no Ink here')), + releaseWithoutInkAcknowledged: () => delivery, + }); + binding.requestSuspend(); + expect(h.suspendSelf, 'SESSION_NO_INK_RELEASE_ACK_BEFORE_SELF_STOP').not.toHaveBeenCalled(); + const closing = binding.finish(); + delivered(true); + await closing; + expect(h.suspendSelf).not.toHaveBeenCalled(); + }); interface Harness { readonly trace: string[]; readonly port: ReturnType; @@ -439,7 +623,9 @@ describe('wireJobControl (G0)', () => { suspendFullScreen( { suspendTerminal, - writeControl: (sequence) => h.trace.push(sequence), + writeControl: (sequence) => { + h.trace.push(sequence); + }, inkOwnsAltScreen: false, altActive: true, mouseActive: true, @@ -448,6 +634,109 @@ describe('wireJobControl (G0)', () => { ), }); + it('owned finish drains an entered job handoff and full native redraw before acknowledging release', async () => { + const h = harness(); + let redraw: () => void = () => undefined; + const redrawn = new Promise((resolve) => { + redraw = resolve; + }); + h.port.attach(async (body) => { + await body(); + await redrawn; + h.trace.push('native:redraw'); + }); + const binding = bind(h); + h.fireSuspend(); + await flush(); + const released = binding.finish(); + expect(binding.finish()).toBe(released); + let acknowledged = false; + void released.then(() => { + acknowledged = true; + }); + await flush(); + expect(h.trace).toContain(ENTER_ALT_SCREEN + HIDE_CURSOR); + expect(h.trace).not.toContain('native:redraw'); + expect(acknowledged, 'SESSION_JOB_FINISH_INCLUDES_NATIVE_RECLAIM').toBe(false); + h.fireSuspend(); + h.fireContinue(); + expect(h.suspendSelf).toHaveBeenCalledOnce(); + redraw(); + await released; + expect(h.trace.at(-1)).toBe('native:redraw'); + }); + + it('an asynchronous finish can drain after the synchronous exit fallback disposed listeners', async () => { + const h = harness(); + h.port.attach(async (body) => body()); + const binding = bind(h); + h.fireSuspend(); + await flush(); + binding.dispose(); + expect(h.trace).not.toContain(ENTER_ALT_SCREEN + HIDE_CURSOR); + await binding.finish(); + expect(h.trace).toContain(ENTER_ALT_SCREEN + HIDE_CURSOR); + expect(h.removeContinue).toHaveBeenCalledOnce(); + }); + + it('transfers construction custody before signal registration and retains unknown partial registration', async () => { + const h = harness(); + let adopted: ReturnType | undefined; + const cause = new Error('PRIVATE_SIGNAL_REGISTRATION_FAILURE'); + expect(() => + wireJobControl({ + suspendPort: h.port, + lifecycle: { + ...h.lifecycle, + onContinue: () => { + expect(adopted).toBeDefined(); + throw cause; + }, + }, + run: () => Promise.resolve(), + adopt: (binding) => { + adopted = binding; + }, + }), + ).toThrow(cause); + if (adopted === undefined) throw new Error('missing native construction custody'); + let acknowledged = false; + void adopted.finish().then(() => { + acknowledged = true; + }); + await flush(); + expect(acknowledged, 'SESSION_JOB_PARTIAL_REGISTRATION_NEVER_ACKS').toBe(false); + }); + + it('a native remover can reenter finish without allowing later self-stop or terminal reclaim', async () => { + const h = harness(); + const reclaim = vi.fn(); + const original = h.lifecycle.onSuspend; + const binding = wireJobControl({ + suspendPort: h.port, + lifecycle: { + ...h.lifecycle, + onSuspend: (listener) => { + const remove = original(listener); + return () => { + remove(); + void binding?.finish(); + }; + }, + }, + run: () => Promise.reject(new Error('no Ink in this fixture')), + releaseWithoutInk: () => true, + reclaimWithoutInk: reclaim, + }); + binding.requestSuspend(); + await binding.finish(); + expect( + h.suspendSelf, + 'SESSION_REENTRANT_JOB_FINISH_PREVENTS_NATIVE_STOP', + ).not.toHaveBeenCalled(); + expect(reclaim, 'SESSION_REENTRANT_JOB_FINISH_PREVENTS_LATE_RECLAIM').not.toHaveBeenCalled(); + }); + it('releases terminal state BEFORE self-stop, then reclaims and redraws only after SIGCONT', async () => { const h = harness(); h.port.attach(async (callback) => { diff --git a/apps/cli/src/render/suspend.ts b/apps/cli/src/render/suspend.ts index a1df1ef7..87148ea4 100644 --- a/apps/cli/src/render/suspend.ts +++ b/apps/cli/src/render/suspend.ts @@ -1,4 +1,5 @@ import { CliError } from '../process/errors.js'; +import { EgressWorkScope } from '../engine/egress-work.js'; import { DISABLE_MOUSE, @@ -25,10 +26,9 @@ import { * for `relavium chat`, whose hoisted `AltScreenController` owns 1049 (Step 4b-3). Hence {@link * SuspendFullScreenOptions.inkOwnsAltScreen}: on the chat surface we exit/re-enter 1049 ourselves, or `$EDITOR` * would paint into the alt buffer and vanish on resume. Both halves ALSO early-return under - * `!interactive || isUnmounted || isUnmounting`, so ink's half of the work is skipped once the instance is torn - * down — harmless, because `unmount()` itself writes `exitAlternativeScreen + showCursor` and clears the option, - * and because `beginSuspend()` runs synchronously at the head of `suspendTerminal` (a mounted instance cannot - * become unmounted between the check and our writes). + * `!interactive || isUnmounted || isUnmounting` for buffer writes. Crucially, `endSuspend()` calls + * `resumeInput()` BEFORE that guard in installed Ink 7.1.0: a late continuation can restore raw input even + * after unmount. An owned mount must seal and join entered suspension before its final unmount. * 3. ink writes **no mouse escapes at all** (verified: its whole build contains no `?1000`/`?1006`). Mouse reporting * is entirely ours, so we suspend and restore it on both surfaces — leaving DECSET 1002 on while a child owns the * TTY floods that child with `\x1b[<…M` reports. @@ -57,7 +57,7 @@ export interface SuspendFullScreenOptions { /** ink's `useApp().suspendTerminal` — the only way to make ink release raw mode, bracketed paste, and its frame. */ readonly suspendTerminal: SuspendTerminal; /** Write a raw control sequence to the TTY (production: `process.stdout.write`; tests: a capture). */ - readonly writeControl: (sequence: string) => void; + readonly writeControl: (sequence: string) => void | Promise; /** * `true` when ink's `alternateScreen` RENDER OPTION is on — the bare Home, where ink's own `beginSuspend`/ * `endSuspend` exit and re-enter DECSET-1049 for us. `false` for `relavium chat` (the option is hard-`false`; the @@ -97,11 +97,11 @@ export async function suspendFullScreen( // RELEASE — hand the terminal over. try { if (mouseActive) { - writeControl(DISABLE_MOUSE); // restore native selection + stop flooding the child with mouse reports + await writeControl(DISABLE_MOUSE); // Delivery precedes native handoff to a child/job stop. mouseSuspended = true; } if (weOwnAltScreen) { - writeControl(EXIT_ALT_SCREEN + SHOW_CURSOR); // ink did not: its render option is false on this surface + await writeControl(EXIT_ALT_SCREEN + SHOW_CURSOR); // Ink does not own this surface's buffer. altExited = true; } await body(); @@ -112,15 +112,15 @@ export async function suspendFullScreen( // RECLAIM — mirror the release, innermost-first. Each write is isolated, so one failing sequence can never skip // the next: a stranded DECSET-1000 (mouse reporting left on) is the worst terminal state we can leave, and it is // restored even when re-entering the alt buffer throws. - const reclaim = (sequence: string): void => { + const reclaim = async (sequence: string): Promise => { try { - writeControl(sequence); + await writeControl(sequence); } catch (error) { pending ??= { error }; // only the root cause survives; a secondary write failure on a dead stdout is noise } }; - if (altExited) reclaim(ENTER_ALT_SCREEN + HIDE_CURSOR); - if (mouseSuspended) reclaim(ENABLE_MOUSE); + if (altExited) await reclaim(ENTER_ALT_SCREEN + HIDE_CURSOR); + if (mouseSuspended) await reclaim(ENABLE_MOUSE); if (pending !== undefined) throw pending.error; }); @@ -159,6 +159,14 @@ export interface SuspendPort { readonly isSuspended: () => boolean; /** Publish and acknowledge a notice while Ink owns the terminal, excluding suspension until completion. */ readonly withActiveTerminal: (publish: () => Promise) => Promise; + /** Reserve a mount-specific attachment before rendering; an old effect cannot detach its successor. */ + readonly reserveAttachment: () => SuspendAttachment; +} + +export interface SuspendAttachment { + readonly port: SuspendPort; + /** Synchronously stop future entry, then join full entered suspension/publication continuations. */ + readonly sealAndJoin: () => Promise; } /** @@ -166,23 +174,38 @@ export interface SuspendPort { * forget to set it, and it is impossible for `isSuspended()` to disagree with what the terminal is actually doing. */ export function createSuspendPort(): SuspendPort { - let suspend: SuspendTerminal | undefined; + interface Reservation { + readonly work: EgressWorkScope; + readonly generation: number; + sealed: boolean; + } + interface Attached { + readonly reservation: Reservation; + readonly suspend: SuspendTerminal; + } + let attached: Attached | undefined; + let legacy: SuspendAttachment | undefined; + let legacySealed = false; let suspended = false; let occupied = false; + let generation = 0; + let latestActivated = 0; const waiters = new Set<() => void>(); const changed = (): void => { const pending = [...waiters]; waiters.clear(); for (const wake of pending) wake(); }; - const exclusive = async (body: () => Promise): Promise => { + const live = (expected: Attached): boolean => + attached === expected && !expected.reservation.sealed; + const unavailable = (): CliError => + new CliError('internal', 'The terminal renderer is no longer active.'); + const exclusive = async (expected: Attached, body: () => Promise): Promise => { while (occupied) { - if (suspend === undefined) - throw new CliError('internal', 'The terminal renderer is no longer active.'); + if (!live(expected)) throw unavailable(); await new Promise((resolve) => waiters.add(resolve)); } - if (suspend === undefined) - throw new CliError('internal', 'The terminal renderer is no longer active.'); + if (!live(expected)) throw unavailable(); occupied = true; try { await body(); @@ -191,32 +214,88 @@ export function createSuspendPort(): SuspendPort { changed(); } }; - return { - attach: (next) => { - suspend = next; - changed(); // Detachment wakes pending publication/suspension so teardown cannot strand a waiter. - }, - current: () => { - const live = suspend; - if (live === undefined) return undefined; - return (callback) => { - if (suspended) - return Promise.reject(new CliError('internal', 'The terminal is already suspended.')); - return exclusive(async () => { - if (suspend !== live) - throw new CliError('internal', 'The terminal renderer is no longer active.'); - suspended = true; - try { - await live(callback); - } finally { - suspended = false; + const enter = (expected: Attached, body: () => Promise): Promise => { + if (!live(expected)) return Promise.reject(unavailable()); + return expected.reservation.work.retainWork(() => exclusive(expected, body)); + }; + const current = (): SuspendTerminal | undefined => { + const expected = attached; + if (expected === undefined || !live(expected)) return undefined; + return (callback) => { + if (suspended) + return Promise.reject(new CliError('internal', 'The terminal is already suspended.')); + return enter(expected, async () => { + suspended = true; + try { + await expected.suspend(callback); + } finally { + suspended = false; + } + }); + }; + }; + const withActiveTerminal = (publish: () => Promise): Promise => { + const expected = attached; + return expected === undefined ? Promise.reject(unavailable()) : enter(expected, publish); + }; + const reserveAttachment = (): SuspendAttachment => { + const reservation: Reservation = { + work: new EgressWorkScope(), + generation: ++generation, + sealed: false, + }; + const port: SuspendPort = Object.freeze({ + attach: (next: SuspendTerminal | undefined) => { + if (next === undefined) { + if (attached?.reservation === reservation) attached = undefined; + } else if (!reservation.sealed && reservation.generation >= latestActivated) { + const predecessor = attached?.reservation; + if (predecessor !== undefined && predecessor !== reservation) { + predecessor.sealed = true; + predecessor.work.seal(); } - }); - }; + latestActivated = reservation.generation; + attached = { reservation, suspend: next }; + } + changed(); + }, + current: () => (attached?.reservation === reservation ? current() : undefined), + isSuspended: () => suspended, + withActiveTerminal: (publish: () => Promise) => + attached?.reservation === reservation + ? withActiveTerminal(publish) + : Promise.reject(unavailable()), + reserveAttachment, + }); + return Object.freeze({ + port, + sealAndJoin: () => { + reservation.sealed = true; + reservation.work.seal(); + changed(); // Queued, not-entered work rejects; actual entered continuations remain owed. + return reservation.work.done; + }, + }); + }; + return Object.freeze({ + attach: (next: SuspendTerminal | undefined) => { + // The legacy embedding API keeps working; owned surfaces reserve their exact mount explicitly. + if (legacy === undefined || (next !== undefined && legacySealed)) { + if (legacy !== undefined) void legacy.sealAndJoin(); + legacy = reserveAttachment(); + legacySealed = false; + } + legacy.port.attach(next); + if (next === undefined) { + legacySealed = true; + void legacy.sealAndJoin(); + } }, + current, isSuspended: () => suspended, - withActiveTerminal: exclusive, - }; + withActiveTerminal, + reserveAttachment, + }); } /** @@ -271,6 +350,10 @@ export interface JobControlBinding { readonly requestSuspend: () => void; /** Remove the process listeners; safe to call more than once. */ readonly dispose: () => void; + /** Drain an already-entered handoff while Ink is still mounted, before final unmount/restore. */ + readonly finish: () => Promise; + /** Positive listener removal and entered-work acknowledgement, never supplied by dispose alone. */ + readonly released: Promise; } /** @@ -296,8 +379,24 @@ export function wireJobControl(opts: { readonly releaseWithoutInk?: () => boolean; /** Mirrors {@link releaseWithoutInk} after the stopped process is foregrounded again. */ readonly reclaimWithoutInk?: () => void; + /** Owned delivery-before-stop path; the synchronous embedding ports above retain compatibility. */ + readonly releaseWithoutInkAcknowledged?: () => Promise; + readonly reclaimWithoutInkAcknowledged?: () => Promise; + /** Transfer cleanup before any potentially allocating signal registration. */ + readonly adopt?: (binding: JobControlBinding) => void; + readonly onFailure?: (cause: unknown) => void; }): JobControlBinding { - if (!opts.lifecycle.supported) return { requestSuspend: noop, dispose: noop }; + if (!opts.lifecycle.supported) { + const released = Promise.resolve(); + const binding = Object.freeze({ + requestSuspend: noop, + dispose: noop, + finish: () => released, + released, + }); + opts.adopt?.(binding); + return binding; + } let disposed = false; let suspending = false; @@ -305,33 +404,78 @@ export function wireJobControl(opts: { let resolveContinue: (() => void) | undefined; let removeSuspend: (() => void) | undefined; let removeContinue: (() => void) | undefined; + let constructing = true; + let unknownRegistration = false; + let registrations = 0; + let finishing: Promise | undefined; + const work = new EgressWorkScope(); + let acknowledge: () => void = () => undefined; + let rejectRelease: (cause: unknown) => void = () => undefined; + const released = new Promise((resolve, reject) => { + acknowledge = resolve; + rejectRelease = reject; + }); + void released.catch(noop); // Embeddings may observe later; rejection supplies no positive native ACK. + const report = (cause: unknown): void => { + try { + opts.onFailure?.(cause); + } catch (observer) { + rejectRelease(new CliError('cli_internal', '', { cause: { primary: cause, observer } })); + } + }; const removeSuspendListener = (): void => { - removeSuspend?.(); + const remove = removeSuspend; + if (remove === undefined) return; removeSuspend = undefined; + registrations += 1; + try { + remove(); + } catch (cause) { + removeSuspend = remove; + throw cause; + } finally { + registrations -= 1; + } }; const installSuspendListener = (): void => { if (disposed || removeSuspend !== undefined) return; - removeSuspend = opts.lifecycle.onSuspend(requestSuspend); + registrations += 1; + let acquired = false; + try { + removeSuspend = opts.lifecycle.onSuspend(requestSuspend); + acquired = true; + if (disposed) removeSuspendListener(); + } catch (cause) { + if (!acquired) unknownRegistration = true; + throw cause; + } finally { + registrations -= 1; + } }; const continueSuspension = (): void => { + if (disposed) return; const resolve = resolveContinue; if (resolve === undefined) return; // a stray SIGCONT must not perturb an active surface // Re-arm BEFORE Ink resumes input/redraws. A fast second Ctrl-Z is then captured by our handler rather than // falling through to a default stop while the terminal is still being reclaimed. installSuspendListener(); + if (disposed) return; resolveContinue = undefined; resolve(); }; /** Remove our handler just long enough for the OS default SIGTSTP action to stop the process. */ const forwardSuspend = (): void => { + if (disposed) return; removeSuspendListener(); + if (disposed) return; try { opts.lifecycle.suspendSelf(); - } catch { + } catch (cause) { + report(cause); // A failed self-signal means there was no stop/continuation. Resolve a pending release so Ink can reclaim; // the finally below restores our SIGTSTP listener too. continueSuspension(); @@ -357,7 +501,7 @@ export function wireJobControl(opts: { }); }; - function requestSuspend(): void { + function performSuspend(): void { if (disposed) return; if (suspending) { // Coalesce a burst into one real follow-up stop. Dropping it would make a fast Ctrl-Z / SIGTSTP after `fg` @@ -375,11 +519,34 @@ export function wireJobControl(opts: { } const suspendTerminal = opts.suspendPort.current(); + if (disposed) return; if (suspendTerminal === undefined) { + if (opts.releaseWithoutInkAcknowledged !== undefined) { + suspending = true; + try { + const raw = work.retainWork(async () => { + if (!(await opts.releaseWithoutInkAcknowledged?.()) || disposed) return; + forwardSuspend(); + if (!disposed) await opts.reclaimWithoutInkAcknowledged?.(); + }); + void raw + .then(settle, (cause: unknown) => { + report(cause); + settle(); + }) + .catch(report); + } catch (cause) { + report(cause); + settle(); + } + return; + } // Before Home mounts (onboarding) there is no Ink state to release. In the chat's rebuild window there can be // a live hoisted alt buffer, supplied by the reversible hooks below. if (opts.releaseWithoutInk !== undefined && !opts.releaseWithoutInk()) return; + if (disposed) return; forwardSuspend(); + if (disposed) return; opts.reclaimWithoutInk?.(); return; } @@ -387,31 +554,143 @@ export function wireJobControl(opts: { suspending = true; const waitForContinue = (): Promise => new Promise((resolve) => { + if (disposed) { + resolve(); + return; + } resolveContinue = resolve; forwardSuspend(); }); // Signals cannot await a promise. Absorb a terminal-write/Ink failure here: `suspendFullScreen` has already // performed its own partial-state reclaim, and an unhandled rejection from a signal handler is strictly worse. // We intentionally do not re-raise after a failed release because terminal release never reached the safe point. - void opts.run(suspendTerminal, waitForContinue).catch(noop).finally(settle); + try { + const raw = work.retainWork(() => opts.run(suspendTerminal, waitForContinue)); + void raw + .then(settle, (cause: unknown) => { + try { + report(cause); + } finally { + settle(); + } + }) + .catch(report); + } catch (cause) { + try { + report(cause); + } finally { + settle(); + } + } } - removeContinue = opts.lifecycle.onContinue(continueSuspension); - installSuspendListener(); + function requestSuspend(): void { + if (constructing) { + pendingSuspend = true; + return; + } + try { + performSuspend(); + } catch (cause) { + report(cause); + } + } - return { - requestSuspend, - dispose: (): void => { - if (disposed) return; - disposed = true; - pendingSuspend = false; - // BOTH listeners, not just SIGCONT: `wireJobControl` installs the SIGTSTP one at construction, so - // removing only the continue handler leaked a suspend handler per binding. Every session rebuild — a - // `/clear`, a `/model` reseat, the onboarding→Home handoff — added another, so one Ctrl-Z eventually ran - // several handlers and Node warned about a listener leak. + const dispose = (): void => { + disposed = true; + pendingSuspend = false; + // BOTH listeners, not just SIGCONT: `wireJobControl` installs the SIGTSTP one at construction, so + // removing only the continue handler leaked a suspend handler per binding. Every session rebuild — a + // `/clear`, a `/model` reseat, the onboarding→Home handoff — added another, so one Ctrl-Z eventually ran + // several handlers and Node warned about a listener leak. + let failed: { readonly cause: unknown } | undefined; + try { removeSuspendListener(); - removeContinue?.(); + } catch (cause) { + failed = { cause }; + } + const remove = removeContinue; + if (remove !== undefined) { removeContinue = undefined; - }, + registrations += 1; + try { + remove(); + } catch (cause) { + removeContinue = remove; + failed ??= { cause }; + } finally { + registrations -= 1; + } + } + if (failed !== undefined) throw failed.cause; + }; + const finish = (): Promise => { + if (finishing !== undefined) return finishing; + finishing = released; + let failed: { readonly cause: unknown } | undefined; + try { + dispose(); + } catch (cause) { + failed = { cause }; + } + // Synchronous exit fallback deliberately leaves this waiter held. An owned asynchronous + // finish instead drains the full reclaim BEFORE its caller unmounts/restores the terminal. + const resume = resolveContinue; + resolveContinue = undefined; + resume?.(); + work.seal(); + if (failed !== undefined) report(failed.cause); + void work.done.then(() => { + if ( + !constructing && + registrations === 0 && + !unknownRegistration && + removeSuspend === undefined && + removeContinue === undefined + ) + acknowledge(); + }); + return released; }; + const binding = Object.freeze({ requestSuspend, dispose, finish, released }); + opts.adopt?.(binding); + let primary: { readonly cause: unknown } | undefined; + try { + if (!disposed) { + registrations += 1; + let acquired = false; + try { + removeContinue = opts.lifecycle.onContinue(() => { + try { + continueSuspension(); + } catch (cause) { + report(cause); + } + }); + acquired = true; + } catch (cause) { + if (!acquired) unknownRegistration = true; + throw cause; + } finally { + registrations -= 1; + } + if (disposed) dispose(); + else installSuspendListener(); + } + } catch (cause) { + primary = { cause }; + try { + dispose(); + } catch (cleanup) { + report(cleanup); + } + } finally { + constructing = false; + } + if (primary !== undefined) throw primary.cause; + if (pendingSuspend && !disposed) { + pendingSuspend = false; + queueMicrotask(requestSuspend); + } + return binding; } diff --git a/apps/cli/src/render/tui/chat-ink.tsx b/apps/cli/src/render/tui/chat-ink.tsx index 60ab17d2..8312f944 100644 --- a/apps/cli/src/render/tui/chat-ink.tsx +++ b/apps/cli/src/render/tui/chat-ink.tsx @@ -12,6 +12,10 @@ import { } from 'react'; import type { SuspendPort } from '../suspend.js'; +import { createSuspendPort } from '../suspend.js'; +import { SessionInkMount } from '../../chat/session-ink-mount.js'; +import { SessionInkInput } from '../../chat/session-ink-input.js'; +import { CliError } from '../../process/cli-error.js'; import type { NoticeFlush } from '../../engine/effect-retention.js'; import { useVisibleRenderFlush } from './render-acknowledgement.js'; import { @@ -207,6 +211,8 @@ function TranscriptLine( } interface ChatAppProps { + /** Synchronous invocation seal; the tree stays mounted until entered native work joins. */ + readonly inputEnabled?: () => boolean; readonly store: ChatStoreController; /** Successful terminal setup and a flushed frame make this transcript eligible for disclosure. */ readonly onActivated?: (flushNotice: NoticeFlush) => void | Promise; @@ -592,6 +598,7 @@ export function ChatView(props: Readonly): ReactElement { } export function ChatApp(props: Readonly): ReactElement { + const acceptsInput = (): boolean => props.inputEnabled?.() !== false; const storeRef = useRef(props.store); storeRef.current = props.store; const [acknowledgement] = useState(() => createTranscriptAcknowledgement(() => storeRef.current)); @@ -795,12 +802,13 @@ export function ChatApp(props: Readonly): ReactElement { // refusal, a binary file, oversize, since-deleted) surfaces a STATIC, secret-free note — never the raw error. A // large/truncated file adds an honest soft size note (the fs 8 MiB cap is the hard ceiling). const acceptMention = (path: string): void => { + if (!acceptsInput()) return; const reader = props.mentionReader; if (reader === undefined) return; const gen = submitGenRef.current; // capture: a submit since accept ⇒ the buffer moved on (drop the marker/attachment) void reader.read(path).then( ({ content, sizeBytes }) => { - if (submitGenRef.current !== gen) return; // the buffer was submitted since accept — never touch the next message + if (!acceptsInput() || submitGenRef.current !== gen) return; // a sealed mount cannot publish late input applyEditor((current) => { historyRef.current = resetHistoryNav(historyRef.current); // a real edit ends history navigation return insertAtCursor(current, `${mentionMarker(path)} `); @@ -810,7 +818,7 @@ export function ChatApp(props: Readonly): ReactElement { if (warn !== undefined) props.store.note(warn); }, () => { - if (submitGenRef.current !== gen) return; + if (!acceptsInput() || submitGenRef.current !== gen) return; props.store.note('@ mention could not read that file (refused, binary, or too large)'); }, ); @@ -825,7 +833,7 @@ export function ChatApp(props: Readonly): ReactElement { mode: ChatMode, gen: number, ): void => { - if (submitGenRef.current !== gen) return; + if (!acceptsInput() || submitGenRef.current !== gen) return; if (outcome.kind === 'ran') { addAttachment({ kind: 'command', @@ -850,6 +858,7 @@ export function ChatApp(props: Readonly): ReactElement { // Run a tokenized `!`-shell command through the session boundary (runUserCommand). The buffer is already cleared // by the submit case; the outcome injects/notes below. A truly unexpected rejection (e.g. a state guard) notes. const runShell = (parsed: ShellCommand): void => { + if (!acceptsInput()) return; const runner = props.runShellCommand; if (runner === undefined) return; const gen = submitGenRef.current; @@ -872,6 +881,7 @@ export function ChatApp(props: Readonly): ReactElement { // Apply a refresh result INTO the same picker generation that kicked it (drop a stale/closed/reopened one via the // epoch); keep the user's filter/selection, surface the per-provider partial-failure banner. Never crash the REPL. const applyPickerRefresh = (epoch: number, report: RefreshReport | undefined): void => { + if (!acceptsInput()) return; const port = props.modelPicker; const open = modelPickerRef.current; if (epoch !== pickerEpochRef.current || open === undefined || port === undefined) return; @@ -893,6 +903,7 @@ export function ChatApp(props: Readonly): ReactElement { }); }; const runPickerRefresh = (refresh: () => Promise): void => { + if (!acceptsInput()) return; const epoch = pickerEpochRef.current; // capture THIS generation so a reopened picker never adopts this result const open = modelPickerRef.current; if (open === undefined) return; @@ -956,6 +967,7 @@ export function ChatApp(props: Readonly): ReactElement { step: Extract, open: ModelPickerState, ): void => { + if (!acceptsInput()) return; if (step.modelId === open.currentDefault) { if (step.reasoningEffort === undefined || step.reasoningEffort === open.currentEffort) { const at = @@ -1067,6 +1079,7 @@ export function ChatApp(props: Readonly): ReactElement { }, [app, suspendPort]); const submit = (message: string, display?: string): void => { + if (!acceptsInput()) return; if (!activationReady.current && message !== '/cancel' && message !== '/exit') return; // A typed `/models` opens the reseat picker overlay (ADR-0059) instead of sending — interactive only (the port // is wired). Covers a directly-typed `/models` AND a chat-palette selection (both route through `submit`). @@ -1116,6 +1129,7 @@ export function ChatApp(props: Readonly): ReactElement { // in flight, no keyboard-owning overlay/submode, and NO pending approval (read FRESH from the store). This also // closes the standalone-chat paste gap — it never enabled DECSET 2004 before; usePaste enables it natively now. usePaste((text) => { + if (!acceptsInput()) return; if (!activationReady.current) return; const pasted = text.replace(/\r\n?/g, '\n'); if (pasted.length === 0) return; @@ -1206,6 +1220,7 @@ export function ChatApp(props: Readonly): ReactElement { }; useInput((char, key) => { + if (!acceptsInput()) return; // Raw Ink input represents Ctrl-Z as Ctrl+`z` (and a few test streams retain the literal SUB byte). It must win // over mouse parsing, overlays, and approval routing: job control is never prompt text and never consent. if (char === '\x1a' || (key.ctrl === true && key.meta !== true && char.toLowerCase() === 'z')) { @@ -1663,7 +1678,7 @@ export function ChatApp(props: Readonly): ReactElement { export function finalizeInkExit( exited: Promise, ops: { - readonly teardown: () => void; + readonly teardown: () => unknown; readonly outcome: () => ChatDriveOutcome; }, ): Promise { @@ -1737,7 +1752,15 @@ export function driveInk(ctx: ChatDriveContext): Promise { }; rejectExit = (err) => { active = false; - reject(err instanceof Error ? err : new Error('Chat driver failed.')); + ctx.workOwner?.failure(err); // Observe before finalizeInkExit can wait on native terminal ACK. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Owned failures retain the original opaque cause after observation, including non-Error native rejections. + reject( + ctx.workOwner === undefined + ? err instanceof Error + ? err + : new Error('Chat driver failed.') + : err, + ); }; }); @@ -1755,6 +1778,7 @@ export function driveInk(ctx: ChatDriveContext): Promise { // abort. Removed LAST in the finally (after unmount), so a Ctrl-C during unmount still hits us. // Hoisted so the SIGINT handler can unmount ink (restoring the terminal) before a forced exit. let instance: ReturnType | undefined; + let ownedMount: SessionInkMount | undefined; // The full-screen render mode (2.6.F, ADR-0068 §e). driveInk only runs on a TTY (selectChatDriver), so the output // mode is 'tui'; the resolver still short-circuits a 'plain' path to inline defensively, then applies // `--no-alt-screen` → `[preferences].alt_screen` (ctx.altScreen) → the phase default (alt-ON since Step 4b-3). This @@ -1780,7 +1804,15 @@ export function driveInk(ctx: ChatDriveContext): Promise { } }; const onSigint = (): void => { + if (ctx.workOwner?.signal.aborted === true) { + resolveExit(); + return; + } if (cancelRequested) { + if (ctx.workOwner !== undefined) { + ctx.workOwner.invocation.finiteExit.cooperativeCancel(EXIT_CODES.chatEnded); + return; + } // A second SIGINT while the cooperative /cancel is still draining (e.g. a provider ignoring the abort): // unmount ink FIRST so the terminal is restored from raw mode, then tear the live MCP connections down // (best-effort, BOUNDED — a forced quit must not orphan a spawned stdio child, but must also not hang on a @@ -1813,68 +1845,133 @@ export function driveInk(ctx: ChatDriveContext): Promise { if (ctx.suspendPort?.isSuspended() === true) return; onSigint(); }; - process.on('SIGINT', onSigintGated); + let removeInterrupt = (): void => undefined; + let driverStopped = false; + const stopDriverInput = (): void => { + if (driverStopped) return; + driverStopped = true; + active = false; + clearInterval(frame); + let failed: { readonly cause: unknown } | undefined; + try { + unsubscribe(); + } catch (cause) { + failed = { cause }; + } + try { + removeInterrupt(); + } catch (cause) { + failed ??= { cause }; + } + resolveExit(); + if (failed !== undefined) throw failed.cause; + }; try { + removeInterrupt = + ctx.subscribeInterrupt === undefined + ? (() => { + process.on('SIGINT', onSigintGated); + return () => { + process.removeListener('SIGINT', onSigintGated); + }; + })() + : ctx.subscribeInterrupt(onSigintGated); + const workOwner = ctx.workOwner; + if (workOwner !== undefined) { + const { nativeStdin, nativeStdout, nativeStderr } = ctx.io; + if (nativeStdin === undefined || nativeStdout === undefined || nativeStderr === undefined) + throw new CliError('cli_internal', ''); + ownedMount = new SessionInkMount({ + invocation: workOwner.invocation, + assertEntering: workOwner.assertAdmitting, + failure: workOwner.failure, + suspendPort: ctx.suspendPort ?? createSuspendPort(), + stopInput: stopDriverInput, + drainNative: ctx.finishJobControl ?? (() => Promise.resolve()), + input: new SessionInkInput({ + input: nativeStdin, + invocation: workOwner.invocation, + acknowledge: (text) => ctx.io.writeOutAcknowledged(text), + failure: workOwner.failure, + }), + }); + ctx.adoptTerminalMount?.(ownedMount); + } // Opening a fresh session only emits its lifecycle event. Disclosure/retention waits for a committed mount. // Keep setup under the same cleanup ownership as render(), including a throwing startSession hook. + ctx.workOwner?.assertAdmitting(); ctx.startSession(); - instance = render( - createElement(ChatApp, { - store: ctx.store, - onActivated, - // The COMPONENT prop `alternateScreen` (ADR-0068 §c) selects the transcript viewport (vs ``) — kept - // as the resolved mode. It is INDEPENDENT of ink's render OPTION below (now hard `false`, Step 4b-3): ink - // renders full-screen via log-update regardless, and the hoisted `runReplLoop` owns the alt-buffer toggle. - alternateScreen, - onSubmit: ctx.processLine, - shouldStop: ctx.shouldStop, - onExit: () => resolveExit(), - // An unexpected turn-core throw rejects `exited` → the finally tears down + the rejection propagates out - // of the command (mapped to exit 1), matching the plain driver where the throw escapes the for-await loop. - onError: (err) => rejectExit(err), - // ADR-0057 mode/abort wiring — the REPL loop always supplies these. onModeChange defaults to a no-op so a - // driver wired without it degrades to a fixed mode; onAbort is passed through AS-IS (optional) so the - // 'abort' handler can reject a pending approval when it is absent (never a dead Esc — see ChatApp). - onAbort: ctx.onAbort, - onModeChange: ctx.onModeChange ?? ((): void => undefined), - // ADR-0066 §5 effort setter — passed AS-IS (optional); absent ⇒ the `/models` effort sub-step is not offered. - onSetEffort: ctx.onSetEffort, - // `/models` reseat (ADR-0059) — the REPL loop wires both onReseat + the picker port only for an interactive - // session; passed AS-IS (optional) so a driver wired without them simply has no `/models` overlay. - onReseat: ctx.onReseat, - modelPicker: ctx.modelPicker, - // `@`-mention completion (2.5.D, ADR-0061) — the REPL loop wires it only for an interactive session; passed - // AS-IS (optional) so an absent reader degrades `@` to a literal char (never a dead key — see ChatApp). - mentionReader: ctx.mentionReader, - // `!`-shell runner (2.5.D, ADR-0061) — interactive-only; absent ⇒ a leading `!` is a literal message. - runShellCommand: ctx.runShellCommand, - suspendPort: ctx.suspendPort, - onSuspend: ctx.onSuspend, - clipboard: ctx.clipboard, - }), - { - // OUR /cancel (Ctrl-C) handler drives the cooperative cancel — never ink's process.exit. - exitOnCtrlC: false, - patchConsole: false, - maxFps: Math.max(1, Math.round(1000 / FRAME_MS)), - // Ink's ambient CI predicate differs from ADR-0054. Own the selected mode so a flushed - // dynamic disclosure is written before its evidence is swept, including CI='' opt-out. - interactive: outputMode === 'tui', - // ink render OPTION is HARD `false` (2.6.F Step 4b-3, ADR-0068 §c): ink must NOT toggle DECSET-1049 per - // session — the hoisted `runReplLoop` enters the alt buffer ONCE above the loop and exits ONCE, so a `/clear` - // / reseat re-drive no longer flips the terminal (the flicker). ink still full-screen-renders via log-update. - alternateScreen: false, - }, - ); + const mount = () => + render( + createElement(ChatApp, { + inputEnabled: () => active && !driverStopped, + store: ctx.store, + onActivated, + // The COMPONENT prop `alternateScreen` (ADR-0068 §c) selects the transcript viewport (vs ``) — kept + // as the resolved mode. It is INDEPENDENT of ink's render OPTION below (now hard `false`, Step 4b-3): ink + // renders full-screen via log-update regardless, and the hoisted `runReplLoop` owns the alt-buffer toggle. + alternateScreen, + onSubmit: ctx.processLine, + shouldStop: ctx.shouldStop, + onExit: () => resolveExit(), + // An unexpected turn-core throw rejects `exited` → the finally tears down + the rejection propagates out + // of the command (mapped to exit 1), matching the plain driver where the throw escapes the for-await loop. + onError: (err) => rejectExit(err), + // ADR-0057 mode/abort wiring — the REPL loop always supplies these. onModeChange defaults to a no-op so a + // driver wired without it degrades to a fixed mode; onAbort is passed through AS-IS (optional) so the + // 'abort' handler can reject a pending approval when it is absent (never a dead Esc — see ChatApp). + onAbort: ctx.onAbort, + onModeChange: ctx.onModeChange ?? ((): void => undefined), + // ADR-0066 §5 effort setter — passed AS-IS (optional); absent ⇒ the `/models` effort sub-step is not offered. + onSetEffort: ctx.onSetEffort, + // `/models` reseat (ADR-0059) — the REPL loop wires both onReseat + the picker port only for an interactive + // session; passed AS-IS (optional) so a driver wired without them simply has no `/models` overlay. + onReseat: ctx.onReseat, + modelPicker: ctx.modelPicker, + // `@`-mention completion (2.5.D, ADR-0061) — the REPL loop wires it only for an interactive session; passed + // AS-IS (optional) so an absent reader degrades `@` to a literal char (never a dead key — see ChatApp). + mentionReader: ctx.mentionReader, + // `!`-shell runner (2.5.D, ADR-0061) — interactive-only; absent ⇒ a leading `!` is a literal message. + runShellCommand: ctx.runShellCommand, + suspendPort: ownedMount?.port ?? ctx.suspendPort, + onSuspend: ctx.onSuspend, + clipboard: ctx.clipboard, + }), + { + ...(workOwner === undefined + ? {} + : { + stdin: ctx.io.nativeStdin, + stdout: ctx.io.nativeStdout, + stderr: ctx.io.nativeStderr, + }), + // OUR /cancel (Ctrl-C) handler drives the cooperative cancel — never ink's process.exit. + exitOnCtrlC: false, + patchConsole: false, + maxFps: Math.max(1, Math.round(1000 / FRAME_MS)), + // Ink's ambient CI predicate differs from ADR-0054. Own the selected mode so a flushed + // dynamic disclosure is written before its evidence is swept, including CI='' opt-out. + interactive: outputMode === 'tui', + // ink render OPTION is HARD `false` (2.6.F Step 4b-3, ADR-0068 §c): ink must NOT toggle DECSET-1049 per + // session — the hoisted `runReplLoop` enters the alt buffer ONCE above the loop and exits ONCE, so a `/clear` + // / reseat re-drive no longer flips the terminal (the flicker). ink still full-screen-renders via log-update. + alternateScreen: false, + }, + ); + instance = ownedMount === undefined ? mount() : ownedMount.acquire(mount); // Ink owns passive input/render failures. Observe its terminal promise rather than leave the // driver alive after an error boundary has unmounted the disclosure surface. - void instance.waitUntilExit().then(resolveExit, rejectExit); + void (ownedMount?.exited ?? instance.waitUntilExit()).then(resolveExit, (cause: unknown) => { + ctx.workOwner?.failure(cause); + rejectExit(cause); + }); return finalizeInkExit(exited, { // Tear down + UNMOUNT (restores raw mode + cursor; with the option false it does NOT exit the alt buffer — the // hoisted runReplLoop owns that). A throw here must not mask the outcome nor skip the SIGINT-listener removal. teardown: () => { + if (ownedMount !== undefined) return ownedMount.close(); active = false; clearInterval(frame); unsubscribe(); @@ -1883,7 +1980,8 @@ export function driveInk(ctx: ChatDriveContext): Promise { } catch { // swallow — never mask the outcome nor skip the SIGINT-listener removal below. } - process.removeListener('SIGINT', onSigintGated); + removeInterrupt(); + return undefined; }, // The end-of-session summary rides on the outcome (Step 4b-3): the hoisted runReplLoop prints it AFTER the // single alt-buffer exit, on the primary buffer (ADR-0068 §c). Only a real end (`/exit`) carries one; a `/clear` @@ -1894,12 +1992,22 @@ export function driveInk(ctx: ChatDriveContext): Promise { : { kind: ctx.stopReason() }, }); } catch (err) { + ctx.workOwner?.failure(err); + try { + stopDriverInput(); + } catch (cause) { + ctx.workOwner?.cleanupUncertain(cause); + } + if (ownedMount !== undefined) + return ownedMount.close().then(() => { + throw err; + }); // render() threw synchronously — clean up the interval, subscription, and SIGINT handler set up above so // none leaks past the throw (the finally above is never reached when render() throws). active = false; clearInterval(frame); unsubscribe(); - process.removeListener('SIGINT', onSigintGated); + removeInterrupt(); throw err; } } diff --git a/apps/cli/src/render/tui/home-app.test.tsx b/apps/cli/src/render/tui/home-app.test.tsx index d5447bf0..48964b4d 100644 --- a/apps/cli/src/render/tui/home-app.test.tsx +++ b/apps/cli/src/render/tui/home-app.test.tsx @@ -131,6 +131,7 @@ interface MountedHome { function mountHome( store: ChatStoreController, opts: { + inputEnabled?: () => boolean; alternateScreen?: boolean; startChat?: () => Promise; reseatChat?: ( @@ -174,6 +175,7 @@ function mountHome( assertRenderStoreAgree(opts.alternateScreen === true, store.getSnapshot().state.transcriptBound); const harness = render( Date.now()} color={opts.color ?? false} @@ -520,6 +522,39 @@ describe('RootApp — mouse selection in the in-Home chat', () => { return store; }; + it('seals mouse, paste and job-control dispatch while the native tree remains mounted', async () => { + let enabled = true; + const copied: string[] = []; + const onSuspend = vi.fn(); + const m = mountHome(seedThree(), { + alternateScreen: true, + inputEnabled: () => enabled, + onSuspend, + clipboard: (text) => { + copied.push(text); + return { kind: 'written', characters: text.length }; + }, + }); + await enterChat(m.c); + await waitFor(() => (m.harness.lastFrame() ?? '').includes('AAAA')); + m.harness.stdin.write('\x1a'); + await settleFrames(); + expect(onSuspend).toHaveBeenCalledOnce(); + m.harness.stdin.write('\x1b[<0;1;1M'); + await settleFrames(); + m.harness.stdin.write('\x1b[<32;3;1M'); + await settleFrames(); + enabled = false; + m.harness.stdin.write('\x1b[<0;3;1m'); + m.harness.stdin.write(bracketed('SEALED_HOME_PASTE')); + m.harness.stdin.write('\x1a'); + await settleFrames(); + expect(copied, 'HOME_NATIVE_INPUT_SEAL_INCLUDES_MOUSE_COPY').toEqual([]); + expect(onSuspend).toHaveBeenCalledOnce(); + expect(m.harness.lastFrame()).not.toContain('SEALED_HOME_PASTE'); + expect(m.c.getSnapshot().mode).toBe('chat'); + }); + it('a DRAG in the in-Home chat copies exactly the cells it covered', async () => { const copied: string[] = []; const store = seedThree(); diff --git a/apps/cli/src/render/tui/home-app.tsx b/apps/cli/src/render/tui/home-app.tsx index 54844040..a4af2161 100644 --- a/apps/cli/src/render/tui/home-app.tsx +++ b/apps/cli/src/render/tui/home-app.tsx @@ -59,6 +59,8 @@ import type { SuspendPort } from '../suspend.js'; import type { NoticeFlush } from '../../engine/effect-retention.js'; export interface RootAppProps { + /** Synchronous native teardown seal, including mouse and paste routes before controller dispatch. */ + readonly inputEnabled?: () => boolean; /** Publishes a mounted render acknowledgement bound to this Ink tree and its exact output stream. */ readonly onRendererReady?: (flushVisible: NoticeFlush) => void; readonly onRendererError?: (error: Error) => void; @@ -409,6 +411,7 @@ export function RootApp(props: Readonly): ReactElement { }; useInput((input, key) => { + if (props.inputEnabled?.() === false) return; // Ink raw mode parses Ctrl-Z as Ctrl+`z` (some harnesses surface the literal SUB byte). Consume it BEFORE mouse, // overlays, and the Home controller so it can neither type into a prompt nor answer a pending approval. if ( @@ -439,7 +442,9 @@ export function RootApp(props: Readonly): ReactElement { // Bracketed paste arrives on ink 7's native `usePaste` channel (separate from `useInput`): the whole paste is // one `text` event, so a multi-line block appends verbatim and a pasted approval token never reaches the key // reducers (ADR-0068). The controller gates it (drops behind an overlay / pending approval / mid-turn). - usePaste((text) => controller.handlePaste(text)); + usePaste((text) => { + if (props.inputEnabled?.() !== false) controller.handlePaste(text); + }); const flushVisible = useVisibleRenderFlush( props.onRendererError, props.suspendPort, diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index cdb9b456..58ca9f58 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -520,7 +520,7 @@ export const mutations = [ ' emitOutput(SESSION_EXPORT_REVIEW_NOTICE.trimEnd());\n', '', ['apps/cli/src/commands/chat.test.ts'], - 127, + 130, "reports fixed continuity and review notice on stderr after live 'human' /export", 'W2_EXPORT_LIVE_NOTICE', [ @@ -540,7 +540,7 @@ export const mutations = [ ' emitOutput(SESSION_EXPORT_REVIEW_NOTICE.trimEnd());\n', ' deps.io.writeErr(SESSION_EXPORT_REVIEW_NOTICE);\n', ['apps/cli/src/commands/chat.test.ts'], - 127, + 130, "renders the live export 'success' through the interactive transcript", 'W2_EXPORT_LIVE_RENDERED_NOTICE', ), From 12e413832e6ea53d8b42c675cabb4ff38137a0c6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 00:41:36 +0300 Subject: [PATCH 106/222] docs(docs): record partial chat and home ownership integration Describe the actual session and native terminal scope at f39a360d, author verification and selected causal-control evidence. Keep bootstrap activation, complete cumulative reviews, security and platform acceptance explicitly open. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- docs/reference/cli/commands.md | 42 ++++++++++++--- docs/roadmap/current.md | 5 +- .../phase-2.5.5-hardening-and-remediation.md | 52 +++++++++++++++++++ 3 files changed, 90 insertions(+), 9 deletions(-) diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 47dbbbfd..34ab0da5 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1,6 +1,6 @@ # CLI Command Reference (`relavium`) -> Last updated: 2026-10-16 +> Last updated: 2026-10-17 - **Status**: Reference (partial — surface defined, exact flags to be finalized as the CLI is built) - **Surface**: CLI (`relavium`) @@ -914,13 +914,39 @@ normal cleanup. Best-effort retention warnings retain their actual delivery tail and output-failure classification. The optional builder ownership port preserves accounting/publication failures across repeated host-safe-close checks. -The new per-mount terminal owner requires explicit acquisition custody and joins -entered suspension/publication work before final native release. Its actual -chat/Home Ink, persistent alternate-screen and job-control adapters remain staged. -Fresh/resumed/replaced chat/Home hosts, wizard custody and global native/bootstrap -activation still require integration and complete cumulative/platform acceptance. -The [implementation register](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-session-substrate-checkpoint--2026-10-16) -records this limited scope; whole Step 8 remains open. +Fresh, resumed, cleared and reseated standalone chats, and every actual pending, +late or superseded Home chat, now retain their construction and post-build +persistence/view setup independently of the UI's selected session. Equal durable +session IDs do not identify an instance's cleanup. The shared database closes in +the surface's final cleanup stage, after host roots and transferred producer, +accounting, prompt, catalog and output work. Signal custody remains until that +cleanup succeeds; a failed database close cannot remove the original signal path. + +Each actual Ink mount reserves custody before render, synchronously seals key, +paste, mouse and submit dispatch, and joins complete entered suspension and +job-control continuations before unmount and captured-input release. Native exit +and final control delivery are separate acknowledgements. The persistent chat +buffer retains all actual mounts and job bindings until their positive retirement; +no-Ink job-control custody covers old-host cleanup as well as rebuild. Control +delivery precedes native handoff and final buffer restoration precedes summary +publication. Every new final write checks the original remaining budget. + +Home onboarding binds informational output and each select/password prompt to +captured streams. The installed spinner's ambient-input path is excluded; a fixed +verification notice supplies progress. Actual provider validation remains retained +after its UX timeout or cancellation result. Every continuation rechecks admission, +including the provider command's internal post-secret-read boundary before keychain +and database mutation. Catalog discovery is joined before database cleanup and +checks admission before publication; unexpected local clock/key/publication faults +are observed before sibling discovery joins. Optional cache reads keep their +existing recoverable UI policy. + +These are **partial Step 8d implementation claims, not acceptance**. Global native, +bootstrap, caught/fatal/background/sink activation, complete cumulative reviews, +security and actual required platform acceptance remain open. The +[session substrate checkpoint](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-session-substrate-checkpoint--2026-10-16) +and [chat/Home checkpoint](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-chat-and-home-integration-checkpoint--2026-10-17) +record their exact scopes. Whole Step 8 remains open. ### Workflow and terminal ownership diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index bef61597..335af5a8 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1143,7 +1143,10 @@ checks preserve that execution attribution. [Step 8c workflow/terminal integrati at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI plus all four same-tarball platform consumers. Step 8d session/terminal integration and product fatal activation, -whole Step 8 and Steps 9–19 remain open. Step 8d proceeds automatically. The +whole Step 8 and Steps 9–19 remain open. Its +[2026-10-17 chat/Home implementation checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-chat-and-home-integration-checkpoint--2026-10-17) +records partial session/native-terminal integration and the remaining activation, +review/security/platform gates without claiming acceptance. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index e0572374..b0f09828 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -6069,3 +6069,55 @@ Two NEW complete cumulative clean rounds from the original `d9625ff0` boundary, a separate NEW clean security sitting and actual required platform evidence are still owed after final integration. **Whole Step 8 and Steps 9–19 remain open.** No new ADR, deferral, credential, provider call, billing or user decision is needed. + +## Wave 3 Step 8d chat and Home integration checkpoint — 2026-10-17 + +**Partial implementation on `development` at `f39a360d`; not Step 8d acceptance.** The +[canonical session ownership contract](../../reference/cli/commands.md#session-ownership-implementation-checkpoint) +now describes actual fresh/resume/clear/reseat and pending/superseded Home +construction, per-instance host/view/persister lifetime, captured Ink/prompt input, +shared database cleanup and persistent terminal/job-control integration. +One-shot acceptance is not inferred from the earlier substrate checkpoint. + +Source advisory reads identify and correct the following mechanisms. They are +implementation advice, not either required complete cumulative clean round or +the separate security sitting. Reports remain local and Git-ignored. The first +53-path pin remains historical; subsequent fixes have their own source identity. +Changes made during advisory reads are explicitly outside those earlier hashes. + +| Observed mechanism | Correction and permanent evidence | +|---|---| +| Readable-listener removal reentry consumes a pre-existing occurrence | Recheck actual multiplicity before every bounded removal. The real EventEmitter regression first fails at the baseline-preservation assertion, then passes. | +| Mounted input continues while native teardown waits | Gate root key/paste/submit handling before dispatch, including Home mouse copy. Installed Ink with a held native drain first reproduces a second Ctrl-Z dispatch, then passes after sealing. | +| Pending/replaced or equal-ID hosts are confused with the current UI pointer | Retain every actual builder invocation through post-build setup and all actual roots/children. Existing physical native forwarding controls retain separate instance/child counts. | +| Validation timeout or catalog completion permits early database close | Move shared close into surface cleanup after all roots/transferred work. Actual Home lifetime regressions retain held fake generation/discovery and refuse post-stop catalog writes. | +| Failed database close drops signal custody | Remove the signal subscription only after shared cleanup succeeds. A later terminating signal retains the original infrastructure disposition and deadline. | +| Partial view/exit-net/input registration or a failed remover supplies false release | Transfer custody before registration, guard empty construction, attempt sibling releases independently and retain uncertainty. Keep the synchronous exit net through final control ACK. | +| Unknown local catalog faults wait behind a held sibling | Report clock/key/publication faults at their actual boundary. Preserve optional cache-read recovery and ordinary provider-discovery isolation. | +| Old-host cleanup has no job-control handler; final publication starts after expiry | Hand off gap custody before waiting on receipts/MCP. Check remaining budget at each final publication; retain failed output classification before sibling joins. | +| Control enqueue is treated as native handoff | Retain exact captured delivery promises and await release/reclaim before native continuation. Final persistent-buffer restoration follows all actual child mounts and bindings. | +| Wizard or consent continuation writes after stop | Bind native prompts and cancellation, omit the installed ambient-input spinner, retain raw validation, and recheck admission after every relevant await and before credential writes. | + +No new dependency, ADR, deferral, paid provider call, credential or user decision +is introduced. The bootstrap remains unchanged and native product-wide fatal +activation is still staged. Complete Step 8d acceptance still requires two NEW +complete cumulative clean rounds from `d9625ff0`, a separate NEW clean security +sitting and actual required same-artifact platform evidence. Whole Step 8 and +Steps 9–19 remain open; work proceeds automatically. + +The final frozen partial source has **56 product/tool paths**, with no source +drift across the author gates. Fresh ordinary CLI execution passes **314 files / +5,266 tests / ten established skips**. The workspace lint/typecheck/test gate +passes **23 tasks**, comprising twenty valid cache hits and three fresh CLI tasks; +other packages' cached inventories are not a fresh whole-repository execution. +The build passes **six tasks**, with five cache hits and a fresh CLI build. +Tool lint, seam and engine-dependency fences, compiled CLI smoke and all **175 +compiled bootstrap controls** pass on macOS/Node **22.23.1**. The finite packaged +graph has **25 JavaScript outputs / seventeen declared external dependencies**. +Both affected secrets controls (`export-live-review-notice` and +`export-live-interactive-notice-routing`) pass separate isolated +fixed/removal/restored cycles against the complete **130-case** chat file, with +the expected assertion failures and byte-identical restoration. These selected +cycles do not claim completion of the entire secrets-control family. Synthetic +TTY/input controls and local macOS smoke do not establish actual Windows session +shutdown, operator-terminal restoration or complete Step 8d acceptance. From 9798c51c96a153e5a8e009766000130d8e69231b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 01:05:56 +0300 Subject: [PATCH 107/222] fix(cli): retain management work before fatal activation Give management and maintenance truthful no-health command ownership. Retain raw provider, catalog, doctor and captured-input work independently of report results; defer database cleanup until actual joins and keep failed close custody. Bind standalone create prompts and their restoration to captured IO. Share existing session work mechanics without changing session identity or workflow health. Preserve optional pricing/cache recovery and synchronous transient reads, while retaining their exact native close capabilities. Fresh workspace gates pass 23 tasks (20 valid cache hits); fresh CLI tests pass 320 files / 5290 tests / 10 established skips. Build and both compiled smokes pass. Complete Step 8d bootstrap, review, security and platform gates remain open; this is an implementation checkpoint, not acceptance. Refs: ADR-0110, ADR-0103, Wave 3 Step 8d Co-Authored-By: Codex --- .../authoring/create-prompter-native.test.ts | 64 +++++++ apps/cli/src/authoring/create-prompter.ts | 29 ++++ apps/cli/src/chat/doctor-deep.test.ts | 42 +++++ apps/cli/src/chat/doctor-deep.ts | 6 + apps/cli/src/chat/doctor-host.ts | 7 +- apps/cli/src/chat/session-work-owner.ts | 158 +---------------- apps/cli/src/commands/agent-run.ts | 4 +- apps/cli/src/commands/chat-export.ts | 6 +- apps/cli/src/commands/chat-list.ts | 6 +- apps/cli/src/commands/chat.ts | 6 +- apps/cli/src/commands/create.ts | 14 +- apps/cli/src/commands/dispatch.ts | 122 +++++++++---- apps/cli/src/commands/gate-list.ts | 5 +- apps/cli/src/commands/input.ts | 3 + apps/cli/src/commands/list.ts | 5 +- apps/cli/src/commands/logs.ts | 5 +- .../src/commands/management-cleanup.test.ts | 106 ++++++++++++ .../management-owner-integration.test.ts | 156 +++++++++++++++++ apps/cli/src/commands/media.ts | 16 +- .../native-forwarding.test-support.ts | 6 +- apps/cli/src/commands/provider.ts | 14 +- apps/cli/src/commands/status.ts | 5 +- apps/cli/src/engine/catalog-refresh.ts | 9 + apps/cli/src/engine/pricing-overlay.ts | 49 ++++-- apps/cli/src/home/drive-home.tsx | 2 + apps/cli/src/process/command-lifetime.test.ts | 149 ++++++++++++++++ apps/cli/src/process/command-lifetime.ts | 143 ++++++++++++++++ apps/cli/src/process/invocation-registry.ts | 12 +- apps/cli/src/process/invocation-work-owner.ts | 162 ++++++++++++++++++ .../src/process/synchronous-resource.test.ts | 138 +++++++++++++++ apps/cli/src/process/synchronous-resource.ts | 90 ++++++++++ .../src/secrets/read-secret-lifetime.test.ts | 69 ++++++++ apps/cli/src/secrets/read-secret.ts | 28 +++ 33 files changed, 1422 insertions(+), 214 deletions(-) create mode 100644 apps/cli/src/authoring/create-prompter-native.test.ts create mode 100644 apps/cli/src/commands/management-cleanup.test.ts create mode 100644 apps/cli/src/commands/management-owner-integration.test.ts create mode 100644 apps/cli/src/process/command-lifetime.test.ts create mode 100644 apps/cli/src/process/command-lifetime.ts create mode 100644 apps/cli/src/process/invocation-work-owner.ts create mode 100644 apps/cli/src/process/synchronous-resource.test.ts create mode 100644 apps/cli/src/process/synchronous-resource.ts create mode 100644 apps/cli/src/secrets/read-secret-lifetime.test.ts diff --git a/apps/cli/src/authoring/create-prompter-native.test.ts b/apps/cli/src/authoring/create-prompter-native.test.ts new file mode 100644 index 00000000..4f624051 --- /dev/null +++ b/apps/cli/src/authoring/create-prompter-native.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { CommandLifetime } from '../process/command-lifetime.js'; +import { captureIo, OwnedTtyInput, OwnedTtyOutput } from '../test-support.js'; +import { createCommand } from '../commands/create.js'; +import { acknowledgeWrite } from '../process/io.js'; +import { SHOW_CURSOR } from '../render/alt-screen.js'; +import { tmpdir } from 'node:os'; + +describe('installed create wizard prompt ownership', () => { + it('uses captured input and releases it before command retirement on stop', async () => { + const f = invocationFixture(); + const input = new OwnedTtyInput(); + const output = new OwnedTtyOutput(); + const { io } = captureIo(); + const lifetime = new CommandLifetime( + f.invocation, + { + ...io, + stdoutIsTty: true, + stdinIsTty: true, + stdin: input, + nativeStdin: input, + nativeStdout: output, + writeOutAcknowledged: (text) => acknowledgeWrite(output, text), + }, + () => () => undefined, + ); + const wizard = lifetime.execute(() => + createCommand( + { force: false }, + { + io: lifetime.io, + commandLifetime: lifetime, + global: { + json: false, + color: false, + cwd: tmpdir(), + configPath: undefined, + verbosity: 'normal', + }, + }, + ), + ); + try { + expect(output.frames.join('')).toContain('Create a'); + expect(input.listenerCount('keypress')).toBeGreaterThan(0); + f.invocation.finiteExit.signal(15); + await wizard; + expect( + input.listenerCount('keypress'), + 'CREATE_PROMPT_NATIVE_RELEASE_PRECEDES_RETIREMENT', + ).toBe(0); + expect(output.listenerCount('resize')).toBe(0); + expect(input.isRaw).toBe(false); + expect(output.frames.join('')).toContain(SHOW_CURSOR); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.exitCode()).toBe(143); + } finally { + input.destroy(); + output.destroy(); + } + }); +}); diff --git a/apps/cli/src/authoring/create-prompter.ts b/apps/cli/src/authoring/create-prompter.ts index 30e5068f..4467adb0 100644 --- a/apps/cli/src/authoring/create-prompter.ts +++ b/apps/cli/src/authoring/create-prompter.ts @@ -2,6 +2,10 @@ import { isCancel, select, text } from '@clack/prompts'; import { LLM_PROVIDERS } from '@relavium/shared'; import type { AuthoredKind, CreatePrompter, CreateSpec } from './authoring.js'; +import type { CliIo } from '../process/io.js'; +import type { InvocationWorkOwner } from '../process/invocation-work-owner.js'; +import { CliError } from '../process/cli-error.js'; +import { runSessionPrompt } from '../chat/session-prompt.js'; /** * The `@clack/prompts`-backed {@link CreatePrompter} (2.J, [ADR-0047](../../../../docs/decisions/0047-cli-framework-commander-ink-clack.md)) @@ -47,6 +51,31 @@ const defaultDeps: ClackCreateDeps = { isCancel, }; +/** Production prompts use the original captured terminal and retain actual Clack input release. */ +export function createOwnedClackPrompter(io: CliIo, owner: InvocationWorkOwner): CreatePrompter { + const input = io.nativeStdin; + const output = io.nativeStdout; + if (input === undefined || output === undefined) throw new CliError('cli_internal', ''); + const prompt = async (factory: (signal: AbortSignal) => Promise) => { + const answer = await runSessionPrompt( + owner.invocation, + io, + owner.assertAdmitting, + owner.failure, + factory, + owner.signal, + ); + owner.assertAdmitting(); + return answer; + }; + return createClackPrompter({ + select: (opts) => + prompt((signal) => select({ ...opts, options: [...opts.options], input, output, signal })), + text: (opts) => prompt((signal) => text({ ...opts, input, output, signal })), + isCancel, + }); +} + /** A clack `validate` callback that rejects an empty / whitespace-only / unset submit. Exported for unit test. */ export const required = (label: string) => diff --git a/apps/cli/src/chat/doctor-deep.test.ts b/apps/cli/src/chat/doctor-deep.test.ts index 8327511e..61862ac5 100644 --- a/apps/cli/src/chat/doctor-deep.test.ts +++ b/apps/cli/src/chat/doctor-deep.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it, vi } from 'vitest'; import type { ProviderResolver } from '../engine/providers.js'; import { CHAT_TEXT_CAPABILITY_FLAGS } from '../test-support.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { SessionWorkOwner } from './session-work-owner.js'; import { buildProviderProbe, mcpSessionChecks } from './doctor-deep.js'; // A test key assembled at runtime (no contiguous secret literal — leakwatch). @@ -40,6 +42,46 @@ const skip = (server: string, name: string, reason: string): ManagerSkippedTool }); describe('buildProviderProbe', () => { + it.each(['timeout', 'stop'] as const)( + 'retains the actual deep-doctor producer after its %s report', + async (end) => { + const f = invocationFixture(); + const cleanup = vi.fn(() => Promise.resolve()); + const workOwner = new SessionWorkOwner(f.invocation, 'chat', { + cancel: () => undefined, + cleanup, + }); + let settle: () => void = () => undefined; + const raw = new Promise((_resolve, reject) => { + settle = () => reject(new Error('synthetic provider settled')); + }); + const generate = vi.fn(() => raw); + const probe = buildProviderProbe({ + resolver: resolverWith({ anthropic: fakeProvider(generate) }, { anthropic: TEST_KEY }), + candidateIds: ['anthropic'], + timeoutMs: end === 'timeout' ? 5 : 100, + ...{ workOwner }, + })(); + expect(generate).toHaveBeenCalledOnce(); + if (end === 'stop') workOwner.stop(); + try { + const checks = await probe; + expect(checks).toHaveLength(1); + expect(checks[0]?.status).toBe('fail'); + expect(checks[0]?.detail).toContain(end === 'timeout' ? 'timeout' : 'stopped'); + void workOwner.close(); + await new Promise((resolve) => setImmediate(resolve)); + expect(cleanup, 'DEEP_DOCTOR_REPORT_IS_NOT_PRODUCER_SETTLEMENT').not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + } finally { + settle(); + await workOwner.close(); + } + expect(cleanup).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); + }, + ); it('warns when no candidate has a resolvable key', async () => { const probe = buildProviderProbe({ resolver: resolverWith({}), candidateIds: [] }); const checks = await probe(); diff --git a/apps/cli/src/chat/doctor-deep.ts b/apps/cli/src/chat/doctor-deep.ts index ed034134..6fdb76b6 100644 --- a/apps/cli/src/chat/doctor-deep.ts +++ b/apps/cli/src/chat/doctor-deep.ts @@ -10,6 +10,7 @@ import { } from '../engine/providers.js'; import { sanitizeInline } from '../render/tui/chat-projection.js'; import { okCheck, warnCheck, failCheck, type DoctorCheck } from './doctor.js'; +import type { SessionWorkOwner } from './session-work-owner.js'; /** * The `/doctor --deep` probes (2.5.C S5). @@ -30,6 +31,8 @@ import { okCheck, warnCheck, failCheck, type DoctorCheck } from './doctor.js'; // ── provider-key validation ────────────────────────────────────────────────── export interface ProviderProbeDeps { + /** A report timeout does not settle the actual provider operation or release its host. */ + readonly workOwner?: Pick; readonly resolver: ProviderResolver; /** The candidate provider ids to consider (default: every known provider, {@link KNOWN_PROVIDER_IDS}). Each is * validated ONLY if its key actually resolves (keychain ∪ env) — the filter runs lazily INSIDE the probe so @@ -42,11 +45,13 @@ export interface ProviderProbeDeps { /** Build the `--deep` provider probe: validate each CONFIGURED key with a bounded live ping. */ export function buildProviderProbe(deps: ProviderProbeDeps): () => Promise { return async () => { + deps.workOwner?.assertAdmitting(); const candidates = deps.candidateIds ?? KNOWN_PROVIDER_IDS; // Resolve each candidate's key ONCE (keychain → env → skip). All reads happen here, lazily, when the probe // runs — never at assembly. A provider with no key is simply not configured, so it is skipped (not failed). const configured: { readonly id: ProviderId; readonly key: string }[] = []; for (const id of candidates) { + deps.workOwner?.assertAdmitting(); try { configured.push({ id, key: deps.resolver.keyFor(id) }); } catch { @@ -75,6 +80,7 @@ async function probeProvider( key, KNOWN_PROVIDERS[id].testModel, deps.timeoutMs, + deps.workOwner, ); return result.ok ? okCheck(`provider:${id}`, id, result.detail) diff --git a/apps/cli/src/chat/doctor-host.ts b/apps/cli/src/chat/doctor-host.ts index b87b03ee..74104a12 100644 --- a/apps/cli/src/chat/doctor-host.ts +++ b/apps/cli/src/chat/doctor-host.ts @@ -8,6 +8,7 @@ import { createOsKeychainStore } from '../secrets/os-keychain.js'; import type { KeychainStore } from '../secrets/keychain.js'; import { buildProviderProbe, mcpSessionChecks } from './doctor-deep.js'; import type { DoctorProbes } from './doctor.js'; +import type { ProviderProbeDeps } from './doctor-deep.js'; /** * Assemble the production {@link DoctorProbes} from the host's real ports (2.5.C S5). Pure CONSTRUCTION — every @@ -23,6 +24,7 @@ import type { DoctorProbes } from './doctor.js'; const DOCTOR_KEYCHAIN_PROBE = 'doctor:reachability-probe'; export interface DoctorHostInputs { + readonly workOwner?: ProviderProbeDeps['workOwner']; readonly cwd: string; readonly configPath?: string; /** The provider seam — the `--deep` probe validates each provider whose key resolves (keychain ∪ env). */ @@ -56,7 +58,10 @@ export function assembleDoctorProbes(inputs: DoctorHostInputs): DoctorProbes { fsScopeTier: 'sandboxed', workspaceDir: inputs.cwd, }).host, - deepProviders: buildProviderProbe({ resolver: inputs.resolver }), + deepProviders: buildProviderProbe({ + resolver: inputs.resolver, + ...(inputs.workOwner === undefined ? {} : { workOwner: inputs.workOwner }), + }), deepMcp: () => Promise.resolve(mcpSessionChecks(inputs.agentMcpServers ?? [], inputs.mcpSkipped ?? [])), }; diff --git a/apps/cli/src/chat/session-work-owner.ts b/apps/cli/src/chat/session-work-owner.ts index 511f0e16..45ca2fb5 100644 --- a/apps/cli/src/chat/session-work-owner.ts +++ b/apps/cli/src/chat/session-work-owner.ts @@ -1,159 +1,19 @@ -import { EgressWorkScope } from '../engine/egress-work.js'; -import { toUserFacing } from '../process/errors.js'; import type { CliInvocation } from '../process/invocation.js'; -import type { OwnerRetirement, SessionOwner } from '../process/invocation-registry.js'; +import type { SessionOwner } from '../process/invocation-registry.js'; +import { + InvocationWorkOwner, + type InvocationWorkOwnerPorts, +} from '../process/invocation-work-owner.js'; -const observePromise: unknown = Object.getOwnPropertyDescriptor(Promise.prototype, 'then')?.value; - -export interface SessionWorkOwnerPorts { - /** Finite cancellation only, including when the common termination budget is exhausted. */ - readonly cancel: () => void; - /** Fulfilment must mean actual complete cleanup, independently of any retained receipt failure. */ - readonly cleanup: () => Promise; -} - -/** - * Reserve each host instance before its asynchronous construction. UI pointers and session IDs do - * not identify its lifetime: a late, replaced or superseded build still owns its entered work. - */ -export class SessionWorkOwner { - readonly invocation: CliInvocation; - readonly #ports: SessionWorkOwnerPorts; - readonly #roots = new EgressWorkScope(); - readonly #work = new EgressWorkScope(); - /** Already-entered roots may still transfer cleanup/receipt tails. This grants no new egress authority. */ - readonly work = Object.freeze({ - retainWork: (factory: () => Promise): Promise => { - const raw = this.#work.retainWork(factory); - if (this.#work.observationFailed) - this.failure(new Error('Session work observation is unavailable.')); - return raw; - }, - }); - readonly #cancel = new AbortController(); - readonly #stoppedMarker = new Error('Session operation stopped.'); - readonly #retired: Promise; - #resolveRetired: (facts: OwnerRetirement) => void = () => undefined; - #stopped = false; - #cleanupUncertain = false; - #closing: Promise | undefined; - #firstFailure: { readonly cause: unknown } | undefined; +export type SessionWorkOwnerPorts = InvocationWorkOwnerPorts; +/** Each real host retains its own instance identity, independently of UI pointers or durable IDs. */ +export class SessionWorkOwner extends InvocationWorkOwner { constructor( invocation: CliInvocation, surface: SessionOwner['surface'], ports: SessionWorkOwnerPorts, ) { - this.invocation = invocation; - Object.freeze(this.#stoppedMarker); - this.#ports = Object.freeze({ ...ports }); - invocation.finiteExit.activateFallback(); - this.#retired = new Promise((resolve) => { - this.#resolveRetired = resolve; - }); - invocation.registry.register({ - kind: 'session', - surface, - retired: this.#retired, - stop: this.stop, - join: this.close, - classifyFailure: () => 'cli_internal', - }); - } - - get signal(): AbortSignal { - return this.#cancel.signal; - } - - get firstFailure(): { readonly cause: unknown } | undefined { - return this.#firstFailure; - } - - /** Positive actual cleanup fact, shared with the invocation registry; expiry/rejection never resolves it. */ - get retired(): Promise { - return this.#retired; + super(invocation, { kind: 'session', surface }, ports); } - - readonly isStoppedFailure = (cause: unknown): boolean => cause === this.#stoppedMarker; - - readonly assertAdmitting = (): void => { - if (this.#stopped || this.invocation.registry.termination !== undefined) - throw this.#stoppedMarker; - this.invocation.registry.assertAdmitting(); - }; - - /** Register before entry, observe escaping infrastructure immediately, and return the exact raw promise. */ - readonly enter = (operation: () => Promise): Promise => { - this.assertAdmitting(); - let raw: Promise; - try { - raw = this.#roots.retainWork(operation); - } catch (cause) { - this.failure(cause); - throw cause; - } - try { - if (typeof observePromise !== 'function') throw new Error('Promise observation unavailable.'); - Reflect.apply(observePromise, raw, [undefined, this.failure]); - } catch (cause) { - // A failed observer is not settlement; the independently retained scope still owes its slot. - this.failure(cause); - } - return raw; - }; - - /** Known inline refusals are not the eventual chat/Home outcome. Only unexpected faults stop the invocation. */ - readonly failure = (cause: unknown): void => { - if (this.isStoppedFailure(cause)) return; - const facing = toUserFacing(cause); - if (facing.code !== 'cli_internal' && facing.code !== 'output_unavailable') return; - this.#firstFailure ??= { cause }; - this.invocation.finiteExit.fail(facing.code); - }; - - /** A callback-reported close fault is retained even when the manager's close promise fulfils. */ - readonly cleanupUncertain = (cause: unknown): void => { - this.failure(cause); // Keep an observed output disposition before any cleanup join can hold. - this.#cleanupUncertain = true; - this.#firstFailure ??= { cause }; - this.invocation.finiteExit.fail('cli_internal'); - }; - - readonly stop = (): void => { - if (this.#stopped) return; - this.#stopped = true; - this.#roots.seal(); - this.#cancel.abort(this.#stoppedMarker); - try { - this.#ports.cancel(); - } catch (cause) { - this.cleanupUncertain(cause); - } - }; - - /** Publish one shared cleanup promise before callbacks. Cancellation, rejection and expiry grant no retirement. */ - readonly close = (): Promise => { - if (this.#closing !== undefined) return this.#closing; - let complete: () => void = () => undefined; - const pending = new Promise((resolve) => { - complete = resolve; - }); - this.#closing = pending; - this.stop(); - void Promise.resolve() - .then(async () => { - // Cancellation seals future roots, not the cleanup continuations of roots already entered. - // Those roots can still register iterator.return/receipt tails before their actual settlement. - await this.#roots.done; - this.#work.seal(); - await this.#work.done; - if (this.invocation.registry.budget.remainingMs() === 0) return; - await this.#ports.cleanup(); - if (this.#cleanupUncertain) return; - this.#resolveRetired({ kind: 'settled' }); - complete(); - }) - .catch(this.cleanupUncertain); - return pending; - }; } diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index 1eff04dc..ce0e9af3 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -155,7 +155,9 @@ async function agentRunOwned( // The ADR-0065 §2 user-pricing overlay (2.5.G S10) — so a one-shot live turn enforces + tracks a user-priced // model. SKIPPED under `--fixture`: cassette pricing stays deterministic and fully offline. Usage coverage // and realized attribution still use the local database, without consulting the user pricing overlay. - const resolvePrice = offline ? undefined : loadUserPricingOverlay(homeDir, deps.io); + const resolvePrice = offline + ? undefined + : loadUserPricingOverlay(homeDir, deps.io, deps.invocation); // An unknown `` (path or id) throws a typed CliError here (exit 2), before any turn. The build is // async (2.R): it connects the agent's inline stdio `mcp_servers` (a connect failure is a fail-loud exit-2 diff --git a/apps/cli/src/commands/chat-export.ts b/apps/cli/src/commands/chat-export.ts index 5a2f94af..d90ef704 100644 --- a/apps/cli/src/commands/chat-export.ts +++ b/apps/cli/src/commands/chat-export.ts @@ -1,3 +1,4 @@ +import type { CommandLifetime } from '../process/command-lifetime.js'; import { closeQuietly } from '../process/close-quietly.js'; import { SessionExportedEventSchema } from '@relavium/shared'; @@ -27,6 +28,7 @@ export interface ChatExportCommandArgs { } export interface ChatExportCommandDeps { + readonly commandLifetime?: CommandLifetime; readonly io: CliIo; readonly global: GlobalOptions; /** Injectable session-store opener — tests pass an in-memory store; production opens `~/.relavium/history.db`. */ @@ -45,6 +47,7 @@ export function chatExportCommand( configPath: deps.global.configPath, }); const opened = (deps.openSessionStore ?? openSessionStore)(homeDir); + deps.commandLifetime?.adoptCleanup(() => opened.close()); try { const result = exportSession({ store: opened.store, @@ -95,6 +98,7 @@ export function chatExportCommand( } return EXIT_CODES.success; } finally { - closeQuietly(deps.io, 'session store', () => opened.close()); + if (deps.commandLifetime === undefined) + closeQuietly(deps.io, 'session store', () => opened.close()); } } diff --git a/apps/cli/src/commands/chat-list.ts b/apps/cli/src/commands/chat-list.ts index ade3c912..a0e785d4 100644 --- a/apps/cli/src/commands/chat-list.ts +++ b/apps/cli/src/commands/chat-list.ts @@ -1,3 +1,4 @@ +import type { CommandLifetime } from '../process/command-lifetime.js'; import { closeQuietly } from '../process/close-quietly.js'; import type { AgentSessionRecord } from '@relavium/shared'; @@ -10,6 +11,7 @@ import { sanitizeInline } from '../render/tui/chat-projection.js'; import { writeRecordLines } from '../render/records.js'; export interface ChatListCommandDeps { + readonly commandLifetime?: CommandLifetime; readonly io: CliIo; readonly global: GlobalOptions; /** Injectable session-store opener — tests pass an in-memory store; production opens `~/.relavium/history.db`. */ @@ -30,6 +32,7 @@ export function chatListCommand(deps: ChatListCommandDeps): ExitCode { configPath: deps.global.configPath, }); const opened = (deps.openSessionStore ?? openSessionStore)(homeDir); + deps.commandLifetime?.adoptCleanup(() => opened.close()); try { if (deps.global.json) { opened.store.visitSessionSummaries((session) => { @@ -55,7 +58,8 @@ export function chatListCommand(deps: ChatListCommandDeps): ExitCode { } return EXIT_CODES.success; } finally { - closeQuietly(deps.io, 'session store', () => opened.close()); + if (deps.commandLifetime === undefined) + closeQuietly(deps.io, 'session store', () => opened.close()); } } diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 72681b0a..2561f254 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -618,7 +618,7 @@ async function chatCommandBody( // The ADR-0065 §2 user-pricing overlay (2.5.G S10) — a transient read of the `model_catalog` `source='user'` // rows, so a user-priced model is enforced by `[chat].max_cost_microcents` + tracked in realized cost. NON-FATAL: // an unopenable db yields `undefined` here and surfaces cleanly through the session store open below. - const resolvePrice = loadUserPricingOverlay(homeDir, deps.io); + const resolvePrice = loadUserPricingOverlay(homeDir, deps.io, deps.invocation); // An unknown --agent / un-inferrable default model throws a typed CliError here (exit 2), before any session. // The build is async (2.R): it connects the agent's inline stdio `mcp_servers` (a connect failure is a @@ -732,6 +732,7 @@ async function chatCommandBody( const doctorProbes = deps.doctorProbes ?? assembleDoctorProbes({ + ...(built.workOwner === undefined ? {} : { workOwner: built.workOwner }), cwd: deps.global.cwd, ...(deps.global.configPath === undefined ? {} : { configPath: deps.global.configPath }), resolver: providers, @@ -1024,6 +1025,7 @@ async function chatResumeCommandBody( const doctorProbes = deps.doctorProbes ?? assembleDoctorProbes({ + ...(built.workOwner === undefined ? {} : { workOwner: built.workOwner }), cwd: deps.global.cwd, ...(deps.global.configPath === undefined ? {} : { configPath: deps.global.configPath }), resolver: providers, @@ -1923,6 +1925,7 @@ async function buildFreshChatWiring( throw err; } const doctorProbes = assembleDoctorProbes({ + ...(built.workOwner === undefined ? {} : { workOwner: built.workOwner }), cwd: deps.cwd, ...(deps.configPath === undefined ? {} : { configPath: deps.configPath }), resolver: deps.providers, @@ -2219,6 +2222,7 @@ async function buildReseatWiring( // transcript rather than a raw `io.writeErr` the alt buffer would discard (Step-4b-3 Sonnet review). for (const line of mcpSkippedLines(resumed.mcpSkipped)) seeded.store.notice(line); const doctorProbes = assembleDoctorProbes({ + ...(resumed.workOwner === undefined ? {} : { workOwner: resumed.workOwner }), cwd: deps.global.cwd, ...(deps.configPath === undefined ? {} : { configPath: deps.configPath }), resolver: deps.providers, diff --git a/apps/cli/src/commands/create.ts b/apps/cli/src/commands/create.ts index 99e6ef81..5e56c156 100644 --- a/apps/cli/src/commands/create.ts +++ b/apps/cli/src/commands/create.ts @@ -9,7 +9,8 @@ import { writeAuthoredFile, type CreatePrompter, } from '../authoring/authoring.js'; -import { createClackPrompter } from '../authoring/create-prompter.js'; +import { createClackPrompter, createOwnedClackPrompter } from '../authoring/create-prompter.js'; +import type { CommandLifetime } from '../process/command-lifetime.js'; import { warnSnapshotLiterals } from '../authoring/snapshot-notice.js'; import { CliError } from '../process/errors.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; @@ -23,6 +24,7 @@ export interface CreateCommandArgs { } export interface CreateCommandDeps { + readonly commandLifetime?: CommandLifetime; readonly io: CliIo; readonly global: GlobalOptions; /** Injectable wizard (tests inject canned answers); default is the `@clack/prompts` wizard. */ @@ -63,7 +65,15 @@ export async function createCommand( ); } - const spec = await (deps.prompter ?? createClackPrompter()).gather(); + const owner = deps.commandLifetime?.work; + owner?.assertAdmitting(); + const spec = await ( + deps.prompter ?? + (owner === undefined + ? createClackPrompter() + : createOwnedClackPrompter(deps.commandLifetime?.terminalIo ?? deps.io, owner)) + ).gather(); + owner?.assertAdmitting(); if (spec === null) { // The user cancelled the wizard (Ctrl-C / ESC) — nothing was written; a clean, non-fault exit. deps.io.writeErr('create cancelled.\n'); diff --git a/apps/cli/src/commands/dispatch.ts b/apps/cli/src/commands/dispatch.ts index 26b4c3c7..a19ea789 100644 --- a/apps/cli/src/commands/dispatch.ts +++ b/apps/cli/src/commands/dispatch.ts @@ -30,6 +30,9 @@ import type { KeychainStore } from '../secrets/keychain.js'; import { createMcpSecretResolver } from '../secrets/mcp-secret.js'; import { createOsKeychainStore } from '../secrets/os-keychain.js'; import { readSecretFromStdin } from '../secrets/read-secret.js'; +import { Readable } from 'node:stream'; +import { withCommandLifetime } from '../process/command-lifetime.js'; +import { withSynchronousResource } from '../process/synchronous-resource.js'; import { agentRunCommand, type AgentRunCommandArgs } from './agent-run.js'; import { chatCommand, chatResumeCommand, type ChatCommandArgs } from './chat.js'; import { chatExportCommand, type ChatExportCommandArgs } from './chat-export.js'; @@ -380,12 +383,11 @@ function storeAwareResolver( cwd: ctx.global.cwd, configPath: ctx.global.configPath, }); - const { db, close } = openLocalDb(homeDir); - try { - return fromDb(db); - } finally { - close(); - } + return withSynchronousResource( + ctx.invocation, + () => openLocalDb(homeDir), + ({ db }) => fromDb(db), + ); } /** One native keychain accessor, shared by the key resolver (2.C) + the MCP named-secret resolver (2.R §6). */ @@ -437,19 +439,18 @@ const executeChatResume: CommandExecutor = (input, ctx) => ); const executeChatList: CommandExecutor = (_input, ctx) => - Promise.resolve(chatListCommand({ io: ctx.io, global: ctx.global, openSessionStore })); + Promise.resolve(chatListCommand({ ...ctx, openSessionStore })); const executeChatExport: CommandExecutor = (input, ctx) => Promise.resolve( chatExportCommand(buildChatExportArgs(input), { - io: ctx.io, - global: ctx.global, + ...ctx, openSessionStore, }), ); const executeCreate: CommandExecutor = (input, ctx) => - createCommand({ force: boolFlag(input.options['force']) }, { io: ctx.io, global: ctx.global }); + createCommand({ force: boolFlag(input.options['force']) }, ctx); const executeExport: CommandExecutor = (input, ctx) => Promise.resolve(exportCommand(buildExportArgs(input), { io: ctx.io, global: ctx.global })); @@ -483,23 +484,16 @@ const executeBudget: CommandExecutor = (input, ctx) => const executeGateList: CommandExecutor = (input, ctx) => { const runId = optPositional(input, 0); - return Promise.resolve( - gateListCommand(runId === undefined ? {} : { runId }, { io: ctx.io, global: ctx.global }), - ); + return Promise.resolve(gateListCommand(runId === undefined ? {} : { runId }, ctx)); }; const executeList: CommandExecutor = (input, ctx) => - Promise.resolve( - listCommand({ agents: boolFlag(input.options['agents']) }, { io: ctx.io, global: ctx.global }), - ); + Promise.resolve(listCommand({ agents: boolFlag(input.options['agents']) }, ctx)); const executeLogs: CommandExecutor = (input, ctx) => - Promise.resolve( - logsCommand({ runId: reqPositional(input, 0, 'runId') }, { io: ctx.io, global: ctx.global }), - ); + Promise.resolve(logsCommand({ runId: reqPositional(input, 0, 'runId') }, ctx)); -const executeStatus: CommandExecutor = (_input, ctx) => - statusCommand({ io: ctx.io, global: ctx.global }); +const executeStatus: CommandExecutor = (_input, ctx) => statusCommand(ctx); /** * The lazy `llm_providers`-UUID → provider-slug (e.g. `anthropic`) resolver the `models` list path uses for its @@ -542,6 +536,7 @@ export interface ModelsDbPorts { readonly refreshCatalog: ( homeDir: string, persist?: (catalog: Readonly>) => void, + lifecycle?: { readonly signal: AbortSignal; readonly assertAdmitting: () => void }, ) => Promise; } @@ -549,8 +544,8 @@ const PRODUCTION_MODELS_PORTS: ModelsDbPorts = { openDb: openLocalDb, makeResolver: (io, providerStore) => createProviderResolver(io.env, createOsKeychainStore(), { providerStore }), - refreshCatalog: (homeDir, persist) => - refreshCatalog({ homeDir, ...(persist === undefined ? {} : { persist }) }), + refreshCatalog: (homeDir, persist, lifecycle) => + refreshCatalog({ homeDir, ...(persist === undefined ? {} : { persist }), ...lifecycle }), }; /** @@ -564,11 +559,22 @@ export async function withModelsDeps( args: ModelsCommandArgs, ports: ModelsDbPorts = PRODUCTION_MODELS_PORTS, ): Promise { + return withCommandLifetime(ctx, (owned) => withModelsDepsOwned(owned, args, ports)); +} + +async function withModelsDepsOwned( + ctx: DispatchContext, + args: ModelsCommandArgs, + ports: ModelsDbPorts, +): Promise { + const owner = ctx.commandLifetime?.work; + owner?.assertAdmitting(); const { homeDir, config } = loadResolvedConfig({ cwd: ctx.global.cwd, configPath: ctx.global.configPath, }); const { db, close } = ports.openDb(homeDir); + ctx.commandLifetime?.adoptCleanup(close); try { const storeDeps = { uuid: () => randomUUID(), now: () => Date.now() }; // The `models` command prices, and it holds the db — so make the DB the catalog's authoritative backing here @@ -580,12 +586,21 @@ export async function withModelsDeps( // The refresh's DB persist hook — mirror a fresh models.dev fetch into `model_metadata` over THIS open db. const metadataStore = createModelMetadataStore(db, storeDeps); const persistCatalog = (catalog: Readonly>): void => { - persistCatalogToDb(metadataStore, catalog, storeDeps.now()); + owner?.assertAdmitting(); + try { + persistCatalogToDb(metadataStore, catalog, storeDeps.now()); + } catch (cause) { + owner?.failure(cause); + throw cause; + } }; const providerStore = createProviderStore(db, storeDeps); const resolver = ports.makeResolver(ctx.io, providerStore); // store-aware ⇒ a custom base_url is used (S9) const catalogStore = createModelCatalogStore(db, storeDeps); - const refreshService = createModelRefreshService({ + const service = createModelRefreshService({ + ...(owner === undefined + ? {} + : { assertAdmitting: owner.assertAdmitting, onHostFailure: owner.failure }), resolveProvider: resolver.resolveProvider, keyFor: resolver.keyFor, providerStore, @@ -594,6 +609,16 @@ export async function withModelsDeps( knownProviders: KNOWN_PROVIDERS, now: () => Date.now(), }); + const refreshService = + owner === undefined + ? service + : { + ...service, + refresh: () => { + owner.assertAdmitting(); + return owner.work.retainWork(() => service.refresh({ signal: owner.signal })); + }, + }; return await modelsCommand(args, { io: ctx.io, global: ctx.global, @@ -602,13 +627,24 @@ export async function withModelsDeps( // ADR-0071 §4a: the catalog half. The command holds a THUNK, not a socket — so a test drives it with a fake // fetch, and the pure `@relavium/llm` catalog keeps taking data as an argument rather than reaching out itself. // `persistCatalog` (ADR-0072) mirrors the fetch into `model_metadata` over this command's open db. - refreshCatalog: () => ports.refreshCatalog(homeDir, persistCatalog), + refreshCatalog: () => { + owner?.assertAdmitting(); + const fetch = () => + ports.refreshCatalog( + homeDir, + persistCatalog, + owner === undefined + ? undefined + : { signal: owner.signal, assertAdmitting: owner.assertAdmitting }, + ); + return owner === undefined ? fetch() : owner.work.retainWork(fetch); + }, // Default OFF (ADR-0071 §4): absent config ⇒ no standing egress, and the shipped snapshot answers everything. ...(config.catalogAutoRefresh ? { autoRefreshCatalog: true } : {}), providerSlug: createProviderSlugResolver(providerStore), }); } finally { - close(); + if (ctx.commandLifetime === undefined) close(); } } @@ -643,6 +679,7 @@ const executeModelsPricing: CommandExecutor = (input, ctx) => { configPath: ctx.global.configPath, }); const { db, close } = openLocalDb(homeDir); + ctx.commandLifetime?.adoptCleanup(close); try { const storeDeps = { uuid: () => randomUUID(), now: () => Date.now() }; return Promise.resolve( @@ -654,7 +691,7 @@ const executeModelsPricing: CommandExecutor = (input, ctx) => { }), ); } finally { - close(); + if (ctx.commandLifetime === undefined) close(); } }; @@ -663,11 +700,21 @@ async function withProviderDeps( ctx: DispatchContext, fn: (deps: ProviderCommandDeps) => Promise, ): Promise { + return withCommandLifetime(ctx, (owned) => withProviderDepsOwned(owned, fn)); +} + +async function withProviderDepsOwned( + ctx: DispatchContext, + fn: (deps: ProviderCommandDeps) => Promise, +): Promise { + const owner = ctx.commandLifetime?.work; + owner?.assertAdmitting(); const { homeDir } = loadResolvedConfig({ cwd: ctx.global.cwd, configPath: ctx.global.configPath, }); const { db, close } = openLocalDb(homeDir); + ctx.commandLifetime?.adoptCleanup(close); try { const keychain = createOsKeychainStore(); // one native accessor, shared by the store-ref writes + the resolver const store = createProviderStore(db, { uuid: () => randomUUID(), now: () => Date.now() }); @@ -677,12 +724,23 @@ async function withProviderDeps( keychain, // Store-aware so `provider test` pings a custom `base_url` provider at its CUSTOM endpoint (2.5.G S9). resolver: createProviderResolver(ctx.io.env, keychain, { providerStore: store }), - readSecret: readSecretFromStdin, + readSecret: + owner === undefined + ? readSecretFromStdin + : () => { + owner.assertAdmitting(); + if (!(ctx.io.stdin instanceof Readable)) throw new CliError('cli_internal', ''); + return readSecretFromStdin(undefined, ctx.io.stdin, { + signal: owner.signal, + onFailure: owner.failure, + }); + }, + ...(owner === undefined ? {} : { workOwner: owner, assertAdmitting: owner.assertAdmitting }), global: ctx.global, // for `provider list --json` (2.5.G S11, ADR-0049) }; return await fn(deps); } finally { - close(); + if (ctx.commandLifetime === undefined) close(); } } @@ -768,5 +826,7 @@ export async function executeCommand( '--allow-fs-root applies only to Home, chat, chat-resume and agent run.', ); } - return executor(input, ctx); + if (['run', 'chat', 'chat-resume', 'agent.run', 'gate', 'budget.resume'].includes(id)) + return executor(input, ctx); + return withCommandLifetime(ctx, (owned) => executor(input, owned)); } diff --git a/apps/cli/src/commands/gate-list.ts b/apps/cli/src/commands/gate-list.ts index 3b168ce7..d0049566 100644 --- a/apps/cli/src/commands/gate-list.ts +++ b/apps/cli/src/commands/gate-list.ts @@ -1,3 +1,4 @@ +import type { CommandLifetime } from '../process/command-lifetime.js'; import type { Db, RunHistoryReader } from '@relavium/db'; import { loadResolvedConfig } from '../config/load.js'; @@ -24,6 +25,7 @@ export interface GateListCommandArgs { } export interface GateListCommandDeps { + readonly commandLifetime?: CommandLifetime; readonly io: CliIo; readonly global: GlobalOptions; readonly openDb?: (homeDir: string) => { db: Db; close: () => void }; @@ -47,6 +49,7 @@ export function gateListCommand(args: GateListCommandArgs, deps: GateListCommand configPath: deps.global.configPath, }); const { reader, close } = openHistoryReader(homeDir, deps.openDb); + deps.commandLifetime?.adoptCleanup(close); try { // Per-run isolation (`readPerRunOrDegrade`) for the no-arg ALL-RUNS form: one damaged run used to abort the // whole listing. With an explicit `runId` there is only one run, so degrading would silently answer "no @@ -82,7 +85,7 @@ export function gateListCommand(args: GateListCommandArgs, deps: GateListCommand renderGateRows(deps.io, rows, args.runId); return EXIT_CODES.success; } finally { - close(); + if (deps.commandLifetime === undefined) close(); } } diff --git a/apps/cli/src/commands/input.ts b/apps/cli/src/commands/input.ts index 923968bc..7eccf9c4 100644 --- a/apps/cli/src/commands/input.ts +++ b/apps/cli/src/commands/input.ts @@ -2,6 +2,7 @@ import type { CliInvocation } from '../process/invocation.js'; import type { ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; +import type { CommandLifetime } from '../process/command-lifetime.js'; export type CommandOptionValue = string | boolean | readonly string[] | undefined; @@ -11,6 +12,8 @@ export interface CommandInput { } export interface DispatchContext { + /** Runtime-only management owner; workflow/session drivers keep their own lifetime. */ + readonly commandLifetime?: CommandLifetime; /** One bootstrap owner, retained through lazy routes; native owner adapters land separately. */ readonly invocation?: CliInvocation; readonly io: CliIo; diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts index 5064c1f8..4c5b9bad 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -1,3 +1,4 @@ +import type { CommandLifetime } from '../process/command-lifetime.js'; import type { Db, RunRecord } from '@relavium/db'; import { byCodeUnit } from '@relavium/shared'; @@ -16,6 +17,7 @@ export interface ListCommandArgs { } export interface ListCommandDeps { + readonly commandLifetime?: CommandLifetime; readonly io: CliIo; readonly global: GlobalOptions; /** Injectable history-db opener — tests pass an in-memory db; production opens `~/.relavium/history.db`. */ @@ -65,12 +67,13 @@ export function listCommand(args: ListCommandArgs, deps: ListCommandDeps): ExitC const lastBySlug = new Map(); if (kind === 'workflows' && entries.some((entry) => entry.valid)) { const { reader, close } = openHistoryReader(homeDir, deps.openDb); + deps.commandLifetime?.adoptCleanup(close); try { for (const summary of reader.loadLatestRunPerWorkflow()) { lastBySlug.set(summary.slug, summary.lastRun); } } finally { - close(); + if (deps.commandLifetime === undefined) close(); } } // A valid workflow entry's trusted last run, or `undefined` (an invalid entry never picks up an overlay). diff --git a/apps/cli/src/commands/logs.ts b/apps/cli/src/commands/logs.ts index 1c8b633a..a0401612 100644 --- a/apps/cli/src/commands/logs.ts +++ b/apps/cli/src/commands/logs.ts @@ -1,3 +1,4 @@ +import type { CommandLifetime } from '../process/command-lifetime.js'; import type { Db } from '@relavium/db'; import type { RunEvent } from '@relavium/shared'; @@ -18,6 +19,7 @@ export interface LogsCommandArgs { const MAX_REPORTED_SKIPS = 8; export interface LogsCommandDeps { + readonly commandLifetime?: CommandLifetime; readonly io: CliIo; readonly global: GlobalOptions; readonly openDb?: (homeDir: string) => { db: Db; close: () => void }; @@ -35,6 +37,7 @@ export function logsCommand(args: LogsCommandArgs, deps: LogsCommandDeps): ExitC configPath: deps.global.configPath, }); const { reader, close } = openHistoryReader(homeDir, deps.openDb); + deps.commandLifetime?.adoptCleanup(close); try { const run = reader.loadRun(args.runId); if (run === undefined) { @@ -72,7 +75,7 @@ export function logsCommand(args: LogsCommandArgs, deps: LogsCommandDeps): ExitC } return EXIT_CODES.success; } finally { - close(); + if (deps.commandLifetime === undefined) close(); } } diff --git a/apps/cli/src/commands/management-cleanup.test.ts b/apps/cli/src/commands/management-cleanup.test.ts new file mode 100644 index 00000000..fbae9779 --- /dev/null +++ b/apps/cli/src/commands/management-cleanup.test.ts @@ -0,0 +1,106 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createClient, + createSessionStore, + runMigrations, + FilesystemMediaStore, +} from '@relavium/db'; +import { describe, expect, it, vi } from 'vitest'; +import { CommandLifetime } from '../process/command-lifetime.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import { chatListCommand } from './chat-list.js'; +import { pruneTemporariesCommand } from './media.js'; + +const global = { + json: true, + color: false, + cwd: tmpdir(), + configPath: undefined, + verbosity: 'normal', +} as const; +const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +describe('management cleanup is an actual obligation', () => { + it('retains a failed reader close despite a successfully rendered empty listing', async () => { + const f = invocationFixture(); + const client = createClient(':memory:'); + runMigrations(client.db); + const { io } = captureIo(); + const lifetime = new CommandLifetime(f.invocation, io, () => () => undefined); + const close = vi.fn(() => { + throw new Error('PRIVATE_READER_CLOSE'); + }); + try { + void lifetime.execute(() => + Promise.resolve( + chatListCommand({ + io: lifetime.io, + global, + commandLifetime: lifetime, + openSessionStore: () => ({ + db: client.db, + store: createSessionStore(client.db), + close, + }), + }), + ), + ); + await reactions(); + expect(close).toHaveBeenCalledOnce(); + expect(client.sqlite.open).toBe(true); + expect(f.registry.activeOwners, 'LISTING_WARNING_CANNOT_ACKNOWLEDGE_DATABASE_CLOSE').toBe(1); + expect(f.registry.observations.exitCode()).toBe(9); + f.advance(3000); + await reactions(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(close).toHaveBeenCalledOnce(); + } finally { + client.sqlite.close(); + } + }); + + it('retains an already-entered maintenance operation and suppresses publication after stop', async () => { + const f = invocationFixture(); + const homeDir = mkdtempSync(join(tmpdir(), 'relavium-owned-maintenance-')); + const { io, out } = captureIo(); + let settle: () => void = () => undefined; + const raw = new Promise((resolve) => { + settle = resolve; + }); + const prune = vi + .spyOn(FilesystemMediaStore.prototype, 'pruneLegacyTemporaries') + .mockImplementation(async () => { + expect(f.registry.activeOwners).toBe(1); + await raw; + return { removed: 0, retained: 0, reasons: {} }; + }); + const command = pruneTemporariesCommand( + { legacyOffline: true, force: true }, + { + io, + global, + homeDir, + invocation: f.invocation, + }, + ); + try { + expect(prune).toHaveBeenCalledOnce(); + f.invocation.finiteExit.fail('cli_internal'); + await reactions(); + expect(f.registry.activeOwners, 'MAINTENANCE_RAW_OPERATION_IS_STILL_OWED').toBe(1); + expect(f.registry.budget.deadline()).toBe(3000); + expect(out()).toBe(''); + settle(); + await expect(command).rejects.toMatchObject({ code: 'cli_internal', exitCode: 9 }); + expect(out()).toBe(''); + expect(f.registry.activeOwners).toBe(0); + } finally { + settle(); + prune.mockRestore(); + rmSync(homeDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/commands/management-owner-integration.test.ts b/apps/cli/src/commands/management-owner-integration.test.ts new file mode 100644 index 00000000..74f76313 --- /dev/null +++ b/apps/cli/src/commands/management-owner-integration.test.ts @@ -0,0 +1,156 @@ +import { mkdtempSync, mkdirSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createClient, runMigrations, createProviderStore } from '@relavium/db'; +import type { LlmProvider, ModelListing } from '@relavium/llm'; +import { describe, expect, it, vi } from 'vitest'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { CommandLifetime } from '../process/command-lifetime.js'; +import { CliError } from '../process/cli-error.js'; +import { captureIo, CHAT_TEXT_CAPABILITY_FLAGS } from '../test-support.js'; +import { withModelsDeps, type ModelsDbPorts } from './dispatch.js'; +import { createCommand } from './create.js'; + +const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); +function gate() { + let release: () => void = () => undefined; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} +const global = { + json: false, + color: false, + cwd: tmpdir(), + configPath: undefined, + verbosity: 'normal', +} as const; + +describe('actual management dispatch ownership', () => { + it.each(['signal', 'catalog-failure'] as const)( + 'retains the real SQLite handle behind sibling discovery on %s', + async (end) => { + const f = invocationFixture(); + const client = createClient(':memory:'); + runMigrations(client.db); + const { io, out } = captureIo(); + const child = gate(); + const entered = gate(); + let providerSignal: unknown; + const provider: LlmProvider = { + id: 'anthropic', + supports: CHAT_TEXT_CAPABILITY_FLAGS, + generate: () => Promise.reject(new Error('not used')), + stream: () => { + throw new Error('not used'); + }, + listModels: async (_key, signal): Promise => { + providerSignal = signal; + entered.release(); + await child.promise; + return [{ id: 'synthetic-after-stop', displayName: 'Synthetic' }]; + }, + }; + const close = vi.fn(); + let catalogSignal: AbortSignal | undefined; + const ports: ModelsDbPorts = { + openDb: () => ({ db: client.db, close }), + makeResolver: () => ({ + resolveProvider: () => provider, + keyFor: (id) => { + if (id !== 'anthropic') throw new CliError('invalid_invocation', 'no configured key'); + return 'synthetic-key'; + }, + }), + refreshCatalog: (_home, _persist, lifecycle) => { + const signal: unknown = Object.getOwnPropertyDescriptor(lifecycle ?? {}, 'signal')?.value; + catalogSignal = signal instanceof AbortSignal ? signal : undefined; + return end === 'catalog-failure' + ? Promise.reject(new Error('PRIVATE_LOCAL_CATALOG_FAULT')) + : Promise.resolve({ status: 'refreshed', models: 1, added: 0 }); + }, + }; + const command = withModelsDeps( + { io, global, invocation: f.invocation }, + { refresh: true }, + ports, + ); + // Observe either outcome before triggering native stop; no unhandled test promise. + const outcome = command.then( + (value) => ({ value }), + (cause: unknown) => ({ cause }), + ); + try { + await entered.promise; + if (end === 'signal') f.invocation.finiteExit.signal(15); + await reactions(); + expect(catalogSignal).toBeInstanceOf(AbortSignal); + expect(catalogSignal?.aborted).toBe(true); + expect(providerSignal).toMatchObject({ aborted: true }); + expect(client.sqlite.open).toBe(true); + expect(close, 'MANAGEMENT_DATABASE_WAITS_FOR_RAW_SIBLING').not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + expect(f.registry.observations.exitCode()).toBe(end === 'signal' ? 143 : 9); + child.release(); + const result = await outcome; + if (end === 'catalog-failure') + expect(result).toMatchObject({ cause: { code: 'cli_internal' } }); + expect(close).toHaveBeenCalledOnce(); + expect(out()).toBe(''); + expect( + createProviderStore(client.db, { uuid: () => 'unused', now: () => 0 }).get('anthropic'), + ).toBeUndefined(); + } finally { + child.release(); + await outcome; + client.sqlite.close(); + } + }, + ); + + it('does not publish a scaffold when an injected wizard answer arrives after stop', async () => { + const f = invocationFixture(); + const cwd = mkdtempSync(join(tmpdir(), 'relavium-owned-create-')); + mkdirSync(join(cwd, '.relavium')); + const { io } = captureIo(); + const lifetime = new CommandLifetime(f.invocation, io, () => () => undefined); + const answer = gate(); + const command = lifetime.execute(() => + createCommand( + { force: false }, + { + io: lifetime.io, + global: { ...global, cwd }, + commandLifetime: lifetime, + prompter: { + gather: async () => { + await answer.promise; + return { + kind: 'agent', + name: 'Late Answer', + provider: 'anthropic', + model: 'synthetic', + systemPrompt: 'Synthetic', + tools: [], + }; + }, + }, + }, + ), + ); + try { + f.invocation.finiteExit.signal(15); + answer.release(); + await command; + expect( + existsSync(join(cwd, '.relavium', 'agents', 'late-answer.agent.yaml')), + 'CREATE_LATE_ANSWER_CANNOT_WRITE', + ).toBe(false); + expect(f.registry.activeOwners).toBe(0); + } finally { + answer.release(); + rmSync(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/commands/media.ts b/apps/cli/src/commands/media.ts index 46816875..74114ff1 100644 --- a/apps/cli/src/commands/media.ts +++ b/apps/cli/src/commands/media.ts @@ -7,6 +7,8 @@ import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; import { stringifyJsonLine } from '../render/sanitize.js'; +import { withCommandLifetime, type CommandLifetime } from '../process/command-lifetime.js'; +import type { CliInvocation } from '../process/invocation.js'; export interface MediaPruneArgs { readonly legacyOffline: boolean; @@ -16,7 +18,13 @@ export interface MediaPruneArgs { /** ADR-0106: strict acknowledgements precede even CAS construction; no config/history/key services. */ export async function pruneTemporariesCommand( args: MediaPruneArgs, - deps: { readonly io: CliIo; readonly global: GlobalOptions; readonly homeDir?: string }, + deps: { + readonly io: CliIo; + readonly global: GlobalOptions; + readonly homeDir?: string; + readonly invocation?: CliInvocation; + readonly commandLifetime?: CommandLifetime; + }, ): Promise { if (args.legacyOffline !== true || args.force !== true) { throw new CliError( @@ -24,6 +32,11 @@ export async function pruneTemporariesCommand( 'Temporary maintenance requires --legacy-offline and --force.', ); } + return withCommandLifetime(deps, (owned) => pruneOwned({ ...deps, ...owned })); +} + +async function pruneOwned(deps: Parameters[1]): Promise { + deps.commandLifetime?.work.assertAdmitting(); let report: Awaited>; try { const store = new FilesystemMediaStore(join(globalConfigDir(deps.homeDir), 'media')); @@ -36,6 +49,7 @@ export async function pruneTemporariesCommand( } throw storageBoundaryError(error); } + deps.commandLifetime?.work.assertAdmitting(); const output = deps.global.json ? `${stringifyJsonLine(report)}\n` : `Temporary maintenance: removed ${report.removed}, retained ${report.retained}.\n${Object.entries( diff --git a/apps/cli/src/commands/native-forwarding.test-support.ts b/apps/cli/src/commands/native-forwarding.test-support.ts index 41bfbd03..e58f100b 100644 --- a/apps/cli/src/commands/native-forwarding.test-support.ts +++ b/apps/cli/src/commands/native-forwarding.test-support.ts @@ -27,7 +27,11 @@ export async function witnessNativeToolProcess( ...(nativeProcesses === undefined ? {} : { nativeProcesses }), }); if (host.process === undefined) throw new Error('the real process arm is missing'); - expect(invocation.registry.activeOwners).toBe(expectedParentOwners); + // A synchronously closed transient DB can still have its positive retirement reaction + // queued. Require its actual removal before measuring the stable native-parent baseline. + await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(expectedParentOwners), { + interval: 1, + }); const pending = host.process.spawn( process.execPath, ['-e', 'setTimeout(() => process.stdout.write("native-witness"), 150)'], diff --git a/apps/cli/src/commands/provider.ts b/apps/cli/src/commands/provider.ts index f119b3e0..8b5aefbe 100644 --- a/apps/cli/src/commands/provider.ts +++ b/apps/cli/src/commands/provider.ts @@ -12,6 +12,7 @@ import { CliError, isCliError } from '../process/errors.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; +import type { InvocationWorkOwner } from '../process/invocation-work-owner.js'; import { writeRecordLines } from '../render/records.js'; import { stripTerminalControls } from '../render/tui/chat-projection.js'; import { @@ -48,6 +49,7 @@ export interface ProviderCommandArgs { } export interface ProviderCommandDeps { + readonly workOwner?: Pick; /** Recheck original invocation admission after asynchronous secret reading, before credential/DB writes. */ readonly assertAdmitting?: () => void; readonly io: CliIo; @@ -118,7 +120,14 @@ async function verifyProvider( } throw err; } - const result = await validateProviderKey(provider, key, KNOWN_PROVIDERS[id].testModel); + const result = await validateProviderKey( + provider, + key, + KNOWN_PROVIDERS[id].testModel, + undefined, + deps.workOwner, + ); + deps.assertAdmitting?.(); // The failure detail is a REMOTE endpoint's error body — clean + bound it at the source so both the human row and // the `--json` `verifyDetail` carry the safe value (the key is already redacted by `validateProviderKey`). return { verified: result.ok, detail: result.ok ? null : cleanDetail(result.detail) }; @@ -315,7 +324,8 @@ async function providerTest(args: ProviderCommandArgs, deps: ProviderCommandDeps const model = args.model ?? KNOWN_PROVIDERS[id].testModel; // The live ping + the defensive key-redaction live in `validateProviderKey` (the seam), shared with the // `/doctor --deep` probe so the secret-scrubbing has one tested home. - const result = await validateProviderKey(provider, key, model); + const result = await validateProviderKey(provider, key, model, undefined, deps.workOwner); + deps.assertAdmitting?.(); // The detail is a REMOTE endpoint's message — clean + bound it before it reaches the TTY (a rogue endpoint must // not inject a cursor jump / bidi spoof via the failure line or the success ping). The key is already redacted. if (!result.ok) { diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index b09550e8..12aca20a 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -1,3 +1,4 @@ +import type { CommandLifetime } from '../process/command-lifetime.js'; import type { RunEvent } from '@relavium/shared'; import type { Db, RunHistoryReader, RunRecord, StepRecord } from '@relavium/db'; @@ -22,6 +23,7 @@ import { createFileTerminalOutbox } from '../engine/terminal-outbox.js'; import { terminalOutboxPath } from '../history/open.js'; export interface StatusCommandDeps { + readonly commandLifetime?: CommandLifetime; readonly io: CliIo; readonly global: GlobalOptions; readonly openDb?: (homeDir: string) => { db: Db; close: () => void }; @@ -82,6 +84,7 @@ export async function statusCommand(deps: StatusCommandDeps): Promise configPath: deps.global.configPath, }); const { reader, close } = openHistoryReader(homeDir, deps.openDb); + deps.commandLifetime?.adoptCleanup(close); // Runs whose TERMINAL is held in the outbox (ADR-0078 §4/§5) — read, never drained. // // A run in that state is still `running` in the derived projection, so it appears here as an ordinary @@ -140,7 +143,7 @@ export async function statusCommand(deps: StatusCommandDeps): Promise } return EXIT_CODES.success; } finally { - close(); + if (deps.commandLifetime === undefined) close(); } } diff --git a/apps/cli/src/engine/catalog-refresh.ts b/apps/cli/src/engine/catalog-refresh.ts index 6c969e92..98ad252e 100644 --- a/apps/cli/src/engine/catalog-refresh.ts +++ b/apps/cli/src/engine/catalog-refresh.ts @@ -53,6 +53,8 @@ export interface CatalogRefreshResult { } export interface CatalogRefreshDeps { + readonly signal?: AbortSignal; + readonly assertAdmitting?: () => void; readonly homeDir: string; /** Injectable for tests — the real one is global `fetch`. */ readonly fetch?: typeof globalThis.fetch; @@ -74,7 +76,9 @@ export interface CatalogRefreshDeps { * to fail a command the user ran to *look at their models*. */ export async function refreshCatalog(deps: CatalogRefreshDeps): Promise { + deps.assertAdmitting?.(); const fetched = await fetchModelsDevPayload(deps); + deps.assertAdmitting?.(); if (typeof fetched !== 'string') return fetched; // a classified network/HTTP failure — the snapshot answers // INSTALL is a SEPARATE failure domain from the network. A Zod / parse / normalization throw here means models.dev @@ -103,8 +107,12 @@ async function fetchModelsDevPayload( ): Promise { const doFetch = deps.fetch ?? globalThis.fetch; const controller = new AbortController(); + const stopped = (): void => controller.abort(); + deps.signal?.addEventListener('abort', stopped, { once: true }); + if (deps.signal?.aborted === true) stopped(); const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); try { + deps.assertAdmitting?.(); const response = await doFetch(SOURCE_URL, { signal: controller.signal, // NO credentials, NO cookies, NO user data — an unauthenticated GET of a public file (ADR-0071 §8). @@ -150,6 +158,7 @@ async function fetchModelsDevPayload( }; } finally { clearTimeout(timer); + deps.signal?.removeEventListener('abort', stopped); } } diff --git a/apps/cli/src/engine/pricing-overlay.ts b/apps/cli/src/engine/pricing-overlay.ts index c20d5960..9e48137a 100644 --- a/apps/cli/src/engine/pricing-overlay.ts +++ b/apps/cli/src/engine/pricing-overlay.ts @@ -1,5 +1,7 @@ import { closeQuietly } from '../process/close-quietly.js'; import type { CliIo } from '../process/io.js'; +import type { CliInvocation } from '../process/invocation.js'; +import { withSynchronousResource } from '../process/synchronous-resource.js'; import { randomUUID } from 'node:crypto'; import { @@ -96,23 +98,34 @@ function readPricingRows(db: Db): PricingOverlay { export function loadUserPricingOverlay( homeDir: string, io: Pick, + invocation?: CliInvocation, ): PricingOverlay | undefined { - let opened: { db: Db; close: () => void }; - try { - opened = openLocalDb(homeDir); - } catch { - return undefined; // a broken db is reported by the surface's own store open, not this best-effort read - } - try { - // Same M4-safe catalog cutover as readUserPricingOverlay, on this transient handle (ADR-0072 point 7). - syncCatalogFromDb(opened.db, readStoreDeps.now); - return buildUserPricingOverlay(opened.db); - } catch { - // A READ fault too (a corrupt provider/catalog row, a locked table) degrades to `undefined` — the docstring's - // "any db fault ⇒ undefined" contract is unconditional, so the overlay is never the thing that crashes a - // surface; the surface's own store open is the authoritative fault report. - return undefined; - } finally { - closeQuietly(io, 'history database', () => opened.close()); - } + return withSynchronousResource( + invocation, + () => { + let opened: { db: Db; close: () => void }; + try { + opened = openLocalDb(homeDir); + } catch { + // Preserve optional open/read degradation; the authoritative store reports its own failure. + return { db: undefined, close: () => undefined }; + } + return { + db: opened.db, + close: () => + invocation === undefined + ? closeQuietly(io, 'history database', () => opened.close()) + : opened.close(), + }; + }, + ({ db }) => { + if (db === undefined) return undefined; + try { + syncCatalogFromDb(db, readStoreDeps.now); + return buildUserPricingOverlay(db); + } catch { + return undefined; + } + }, + ); } diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index 4645abb0..bfbf97cb 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -529,6 +529,7 @@ async function driveHomeBody(deps: HomeDeps, ownership?: SessionHomeLifetime): P const doctorProbes = deps.doctorProbes ?? assembleDoctorProbes({ + ...(ownership === undefined ? {} : { workOwner: ownership.surface.work }), cwd: deps.global.cwd, ...(deps.global.configPath === undefined ? {} : { configPath: deps.global.configPath }), resolver: providers, @@ -605,6 +606,7 @@ async function driveHomeBody(deps: HomeDeps, ownership?: SessionHomeLifetime): P const chatDoctorProbes = deps.doctorProbes ?? assembleDoctorProbes({ + ...(built.workOwner === undefined ? {} : { workOwner: built.workOwner }), cwd: deps.global.cwd, ...(deps.global.configPath === undefined ? {} : { configPath: deps.global.configPath }), resolver: providers, diff --git a/apps/cli/src/process/command-lifetime.test.ts b/apps/cli/src/process/command-lifetime.test.ts new file mode 100644 index 00000000..e9b72a1d --- /dev/null +++ b/apps/cli/src/process/command-lifetime.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest'; +import { captureIo } from '../test-support.js'; +import { CliError } from './cli-error.js'; +import { CommandLifetime } from './command-lifetime.js'; +import { invocationFixture } from './invocation.test-support.js'; + +function gate() { + let release: () => void = () => undefined; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} +const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +function fixture() { + const f = invocationFixture(); + let signal: (signo: number) => void = () => undefined; + const removeSignals = vi.fn(); + const registration = vi.spyOn(f.registry, 'register'); + const { io } = captureIo(); + const lifetime = new CommandLifetime(f.invocation, io, (listener) => { + signal = listener; + return removeSignals; + }); + return { ...f, lifetime, removeSignals, registration, signal: (signo: number) => signal(signo) }; +} + +describe('management command lifetime', () => { + it.each([0, 1, 2] as const)( + 'preserves ordinary %s with a truthful no-health owner', + async (code) => { + const f = fixture(); + expect(f.registration).toHaveBeenCalledWith(expect.objectContaining({ kind: 'command' })); + const close = vi.fn(); + f.lifetime.adoptCleanup(close); + expect(await f.lifetime.execute(() => Promise.resolve(code))).toBe(code); + expect(close).toHaveBeenCalledOnce(); + expect(f.removeSignals).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.budget.deadline()).toBeUndefined(); + expect(f.exit).not.toHaveBeenCalled(); + }, + ); + + it('joins raw work after a UX result before closing the command database', async () => { + const f = fixture(); + const raw = gate(); + const close = vi.fn(); + f.lifetime.adoptCleanup(close); + const command = f.lifetime.execute(() => { + void f.lifetime.work.work.retainWork(() => raw.promise); + return Promise.resolve(0); + }); + await reactions(); + expect(close, 'COMMAND_RESULT_IS_NOT_RAW_SETTLEMENT').not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + f.signal(15); + expect(f.lifetime.work.signal.aborted).toBe(true); + expect(f.exit).not.toHaveBeenCalled(); + raw.release(); + await command; + await reactions(); + expect(close).toHaveBeenCalledOnce(); + expect(f.removeSignals).toHaveBeenCalledOnce(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(143); + }); + + it('starts the common fault budget before a competing held raw operation', async () => { + const f = fixture(); + const raw = gate(); + const close = vi.fn(); + f.lifetime.adoptCleanup(close); + const command = f.lifetime.execute(() => { + void f.lifetime.work.work.retainWork(() => raw.promise); + throw new Error('PRIVATE_COMMAND_CAUSE'); + }); + await reactions(); + expect(f.registry.budget.deadline()).toBe(3000); + expect(f.registry.observations.exitCode()).toBe(9); + expect(close).not.toHaveBeenCalled(); + raw.release(); + await expect(command).rejects.toMatchObject({ code: 'cli_internal', exitCode: 9 }); + expect(close).toHaveBeenCalledOnce(); + }); + + it('keeps failed cleanup and original signal custody unacknowledged', async () => { + const f = fixture(); + f.lifetime.adoptCleanup(() => { + throw new Error('PRIVATE_CLOSE_FAILURE'); + }); + void f.lifetime.execute(() => Promise.resolve(0)); + await reactions(); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.activeOwners).toBe(1); + expect(f.removeSignals).not.toHaveBeenCalled(); + f.advance(500); + f.signal(15); + expect(f.registry.budget.deadline()).toBe(3000); + f.advance(3000); + await reactions(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(f.registry.activeOwners).toBe(1); + }); + + it('starts all independent releases before awaiting one and does not overwrite an operational refusal', async () => { + const f = fixture(); + const held = gate(); + const later = vi.fn(); + f.lifetime.adoptCleanup(() => held.promise); + f.lifetime.adoptCleanup(later); + const refusal = new CliError('internal', 'known operational refusal'); + const command = f.lifetime.execute(() => Promise.reject(refusal)); + await reactions(); + expect(later).toHaveBeenCalledOnce(); + expect(f.removeSignals).not.toHaveBeenCalled(); + expect(f.registry.budget.deadline()).toBeUndefined(); + held.release(); + await expect(command).rejects.toBe(refusal); + expect(f.registry.observations.exitCode()).toBe(1); + expect(f.exit).not.toHaveBeenCalled(); + }); + + it('classifies an actual output failure before raw-child cleanup and refuses late writes', async () => { + const f = invocationFixture(); + const { io } = captureIo(); + const write = vi.fn(() => { + throw new Error('PRIVATE_SINK_FAILURE'); + }); + const lifetime = new CommandLifetime( + f.invocation, + { ...io, writeOut: write }, + () => () => undefined, + ); + const child = gate(); + const command = lifetime.execute(() => { + void lifetime.work.work.retainWork(() => child.promise); + lifetime.io.writeOut('requested result'); + return Promise.resolve(0); + }); + await reactions(); + expect(f.registry.observations.infrastructureCode()).toBe('output_unavailable'); + expect(f.registry.budget.deadline()).toBe(3000); + expect(() => lifetime.io.writeOut('late result')).toThrow('Command operation stopped.'); + expect(write).toHaveBeenCalledOnce(); + child.release(); + await expect(command).rejects.toMatchObject({ code: 'output_unavailable' }); + }); +}); diff --git a/apps/cli/src/process/command-lifetime.ts b/apps/cli/src/process/command-lifetime.ts new file mode 100644 index 00000000..c89ba045 --- /dev/null +++ b/apps/cli/src/process/command-lifetime.ts @@ -0,0 +1,143 @@ +import type { DispatchContext } from '../commands/input.js'; +import { CliError, toUserFacing } from './errors.js'; +import { EXIT_CODES, type ExitCode } from './exit-codes.js'; +import type { CliInvocation } from './invocation.js'; +import type { CliIo } from './io.js'; +import { InvocationWorkOwner } from './invocation-work-owner.js'; +import { defaultSubscribeSignals } from './signals.js'; + +/** One management operation, with no AgentSession label or workflow receipt authority. */ +export class CommandLifetime { + readonly work: InvocationWorkOwner; + readonly io: CliIo; + /** Captured restoration capability; business admission must not block an entered prompt's cleanup. */ + readonly terminalIo: CliIo; + readonly #cleanups: (() => void | Promise)[] = []; + readonly #removeSignals: () => void; + #cleaning = false; + + constructor( + readonly invocation: CliInvocation, + io: CliIo, + subscribeSignals = defaultSubscribeSignals, + ) { + this.terminalIo = io; + this.work = new InvocationWorkOwner( + invocation, + { kind: 'command' }, + { + cancel: () => undefined, + cleanup: async () => { + this.#cleaning = true; + // Enter all independent releases before awaiting any. An uncertain release never retires this owner. + const releases = this.#cleanups.splice(0).map((close) => + Promise.resolve() + .then(close) + .catch((cause: unknown) => { + this.work.cleanupUncertain(cause); + }), + ); + await Promise.all(releases); + if (this.#cleanupFailed) await new Promise(() => {}); + this.#removeSignals(); + }, + }, + ); + this.io = { + ...io, + writeOut: (text) => this.#write(() => io.writeOut(text)), + writeErr: (text) => this.#write(() => io.writeErr(text)), + writeOutAcknowledged: (text) => this.#deliver(() => io.writeOutAcknowledged(text)), + writeErrAcknowledged: (text) => this.#deliver(() => io.writeErrAcknowledged(text)), + }; + try { + this.#removeSignals = subscribeSignals((signo) => invocation.finiteExit.signal(signo)); + } catch (cause) { + this.work.cleanupUncertain(cause); + throw cause; + } + } + + #cleanupFailed = false; + + /** Adopt an already-entered acquisition even if stop raced its return; roots still hold cleanup open. */ + readonly adoptCleanup = (close: () => void | Promise): void => { + if (this.#cleaning) { + const cause = new CliError('cli_internal', ''); + this.work.cleanupUncertain(cause); + throw cause; + } + this.#cleanups.push(() => { + try { + const raw = close(); + return Promise.resolve(raw).catch((cause: unknown) => { + this.#cleanupFailed = true; + throw cause; + }); + } catch (cause) { + this.#cleanupFailed = true; + throw cause; + } + }); + }; + + async execute(body: () => Promise): Promise { + let result: ExitCode = EXIT_CODES.success; + let failure: { readonly cause: unknown } | undefined; + try { + result = await this.work.enter(body); + if (result === 9 || result === 5 || result === 7 || result === 8) + this.invocation.finiteExit.fail('cli_internal'); + else this.invocation.registry.observations.observeOutcome(result); + } catch (cause) { + if (!this.work.isStoppedFailure(cause)) { + failure = { cause }; + this.invocation.registry.observations.observeFailure(toUserFacing(cause).code); + } + } finally { + await this.work.close(); + } + if (this.invocation.registry.observations.hasInfrastructureFailure()) + throw new CliError( + this.invocation.registry.observations.infrastructureCode() ?? 'cli_internal', + '', + failure === undefined ? undefined : { cause: failure.cause }, + ); + if (failure !== undefined) throw failure.cause; + return result; + } + + #write(factory: () => void): void { + this.work.assertAdmitting(); + try { + factory(); + } catch (cause) { + const failure = new CliError('output_unavailable', '', { cause }); + this.work.failure(failure); + throw failure; + } + } + + #deliver(factory: () => Promise): Promise { + this.work.assertAdmitting(); + return this.work.work.retainWork(async () => { + try { + await factory(); + } catch (cause) { + const failure = new CliError('output_unavailable', '', { cause }); + this.work.failure(failure); + throw failure; + } + }); + } +} + +/** Existing workflow/session drivers own themselves; this helper covers management and maintenance only. */ +export function withCommandLifetime( + ctx: DispatchContext, + body: (owned: DispatchContext) => Promise, +): Promise { + if (ctx.invocation === undefined || ctx.commandLifetime !== undefined) return body(ctx); + const lifetime = new CommandLifetime(ctx.invocation, ctx.io); + return lifetime.execute(() => body({ ...ctx, io: lifetime.io, commandLifetime: lifetime })); +} diff --git a/apps/cli/src/process/invocation-registry.ts b/apps/cli/src/process/invocation-registry.ts index 5167083c..815d58ec 100644 --- a/apps/cli/src/process/invocation-registry.ts +++ b/apps/cli/src/process/invocation-registry.ts @@ -52,6 +52,11 @@ export interface SessionOwner extends OwnerBase { readonly surface: 'chat' | 'home' | 'agent'; } +/** Management/maintenance operations own raw work and cleanup, never workflow receipt health. */ +export interface CommandOwner extends OwnerBase { + readonly kind: 'command'; +} + export interface TerminalOwner extends OwnerBase { readonly kind: 'terminal'; readonly restore: (budget: RemainingBudget) => void | Promise; @@ -69,7 +74,12 @@ export interface NativeOwner extends OwnerBase { readonly exitReap?: () => void; } -export type InvocationOwner = WorkflowOwner | SessionOwner | TerminalOwner | NativeOwner; +export type InvocationOwner = + | WorkflowOwner + | SessionOwner + | CommandOwner + | TerminalOwner + | NativeOwner; export type OwnerStage = | 'stop' | 'join' diff --git a/apps/cli/src/process/invocation-work-owner.ts b/apps/cli/src/process/invocation-work-owner.ts new file mode 100644 index 00000000..badcde1b --- /dev/null +++ b/apps/cli/src/process/invocation-work-owner.ts @@ -0,0 +1,162 @@ +import { EgressWorkScope } from '../engine/egress-work.js'; +import { toUserFacing } from './errors.js'; +import type { CliInvocation } from './invocation.js'; +import type { CommandOwner, OwnerRetirement, SessionOwner } from './invocation-registry.js'; + +const observePromise: unknown = Object.getOwnPropertyDescriptor(Promise.prototype, 'then')?.value; + +export interface InvocationWorkOwnerPorts { + /** Finite cancellation only, including when the common termination budget is exhausted. */ + readonly cancel: () => void; + /** Fulfilment must mean actual complete cleanup, independently of any retained receipt failure. */ + readonly cleanup: () => Promise; +} + +/** + * Retain entered roots and transferred raw work for one no-health owner. Session wrappers + * keep their actual surface identity; management commands do not impersonate an AgentSession. + */ +export class InvocationWorkOwner { + readonly invocation: CliInvocation; + readonly #ports: InvocationWorkOwnerPorts; + readonly #roots = new EgressWorkScope(); + readonly #work = new EgressWorkScope(); + /** Already-entered roots may still transfer cleanup/receipt tails. This grants no new egress authority. */ + readonly work = Object.freeze({ + retainWork: (factory: () => Promise): Promise => { + const raw = this.#work.retainWork(factory); + if (this.#work.observationFailed) + this.failure(new Error('Session work observation is unavailable.')); + return raw; + }, + }); + readonly #cancel = new AbortController(); + readonly #stoppedMarker: Error; + readonly #retired: Promise; + #resolveRetired: (facts: OwnerRetirement) => void = () => undefined; + #stopped = false; + #cleanupUncertain = false; + #closing: Promise | undefined; + #firstFailure: { readonly cause: unknown } | undefined; + + constructor( + invocation: CliInvocation, + identity: Pick | Pick, + ports: InvocationWorkOwnerPorts, + ) { + this.invocation = invocation; + this.#stoppedMarker = Object.freeze( + new Error( + identity.kind === 'session' ? 'Session operation stopped.' : 'Command operation stopped.', + ), + ); + this.#ports = Object.freeze({ ...ports }); + invocation.finiteExit.activateFallback(); + this.#retired = new Promise((resolve) => { + this.#resolveRetired = resolve; + }); + invocation.registry.register({ + ...identity, + retired: this.#retired, + stop: this.stop, + join: this.close, + classifyFailure: () => 'cli_internal', + }); + } + + get signal(): AbortSignal { + return this.#cancel.signal; + } + + get firstFailure(): { readonly cause: unknown } | undefined { + return this.#firstFailure; + } + + /** Positive actual cleanup fact, shared with the invocation registry; expiry/rejection never resolves it. */ + get retired(): Promise { + return this.#retired; + } + + readonly isStoppedFailure = (cause: unknown): boolean => cause === this.#stoppedMarker; + + readonly assertAdmitting = (): void => { + if (this.#stopped || this.invocation.registry.termination !== undefined) + throw this.#stoppedMarker; + this.invocation.registry.assertAdmitting(); + }; + + /** Register before entry, observe escaping infrastructure immediately, and return the exact raw promise. */ + readonly enter = (operation: () => Promise): Promise => { + this.assertAdmitting(); + let raw: Promise; + try { + raw = this.#roots.retainWork(operation); + } catch (cause) { + this.failure(cause); + throw cause; + } + try { + if (typeof observePromise !== 'function') throw new Error('Promise observation unavailable.'); + Reflect.apply(observePromise, raw, [undefined, this.failure]); + } catch (cause) { + // A failed observer is not settlement; the independently retained scope still owes its slot. + this.failure(cause); + } + return raw; + }; + + /** Known inline refusals are not the eventual surface outcome. Only unexpected faults stop the invocation. */ + readonly failure = (cause: unknown): void => { + if (this.isStoppedFailure(cause)) return; + const facing = toUserFacing(cause); + if (facing.code !== 'cli_internal' && facing.code !== 'output_unavailable') return; + this.#firstFailure ??= { cause }; + this.invocation.finiteExit.fail(facing.code); + }; + + /** A callback-reported close fault is retained even when the manager's close promise fulfils. */ + readonly cleanupUncertain = (cause: unknown): void => { + this.failure(cause); // Keep an observed output disposition before any cleanup join can hold. + this.#cleanupUncertain = true; + this.#firstFailure ??= { cause }; + this.invocation.finiteExit.fail('cli_internal'); + }; + + readonly stop = (): void => { + if (this.#stopped) return; + this.#stopped = true; + this.#roots.seal(); + this.#cancel.abort(this.#stoppedMarker); + try { + this.#ports.cancel(); + } catch (cause) { + this.cleanupUncertain(cause); + } + }; + + /** Publish one shared cleanup promise before callbacks. Cancellation, rejection and expiry grant no retirement. */ + readonly close = (): Promise => { + if (this.#closing !== undefined) return this.#closing; + let complete: () => void = () => undefined; + const pending = new Promise((resolve) => { + complete = resolve; + }); + this.#closing = pending; + this.stop(); + void Promise.resolve() + .then(async () => { + // Cancellation seals future roots, not the cleanup continuations of roots already entered. + // Those roots can still register iterator.return/receipt tails before their actual settlement. + await this.#roots.done; + this.#work.seal(); + await this.#work.done; + if (this.invocation.registry.budget.remainingMs() === 0) return; + await this.#ports.cleanup(); + if (this.#cleanupUncertain) return; + this.#resolveRetired({ kind: 'settled' }); + complete(); + }) + .catch(this.cleanupUncertain); + return pending; + }; +} diff --git a/apps/cli/src/process/synchronous-resource.test.ts b/apps/cli/src/process/synchronous-resource.test.ts new file mode 100644 index 00000000..21b17aa8 --- /dev/null +++ b/apps/cli/src/process/synchronous-resource.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CliError } from './cli-error.js'; +import { invocationFixture } from './invocation.test-support.js'; +import { withSynchronousResource } from './synchronous-resource.js'; + +const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +describe('transient synchronous resource custody', () => { + it('keeps the acquired close capability when a caller changes the returned container', async () => { + const f = invocationFixture(); + const close = vi.fn(); + const foreign = vi.fn(); + const resource = { close }; + withSynchronousResource( + f.invocation, + () => resource, + (opened) => { + Object.defineProperty(opened, 'close', { value: foreign }); + }, + ); + expect(close).toHaveBeenCalledOnce(); + expect(foreign).not.toHaveBeenCalled(); + await reactions(); + expect(f.registry.activeOwners).toBe(0); + }); + it('registers before acquisition and closes synchronously before the next resource opens', async () => { + const f = invocationFixture(); + const order: string[] = []; + const result = withSynchronousResource( + f.invocation, + () => { + expect(f.registry.activeOwners).toBe(1); + order.push('open'); + return { + close: () => { + order.push('close'); + }, + value: 42, + }; + }, + ({ value }) => { + order.push('read'); + return value; + }, + ); + order.push('next-open'); + expect(result).toBe(42); + expect(order).toEqual(['open', 'read', 'close', 'next-open']); + await reactions(); + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); + }); + + it('retains a throwing native close and its exact capability without retry or false retirement', async () => { + const f = invocationFixture(); + const close = vi.fn(() => { + throw new Error('PRIVATE_TRANSIENT_CLOSE'); + }); + expect(() => + withSynchronousResource( + f.invocation, + () => ({ close }), + () => 42, + ), + ).toThrow(CliError); + await reactions(); + expect(f.registry.activeOwners, 'TRANSIENT_CLOSE_THROW_IS_NOT_RETIREMENT').toBe(1); + expect(f.registry.observations.exitCode()).toBe(9); + f.advance(3000); + await reactions(); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(close).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(1); + }); + + it('preserves the primary refusal while a failed release adds stronger infrastructure uncertainty', async () => { + const f = invocationFixture(); + const refusal = new CliError('invalid_invocation', 'known refusal'); + expect(() => + withSynchronousResource( + f.invocation, + () => ({ + close: () => { + throw new Error('close'); + }, + }), + () => { + throw refusal; + }, + ), + ).toThrow(refusal); + await reactions(); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.activeOwners).toBe(1); + }); + + it('cannot reentrantly close an active synchronous query', async () => { + const f = invocationFixture(); + const close = vi.fn(); + expect( + withSynchronousResource( + f.invocation, + () => ({ close }), + () => { + f.invocation.finiteExit.fail('cli_internal'); + expect(close).not.toHaveBeenCalled(); + return 'query completed'; + }, + ), + ).toBe('query completed'); + expect(close).toHaveBeenCalledOnce(); + await reactions(); + expect(f.registry.activeOwners).toBe(0); + }); + + it('distinguishes a trusted cleaned-up refusal from unknown partial acquisition', async () => { + for (const unknown of [false, true]) { + const f = invocationFixture(); + const cause = unknown + ? new Error('PRIVATE_ACQUIRE') + : new CliError('storage_unavailable', ''); + const use = vi.fn(() => undefined); + expect(() => + withSynchronousResource( + f.invocation, + () => { + throw cause; + }, + use, + ), + ).toThrow(cause); + await reactions(); + expect(use).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(unknown ? 1 : 0); + expect(f.registry.observations.hasInfrastructureFailure()).toBe(unknown); + } + }); +}); diff --git a/apps/cli/src/process/synchronous-resource.ts b/apps/cli/src/process/synchronous-resource.ts new file mode 100644 index 00000000..b1555e75 --- /dev/null +++ b/apps/cli/src/process/synchronous-resource.ts @@ -0,0 +1,90 @@ +import { CliError, toUserFacing } from './errors.js'; +import type { CliInvocation } from './invocation.js'; +import type { OwnerRetirement } from './invocation-registry.js'; + +/** + * Short trusted native acquisitions remain synchronous: their positive close precedes the + * next open. A failed close retains the exact resource capability, rather than a warning ACK. + */ +export function withSynchronousResource void }, Result>( + invocation: CliInvocation | undefined, + acquire: () => Resource, + use: (resource: Resource) => Result, +): Result { + if (invocation === undefined) { + const resource = acquire(); + try { + return use(resource); + } finally { + resource.close(); + } + } + let complete: (facts: OwnerRetirement) => void = () => undefined; + const retired = new Promise((resolve) => { + complete = resolve; + }); + let resource: Resource | undefined; + let close: (() => void) | undefined; + let active = true; + let attempted = false; + let uncertain = false; + const release = (): void => { + if (active || attempted || uncertain) return; + attempted = true; + try { + close?.(); + close = undefined; + resource = undefined; + complete({ kind: 'settled' }); + } catch (cause) { + uncertain = true; + invocation.finiteExit.fail('cli_internal'); + throw new CliError('cli_internal', '', { cause }); + } + }; + invocation.finiteExit.activateFallback(); + invocation.registry.register({ + kind: 'command', + retired, + stop: () => undefined, // This entered synchronous scope cannot be preempted; later entry is guarded. + join: release, + classifyFailure: () => 'cli_internal', + }); + let outcome: + | { readonly kind: 'value'; readonly value: Result } + | { readonly kind: 'error'; readonly cause: unknown }; + try { + invocation.registry.assertAdmitting(); + try { + resource = acquire(); + const acquired = resource; + const nativeClose = acquired.close; + close = () => Reflect.apply(nativeClose, acquired, []); + } catch (cause) { + // Trusted owned acquisition adapters either return their close capability or classify + // a cleaned-up refusal. Unknown partial construction never certifies resource retirement. + if ( + toUserFacing(cause).code === 'cli_internal' || + toUserFacing(cause).code === 'output_unavailable' + ) { + uncertain = true; + invocation.finiteExit.fail('cli_internal'); + } + throw cause; + } + invocation.registry.assertAdmitting(); + outcome = { kind: 'value', value: use(resource) }; + } catch (cause) { + outcome = { kind: 'error', cause }; + } finally { + active = false; + } + try { + release(); + } catch (cause) { + // The stronger fixed infrastructure observation is already sticky; preserve a primary refusal. + if (outcome.kind === 'value') outcome = { kind: 'error', cause }; + } + if (outcome.kind === 'error') throw outcome.cause; + return outcome.value; +} diff --git a/apps/cli/src/secrets/read-secret-lifetime.test.ts b/apps/cli/src/secrets/read-secret-lifetime.test.ts new file mode 100644 index 00000000..c360709c --- /dev/null +++ b/apps/cli/src/secrets/read-secret-lifetime.test.ts @@ -0,0 +1,69 @@ +import { Readable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { CommandLifetime } from '../process/command-lifetime.js'; +import { captureIo } from '../test-support.js'; +import { readSecretFromStdin } from './read-secret.js'; + +class HeldCloseInput extends Readable { + release: () => void = () => undefined; + override _read(): void {} + override _destroy(error: Error | null, callback: (error: Error | null) => void): void { + this.release = () => callback(error); + } +} + +describe('captured secret input lifetime', () => { + it('waits for actual native input close after cancellation before database cleanup', async () => { + const f = invocationFixture(); + const input = new HeldCloseInput(); + const { io } = captureIo(); + const lifetime = new CommandLifetime( + f.invocation, + { ...io, stdin: input }, + () => () => undefined, + ); + const databaseClose = vi.fn(); + lifetime.adoptCleanup(databaseClose); + const reading = lifetime.execute(async () => { + await readSecretFromStdin(undefined, input, { + signal: lifetime.work.signal, + onFailure: lifetime.work.failure, + }); + expect.fail('cancelled input cannot continue to a credential write'); + }); + f.invocation.finiteExit.signal(15); + await new Promise((resolve) => setImmediate(resolve)); + expect(input.destroyed).toBe(true); + expect(input.closed).toBe(false); + expect(databaseClose, 'SECRET_INPUT_DESTROY_IS_NOT_CLOSE_ACK').not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + input.release(); + await reading; + expect(input.closed).toBe(true); + expect(databaseClose).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.exitCode()).toBe(143); + }); + + it('refuses a stopped invocation before acquiring the source iterator', async () => { + const controller = new AbortController(); + const reason = new Error('synthetic stop'); + controller.abort(reason); + const iterator = vi.fn(async function* () { + await Promise.resolve(); + yield 'synthetic'; + }); + await expect( + readSecretFromStdin( + undefined, + { [Symbol.asyncIterator]: iterator }, + { + signal: controller.signal, + onFailure: vi.fn(), + }, + ), + ).rejects.toBe(reason); + expect(iterator).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/cli/src/secrets/read-secret.ts b/apps/cli/src/secrets/read-secret.ts index 5da31d47..fba7768a 100644 --- a/apps/cli/src/secrets/read-secret.ts +++ b/apps/cli/src/secrets/read-secret.ts @@ -1,4 +1,5 @@ import { CliError } from '../process/errors.js'; +import { Readable } from 'node:stream'; /** * Read a secret (an API key) from **stdin** for `relavium provider set-key` (2.C). The key is deliberately @@ -52,7 +53,9 @@ function limitFailure(): CliError { export async function readSecretFromStdin( context: StdinSecretContext = API_KEY, source?: StdinSecretSource, + lifecycle?: { readonly signal: AbortSignal; readonly onFailure: (cause: unknown) => void }, ): Promise { + lifecycle?.signal.throwIfAborted(); let input: StdinSecretSource; let isTTY: boolean | undefined; try { @@ -70,6 +73,26 @@ export async function readSecretFromStdin( throw readFailure(cause); } let finished = false; + const native = input instanceof Readable ? input : undefined; + const cancelled = new Error('Secret input stopped.'); + let closing: Promise | undefined; + let acknowledgeClose: () => void = () => undefined; + const onClose = (): void => acknowledgeClose(); + const stop = (): void => { + if (native === undefined || closing !== undefined) return; + closing = new Promise((resolve) => { + acknowledgeClose = resolve; + }); + try { + native.once('close', onClose); + if (native.closed) acknowledgeClose(); + native.destroy(cancelled); + } catch (cause) { + lifecycle?.onFailure(cause); + } + }; + lifecycle?.signal.addEventListener('abort', stop, { once: true }); + if (lifecycle?.signal.aborted === true) stop(); const chunks: Buffer[] = []; let totalBytes = 0; try { @@ -93,6 +116,7 @@ export async function readSecretFromStdin( } else if (typeof chunk === 'string') size = chunk.length; } } catch (cause) { + if (cause === cancelled) lifecycle?.signal.throwIfAborted(); throw readFailure(cause); } if (done) { @@ -128,7 +152,11 @@ export async function readSecretFromStdin( // Best-effort release must not replace the primary refusal with foreign cleanup text. } } + lifecycle?.signal.removeEventListener('abort', stop); + await closing; + native?.removeListener('close', onClose); } + lifecycle?.signal.throwIfAborted(); // Decode once to preserve code points split across Buffer chunks; only callers may trim values. const payload = Buffer.concat(chunks, totalBytes).toString('utf8'); if (payload.trim() === '') throw new CliError('invalid_invocation', context.emptyMessage); From cd2c0382ad79107f25bc8af7861b34cc0e924058 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 01:06:07 +0300 Subject: [PATCH 108/222] docs(docs): record management ownership integration evidence Clarify the command owner and its raw-work/resource obligations in the canonical CLI contract. Attribute the partial 9798c51c implementation, corrected author failures and final local gates without claiming global fatal activation or independent Step 8d acceptance. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- docs/reference/cli/commands.md | 28 ++++++++++ docs/roadmap/current.md | 5 +- .../phase-2.5.5-hardening-and-remediation.md | 53 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 34ab0da5..54c00561 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -859,6 +859,7 @@ health object: |---|---| | Workflow | RunHandle departure/durability/terminal error; actual host joins and owned producer teardown | | Chat/Home/standalone AgentSession | Cancel/stop and existing whenHostSafeToClose join; retain rejection/publication failure. No invented RunDeparture, 5–8 or healthy receipt inference | +| Management/maintenance command | Seal command admission, cancel captured input/probes and join actual raw work before releasing its database or maintenance resources. No AgentSession identity or workflow receipt health | | Terminal renderer/input | Stop redraw/input and SIGCONT reclaim; restore raw/paste/mouse/cursor/alternate-screen custody | | Native tool/MCP | Exact creation-specific registration, stop/join and the finite reap authority below | @@ -948,6 +949,33 @@ security and actual required platform acceptance remain open. The and [chat/Home checkpoint](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-chat-and-home-integration-checkpoint--2026-10-17) record their exact scopes. Whole Step 8 remains open. +### Management ownership implementation checkpoint + +**Partial Step 8d integration on `development`; not independently accepted.** +Management dispatch and the separate media-maintenance route reserve a command +owner before entering work. Provider verification and both model-refresh axes +retain actual operations beyond early report/failure results. Their original +signal and admission checks prevent credential, catalog and output publication +after stop. Captured secret input joins its native close separately from a +cancellation request. Deep-doctor probes belong to their actual chat/Home host; +an ordinary failed probe remains a report, while its still-running producer +remains owed. + +Command database cleanup follows roots and transferred work, with independent +releases entered before any join. A failed close retains uncertainty and signal +custody. Short resolver/pricing reads keep synchronous close-before-next-open +ordering and retain their exact close capability if release fails; optional +pricing open/read degradation remains unchanged. This is lifecycle integration, +not the later sole-opener refactor. + +The standalone create wizard binds installed Clack prompts to captured streams +and checks admission between answers and before writing. Its already-entered +terminal restoration uses the original captured control capability, separately +from guarded business output. No deadline certifies input, terminal or database +release. Bootstrap/caught-fatal activation and complete Step 8d review, security +and platform gates remain staged in the +[implementation register](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-management-ownership-checkpoint--2026-10-17). + ### Workflow and terminal ownership **Step 8c independently accepted on `development`.** The diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 335af5a8..d6753394 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1146,7 +1146,10 @@ consumers. Step 8d session/terminal integration and product fatal activation, whole Step 8 and Steps 9–19 remain open. Its [2026-10-17 chat/Home implementation checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-chat-and-home-integration-checkpoint--2026-10-17) records partial session/native-terminal integration and the remaining activation, -review/security/platform gates without claiming acceptance. Step 8d proceeds automatically. The +review/security/platform gates without claiming acceptance. The subsequent +[management-owner checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-management-ownership-checkpoint--2026-10-17) +records remaining command, prompt, doctor and transient-resource integration; +bootstrap activation and complete acceptance remain staged. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index b0f09828..dc8bcb2d 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -6121,3 +6121,56 @@ the expected assertion failures and byte-identical restoration. These selected cycles do not claim completion of the entire secrets-control family. Synthetic TTY/input controls and local macOS smoke do not establish actual Windows session shutdown, operator-terminal restoration or complete Step 8d acceptance. + +## Wave 3 Step 8d management ownership checkpoint — 2026-10-17 + +**Partial implementation on `development` at `9798c51c`; not Step 8d acceptance.** A focused +read-only activation inventory found remaining management, standalone create, +deep-doctor and media-maintenance owners. Their integration follows the existing +ADR-0110 all-owners decision. The +[canonical command contract](../../reference/cli/commands.md#management-ownership-implementation-checkpoint) +owns concrete lifetime semantics; no new ADR, dependency, deferral or user +decision is introduced. + +The command identity has stop/join/actual-retirement capability, without session +or workflow health. Shared no-health work mechanics preserve existing session +owners. Provider/model operations, captured stdin, deferred command database +cleanup, actual create prompts and the separate maintenance route are connected. +Every actual chat/Home doctor constructor forwards its host's raw producer +lifetime. The report timeout is not producer settlement: the new doctor +regression failed at that exact premature-cleanup assertion before correction. + +Further implementation advice found four material integration gaps, all +corrected: spreading a class dropped the catalog signal getter; stopped business +IO blocked create-prompt restoration; a best-effort catalog catch consumed local +publication faults; and transient resolver/pricing handles lost failed-close +custody. Permanent controls exercise captured installed Clack, held native stdin +close, real SQLite with held discovery, late scaffold answers, retained +maintenance work, exact synchronous close and failed-release uncertainty. +The maintenance hold is an injected raw operation, not a claim of a real marker +failure. Synthetic streams do not establish operator-terminal restoration. + +Earlier failed author runs remain attributed: nine lint errors stopped the first +workspace gate, and a later CLI run failed three native-forwarding assertions +that sampled the parent inventory before an already-closed transient resource's +positive retirement reaction. The fixture now requires actual removal before +measuring its strict native-parent baseline; cancellation or a timer never +supplies that fact. All **167** chat/one-shot cases then pass. These focused +advisories and author controls are neither required cumulative review round nor +the separate security sitting. + +At the final frozen **33-path** product/test source, the complete workspace gate +passes **23 tasks**: twenty valid cache hits and fresh CLI lint, typecheck and +ordinary tests. Fresh CLI execution passes **320 files / 5,290 tests / ten +established skips**. The build passes **six tasks**, with five cache hits and a +fresh CLI build. Finite closure remains **25 JavaScript outputs / seventeen +declared external dependencies**; compiled CLI smoke and all **175 bootstrap +controls** pass locally on macOS/Node **22.23.1**. Unchanged package results retain +their cache attribution. No remote CI, new same-tarball platform execution, +whole-wave test or merge execution is inferred. + +The original bootstrap remains unchanged. Global native installation, +post-import/caught/fatal/background/sink integration, two NEW complete cumulative +clean rounds from `d9625ff0`, a NEW clean security sitting and required actual +same-artifact platform evidence remain open. Whole Step 8 and Steps 9–19 remain +open; no provider, credential, billing or user action is pending. From a2acba8ebc7ae9f3e3c9f454ec030e4fbf37b533 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 01:28:37 +0300 Subject: [PATCH 109/222] fix(cli): activate native invocation shutdown across entry boundaries Retain the actual bootstrap root before deferred imports and hand the original finite-exit authority to the native coordinator. Explicit signal custody preserves surface Ctrl-C semantics while covering startup gaps. Normalize known runtime failures before the pure entry and acknowledge one fixed infrastructure diagnostic only after actual terminal cleanup. Observe Home post-chat snapshot failures and suppress owned slash notices after stop. Preserve ownerless Home causes and background-only exit 9. Atomically update compiled/package controls to the 177-case human/JSON native failure inventory and the affected live-export test inventories. Validation: workspace lint/typecheck/test 23/23; fresh CLI 5313 passed, 10 established skips. Build 6/6; 508 acceptance-tool tests; bootstrap 177; closure and CLI smoke; two selected export removal/restoration controls. Final JSDoc/canonical-reference changes are non-runtime only. Complete Step 8d reviews, security and current platform acceptance remain. Refs: ADR-0110, ADR-0111, Wave 3 Step 8d Co-Authored-By: Codex --- apps/cli/src/chat/session-surface-lifetime.ts | 7 +- apps/cli/src/commands/chat.test.ts | 86 ++++++ apps/cli/src/commands/chat.ts | 8 + apps/cli/src/commands/dispatch.ts | 14 +- apps/cli/src/commands/lazy-dispatch.ts | 6 +- apps/cli/src/commands/workflow-lifetime.ts | 25 +- apps/cli/src/home/drive-home.tsx | 14 +- apps/cli/src/index.ts | 21 +- apps/cli/src/process/cli-error.ts | 31 +++ apps/cli/src/process/command-lifetime.ts | 7 +- apps/cli/src/process/errors.ts | 34 ++- apps/cli/src/process/invocation.ts | 5 + apps/cli/src/process/io.test.ts | 20 ++ apps/cli/src/process/io.ts | 13 +- apps/cli/src/process/native-bootstrap.test.ts | 249 ++++++++++++++++++ apps/cli/src/process/native-bootstrap.ts | 108 ++++++++ apps/cli/src/process/native-invocation.ts | 2 +- apps/cli/src/process/owned-storage.ts | 16 +- apps/cli/src/process/signal-custody.test.ts | 48 ++++ apps/cli/src/process/signal-custody.ts | 41 +++ .../src/render/tui/home-controller.test.ts | 64 +++++ apps/cli/src/render/tui/home-controller.ts | 2 +- apps/cli/src/run.ts | 68 +++-- docs/reference/cli/commands.md | 50 ++-- docs/reference/cli/packaging.md | 15 +- tools/cli-bootstrap/README.md | 3 +- tools/cli-bootstrap/check.mjs | 72 ++--- tools/cli-bootstrap/inventory.mjs | 2 + tools/cli-bootstrap/witness.test.mjs | 5 + tools/cli-package/README.md | 2 +- tools/cli-package/binding.mjs | 4 +- tools/cli-package/binding.test.mjs | 8 +- tools/secrets-controls/mutations.mjs | 4 +- 33 files changed, 890 insertions(+), 164 deletions(-) create mode 100644 apps/cli/src/process/native-bootstrap.test.ts create mode 100644 apps/cli/src/process/native-bootstrap.ts create mode 100644 apps/cli/src/process/signal-custody.test.ts create mode 100644 apps/cli/src/process/signal-custody.ts diff --git a/apps/cli/src/chat/session-surface-lifetime.ts b/apps/cli/src/chat/session-surface-lifetime.ts index 211dfadd..69fcacde 100644 --- a/apps/cli/src/chat/session-surface-lifetime.ts +++ b/apps/cli/src/chat/session-surface-lifetime.ts @@ -3,6 +3,7 @@ import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import { createCliInvocation, type CliInvocation } from '../process/invocation.js'; import type { SessionOwner } from '../process/invocation-registry.js'; import { defaultSubscribeSignals } from '../process/signals.js'; +import { subscribeWithSignalCustody } from '../process/signal-custody.js'; import { SessionWorkOwner } from './session-work-owner.js'; /** @@ -31,7 +32,11 @@ export class SessionSurfaceLifetime { }, }); try { - this.#removeSignals = subscribeSignals((signo) => this.invocation.finiteExit.signal(signo)); + this.#removeSignals = subscribeWithSignalCustody( + this.invocation.signalCustody, + subscribeSignals, + (signo) => this.invocation.finiteExit.signal(signo), + ); } catch (cause) { this.work.cleanupUncertain(cause); throw cause; diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index 0c3f4c81..6169b3cc 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -557,6 +557,92 @@ describe('chatCommand', () => { ]), }; + it.each([false, true])( + 'drops a late doctor report after the owned chat stops (interactive=%s)', + async (interactive) => { + const f = invocationFixture(); + let release = (): void => undefined; + let entered = (): void => undefined; + const started = new Promise((resolve) => { + entered = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + const { d, err } = deps([], [], { + doctorProbes: { + ...fakeDoctorProbes, + deepProviders: async () => { + entered(); + await held; + return [{ id: 'late', label: 'LATE_DOCTOR_REPORT', status: 'ok', detail: 'late' }]; + }, + }, + }); + const done = chatCommand( + { agent: undefined }, + { + ...d, + ...INERT_HOIST, + invocation: f.invocation, + io: { ...d.io, stdoutIsTty: interactive, stdinIsTty: interactive }, + drive: async (ctx) => { + ctx.startSession(); + const report = ctx.processLine('/doctor --deep'); + await started; + const notices = (): string[] => + ctx.store + .getSnapshot() + .state.transcript.filter((entry) => entry.role === 'notice') + .map((entry) => entry.text); + expect(interactive ? notices().join('\n') : err()).toContain('doctor: validating'); + ctx.workOwner?.stop(); + release(); + await report; + expect( + interactive ? notices().join('\n') : err(), + 'CHAT_NO_POST_STOP_SLASH_NOTICE', + ).not.toContain('LATE_DOCTOR_REPORT'); + return { kind: 'exit' }; + }, + }, + ); + try { + expect(await done).toBe(4); + } finally { + release(); + } + expect(f.registry.activeOwners).toBe(0); + }, + ); + + it('leaves an owned export infrastructure failure to the final diagnostic instead of publishing a slash notice', async () => { + const f = invocationFixture(); + const { d, store, err } = deps([], []); + const failure = new Error('PRIVATE_EXPORT_READ_FAILURE'); + const done = chatCommand( + { agent: undefined }, + { + ...d, + ...INERT_HOIST, + invocation: f.invocation, + drive: async (ctx) => { + ctx.startSession(); + vi.spyOn(store, 'loadFull').mockImplementationOnce(() => { + throw failure; + }); + await ctx.processLine('/export'); + expect(f.registry.observations.exitCode()).toBe(9); + expect(err(), 'CHAT_NO_POST_FAULT_EXPORT_NOTICE').not.toContain('export failed:'); + expect(err()).not.toContain('PRIVATE_EXPORT_READ_FAILURE'); + return { kind: 'exit' }; + }, + }, + ); + expect(await done).toBe(9); + expect(f.registry.activeOwners).toBe(0); + }); + it('/doctor runs the fast tier into the notice channel (stderr, non-TTY), without ending the session', async () => { const { d, err, store, sessionId } = deps(['/doctor', 'hello', '/exit'], [textTurn('hi')], { doctorProbes: fakeDoctorProbes, diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 2561f254..d2ec7c8e 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -1385,7 +1385,13 @@ export function createChatLineHandler( // keys (Shift+Tab / Esc) drive the SAME control. It applies the initial `ask` mode immediately, so the // full-capability host is never live without the fail-closed approval regime (default `ask` denies governed). const modeControl = createChatModeControl(built, store, { interactive }); + const noticesStopped = (): boolean => + workOwner !== undefined && + (workOwner.signal.aborted || workOwner.invocation.registry.termination !== undefined); const emitOutput = (text: string): void => { + // Entered commands still settle after cancellation, but may not publish new business output. + // The invocation's final diagnostic belongs to its restored terminal, not this view/notice sink. + if (noticesStopped()) return; if (interactive) { store.notice(text); } else { @@ -1436,6 +1442,7 @@ export function createChatLineHandler( force: true, }); emitOutput(SESSION_EXPORT_REVIEW_NOTICE.trimEnd()); + if (noticesStopped()) return; if (deps.global.json) { // Machine mode (--json, 2.Q): emit `session:exported` THROUGH the session bus, so it rides the // live stream's monotonic per-session sequenceNumber (a DB-derived seq would jump backward and @@ -1447,6 +1454,7 @@ export function createChatLineHandler( } } catch (err) { workOwner?.failure(err); + if (noticesStopped()) return; const message = workOwner === undefined ? err instanceof Error diff --git a/apps/cli/src/commands/dispatch.ts b/apps/cli/src/commands/dispatch.ts index a19ea789..116048ff 100644 --- a/apps/cli/src/commands/dispatch.ts +++ b/apps/cli/src/commands/dispatch.ts @@ -22,7 +22,7 @@ import { openHistoryStore } from '../history/open.js'; import { openSessionStore } from '../history/session-open.js'; import { refreshCatalog, type CatalogRefreshResult } from '../engine/catalog-refresh.js'; import { persistCatalogToDb, syncCatalogFromDb } from '../engine/catalog-metadata.js'; -import { CliError } from '../process/errors.js'; +import { CliError, toCliError } from '../process/errors.js'; import { type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import { selectChatDriver } from '../render/tui/chat-ink.js'; @@ -812,6 +812,18 @@ export async function executeCommand( id: string, input: CommandInput, ctx: DispatchContext, +): Promise { + try { + return await executeRuntimeCommand(id, input, ctx); + } catch (cause) { + throw toCliError(cause); + } +} + +async function executeRuntimeCommand( + id: string, + input: CommandInput, + ctx: DispatchContext, ): Promise { const executor = COMMAND_EXECUTORS.get(id); if (executor === undefined) { diff --git a/apps/cli/src/commands/lazy-dispatch.ts b/apps/cli/src/commands/lazy-dispatch.ts index 2f379eca..c3201110 100644 --- a/apps/cli/src/commands/lazy-dispatch.ts +++ b/apps/cli/src/commands/lazy-dispatch.ts @@ -38,14 +38,16 @@ for (const id of RUNTIME_IDS) { try { runtime = await loadRuntime(); } catch (cause) { - throw new CliError('internal', 'The CLI runtime could not be loaded.', { cause }); + throw new CliError('cli_internal', '', { cause }); } + if (ctx.invocation?.registry.termination !== undefined) return 0; return runtime.executeCommand(id, input, ctx); }); } routes.set('media.prune-temporaries', async (_id, input, ctx) => { const args = buildMediaPruneArgs(input); const { pruneTemporariesCommand } = await import('./media.js'); + if (ctx.invocation?.registry.termination !== undefined) return 0; return pruneTemporariesCommand(args, ctx); }); @@ -70,6 +72,7 @@ const PRICED_IDS = new Set([ /** Read the offline cache before the runtime's first metadata consumer; fixtures/media never seed it. */ export const executeLazyCommand: DispatchCommand = async (id, input, ctx) => { + if (ctx.invocation?.registry.termination !== undefined) return 0; const route = routes.get(id); if (route === undefined) throw new CliError('invalid_invocation', 'unknown command.'); if ( @@ -83,5 +86,6 @@ export const executeLazyCommand: DispatchCommand = async (id, input, ctx) => { } if (PRICED_IDS.has(id) && !(id === 'agent.run' && typeof input.options['fixture'] === 'string')) await ctx.invocation?.seedFileCatalogOnce(); + if (ctx.invocation?.registry.termination !== undefined) return 0; return route(id, input, ctx); }; diff --git a/apps/cli/src/commands/workflow-lifetime.ts b/apps/cli/src/commands/workflow-lifetime.ts index 7349d1fc..9e2e5883 100644 --- a/apps/cli/src/commands/workflow-lifetime.ts +++ b/apps/cli/src/commands/workflow-lifetime.ts @@ -8,6 +8,7 @@ import type { OwnerRetirement } from '../process/invocation-registry.js'; import type { CliIo } from '../process/io.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import { defaultSubscribeSignals } from '../process/signals.js'; +import { subscribeWithSignalCustody } from '../process/signal-custody.js'; import { WorkflowTerminal } from './workflow-terminal.js'; /** Single command owner: registration/signals precede its first owned asynchronous preparation. */ @@ -55,14 +56,22 @@ export class WorkflowLifetime { () => this.checkpoint(), (cause) => this.failure(cause), ); - this.#removeSignals = subscribeSignals((signo) => { - if (signo === 2 && !this.#interrupted && this.invocation.registry.termination === undefined) { - this.#interrupted = true; - this.stop(); - } else { - this.invocation.finiteExit.signal(signo); - } - }); + this.#removeSignals = subscribeWithSignalCustody( + this.invocation.signalCustody, + subscribeSignals, + (signo) => { + if ( + signo === 2 && + !this.#interrupted && + this.invocation.registry.termination === undefined + ) { + this.#interrupted = true; + this.stop(); + } else { + this.invocation.finiteExit.signal(signo); + } + }, + ); } get signal(): AbortSignal { return this.#cancel.signal; diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index bfbf97cb..1c62b5a2 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -58,7 +58,7 @@ import { readUserPricingOverlay } from '../engine/pricing-overlay.js'; import { assembleToolEnv } from '../engine/tool-host/assemble.js'; import { createProviderResolver, type ProviderResolver } from '../engine/providers.js'; import { openSessionStore, type OpenedSessionStore } from '../history/session-open.js'; -import { CliError } from '../process/errors.js'; +import { CliError, toCliError } from '../process/errors.js'; import { isProviderKeyless, runOnboardingWizard, @@ -203,10 +203,16 @@ export const defaultOnboardingTerminalLifecycle: OnboardingTerminalLifecycle = { }, }; -export function driveHome(deps: HomeDeps): Promise { +export async function driveHome(deps: HomeDeps): Promise { + // Ownerless embeddings retain their original cause/cleanup contract. The product boundary + // always supplies the original invocation and normalizes before returning to its pure entry. if (deps.invocation === undefined) return driveHomeBody(deps); - const ownership = new SessionHomeLifetime(deps.invocation, deps.subscribeSignals); - return ownership.execute(() => driveHomeBody(deps, ownership)); + try { + const ownership = new SessionHomeLifetime(deps.invocation, deps.subscribeSignals); + return await ownership.execute(() => driveHomeBody(deps, ownership)); + } catch (cause) { + throw toCliError(cause); + } } async function driveHomeBody(deps: HomeDeps, ownership?: SessionHomeLifetime): Promise { diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 2cb2f223..1b51edc4 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,21 +1,6 @@ #!/usr/bin/env node -import { escalateExitCode, installBackgroundFailureNet } from './process/background-failure.js'; -import { EXIT_CODES } from './process/exit-codes.js'; import { processIo } from './process/io.js'; -import { createCliInvocation } from './process/invocation.js'; +import { startNativeCli } from './process/native-bootstrap.js'; -// Pure bootstrap owners exist before any runtime import can fail. Native adapters land in Step 8. -const io = processIo(); -const invocation = createCliInvocation(); -const net = installBackgroundFailureNet(io); - -try { - const { run } = await import('./run.js'); - process.exitCode = await run(process.argv, io, undefined, invocation); -} catch { - // Import/evaluation failures must reach a fixed early sink even when runtime classification cannot load. - io.writeErr('relavium: a fatal internal error occurred.\n'); - process.exitCode = EXIT_CODES.workflowFailed; -} - -if (net.occurred()) escalateExitCode(); +// The coordinator owns exitCode, including late background failures; never overwrite it after completion. +await startNativeCli(process.argv, processIo()).finished; diff --git a/apps/cli/src/process/cli-error.ts b/apps/cli/src/process/cli-error.ts index 27e5643d..c71cb81b 100644 --- a/apps/cli/src/process/cli-error.ts +++ b/apps/cli/src/process/cli-error.ts @@ -97,3 +97,34 @@ export interface UserFacingError { readonly message: string; readonly exitCode: ExitCode; } + +/** Lightweight own-data projection; never load the engine to classify an early CLI failure. */ +export function projectCliError(value: unknown): UserFacingError | undefined { + try { + if (!isCliError(value)) return undefined; + const code = ownData(value, 'code'); + if (typeof code !== 'string' || !isCliCode(code)) return fixedInternalFailure(); + const message = CLI_ERROR_POLICY[code].fixedMessage ?? ownData(value, 'message'); + if (typeof message !== 'string') return fixedInternalFailure(); + return { code, message, exitCode: cliErrorExitCode(code) }; + } catch { + return fixedInternalFailure(); + } +} + +function ownData(value: object, key: string): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined; +} + +function isCliCode(value: string): value is CliErrorCode { + return Object.hasOwn(CLI_ERROR_POLICY, value); +} + +function fixedInternalFailure(): UserFacingError { + return { + code: 'cli_internal', + message: CLI_ERROR_POLICY.cli_internal.fixedMessage ?? '', + exitCode: 9, + }; +} diff --git a/apps/cli/src/process/command-lifetime.ts b/apps/cli/src/process/command-lifetime.ts index c89ba045..dfc988af 100644 --- a/apps/cli/src/process/command-lifetime.ts +++ b/apps/cli/src/process/command-lifetime.ts @@ -5,6 +5,7 @@ import type { CliInvocation } from './invocation.js'; import type { CliIo } from './io.js'; import { InvocationWorkOwner } from './invocation-work-owner.js'; import { defaultSubscribeSignals } from './signals.js'; +import { subscribeWithSignalCustody } from './signal-custody.js'; /** One management operation, with no AgentSession label or workflow receipt authority. */ export class CommandLifetime { @@ -51,7 +52,11 @@ export class CommandLifetime { writeErrAcknowledged: (text) => this.#deliver(() => io.writeErrAcknowledged(text)), }; try { - this.#removeSignals = subscribeSignals((signo) => invocation.finiteExit.signal(signo)); + this.#removeSignals = subscribeWithSignalCustody( + invocation.signalCustody, + subscribeSignals, + (signo) => invocation.finiteExit.signal(signo), + ); } catch (cause) { this.work.cleanupUncertain(cause); throw cause; diff --git a/apps/cli/src/process/errors.ts b/apps/cli/src/process/errors.ts index 19feaecc..1d670acf 100644 --- a/apps/cli/src/process/errors.ts +++ b/apps/cli/src/process/errors.ts @@ -16,8 +16,9 @@ import { EXIT_CODES } from './exit-codes.js'; import { CLI_ERROR_POLICY, cliErrorExitCode, + CliError, isCliError, - type CliErrorCode, + projectCliError, type UserFacingError, } from './cli-error.js'; export { CliError, isCliError } from './cli-error.js'; @@ -34,14 +35,8 @@ export function toUserFacing(value: unknown): UserFacingError { } function projectUserFacing(value: unknown): UserFacingError { - if (isCliError(value)) { - const code = ownData(value, 'code'); - if (!isCliCode(code)) return fixedFailure('cli_internal'); - const message = CLI_ERROR_POLICY[code].fixedMessage ?? ownData(value, 'message'); - if (typeof message !== 'string') return fixedFailure('cli_internal'); - // Policy owns the exit. Public mutation cannot manufacture a new code/exit disposition. - return { code, message, exitCode: cliErrorExitCode(code) }; - } + const cli = projectCliError(value); + if (cli !== undefined) return cli; // Only the actual DB/native/media boundary may add this private identity fact. Constructors, // errno-shaped objects and convincing causes never supply storage authority to this classifier. if (isOwnedStorageFailure(value)) return fixedFailure('storage_unavailable'); @@ -120,10 +115,6 @@ function ownData(value: object, key: string): unknown { return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined; } -function isCliCode(value: unknown): value is CliErrorCode { - return typeof value === 'string' && Object.hasOwn(CLI_ERROR_POLICY, value); -} - function fixedFailure(code: 'cli_internal' | 'storage_unavailable'): UserFacingError { return { code, @@ -131,3 +122,20 @@ function fixedFailure(code: 'cli_internal' | 'storage_unavailable'): UserFacingE exitCode: cliErrorExitCode(code), }; } + +/** Normalize an escaping runtime error while its already-loaded classifier is available. */ +export function toCliError(value: unknown): CliError { + const projected = toUserFacing(value); + try { + if ( + isCliError(value) && + ownData(value, 'code') === projected.code && + ownData(value, 'message') === projected.message && + ownData(value, 'exitCode') === projected.exitCode + ) + return value; + } catch { + // Hostile public errors cannot bypass the fixed projection. + } + return new CliError(projected.code, projected.message, { cause: value }); +} diff --git a/apps/cli/src/process/invocation.ts b/apps/cli/src/process/invocation.ts index 135d3ac9..0e2635f0 100644 --- a/apps/cli/src/process/invocation.ts +++ b/apps/cli/src/process/invocation.ts @@ -1,4 +1,5 @@ import { createFileCatalogSeed } from './file-catalog-seed.js'; +import { SignalCustody } from './signal-custody.js'; import { InvocationRegistry, type InvocationClock } from './invocation-registry.js'; import { createInvocationFiniteExit, @@ -10,6 +11,7 @@ export interface CliInvocation { readonly registry: InvocationRegistry; readonly finiteExit: InvocationFiniteExit; readonly seedFileCatalogOnce: () => Promise; + readonly signalCustody?: SignalCustody; } /** Created once before guarded imports; authority is explicitly threaded, never module-global. */ @@ -24,6 +26,7 @@ export function createCliInvocation(finitePorts?: FiniteExitPorts): CliInvocatio const registry = new InvocationRegistry(clock); return Object.freeze({ registry, + signalCustody: new SignalCustody(), finiteExit: createInvocationFiniteExit( registry, clock, @@ -35,7 +38,9 @@ export function createCliInvocation(finitePorts?: FiniteExitPorts): CliInvocatio }, ), seedFileCatalogOnce: createFileCatalogSeed(async () => { + if (registry.termination !== undefined) return; const { seedFileCatalog } = await import('../engine/catalog-seed.js'); + if (registry.termination !== undefined) return; seedFileCatalog(); }), }); diff --git a/apps/cli/src/process/io.test.ts b/apps/cli/src/process/io.test.ts index 6ca4696d..4375a844 100644 --- a/apps/cli/src/process/io.test.ts +++ b/apps/cli/src/process/io.test.ts @@ -46,6 +46,26 @@ const released = async (stderr: NodeJS.WritableStream): Promise => { }; describe('native diagnostic acknowledgement (ADR-0098)', () => { + it('classifies a synchronous failure at the captured native stderr without promoting its private text', () => { + const stderr = new OwnedTtyOutput(); + const cause = new Error('PRIVATE_NATIVE_WRITE_FAILURE'); + const io = ioFor(stderr); + vi.spyOn(stderr, 'write').mockImplementation(() => { + throw cause; + }); + try { + let caught: unknown; + try { + io.writeErr('notice'); + } catch (error) { + caught = error; + } + expect(caught).toMatchObject({ code: 'output_unavailable', exitCode: 9, cause }); + expect(caught).not.toHaveProperty('message', 'PRIVATE_NATIVE_WRITE_FAILURE'); + } finally { + stderr.destroy(); + } + }); it('waits for native delivery and writes to the exact captured stderr after process binding changes', async () => { const stderr = new DelayedOutput(); let acknowledged = false; diff --git a/apps/cli/src/process/io.ts b/apps/cli/src/process/io.ts index 365c3e47..595668d4 100644 --- a/apps/cli/src/process/io.ts +++ b/apps/cli/src/process/io.ts @@ -37,10 +37,10 @@ export function processIo(): CliIo { nativeStderr: stderr, nativeStdin: stdin, writeOut: (text) => { - stdout.write(text); + writeNative(stdout, text); }, writeErr: (text) => { - stderr.write(text); + writeNative(stderr, text); }, writeOutAcknowledged: (text) => acknowledgeWrite(stdout, text), writeErrAcknowledged: (text) => acknowledgeWrite(stderr, text), @@ -51,6 +51,15 @@ export function processIo(): CliIo { }; } +/** Classify failures at the exact native write, without blocking entered terminal restoration. */ +function writeNative(stream: NodeJS.WriteStream, text: string): void { + try { + stream.write(text); + } catch (cause) { + throw new CliError('output_unavailable', '', { cause }); + } +} + export function canWrite(stream: NodeJS.WriteStream): boolean { return ( stream.writable && diff --git a/apps/cli/src/process/native-bootstrap.test.ts b/apps/cli/src/process/native-bootstrap.test.ts new file mode 100644 index 00000000..1bffa12c --- /dev/null +++ b/apps/cli/src/process/native-bootstrap.test.ts @@ -0,0 +1,249 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { captureIo, OwnedTtyOutput } from '../test-support.js'; +import type { ExitCode } from './exit-codes.js'; +import type { OwnerRetirement } from './invocation-registry.js'; +import { invocationFixture } from './invocation.test-support.js'; +import { startNativeCli } from './native-bootstrap.js'; +import { SignalCustody, subscribeWithSignalCustody } from './signal-custody.js'; +import type { NativeInvocationPorts } from './native-invocation.js'; + +function deferred() { + let resolve: (value: T) => void = () => undefined; + let reject: (cause: unknown) => void = () => undefined; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +const cleanups: (() => void)[] = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); +}); + +function fixture() { + const f = invocationFixture(); + const invocation = { ...f.invocation, signalCustody: new SignalCustody() }; + const stdout = new OwnedTtyOutput(); + const stderr = new OwnedTtyOutput(); + let fatal = (): void => undefined; + let background: (reason: unknown) => void = () => undefined; + let signal: (signo: number) => void = () => undefined; + let exitCode: number | undefined; + const removeSignals = vi.fn(); + const ports: NativeInvocationPorts = { + stdout, + stderr, + schedule: () => () => undefined, // The original finite authority owns the clock/backstop. + onFatal: (listener) => { + fatal = listener; + return () => undefined; + }, + onBackground: (listener) => { + background = listener; + return () => undefined; + }, + readExitCode: () => exitCode, + setExitCode: (code) => { + exitCode = code; + }, + exit: vi.fn(), // A second physical exit authority must never be used. + }; + const { io } = captureIo(); + const start = (load: Parameters[4], args: string[] = []) => { + const native = startNativeCli( + ['node', 'relavium', ...args], + io, + invocation, + ports, + load, + (listener) => { + signal = listener; + return removeSignals; + }, + ); + cleanups.push(() => { + native.dispose(); + stdout.destroy(); + stderr.destroy(); + }); + return native; + }; + return { + ...f, + invocation, + start, + stderr, + ports, + fatal: () => fatal(), + background: (cause: unknown) => background(cause), + signal: (signo: number) => signal(signo), + exitCode: () => exitCode, + }; +} + +describe('native product bootstrap', () => { + it.each([0, 1, 2, 3, 4, 6] satisfies ExitCode[])( + 'settles the actual entry root before normal exit %s', + async (code) => { + const f = fixture(); + const native = f.start(() => Promise.resolve({ run: () => Promise.resolve(code) })); + expect(await native.finished).toBe(code); + expect(f.registry.activeOwners).toBe(0); + expect(f.exitCode()).toBe(code); + expect(f.exit).not.toHaveBeenCalled(); + expect(f.ports.exit).not.toHaveBeenCalled(); + }, + ); + + it.each([{ args: [] }, { args: ['--json', '--verbose'] }])( + 'reports a loader rejection once without its private text (%j)', + async ({ args }) => { + const f = fixture(); + const native = f.start(() => Promise.reject(new Error('PRIVATE_LOADER_FAILURE')), args); + expect(await native.finished).toBe(9); + const text = f.stderr.frames.join(''); + expect(text).toContain(args.length === 0 ? 'relavium:' : '"code":"cli_internal"'); + expect(text).not.toContain('PRIVATE_LOADER_FAILURE'); + expect(f.stderr.frames).toHaveLength(1); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(f.registry.activeOwners).toBe(0); + }, + ); + + it('keeps a held runtime import owned until the original fatal deadline and refuses its late entry', async () => { + const f = fixture(); + const loaded = deferred<{ run: (typeof import('../run.js'))['run'] }>(); + const run = vi.fn(() => Promise.resolve(0)); + const native = f.start(() => loaded.promise); + expect(f.registry.activeOwners).toBe(1); + f.fatal(); + expect(f.registry.budget.deadline()).toBe(3000); + f.advance(3000); + expect(await native.finished).toBe(9); + expect(f.registry.activeOwners).toBe(1); + expect(f.stderr.frames).toEqual([]); + loaded.resolve({ run }); + await vi.waitFor(() => expect(f.registry.activeOwners).toBe(0)); + expect(run, 'BOOTSTRAP_STOPPED_IMPORT_HAS_NO_RUNTIME_ENTRY').not.toHaveBeenCalled(); + expect(f.exit).toHaveBeenCalledOnce(); + }); + + it('preserves SIGTERM during a suspended import without reclassifying cancellation as infrastructure', async () => { + const f = fixture(); + const loaded = deferred<{ run: (typeof import('../run.js'))['run'] }>(); + const run = vi.fn(() => Promise.resolve(0)); + const native = f.start(() => loaded.promise); + f.signal(15); + loaded.resolve({ run }); + expect(await native.finished).toBe(143); + expect(run).not.toHaveBeenCalled(); + expect(f.registry.observations.hasInfrastructureFailure()).toBe(false); + expect(f.stderr.frames).toEqual([]); + }); + + it('upgrades an unrelated loader failure racing a terminating signal', async () => { + const f = fixture(); + const loaded = deferred<{ run: (typeof import('../run.js'))['run'] }>(); + const native = f.start(() => loaded.promise); + f.signal(15); + loaded.reject(new Error('PRIVATE_RACING_LOADER_FAILURE')); + expect(await native.finished).toBe(9); + expect(f.stderr.frames.join('')).not.toContain('PRIVATE_RACING_LOADER_FAILURE'); + }); + + it('yields first Ctrl-C to explicit runtime custody and covers the later gap', async () => { + const f = fixture(); + const entered = deferred(); + const done = deferred(); + const runtimeSignal = vi.fn(); + let callback: (signo: number) => void = () => undefined; + const native = f.start(() => + Promise.resolve({ + run: async () => { + const remove = subscribeWithSignalCustody( + f.invocation.signalCustody, + (listener) => { + callback = listener; + return () => undefined; + }, + runtimeSignal, + ); + entered.resolve(); + await done.promise; + remove(); + f.signal(15); // Actual surface released its net; the original bootstrap fallback resumes custody. + return 0; + }, + }), + ); + await entered.promise; + f.signal(2); + callback(2); + expect(runtimeSignal).toHaveBeenCalledExactlyOnceWith(2); + expect(f.registry.termination).toBeUndefined(); + done.resolve(); + expect(await native.finished).toBe(143); + }); + + it('retains real workflow health over a later infrastructure failure without minting health from a return code', async () => { + const f = fixture(); + const native = f.start(() => + Promise.resolve({ + run: () => { + const retired = deferred(); + f.registry.register({ + kind: 'workflow', + retired: retired.promise, + stop: () => retired.resolve({ kind: 'settled' }), + join: () => undefined, + readSafetyExit: () => 8, + classifyFailure: () => 'cli_internal', + }); + throw new Error('PRIVATE_AFTER_PAID_WORK'); + }, + }), + ); + expect(await native.finished).toBe(8); + expect(f.stderr.frames.join('')).not.toContain('PRIVATE_AFTER_PAID_WORK'); + const forged = fixture(); + const invalid = forged.start(() => Promise.resolve({ run: () => Promise.resolve(8) })); + expect(await invalid.finished).toBe(9); + }); + + it('keeps background keepalive observation after normal completion without overwriting an established nonzero', async () => { + const f = fixture(); + const native = f.start(() => Promise.resolve({ run: () => Promise.resolve(2) })); + expect(await native.finished).toBe(2); + f.background(new Error('PRIVATE_BACKGROUND_REASON')); + await vi.waitFor(() => expect(f.stderr.frames).toHaveLength(1)); + expect(f.exitCode()).toBe(2); + expect(f.stderr.frames.join('')).not.toContain('PRIVATE_BACKGROUND_REASON'); + expect(f.exit).not.toHaveBeenCalled(); + }); + + it('does not escalate a background-only exit 9 returned by a completed runtime surface into fatal shutdown', async () => { + const f = fixture(); + const entered = deferred(); + const done = deferred(); + const native = f.start(() => + Promise.resolve({ + run: async () => { + entered.resolve(); + await done.promise; + return 9; + }, + }), + ); + await entered.promise; + f.background(new Error('PRIVATE_RECOVERABLE_BACKGROUND')); + await vi.waitFor(() => expect(f.stderr.frames).toHaveLength(1)); + done.resolve(); + expect(await native.finished).toBe(9); + expect(f.registry.observations.hasInfrastructureFailure()).toBe(false); + expect(f.registry.budget.deadline()).toBeUndefined(); + expect(f.stderr.frames).toHaveLength(1); + expect(f.exit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/cli/src/process/native-bootstrap.ts b/apps/cli/src/process/native-bootstrap.ts new file mode 100644 index 00000000..0bbd7c72 --- /dev/null +++ b/apps/cli/src/process/native-bootstrap.ts @@ -0,0 +1,108 @@ +import { resolveDiagnosticPolicy, type DiagnosticPolicy } from './diagnostic-policy.js'; +import type { CliIo } from './io.js'; +import { createCliInvocation, type CliInvocation } from './invocation.js'; +import type { OwnerRetirement } from './invocation-registry.js'; +import { + installNativeInvocation, + type NativeInvocation, + type NativeInvocationPorts, +} from './native-invocation.js'; +import { extractGlobalOptions } from './options.js'; +import { defaultSubscribeSignals } from './signals.js'; +import { projectCliError } from './cli-error.js'; + +type Runtime = Pick; + +/** One native observer/exit authority exists before the first guarded runtime import. */ +export function startNativeCli( + argv: readonly string[], + io: CliIo, + invocation: CliInvocation = createCliInvocation(), + ports?: NativeInvocationPorts, + load: () => Promise = () => import('../run.js'), + subscribeSignals = defaultSubscribeSignals, +): NativeInvocation { + const { raw } = extractGlobalOptions(argv); + let policy: DiagnosticPolicy; + try { + policy = resolveDiagnosticPolicy(raw, io.env); + } catch { + // run owns the typed flag refusal. A failing runtime import still needs the captured JSON sink. + policy = { json: raw.json === true, verbosity: 'normal' }; + } + let retire: (fact: OwnerRetirement) => void = () => undefined; + const retired = new Promise((resolve) => { + retire = resolve; + }); + invocation.registry.register({ + kind: 'command', + retired, + stop: () => undefined, // Registry sealing prevents late imports from entering runtime work. + join: () => retired.then(() => undefined), + classifyFailure: () => 'cli_internal', + }); + const native = installNativeInvocation(invocation.registry, policy, ports, invocation.finiteExit); + let removeSignals = (): void => undefined; + try { + removeSignals = subscribeSignals((signo) => { + if (invocation.signalCustody?.claimed !== true) invocation.finiteExit.signal(signo); + }); + } catch { + native.fail('cli_internal'); + } + + const execute = async (): Promise => { + try { + if (invocation.registry.termination !== undefined) return; + const runtime = await load(); + if (invocation.registry.termination !== undefined) return; + const code = await runtime.run(argv, io, undefined, invocation, native); + switch (code) { + case 0: + case 1: + case 2: + case 3: + case 4: + case 6: + invocation.registry.observations.observeOutcome(code); + break; + case 5: + case 7: + case 8: { + // A returned number grants no receipt/effect authority. The actual workflow owner supplies it. + invocation.registry.sampleWorkflowHealth(); + const actual = invocation.registry.observations.exitCode(); + if (actual !== 5 && actual !== 7 && actual !== 8) native.fail('cli_internal'); + break; + } + case 9: + if ( + !invocation.registry.observations.hasInfrastructureFailure() && + invocation.registry.observations.exitCode() !== 9 + ) + native.fail('cli_internal'); + break; + default: + native.fail('cli_internal'); + } + } catch (cause) { + native.fail( + projectCliError(cause)?.code === 'output_unavailable' + ? 'output_unavailable' + : 'cli_internal', + ); + } finally { + // Actual loader/run settlement precedes finalization: awaiting shutdown here would join ourselves. + retire({ kind: 'settled' }); + void native.complete(); + } + }; + void execute().catch(() => native.fail('cli_internal')); + return Object.freeze({ + ...native, + dispose: () => { + removeSignals(); + native.dispose(); + }, + }); +} diff --git a/apps/cli/src/process/native-invocation.ts b/apps/cli/src/process/native-invocation.ts index a9c5a83a..f46634b4 100644 --- a/apps/cli/src/process/native-invocation.ts +++ b/apps/cli/src/process/native-invocation.ts @@ -52,7 +52,7 @@ function processPorts(): NativeInvocationPorts { } /** - * Explicitly installed native foundation. Product bootstrap activation waits for Step 8d's actual owners. + * Explicit native coordinator; bootstrap binds the original registry and finite-exit capability. * No import-time listeners, service imports, cause inspection, DB close or receipt acknowledgement. */ export function installNativeInvocation( diff --git a/apps/cli/src/process/owned-storage.ts b/apps/cli/src/process/owned-storage.ts index 6e189f93..3042e5a3 100644 --- a/apps/cli/src/process/owned-storage.ts +++ b/apps/cli/src/process/owned-storage.ts @@ -1,22 +1,10 @@ import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; -import { CliError, isCliError, toUserFacing } from './errors.js'; +import { CliError, toCliError } from './errors.js'; /** Project an escaping open/maintenance failure without inventing storage provenance. */ export function storageBoundaryError(value: unknown): CliError { - const projected = toUserFacing(value); - try { - if ( - isCliError(value) && - Object.getOwnPropertyDescriptor(value, 'code')?.value === projected.code && - Object.getOwnPropertyDescriptor(value, 'message')?.value === projected.message && - Object.getOwnPropertyDescriptor(value, 'exitCode')?.value === projected.exitCode - ) - return value; - } catch { - // A hostile public error cannot bypass the defensive runtime projection. - } - return new CliError(projected.code, projected.message, { cause: value }); + return toCliError(value); } /** Directory creation owned by the CLI's storage/export boundary. */ diff --git a/apps/cli/src/process/signal-custody.test.ts b/apps/cli/src/process/signal-custody.test.ts new file mode 100644 index 00000000..7943c8c6 --- /dev/null +++ b/apps/cli/src/process/signal-custody.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest'; +import { SignalCustody, subscribeWithSignalCustody } from './signal-custody.js'; + +describe('explicit invocation signal custody', () => { + it('claims before subscription, releases after removal, and rejects a second owner', () => { + const custody = new SignalCustody(); + const remove = vi.fn(() => expect(custody.claimed).toBe(true)); + const callback = vi.fn(); + const release = subscribeWithSignalCustody( + custody, + (listener) => { + expect(custody.claimed).toBe(true); + expect(() => custody.claim()).toThrow(); + listener(15); + return remove; + }, + callback, + ); + expect(callback).toHaveBeenCalledExactlyOnceWith(15); + release(); + expect(remove).toHaveBeenCalledOnce(); + expect(custody.claimed).toBe(false); + }); + + it('returns custody after a failed acquisition but retains it when native removal fails', () => { + const custody = new SignalCustody(); + const failure = new Error('native signal registration failed'); + expect(() => + subscribeWithSignalCustody( + custody, + () => { + throw failure; + }, + vi.fn(), + ), + ).toThrow(failure); + expect(custody.claimed).toBe(false); + const release = subscribeWithSignalCustody( + custody, + () => () => { + throw failure; + }, + vi.fn(), + ); + expect(() => release()).toThrow(failure); + expect(custody.claimed).toBe(true); + }); +}); diff --git a/apps/cli/src/process/signal-custody.ts b/apps/cli/src/process/signal-custody.ts new file mode 100644 index 00000000..555b0bd4 --- /dev/null +++ b/apps/cli/src/process/signal-custody.ts @@ -0,0 +1,41 @@ +import { CliError } from './cli-error.js'; + +/** Pure explicit signal ownership; owner counts and listener inspection grant no authority. */ +export class SignalCustody { + #claimed = false; + + get claimed(): boolean { + return this.#claimed; + } + + claim(): () => void { + if (this.#claimed) throw new CliError('cli_internal', ''); + this.#claimed = true; + let released = false; + return () => { + if (released) return; + released = true; + this.#claimed = false; + }; + } +} + +/** The existing actual surface net takes custody before registration and keeps it through removal. */ +export function subscribeWithSignalCustody( + custody: SignalCustody | undefined, + subscribe: (listener: (signo: number) => void) => () => void, + listener: (signo: number) => void, +): () => void { + const release = custody?.claim(); + let remove: () => void; + try { + remove = subscribe(listener); + } catch (cause) { + release?.(); + throw cause; + } + return () => { + remove(); // A failed native removal cannot certify transfer back to the bootstrap. + release?.(); + }; +} diff --git a/apps/cli/src/render/tui/home-controller.test.ts b/apps/cli/src/render/tui/home-controller.test.ts index bc496a26..a3fa720e 100644 --- a/apps/cli/src/render/tui/home-controller.test.ts +++ b/apps/cli/src/render/tui/home-controller.test.ts @@ -125,6 +125,70 @@ describe('createHomeController (2.5.B lifecycle / ADR-0054)', () => { vi.restoreAllMocks(); }); + it('reports an unexpected snapshot failure after chat teardown instead of returning a healthy Home', async () => { + let stopped = false; + const made = makeSession({ stop: () => stopped }); + const failure = new Error('PRIVATE_HOME_SNAPSHOT_FAILURE'); + const onError = vi.fn(); + const onExit = vi.fn(); + const read = vi.fn(() => EMPTY); + const c = createHomeController({ + doctorProbes: STUB_DOCTOR_PROBES, + startChat: () => Promise.resolve(made.session), + homeStore: { read }, + onExit, + onError, + }); + type(c, 'hello'); + c.handleKey('', ENTER); + await flush(); + expect(c.getSnapshot().mode).toBe('chat'); + read.mockImplementation(() => { + throw failure; + }); + stopped = true; + type(c, '/exit'); + c.handleKey('', ENTER); + await flush(); + expect(made.teardown).toHaveBeenCalledOnce(); + expect(onError, 'HOME_POST_CHAT_SNAPSHOT_FAILURE_IS_OBSERVED').toHaveBeenCalledExactlyOnceWith( + failure, + ); + expect(onExit).not.toHaveBeenCalled(); + }); + + it('does not refresh the Home snapshot after terminating during a held chat teardown', async () => { + let stopped = false; + let release = (): void => undefined; + const held = new Promise((resolve) => { + release = resolve; + }); + const made = makeSession({ stop: () => stopped }); + made.teardown.mockReturnValue(held); + const onError = vi.fn(); + const read = vi.fn(() => EMPTY); + const c = createHomeController({ + doctorProbes: STUB_DOCTOR_PROBES, + startChat: () => Promise.resolve(made.session), + homeStore: { read }, + onExit: vi.fn(), + onError, + }); + type(c, 'hello'); + c.handleKey('', ENTER); + await flush(); + stopped = true; + type(c, '/exit'); + c.handleKey('', ENTER); + await flush(); + const terminating = c.teardownActive(); + release(); + await terminating; + await flush(); + expect(read).toHaveBeenCalledOnce(); + expect(onError).not.toHaveBeenCalled(); + }); + it('types into the buffer and reflects it in the snapshot', () => { const c = createHomeController({ doctorProbes: STUB_DOCTOR_PROBES, diff --git a/apps/cli/src/render/tui/home-controller.ts b/apps/cli/src/render/tui/home-controller.ts index 53a4a86f..1883a72c 100644 --- a/apps/cli/src/render/tui/home-controller.ts +++ b/apps/cli/src/render/tui/home-controller.ts @@ -439,7 +439,7 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { attachments: [], // pending `@`/`!` attachments must not leak into the next chat }); }) - .catch(() => undefined); // a rejecting teardown (or read) must not surface as an unhandled rejection + .catch(failHome); // Escaping snapshot/continuation faults belong to the actual Home lifetime. }; // `/clear` (ADR-0062 §7) — swap the current in-Home chat for a FRESH session, STAYING in chat (the defining diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index 43fc77fa..7694b98b 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -3,15 +3,11 @@ import { CommanderError } from 'commander'; import { executeLazyCommand } from './commands/lazy-dispatch.js'; import type { HomeDeps } from './home/drive-home.js'; import { shouldOpenHome } from './home/should-open-home.js'; -import { - CLI_ERROR_POLICY, - CliError, - isCliError, - type UserFacingError, -} from './process/cli-error.js'; +import { CLI_ERROR_POLICY, CliError, projectCliError } from './process/cli-error.js'; import { EXIT_CODES, type ExitCode } from './process/exit-codes.js'; import type { CliIo } from './process/io.js'; import { createCliInvocation, type CliInvocation } from './process/invocation.js'; +import type { NativeInvocation } from './process/native-invocation.js'; import { extractGlobalOptions, resolveGlobalOptions, @@ -28,8 +24,9 @@ const openLazyHome: OpenHome = async (deps) => { try { runtime = await import('./home/drive-home.js'); } catch (cause) { - throw new CliError('internal', 'The CLI runtime could not be loaded.', { cause }); + throw new CliError('cli_internal', '', { cause }); } + if (deps.invocation?.registry.termination !== undefined) return EXIT_CODES.success; return runtime.driveHome(deps); }; @@ -42,6 +39,7 @@ export async function run( io: CliIo, openHome: OpenHome = openLazyHome, invocation: CliInvocation = createCliInvocation(), + native?: Pick, ): Promise { const { raw, rest, error: extractError } = extractGlobalOptions(argv); const renderCtx = { json: raw.json === true, verbose: raw.verbose === true }; @@ -54,15 +52,22 @@ export async function run( try { global = resolveGlobalOptions(raw, process.cwd(), io.env); } catch (err) { - return renderFailure(err, renderCtx, io); + return renderFailure(err, renderCtx, io, native); } const result: { exitCode?: ExitCode } = {}; + const stopped = new Error('CLI preparation stopped.'); + Object.freeze(stopped); + const assertPreparing = (): void => { + if (invocation.registry.termination !== undefined) throw stopped; + }; try { + assertPreparing(); const program = buildProgram(io, { suppressErrorOutput: renderCtx.json, context: { io, global, result, invocation }, execute: async (id, input, ctx) => { - const admitted = await admitGlobal(ctx.global); + const admitted = await admitGlobal(ctx.global, assertPreparing); + assertPreparing(); return executeLazyCommand(id, input, { ...ctx, global: admitted }); }, }); @@ -78,13 +83,15 @@ export async function run( io.writeOut(program.helpInformation()); return EXIT_CODES.success; } - const admitted = await admitGlobal(global); + const admitted = await admitGlobal(global, assertPreparing); await invocation.seedFileCatalogOnce(); + assertPreparing(); return await openHome({ io, global: admitted, invocation }); } await program.parseAsync([...rest]); return result.exitCode ?? EXIT_CODES.success; } catch (err) { + if (err === stopped) return EXIT_CODES.success; if (err instanceof CommanderError) { if (err.exitCode !== 0 && renderCtx.json) renderKnownError( @@ -94,17 +101,22 @@ export async function run( ); return err.exitCode === 0 ? EXIT_CODES.success : EXIT_CODES.invalidInvocation; } - return renderFailure(err, renderCtx, io); + return renderFailure(err, renderCtx, io, native); } } -async function admitGlobal(global: GlobalOptions): Promise { +async function admitGlobal( + global: GlobalOptions, + assertPreparing: () => void, +): Promise { + assertPreparing(); let runtime: typeof import('./engine/tool-host/fs-invocation-admission.js'); try { runtime = await import('./engine/tool-host/fs-invocation-admission.js'); } catch (cause) { - throw new CliError('internal', 'The CLI runtime could not be loaded.', { cause }); + throw new CliError('cli_internal', '', { cause }); } + assertPreparing(); return Object.freeze({ ...global, fsGrants: runtime.admitFsInvocation(global) }); } @@ -125,26 +137,24 @@ function renderKnownError( ); } -async function renderFailure( +function renderFailure( value: unknown, opts: { json: boolean; verbose: boolean }, io: CliIo, -): Promise { - if (isCliError(value)) { - renderKnownError(value, opts, io); - return value.exitCode; - } - let facing: UserFacingError; - try { - const { toUserFacing } = await import('./process/errors.js'); - facing = toUserFacing(value); - } catch { - // Classification may itself be unavailable after a dependency import failure. - const fixed = new CliError('internal', 'An unexpected internal error occurred.'); - renderKnownError(fixed, opts, io); - return fixed.exitCode; + native?: Pick, +): ExitCode { + const facing = projectCliError(value) ?? projectCliError(new CliError('cli_internal', '')); + if (facing === undefined) return EXIT_CODES.infrastructureFailed; + if (facing.code === 'cli_internal' || facing.code === 'output_unavailable') { + if (native !== undefined) { + native.fail(facing.code); // Observe before joins; the native owner presents after terminal restoration. + return EXIT_CODES.infrastructureFailed; + } + // Pure embeddings have no native shutdown authority and still receive a fixed content-free diagnostic. + renderUserFacing(facing, undefined, { ...opts, verbose: false }, io); + } else { + renderUserFacing(facing, value, opts, io); } - renderUserFacing(facing, value, opts, io); return facing.exitCode; } diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 54c00561..7909b310 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -670,8 +670,8 @@ production JSON help, aliases, pricing spellings, output categories and consent remain Step 10. Step 7's lazy entry and installed package are independently accepted. Step 8a implements minimal storage-origin adapters and defensive runtime projections, and adds an inert native invocation foundation. Product fatal/native/terminal -activation remains pending in Step 8d, after all actual creation owners and -caught/forced-exit paths are connected. Earlier sections describe current +activation is implemented in Step 8d on `development`; its complete cumulative +review, separate security sitting and current platform acceptance remain open. Earlier sections describe current behaviour unless stated here. ### Diagnostic policy @@ -831,7 +831,7 @@ numeric value alone ([Node exit codes](https://nodejs.org/docs/latest-v22.x/api/ ### Invocation owners and fatal ordering -**Step 8a foundation; product activation pending in 8d.** The registry now +**Step 8d activation implemented; independent acceptance pending.** The registry exposes read-only termination and remaining-budget observations so an explicitly installed native adapter can react to autonomous owner failure. The adapter observes both real output streams and uses the same absolute @@ -844,11 +844,15 @@ cause, and stderr failure has no stdout diagnostic fallback. The successor background observer keeps the process alive, emits at most five fixed failure records plus one suppression record, and upgrades only an otherwise clean exit to 9. Existing nonzero outcomes remain intact. Importing -these modules installs no listeners. Production bootstrap still uses the -existing net until 8d activation; fixtures explicitly install the foundation -and register their own owners. Fixture success does not certify connection of -production workflow, session, terminal, native-child or caught/forced-exit -owners. The complete required integration contract follows. +these modules installs no listeners. Product bootstrap explicitly installs the +coordinator and retains its actual loader/run root before entering deferred +modules. The root retires before finalization so cleanup cannot join itself. +An invocation-owned signal-custody lease leaves each actual surface's existing +first-Ctrl-C and suspended-terminal behaviour authoritative; the bootstrap +fallback covers loading, admission, metadata and post-surface gaps. Known runtime +errors are projected before returning to the pure entry. Escaping infrastructure +faults are observed before joins and diagnosed only after restored-terminal ACK. +Cumulative review, security and platform acceptance remain separate obligations. One invocation-owned lightweight registry is installed before dynamic imports. Owners register before admitting work and retire only after actual settlement. @@ -942,9 +946,11 @@ checks admission before publication; unexpected local clock/key/publication faul are observed before sibling discovery joins. Optional cache reads keep their existing recoverable UI policy. -These are **partial Step 8d implementation claims, not acceptance**. Global native, -bootstrap, caught/fatal/background/sink activation, complete cumulative reviews, -security and actual required platform acceptance remain open. The +These are **Step 8d implementation claims, not acceptance**. Complete cumulative +reviews, security and current required platform acceptance remain open. Home +post-chat snapshot failures reach the actual lifetime; ordinary slash notices +are suppressed after owned stop, including delayed doctor/compaction results and +export fault catches. The [session substrate checkpoint](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-session-substrate-checkpoint--2026-10-16) and [chat/Home checkpoint](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-chat-and-home-integration-checkpoint--2026-10-17) record their exact scopes. Whole Step 8 remains open. @@ -972,8 +978,8 @@ The standalone create wizard binds installed Clack prompts to captured streams and checks admission between answers and before writing. Its already-entered terminal restoration uses the original captured control capability, separately from guarded business output. No deadline certifies input, terminal or database -release. Bootstrap/caught-fatal activation and complete Step 8d review, security -and platform gates remain staged in the +release. Bootstrap/caught-fatal activation is implemented; complete Step 8d +review, security and platform gates remain open in the [implementation register](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-management-ownership-checkpoint--2026-10-17). ### Workflow and terminal ownership @@ -1020,12 +1026,12 @@ their classifier; unknown cleanup failures do not silently become success. The first owned infrastructure fault starts the common three-second backstop immediately, including when cleanup holds the rejection from the outer catch. -The staged workflow fallback permits that catch to render after actual cleanup; -infrastructure does not use its signal-only early-exit continuation. A later -native handoff keeps the same observer, deadline and physical-exit latch and -invalidates queued fallback completion. This claims no diagnostic delivery ACK. -Global fatal/background/sink installation, session owners and final acknowledged -diagnostic ordering remain Step 8d. Whole Step 8 is still open. +The native bootstrap takes the original observer, deadline and physical-exit +latch before runtime entry, invalidating any queued fallback completion. Actual +terminal restoration and the sole fixed final diagnostic require native ACK +within the unchanged budget. A background-only selected exit 9 does not become +a fatal shutdown merely because a completed surface returns it. Whole Step 8 +remains open until Step 8d cumulative review, security and platform acceptance. ### Native crash teardown scope @@ -1042,9 +1048,9 @@ the already-captured MCP consent gate. One removable invocation-bound exit callback iterates the existing registry's active native capabilities. There is no completed PID/group history or independent product MCP reaper collection. Workflow routing follows the ownership section -above; session compatibility signal guards remain staged until 8d. Connecting native creation does not -activate the complete fatal coordinator. Source fixtures explicitly install that -foundation when testing fatal cleanup. Workflow/session/terminal retirement and +above; Step 8d implements the corresponding session/management lifetimes and +activates the product coordinator. Source fixtures retain their separately +attributed explicit foundation tests; they do not certify product activation. Workflow/session/terminal retirement and safe DB close remain their separate integration obligations. | Creation owner / platform | Permitted finite teardown | diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index 8b0abc5c..d8fad003 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -1,6 +1,6 @@ # CLI Packaging Contract -- **Status**: Compiled graph and installed package independently accepted on `development`; native fatal integration and release gates staged +- **Status**: Compiled graph and installed package independently accepted on `development`; Step 8d activation implemented, acceptance and release gates staged - **Date**: 2026-10-15 - **Related**: [ADR-0111](../../decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md), [commands.md](commands.md), [release runbook](../../runbooks/release-a-surface.md) @@ -73,11 +73,14 @@ DB metadata; no rebuild can repeat the invocation's file-cache attempt over it. Pure supported leaves retain their existing constructor/function identities: shared terminal stripping and the error base, LLM secret scrubbing, and core's tool-dispatch error base. Runtime classification remains separate from diagnostic -rendering. Dependency/evaluation failures reach fixed early diagnostics even if -the heavy classifier cannot load; their historical fallback exit 1 is retained -until Step 8d connects all creation owners and activates the native coordinator. -Step 8a separately implements runtime storage-origin and fixed classification; -its inert native foundation does not change this early fallback yet. +rendering. Step 8d implements the native coordinator before the first guarded +runtime import. Dependency/evaluation failures now reach one fixed human/JSON +diagnostic and exit 9 without loading the heavy classifier. Actual runtime +boundaries project known parser/storage/operational errors before returning to +the pure entry, preserving their existing exit meanings. The complete +177-control compiled bootstrap inventory pins both deferred entry and dispatcher +failures in both representations; installed-package binding requires the same +inventory. Step 8d's cumulative review/security/platform acceptance remains open. The emitted graph checker parses actual JavaScript and compares every loading edge with the build inventory. It rejects missing/extra/unreachable/escaping chunks, diff --git a/tools/cli-bootstrap/README.md b/tools/cli-bootstrap/README.md index dc04b5a9..77eded1a 100644 --- a/tools/cli-bootstrap/README.md +++ b/tools/cli-bootstrap/README.md @@ -35,7 +35,8 @@ the patched FS wrappers. Native `realpath[Sync].native` is preserved and guarded Positive controls execute the real compiled chat grant-admission path and refuse real deferred entry/dispatcher chunk loads. Import failures must reach the fixed diagnostics without the arbitrary synthetic error canary or stack. Their current -fallback exit 1 is explicit; Step 8 owns native ABI classification and exit 9. +native exit 9 and single fixed human/JSON diagnostic are pinned. The complete +177-control inventory is shared with installed-package binding. Each scenario uses a fresh sanitized child environment and a finite deadline, with a combined UTF-8 output-byte cap. POSIX children own a process group which diff --git a/tools/cli-bootstrap/check.mjs b/tools/cli-bootstrap/check.mjs index 1bb284d6..fa02e7c5 100644 --- a/tools/cli-bootstrap/check.mjs +++ b/tools/cli-bootstrap/check.mjs @@ -5,7 +5,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { performance } from 'node:perf_hooks'; import { createHash } from 'node:crypto'; import { withFixture, toEntryUrl } from './child.mjs'; -import { metaCases, grammarCases } from './inventory.mjs'; +import { metaCases, grammarCases, BOOTSTRAP_CONTROL_COUNT } from './inventory.mjs'; import { assertWitness, importFailureCanary } from './policy.mjs'; const repository = fileURLToPath(new URL('../../', import.meta.url)); @@ -121,39 +121,44 @@ export async function runBootstrap(packageRoot = join(repository, 'apps/cli')) { witness: firstDenied.method, }); - // These refuse real emitted chunks, without rewriting the compiled entry or implementation. - for (const [name, failRuntimeUrl, args, diagnostic] of [ - [ - 'entry-runtime-load-failure', - runtimeUrl, - ['run', 'synthetic-absent.yaml'], - 'relavium: a fatal internal error occurred.\n', - ], - [ - 'dispatcher-load-failure', - dispatchUrl, - ['provider', 'list'], - 'The CLI runtime could not be loaded.', - ], + // Refuse actual emitted chunks in human and JSON mode; native activation owns exit 9. + for (const [name, failRuntimeUrl, args] of [ + ['entry-runtime-load-failure', runtimeUrl, ['run', 'synthetic-absent.yaml']], + ['dispatcher-load-failure', dispatchUrl, ['provider', 'list']], ]) { - const result = await fixture.child([entry, ...args], { failRuntimeUrl }); - retainEvents(result.events, fixture); - assert.ok( - result.events.some( - (event) => event.kind === 'load' && event.url === failRuntimeUrl && event.runtimeFailure, - ), - ); - assert.equal(result.code, 1, name); // Native ABI classification/exit 9 is Step 8. - assert.equal(result.stdout, '', name); - assert.ok(result.stderr.includes(diagnostic), name); - assert.ok(!result.stderr.includes(importFailureCanary), name); - assert.doesNotMatch(result.stderr, /\n\s+at |file:\/\/|Error: synthetic/, name); - assert.equal( - result.events.filter((event) => event.denied && !event.runtimeFailure).length, - 0, - name, - ); - records.push({ name, exitCode: result.code, refusedUrl: failRuntimeUrl }); + for (const json of [false, true]) { + const result = await fixture.child([entry, ...(json ? ['--json'] : []), ...args], { + failRuntimeUrl, + }); + retainEvents(result.events, fixture); + assert.ok( + result.events.some( + (event) => + event.kind === 'load' && event.url === failRuntimeUrl && event.runtimeFailure, + ), + ); + assert.equal(result.code, 9, name); + assert.equal(result.stdout, '', name); + if (json) { + const lines = result.stderr.trim().split('\n'); + assert.equal(lines.length, 1, name); + const error = JSON.parse(lines[0]); + assert.equal(error.type, 'error', name); + assert.equal(error.code, 'cli_internal', name); + assert.match(error.message, /Inspect existing work/, name); + } else { + assert.match(result.stderr, /^relavium: An unexpected CLI failure occurred\./, name); + assert.match(result.stderr, /Inspect existing work/, name); + } + assert.ok(!result.stderr.includes(importFailureCanary), name); + assert.doesNotMatch(result.stderr, /\n\s+at |file:\/\/|Error: synthetic/, name); + assert.equal( + result.events.filter((event) => event.denied && !event.runtimeFailure).length, + 0, + name, + ); + records.push({ name, json, exitCode: result.code, refusedUrl: failRuntimeUrl }); + } } }); assert.deepEqual( @@ -161,6 +166,7 @@ export async function runBootstrap(packageRoot = join(repository, 'apps/cli')) { artifact, 'compiled graph changed during bootstrap controls', ); + assert.equal(records.length, BOOTSTRAP_CONTROL_COUNT, 'complete bootstrap control inventory'); return { kind: 'compiled-cli-bootstrap', node: process.version, diff --git a/tools/cli-bootstrap/inventory.mjs b/tools/cli-bootstrap/inventory.mjs index 31c912f5..f52b5295 100644 --- a/tools/cli-bootstrap/inventory.mjs +++ b/tools/cli-bootstrap/inventory.mjs @@ -1,3 +1,5 @@ +export const BOOTSTRAP_CONTROL_COUNT = 177; + /** Existing human meta grammar only. JSON help remains human until Wave 3 Step 10. */ export const metaCases = Object.freeze([ ['root-help', ['--help']], diff --git a/tools/cli-bootstrap/witness.test.mjs b/tools/cli-bootstrap/witness.test.mjs index 5cfada02..f3724e25 100644 --- a/tools/cli-bootstrap/witness.test.mjs +++ b/tools/cli-bootstrap/witness.test.mjs @@ -7,6 +7,11 @@ import { pathToFileURL } from 'node:url'; import { withFixture, toEntryUrl } from './child.mjs'; import { assertWitness, importFailureCanary, servicePackages } from './policy.mjs'; import { spawnBounded } from './process.mjs'; +import { BOOTSTRAP_CONTROL_COUNT } from './inventory.mjs'; + +test('package binding expects the complete human/JSON native-failure cohort', () => { + assert.equal(BOOTSTRAP_CONTROL_COUNT, 177); +}); function fixtureModule(fixture, body, name = 'fixture.mjs') { const path = join(fixture.root, name); diff --git a/tools/cli-package/README.md b/tools/cli-package/README.md index 23a524fd..7f92ad8c 100644 --- a/tools/cli-package/README.md +++ b/tools/cli-package/README.md @@ -37,7 +37,7 @@ access is refused. No workspace install, product build, repack, provider request credential is used by a consumer. Actual installed dependency versions and metadata digests are recorded; a workspace resolution is never substituted. -Each consumer repeats the 175 untouched-entry bootstrap controls and the seven +Each consumer repeats the 177 untouched-entry bootstrap controls and the seven [installed behavior cases](smoke/README.md). Detailed local reports remain in the support directory. CI uploads **only** the bounded public `installed-consumer.json`: source/artifact/origin/runtime identities, public file/dependency digests, and exact diff --git a/tools/cli-package/binding.mjs b/tools/cli-package/binding.mjs index 20c74d54..565031d7 100644 --- a/tools/cli-package/binding.mjs +++ b/tools/cli-package/binding.mjs @@ -1,4 +1,5 @@ import { isDeepStrictEqual } from 'node:util'; +import { BOOTSTRAP_CONTROL_COUNT } from '../cli-bootstrap/inventory.mjs'; import { ARCHIVE_LIMITS, assertPublicPackagePath } from './archive.mjs'; import { REQUIRED_SMOKE_CASES } from './smoke/cases.mjs'; @@ -345,7 +346,8 @@ export function validateConsumerRecord(value, receiptValue, expected) { if (!isDeepStrictEqual(installedFiles, packedFiles)) refuse('INSTALLED_FILES'); const bootstrap = exact(consumer.bootstrap, ['controls', 'entrySha256']); const entry = receipt.files.find((file) => file.path === 'package/dist/index.js'); - if (bootstrap.controls !== 175 || bootstrap.entrySha256 !== entry?.sha256) refuse('BOOTSTRAP'); + if (bootstrap.controls !== BOOTSTRAP_CONTROL_COUNT || bootstrap.entrySha256 !== entry?.sha256) + refuse('BOOTSTRAP'); const smoke = exact(consumer.smoke, ['success', 'cases']); if ( smoke.success !== true || diff --git a/tools/cli-package/binding.test.mjs b/tools/cli-package/binding.test.mjs index 7ffc1240..ce661cb7 100644 --- a/tools/cli-package/binding.test.mjs +++ b/tools/cli-package/binding.test.mjs @@ -62,7 +62,7 @@ function consumer(leg = 'current-linux') { platform: leg === 'current-macos' ? 'darwin' : leg === 'current-windows' ? 'win32' : 'linux', arch: 'x64', success: true, - bootstrap: { controls: 175, entrySha256: 'c'.repeat(64) }, + bootstrap: { controls: 177, entrySha256: 'c'.repeat(64) }, installedFiles: pack.files.map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 })), smoke: { success: true, cases: REQUIRED_SMOKE_CASES.map((id) => ({ id, passed: true })) }, packageVersion: '0.1.1', @@ -217,8 +217,8 @@ test('consumer refuses replay, mismatched files, failed/skipped/incomplete contr { platform: 'darwin' }, { node: 'v22.0.0' }, { node: 'v24.0.0-preview' }, - { bootstrap: { controls: 174, entrySha256: 'c'.repeat(64) } }, - { bootstrap: { controls: 175, entrySha256: '0'.repeat(64) } }, + { bootstrap: { controls: 176, entrySha256: 'c'.repeat(64) } }, + { bootstrap: { controls: 177, entrySha256: '0'.repeat(64) } }, { installedFiles: report.installedFiles.slice(1) }, { installedFiles: report.installedFiles.map((item) => ({ ...item, mode: 0o644 })) }, { installedFiles: report.installedFiles.map((item) => ({ ...item, sha256: '0'.repeat(64) })) }, @@ -462,7 +462,7 @@ function filesystemFixture() { ...consumer(leg), tarballSha256: pack.tarballSha256, installedFiles: pack.files.map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 })), - bootstrap: { controls: 175, entrySha256: entry.sha256 }, + bootstrap: { controls: 177, entrySha256: entry.sha256 }, }), ); } diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index 58ca9f58..b581a5da 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -520,7 +520,7 @@ export const mutations = [ ' emitOutput(SESSION_EXPORT_REVIEW_NOTICE.trimEnd());\n', '', ['apps/cli/src/commands/chat.test.ts'], - 130, + 133, "reports fixed continuity and review notice on stderr after live 'human' /export", 'W2_EXPORT_LIVE_NOTICE', [ @@ -540,7 +540,7 @@ export const mutations = [ ' emitOutput(SESSION_EXPORT_REVIEW_NOTICE.trimEnd());\n', ' deps.io.writeErr(SESSION_EXPORT_REVIEW_NOTICE);\n', ['apps/cli/src/commands/chat.test.ts'], - 130, + 133, "renders the live export 'success' through the interactive transcript", 'W2_EXPORT_LIVE_RENDERED_NOTICE', ), From 2928d8ef1f44b6ad33551fae718a8bc27cabfdf3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 01:37:27 +0300 Subject: [PATCH 110/222] test(repo): verify installed native bootstrap lifetime Require an eighth same-tarball platform case using the untouched installed entry. Real uncaught, unhandled-rejection and closed-pipe events exercise product hooks after exact native target/descendant readiness. Fatal output waits for gated native close delivery; background-only failure permits a successful ordinary turn while selecting exit 9. Each mode owns its sibling, watchdog and exact native cleanup. No provider/keychain/live terminal use. Observe actual exit-listener removal synchronously and retain bounded multi-case failure diagnostics. Pin both required native cases on all four consumer legs. Add a focused CLI regression for escaped output refusal. Validation: local installed macOS/Node22.23.1 177 controls and eight cases; 508 acceptance-tool tests; 37 focused package-tool tests; tools lint/typecheck; workspace 23/23 with fresh CLI 5314 passed and 10 established skips. Current Linux/Windows/minimum-node evidence and complete Step 8d review, security and acceptance remain open. Deadline and interactive restoration proofs remain separately attributed source controls. Refs: ADR-0110, ADR-0111, Wave 3 Step 8d Co-Authored-By: Codex --- apps/cli/src/process/native-bootstrap.test.ts | 21 +++ tools/cli-package/README.md | 5 +- tools/cli-package/binding.test.mjs | 50 ++++--- tools/cli-package/smoke/README.md | 18 ++- tools/cli-package/smoke/cases.mjs | 1 + .../cli-package/smoke/failure-diagnostic.mjs | 23 ++- .../smoke/failure-diagnostic.test.mjs | 7 +- tools/cli-package/smoke/native-process.mjs | 138 ++++++++++++++++-- .../smoke/native-spawn-witness.mjs | 48 +++++- .../smoke/native-spawn-witness.test.mjs | 1 - tools/cli-package/smoke/native-status.cjs | 24 ++- tools/cli-package/smoke/worker.mjs | 3 + 12 files changed, 275 insertions(+), 64 deletions(-) diff --git a/apps/cli/src/process/native-bootstrap.test.ts b/apps/cli/src/process/native-bootstrap.test.ts index 1bffa12c..d3783f7a 100644 --- a/apps/cli/src/process/native-bootstrap.test.ts +++ b/apps/cli/src/process/native-bootstrap.test.ts @@ -6,6 +6,7 @@ import { invocationFixture } from './invocation.test-support.js'; import { startNativeCli } from './native-bootstrap.js'; import { SignalCustody, subscribeWithSignalCustody } from './signal-custody.js'; import type { NativeInvocationPorts } from './native-invocation.js'; +import { CliError } from './cli-error.js'; function deferred() { let resolve: (value: T) => void = () => undefined; @@ -84,6 +85,26 @@ function fixture() { } describe('native product bootstrap', () => { + it('preserves an escaping native output refusal even when ordinary diagnostic rendering throws', async () => { + const f = fixture(); + const native = f.start( + () => + Promise.resolve({ + run: () => { + throw new CliError('output_unavailable', '', { + cause: new Error('PRIVATE_NATIVE_SINK'), + }); + }, + }), + ['--json'], + ); + expect(await native.finished).toBe(9); + expect(f.registry.observations.infrastructureCode()).toBe('output_unavailable'); + expect(f.stderr.frames).toHaveLength(1); + const diagnostic: unknown = JSON.parse(f.stderr.frames[0] ?? ''); + expect(diagnostic).toMatchObject({ code: 'output_unavailable' }); + expect(f.stderr.frames.join('')).not.toContain('PRIVATE_NATIVE_SINK'); + }); it.each([0, 1, 2, 3, 4, 6] satisfies ExitCode[])( 'settles the actual entry root before normal exit %s', async (code) => { diff --git a/tools/cli-package/README.md b/tools/cli-package/README.md index 7f92ad8c..864741bb 100644 --- a/tools/cli-package/README.md +++ b/tools/cli-package/README.md @@ -5,7 +5,8 @@ These tooling runners implement Wave 3 Step 7b under The canonical package and release contract lives in [packaging.md](../../docs/reference/cli/packaging.md). Step 8b adds required creation-owned native process controls to the same installed -artifact cohort; fatal coordinator activation remains Step 8d. +artifact cohort. Step 8d adds installed global fatal/background/output-hook +controls; complete Step 8d acceptance remains separate. ```sh pnpm package:cli:pack /absolute/empty/artifact-directory @@ -37,7 +38,7 @@ access is refused. No workspace install, product build, repack, provider request credential is used by a consumer. Actual installed dependency versions and metadata digests are recorded; a workspace resolution is never substituted. -Each consumer repeats the 177 untouched-entry bootstrap controls and the seven +Each consumer repeats the 177 untouched-entry bootstrap controls and the eight [installed behavior cases](smoke/README.md). Detailed local reports remain in the support directory. CI uploads **only** the bounded public `installed-consumer.json`: source/artifact/origin/runtime identities, public file/dependency digests, and exact diff --git a/tools/cli-package/binding.test.mjs b/tools/cli-package/binding.test.mjs index ce661cb7..7accf4db 100644 --- a/tools/cli-package/binding.test.mjs +++ b/tools/cli-package/binding.test.mjs @@ -243,32 +243,34 @@ test('consumer refuses replay, mismatched files, failed/skipped/incomplete contr ); }); -test('each required platform refuses the former six-case cohort without its native owner control', () => { +test('each required platform refuses a missing or failed native owner/bootstrap case', () => { assert.ok(REQUIRED_SMOKE_CASES.includes('actual-native-process-owner')); - assert.equal(REQUIRED_SMOKE_CASES.length, 7); - for (const leg of REQUIRED_CONSUMER_LEGS) { - const report = consumer(leg); - const cases = report.smoke.cases.filter((item) => item.id !== 'actual-native-process-owner'); - assert.equal(cases.length, 6); - refuses(() => - validateConsumerRecord({ ...report, smoke: { success: true, cases } }, receipt(), expected), - ); - refuses(() => - validateConsumerRecord( - { - ...report, - smoke: { - success: true, - cases: report.smoke.cases.map((item) => - item.id === 'actual-native-process-owner' ? { ...item, passed: false } : item, - ), + assert.ok(REQUIRED_SMOKE_CASES.includes('actual-native-bootstrap-lifecycle')); + assert.equal(REQUIRED_SMOKE_CASES.length, 8); + for (const nativeCase of ['actual-native-process-owner', 'actual-native-bootstrap-lifecycle']) + for (const leg of REQUIRED_CONSUMER_LEGS) { + const report = consumer(leg); + const cases = report.smoke.cases.filter((item) => item.id !== nativeCase); + assert.equal(cases.length, 7); + refuses(() => + validateConsumerRecord({ ...report, smoke: { success: true, cases } }, receipt(), expected), + ); + refuses(() => + validateConsumerRecord( + { + ...report, + smoke: { + success: true, + cases: report.smoke.cases.map((item) => + item.id === nativeCase ? { ...item, passed: false } : item, + ), + }, }, - }, - receipt(), - expected, - ), - ); - } + receipt(), + expected, + ), + ); + } }); test('resolved dependency evidence records every approved package without exposing host paths', () => { diff --git a/tools/cli-package/smoke/README.md b/tools/cli-package/smoke/README.md index 0960f987..13ff60d4 100644 --- a/tools/cli-package/smoke/README.md +++ b/tools/cli-package/smoke/README.md @@ -90,10 +90,20 @@ captures the actual packaged creation-registry exit callback before native entry invokes that exact finite callback in a separate subcase, and observes its release after native close. This proves packaged creation/fallback identity; source forwarding controls separately prove original bootstrap object identity. The error -is explicit native-edge fault injection, not an OS-fault claim. Fatal coordinator -activation and live fatal budget integration retain the later Step 8d scope; -original-TS maps/custody remain Step 9. All existing six behavior cases and 175 -bootstrap controls remain required. +is explicit native-edge fault injection, not an OS-fault claim. + +Step 8d adds an eighth required case through the untouched installed entry: an +actual scheduled uncaught throw, an actual unhandled rejection, and a real stdout +pipe closure/write. Exact root/descendant readiness precedes injection. Fatal and +output modes require empty stderr while actual native close delivery remains +gated, then one fixed diagnostic and exit 9 after delivery. Background mode keeps +both native PIDs alive, permits the fixture's ordinary successful turn, and ends +with background-only exit 9. Each independent mode retains its own sibling, +watchdog, actual close and exact-PID cleanup evidence. These are plain one-shot +activation/ACK-order controls: interactive terminal restoration and absolute +budget nonrenewal retain their separately attributed source controls. Whole +Step 8d acceptance remains open. Original-TS maps/custody remain Step 9. All eight +behavior cases and 177 bootstrap controls are required. Narrow child-lifetime negative controls run with: diff --git a/tools/cli-package/smoke/cases.mjs b/tools/cli-package/smoke/cases.mjs index 43a08127..72262629 100644 --- a/tools/cli-package/smoke/cases.mjs +++ b/tools/cli-package/smoke/cases.mjs @@ -7,4 +7,5 @@ export const REQUIRED_SMOKE_CASES = Object.freeze([ 'actual-mcp-initialize-version', 'actual-media-cas-iterator-cleanup', 'actual-native-process-owner', + 'actual-native-bootstrap-lifecycle', ]); diff --git a/tools/cli-package/smoke/failure-diagnostic.mjs b/tools/cli-package/smoke/failure-diagnostic.mjs index 6b8e1dbe..3ba9e1d6 100644 --- a/tools/cli-package/smoke/failure-diagnostic.mjs +++ b/tools/cli-package/smoke/failure-diagnostic.mjs @@ -3,7 +3,15 @@ import { writeSync } from 'node:fs'; import { REQUIRED_SMOKE_CASES } from './cases.mjs'; export const MAX_WORKER_FAILURE_BYTES = 8192; -const modes = new Set(['injected-error', 'exit-fallback', 'facility-refusal', 'leader-exit']); +const modes = new Set([ + 'injected-error', + 'exit-fallback', + 'facility-refusal', + 'leader-exit', + 'bootstrap-fatal', + 'bootstrap-background', + 'bootstrap-output', +]); const signals = new Set([ 'SIGKILL', 'SIGTERM', @@ -31,12 +39,17 @@ const operations = new Set([ 'native-helper-error', 'native-target-actual-close', 'native-helper-actual-close', + 'native-target-close-delivered', + 'native-helper-close-delivered', 'native-exit-listener-released', 'native-synthetic-error-injected', 'native-captured-exit-reap-invoked', 'native-witness-gate-timeout', 'native-witness-readiness-refused', 'native-facility-access-refused', + 'native-bootstrap-fatal-injected', + 'native-bootstrap-background-injected', + 'native-bootstrap-output-injected', ]); const object = (value) => (value !== null && typeof value === 'object' ? value : {}); const list = (value, maximum) => (Array.isArray(value) ? value.slice(0, maximum) : []); @@ -140,7 +153,7 @@ export function formatInstalledWorkerFailure(raw) { id, status: item === undefined ? 'missing' : 'failed', error: errorText(item?.error), - ...(id === 'actual-native-process-owner' + ...(['actual-native-process-owner', 'actual-native-bootstrap-lifecycle'].includes(id) ? { native: list(object(item?.evidence).outcomes, 6) .map(nativeMode) @@ -157,6 +170,10 @@ export function formatInstalledWorkerFailure(raw) { diagnostic.truncated = true; for (const item of diagnostic.cases) { item.error = errorText(item.error, 96); + if ((item.native?.length ?? 0) > 2) { + item.native = [item.native[0], item.native.at(-1)]; + item.nativeModesTruncated = true; + } for (const mode of item.native ?? []) { if (mode.observed.length > 4) mode.observed = [...mode.observed.slice(0, 2), ...mode.observed.slice(-2)]; @@ -166,7 +183,7 @@ export function formatInstalledWorkerFailure(raw) { } line = JSON.stringify(diagnostic) + '\n'; } - // The compact fixed inventory fits well below this bound even with all seven failures. + // The compact fixed inventory fits well below this bound even with all eight failures. if (Buffer.byteLength(line) > MAX_WORKER_FAILURE_BYTES) throw new Error('Worker failure diagnostic bound exceeded'); return line; diff --git a/tools/cli-package/smoke/failure-diagnostic.test.mjs b/tools/cli-package/smoke/failure-diagnostic.test.mjs index 39f76e60..8c0d48b4 100644 --- a/tools/cli-package/smoke/failure-diagnostic.test.mjs +++ b/tools/cli-package/smoke/failure-diagnostic.test.mjs @@ -35,10 +35,11 @@ test('successful worker stays silent on both formatter and failure-only emitter' test('failure identifies the exact artifact/runtime/canonical case and partial native cleanup facts', () => { const failed = report(); - failed.cases.at(-1).passed = false; - failed.cases.at(-1).error = + const native = failed.cases.find((item) => item.id === 'actual-native-process-owner'); + native.passed = false; + native.error = 'AssertionError [ERR_ASSERTION]: synthetic helper close failed\nprivate stack omitted'; - failed.cases.at(-1).evidence = { + native.evidence = { outcomes: [ { mode: 'injected-error', diff --git a/tools/cli-package/smoke/native-process.mjs b/tools/cli-package/smoke/native-process.mjs index 4993e2c2..690d7807 100644 --- a/tools/cli-package/smoke/native-process.mjs +++ b/tools/cli-package/smoke/native-process.mjs @@ -185,9 +185,9 @@ function cassette() { } /** Public offline route; all native mechanism/controller/registry bytes are from the selected installation. */ -export async function assertInstalledNativeProcesses(context) { +export async function assertInstalledNativeProcesses(context, { bootstrap = false } = {}) { const { root, cli, environment, calls } = context; - const home = join(root, 'native-home'); + const home = join(root, bootstrap ? 'bootstrap-home' : 'native-home'); const bin = join(home, 'bin'); mkdirSync(bin, { recursive: true, mode: 0o700 }); const executable = join(bin, process.platform === 'win32' ? 'git.exe' : 'git'); @@ -202,11 +202,13 @@ export async function assertInstalledNativeProcesses(context) { const outcomes = []; let caseFailure; let sibling; - const modes = [ - 'injected-error', - 'exit-fallback', - ...(process.platform === 'win32' ? ['facility-refusal'] : ['leader-exit']), - ]; + const modes = bootstrap + ? ['bootstrap-fatal', 'bootstrap-background', 'bootstrap-output'] + : [ + 'injected-error', + 'exit-fallback', + ...(process.platform === 'win32' ? ['facility-refusal'] : ['leader-exit']), + ]; try { for (const mode of modes) { const cwd = join(home, mode); @@ -217,7 +219,8 @@ export async function assertInstalledNativeProcesses(context) { const gate = mode === 'facility-refusal' ? 'none' - : process.platform === 'win32' && mode === 'injected-error' + : process.platform === 'win32' && + ['injected-error', 'bootstrap-fatal', 'bootstrap-output'].includes(mode) ? 'helper' : 'target'; const outcome = { @@ -236,6 +239,7 @@ export async function assertInstalledNativeProcesses(context) { log, node: process.execPath, descendant: fixture('native-descendant.cjs'), + ...(bootstrap ? { release } : {}), }), { mode: 0o600 }, ); @@ -285,6 +289,63 @@ export async function assertInstalledNativeProcesses(context) { }, 15000); let primary; try { + if (bootstrap) { + await poll( + () => records(log).some((item) => item.operation === 'native-fixture-descendant-ready'), + 10000, + 'Actual bootstrap descendant never became ready', + ); + if (mode === 'bootstrap-background') { + await poll( + () => owned.output().stderr.includes('A background operation failed.'), + 5000, + 'Installed entry did not observe actual background rejection', + ); + assert.equal( + owned.isSettled(), + false, + 'Background observer unexpectedly killed entered work', + ); + const live = records(log).find((item) => item.operation === 'native-target-spawn'); + assert.equal( + nativePidGone(live.pid), + false, + 'Background observer reaped the actual live target', + ); + const atNotice = records(log); + const descendant = atNotice.find( + (item) => + item.operation === 'native-fixture-descendant' && item.parentPid === live.pid, + ); + assert.ok(descendant); + assert.equal( + nativePidGone(descendant.pid), + false, + 'Background observer reaped the descendant', + ); + assert.equal( + atNotice.some((item) => + ['native-target-actual-close', 'native-helper-spawn'].includes(item.operation), + ), + false, + ); + assert.equal( + completeEvents(owned.output().stdout).some( + (event) => event.type === 'session:turn_completed', + ), + false, + ); + sibling.assertAlive(); + writeFileSync(release + '.target-exit', 'finish the exact fixture normally\n', { + mode: 0o600, + }); + } else if (mode === 'bootstrap-output') { + owned.child.stdout.destroy(); // Real parent read-end close, then one native child pipe write. + writeFileSync(release + '.pipe-closed', 'parent closed the exact stdout pipe\n', { + mode: 0o600, + }); + } + } if (mode !== 'facility-refusal') { await poll( () => @@ -306,6 +367,13 @@ export async function assertInstalledNativeProcesses(context) { false, 'A request/error supplied a premature close acknowledgement', ); + if (bootstrap && mode !== 'bootstrap-background') { + assert.equal( + owned.output().stderr, + '', + 'Fatal diagnostic preceded actual native close delivery', + ); + } const atClose = records(log); const descendant = atClose.find((item) => item.operation === 'native-fixture-descendant'); const started = atClose.find((item) => item.operation === 'native-target-spawn'); @@ -336,13 +404,42 @@ export async function assertInstalledNativeProcesses(context) { } const result = await owned.result(); assert.equal(result.signal, null, result.stderr); - assert.ok([0, 1].includes(result.code), result.stderr); + if (bootstrap) assert.equal(result.code, 9, result.stderr); + else assert.ok([0, 1].includes(result.code), result.stderr); assert.equal( ndjson(result.stdout).filter((event) => event.type === 'session:turn_completed').length, - 1, - 'The public offline turn did not complete', + bootstrap && mode !== 'bootstrap-background' ? 0 : 1, + 'The public offline turn completion differs from its actual lifetime', ); const observed = records(log); + if (bootstrap) { + assert.ok( + observed.some((item) => item.operation === `native-${gate}-close-delivered`), + 'Actual gated close was not delivered before product completion', + ); + const diagnostics = ndjson(result.stderr); + assert.equal(diagnostics.length, 1, 'Exactly one native diagnostic is owed'); + assert.equal( + diagnostics[0].code, + mode === 'bootstrap-output' ? 'output_unavailable' : 'cli_internal', + ); + if (mode === 'bootstrap-background') { + assert.match(diagnostics[0].message, /^A background operation failed\./); + assert.equal( + observed.find((item) => item.operation === 'native-target-actual-close').code, + 0, + ); + const completed = ndjson(result.stdout).find( + (event) => event.type === 'session:turn_completed', + ); + assert.equal( + completed.error, + undefined, + 'Background-only observation changed a successful turn', + ); + } + assert.doesNotMatch(result.stderr, /PRIVATE_INSTALLED_|\n\s+at |file:\/\//); + } assert.equal(watchdogFired, false, 'External fixture watchdog supplied native cleanup'); assert.equal( observed.some((item) => item.operation === 'native-witness-gate-timeout'), @@ -389,7 +486,10 @@ export async function assertInstalledNativeProcesses(context) { observed.filter((item) => item.operation === 'native-synthetic-error-injected').length, mode === 'injected-error' ? 1 : 0, ); - if (process.platform === 'win32' && mode === 'injected-error') { + if ( + process.platform === 'win32' && + ['injected-error', 'bootstrap-fatal', 'bootstrap-output'].includes(mode) + ) { assert.equal(helpers.length, 1); assert.equal(helpers[0].executable, systemTaskkill); assert.notEqual( @@ -415,7 +515,7 @@ export async function assertInstalledNativeProcesses(context) { actualNativeClose: mode !== 'facility-refusal', syntheticNativeError: mode === 'injected-error', syntheticFacilityAccessRefusal: mode === 'facility-refusal', - ownedDescendantClosed: mode === 'injected-error' || mode === 'leader-exit', + ownedDescendantClosed: bootstrap || mode === 'injected-error' || mode === 'leader-exit', siblingSurvived: true, observed, }); @@ -496,8 +596,16 @@ export async function assertInstalledNativeProcesses(context) { runtime: process.version, publicOfflineAgentRoute: true, capturedPackagedCreationExitClosure: true, - originalBootstrapIdentity: 'separately checked by source forwarding controls', - fatalCoordinatorActivated: false, + originalBootstrapIdentity: bootstrap + ? 'untouched installed entry owns global hooks' + : 'separately checked by source forwarding controls', + fatalCoordinatorActivated: bootstrap, + ...(bootstrap + ? { + scope: + 'plain installed one-shot hooks and owned native ACK ordering; interactive restoration and deadline nonrenewal remain separately attributed source controls', + } + : {}), copiedRuntimeLibraries, outcomes, }; diff --git a/tools/cli-package/smoke/native-spawn-witness.mjs b/tools/cli-package/smoke/native-spawn-witness.mjs index 4b763363..8aa0f1ae 100644 --- a/tools/cli-package/smoke/native-spawn-witness.mjs +++ b/tools/cli-package/smoke/native-spawn-witness.mjs @@ -44,7 +44,15 @@ export function createNativeSpawnWitness(configuration, spawn) { ); for (const key of ['cwd', 'executable', 'log', 'release']) assert.ok(isAbsolute(config[key])); assert.ok( - ['injected-error', 'leader-exit', 'exit-fallback', 'facility-refusal'].includes(config.mode), + [ + 'injected-error', + 'leader-exit', + 'exit-fallback', + 'facility-refusal', + 'bootstrap-fatal', + 'bootstrap-background', + 'bootstrap-output', + ].includes(config.mode), ); assert.ok(['target', 'helper', 'none'].includes(config.gate)); assert.ok(config.taskkill === null || isAbsolute(config.taskkill)); @@ -66,12 +74,14 @@ export function createNativeSpawnWitness(configuration, spawn) { let pending = []; let gateTimer; let gateTicks = 0; - const afterDelivery = () => { - setImmediate(() => { - if (exitReaper !== undefined && !process.listeners('exit').includes(exitReaper)) - record({ operation: 'native-exit-listener-released' }); - }); + // Observe the actual synchronous removal. A later setImmediate would be skipped by a + // legitimate fatal process exit even after the creation owner had already retired. + const onExitRemoved = (event, listener) => { + if (event !== 'exit' || listener !== exitReaper) return; + process.off('removeListener', onExitRemoved); + record({ operation: 'native-exit-listener-released' }); }; + process.on('removeListener', onExitRemoved); function holdClose(child, kind) { const on = child.on; const output = { stdout: '', stderr: '' }; @@ -89,7 +99,7 @@ export function createNativeSpawnWitness(configuration, spawn) { return on.call(this, event, (...args) => { const deliver = () => { listener.apply(this, args); - afterDelivery(); + record({ operation: `native-${kind}-close-delivered`, pid: child.pid }); }; if (config.gate !== kind || existsSync(config.release)) return deliver(); pending.push(deliver); @@ -175,7 +185,29 @@ export function createNativeSpawnWitness(configuration, spawn) { }); return; } - if (config.mode === 'injected-error') { + if (config.mode === 'bootstrap-fatal') { + record({ operation: 'native-bootstrap-fatal-injected', pid: target.pid }); + // Real uncaught callback, observed by the untouched installed entry's native hook. + setImmediate(() => { + throw new Error('PRIVATE_INSTALLED_FATAL_CANARY'); + }); + } else if (config.mode === 'bootstrap-background') { + record({ operation: 'native-bootstrap-background-injected', pid: target.pid }); + // No listener/process.emit replacement: Node supplies the actual unhandled-rejection event. + void Promise.reject(new Error('PRIVATE_INSTALLED_BACKGROUND_CANARY')); + } else if (config.mode === 'bootstrap-output') { + let ticks = 0; + const pipe = setInterval(() => { + if (!existsSync(config.release + '.pipe-closed') && ++ticks < 400) return; + clearInterval(pipe); + if (!existsSync(config.release + '.pipe-closed')) { + record({ operation: 'native-witness-gate-timeout' }); + return; + } + record({ operation: 'native-bootstrap-output-injected', pid: target.pid }); + process.stdout.write('SYNTHETIC_CLOSED_PIPE_WRITE\n'); + }, 25); + } else if (config.mode === 'injected-error') { record({ operation: 'native-synthetic-error-injected', pid: target.pid }); target.emit('error', new Error('Synthetic installed native lifetime control')); } else if (config.mode === 'exit-fallback') { diff --git a/tools/cli-package/smoke/native-spawn-witness.test.mjs b/tools/cli-package/smoke/native-spawn-witness.test.mjs index 510665c5..812ae4ee 100644 --- a/tools/cli-package/smoke/native-spawn-witness.test.mjs +++ b/tools/cli-package/smoke/native-spawn-witness.test.mjs @@ -152,7 +152,6 @@ test('callback capture uses the exact pre-entry function and releases only after ); h.target.on('close', () => process.off('exit', h.exit)); h.target.emit('close', null, 'SIGKILL'); - await new Promise(setImmediate); assert.equal( h.log().filter((item) => item.operation === 'native-exit-listener-released').length, 1, diff --git a/tools/cli-package/smoke/native-status.cjs b/tools/cli-package/smoke/native-status.cjs index d79572a9..07469538 100644 --- a/tools/cli-package/smoke/native-status.cjs +++ b/tools/cli-package/smoke/native-status.cjs @@ -2,14 +2,23 @@ // A copied Node executable named git/git.exe runs this through the real git_status host arm. const assert = require('node:assert/strict'); const { spawn } = require('node:child_process'); -const { appendFileSync, readFileSync } = require('node:fs'); +const { appendFileSync, readFileSync, existsSync } = require('node:fs'); const { join } = require('node:path'); -const { setInterval } = require('node:timers'); +const { setInterval, clearInterval } = require('node:timers'); const config = JSON.parse(readFileSync(join(process.cwd(), 'native-fixture.json'), 'utf8')); const record = (value) => appendFileSync(config.log, JSON.stringify(value) + '\n', { mode: 0o600 }); assert.equal(process.argv.length, 2, 'Only the fixed status script may execute'); -assert.ok(['injected-error', 'leader-exit', 'exit-fallback'].includes(config.mode)); +assert.ok( + [ + 'injected-error', + 'leader-exit', + 'exit-fallback', + 'bootstrap-fatal', + 'bootstrap-background', + 'bootstrap-output', + ].includes(config.mode), +); record({ operation: 'native-fixture-root', pid: process.pid }); if (config.mode === 'exit-fallback') { process.stdout.write('RELAVIUM_NATIVE_FIXTURE_READY\n'); @@ -29,5 +38,12 @@ if (config.mode === 'exit-fallback') { descendant.disconnect(); if (config.mode === 'leader-exit') descendant.unref(); }); - if (config.mode !== 'leader-exit') setInterval(() => {}, 1000); + if (config.mode === 'bootstrap-background') { + const finish = setInterval(() => { + if (!existsSync(config.release + '.target-exit')) return; + clearInterval(finish); + // Fixture-owned normal completion, not product fatal cleanup: join the exact created child. + descendant.kill('SIGTERM'); + }, 25); + } else if (config.mode !== 'leader-exit') setInterval(() => {}, 1000); } diff --git a/tools/cli-package/smoke/worker.mjs b/tools/cli-package/smoke/worker.mjs index d31aab0d..8c3eb61e 100644 --- a/tools/cli-package/smoke/worker.mjs +++ b/tools/cli-package/smoke/worker.mjs @@ -206,6 +206,9 @@ async function exerciseInstalledSmoke({ packageRoot, supportRoot, artifactSha256 }; }); await runCase('actual-native-process-owner', () => assertInstalledNativeProcesses(context)); + await runCase('actual-native-bootstrap-lifecycle', () => + assertInstalledNativeProcesses(context, { bootstrap: true }), + ); report.guardCalls = calls(); for (const call of report.guardCalls.filter((value) => value.operation === 'external-module')) { const resolved = realpathSync(fileURLToPath(call.url)); From e3b0b673b0c7c7829e07bc0abb2fa5db2f581dc4 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 01:38:14 +0300 Subject: [PATCH 111/222] docs(docs): record native activation implementation evidence Link the implemented native entry and eighth installed case to their source-attributed author gates. Preserve failed attempts, selected-only causal scope, local-only package evidence and ownerless compatibility. Keep complete cumulative review, security, current CI/platform acceptance and whole Step 8 explicitly open. No implementation or decision is deferred. Refs: ADR-0110, ADR-0111, Wave 3 Step 8d Co-Authored-By: Codex --- docs/reference/cli/packaging.md | 5 +- docs/roadmap/current.md | 6 +- .../phase-2.5.5-hardening-and-remediation.md | 65 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index d8fad003..e543d92f 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -80,7 +80,10 @@ boundaries project known parser/storage/operational errors before returning to the pure entry, preserving their existing exit meanings. The complete 177-control compiled bootstrap inventory pins both deferred entry and dispatcher failures in both representations; installed-package binding requires the same -inventory. Step 8d's cumulative review/security/platform acceptance remains open. +inventory. Step 8d adds an eighth required installed case for actual global +fatal/background/output hooks and native close-delivery ordering through the +untouched entry. Interactive restoration and deadline nonrenewal retain separate +source controls. Step 8d's cumulative review/security/platform acceptance remains open. The emitted graph checker parses actual JavaScript and compares every loading edge with the build inventory. It rejects missing/extra/unreachable/escaping chunks, diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index d6753394..be43e84e 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1149,7 +1149,11 @@ records partial session/native-terminal integration and the remaining activation review/security/platform gates without claiming acceptance. The subsequent [management-owner checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-management-ownership-checkpoint--2026-10-17) records remaining command, prompt, doctor and transient-resource integration; -bootstrap activation and complete acceptance remain staged. Step 8d proceeds automatically. The +[native activation checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-native-activation-implementation-checkpoint--2026-10-17) +records product bootstrap activation at `a2acba8e` and the eighth required +installed case at `2928d8ef`. Implementation is present; two NEW complete +cumulative clean reviews, a separate clean security sitting and current full +CI/platform acceptance remain open. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index dc8bcb2d..d0dffd1b 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -6174,3 +6174,68 @@ post-import/caught/fatal/background/sink integration, two NEW complete cumulativ clean rounds from `d9625ff0`, a NEW clean security sitting and required actual same-artifact platform evidence remain open. Whole Step 8 and Steps 9–19 remain open; no provider, credential, billing or user action is pending. + +## Wave 3 Step 8d native activation implementation checkpoint — 2026-10-17 + +**Implemented on `development` at `a2acba8e`, with required installed controls at +`2928d8ef`; not Step 8d acceptance.** The product entry installs the native +coordinator before deferred runtime loading and retains the actual loader/run +root. The original registry, finite-exit observer, absolute budget and physical +exit latch remain one authority. Explicit signal custody preserves each actual +surface's existing cooperative Ctrl-C behaviour while covering startup and +post-surface gaps. Runtime classification precedes return to the pure entry; +known operational refusals keep their meanings. Fatal classification precedes +joins and the sole fixed diagnostic follows acknowledged terminal restoration. +Background-only exit 9 remains recoverable rather than becoming a second fatal +failure. The canonical [ownership contract](../../reference/cli/commands.md#invocation-owners-and-fatal-ordering) +and [packaging contract](../../reference/cli/packaging.md) own these mechanics. + +The two caught-exit advisory findings are corrected: an unexpected Home snapshot +failure after chat teardown reaches its actual lifetime, and owned slash commands +publish no ordinary notices after stop, including delayed doctor/compaction and +export-failure paths. Four assertions failed before the fixes; the focused 227-test +suite then passed, including positive normal-notice and terminating-Home controls. +Ownerless Home embeddings retain their original causes. The initial broad attempt +exposed eight ownerless compatibility failures; the corrected boundary's 85-test +focused run passed. Author lint/test failures are not clean review rounds. + +Author workspace validation passed 23/23 tasks, with 20 valid cache hits and +fresh CLI lint/typecheck/test: 322 files, 5,314 passing tests and 10 established +skips. Other package test inventories were replayed from valid cache. Build +passed 6/6 (five cached packages and a fresh CLI). The finite emitted graph has +25 JavaScript outputs, 404 inputs and 17 declared externals. Tools lint/typecheck, +508 acceptance-tool tests, 37 focused package-tool tests, seam/purity gates, +compiled CLI smoke and all 177 compiled bootstrap controls passed on local +macOS/Node 22.23.1. Final publication validation remains attributed to the saved +working-tree source records, not inferred execution of a later commit. + +Two selected live-export mechanisms each passed fixed/removal/restored with +133 fixed/restored tests, their named semantic failures, byte-exact restoration +and unchanged developer source. Each selected-family record deliberately remains +`complete: false`; this is not the full secrets family. The new installed eighth +case exercises real uncaught, unhandled-rejection and closed-stdout events through +the untouched selected entry. It joins exact target/descendant readiness, retains +the unrelated sibling, refuses a fatal diagnostic while native close delivery is +held, and requires one fixed diagnostic after delivery. Background mode permits +an ordinary successful turn before background-only exit 9. The local selected +package passed 177 bootstrap controls and all eight cases; its recorded tarball +SHA-256 is `22a0337dedbe85e2b6bf50b3245681e940d9c9480e581e736a4186aa13cb0cbe`. +Its receipt names `a2acba8e` with the then-uncommitted, pinned control changes; +no clean-commit or other-platform execution is inferred. Interactive restoration +and deadline nonrenewal retain their separate source-test attribution. + +Local source records remain Git-ignored: the final activation record pins 33 +paths (SHA-256 `4020e5ac1d09fb97c8e02a25dd5735cc0c007e244ce0b94f709c84da3d6fd498`), +and the installed-control record pins 12 unchanged paths +(`8e37bc50943db3039d2ab48d3785c198dd2415543247b7823de2dbd8a9065e63`). +Earlier author attempts, moving-source advisories and superseded evidence retain +their original scopes. None is an independent complete cumulative or security +review. No provider, keychain, operator-terminal, live clipboard or paid operation +was used. + +**Remaining gates:** two NEW complete cumulative clean review rounds from +`d9625ff0a641197c6ed45968df99094a25db88d6` after the final material correction, +a NEW separate clean security sitting, fresh required full CI and the four +current same-tarball Linux/macOS/Windows/Node-22.0.0 consumers with the eighth case. +Whole Step 8 and Steps 9–19 remain open. No new ADR approval, provider capture, +credential, billing or user decision is pending. Work proceeds automatically. From 98259c97b238c185c887016b4a19dcb86599b07d Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 02:08:09 +0300 Subject: [PATCH 112/222] fix(cli): bind workflow secret input to invocation ownership Use captured native stdin and the workflow cancellation/failure lifetime for fresh run and gate resume. Add real open-pipe child regressions and preserve input-close-before-DB retirement. Migrate ambient input fixtures without changing their named causal inventories. Correct the Windows installed creation count and separate only the exact owned Node 22.0.0 JSON warning from product diagnostics. Make removal-edge fault injection independent of older stream event behavior. Record the complete NONCLEAN first cumulative review and author correction evidence; Step 8d acceptance and platform gates remain open. Validation: green workspace lint/typecheck/tests, build, 511 tooling tests, 177 bootstrap controls, seam/purity, isolation, CLI smoke and two selected fixed/removal/restored secrets controls. Minimum-runtime local consume passes on the old artifact; fresh remote corrected-product gates remain. Refs: ADR-0110, ADR-0111, ADR-0107, Wave 3 Step 8d Co-Authored-By: Codex --- .../src/chat/native-removal.test-support.ts | 18 ++ apps/cli/src/chat/session-ink-input.test.ts | 24 ++- .../fixtures/workflow-secret-child.ts | 172 ++++++++++++++++++ apps/cli/src/commands/gate.test.ts | 50 +++-- apps/cli/src/commands/gate.ts | 14 +- apps/cli/src/commands/run.test.ts | 25 +-- apps/cli/src/commands/run.ts | 12 +- .../commands/workflow-secret-native.test.ts | 87 +++++++++ apps/cli/src/render/scrollback.test.ts | 3 +- apps/cli/src/run-secret-inputs.test.ts | 44 +++-- apps/cli/src/run-snapshot-notice.test.ts | 23 +-- docs/reference/cli/packaging.md | 5 +- docs/roadmap/current.md | 9 +- .../phase-2.5.5-hardening-and-remediation.md | 70 +++++++ package.json | 2 +- tools/cli-package/smoke/README.md | 13 +- tools/cli-package/smoke/native-process.mjs | 20 +- tools/cli-package/smoke/runtime-stderr.mjs | 18 ++ .../cli-package/smoke/runtime-stderr.test.mjs | 43 +++++ tools/secrets-controls/README.md | 2 +- tools/secrets-controls/mutations.mjs | 2 +- 21 files changed, 559 insertions(+), 97 deletions(-) create mode 100644 apps/cli/src/chat/native-removal.test-support.ts create mode 100644 apps/cli/src/commands/fixtures/workflow-secret-child.ts create mode 100644 apps/cli/src/commands/workflow-secret-native.test.ts create mode 100644 tools/cli-package/smoke/runtime-stderr.mjs create mode 100644 tools/cli-package/smoke/runtime-stderr.test.mjs diff --git a/apps/cli/src/chat/native-removal.test-support.ts b/apps/cli/src/chat/native-removal.test-support.ts new file mode 100644 index 00000000..5078ca59 --- /dev/null +++ b/apps/cli/src/chat/native-removal.test-support.ts @@ -0,0 +1,18 @@ +import type { Readable } from 'node:stream'; + +/** + * Inject at an actual completed native removal. Older Node 22 streams do not emit + * removeListener, so that event cannot be the cross-runtime fault-injection authority. + */ +export function injectPostRemoval(input: Readable) { + const callbacks: Array<(event: string | symbol, listener: unknown) => void> = []; + const native = input.removeListener.bind(input); + input.removeListener = (event, listener) => { + const result = native(event, listener); + for (const callback of callbacks) callback(event, listener); + return result; + }; + return (callback: (event: string | symbol, listener: unknown) => void): void => { + callbacks.push(callback); + }; +} diff --git a/apps/cli/src/chat/session-ink-input.test.ts b/apps/cli/src/chat/session-ink-input.test.ts index b836732a..56700e52 100644 --- a/apps/cli/src/chat/session-ink-input.test.ts +++ b/apps/cli/src/chat/session-ink-input.test.ts @@ -4,14 +4,16 @@ import { CliError } from '../process/cli-error.js'; import { invocationFixture } from '../process/invocation.test-support.js'; import { OwnedTtyInput } from '../test-support.js'; import { SessionInkInput } from './session-ink-input.js'; +import { injectPostRemoval } from './native-removal.test-support.js'; const turn = (): Promise => new Promise((resolve) => setImmediate(resolve)); function fixture(acknowledge = vi.fn(() => Promise.resolve())) { const f = invocationFixture(); const input = new OwnedTtyInput(); + const afterRemoval = injectPostRemoval(input); const failure = vi.fn(() => f.invocation.finiteExit.fail('cli_internal')); const lease = new SessionInkInput({ input, invocation: f.invocation, acknowledge, failure }); - return { ...f, input, failure, lease, acknowledge }; + return { ...f, input, failure, lease, acknowledge, afterRemoval }; } describe('captured native Ink input final release', () => { @@ -22,10 +24,15 @@ describe('captured native Ink input final release', () => { f.lease.enter(); f.input.on('readable', shared); f.input.on('readable', shared); - f.input.once('removeListener', (event, listener: unknown) => { - if (event === 'readable' && listener === shared) f.input.removeListener('readable', shared); + let reentered = false; + f.afterRemoval((event, listener: unknown) => { + if (!reentered && event === 'readable' && listener === shared) { + reentered = true; + f.input.removeListener('readable', shared); + } }); await f.lease.close(); + expect(reentered).toBe(true); expect(f.input.listeners('readable'), 'SESSION_INK_INPUT_REMOVAL_REENTRY_BASELINE').toEqual([ shared, ]); @@ -51,6 +58,7 @@ describe('captured native Ink input final release', () => { it('a throwing failure reporter cannot skip independent release or replace the primary remover failure', async () => { const f = invocationFixture(); const input = new OwnedTtyInput(); + const afterRemoval = injectPostRemoval(input); const primary = new Error('PRIVATE_PRIMARY_REMOVER'); const observer = new Error('PRIVATE_FAILURE_OBSERVER'); const acknowledge = vi.fn(() => Promise.resolve()); @@ -67,7 +75,7 @@ describe('captured native Ink input final release', () => { input.on('readable', first); input.on('readable', vi.fn()); input.setRawMode(true); - input.on('removeListener', (event, listener: unknown) => { + afterRemoval((event, listener: unknown) => { if (event === 'readable' && listener === first) throw primary; }); await expect(lease.close()).rejects.toBe(primary); @@ -96,7 +104,7 @@ describe('captured native Ink input final release', () => { const shared = vi.fn(); f.input.on('readable', shared); f.lease.enter(); - f.input.on('removeListener', (event) => { + f.afterRemoval((event) => { if (event === 'newListener') f.input.on('readable', shared); }); await expect(f.lease.close()).rejects.toBeInstanceOf(CliError); @@ -131,7 +139,7 @@ describe('captured native Ink input final release', () => { const native = vi.fn(); f.input.on('readable', native); let reentered: Promise | undefined; - f.input.on('removeListener', (event) => { + f.afterRemoval((event) => { if (event === 'readable') reentered = f.lease.close(); }); const close = f.lease.close(); @@ -158,7 +166,7 @@ describe('captured native Ink input final release', () => { f.input.on('readable', first); f.input.on('readable', second); f.input.setRawMode(true); - f.input.on('removeListener', (event, listener: unknown) => { + f.afterRemoval((event, listener: unknown) => { if (event === 'readable' && listener === first) throw primary; }); await expect(f.lease.close()).rejects.toBe(primary); @@ -173,7 +181,7 @@ describe('captured native Ink input final release', () => { const f = fixture(); f.lease.enter(); const late = vi.fn(); - f.input.on('removeListener', (event) => { + f.afterRemoval((event) => { if (event === 'newListener') f.input.on('readable', late); }); await expect(f.lease.close()).rejects.toBeInstanceOf(CliError); diff --git a/apps/cli/src/commands/fixtures/workflow-secret-child.ts b/apps/cli/src/commands/fixtures/workflow-secret-child.ts new file mode 100644 index 00000000..4ba95ebd --- /dev/null +++ b/apps/cli/src/commands/fixtures/workflow-secret-child.ts @@ -0,0 +1,172 @@ +/** Synthetic inputs only; the parent owns this child and keeps its actual stdin pipe open. */ +import { randomUUID } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import { parseWorkflow } from '@relavium/core'; +import { + createClient, + createRunHistoryStore, + createRunLeasePort, + runMigrations, +} from '@relavium/db'; +import { buildEngine } from '../../engine/build-engine.js'; +import { createCliHost } from '../../engine/host.js'; +import { createCliInvocation } from '../../process/invocation.js'; +import { captureIo } from '../../test-support.js'; +import { gateCommand } from '../gate.js'; +import { runCommand } from '../run.js'; + +const mode = process.argv[2]; +const root = process.argv[3]; +if (root === undefined || (mode !== 'run' && mode !== 'gate')) + throw new Error('Fixture arguments required.'); +const source = JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'native-secret-cancellation', + inputs: [{ name: 'token', type: 'secret', required: true }], + nodes: [ + { id: 'start', type: 'input' }, + { id: 'gate', type: 'human_gate', gate_type: 'approval' }, + { id: 'out', type: 'output' }, + ], + edges: [ + { from: 'start', to: 'gate' }, + { from: 'gate', to: 'out' }, + ], + }, +}); +const workflowPath = join(root, 'workflow.relavium.yaml'); +const configPath = join(root, 'config.toml'); +writeFileSync(workflowPath, source); +writeFileSync(configPath, ''); +async function seedGate(root: string) { + const client = createClient(join(root, 'owned.sqlite')); + runMigrations(client.db); + const workflow = parseWorkflow(source); + const store = createRunHistoryStore(client.db, { + uuid: randomUUID, + now: Date.now, + workflow: { + slug: workflow.workflow.id, + name: workflow.workflow.id, + definitionJson: JSON.stringify(workflow), + }, + }); + const engine = await buildEngine({ + host: createCliHost(store, { runLeases: createRunLeasePort(store) }), + }); + const handle = engine.start({ + workflow, + inputs: { token: 'synthetic-unused' }, + eventConsumption: 'iterated', + }); + const iterator = handle.events[Symbol.asyncIterator](); + for (;;) { + const next = await iterator.next(); + if (next.done) throw new Error('Seed ended before gate.'); + if (next.value.type === 'run:paused') break; + } + if ((await handle.depart()).kind !== 'detached') throw new Error('Seed did not detach.'); + while (!(await iterator.next()).done) { + /* Join seeded delivery. */ + } + await iterator.return?.(); + return { client, runId: handle.runId }; +} +const gate = mode === 'gate' ? await seedGate(root) : undefined; +const captured = process.stdin; +const ambient = new PassThrough(); +const original = captured[Symbol.asyncIterator].bind(captured); +const events: string[] = []; +let keyCalls = 0, + buildCalls = 0, + dbCloses = 0, + ambientReads = 0; +Object.defineProperty(captured, Symbol.asyncIterator, { + value: () => { + process.send?.({ type: 'ready', source: 'captured' }); + return original(); + }, +}); +const foreign = ambient[Symbol.asyncIterator].bind(ambient); +Object.defineProperty(ambient, Symbol.asyncIterator, { + value: () => { + ambientReads++; + process.send?.({ type: 'ready', source: 'ambient' }); + return foreign(); + }, +}); +Object.defineProperty(process, 'stdin', { value: ambient }); +captured.once('close', () => events.push('stdin-close')); +process.on('message', (message: unknown) => { + if ( + typeof message === 'object' && + message !== null && + Object.getOwnPropertyDescriptor(message, 'type')?.value === 'interrupt' + ) + process.emit('SIGINT'); +}); +const invocation = createCliInvocation({ + exit: () => { + throw new Error('Unexpected finite exit.'); + }, + setExitCode: () => {}, +}); +const deps = { + invocation, + io: { ...captureIo().io, stdin: captured }, + global: { json: true, color: false, verbosity: 'normal' as const, cwd: root, configPath }, + resolveKeys: () => { + keyCalls++; + throw new Error('Unexpected key entry.'); + }, + buildEngine: () => { + buildCalls++; + throw new Error('Unexpected engine entry.'); + }, +}; +try { + const code = + gate === undefined + ? await runCommand( + { workflow: workflowPath, input: [], allowMcpStdio: [], secretInputsStdin: true }, + deps, + ) + : await gateCommand( + { runId: gate.runId, approve: true, secretStdin: true }, + { + ...deps, + openDb: () => ({ + db: gate.client.db, + close: () => { + dbCloses++; + events.push('db-close'); + gate.client.sqlite.close(); + }, + }), + }, + ); + events.push('command-settled'); + writeFileSync( + join(root, 'result.json'), + JSON.stringify({ + code, + events, + keyCalls, + buildCalls, + dbCloses, + ambientReads, + capturedClosed: captured.closed, + ambientDestroyed: ambient.destroyed, + owners: invocation.registry.activeOwners, + terminating: invocation.registry.termination !== undefined, + observedExit: invocation.registry.observations.exitCode(), + }), + ); +} finally { + if (gate?.client.sqlite.open) gate.client.sqlite.close(); + ambient.destroy(); + if (process.connected) process.disconnect(); +} diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index 852f1663..cf8fe517 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { Readable } from 'node:stream'; import { EngineStateError, @@ -562,14 +563,13 @@ describe('gateCommand', () => { // refuse immediately, which exercises the real function and its message without touching stdin. const { runId, gateIds } = await seedSecretRun(); const { io } = captureIo(); - const original = process.stdin.isTTY; try { - Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + Object.defineProperty(io.stdin, 'isTTY', { value: true, configurable: true }); await expect( gateCommand({ runId, approve: true, secretStdin: true }, deps(io)), ).rejects.toThrow(`relavium gate ${runId} --gate ${gateIds[0]} --approve --secret-stdin`); } finally { - Object.defineProperty(process.stdin, 'isTTY', { value: original, configurable: true }); + Object.defineProperty(io.stdin, 'isTTY', { value: false, configurable: true }); } }); @@ -578,7 +578,7 @@ describe('gateCommand', () => { async (mode) => { const { runId } = await seedSecretRun(); const captured = captureIo(); - const original = Object.getOwnPropertyDescriptor(process, 'stdin'); + const source = new Readable({ read() {} }); const close = vi.fn(); const resolveKeys = vi.fn(() => { throw new Error('keys must not resolve'); @@ -598,17 +598,14 @@ describe('gateCommand', () => { return Promise.resolve({ done, value: 'api_key=' + 'x'.repeat(65_536) }); }); try { - Object.defineProperty(process, 'stdin', { - configurable: true, - value: { - isTTY: mode === 'TTY', - [Symbol.asyncIterator]: () => ({ next }), - }, + Object.defineProperties(source, { + isTTY: { value: mode === 'TTY' }, + [Symbol.asyncIterator]: { value: () => ({ next }) }, }); const operation = gateCommand( { runId, approve: true, secretStdin: true }, { - ...deps(captured.io), + ...deps({ ...captured.io, stdin: source }), openDb: () => ({ db, close }), resolveKeys, startMcpClient, @@ -630,7 +627,7 @@ describe('gateCommand', () => { expect(captured.out()).toBe(''); expect(captured.err()).not.toContain('opaque-gate-stdin-fault'); } finally { - if (original !== undefined) Object.defineProperty(process, 'stdin', original); + source.destroy(); } }, ); @@ -640,26 +637,23 @@ describe('gateCommand', () => { async (exactCapacity) => { const { runId } = await seedSecretRun(); const { io } = captureIo(); - const original = Object.getOwnPropertyDescriptor(process, 'stdin'); + const source = new Readable({ read() {} }); let supplied: unknown; const padding = exactCapacity ? 'x'.repeat(65_536 - Buffer.byteLength(' api_key = value=with=equals \r\n')) : ''; const value = ` ${padding}value=with=equals `; try { - Object.defineProperty(process, 'stdin', { - configurable: true, - value: { - async *[Symbol.asyncIterator]() { - await Promise.resolve(); - yield ` api_key =${value}\r\n`; - }, + Object.defineProperty(source, Symbol.asyncIterator, { + value: async function* () { + await Promise.resolve(); + yield ` api_key =${value}\r\n`; }, }); const code = await gateCommand( { runId, approve: true, secretStdin: true }, { - ...deps(io), + ...deps({ ...io, stdin: source }), buildEngine: async (options) => { const engine = await buildEngine(options); const resume = engine.resumeFromCheckpoint.bind(engine); @@ -675,7 +669,7 @@ describe('gateCommand', () => { expect(supplied).toMatchObject({ inputs: { api_key: value } }); expect(JSON.stringify(reader().loadRunEvents(runId))).not.toContain('value=with=equals'); } finally { - if (original !== undefined) Object.defineProperty(process, 'stdin', original); + source.destroy(); } }, ); @@ -858,21 +852,21 @@ describe('gateCommand', () => { const io = { ...captured.io, stdinIsTty: true, stdoutIsTty: true, env: {} }; const startMcpClient = vi.fn(() => realStartMcpClient([])); const before = reader().loadRunEvents(runId); - const descriptor = Object.getOwnPropertyDescriptor(process, 'stdin'); + const source = new Readable({ read() {} }); const iterator = vi.fn(async function* () { yield await Promise.resolve(`api_key=${opaque}`); }); let caught: unknown; try { - Object.defineProperty(process, 'stdin', { - configurable: true, - value: { isTTY: false, [Symbol.asyncIterator]: iterator }, + Object.defineProperties(source, { + isTTY: { value: false }, + [Symbol.asyncIterator]: { value: iterator }, }); try { await gateCommand( { runId, approve: true, secretStdin: true }, { - ...deps(io), + ...deps({ ...io, stdin: source }), startMcpClient, selectRenderer: () => ({ onEvent: () => {}, finalize: () => {} }), }, @@ -891,7 +885,7 @@ describe('gateCommand', () => { expect(existsSync(mcpConsentPath(home))).toBe(false); expect(reader().loadRunEvents(runId)).toEqual(before); } finally { - if (descriptor !== undefined) Object.defineProperty(process, 'stdin', descriptor); + source.destroy(); } }); diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index 1b169ebe..e4a1a35f 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -74,6 +74,7 @@ import { createMcpSecretResolver, type McpSecretResolver } from '../secrets/mcp- import type { GatePrompter } from '../gate/prompter.js'; import { selectGatePrompter } from '../gate/select-prompter.js'; import { readSecretFromStdin, type StdinSecretContext } from '../secrets/read-secret.js'; +import { Readable } from 'node:stream'; import { parseSecretLines } from '../secrets/parse-secret-lines.js'; import { CliError } from '../process/errors.js'; import { storageBoundaryError } from '../process/owned-storage.js'; @@ -833,8 +834,17 @@ async function resolveSecretInputs( } const read = deps.readSecretInput ?? - ((): Promise => - readSecretFromStdin(SECRET_STDIN_CONTEXT(args.runId, resumeInvocation))); + ((): Promise => { + if (!(deps.io.stdin instanceof Readable)) throw new CliError('cli_internal', ''); + return readSecretFromStdin( + SECRET_STDIN_CONTEXT(args.runId, resumeInvocation), + deps.io.stdin, + { + signal: lifetime.signal, + onFailure: (cause) => lifetime.failure(cause), + }, + ); + }); const raw = await read(); lifetime.checkpoint(); const supplied = parseSecretLines(raw, `run ${args.runId}`); diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts index 4a1b3fe5..ad8af18f 100644 --- a/apps/cli/src/commands/run.test.ts +++ b/apps/cli/src/commands/run.test.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { Readable } from 'node:stream'; import { WorkflowEngine, @@ -2137,13 +2138,13 @@ describe('first-run secret admission before service construction (ADR-0107)', () ), ); const { io } = captureIo(); - const descriptor = Object.getOwnPropertyDescriptor(process, 'stdin'); + const source = new Readable({ read() {} }); const iterator = vi.fn(async function* () { yield await Promise.resolve(Buffer.from('token= opaque=verbatim \r\n')); }); - Object.defineProperty(process, 'stdin', { - configurable: true, - value: { isTTY: false, [Symbol.asyncIterator]: iterator }, + Object.defineProperties(source, { + isTTY: { value: false }, + [Symbol.asyncIterator]: { value: iterator }, }); const prompt = vi.fn(() => Promise.resolve(null)); const selectGatePrompter = vi.fn(() => ({ prompt, dispose: () => {} })); @@ -2155,7 +2156,7 @@ describe('first-run secret admission before service construction (ADR-0107)', () try { code = await runCommand( { workflow: path, input: [], allowMcpStdio: [], secretInputsStdin: true }, - deps(io, globalOptions(), { + deps({ ...io, stdin: source }, globalOptions(), { buildEngine: () => Promise.resolve(engine), selectGatePrompter, }), @@ -2175,7 +2176,7 @@ describe('first-run secret admission before service construction (ADR-0107)', () expect(caught).toBeUndefined(); expect(code).toBe(EXIT_CODES.gatePaused); } finally { - if (descriptor !== undefined) Object.defineProperty(process, 'stdin', descriptor); + source.destroy(); } }); @@ -2184,13 +2185,13 @@ describe('first-run secret admission before service construction (ADR-0107)', () async (isTTY) => { const path = writeWorkflow('reader-refusal.relavium.yaml', SECRET_RUN); const { io } = captureIo(); - const descriptor = Object.getOwnPropertyDescriptor(process, 'stdin'); + const source = new Readable({ read() {} }); const iterator = vi.fn(async function* () { yield await Promise.resolve('token=' + 'x'.repeat(65_536)); }); - Object.defineProperty(process, 'stdin', { - configurable: true, - value: { isTTY, [Symbol.asyncIterator]: iterator }, + Object.defineProperties(source, { + isTTY: { value: isTTY }, + [Symbol.asyncIterator]: { value: iterator }, }); const resolveKeys = vi.fn(() => ({ providers: createProviderResolver({}), @@ -2200,13 +2201,13 @@ describe('first-run secret admission before service construction (ADR-0107)', () await expect( runCommand( { workflow: path, input: [], allowMcpStdio: [], secretInputsStdin: true }, - deps(io, globalOptions(), { resolveKeys }), + deps({ ...io, stdin: source }, globalOptions(), { resolveKeys }), ), ).rejects.toMatchObject({ code: 'invalid_invocation' }); expect(resolveKeys).not.toHaveBeenCalled(); expect(iterator).toHaveBeenCalledTimes(isTTY ? 0 : 1); } finally { - if (descriptor !== undefined) Object.defineProperty(process, 'stdin', descriptor); + source.destroy(); } }, ); diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 0a821bd6..bb704f82 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -66,6 +66,7 @@ import { selectRenderer } from '../render/select.js'; import { createMcpSecretResolver, type McpSecretResolver } from '../secrets/mcp-secret.js'; import { parseSecretLines } from '../secrets/parse-secret-lines.js'; import { readSecretFromStdin, type StdinSecretContext } from '../secrets/read-secret.js'; +import { Readable } from 'node:stream'; import { resolveWorkflowSource } from '../workflows/resolve.js'; import { assertWorkflowCatalogValid, @@ -577,7 +578,16 @@ async function readRunSecrets( '--secret-inputs-stdin requires a declared secret input.', ); } - const raw = await (deps.readSecretInput?.() ?? readSecretFromStdin(RUN_SECRET_STDIN_CONTEXT)); + const read = + deps.readSecretInput ?? + ((): Promise => { + if (!(deps.io.stdin instanceof Readable)) throw new CliError('cli_internal', ''); + return readSecretFromStdin(RUN_SECRET_STDIN_CONTEXT, deps.io.stdin, { + signal: lifetime.signal, + onFailure: (cause) => lifetime.failure(cause), + }); + }); + const raw = await read(); lifetime.checkpoint(); return parseSecretLines(raw, 'run --secret-inputs-stdin'); } diff --git a/apps/cli/src/commands/workflow-secret-native.test.ts b/apps/cli/src/commands/workflow-secret-native.test.ts new file mode 100644 index 00000000..d5af8db9 --- /dev/null +++ b/apps/cli/src/commands/workflow-secret-native.test.ts @@ -0,0 +1,87 @@ +import { spawn } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const register = fileURLToPath( + new URL('./fixtures/workflow-cleanup-register.mjs', import.meta.url), +); +const fixture = fileURLToPath(new URL('./fixtures/workflow-secret-child.ts', import.meta.url)); + +describe('default workflow secret reader owns captured native input', () => { + it.each(['run', 'gate'] as const)( + '%s closes its open pipe on first Ctrl-C before command/DB retirement', + async (mode) => { + const root = mkdtempSync(join(tmpdir(), 'relavium-workflow-secret-')); + const child = spawn( + process.execPath, + ['--no-warnings', '--experimental-strip-types', '--import', register, fixture, mode, root], + { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + env: { ...process.env, HOME: root, USERPROFILE: root }, + }, + ); + let stdout = '', + stderr = '', + watchdogFired = false; + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + const messages: unknown[] = []; + child.on('message', (message: unknown) => { + messages.push(message); + if ( + typeof message === 'object' && + message !== null && + Object.getOwnPropertyDescriptor(message, 'type')?.value === 'ready' + ) { + // Windows exercises the exact installed callback; POSIX receives an actual OS SIGINT. + if (process.platform === 'win32') child.send({ type: 'interrupt' }); + else child.kill('SIGINT'); + } + }); + const closed = new Promise((resolve) => + child.once('close', (code, signal) => resolve({ code, signal })), + ); + const watchdog = setTimeout(() => { + watchdogFired = true; + child.kill('SIGKILL'); + }, 10000); + try { + expect(await closed, 'WORKFLOW_SECRET_NATIVE_CANCEL').toEqual({ code: 0, signal: null }); + expect(watchdogFired).toBe(false); + expect(messages).toEqual([{ type: 'ready', source: 'captured' }]); + expect(stdout).toBe(''); + expect(stderr).toBe(''); + const evidence: unknown = JSON.parse(readFileSync(join(root, 'result.json'), 'utf8')); + expect(evidence).toEqual({ + code: 1, + events: + mode === 'gate' + ? ['stdin-close', 'db-close', 'command-settled'] + : ['stdin-close', 'command-settled'], + keyCalls: 0, + buildCalls: 0, + dbCloses: mode === 'gate' ? 1 : 0, + ambientReads: 0, + capturedClosed: true, + ambientDestroyed: false, + owners: 0, + terminating: false, + observedExit: 1, + }); + } finally { + clearTimeout(watchdog); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await closed; + rmSync(root, { recursive: true, force: true }); + } + }, + 15000, + ); +}); diff --git a/apps/cli/src/render/scrollback.test.ts b/apps/cli/src/render/scrollback.test.ts index bb4e8865..fd0959a7 100644 --- a/apps/cli/src/render/scrollback.test.ts +++ b/apps/cli/src/render/scrollback.test.ts @@ -3,6 +3,7 @@ import { Writable } from 'node:stream'; import { describe, expect, it, vi } from 'vitest'; import { PassThrough } from 'node:stream'; +import { injectPostRemoval } from '../chat/native-removal.test-support.js'; import { DUMP_FOOTER, @@ -160,7 +161,7 @@ describe('nodeWaitForContinue', () => { const interrupts = fakeInterrupts(); const cancel = new AbortController(); const primary = new Error('native removal failed'); - stdin.on('removeListener', (event: string | symbol) => { + injectPostRemoval(stdin)((event) => { if (event === 'data') throw primary; }); const wait = nodeWaitForContinue(stdin, interrupts, cancel.signal)(); diff --git a/apps/cli/src/run-secret-inputs.test.ts b/apps/cli/src/run-secret-inputs.test.ts index 68d48e7c..77915567 100644 --- a/apps/cli/src/run-secret-inputs.test.ts +++ b/apps/cli/src/run-secret-inputs.test.ts @@ -1,6 +1,7 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { Readable } from 'node:stream'; import { createCheckpointReconstructor, parseWorkflow, WorkflowEngine } from '@relavium/core'; import { createClient, createRunHistoryReader, loadRunSnapshot } from '@relavium/db'; @@ -17,7 +18,7 @@ import * as mcpConsent from './engine/mcp-consent.js'; import * as providerResolver from './engine/providers.js'; import * as history from './history/open.js'; import { run } from './run.js'; -import { captureIo } from './test-support.js'; +import { captureIo as captureOutput } from './test-support.js'; const isolated = vi.hoisted(() => ({ home: '' })); const keychain = vi.hoisted(() => ({ @@ -62,6 +63,22 @@ const INPUTS = ` - { name: token, type: secret, required: true } - { name: n, type: number, default: 2 } - { name: optional, type: secret, required: false }`; +let capturedSecretInput: Readable | undefined; +function captureIo() { + const capture = captureOutput(); + return { + ...capture, + io: { ...capture.io, stdin: capturedSecretInput ?? capture.io.stdin }, + }; +} +function installSource(iterator: () => AsyncIterator, isTTY = false): void { + capturedSecretInput = new Readable({ read() {} }); + Object.defineProperties(capturedSecretInput, { + isTTY: { value: isTTY }, + [Symbol.asyncIterator]: { value: iterator }, + }); +} + /** Real default reader, owned async source. Never replace readSecretFromStdin or inject a string reader. */ function installStdin(chunks: readonly unknown[], isTTY = false) { const cleanup = vi.fn( @@ -79,10 +96,7 @@ function installStdin(chunks: readonly unknown[], isTTY = false) { return: cleanup, }; }); - Object.defineProperty(process, 'stdin', { - configurable: true, - value: { isTTY, [Symbol.asyncIterator]: iterator }, - }); + installSource(iterator, isTTY); return Object.assign(iterator, { cleanup }); } @@ -127,13 +141,12 @@ function nativeEvidence(home: string) { describe('top-level secret stdin admission with native history (ADR-0107)', () => { let startSpy: MockInstance; let directory: string; - let stdinDescriptor: PropertyDescriptor | undefined; beforeEach(() => { directory = mkdtempSync(join(tmpdir(), 'relavium-secret-native-')); isolated.home = directory; vi.stubEnv('HOME', directory); vi.stubEnv('USERPROFILE', directory); - stdinDescriptor = Object.getOwnPropertyDescriptor(process, 'stdin'); + capturedSecretInput = undefined; vi.clearAllMocks(); // These are observations, not replacements: both production history opens and the real engine run. vi.spyOn(localDb, 'openLocalDb'); @@ -149,7 +162,8 @@ describe('top-level secret stdin admission with native history (ADR-0107)', () = startSpy = vi.spyOn(WorkflowEngine.prototype, 'start'); }); afterEach(() => { - if (stdinDescriptor !== undefined) Object.defineProperty(process, 'stdin', stdinDescriptor); + capturedSecretInput?.destroy(); + capturedSecretInput = undefined; vi.restoreAllMocks(); vi.unstubAllEnvs(); rmSync(directory, { recursive: true, force: true }); @@ -451,16 +465,10 @@ describe('top-level secret stdin admission with native history (ADR-0107)', () = it(`keeps real stdin iterator faults private and closes the iterator (${mode.join(' ') || 'human'})`, async () => { const path = writeWorkflow(); const cleanup = vi.fn(() => Promise.resolve({ done: true, value: undefined })); - Object.defineProperty(process, 'stdin', { - configurable: true, - value: { - isTTY: false, - [Symbol.asyncIterator]: () => ({ - next: () => Promise.reject(new Error(SENTINEL)), - return: cleanup, - }), - }, - }); + installSource(() => ({ + next: () => Promise.reject(new Error(SENTINEL)), + return: cleanup, + })); const capture = captureIo(); const code = await run(argv('run', path, '--secret-inputs-stdin', ...mode), capture.io); expectNoEffects(); diff --git a/apps/cli/src/run-snapshot-notice.test.ts b/apps/cli/src/run-snapshot-notice.test.ts index 7344ce75..9ee31a5e 100644 --- a/apps/cli/src/run-snapshot-notice.test.ts +++ b/apps/cli/src/run-snapshot-notice.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; +import { Readable } from 'node:stream'; import { parseWorkflow, WorkflowEngine } from '@relavium/core'; import { createClient } from '@relavium/db'; @@ -12,7 +13,7 @@ import { RunEventSchema } from '@relavium/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { run } from './run.js'; -import { captureIo } from './test-support.js'; +import { captureIo as captureOutput } from './test-support.js'; const isolated = vi.hoisted(() => ({ home: '' })); vi.mock('./secrets/os-keychain.js', () => ({ @@ -44,16 +45,12 @@ workflow: - { from: second, to: out } `; -function installStdin(): void { - Object.defineProperty(process, 'stdin', { - configurable: true, - value: { - isTTY: false, - async *[Symbol.asyncIterator]() { - yield await Promise.resolve(Buffer.from(`token=${INPUT}`)); - }, - }, - }); +function captureIo() { + const capture = captureOutput(); + return { + ...capture, + io: { ...capture.io, stdin: Readable.from([Buffer.from(`token=${INPUT}`)]) }, + }; } function nativeSnapshot(directory: string) { @@ -88,17 +85,13 @@ const childMismatch = process.env['RELAVIUM_SNAPSHOT_TEST_MISMATCH'] === 'true'; describe('native authored snapshot warning and resume identity', () => { let directory: string; - let stdinDescriptor: PropertyDescriptor | undefined; beforeEach(() => { directory = childHome ?? mkdtempSync(join(tmpdir(), 'relavium-snapshot-native-')); isolated.home = directory; vi.stubEnv('HOME', directory); vi.stubEnv('USERPROFILE', directory); - stdinDescriptor = Object.getOwnPropertyDescriptor(process, 'stdin'); - installStdin(); }); afterEach(() => { - if (stdinDescriptor !== undefined) Object.defineProperty(process, 'stdin', stdinDescriptor); vi.restoreAllMocks(); vi.unstubAllEnvs(); if (childHome === undefined) rmSync(directory, { recursive: true, force: true }); diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index e543d92f..40c724d3 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -83,7 +83,10 @@ failures in both representations; installed-package binding requires the same inventory. Step 8d adds an eighth required installed case for actual global fatal/background/output hooks and native close-delivery ordering through the untouched entry. Interactive restoration and deadline nonrenewal retain separate -source controls. Step 8d's cumulative review/security/platform acceptance remains open. +source controls. The exact owned Node 22.0.0 experimental JSON-module warning is +retained separately from product diagnostics; unknown runtime output still fails +the diagnostic grammar and ordering controls. Step 8d's cumulative +review/security/platform acceptance remains open. The emitted graph checker parses actual JavaScript and compares every loading edge with the build inventory. It rejects missing/extra/unreachable/escaping chunks, diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index be43e84e..3511fc19 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1142,8 +1142,8 @@ four required same-tarball consumers pass at `ab50be11`; independent equivalence checks preserve that execution attribution. [Step 8c workflow/terminal integration is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8c-acceptance--2026-10-16) at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI plus all four same-tarball platform -consumers. Step 8d session/terminal integration and product fatal activation, -whole Step 8 and Steps 9–19 remain open. Its +consumers. Step 8d session/terminal integration and product fatal activation are +implemented but their acceptance remains open; whole Step 8 and Steps 9–19 remain open. Its [2026-10-17 chat/Home implementation checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-chat-and-home-integration-checkpoint--2026-10-17) records partial session/native-terminal integration and the remaining activation, review/security/platform gates without claiming acceptance. The subsequent @@ -1153,7 +1153,10 @@ records remaining command, prompt, doctor and transient-resource integration; records product bootstrap activation at `a2acba8e` and the eighth required installed case at `2928d8ef`. Implementation is present; two NEW complete cumulative clean reviews, a separate clean security sitting and current full -CI/platform acceptance remain open. Step 8d proceeds automatically. The +CI/platform acceptance remain open. The +[first cumulative round and correction register](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-first-cumulative-review-and-corrections--2026-10-17) +records complete NONCLEAN coverage of all 255 paths and the verified corrections; +no clean-round acceptance is inferred. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index d0dffd1b..52484ced 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -6239,3 +6239,73 @@ a NEW separate clean security sitting, fresh required full CI and the four current same-tarball Linux/macOS/Windows/Node-22.0.0 consumers with the eighth case. Whole Step 8 and Steps 9–19 remain open. No new ADR approval, provider capture, credential, billing or user decision is pending. Work proceeds automatically. + +## Wave 3 Step 8d first cumulative review and corrections — 2026-10-17 + +**Complete cumulative round 1 is NONCLEAN at `e3b0b673`; Step 8d is not accepted.** +Three independent reviewers inspected every path from +`d9625ff0a641197c6ed45968df99094a25db88d6`: 69 session/terminal paths, +117 workflow/management paths and 69 native/tooling/documentation paths. +Their coverage records join exactly 255 distinct paths without omission or +intersection. The local Git-ignored aggregate has SHA-256 +`ff119d6d7d733a1bbf827ddb9d93206a5baa59ca9a5ea74043bbc89b6bcc7fff`. +A clean individual slice does not turn this nonclean round into acceptance. + +All three verified findings are corrected in the subsequent working source: + +- Fresh workflow and gate-resume default secret readers now use the original + captured native stdin and workflow cancellation/failure ownership. The first + cooperative Ctrl-C closes the actual open input pipe; command retirement and + gate DB closure follow actual stdin close, without entering keys or the engine. + Two permanent native-child regressions use an actual POSIX SIGINT on macOS and + the exact registered callback on Windows. Removing only cancellation forwarding + breaks both regressions; watchdog termination remains failure, never an ACK. + Human-gate resume is physically exercised; the shared budget-resume helper is + covered by its existing command tests rather than claimed as a new native probe. +- The installed Windows bootstrap cohort now requires five actual target/helper + creations; the other cohort/platform combinations retain three. At the old head, + all three Windows modes passed their native lifetime assertions before the wrong + aggregate count refused publication. This does not certify the corrected head. +- The secrets-control README now records the actual 133-case live-chat inventory. + +Required CI at the old head also refused the minimum-Node installed case. An +independently downloaded official Node 22.0.0 macOS runtime, verified against its +published SHA-256, reproduced the failure: the exact experimental JSON-module +warning was mistaken for a premature product diagnostic. The warning now remains +in raw private stderr and a separately named runtime observation; only the exact +owned-PID/version/two-line frame can be separated. Unknown, partial, duplicate and +wrong-identity stderr still fails. Three permanent tooling tests cover that fence. +The unchanged old package then passed all 177 bootstrap and eight installed cases +locally on Node 22.0.0; its SHA-256 is +`5d50070afbfe2f61efa6f59c64238ea374b48ba745b78b5b885cdb69da761ff0`. +This is a tooling correction on an old package, not corrected-product or Linux +acceptance. The old remote artifact retains its different recorded digest. + +Four advisory Node-22.13 test failures reproduced on Node 22.0.0: older native +streams do not emit the `removeListener` event used by those injection fixtures. +The fixtures now inject reentry/refusal after the actual native remover returns, +independently of that optional runtime event. The callback-multiplicity control +also requires positive reentry. Both original-Node and minimum-Node 32-test cohorts +pass. Product removal logic is unchanged. Existing secret-reader fixtures now +supply captured sources rather than replacing ambient process stdin; named causal +witnesses and fixed test inventories remain unchanged. + +Author validation after the corrections passes workspace lint/typecheck (17 tasks, +15 cached) and tests (11 tasks, ten cached packages and a fresh CLI execution). +Fresh CLI results are 323 files, 5,316 passing tests and ten established skips. +Build passes all six tasks, finite emitted-graph closure and 177 bootstrap controls +pass, as do tools lint/typecheck, 511 acceptance-tool tests and seam/purity gates. +Initial broad validation exposed four remaining ambient-stdin fixture failures; +those are corrected and the fresh workspace run is green. Earlier failed attempts +and source-change-contaminated selected controls remain failure evidence. + +The default-reader and consumed-stdin consent mechanisms each pass their fresh +fixed/removal/restored cycles with 80 and 68 fixed/restored cases respectively, +exact named failures and byte-identical restoration. Both selected-family reports +retain `complete: false` and verify unchanged developer sources; neither is the +full secrets family. Test-isolation controls and the compiled CLI smoke also pass. + +Two NEW complete cumulative clean review rounds after the final correction, a NEW +separate clean security sitting and fresh required full CI/four same-artifact +platform consumers remain open. Whole Step 8 and Steps 9–19 remain open. No new +ADR approval, credential, provider, billing or user action is pending. diff --git a/package.json b/package.json index 4cfb5cd6..f165385b 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "smoke:budget-replay": "node tools/budget-replay-compat/check.mjs", "check:wire-mutations": "node tools/wire-conformance/check.mjs", "check:usage-mutations": "node tools/usage-coverage-controls/check.mjs", - "test:acceptance-tools": "node --test tools/ci/windows-headless-smoke.test.mjs tools/ci/workspace-scheduling.test.mjs tools/ci/require-native-db-diagnostics.test.mjs tools/usage-coverage-controls/outcome.test.mjs tools/usage-coverage-controls/workload.test.mjs tools/ci/require-w8-acceptance.test.mjs tools/acceptance-evidence/export.test.mjs tools/wire-conformance/provenance.test.mjs tools/causal-controls/outcome.test.mjs tools/causal-controls/process.test.mjs tools/causal-controls/provenance.test.mjs tools/causal-controls/retention.test.mjs tools/causal-controls/run.test.mjs tools/causal-controls/timeout-policy.test.mjs tools/bundle-closure/closure.test.mjs tools/cli-bootstrap/witness.test.mjs tools/ci/require-packed-cli.test.mjs tools/cli-package/archive.test.mjs tools/cli-package/binding.test.mjs tools/cli-package/process.test.mjs tools/cli-package/files.test.mjs tools/cli-package/smoke/process.test.mjs tools/cli-package/smoke/failure-diagnostic.test.mjs tools/cli-package/smoke/native-process.test.mjs tools/cli-package/smoke/native-spawn-witness.test.mjs tools/cli-package/pipeline.test.mjs tools/cli-package/consumer-lifetime.test.mjs", + "test:acceptance-tools": "node --test tools/ci/windows-headless-smoke.test.mjs tools/ci/workspace-scheduling.test.mjs tools/ci/require-native-db-diagnostics.test.mjs tools/usage-coverage-controls/outcome.test.mjs tools/usage-coverage-controls/workload.test.mjs tools/ci/require-w8-acceptance.test.mjs tools/acceptance-evidence/export.test.mjs tools/wire-conformance/provenance.test.mjs tools/causal-controls/outcome.test.mjs tools/causal-controls/process.test.mjs tools/causal-controls/provenance.test.mjs tools/causal-controls/retention.test.mjs tools/causal-controls/run.test.mjs tools/causal-controls/timeout-policy.test.mjs tools/bundle-closure/closure.test.mjs tools/cli-bootstrap/witness.test.mjs tools/ci/require-packed-cli.test.mjs tools/cli-package/archive.test.mjs tools/cli-package/binding.test.mjs tools/cli-package/process.test.mjs tools/cli-package/files.test.mjs tools/cli-package/smoke/process.test.mjs tools/cli-package/smoke/failure-diagnostic.test.mjs tools/cli-package/smoke/native-process.test.mjs tools/cli-package/smoke/runtime-stderr.test.mjs tools/cli-package/smoke/native-spawn-witness.test.mjs tools/cli-package/pipeline.test.mjs tools/cli-package/consumer-lifetime.test.mjs", "check:fs-mutations": "node tools/fs-policy-controls/check.mjs", "check:secrets-mutations": "node tools/secrets-controls/check.mjs", "smoke:cli-bootstrap": "node tools/cli-bootstrap/check.mjs", diff --git a/tools/cli-package/smoke/README.md b/tools/cli-package/smoke/README.md index 13ff60d4..763c1cef 100644 --- a/tools/cli-package/smoke/README.md +++ b/tools/cli-package/smoke/README.md @@ -95,7 +95,7 @@ is explicit native-edge fault injection, not an OS-fault claim. Step 8d adds an eighth required case through the untouched installed entry: an actual scheduled uncaught throw, an actual unhandled rejection, and a real stdout pipe closure/write. Exact root/descendant readiness precedes injection. Fatal and -output modes require empty stderr while actual native close delivery remains +output modes require no product diagnostic while actual native close delivery remains gated, then one fixed diagnostic and exit 9 after delivery. Background mode keeps both native PIDs alive, permits the fixture's ordinary successful turn, and ends with background-only exit 9. Each independent mode retains its own sibling, @@ -103,7 +103,16 @@ watchdog, actual close and exact-PID cleanup evidence. These are plain one-shot activation/ACK-order controls: interactive terminal restoration and absolute budget nonrenewal retain their separately attributed source controls. Whole Step 8d acceptance remains open. Original-TS maps/custody remain Step 9. All eight -behavior cases and 177 bootstrap controls are required. +behavior cases and 177 bootstrap controls are required. Windows bootstrap modes +require five actual target/helper creations (two fatal, one background, two output); +the other platform/cohort combinations require three. + +The exact Node 22.0.0 JSON-module ExperimentalWarning is retained in raw private +stderr and recorded separately from product diagnostics only for that runtime +and the actual owned CLI PID. The entire two-line frame must match once; unknown, +partial, duplicate, wrong-PID and wrong-runtime output remains diagnostic output +and cannot pass the JSON or pre-delivery checks. No warning is disabled in the +installed process. Narrow child-lifetime negative controls run with: diff --git a/tools/cli-package/smoke/native-process.mjs b/tools/cli-package/smoke/native-process.mjs index 690d7807..32d1a935 100644 --- a/tools/cli-package/smoke/native-process.mjs +++ b/tools/cli-package/smoke/native-process.mjs @@ -16,6 +16,7 @@ import { clearTimeout, setTimeout } from 'node:timers'; import { fileURLToPath } from 'node:url'; import { readOrdinaryFile, sha256 } from '../files.mjs'; import { ndjson, poll, startOwned } from './process.mjs'; +import { projectRuntimeStderr } from './runtime-stderr.mjs'; const fixture = (name) => fileURLToPath(new URL(name, import.meta.url)); const positivePid = (pid) => Number.isSafeInteger(pid) && pid > 0; @@ -369,7 +370,10 @@ export async function assertInstalledNativeProcesses(context, { bootstrap = fals ); if (bootstrap && mode !== 'bootstrap-background') { assert.equal( - owned.output().stderr, + projectRuntimeStderr(owned.output().stderr, { + runtime: process.version, + pid: owned.child.pid, + }).diagnostics, '', 'Fatal diagnostic preceded actual native close delivery', ); @@ -417,7 +421,12 @@ export async function assertInstalledNativeProcesses(context, { bootstrap = fals observed.some((item) => item.operation === `native-${gate}-close-delivered`), 'Actual gated close was not delivered before product completion', ); - const diagnostics = ndjson(result.stderr); + const projected = projectRuntimeStderr(result.stderr, { + runtime: process.version, + pid: owned.child.pid, + }); + outcome.runtimeWarnings = projected.runtimeWarnings; + const diagnostics = ndjson(projected.diagnostics); assert.equal(diagnostics.length, 1, 'Exactly one native diagnostic is owed'); assert.equal( diagnostics[0].code, @@ -590,7 +599,12 @@ export async function assertInstalledNativeProcesses(context, { bootstrap = fals 0, 'Native offline control attempted a provider/keychain/forbidden service', ); - assert.equal(observed.filter((call) => call.operation === 'native-owned-spawn').length, 3); + // Windows fatal/output modes each own a target plus a real helper; background owns only its target. + const expectedSpawns = bootstrap && process.platform === 'win32' ? 5 : 3; + assert.equal( + observed.filter((call) => call.operation === 'native-owned-spawn').length, + expectedSpawns, + ); return { platform: process.platform, runtime: process.version, diff --git a/tools/cli-package/smoke/runtime-stderr.mjs b/tools/cli-package/smoke/runtime-stderr.mjs new file mode 100644 index 00000000..d1e623c3 --- /dev/null +++ b/tools/cli-package/smoke/runtime-stderr.mjs @@ -0,0 +1,18 @@ +/** Keep runtime warnings separate from product diagnostics without suppressing foreign stderr. */ +export function projectRuntimeStderr(stderr, { runtime, pid }) { + const warning = `(node:${pid}) ExperimentalWarning: Importing JSON modules is an experimental feature and might change at any time\n(Use \`node --trace-warnings ...\` to show where the warning was created)\n`; + const first = stderr.indexOf(warning); + if ( + runtime !== 'v22.0.0' || + !Number.isSafeInteger(pid) || + pid <= 0 || + first < 0 || + (first > 0 && stderr[first - 1] !== '\n') || + stderr.indexOf(warning, first + warning.length) !== -1 + ) + return { diagnostics: stderr, runtimeWarnings: [] }; + return { + diagnostics: stderr.slice(0, first) + stderr.slice(first + warning.length), + runtimeWarnings: ['node-22.0.0-experimental-json-modules'], + }; +} diff --git a/tools/cli-package/smoke/runtime-stderr.test.mjs b/tools/cli-package/smoke/runtime-stderr.test.mjs new file mode 100644 index 00000000..3efa1ece --- /dev/null +++ b/tools/cli-package/smoke/runtime-stderr.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { projectRuntimeStderr } from './runtime-stderr.mjs'; + +const warning = + '(node:123) ExperimentalWarning: Importing JSON modules is an experimental feature and might change at any time\n' + + '(Use `node --trace-warnings ...` to show where the warning was created)\n'; +const identity = { runtime: 'v22.0.0', pid: 123 }; +const diagnostic = '{"code":"cli_internal","message":"A background operation failed."}\n'; + +test('only the exact owned minimum-runtime warning is separate from product diagnostics', () => { + for (const text of [warning, warning + diagnostic, diagnostic + warning]) { + const projected = projectRuntimeStderr(text, identity); + assert.equal(projected.diagnostics, text.replace(warning, '')); + assert.deepEqual(projected.runtimeWarnings, ['node-22.0.0-experimental-json-modules']); + } +}); + +test('runtime identity, PID, frame boundaries and one-warning limit remain mandatory', () => { + for (const [text, owner] of [ + [warning, { ...identity, runtime: 'v22.23.1' }], + [warning, { ...identity, pid: 124 }], + [warning, { ...identity, pid: -1 }], + [warning + warning, identity], + ['prefix' + warning, identity], + [warning.replace('experimental feature', 'different warning'), identity], + [warning.replace('node --trace-warnings', 'other --trace-warnings'), identity], + [warning.slice(0, -1), identity], + ['Unhandled rejection\n' + diagnostic, identity], + ]) { + assert.deepEqual(projectRuntimeStderr(text, owner), { + diagnostics: text, + runtimeWarnings: [], + }); + } +}); + +test('unknown runtime output survives alongside the one recognized warning', () => { + const foreign = 'Error: unrelated runtime failure\n'; + const projected = projectRuntimeStderr(warning + foreign + diagnostic, identity); + assert.equal(projected.diagnostics, foreign + diagnostic); + assert.throws(() => JSON.parse(projected.diagnostics)); +}); diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index 458f8d0f..9ed8757e 100644 --- a/tools/secrets-controls/README.md +++ b/tools/secrets-controls/README.md @@ -109,7 +109,7 @@ transliteration or uniqueness of generated IDs. Two independent CLI notice controls remove the successful standalone command and live `/export` notice sites. Each requires named human and JSON witnesses in -the complete 15-case command-export or 127-case chat file. The live control also +the complete 15-case command-export or 133-case chat file. The live control also requires its actual interactive transcript/Ink-frame witness. A third control restores raw stderr delivery only for the live notice: human/JSON positive cases still pass, while the interactive witness must fail with its named assertion marker. diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index b581a5da..7c2085a9 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -344,7 +344,7 @@ export const mutations = [ control( 'run-default-secret-reader', 'apps/cli/src/commands/run.ts', - ' const raw = await (deps.readSecretInput?.() ?? readSecretFromStdin(RUN_SECRET_STDIN_CONTEXT));', + ' const raw = await read();', " const raw = await (deps.readSecretInput?.() ?? Promise.resolve(''));", ['apps/cli/src/commands/run.test.ts'], 80, From ec8ba368ae0c5ad8254d5afc5c2c53432384320b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 03:05:35 +0300 Subject: [PATCH 113/222] fix(cli): retain partial input acquisition through native close Keep plain/JSON readline continuations and consumed secret pipes owned until actual cleanup. Refuse later catalog work after synchronous owner stop, and preserve the original finite-exit deadline when native cleanup is uncertain. Use injected fatal-exit ports in intentional refusal fixtures, own the concurrency outbox, and bind the 446-case usage inventory to actual collection. Record the complete nonclean reviews and source-attributed author evidence; independent clean reviews and current CI/platform acceptance remain open. Refs: ADR-0110, ADR-0103 Co-Authored-By: Codex --- apps/cli/src/chat/session-readline.test.ts | 198 ++++++++++++++ apps/cli/src/chat/session-readline.ts | 193 ++++++++++++++ .../commands/chat-readline-custody.test.ts | 113 ++++++++ apps/cli/src/commands/chat.ts | 249 +++++++++--------- apps/cli/src/commands/fs-invocation.test.ts | 20 +- .../engine/model-refresh-admission.test.ts | 108 ++++++++ apps/cli/src/engine/model-refresh.ts | 14 +- apps/cli/src/harness/concurrency.e2e.test.ts | 2 +- .../secrets/fixtures/native-close-child.ts | 94 +++++++ apps/cli/src/secrets/native-close.e2e.test.ts | 95 +++++++ .../src/secrets/read-secret-lifetime.test.ts | 121 +++++++++ apps/cli/src/secrets/read-secret.ts | 83 ++++-- docs/reference/cli/commands.md | 13 +- docs/roadmap/current.md | 5 +- .../phase-2.5.5-hardening-and-remediation.md | 83 ++++++ tools/usage-coverage-controls/README.md | 2 +- tools/usage-coverage-controls/workload.mjs | 2 +- .../usage-coverage-controls/workload.test.mjs | 41 ++- 18 files changed, 1284 insertions(+), 152 deletions(-) create mode 100644 apps/cli/src/chat/session-readline.test.ts create mode 100644 apps/cli/src/chat/session-readline.ts create mode 100644 apps/cli/src/commands/chat-readline-custody.test.ts create mode 100644 apps/cli/src/engine/model-refresh-admission.test.ts create mode 100644 apps/cli/src/secrets/fixtures/native-close-child.ts create mode 100644 apps/cli/src/secrets/native-close.e2e.test.ts diff --git a/apps/cli/src/chat/session-readline.test.ts b/apps/cli/src/chat/session-readline.test.ts new file mode 100644 index 00000000..004b61ad --- /dev/null +++ b/apps/cli/src/chat/session-readline.test.ts @@ -0,0 +1,198 @@ +import { PassThrough } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { SessionWorkOwner } from './session-work-owner.js'; +import { withSessionReadline } from './session-readline.js'; + +const turn = () => new Promise((resolve) => setImmediate(resolve)); +function deferred() { + let resolve: (value: T) => void = () => {}; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} +function fixture() { + const f = invocationFixture(); + const work = new SessionWorkOwner(f.invocation, 'chat', { + cancel: () => {}, + cleanup: () => Promise.resolve(), + }); + return { ...f, work, input: new PassThrough() }; +} + +describe('captured headless input lifetime', () => { + it('joins native construction reentry before retiring input and never enters a stopped body', async () => { + const f = fixture(); + const body = vi.fn(() => Promise.resolve()); + const unsubscribe = vi.fn(); + f.input.on('newListener', (event) => { + if (event === 'end') f.work.stop(); + }); + try { + const result = await f.work + .enter(() => withSessionReadline(f.input, f.work, () => unsubscribe, body)) + .then( + () => false, + (cause: unknown) => f.work.isStoppedFailure(cause), + ); + expect(result).toBe(true); + await f.work.close(); + expect(body).not.toHaveBeenCalled(); + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(f.input.listeners('data')).toEqual([]); + expect(f.input.listeners('error')).toEqual([]); + expect(f.input.listenerCount('end')).toBe(0); + expect(f.input.isPaused()).toBe(true); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.exitCode()).toBe(0); + } finally { + f.input.removeAllListeners(); + f.input.destroy(); + } + }); + + it('retains uncertain native removal through the original fatal deadline without granting host cleanup', async () => { + const f = invocationFixture(); + const cleanup = vi.fn(() => Promise.resolve()); + const work = new SessionWorkOwner(f.invocation, 'chat', { cancel: () => {}, cleanup }); + const input = new PassThrough(); + const nativeRemove = input.removeListener.bind(input); + input.removeListener = (event, listener) => { + if (event === 'data') throw new Error('PRIVATE_NATIVE_REMOVAL_REFUSAL'); + return nativeRemove(event, listener); + }; + input.on('newListener', (event) => { + if (event === 'end') throw new Error('PRIVATE_PARTIAL_CONSTRUCTOR'); + }); + const unsubscribe = vi.fn(); + let settled = false; + void work + .enter(() => + withSessionReadline( + input, + work, + () => unsubscribe, + () => Promise.resolve(), + ), + ) + .then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + try { + await turn(); + expect(input.listenerCount('data')).toBe(1); + expect(input.listenerCount('error')).toBe(0); // Independent removal is still attempted. + expect(input.isPaused()).toBe(true); + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(cleanup).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(2); + f.advance(3000); + expect(await f.invocation.finiteExit.exited).toBe(9); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(settled, 'HEADLESS_INPUT_REFUSAL_IS_NOT_RETIREMENT').toBe(false); + expect(cleanup).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(2); + } finally { + // Test-owned resource recovery does not resolve the product's refused retirement promise. + input.removeListener = nativeRemove; + input.removeAllListeners(); + input.destroy(); + } + }); + + it('holds actual driver continuation through stop, then removes late callbacks and pauses input', async () => { + const f = fixture(); + const release = deferred(); + const entered = deferred(); + const late = vi.fn(); + const unsubscribe = vi.fn(); + const pending = f.work.enter(() => + withSessionReadline( + f.input, + f.work, + () => unsubscribe, + async (reader) => { + entered.resolve(); + await release.promise; + reader.resume(); // The actual entered continuation resumes native input after stop. + f.input.on('data', late); + return 'finished'; + }, + ), + ); + try { + await entered.promise; + f.invocation.finiteExit.signal(15); + await turn(); + expect(f.registry.activeOwners, 'HEADLESS_INPUT_RETAINS_ENTERED_CONTINUATION').toBe(2); + expect(unsubscribe).not.toHaveBeenCalled(); + release.resolve(); + expect(await pending).toBe('finished'); + await f.work.close(); + expect(f.input.listeners('data')).toEqual([]); + expect(f.input.isPaused()).toBe(true); + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + } finally { + release.resolve(); + await pending.catch(() => {}); + await f.work.close(); + f.input.removeAllListeners(); + f.input.destroy(); + } + }); + + it('releases independently owed input after a subscription cleanup fault without replacing the body fault', async () => { + const f = fixture(); + const primary = new Error('PRIVATE_DRIVER_FAILURE'); + const cleanup = new Error('PRIVATE_UNSUBSCRIBE_FAILURE'); + try { + await expect( + f.work.enter(() => + withSessionReadline( + f.input, + f.work, + () => () => { + throw cleanup; + }, + () => Promise.reject(primary), + ), + ), + ).rejects.toBe(primary); + await f.work.close(); + expect(f.input.listeners('data')).toEqual([]); + expect(f.input.listeners('error')).toEqual([]); + expect(f.input.isPaused()).toBe(true); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.exitCode()).toBe(9); + } finally { + f.input.removeAllListeners(); + f.input.destroy(); + } + }); + + it('does not wait for a nonexistent constructor when an ownerless subscription refuses', async () => { + const input = new PassThrough(); + const primary = new Error('PRIVATE_SUBSCRIPTION_FAILURE'); + const body = vi.fn(() => Promise.resolve()); + await expect( + withSessionReadline( + input, + undefined, + () => { + throw primary; + }, + body, + ), + ).rejects.toBe(primary); + expect(body).not.toHaveBeenCalled(); + expect(input.listenerCount('data')).toBe(0); + input.destroy(); + }); +}); diff --git a/apps/cli/src/chat/session-readline.ts b/apps/cli/src/chat/session-readline.ts new file mode 100644 index 00000000..4c121a19 --- /dev/null +++ b/apps/cli/src/chat/session-readline.ts @@ -0,0 +1,193 @@ +import { createInterface, type Interface } from 'node:readline'; +import { CliError } from '../process/cli-error.js'; +import type { SessionWorkOwner } from './session-work-owner.js'; +import { SessionTerminalOwner } from './session-terminal-owner.js'; + +/** Native terminal:false readline acquires input callbacks even when its constructor throws. */ +class CapturedReadline { + readonly #input: NodeJS.ReadableStream; + readonly #work: SessionWorkOwner | undefined; + readonly #remove: NodeJS.ReadableStream['removeListener']; + readonly #listeners: (event: string | symbol) => readonly unknown[]; + readonly #events: () => Array; + readonly #pause: () => void; + readonly #owner: SessionTerminalOwner | undefined; + readonly #baseline = new Map>(); + readonly #introduced = new Map>(); + #readline: Interface | undefined; + #nativeClose: (() => void) | undefined; + #sealed = false; + #closed = false; + #failure: { cause: unknown } | undefined; + #started = false; + #constructed = false; + #finishConstructing: () => void = () => {}; + readonly #construction = new Promise((resolve) => { + this.#finishConstructing = resolve; + }); + #closing: Promise | undefined; + + constructor(input: NodeJS.ReadableStream, work: SessionWorkOwner | undefined) { + this.#input = input; + this.#work = work; + this.#owner = + work === undefined + ? undefined + : new SessionTerminalOwner(work.invocation, work.assertAdmitting, work.failure); + this.#remove = input.removeListener.bind(input); + this.#listeners = input.listeners.bind(input); + this.#events = input.eventNames.bind(input); + this.#pause = input.pause.bind(input); + } + + readonly #added = (event: string | symbol, listener: unknown): void => { + const callbacks = this.#introduced.get(event) ?? new Set(); + callbacks.add(listener); + this.#introduced.set(event, callbacks); + }; + + #remember(cause: unknown): void { + this.#failure ??= { cause }; + this.#work?.failure(cause); + } + + readonly stop = (): void => { + this.#sealed = true; + if (this.#nativeClose === undefined || this.#closed) return; + try { + this.#nativeClose(); + this.#closed = true; // Actual synchronous native close returned; a request alone sets no ACK. + } catch (cause) { + this.#remember(cause); + } + }; + + open(): Interface { + const construct = (): Interface => { + this.#started = true; + try { + for (const event of this.#events()) { + const counts = new Map(); + for (const listener of this.#listeners(event)) + counts.set(listener, (counts.get(listener) ?? 0) + 1); + this.#baseline.set(event, counts); + } + this.#input.on('newListener', this.#added); + this.#readline = createInterface({ input: this.#input, terminal: false }); + this.#nativeClose = this.#readline.close.bind(this.#readline); + if (this.#sealed) this.stop(); + return this.#readline; + } finally { + this.#constructed = true; + this.#finishConstructing(); + } + }; + if (this.#owner === undefined) return construct(); + return this.#owner.acquire((acquisition) => { + // Fixed native construction profile: no output/raw-mode acquisition, only this captured input. + // Declare its cleanup BEFORE observer/constructor/iterator entry, including partial creation. + acquisition.adopt({ seal: this.stop, release: () => this.#release() }); + return construct(); + }); + } + + enter(body: () => Promise): Promise { + return this.#owner?.enter(body) ?? body(); + } + + async #release(): Promise { + if (!this.#started) return; + if (!this.#constructed) await this.#construction; + const attempt = (body: () => void): void => { + try { + body(); + } catch (cause) { + this.#remember(cause); + } + }; + this.stop(); + // Preserve prior occurrences even when native removal reenters and consumes a sibling. + for (let pass = 0; pass < 2; pass++) { + for (const [event, listeners] of [...this.#introduced]) { + for (const listener of [...listeners]) { + const baseline = this.#baseline.get(event)?.get(listener) ?? 0; + const surplus = + this.#listeners(event).filter((value) => value === listener).length - baseline; + for (let remaining = surplus; remaining > 0; remaining--) { + if (this.#listeners(event).filter((value) => value === listener).length <= baseline) + break; + attempt(() => { + Reflect.apply(this.#remove, this.#input, [event, listener]); + }); + } + } + } + attempt(() => { + this.#pause(); + }); + } + attempt(() => { + this.#remove('newListener', this.#added); + }); + attempt(() => { + for (const event of this.#events()) { + const counts = new Map(); + for (const listener of this.#listeners(event)) + counts.set(listener, (counts.get(listener) ?? 0) + 1); + if ( + [...counts].some( + ([listener, count]) => count > (this.#baseline.get(event)?.get(listener) ?? 0), + ) + ) + throw new CliError('cli_internal', ''); + } + }); + if (this.#failure !== undefined) throw this.#failure.cause; + } + + close(): Promise { + if (this.#closing !== undefined) return this.#closing; + this.#closing = Promise.resolve().then(() => this.#owner?.close() ?? this.#release()); + return this.#closing; + } +} + +/** Cover subscription and native construction/iterator entry with one independently owed input release. */ +export async function withSessionReadline( + input: NodeJS.ReadableStream, + work: SessionWorkOwner | undefined, + subscribe: () => () => void, + body: (readline: Interface, stop: () => void) => Promise, +): Promise { + const captured = new CapturedReadline(input, work); + let unsubscribe: () => void = () => {}; + let result: { value: T } | undefined; + let primary: { cause: unknown } | undefined; + let secondary: { cause: unknown } | undefined; + try { + unsubscribe = subscribe(); + const readline = captured.open(); + // Iterator/driver continuations can resume input. Final release follows their actual settlement. + result = { value: await captured.enter(() => body(readline, captured.stop)) }; + } catch (cause) { + primary = { cause }; + work?.failure(cause); + } finally { + try { + unsubscribe(); + } catch (cause) { + secondary = { cause }; + work?.failure(cause); + } + try { + await captured.close(); + } catch (cause) { + secondary ??= { cause }; + work?.failure(cause); + } + } + if (primary !== undefined) throw primary.cause; + if (secondary !== undefined) throw secondary.cause; + if (result === undefined) throw new CliError('cli_internal', ''); + return result.value; +} diff --git a/apps/cli/src/commands/chat-readline-custody.test.ts b/apps/cli/src/commands/chat-readline-custody.test.ts new file mode 100644 index 00000000..aceefd24 --- /dev/null +++ b/apps/cli/src/commands/chat-readline-custody.test.ts @@ -0,0 +1,113 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Interface } from 'node:readline'; +import { PassThrough } from 'node:stream'; +import { createClient, createSessionStore, runMigrations } from '@relavium/db'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as config from '../config/load.js'; +import { scriptedResolver } from '../chat/test-support.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import { chatCommand, driveJson, drivePlain } from './chat.js'; + +vi.mock('../config/load.js', { spy: true }); +const actualConfig = await vi.importActual('../config/load.js'); +afterEach(() => vi.restoreAllMocks()); + +describe('actual headless chat partial readline custody', () => { + for (const mode of ['plain', 'json'] as const) { + it.each(['error', 'data', 'end', 'iterator'] as const)( + `${mode} releases captured callbacks and session subscription after %s acquisition fails`, + async (stage) => { + const directory = mkdtempSync(join(tmpdir(), 'relavium-readline-custody-')); + const client = createClient(':memory:'); + runMigrations(client.db); + vi.spyOn(config, 'loadResolvedConfig').mockImplementation((options) => + actualConfig.loadResolvedConfig({ ...options, home: directory }), + ); + vi.spyOn(config, 'resolveHomeDir').mockReturnValue(directory); + const f = invocationFixture(); + const input = new PassThrough(); + const primary = new Error('PRIVATE_PARTIAL_READLINE_FAILURE'); + const priorData = vi.fn(); + const priorError = vi.fn(); + input.on('data', priorData); + input.on('error', priorError); + input.on('newListener', (event) => { + if (event === stage) throw primary; + }); + if (stage === 'iterator') + vi.spyOn(Interface.prototype, Symbol.asyncIterator).mockImplementation(() => { + throw primary; + }); + const close = vi.fn(); + let unsubscribed = 0; + const captured = captureIo(); + try { + await expect( + chatCommand( + { agent: undefined }, + { + invocation: f.invocation, + io: { ...captured.io, stdin: input }, + global: { + cwd: directory, + json: mode === 'json', + color: false, + configPath: undefined, + verbosity: 'normal', + }, + providers: scriptedResolver([]), + openSessionStore: () => ({ + db: client.db, + store: createSessionStore(client.db), + close, + }), + drive: (ctx) => + (mode === 'plain' ? drivePlain : driveJson)({ + ...ctx, + handle: { + ...ctx.handle, + subscribe: (listener) => { + const unsubscribe = ctx.handle.subscribe(listener); + return () => { + unsubscribed++; + unsubscribe(); + }; + }, + }, + }), + lifecycle: { + onInterrupt: () => () => {}, + onTerminationSignal: () => () => {}, + onProcessExit: () => () => {}, + setRawMode: () => {}, + exit: () => {}, + }, + now: () => 0, + uuid: () => 'synthetic-readline-session', + }, + ), + ).rejects.toBe(primary); + expect(input.listeners('data'), 'HEADLESS_READLINE_PARTIAL_INPUT_RELEASE').toEqual([ + priorData, + ]); + expect(input.listeners('error')).toEqual([priorError]); + expect(input.listenerCount('end')).toBe(0); + expect(input.isPaused()).toBe(true); + expect(unsubscribed, 'HEADLESS_READLINE_PARTIAL_SUBSCRIPTION_RELEASE').toBe(1); + expect(close).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.exitCode()).toBe(9); + expect(captured.out() + captured.err()).not.toContain(primary.message); + } finally { + input.removeAllListeners(); + input.destroy(); + client.sqlite.close(); + rmSync(directory, { recursive: true, force: true }); + } + }, + ); + } +}); diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index d2ec7c8e..a11f5b5d 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -11,7 +11,7 @@ import { import { randomUUID } from 'node:crypto'; import { realpathSync } from 'node:fs'; import { isAbsolute } from 'node:path'; -import { createInterface } from 'node:readline'; +import { withSessionReadline } from '../chat/session-readline.js'; import { DEFAULT_SESSION_MAX_TURNS, @@ -3016,67 +3016,72 @@ export async function runReplLoop( * TTY is attached (a pipe / CI without `--json`, which is 2.Q); the TTY ink driver overrides `deps.drive`. */ export async function drivePlain(ctx: ChatDriveContext): Promise { - const unsubscribe = ctx.handle.subscribe( - makePlainPrinter(ctx.io, true, ctx.store.getSnapshot().state.memoryPolicy), - ); - const rl = createInterface({ input: ctx.io.stdin, terminal: false }); - const lines = rl[Symbol.asyncIterator](); // buffer piped lines before any awaited activation can yield. - // Ctrl-C (cooked mode here, unlike the raw-mode ink path) closes the input so the loop ends and the - // command's finally runs cancelOnce() + close() — the session is marked 'ended', never left orphaned 'active'. - let active = true; - const isActive = (): boolean => active && !ctx.shouldStop(); - let interrupt: () => void = () => undefined; - const interrupted = new Promise((resolve) => { - interrupt = resolve; - }); - const stopInput = (): void => { - active = false; - interrupt(); - rl.close(); - }; - const onSigint = (): void => { - if (!active) { - ctx.workOwner?.invocation.finiteExit.cooperativeCancel(EXIT_CODES.chatEnded); - return; - } - stopInput(); - ctx.finalize?.(); - }; - let removeInterrupt = (): void => undefined; - try { - removeInterrupt = - ctx.subscribeInterrupt === undefined - ? (() => { - process.once('SIGINT', onSigint); - return () => { - process.removeListener('SIGINT', onSigint); - }; - })() - : ctx.subscribeInterrupt(onSigint); - ctx.workOwner?.signal.addEventListener('abort', stopInput, { once: true }); - if (ctx.workOwner?.signal.aborted === true) stopInput(); - ctx.io.writeOut(`${ctx.intro ?? 'relavium chat — type a message, or /exit to quit.'}\n`); - ctx.workOwner?.assertAdmitting(); - ctx.startSession(); // subscription wired above ⇒ session:started is observed (fresh), or a no-op (resume) - // A full output pipe must not hold cancellation/teardown hostage. The race observes late - // delivery failures; the permanently disarmed activity predicate prevents a late sweep. - if (isActive()) await Promise.race([ctx.onActivated?.(isActive), interrupted]); - if (isActive()) { - for await (const line of lines) { - if (!isActive()) break; - await ctx.processLine(line); - if (ctx.shouldStop()) break; + return withSessionReadline( + ctx.io.stdin, + ctx.workOwner, + () => + ctx.handle.subscribe( + makePlainPrinter(ctx.io, true, ctx.store.getSnapshot().state.memoryPolicy), + ), + async (rl, closeInput) => { + const lines = rl[Symbol.asyncIterator](); // buffer piped lines before any awaited activation can yield. + // Ctrl-C (cooked mode here, unlike the raw-mode ink path) closes the input so the loop ends and the + // command's finally runs cancelOnce() + close() — the session is marked 'ended', never left orphaned 'active'. + let active = true; + const isActive = (): boolean => active && !ctx.shouldStop(); + let interrupt: () => void = () => undefined; + const interrupted = new Promise((resolve) => { + interrupt = resolve; + }); + const stopInput = (): void => { + active = false; + interrupt(); + closeInput(); + }; + const onSigint = (): void => { + if (!active) { + ctx.workOwner?.invocation.finiteExit.cooperativeCancel(EXIT_CODES.chatEnded); + return; + } + stopInput(); + ctx.finalize?.(); + }; + let removeInterrupt = (): void => undefined; + try { + removeInterrupt = + ctx.subscribeInterrupt === undefined + ? (() => { + process.once('SIGINT', onSigint); + return () => { + process.removeListener('SIGINT', onSigint); + }; + })() + : ctx.subscribeInterrupt(onSigint); + ctx.workOwner?.signal.addEventListener('abort', stopInput, { once: true }); + if (ctx.workOwner?.signal.aborted === true) stopInput(); + ctx.io.writeOut(`${ctx.intro ?? 'relavium chat — type a message, or /exit to quit.'}\n`); + ctx.workOwner?.assertAdmitting(); + ctx.startSession(); // subscription wired above ⇒ session:started is observed (fresh), or a no-op (resume) + // A full output pipe must not hold cancellation/teardown hostage. The race observes late + // delivery failures; the permanently disarmed activity predicate prevents a late sweep. + if (isActive()) await Promise.race([ctx.onActivated?.(isActive), interrupted]); + if (isActive()) { + for await (const line of lines) { + if (!isActive()) break; + await ctx.processLine(line); + if (ctx.shouldStop()) break; + } + } + } finally { + active = false; + removeInterrupt(); + ctx.workOwner?.signal.removeEventListener('abort', stopInput); + closeInput(); } - } - } finally { - active = false; - removeInterrupt(); - ctx.workOwner?.signal.removeEventListener('abort', stopInput); - rl.close(); - unsubscribe(); - } - // A plain (non-TTY) session is not interactive, so `/clear` is gated off — `stopReason()` is always `'exit'`. - return { kind: ctx.stopReason() }; + // A plain (non-TTY) session is not interactive, so `/clear` is gated off — `stopReason()` is always `'exit'`. + return { kind: ctx.stopReason() }; + }, + ); } /** @@ -3088,67 +3093,69 @@ export async function drivePlain(ctx: ChatDriveContext): Promise { - const unsubscribe = ctx.handle.subscribe((event) => - ctx.io.writeOut(`${stringifyJsonLine(event)}\n`), - ); - const rl = createInterface({ input: ctx.io.stdin, terminal: false }); - const lines = rl[Symbol.asyncIterator](); - let active = true; - const isActive = (): boolean => active && !ctx.shouldStop(); - let interrupt: () => void = () => undefined; - const interrupted = new Promise((resolve) => { - interrupt = resolve; - }); - const stopInput = (): void => { - active = false; - interrupt(); - rl.close(); - }; - const onSigint = (): void => { - if (!active) { - ctx.workOwner?.invocation.finiteExit.cooperativeCancel(EXIT_CODES.chatEnded); - return; - } - stopInput(); - ctx.finalize?.(); - }; - let removeInterrupt = (): void => undefined; - try { - removeInterrupt = - ctx.subscribeInterrupt === undefined - ? (() => { - process.once('SIGINT', onSigint); - return () => { - process.removeListener('SIGINT', onSigint); - }; - })() - : ctx.subscribeInterrupt(onSigint); - ctx.workOwner?.signal.addEventListener('abort', stopInput, { once: true }); - if (ctx.workOwner?.signal.aborted === true) stopInput(); - ctx.workOwner?.assertAdmitting(); - ctx.startSession(); // subscription wired above ⇒ the synchronous session:started is the first NDJSON line - // Keep terminal finalization and resource teardown independent of a blocked diagnostic sink. - // Late activation settlement stays observed and cannot recover this driver's activity. - if (isActive()) await Promise.race([ctx.onActivated?.(isActive), interrupted]); - if (isActive()) { - for await (const line of lines) { - if (!isActive()) break; - await ctx.processLine(line); - if (ctx.shouldStop()) break; + return withSessionReadline( + ctx.io.stdin, + ctx.workOwner, + () => ctx.handle.subscribe((event) => ctx.io.writeOut(`${stringifyJsonLine(event)}\n`)), + async (rl, closeInput) => { + const lines = rl[Symbol.asyncIterator](); + let active = true; + const isActive = (): boolean => active && !ctx.shouldStop(); + let interrupt: () => void = () => undefined; + const interrupted = new Promise((resolve) => { + interrupt = resolve; + }); + const stopInput = (): void => { + active = false; + interrupt(); + closeInput(); + }; + const onSigint = (): void => { + if (!active) { + ctx.workOwner?.invocation.finiteExit.cooperativeCancel(EXIT_CODES.chatEnded); + return; + } + stopInput(); + ctx.finalize?.(); + }; + let removeInterrupt = (): void => undefined; + try { + removeInterrupt = + ctx.subscribeInterrupt === undefined + ? (() => { + process.once('SIGINT', onSigint); + return () => { + process.removeListener('SIGINT', onSigint); + }; + })() + : ctx.subscribeInterrupt(onSigint); + ctx.workOwner?.signal.addEventListener('abort', stopInput, { once: true }); + if (ctx.workOwner?.signal.aborted === true) stopInput(); + ctx.workOwner?.assertAdmitting(); + ctx.startSession(); // subscription wired above ⇒ the synchronous session:started is the first NDJSON line + // Keep terminal finalization and resource teardown independent of a blocked diagnostic sink. + // Late activation settlement stays observed and cannot recover this driver's activity. + if (isActive()) await Promise.race([ctx.onActivated?.(isActive), interrupted]); + if (isActive()) { + for await (const line of lines) { + if (!isActive()) break; + await ctx.processLine(line); + if (ctx.shouldStop()) break; + } + } + } finally { + active = false; + removeInterrupt(); + ctx.workOwner?.signal.removeEventListener('abort', stopInput); + closeInput(); + // Flush session:cancelled BEFORE unsubscribing, so the NDJSON stream includes its sole terminal event. + ctx.finalize?.(); } - } - } finally { - active = false; - removeInterrupt(); - ctx.workOwner?.signal.removeEventListener('abort', stopInput); - rl.close(); - // Flush session:cancelled BEFORE unsubscribing, so the NDJSON stream includes its sole terminal event. - ctx.finalize?.(); - unsubscribe(); - } - // `--json` is a machine stream, not interactive, so `/clear` is gated off — `stopReason()` is always `'exit'` - // (one stdout stream stays one session lifecycle, ADR-0049). - return { kind: ctx.stopReason() }; + // `--json` is a machine stream, not interactive, so `/clear` is gated off — `stopReason()` is always `'exit'` + // (one stdout stream stays one session lifecycle, ADR-0049). + return { kind: ctx.stopReason() }; + }, + ); } /** diff --git a/apps/cli/src/commands/fs-invocation.test.ts b/apps/cli/src/commands/fs-invocation.test.ts index e6b6e9a6..056f56b8 100644 --- a/apps/cli/src/commands/fs-invocation.test.ts +++ b/apps/cli/src/commands/fs-invocation.test.ts @@ -26,6 +26,7 @@ import { scriptedResolver } from '../chat/test-support.js'; import { openSessionStore } from '../history/session-open.js'; import type { GlobalOptions } from '../process/options.js'; import { run } from '../run.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; import { captureIo } from '../test-support.js'; import { chatCommand, chatResumeCommand, type ChatDriver } from './chat.js'; import { runCommand } from './run.js'; @@ -159,6 +160,7 @@ describe('real argv → Commander → dispatcher admission (synthetic key bounda 'bounded repeated grants with no config reach the authorised %j composition boundary', async ({ surface }) => { const captured = captureIo(); + const f = invocationFixture(); const code = await run( argv( '--allow-fs-root', @@ -174,6 +176,8 @@ describe('real argv → Commander → dispatcher admission (synthetic key bounda stdinIsTty: surface[0] !== 'agent', stdoutIsTty: surface[0] !== 'agent', }, + undefined, + f.invocation, ); // The injected key boundary deliberately throws: this certifies routing/admission, not a live provider turn. expect(code).toBe(9); @@ -181,6 +185,10 @@ describe('real argv → Commander → dispatcher admission (synthetic key bounda expect(captured.err()).not.toContain('synthetic composition boundary reached'); expect(effects.keys, 'FS_CAUSAL_INVOCATION_FORWARD').toBe(1); expect(effects.history).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); + f.advance(3000); + expect(await f.invocation.finiteExit.exited).toBe(9); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); }, ); @@ -399,9 +407,19 @@ it('revalidates a revoked policy before binding an agent or connecting MCP durin it('forwards real argv grants into a project selector before composition', async () => { config(workspace, `[chat]\nfs_scope="project"\nextra_roots=[${JSON.stringify(outside)}]\n`); const captured = captureIo(); - const code = await run(argv('chat', '--cwd', workspace, '--allow-fs-root', outside), captured.io); + const f = invocationFixture(); + const code = await run( + argv('chat', '--cwd', workspace, '--allow-fs-root', outside), + captured.io, + undefined, + f.invocation, + ); expect(code, 'FS_CAUSAL_INVOCATION_FORWARD').toBe(9); expect(captured.err()).not.toContain('synthetic composition boundary reached'); expect(effects.keys, 'FS_CAUSAL_INVOCATION_FORWARD').toBe(1); expect(effects.history).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); + f.advance(3000); + expect(await f.invocation.finiteExit.exited).toBe(9); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); }); diff --git a/apps/cli/src/engine/model-refresh-admission.test.ts b/apps/cli/src/engine/model-refresh-admission.test.ts new file mode 100644 index 00000000..08485fe5 --- /dev/null +++ b/apps/cli/src/engine/model-refresh-admission.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CliError } from '../process/cli-error.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { InvocationWorkOwner } from '../process/invocation-work-owner.js'; +import { createModelRefreshService } from './model-refresh.js'; +import { KNOWN_PROVIDERS } from './providers.js'; + +function fixture(fatal: boolean, clockFault = false) { + const f = invocationFixture(); + const owner = new InvocationWorkOwner( + f.invocation, + { kind: 'command' }, + { + cancel: () => {}, + cleanup: () => Promise.resolve(), + }, + ); + const entered: Array<{ kind: string; stopped: boolean }> = []; + const keyFor = vi.fn((id: string) => { + entered.push({ kind: `key:${id}`, stopped: owner.signal.aborted }); + if (id === 'anthropic' && !clockFault) + throw fatal ? new Error('PRIVATE_KEY_FAULT') : new CliError('invalid_invocation', 'No key.'); + return 'synthetic-unused'; + }); + const now = vi.fn(() => { + entered.push({ kind: 'clock', stopped: owner.signal.aborted }); + if (clockFault) throw new Error('PRIVATE_CLOCK_FAULT'); + return 0; + }); + const get = vi.fn((id: string) => { + entered.push({ kind: `store:${id}`, stopped: owner.signal.aborted }); + return { id }; + }); + const resolveProvider = vi.fn(() => undefined); + const service = createModelRefreshService({ + assertAdmitting: owner.assertAdmitting, + onHostFailure: owner.failure, + knownProviderIds: ['anthropic', 'openai'], + knownProviders: KNOWN_PROVIDERS, + keyFor, + now, + resolveProvider, + providerStore: { get, upsert: () => ({ id: 'synthetic' }) }, + catalogStore: { + providerRefreshedAt: () => undefined, + replaceProviderModels: () => ({ added: 0, updated: 0, deactivated: 0 }), + }, + }); + return { ...f, owner, service, entered, keyFor, now, get, resolveProvider }; +} + +describe('catalog refresh original-owner admission', () => { + it.each(['refresh', 'refreshIfStale'] as const)( + '%s starts no next key/store/clock work after a fatal key port stops its actual owner', + async (method) => { + const f = fixture(true); + try { + const outcome = await f.service[method]({ signal: f.owner.signal }).then( + () => ({ stopped: false }), + (cause: unknown) => ({ stopped: f.owner.isStoppedFailure(cause) }), + ); + expect(outcome, 'CATALOG_FATAL_KEY_STOPS_CANDIDATE_ADMISSION').toEqual({ stopped: true }); + expect(f.keyFor.mock.calls.map(([id]) => id)).toEqual(['anthropic']); + expect(f.entered.some((event) => event.stopped)).toBe(false); + expect(f.get).not.toHaveBeenCalled(); + expect(f.resolveProvider).not.toHaveBeenCalled(); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.budget.deadline()).toBe(3000); + } finally { + await f.owner.close(); + } + }, + ); + + it.each(['refresh', 'refreshIfStale'] as const)( + '%s preserves ordinary typed missing-key isolation while the owner remains admitted', + async (method) => { + const f = fixture(false); + try { + const report = await f.service[method]({ signal: f.owner.signal }); + expect(report?.providers).toContainEqual({ + provider: 'openai', + status: 'skipped-unsupported', + }); + expect(f.keyFor.mock.calls.some(([id]) => id === 'openai')).toBe(true); + expect(f.entered.every((event) => !event.stopped)).toBe(true); + expect(f.owner.signal.aborted).toBe(false); + expect(f.registry.observations.exitCode()).toBe(0); + } finally { + await f.owner.close(); + } + }, + ); + + it('admits sibling attempt clocks before entry, preserving already selected keys without starting post-stop work', async () => { + const f = fixture(true, true); + try { + await f.service.refresh({ signal: f.owner.signal }); + expect(f.keyFor).toHaveBeenCalledTimes(2); // Both keys were selected before the first attempt fault. + expect(f.now, 'CATALOG_FATAL_CLOCK_STOPS_SIBLING_ATTEMPT').toHaveBeenCalledOnce(); + expect(f.entered.some((event) => event.stopped)).toBe(false); + expect(f.resolveProvider).not.toHaveBeenCalled(); + expect(f.registry.observations.exitCode()).toBe(9); + } finally { + await f.owner.close(); + } + }); +}); diff --git a/apps/cli/src/engine/model-refresh.ts b/apps/cli/src/engine/model-refresh.ts index 6b376dee..cb1e5317 100644 --- a/apps/cli/src/engine/model-refresh.ts +++ b/apps/cli/src/engine/model-refresh.ts @@ -108,7 +108,8 @@ export interface ModelRefreshDeps { } export interface ModelRefreshService { - /** Force-refresh every connected provider (or `opts.providers`), per-provider-isolated. Never rejects. The + /** Force-refresh every connected provider (or `opts.providers`), with isolated discovery failures. + * Original-owner admission refusals reject rather than admitting more work after stop. The * UNBOUNDED, user-initiated refresh (no TTL backoff) — this is what a one-shot CLI invocation must use * (`relavium models` / `models refresh` do). */ refresh(opts?: RefreshOptions): Promise; @@ -188,12 +189,13 @@ export function createModelRefreshService(deps: ModelRefreshDeps): ModelRefreshS // `refresh()` always attempts. const lastAttemptAt = new Map(); - /** Refresh ONE connected provider (its key already resolved). NEVER throws — a fault becomes `status:'failed'`. */ + /** Refresh one already-selected provider; stopped admission rejects before any new attempt work. */ const refreshOne = async ( id: ProviderId, key: string, signal: AbortSignalLike | undefined, ): Promise => { + deps.assertAdmitting?.(); // Stamp the ATTEMPT before anything can throw — `refreshOne` runs only for a connected provider, and every // outcome (refreshed / failed / skipped-unsupported / empty) counts as an attempt for the TTL backoff (FIX 3). try { @@ -211,6 +213,7 @@ export function createModelRefreshService(deps: ModelRefreshDeps): ModelRefreshS deps.onHostFailure?.(cause); throw cause; } + deps.assertAdmitting?.(); if (adapter?.listModels === undefined) { // No adapter, or an adapter without the optional list capability — degrade to static (ADR-0064 §1/§5). return { provider: id, status: 'skipped-unsupported' }; @@ -228,6 +231,7 @@ export function createModelRefreshService(deps: ModelRefreshDeps): ModelRefreshS displayName: meta.displayName, baseUrl: meta.baseUrl, }).id; + deps.assertAdmitting?.(); const rows = listings.map(toLiveModel); // The store counts added/updated/deactivated ATOMICALLY inside its own transaction and returns them — no @@ -259,14 +263,17 @@ export function createModelRefreshService(deps: ModelRefreshDeps): ModelRefreshS const results: RefreshProviderResult[] = []; const connected: { readonly id: ProviderId; readonly key: string }[] = []; for (const id of candidates) { + deps.assertAdmitting?.(); let key: string; try { key = deps.keyFor(id); } catch (cause) { deps.onHostFailure?.(cause); + deps.assertAdmitting?.(); results.push({ provider: id, status: 'skipped-no-key' }); continue; } + deps.assertAdmitting?.(); connected.push({ id, key }); } @@ -301,13 +308,16 @@ export function createModelRefreshService(deps: ModelRefreshDeps): ModelRefreshS } const stale: ProviderId[] = []; for (const id of candidates) { + deps.assertAdmitting?.(); // Only a CONNECTED provider can be refreshed — an unconnected one is never "stale" (nothing to fetch). try { deps.keyFor(id); } catch (cause) { deps.onHostFailure?.(cause); + deps.assertAdmitting?.(); continue; } + deps.assertAdmitting?.(); const record = deps.providerStore.get(id); const refreshedAt = record === undefined ? undefined : deps.catalogStore.providerRefreshedAt(record.id); diff --git a/apps/cli/src/harness/concurrency.e2e.test.ts b/apps/cli/src/harness/concurrency.e2e.test.ts index bd9b14ac..20ee8d56 100644 --- a/apps/cli/src/harness/concurrency.e2e.test.ts +++ b/apps/cli/src/harness/concurrency.e2e.test.ts @@ -158,7 +158,7 @@ describe('concurrency e2e (2.5.I S3) — a run and a chat share one history.db', db: runClient.db, // CR-92's outbox lives beside history.db; a fixture points it at the temp home so nothing // touches the developer's ~/.relavium (ADR-0078 §4). - terminalOutboxPath: join(tmpdir(), 'relavium-test-outbox.ndjson'), + terminalOutboxPath: join(dir, 'terminal-outbox.ndjson'), close: () => {}, }); const runIo = captureIo(); diff --git a/apps/cli/src/secrets/fixtures/native-close-child.ts b/apps/cli/src/secrets/fixtures/native-close-child.ts new file mode 100644 index 00000000..6435af90 --- /dev/null +++ b/apps/cli/src/secrets/fixtures/native-close-child.ts @@ -0,0 +1,94 @@ +/** Actual consumed stdin pipe; parent owns release of its native close delivery. Synthetic data only. */ +import { CommandLifetime } from '../../process/command-lifetime.js'; +import { createCliInvocation } from '../../process/invocation.js'; +import { toUserFacing } from '../../process/errors.js'; +import { captureIo } from '../../test-support.js'; +import { readSecretFromStdin } from '../read-secret.js'; + +const mode = process.argv[2]; +if (!['factory', 'cap', 'cancel', 'predestroyed'].includes(mode ?? '')) + throw new Error('Fixture mode required.'); +const input = process.stdin; +let releaseClose: (() => void) | undefined; +let enteredClose: () => void = () => {}; +const heldClose = new Promise((resolve) => { + enteredClose = resolve; +}); +let delivered = false, + settled = false, + cleaned = false; +let result: { code?: string; exitCode?: number; returned?: number } | undefined; +const originalEmit = input.emit.bind(input); +input.emit = (event: string | symbol, ...args: unknown[]): boolean => { + if (event === 'close') { + releaseClose = () => { + delivered = true; + Reflect.apply(originalEmit, input, [event, ...args]); + }; + enteredClose(); + return true; + } + const emitted: unknown = Reflect.apply(originalEmit, input, [event, ...args]); + if (typeof emitted !== 'boolean') throw new Error('Unexpected native event result.'); + return emitted; +}; +input.on('error', () => {}); +const snapshot = () => ({ + mode, + delivered, + settled, + cleaned, + closed: input.closed, + activeOwners: invocation.registry.activeOwners, + result, +}); +const invocation = createCliInvocation({ + exit: (code) => { + process.stdout.write(JSON.stringify({ phase: 'physical-exit', code, ...snapshot() }) + '\n'); + process.exit(code); + }, + setExitCode: () => {}, +}); +const owner = new CommandLifetime(invocation, { ...captureIo().io, stdin: input }, () => () => {}); +owner.adoptCleanup(() => { + cleaned = true; +}); +process.on('message', (message: unknown) => { + if (message === 'release') releaseClose?.(); +}); +if (mode === 'predestroyed') { + input.destroy(); + await heldClose; +} +if (mode === 'factory') + input[Symbol.asyncIterator] = () => { + throw new Error('synthetic native iterator factory refusal'); + }; +const reading = owner + .execute(async () => { + await readSecretFromStdin(undefined, input, { + signal: owner.work.signal, + onFailure: owner.work.failure, + }); + return 0; + }) + .then( + (returned) => { + settled = true; + result = { returned }; + }, + (cause: unknown) => { + settled = true; + const facing = toUserFacing(cause); + result = { code: facing.code, exitCode: facing.exitCode }; + }, + ); +if (mode === 'cancel') invocation.finiteExit.signal(15); +await heldClose; +// Flush promise continuations: public Socket.closed may already be true while delivery stays held. +await new Promise((resolve) => setImmediate(resolve)); +process.send?.({ phase: 'held', ...snapshot() }); +await reading; +// Signal mode specifically owes the actual finite-exit port, not an IPC/natural-exit race. +if (mode === 'cancel') await new Promise(() => {}); +process.send?.({ phase: 'settled', ...snapshot() }, () => process.disconnect()); diff --git a/apps/cli/src/secrets/native-close.e2e.test.ts b/apps/cli/src/secrets/native-close.e2e.test.ts new file mode 100644 index 00000000..5923b609 --- /dev/null +++ b/apps/cli/src/secrets/native-close.e2e.test.ts @@ -0,0 +1,95 @@ +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const register = fileURLToPath( + new URL('../commands/fixtures/workflow-cleanup-register.mjs', import.meta.url), +); +const fixture = fileURLToPath(new URL('./fixtures/native-close-child.ts', import.meta.url)); + +describe('native secret input close delivery', () => { + it.each(['factory', 'cap', 'cancel', 'predestroyed'] as const)( + '%s grants no owner/database retirement from a public closed flag', + async (mode) => { + const child = spawn( + process.execPath, + ['--no-warnings', '--experimental-transform-types', '--import', register, fixture, mode], + { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] }, + ); + const messages: unknown[] = []; + let stdout = '', + stderr = '', + watchdogFired = false; + child.stdin?.on('error', () => {}); // Native refusal may close the parent write side. + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on('message', (message: unknown) => { + messages.push(message); + if ( + typeof message === 'object' && + message !== null && + Object.getOwnPropertyDescriptor(message, 'phase')?.value === 'held' + ) + child.send('release'); + }); + const closed = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + const watchdog = setTimeout(() => { + watchdogFired = true; + child.kill('SIGKILL'); + }, 10_000); + if (mode === 'cap') child.stdin?.write(Buffer.alloc(65_537, 'x')); + try { + const exit = await closed; + expect(stderr, 'NATIVE_CLOSE_CHILD_INFRASTRUCTURE').toBe(''); + expect(exit).toEqual({ + code: mode === 'cancel' ? 143 : mode === 'predestroyed' ? 9 : 0, + signal: null, + }); + expect(watchdogFired).toBe(false); + expect(stderr).toBe(''); + expect(messages[0], 'SECRET_NATIVE_CLOSED_IS_NOT_DELIVERED_CLOSE').toEqual({ + phase: 'held', + mode, + delivered: false, + settled: false, + cleaned: false, + closed: true, + activeOwners: 1, + }); + if (mode === 'cancel' || mode === 'predestroyed') { + expect(messages).toHaveLength(1); + const final: unknown = JSON.parse(stdout); + expect(final).toMatchObject({ + phase: 'physical-exit', + delivered: true, + cleaned: mode === 'cancel', + activeOwners: mode === 'cancel' ? 0 : 1, + }); + } else { + expect(stdout).toBe(''); + expect(messages[1]).toMatchObject({ + phase: 'settled', + delivered: true, + settled: true, + cleaned: true, + activeOwners: 0, + result: { code: 'invalid_invocation', exitCode: 2 }, + }); + expect(messages).toHaveLength(2); + } + } finally { + clearTimeout(watchdog); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await closed; + } + }, + 15_000, + ); +}); diff --git a/apps/cli/src/secrets/read-secret-lifetime.test.ts b/apps/cli/src/secrets/read-secret-lifetime.test.ts index c360709c..b2a9f9fe 100644 --- a/apps/cli/src/secrets/read-secret-lifetime.test.ts +++ b/apps/cli/src/secrets/read-secret-lifetime.test.ts @@ -14,6 +14,127 @@ class HeldCloseInput extends Readable { } describe('captured secret input lifetime', () => { + it.each(['cap', 'partial-iterator', 'factory'] as const)( + 'joins actual native close before retiring a %s refusal', + async (mode) => { + const f = invocationFixture(); + const input = new HeldCloseInput(); + const { io } = captureIo(); + const lifetime = new CommandLifetime(f.invocation, { ...io, stdin: input }, () => () => {}); + const databaseClose = vi.fn(); + lifetime.adoptCleanup(databaseClose); + if (mode === 'cap') input.push(Buffer.alloc(65_537, 'x')); + else if (mode === 'factory') { + input[Symbol.asyncIterator] = () => { + throw new Error('synthetic iterator factory fault'); + }; + } else { + let injected = false; + input.on('newListener', (event) => { + if (event === 'end' && !injected) { + injected = true; + throw new Error('synthetic iterator acquisition fault'); + } + }); + } + let settled = false; + const reading = lifetime.execute(async () => { + await readSecretFromStdin(undefined, input, { + signal: lifetime.work.signal, + onFailure: lifetime.work.failure, + }); + expect.fail('invalid input cannot continue to a credential write'); + }); + const outcome = reading.then( + () => { + settled = true; + return undefined; + }, + (cause: unknown) => { + settled = true; + return cause; + }, + ); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(settled, 'SECRET_REFUSAL_RETAINS_NATIVE_CLOSE').toBe(false); + expect(input.destroyed, 'SECRET_REFUSAL_REQUESTS_NATIVE_CLOSE').toBe(true); + expect(input.closed).toBe(false); + expect(databaseClose).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + } finally { + // Test cleanup requests close even on the unfixed partial-acquisition path. + input.destroy(); + input.release(); + } + expect(await outcome).toMatchObject({ code: 'invalid_invocation', exitCode: 2 }); + expect(input.closed).toBe(true); + expect(databaseClose).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + }, + ); + + it('attempts destruction independently of failed close observation and grants no retirement', async () => { + const f = invocationFixture(); + const input = new HeldCloseInput(); + const { io } = captureIo(); + const lifetime = new CommandLifetime(f.invocation, { ...io, stdin: input }, () => () => {}); + const databaseClose = vi.fn(); + lifetime.adoptCleanup(databaseClose); + let injected = false; + input.on('newListener', (event) => { + if (event === 'close' && !injected) { + injected = true; + throw new Error('synthetic close observation fault'); + } + }); + let settled = false; + const reading = lifetime.execute(async () => { + await readSecretFromStdin(undefined, input, { + signal: lifetime.work.signal, + onFailure: lifetime.work.failure, + }); + return 0; + }); + void reading.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(input.destroyed, 'SECRET_CLOSE_OBSERVER_FAILURE_STILL_DESTROYS').toBe(true); + expect(input.closed).toBe(false); + expect(databaseClose).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + f.advance(3_000); + expect(await f.invocation.finiteExit.exited).toBe(9); + } finally { + input.destroy(); + input.release(); + } + await new Promise((resolve) => setImmediate(resolve)); + expect(input.closed).toBe(true); + expect(settled).toBe(false); + expect(databaseClose).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + }); + + it('refuses an unconsumed native TTY without iterator or close acquisition', async () => { + const input = new HeldCloseInput(); + Object.defineProperty(input, 'isTTY', { value: true }); + const acquire = vi.spyOn(input, Symbol.asyncIterator); + const destroy = vi.spyOn(input, 'destroy'); + await expect(readSecretFromStdin(undefined, input)).rejects.toMatchObject({ exitCode: 2 }); + expect(acquire).not.toHaveBeenCalled(); + expect(destroy).not.toHaveBeenCalled(); + input.destroy(); + input.release(); + }); + it('waits for actual native input close after cancellation before database cleanup', async () => { const f = invocationFixture(); const input = new HeldCloseInput(); diff --git a/apps/cli/src/secrets/read-secret.ts b/apps/cli/src/secrets/read-secret.ts index fba7768a..0f2e5af0 100644 --- a/apps/cli/src/secrets/read-secret.ts +++ b/apps/cli/src/secrets/read-secret.ts @@ -66,36 +66,62 @@ export async function readSecretFromStdin( } if (isTTY === true) throw new CliError('invalid_invocation', context.pipeHint); - let iterator: AsyncIterator; - try { - iterator = input[Symbol.asyncIterator](); - } catch (cause) { - throw readFailure(cause); - } + let iterator: AsyncIterator | undefined; let finished = false; const native = input instanceof Readable ? input : undefined; const cancelled = new Error('Secret input stopped.'); - let closing: Promise | undefined; let acknowledgeClose: () => void = () => undefined; + // Iterator return can settle before _destroy acknowledges close, or fail before its cleanup starts. + // Reserve the actual native-close obligation BEFORE iterator construction/first next. + const closing = + native === undefined + ? undefined + : new Promise((resolve) => { + acknowledgeClose = resolve; + }); const onClose = (): void => acknowledgeClose(); - const stop = (): void => { - if (native === undefined || closing !== undefined) return; - closing = new Promise((resolve) => { - acknowledgeClose = resolve; - }); + // An abort can enter before the iterator installs its own error observer. The iterator still owns + // read-error classification; this guard only prevents an unobserved cancellation during acquisition. + const onNativeError = (): void => {}; + let destroyEntered = false; + let cleanupFailed = false; + const cleanupFailure = (cause: unknown): void => { + cleanupFailed = true; + lifecycle?.onFailure(new CliError('cli_internal', '', { cause })); + }; + const requestClose = (reason?: Error): void => { + if (native === undefined || destroyEntered) return; + destroyEntered = true; try { - native.once('close', onClose); - if (native.closed) acknowledgeClose(); - native.destroy(cancelled); + native.destroy(reason); } catch (cause) { - lifecycle?.onFailure(cause); + cleanupFailure(cause); } }; - lifecycle?.signal.addEventListener('abort', stop, { once: true }); - if (lifecycle?.signal.aborted === true) stop(); + const stop = (): void => requestClose(cancelled); const chunks: Buffer[] = []; let totalBytes = 0; try { + try { + native?.on('error', onNativeError); + native?.once('close', onClose); + // Socket.closed can become true BEFORE its actual close delivery. An unobserved preclosed + // source supplies no receipt; retain uncertainty rather than inventing already-complete ACK. + if (native?.closed === true) throw new CliError('cli_internal', ''); + } catch (cause) { + // Destruction is independently attempted in finally even if close-observer acquisition failed. + cleanupFailure(cause); + throw readFailure(cause); + } + lifecycle?.signal.addEventListener('abort', stop, { once: true }); + if (lifecycle?.signal.aborted === true) stop(); + try { + lifecycle?.signal.throwIfAborted(); + iterator = input[Symbol.asyncIterator](); + } catch (cause) { + if (lifecycle?.signal.aborted === true && cause === lifecycle.signal.reason) throw cause; + throw readFailure(cause); + } while (true) { let done: boolean | undefined; let chunk: unknown; @@ -145,16 +171,33 @@ export async function readSecretFromStdin( totalBytes += size; } } finally { - if (!finished) { + // Enter native cancellation before awaiting any iterator cleanup; a partial or foreign iterator + // must not prevent the independent finite destruction request for the consumed pipe. + requestClose(); + if (!finished && iterator !== undefined) { try { await iterator.return?.(); } catch { // Best-effort release must not replace the primary refusal with foreign cleanup text. } } + // A consumed non-TTY source is never reused as interactive authority. Closing it also covers + // partial iterator acquisition and early cap refusal, independently of iterator.return's result. lifecycle?.signal.removeEventListener('abort', stop); await closing; - native?.removeListener('close', onClose); + for (const [event, callback] of [ + ['close', onClose], + ['error', onNativeError], + ] as const) { + try { + native?.removeListener(event, callback); + } catch (cause) { + cleanupFailure(cause); + } + } + // A failed cleanup step cannot acknowledge retirement. The original invocation's finite exit + // still bounds the process; neither an iterator return nor a destroy request invents close ACK. + if (cleanupFailed) await new Promise(() => {}); } lifecycle?.signal.throwIfAborted(); // Decode once to preserve code points split across Buffer chunks; only callers may trim values. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 7909b310..b5b02deb 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -962,8 +962,17 @@ Management dispatch and the separate media-maintenance route reserve a command owner before entering work. Provider verification and both model-refresh axes retain actual operations beyond early report/failure results. Their original signal and admission checks prevent credential, catalog and output publication -after stop. Captured secret input joins its native close separately from a -cancellation request. Deep-doctor probes belong to their actual chat/Home host; +after stop. Captured secret input reserves close observation before iterator +acquisition and joins actual native close on success, early refusal and failure, +separately from `iterator.return()` or a cancellation/destruction request. A +partially acquired iterator cannot retire the command or release its database +while the consumed pipe remains live. A public `closed` flag does not establish +close delivery; an unobserved preclosed source retains cleanup uncertainty. +TTY refusal remains before consumption; +non-native iterable sources retain their separate awaited-return contract. +Headless plain/JSON chat likewise owns partial native readline construction and +retains entered driver continuations through final callback removal and input +pause. Deep-doctor probes belong to their actual chat/Home host; an ordinary failed probe remains a report, while its still-running producer remains owed. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 3511fc19..7196cc65 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1156,7 +1156,10 @@ cumulative clean reviews, a separate clean security sitting and current full CI/platform acceptance remain open. The [first cumulative round and correction register](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-first-cumulative-review-and-corrections--2026-10-17) records complete NONCLEAN coverage of all 255 paths and the verified corrections; -no clean-round acceptance is inferred. Step 8d proceeds automatically. The +no clean-round acceptance is inferred. The [second cumulative round and native-input corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-second-cumulative-review-and-native-input-corrections--2026-10-17) +record complete NONCLEAN coverage of all 262 paths, the supplemental native-input +finding and subsequent fixes. Fresh complete reviews/security/full-CI/platform +acceptance remain open. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 52484ced..7e5a099b 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -6309,3 +6309,86 @@ Two NEW complete cumulative clean review rounds after the final correction, a NE separate clean security sitting and fresh required full CI/four same-artifact platform consumers remain open. Whole Step 8 and Steps 9–19 remain open. No new ADR approval, credential, provider, billing or user action is pending. + + +## Wave 3 Step 8d second cumulative review and native-input corrections — 2026-10-17 + +**Complete cumulative round 2 is NONCLEAN at `98259c97`; Step 8d remains open.** +Three independent reviewers inspected all 262 paths from `d9625ff0`: 70 +session/terminal paths, 121 workflow/management paths and 71 native/tooling/docs +paths. Their inventories join without omission or overlap. The local Git-ignored +aggregate SHA-256 is `7d805b33457b732acbecd05bea77de9b143875c02be047e4f3da5bc496dc7a7c`. +Two verified High findings, followed by one verified High supplemental finding, +are corrected in the subsequent source. A clean slice, supplemental advisory or +passing author test is not a complete clean review round. + +- Plain/JSON chat now reserves partial native readline cleanup before construction + or iterator entry. The actual entered driver remains owned through cancellation, + late continuations, callback removal and input pause. Eight full-command + regressions failed before correction; separate retained-continuation and failed + removal controls cover the original finite-exit path without inventing cleanup + ACK. Prior input callbacks remain intact. +- Both catalog-refresh axes recheck their original admission before each credential + candidate, database read and clock stamp. An unexpected first-key failure can no + longer start another candidate after synchronous owner stop. Ordinary typed + missing-key discovery still continues. Three negative controls failed before + correction; two ordinary-discovery controls remain positive. +- The focused supplemental audit reproduced early secret refusal retiring before + actual native close: both partial iterator setup and the ordinary 65,537-byte + refusal. The reader now reserves native close observation before iterator entry, + independently requests destruction before awaiting iterator cleanup and joins + actual close delivery on every consumed non-TTY outcome. Operator TTY refusal + remains before consumption; generic iterables retain their awaited-return/primary + failure contract. Two held-`_destroy` regressions failed before correction. + Close-observer failure still requests destruction but retains uncertainty and + database ownership through the original physical exit. No second timeout is added. + +The author's first secret-reader repair prematurely acknowledged `Socket.closed`; +existing actual workflow/gate pipe tests caught both missing close-delivery events. +The public flag can become true before native close delivery. The repair now uses +its owned event receipt; an unobserved preclosed source retains uncertainty. Four +permanent native-pipe controls hold actual close delivery across factory refusal, +cap refusal, cancellation and preclosed input. No public flag, iterator return, +internal state bit or elapsed deadline supplies delivery ACK. The focused advisory +was initially clean on a narrower probe set; its appended follow-up records this +miss and corrected scope. Neither advisory counts as an acceptance/security round. + +At `98259c97`, all four required same-tarball remote consumers and their aggregate +passed, including current Windows and Node 22.0.0. This is historical execution, +not execution of the subsequent corrections. Broader CI refused the usage inventory +and the Windows advisory job. The usage suite passed all 23 mechanisms and 446 +permanent cases before rejecting its stale 440-case pin; the corrected guard pins +446 and independently collects the actual 17 test files. Native-child inventory +remains 71. Four intentional fatal-exit filesystem fixtures now use their existing +injected finite clock/exit ports instead of accidentally exiting the real test +worker three seconds later. Production timing and physical exit are unchanged. +The concurrency outbox now belongs to the test's own temporary directory. This +hermetic change is not claimed to explain the separately observed Windows 5s +coexistence timeout; its timeout remains unchanged and fresh platform execution is +required. + +At the final frozen fifteen-path product/test/tooling source, author workspace +lint/typecheck passes all seventeen tasks (fifteen valid cache hits); tests pass all +eleven tasks, with ten cached packages and fresh CLI execution: **327 files, 5,343 +passing tests and ten established skips**. Build passes six tasks (five cached). +Tools lint/typecheck, all **512 acceptance-tool tests**, test isolation, seam/purity, +finite emitted-graph closure, **177 compiled bootstrap controls** and compiled CLI +smoke pass locally on macOS/Node 22.23.1. The local Git-ignored author source pin +has SHA-256 `9b2441993c2fb6bf39d174c5352f45d9ea60066bab84ccd0222d5d15c2714354`. + +Removing only entered-readline retention or only native-secret close joining breaks +the corresponding named semantic regressions; both exact restorations pass. The +existing generic-iterator cleanup mechanism also passes its fixed/removal/restored +cycle with 31 fixed/restored cases. That selected-family report retains +`complete: false`; this is not full-family or remote/platform acceptance. +Earlier failed tool invocations, loader/fixture corrections, the first +full workspace's two close-order failures and narrower historical evidence retain +their scopes. Test collection initially overwrote one test file; the author restored +its exact HEAD bytes immediately. The new collector always writes an explicitly +owned temporary output path, so no test filter can become its report destination. +No unintended test-content change remains. + +Two NEW complete cumulative clean rounds after the final material fix, a NEW +separate clean security sitting, fresh full CI and the four same-artifact platform +consumers remain open. Whole Step 8 and Steps 9–19 remain open. No user decision, +ADR approval, credential, provider capture or billing action is pending. diff --git a/tools/usage-coverage-controls/README.md b/tools/usage-coverage-controls/README.md index e2af348c..a8e285bb 100644 --- a/tools/usage-coverage-controls/README.md +++ b/tools/usage-coverage-controls/README.md @@ -70,7 +70,7 @@ shared 100,000-case scale files run separately from repeated small mutation coho crash, lock, receipt and scale tests remain part of ordinary package CI. Each ordinary phase retains its five-minute outer watchdog. The one complete permanent -cohort pins 440 cases and 71 joined native children, including Wave 2's two DB readiness +cohort pins 446 cases and 71 joined native children, including Wave 2's two DB readiness controls. Six additional CLI chat cases exercise live export notices, refusals and transcript rendering; they add no native children. Its outer watchdog is calculated from the existing five-minute setup/general allowance, 71 existing 20-second native-case bounds and the existing ten-minute DB scale diff --git a/tools/usage-coverage-controls/workload.mjs b/tools/usage-coverage-controls/workload.mjs index fde3c4e5..d4b5bed2 100644 --- a/tools/usage-coverage-controls/workload.mjs +++ b/tools/usage-coverage-controls/workload.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; export const ordinaryPhaseTimeoutMs = 300_000; // Pinned complete cohort: 57 CLI crash, four causal crash and ten DB process children. // Each native case already has a 20s bound; the full DB scale test permits 600s. -export const permanentWorkload = Object.freeze({ cases: 440, nativeChildren: 71 }); +export const permanentWorkload = Object.freeze({ cases: 446, nativeChildren: 71 }); export const permanentPhaseTimeoutMs = ordinaryPhaseTimeoutMs + permanentWorkload.nativeChildren * 20_000 + 600_000; diff --git a/tools/usage-coverage-controls/workload.test.mjs b/tools/usage-coverage-controls/workload.test.mjs index 377fbaed..8a7255b8 100644 --- a/tools/usage-coverage-controls/workload.test.mjs +++ b/tools/usage-coverage-controls/workload.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { Buffer } from 'node:buffer'; +import { execFileSync } from 'node:child_process'; import { mkdtempSync, readFileSync, @@ -13,6 +14,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; +import { permanentFiles } from './mutations.mjs'; import { aliases, digest, @@ -25,6 +27,7 @@ import { assertPermanentWorkload, ordinaryPhaseTimeoutMs, permanentPhaseTimeoutMs, + permanentWorkload, } from './workload.mjs'; test('supported shared, llm and core exports map to exact owned sources, with unknown aliases refused', () => { @@ -57,11 +60,11 @@ test('the complete native/scale workload has its own calculated bound and exact assert.equal(ordinaryPhaseTimeoutMs, 300_000); assert.equal(permanentPhaseTimeoutMs, 300_000 + 71 * 20_000 + 600_000); const result = { - tests: Array.from({ length: 440 }), + tests: Array.from({ length: 446 }), runtime: { nativeChildren: 71, readinessTimeoutChildren: 1 }, }; assert.doesNotThrow(() => assertPermanentWorkload(result)); - for (const count of [0, 435, 439, 441]) { + for (const count of [0, 440, 445, 447]) { assert.throws( () => assertPermanentWorkload({ ...result, tests: Array.from({ length: count }) }), /Permanent case inventory drift/, @@ -88,6 +91,40 @@ test('the complete native/scale workload has its own calculated bound and exact ); }); +test('the pinned permanent inventory matches actual installed Vitest collection', () => { + const repository = fileURLToPath(new URL('../../', import.meta.url)); + const scratch = mkdtempSync(join(tmpdir(), 'relavium-usage-inventory-')); + const output = join(scratch, 'collected.json'); + try { + // Vitest's optional --json argument is a filename, including the literal string "true". + // Always give an owned output path: no filter may become the reporter's destination. + execFileSync( + process.execPath, + [ + join(repository, 'node_modules/vitest/vitest.mjs'), + 'list', + `--json=${output}`, + ...permanentFiles, + ], + { cwd: repository, timeout: 30_000, maxBuffer: 4 * 1024 * 1024, stdio: 'pipe' }, + ); + const collected = JSON.parse(readFileSync(output, 'utf8')); + assert.ok(Array.isArray(collected)); + assert.equal( + collected.length, + permanentWorkload.cases, + 'Permanent collected case inventory drift', + ); + assert.deepEqual( + [...new Set(collected.map((item) => item.file))].sort(), + permanentFiles.map((file) => join(repository, file)).sort(), + 'Permanent collected file inventory drift', + ); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +}); + test('only the loaded-but-unready timeout control may omit a real readiness barrier', () => { const child = { pid: 123, callerPid: 456, mode: 'readiness-timeout-control', stage: 'spawn' }; const records = [ From db825377a6b213d8a63a1ed0b39a2a63b9193982 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 04:00:43 +0300 Subject: [PATCH 114/222] fix(cli): capture partial native resources before setup Retain consumed one-shot and secret input through actual native close. Transfer SQLite close capabilities before PRAGMAs, migrations and store construction to each existing command, workflow or session owner. Preserve primary and secondary startup failures without disguising failed cleanup as optional pricing absence. The coupled DB factory handoff and source-only usage collector close the complete Step 8d round-3 findings. Permanent native/surface controls, causal removals and Node 22/24 controls preserve actual ownership, signal custody and final cleanup. Author workspace gates, build, 512 tooling tests and compiled smokes pass. Independent cumulative reviews, security and fresh remote acceptance remain open. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- apps/cli/src/chat/session-chat-lifetime.ts | 17 ++ apps/cli/src/chat/session-readline.test.ts | 8 +- .../src/chat/session-surface-lifetime.test.ts | 27 +++ apps/cli/src/chat/session-surface-lifetime.ts | 1 + .../commands/agent-native-input.e2e.test.ts | 115 +++++++++++ apps/cli/src/commands/agent-run.ts | 64 +++--- apps/cli/src/commands/chat-export.ts | 12 +- apps/cli/src/commands/chat-list.ts | 12 +- apps/cli/src/commands/chat.ts | 26 ++- apps/cli/src/commands/dispatch.ts | 23 ++- apps/cli/src/commands/gate-list.ts | 12 +- apps/cli/src/commands/gate.test.ts | 44 +++-- apps/cli/src/commands/gate.ts | 29 ++- apps/cli/src/commands/list.ts | 12 +- apps/cli/src/commands/logs.ts | 12 +- .../commands/partial-db-acquisition.test.ts | 138 +++++++++++++ apps/cli/src/commands/run.test.ts | 64 +++--- apps/cli/src/commands/run.ts | 20 +- apps/cli/src/commands/status.ts | 12 +- .../commands/surface-db-acquisition.test.ts | 186 ++++++++++++++++++ apps/cli/src/db/open.test.ts | 45 +++++ apps/cli/src/db/open.ts | 32 ++- apps/cli/src/engine/pricing-overlay.test.ts | 23 ++- apps/cli/src/engine/pricing-overlay.ts | 16 +- apps/cli/src/history/open.ts | 3 +- apps/cli/src/history/reader.ts | 19 +- apps/cli/src/history/session-open.ts | 7 +- apps/cli/src/home/drive-home.tsx | 13 +- apps/cli/src/home/session-home-lifetime.ts | 17 ++ .../process/acquire-native-resource.test.ts | 87 ++++++++ .../src/process/acquire-native-resource.ts | 43 ++++ apps/cli/src/process/command-lifetime.test.ts | 94 +++++++++ apps/cli/src/process/command-lifetime.ts | 24 +++ apps/cli/src/process/invocation-work-owner.ts | 5 + apps/cli/src/process/native-input.test.ts | 109 ++++++++++ apps/cli/src/process/native-input.ts | 100 ++++++++++ apps/cli/src/process/synchronous-resource.ts | 34 +++- .../secrets/fixtures/native-close-child.ts | 91 ++++++--- apps/cli/src/secrets/read-secret.ts | 70 +------ docs/reference/cli/commands.md | 16 +- docs/reference/shared-core/database-schema.md | 10 + docs/roadmap/current.md | 8 +- .../phase-2.5.5-hardening-and-remediation.md | 91 +++++++++ packages/db/src/client-acquisition.test.ts | 70 +++++++ packages/db/src/client.ts | 35 ++-- .../usage-coverage-controls/workload.test.mjs | 110 +++++++++-- 46 files changed, 1723 insertions(+), 283 deletions(-) create mode 100644 apps/cli/src/commands/agent-native-input.e2e.test.ts create mode 100644 apps/cli/src/commands/partial-db-acquisition.test.ts create mode 100644 apps/cli/src/commands/surface-db-acquisition.test.ts create mode 100644 apps/cli/src/process/acquire-native-resource.test.ts create mode 100644 apps/cli/src/process/acquire-native-resource.ts create mode 100644 apps/cli/src/process/native-input.test.ts create mode 100644 apps/cli/src/process/native-input.ts create mode 100644 packages/db/src/client-acquisition.test.ts diff --git a/apps/cli/src/chat/session-chat-lifetime.ts b/apps/cli/src/chat/session-chat-lifetime.ts index 8efe64ee..cc401fee 100644 --- a/apps/cli/src/chat/session-chat-lifetime.ts +++ b/apps/cli/src/chat/session-chat-lifetime.ts @@ -1,3 +1,4 @@ +import { acquireNativeResource } from '../process/acquire-native-resource.js'; import type { CliInvocation } from '../process/invocation.js'; import type { ExitCode } from '../process/exit-codes.js'; import { CliError } from '../process/cli-error.js'; @@ -84,6 +85,22 @@ export class SessionChatLifetime { }; }; + /** Capture the shared connection before native setup; release remains after every original host/tail. */ + acquireStore void }>( + factory: (captureClose: (close: () => void) => void) => Resource, + ): Resource { + if (this.#store !== undefined) throw new CliError('cli_internal', ''); + return acquireNativeResource(factory, { + assertAdmitting: this.surface.work.assertAdmitting, + captureClose: (close) => { + if (this.#store !== undefined) throw new CliError('cli_internal', ''); + this.#store = { close }; + }, + cleanupUncertain: this.surface.work.cleanupUncertain, + isStoppedFailure: this.surface.work.isStoppedFailure, + }); + } + adoptStore(store: { readonly close: () => void }): void { if (this.#store !== undefined) throw new CliError('cli_internal', ''); this.#store = store; diff --git a/apps/cli/src/chat/session-readline.test.ts b/apps/cli/src/chat/session-readline.test.ts index 004b61ad..78a378ea 100644 --- a/apps/cli/src/chat/session-readline.test.ts +++ b/apps/cli/src/chat/session-readline.test.ts @@ -117,11 +117,15 @@ describe('captured headless input lifetime', () => { f.input, f.work, () => unsubscribe, - async (reader) => { + async () => { entered.resolve(); await release.promise; - reader.resume(); // The actual entered continuation resumes native input after stop. + // The readline facade is already sealed. The entered continuation can still touch + // its captured native input; Node 24 correctly refuses reader.resume() after close. + f.input.resume(); f.input.on('data', late); + expect(f.input.isPaused(), 'LATE_DRIVER_ACTUALLY_RESUMED_NATIVE_INPUT').toBe(false); + expect(f.input.listeners('data')).toContain(late); return 'finished'; }, ), diff --git a/apps/cli/src/chat/session-surface-lifetime.test.ts b/apps/cli/src/chat/session-surface-lifetime.test.ts index 5bea43ab..37d369a3 100644 --- a/apps/cli/src/chat/session-surface-lifetime.test.ts +++ b/apps/cli/src/chat/session-surface-lifetime.test.ts @@ -59,6 +59,33 @@ function fixture() { } describe('surface roots outlive their shared preparation and database tails', () => { + it('retains signal custody when a partial native acquisition supplies no release capability', async () => { + const f = fixture(); + let settled = false; + void f.lifetime + .execute(() => { + const failure = new Error('synthetic unknown partial acquisition'); + f.lifetime.work.cleanupUncertain(failure); + throw failure; + }) + .then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await reactions(); + expect(f.registry.activeOwners).toBe(1); + expect(f.remove, 'UNKNOWN_PARTIAL_ACQUISITION_RETAINS_SIGNAL_CUSTODY').not.toHaveBeenCalled(); + expect(settled).toBe(false); + f.advance(3_000); + expect(await f.invocation.finiteExit.exited).toBe(9); + expect(f.registry.activeOwners).toBe(1); + expect(f.remove).not.toHaveBeenCalled(); + }); + it.each([0, 1, 2, 4] as const)( 'preserves actual surface outcome %s without creating a fatal timer or physical exit', async (outcome) => { diff --git a/apps/cli/src/chat/session-surface-lifetime.ts b/apps/cli/src/chat/session-surface-lifetime.ts index 69fcacde..f93f04c3 100644 --- a/apps/cli/src/chat/session-surface-lifetime.ts +++ b/apps/cli/src/chat/session-surface-lifetime.ts @@ -28,6 +28,7 @@ export class SessionSurfaceLifetime { // Signal custody must survive a failed database release. A later signal still // belongs to the original finite authority while cleanup remains uncertain. await cleanup?.(); + if (this.work.hasCleanupUncertainty) await new Promise(() => {}); this.#removeSignals(); }, }); diff --git a/apps/cli/src/commands/agent-native-input.e2e.test.ts b/apps/cli/src/commands/agent-native-input.e2e.test.ts new file mode 100644 index 00000000..30a5aafc --- /dev/null +++ b/apps/cli/src/commands/agent-native-input.e2e.test.ts @@ -0,0 +1,115 @@ +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const register = fileURLToPath( + new URL('./fixtures/workflow-cleanup-register.mjs', import.meta.url), +); +const fixture = fileURLToPath( + new URL('../secrets/fixtures/native-close-child.ts', import.meta.url), +); + +describe('shipping one-shot agent input close custody', () => { + it.each(['eof', 'factory', 'cancel', 'predestroyed'] as const)( + '%s keeps its actual invocation until native pipe close delivery', + async (action) => { + const root = mkdtempSync(join(tmpdir(), 'relavium-agent-close-')); + writeFileSync(join(root, 'config.toml'), ''); + const child = spawn( + process.execPath, + [ + '--no-warnings', + '--experimental-transform-types', + '--import', + register, + fixture, + `agent-${action}`, + root, + ], + { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + env: { ...process.env, HOME: root, USERPROFILE: root }, + }, + ); + let stdout = '', + stderr = '', + watchdogFired = false; + let releaseFailure: Error | null = null; + const messages: unknown[] = []; + child.stdin?.on('error', () => {}); + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on('message', (message: unknown) => { + messages.push(message); + if ( + typeof message === 'object' && + message !== null && + Object.getOwnPropertyDescriptor(message, 'phase')?.value === 'held' + ) + child.send('release', (error) => { + releaseFailure = error; + }); + }); + const closed = new Promise((resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }); + const watchdog = setTimeout(() => { + watchdogFired = true; + child.kill('SIGKILL'); + }, 10_000); + if (action === 'eof') child.stdin?.end('synthetic prompt\n'); + try { + const exit = await closed; + expect(stderr, 'AGENT_STDIN_FIXTURE_INFRASTRUCTURE').toBe(''); + expect(watchdogFired).toBe(false); + expect(messages[0], 'AGENT_INPUT_RETAINS_ACTUAL_NATIVE_CLOSE').toMatchObject({ + phase: 'held', + delivered: false, + settled: false, + closed: true, + // EOF keeps both the surface and its not-yet-built host reservation. Fatal/signal stop + // retires the empty host first, while the surface still owes actual stdin close. + activeOwners: action === 'eof' ? 2 : 1, + }); + expect(releaseFailure, 'AGENT_STDIN_FIXTURE_RELEASE_DELIVERY').toBeNull(); + expect(exit).toEqual({ + code: action === 'eof' ? 0 : action === 'cancel' ? 143 : 9, + signal: null, + }); + if (action === 'eof') { + expect(stdout).toBe(''); + expect(messages).toHaveLength(2); + expect(messages[1]).toMatchObject({ + phase: 'settled', + delivered: true, + settled: true, + activeOwners: 0, + result: { code: 'invalid_invocation', exitCode: 2 }, + }); + } else { + expect(messages).toHaveLength(1); + const final: unknown = JSON.parse(stdout); + expect(final).toMatchObject({ + phase: 'physical-exit', + delivered: true, + activeOwners: action === 'predestroyed' ? 1 : 0, + }); + } + } finally { + clearTimeout(watchdog); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await closed; + rmSync(root, { recursive: true, force: true }); + } + }, + 15_000, + ); +}); diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index ce0e9af3..1389f50a 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -1,3 +1,4 @@ +import { acquireNativeResource } from '../process/acquire-native-resource.js'; import type { CliInvocation } from '../process/invocation.js'; import { SessionHostOwner } from '../chat/session-host-owner.js'; import { SessionSurfaceLifetime } from '../chat/session-surface-lifetime.js'; @@ -5,7 +6,7 @@ import { runSessionPrompt } from '../chat/session-prompt.js'; import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; import { randomUUID } from 'node:crypto'; import { StringDecoder } from 'node:string_decoder'; -import { Readable } from 'node:stream'; +import { CapturedNativeInput } from '../process/native-input.js'; import type { SessionStreamHandleEvent } from '@relavium/core'; @@ -94,7 +95,13 @@ export async function agentRunCommand( args: AgentRunCommandArgs, deps: AgentRunCommandDeps, ): Promise { - const lifetime = new SessionSurfaceLifetime(deps.invocation, 'agent'); + let closeJournal: (() => void) | undefined; + const lifetime = new SessionSurfaceLifetime(deps.invocation, 'agent', undefined, () => { + // The original surface releases its native database only after its host/root/tails join, + // and before its signal custody can be removed. + closeJournal?.(); + return Promise.resolve(); + }); return lifetime.execute(() => agentRunOwned( args, @@ -117,6 +124,9 @@ export async function agentRunCommand( }, }, lifetime, + (close) => { + closeJournal = close; + }, ), ); } @@ -125,9 +135,9 @@ async function agentRunOwned( args: AgentRunCommandArgs, deps: AgentRunCommandDeps, lifetime: SessionSurfaceLifetime, + captureJournalClose: (close: () => void) => void, ): Promise { const hostOwner = new SessionHostOwner(lifetime.invocation, 'agent'); - let openedJournal: ReturnType | undefined; try { const nativeProcesses = nativeProcessesForInvocation(deps.invocation); const grants = admitFsInvocation(deps.global); @@ -242,8 +252,15 @@ async function agentRunOwned( // The host reservation already owns every entered builder/MCP operation. The outer surface root also // owns this database from acquisition through actual host cleanup, including setup failures before a turn. lifetime.work.assertAdmitting(); - const journalStore = openSessionStore(homeDir); - openedJournal = journalStore; + const journalStore = acquireNativeResource( + (captureClose) => openSessionStore(homeDir, captureClose), + { + assertAdmitting: lifetime.work.assertAdmitting, + captureClose: captureJournalClose, + cleanupUncertain: lifetime.work.cleanupUncertain, + isStoppedFailure: lifetime.work.isStoppedFailure, + }, + ); let ownedEffectSessionId: string | undefined; const usageStore = createSessionUsageStore(journalStore.db); const receipts = new Map(); @@ -385,13 +402,6 @@ async function agentRunOwned( throw cause; } finally { await hostOwner.work.close(); - if (openedJournal !== undefined) { - try { - openedJournal.close(); - } catch (cause) { - lifetime.work.cleanupUncertain(cause); - } - } } } @@ -497,29 +507,9 @@ export async function readAllStdin( let data = ''; const signal = options?.signal; signal?.throwIfAborted(); - const native = stream instanceof Readable ? stream : undefined; - const cancelled = new Error('One-shot input stopped.'); - let cancellationEntered = false; - let closed: Promise | undefined; - let acknowledgeClose: () => void = () => undefined; - const onClose = (): void => acknowledgeClose(); - const abort = (): void => { - if (native === undefined || cancellationEntered) return; - cancellationEntered = true; - closed = new Promise((resolve) => { - acknowledgeClose = resolve; - }); - native.once('close', onClose); - if (native.closed) acknowledgeClose(); - try { - native.destroy(cancelled); - } catch (cause) { - // A throwing cancellation is not close ACK. Report it before waiting on the retained read. - options?.onFailure(cause); - } - }; - signal?.addEventListener('abort', abort, { once: true }); + const input = new CapturedNativeInput(stream, options, 'One-shot input stopped.'); try { + input.open(); for await (const chunk of stream) { data += chunk instanceof Uint8Array ? decoder.write(Buffer.from(chunk)) : String(chunk); } @@ -529,12 +519,10 @@ export async function readAllStdin( } catch (cause) { // Only this owned native destroy identity can recover the invocation's cancellation reason. // An unrelated read fault racing an aborted signal keeps its original infrastructure diagnosis. - if (cause === cancelled) signal?.throwIfAborted(); + if (cause === input.cancelled) signal?.throwIfAborted(); options?.onFailure(cause); throw cause; } finally { - signal?.removeEventListener('abort', abort); - await closed; - native?.removeListener('close', onClose); + await input.close(); } } diff --git a/apps/cli/src/commands/chat-export.ts b/apps/cli/src/commands/chat-export.ts index d90ef704..2d7290a7 100644 --- a/apps/cli/src/commands/chat-export.ts +++ b/apps/cli/src/commands/chat-export.ts @@ -1,4 +1,4 @@ -import type { CommandLifetime } from '../process/command-lifetime.js'; +import { acquireCommandResource, type CommandLifetime } from '../process/command-lifetime.js'; import { closeQuietly } from '../process/close-quietly.js'; import { SessionExportedEventSchema } from '@relavium/shared'; @@ -32,7 +32,10 @@ export interface ChatExportCommandDeps { readonly io: CliIo; readonly global: GlobalOptions; /** Injectable session-store opener — tests pass an in-memory store; production opens `~/.relavium/history.db`. */ - readonly openSessionStore?: (homeDir: string) => OpenedSessionStore; + readonly openSessionStore?: ( + homeDir: string, + captureClose?: (close: () => void) => void, + ) => OpenedSessionStore; /** Wall-clock (ms) for the `exported` row timestamp + the event timestamp (injectable for tests). */ readonly now?: () => number; } @@ -46,8 +49,9 @@ export function chatExportCommand( cwd: deps.global.cwd, configPath: deps.global.configPath, }); - const opened = (deps.openSessionStore ?? openSessionStore)(homeDir); - deps.commandLifetime?.adoptCleanup(() => opened.close()); + const opened = acquireCommandResource(deps.commandLifetime, (captureClose) => + (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose), + ); try { const result = exportSession({ store: opened.store, diff --git a/apps/cli/src/commands/chat-list.ts b/apps/cli/src/commands/chat-list.ts index a0e785d4..81602415 100644 --- a/apps/cli/src/commands/chat-list.ts +++ b/apps/cli/src/commands/chat-list.ts @@ -1,4 +1,4 @@ -import type { CommandLifetime } from '../process/command-lifetime.js'; +import { acquireCommandResource, type CommandLifetime } from '../process/command-lifetime.js'; import { closeQuietly } from '../process/close-quietly.js'; import type { AgentSessionRecord } from '@relavium/shared'; @@ -15,7 +15,10 @@ export interface ChatListCommandDeps { readonly io: CliIo; readonly global: GlobalOptions; /** Injectable session-store opener — tests pass an in-memory store; production opens `~/.relavium/history.db`. */ - readonly openSessionStore?: (homeDir: string) => OpenedSessionStore; + readonly openSessionStore?: ( + homeDir: string, + captureClose?: (close: () => void) => void, + ) => OpenedSessionStore; } /** @@ -31,8 +34,9 @@ export function chatListCommand(deps: ChatListCommandDeps): ExitCode { cwd: deps.global.cwd, configPath: deps.global.configPath, }); - const opened = (deps.openSessionStore ?? openSessionStore)(homeDir); - deps.commandLifetime?.adoptCleanup(() => opened.close()); + const opened = acquireCommandResource(deps.commandLifetime, (captureClose) => + (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose), + ); try { if (deps.global.json) { opened.store.visitSessionSummaries((session) => { diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index a11f5b5d..cc0c6ee5 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -371,7 +371,10 @@ export interface ChatCommandDeps { * just-ended session under a new-model agent. Default {@link buildResumedChatSession}. */ readonly buildResumedSession?: typeof buildResumedChatSession; /** Injectable session-store opener (tests pass an in-memory store). Default {@link openSessionStore}. */ - readonly openSessionStore?: (homeDir: string) => OpenedSessionStore; + readonly openSessionStore?: ( + homeDir: string, + captureClose?: (close: () => void) => void, + ) => OpenedSessionStore; /** The MCP named-secret resolver (2.R Step 4) — production injects the keychain-backed one (specs.ts); default env-only. */ readonly mcpSecretResolver?: McpSecretResolver; readonly resolveKeys?: ( @@ -410,7 +413,10 @@ export interface ChatResumeCommandDeps { * brand-new session (not a re-resume) bound to the resumed agent. Default {@link buildChatSession}. */ readonly buildSession?: typeof buildChatSession; /** Injectable session-store opener (tests pass an in-memory store). Default {@link openSessionStore}. */ - readonly openSessionStore?: (homeDir: string) => OpenedSessionStore; + readonly openSessionStore?: ( + homeDir: string, + captureClose?: (close: () => void) => void, + ) => OpenedSessionStore; /** The MCP named-secret resolver (2.R Step 4) — production injects the keychain-backed one; default env-only. */ readonly mcpSecretResolver?: McpSecretResolver; readonly resolveKeys?: ( @@ -674,8 +680,12 @@ async function chatCommandBody( // Acquire the store first; on failure tear the MCP children down (best-effort — never mask the open error). let opened: OpenedSessionStore; try { - opened = (deps.openSessionStore ?? openSessionStore)(homeDir); - ownership?.adoptStore(opened); + opened = + ownership === undefined + ? (deps.openSessionStore ?? openSessionStore)(homeDir) + : ownership.acquireStore((captureClose) => + (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose), + ); } catch (err) { if (host !== undefined) { host.work.failure(err); @@ -863,8 +873,12 @@ async function chatResumeCommandBody( const grants = admitFsInvocation(deps.global); const homeDir = resolveHomeDir({}); - const opened = (deps.openSessionStore ?? openSessionStore)(homeDir); - ownership?.adoptStore(opened); + const opened = + ownership === undefined + ? (deps.openSessionStore ?? openSessionStore)(homeDir) + : ownership.acquireStore((captureClose) => + (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose), + ); const prepare = async (host?: SessionHostOwner) => { let config: LoadedConfig['config']; let projectConfigDir: LoadedConfig['projectConfigDir']; diff --git a/apps/cli/src/commands/dispatch.ts b/apps/cli/src/commands/dispatch.ts index 116048ff..0a6b3fc1 100644 --- a/apps/cli/src/commands/dispatch.ts +++ b/apps/cli/src/commands/dispatch.ts @@ -31,7 +31,7 @@ import { createMcpSecretResolver } from '../secrets/mcp-secret.js'; import { createOsKeychainStore } from '../secrets/os-keychain.js'; import { readSecretFromStdin } from '../secrets/read-secret.js'; import { Readable } from 'node:stream'; -import { withCommandLifetime } from '../process/command-lifetime.js'; +import { acquireCommandResource, withCommandLifetime } from '../process/command-lifetime.js'; import { withSynchronousResource } from '../process/synchronous-resource.js'; import { agentRunCommand, type AgentRunCommandArgs } from './agent-run.js'; import { chatCommand, chatResumeCommand, type ChatCommandArgs } from './chat.js'; @@ -385,7 +385,7 @@ function storeAwareResolver( }); return withSynchronousResource( ctx.invocation, - () => openLocalDb(homeDir), + (captureClose) => openLocalDb(homeDir, undefined, captureClose), ({ db }) => fromDb(db), ); } @@ -520,7 +520,7 @@ export function createProviderSlugResolver( * touching the real `history.db` or loading the native keychain. */ export interface ModelsDbPorts { - readonly openDb: (homeDir: string) => OpenedDb; + readonly openDb: (homeDir: string, captureClose?: (close: () => void) => void) => OpenedDb; /** Build the key resolver over the models db — the `providerStore` (from the SAME db) makes it store-aware so a * custom `base_url` lists models over the SSRF-validated hop (2.5.G S9, ADR-0065 §4). A test stub may ignore it. */ readonly makeResolver: ( @@ -541,7 +541,7 @@ export interface ModelsDbPorts { } const PRODUCTION_MODELS_PORTS: ModelsDbPorts = { - openDb: openLocalDb, + openDb: (homeDir, captureClose) => openLocalDb(homeDir, undefined, captureClose), makeResolver: (io, providerStore) => createProviderResolver(io.env, createOsKeychainStore(), { providerStore }), refreshCatalog: (homeDir, persist, lifecycle) => @@ -573,8 +573,9 @@ async function withModelsDepsOwned( cwd: ctx.global.cwd, configPath: ctx.global.configPath, }); - const { db, close } = ports.openDb(homeDir); - ctx.commandLifetime?.adoptCleanup(close); + const { db, close } = acquireCommandResource(ctx.commandLifetime, (captureClose) => + ports.openDb(homeDir, captureClose), + ); try { const storeDeps = { uuid: () => randomUUID(), now: () => Date.now() }; // The `models` command prices, and it holds the db — so make the DB the catalog's authoritative backing here @@ -678,8 +679,9 @@ const executeModelsPricing: CommandExecutor = (input, ctx) => { cwd: ctx.global.cwd, configPath: ctx.global.configPath, }); - const { db, close } = openLocalDb(homeDir); - ctx.commandLifetime?.adoptCleanup(close); + const { db, close } = acquireCommandResource(ctx.commandLifetime, (captureClose) => + openLocalDb(homeDir, undefined, captureClose), + ); try { const storeDeps = { uuid: () => randomUUID(), now: () => Date.now() }; return Promise.resolve( @@ -713,8 +715,9 @@ async function withProviderDepsOwned( cwd: ctx.global.cwd, configPath: ctx.global.configPath, }); - const { db, close } = openLocalDb(homeDir); - ctx.commandLifetime?.adoptCleanup(close); + const { db, close } = acquireCommandResource(ctx.commandLifetime, (captureClose) => + openLocalDb(homeDir, undefined, captureClose), + ); try { const keychain = createOsKeychainStore(); // one native accessor, shared by the store-ref writes + the resolver const store = createProviderStore(db, { uuid: () => randomUUID(), now: () => Date.now() }); diff --git a/apps/cli/src/commands/gate-list.ts b/apps/cli/src/commands/gate-list.ts index d0049566..ca66d605 100644 --- a/apps/cli/src/commands/gate-list.ts +++ b/apps/cli/src/commands/gate-list.ts @@ -1,4 +1,4 @@ -import type { CommandLifetime } from '../process/command-lifetime.js'; +import { acquireCommandResource, type CommandLifetime } from '../process/command-lifetime.js'; import type { Db, RunHistoryReader } from '@relavium/db'; import { loadResolvedConfig } from '../config/load.js'; @@ -28,7 +28,10 @@ export interface GateListCommandDeps { readonly commandLifetime?: CommandLifetime; readonly io: CliIo; readonly global: GlobalOptions; - readonly openDb?: (homeDir: string) => { db: Db; close: () => void }; + readonly openDb?: ( + homeDir: string, + captureClose?: (close: () => void) => void, + ) => { db: Db; close: () => void }; } /** @@ -48,8 +51,9 @@ export function gateListCommand(args: GateListCommandArgs, deps: GateListCommand cwd: deps.global.cwd, configPath: deps.global.configPath, }); - const { reader, close } = openHistoryReader(homeDir, deps.openDb); - deps.commandLifetime?.adoptCleanup(close); + const { reader, close } = acquireCommandResource(deps.commandLifetime, (captureClose) => + openHistoryReader(homeDir, deps.openDb, captureClose), + ); try { // Per-run isolation (`readPerRunOrDegrade`) for the no-arg ALL-RUNS form: one damaged run used to abort the // whole listing. With an explicit `runId` there is only one run, so degrading would silently answer "no diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index cf8fe517..d730b36b 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -1,3 +1,4 @@ +import { invocationFixture } from '../process/invocation.test-support.js'; import { createCliInvocation } from '../process/invocation.js'; import { witnessNativeToolProcess } from './native-forwarding.test-support.js'; import { randomUUID } from 'node:crypto'; @@ -241,19 +242,38 @@ describe('gateCommand', () => { it('does not promote an injected errno-shaped opener failure to storage', async () => { const cause = Object.assign(new Error('PRIVATE_FOREIGN_OPEN_FAILURE'), { code: 'ENOSPC' }); - await expect( - gateCommand( - { runId: 'synthetic', approve: true }, - { - io: captureIo().io, - global: globalOptions(), - invocation: createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }), - openDb: () => { - throw cause; - }, + const control = invocationFixture(); + const removeSignals = vi.fn(); + let settled = false; + void gateCommand( + { runId: 'synthetic', approve: true }, + { + io: captureIo().io, + global: globalOptions(), + invocation: control.invocation, + subscribeSignals: () => removeSignals, + openDb: () => { + throw cause; }, - ), - ).rejects.toMatchObject({ code: 'cli_internal', exitCode: 9, cause }); + }, + ).then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(toUserFacing(cause)).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + expect(control.registry.observations.exitCode()).toBe(9); + expect(control.registry.activeOwners, 'UNKNOWN_PARTIAL_GATE_OPEN_IS_NOT_RETIREMENT').toBe(1); + expect(removeSignals).not.toHaveBeenCalled(); + expect(settled).toBe(false); + control.advance(3_000); + expect(await control.invocation.finiteExit.exited).toBe(9); + expect(control.registry.activeOwners).toBe(1); + expect(settled).toBe(false); }); let client: DbClient; diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index e4a1a35f..752765ed 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -1,3 +1,4 @@ +import { acquireNativeResource } from '../process/acquire-native-resource.js'; import type { CliInvocation } from '../process/invocation.js'; import type { defaultSubscribeSignals } from '../process/signals.js'; import { warnTeardown } from '../process/close-quietly.js'; @@ -152,7 +153,10 @@ export interface GateCommandDeps { /** Injectable provider seam (the engine's resolver for a post-gate agent node). Defaults to the env resolver. */ readonly providers?: ProviderResolver; /** Injectable history-db opener — tests pass an in-memory db; production opens `~/.relavium/history.db`. */ - readonly openDb?: (homeDir: string) => { db: Db; close: () => void }; + readonly openDb?: ( + homeDir: string, + captureClose?: (close: () => void) => void, + ) => { db: Db; close: () => void }; readonly selectRenderer?: ( io: CliIo, global: GlobalOptions, @@ -294,12 +298,7 @@ async function resumeGateOwned( cwd: deps.global.cwd, configPath: deps.global.configPath, }); - let opened: { db: Db; close: () => void }; - try { - opened = (deps.openDb ?? openLocalDb)(homeDir); - } catch (err) { - throw storageBoundaryError(err); - } + let closeHistory: (() => void) | undefined; let mcpRuntime: WorkflowMcpRuntime | undefined; let mcpAcknowledged = true; @@ -317,6 +316,20 @@ async function resumeGateOwned( }; try { lifetime.checkpoint(); + const opened = acquireNativeResource( + (captureClose) => + deps.openDb === undefined + ? openLocalDb(homeDir, undefined, captureClose) + : deps.openDb(homeDir, captureClose), + { + assertAdmitting: () => lifetime.checkpoint(), + captureClose: (close) => { + closeHistory = close; + }, + cleanupUncertain: (cause) => lifetime.cleanupFailed(cause), + isStoppedFailure: (cause) => lifetime.isStoppedFailure(cause), + }, + ); const snapshot = loadRunSnapshot(opened.db, args.runId); if (snapshot === undefined) { throw new CliError('invalid_invocation', `no run found with id ${args.runId}`); @@ -649,7 +662,7 @@ async function resumeGateOwned( } if (!mcpAcknowledged) await new Promise(() => {}); try { - opened.close(); + closeHistory?.(); } catch (cause) { lifetime.cleanupFailed(storageBoundaryError(cause)); warnTeardown(deps.io, 'history database'); diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts index 4c5b9bad..9238b869 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -1,4 +1,4 @@ -import type { CommandLifetime } from '../process/command-lifetime.js'; +import { acquireCommandResource, type CommandLifetime } from '../process/command-lifetime.js'; import type { Db, RunRecord } from '@relavium/db'; import { byCodeUnit } from '@relavium/shared'; @@ -21,7 +21,10 @@ export interface ListCommandDeps { readonly io: CliIo; readonly global: GlobalOptions; /** Injectable history-db opener — tests pass an in-memory db; production opens `~/.relavium/history.db`. */ - readonly openDb?: (homeDir: string) => { db: Db; close: () => void }; + readonly openDb?: ( + homeDir: string, + captureClose?: (close: () => void) => void, + ) => { db: Db; close: () => void }; /** Injectable catalog scanner — tests pass a fixture catalog without touching the filesystem. */ readonly discoverCatalog?: typeof discoverCatalog; } @@ -66,8 +69,9 @@ export function listCommand(args: ListCommandArgs, deps: ListCommandDeps): ExitC // would open the db for nothing. So overlay history only for valid entries (lookupHistory below). const lastBySlug = new Map(); if (kind === 'workflows' && entries.some((entry) => entry.valid)) { - const { reader, close } = openHistoryReader(homeDir, deps.openDb); - deps.commandLifetime?.adoptCleanup(close); + const { reader, close } = acquireCommandResource(deps.commandLifetime, (captureClose) => + openHistoryReader(homeDir, deps.openDb, captureClose), + ); try { for (const summary of reader.loadLatestRunPerWorkflow()) { lastBySlug.set(summary.slug, summary.lastRun); diff --git a/apps/cli/src/commands/logs.ts b/apps/cli/src/commands/logs.ts index a0401612..027b1555 100644 --- a/apps/cli/src/commands/logs.ts +++ b/apps/cli/src/commands/logs.ts @@ -1,4 +1,4 @@ -import type { CommandLifetime } from '../process/command-lifetime.js'; +import { acquireCommandResource, type CommandLifetime } from '../process/command-lifetime.js'; import type { Db } from '@relavium/db'; import type { RunEvent } from '@relavium/shared'; @@ -22,7 +22,10 @@ export interface LogsCommandDeps { readonly commandLifetime?: CommandLifetime; readonly io: CliIo; readonly global: GlobalOptions; - readonly openDb?: (homeDir: string) => { db: Db; close: () => void }; + readonly openDb?: ( + homeDir: string, + captureClose?: (close: () => void) => void, + ) => { db: Db; close: () => void }; } /** @@ -36,8 +39,9 @@ export function logsCommand(args: LogsCommandArgs, deps: LogsCommandDeps): ExitC cwd: deps.global.cwd, configPath: deps.global.configPath, }); - const { reader, close } = openHistoryReader(homeDir, deps.openDb); - deps.commandLifetime?.adoptCleanup(close); + const { reader, close } = acquireCommandResource(deps.commandLifetime, (captureClose) => + openHistoryReader(homeDir, deps.openDb, captureClose), + ); try { const run = reader.loadRun(args.runId); if (run === undefined) { diff --git a/apps/cli/src/commands/partial-db-acquisition.test.ts b/apps/cli/src/commands/partial-db-acquisition.test.ts new file mode 100644 index 00000000..a5e02e58 --- /dev/null +++ b/apps/cli/src/commands/partial-db-acquisition.test.ts @@ -0,0 +1,138 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; + +const routes = [ + { id: 'gate.list', positionals: [], options: {} }, + { id: 'list', positionals: [], options: {} }, + { id: 'logs', positionals: ['synthetic-run'], options: {} }, + { id: 'status', positionals: ['synthetic-run'], options: {} }, + { id: 'chat-list', positionals: [], options: {} }, + { id: 'chat-export', positionals: ['synthetic-session'], options: {} }, + { id: 'models', positionals: [], options: {} }, + { + id: 'models.pricing', + positionals: ['synthetic-model'], + options: { provider: 'anthropic', clear: true }, + }, + { id: 'provider.list', positionals: [], options: {} }, +]; + +describe('management custody before local database setup', () => { + it.each(routes.flatMap((route) => [false, true].map((closeFails) => ({ ...route, closeFails }))))( + '$id joins actual SQLite after setup fails; close failure=$closeFails', + async ({ id, positionals, options, closeFails }) => { + const root = mkdtempSync(join(tmpdir(), 'relavium-partial-db-')); + const actual = await import('@relavium/db'); + let opened: ReturnType | undefined; + let release: (() => void) | undefined; + let closeAttempts = 0; + const setupFailure = new Error('synthetic database setup failure'); + const closeFailure = new Error('synthetic native close failure'); + vi.stubEnv('HOME', root); + vi.stubEnv('USERPROFILE', root); + writeFileSync(join(root, 'config.toml'), ''); + mkdirSync(join(root, '.relavium', 'workflows'), { recursive: true }); + writeFileSync( + join(root, '.relavium', 'workflows', 'synthetic.relavium.yaml'), + `schema_version: '1.0' +workflow: + id: synthetic + nodes: + - { id: start, type: input } + - { id: end, type: output } + edges: + - { from: start, to: end } +`, + ); + vi.doMock('@relavium/db', () => ({ + ...actual, + createClient: (...args: Parameters) => { + const client = actual.createClient(...args); + opened = client; + const nativeClose = client.sqlite.close.bind(client.sqlite); + release = () => { + nativeClose(); + }; + client.sqlite.close = () => { + closeAttempts++; + if (closeFails) throw closeFailure; + return nativeClose(); + }; + return client; + }, + runMigrations: () => { + throw setupFailure; + }, + })); + vi.resetModules(); + try { + const { executeCommand } = await import('./dispatch.js'); + const { CommandLifetime } = await import('../process/command-lifetime.js'); + const { invocationFixture } = await import('../process/invocation.test-support.js'); + const { captureIo } = await import('../test-support.js'); + const f = invocationFixture(); + const captured = captureIo(); + const removeSignals = vi.fn(); + const lifetime = new CommandLifetime(f.invocation, captured.io, () => removeSignals); + let settled = false; + const command = lifetime.execute(() => + executeCommand( + id, + { positionals, options }, + { + io: lifetime.io, + invocation: f.invocation, + commandLifetime: lifetime, + global: { + cwd: root, + configPath: join(root, 'config.toml'), + json: true, + color: false, + verbosity: 'normal', + }, + }, + ), + ); + const outcome = command.then( + (value) => { + settled = true; + return value; + }, + (cause: unknown) => { + settled = true; + return cause; + }, + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(captured.out()).toBe(''); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.budget.deadline()).toBe(3_000); + if (closeFails) { + expect(settled, 'PARTIAL_DATABASE_FAILURE_RETAINS_ACTUAL_NATIVE_CLOSE').toBe(false); + expect(opened?.sqlite.open).toBe(true); + expect(f.registry.activeOwners).toBe(1); + expect(removeSignals).not.toHaveBeenCalled(); + expect(closeAttempts).toBe(2); // Opener's immediate release and adopted actual-handle cleanup. + f.advance(3_000); + expect(await f.invocation.finiteExit.exited).toBe(9); + expect(f.registry.activeOwners).toBe(1); + expect(opened?.sqlite.open).toBe(true); + } else { + expect(await outcome).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + expect(opened?.sqlite.open).toBe(false); + expect(f.registry.activeOwners).toBe(0); + expect(removeSignals).toHaveBeenCalledOnce(); + expect(closeAttempts).toBe(1); + } + } finally { + if (opened?.sqlite.open) release?.(); // Harness disposal never acknowledges the failed owner. + vi.doUnmock('@relavium/db'); + vi.resetModules(); + vi.unstubAllEnvs(); + rmSync(root, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts index ad8af18f..b50bfa78 100644 --- a/apps/cli/src/commands/run.test.ts +++ b/apps/cli/src/commands/run.test.ts @@ -1,3 +1,4 @@ +import { invocationFixture } from '../process/invocation.test-support.js'; import { createCliInvocation } from '../process/invocation.js'; import { witnessNativeToolProcess } from './native-forwarding.test-support.js'; import { randomUUID } from 'node:crypto'; @@ -1721,31 +1722,46 @@ workflow: return Promise.resolve(); }, }; - let caught: unknown; - try { - await runCommand( - { workflow: path, input: [], allowMcpStdio: [] }, - { - io, - global: globalOptions(), - providers: scriptedResolver([textTurn('done')]), - consentGate: PASS_CONSENT, - startMcpClient: () => - realStartMcpClient([ - { id: 'fs', toolsAllowlist: ['read'], open: () => Promise.resolve(conn) }, - ]), - openRunStore: () => { - throw Object.assign(new Error('PRIVATE_HISTORY_FAULT'), { code: 'EACCES' }); - }, + const control = invocationFixture(); + const removeSignals = vi.fn(); + const cause = Object.assign(new Error('PRIVATE_HISTORY_FAULT'), { code: 'EACCES' }); + let settled = false; + void runCommand( + { workflow: path, input: [], allowMcpStdio: [] }, + { + io, + global: globalOptions(), + invocation: control.invocation, + subscribeSignals: () => removeSignals, + providers: scriptedResolver([textTurn('done')]), + consentGate: PASS_CONSENT, + startMcpClient: () => + realStartMcpClient([ + { id: 'fs', toolsAllowlist: ['read'], open: () => Promise.resolve(conn) }, + ]), + openRunStore: () => { + throw cause; }, - ); - } catch (error) { - caught = error; - } - expect(isCliError(caught) && caught.code).toBe('cli_internal'); - expect(isCliError(caught) && caught.exitCode).toBe(EXIT_CODES.infrastructureFailed); - expect(isCliError(caught) && caught.message).not.toContain('PRIVATE_HISTORY_FAULT'); - expect(closed).toBe(1); // the spawned connection did not leak past the fault + }, + ).then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await vi.waitFor(() => expect(closed).toBe(1)); + expect(toUserFacing(cause)).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + expect(toUserFacing(cause).message).not.toContain('PRIVATE_HISTORY_FAULT'); + expect(control.registry.observations.exitCode()).toBe(9); + expect(control.registry.activeOwners, 'UNKNOWN_PARTIAL_RUN_OPEN_IS_NOT_RETIREMENT').toBe(1); + expect(removeSignals).not.toHaveBeenCalled(); + expect(settled).toBe(false); + control.advance(3_000); + expect(await control.invocation.finiteExit.exited).toBe(9); + expect(control.registry.activeOwners).toBe(1); + expect(settled).toBe(false); }); it('routes an MCP tool RESULT with isError:true through dispatch → engine as a RECOVERABLE error (run still completes)', async () => { diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index bb704f82..f18e6267 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -1,3 +1,4 @@ +import { acquireNativeResource } from '../process/acquire-native-resource.js'; import type { CliInvocation } from '../process/invocation.js'; import type { defaultSubscribeSignals } from '../process/signals.js'; import { warnTeardown } from '../process/close-quietly.js'; @@ -124,6 +125,7 @@ export interface RunCommandDeps { workflow: WorkflowDefinition, homeDir: string, projectRoot: string, + captureClose?: (close: () => void) => void, ) => OpenedHistory; /** * Injectable renderer selector (TUI / json / plain). Defaults to the real {@link selectRenderer}; tests @@ -249,6 +251,7 @@ async function runOwned( // Declared here, assigned AFTER the MCP connect below — the shared `finally` closes both resources, and // the store cannot be opened until the definition it must freeze is known. let opened: OpenedHistory | undefined; + let closeHistory: (() => void) | undefined; let mcpRuntime: WorkflowMcpRuntime | undefined; let mcpAcknowledged = true; const reportMcpCloseFailure = (_serverId: string, cause: unknown): void => { @@ -316,7 +319,20 @@ async function runOwned( // only be seen if what was frozen is what was executed. lifetime.checkpoint(); try { - opened = deps.openRunStore?.(runWorkflow, homeDir, deps.global.cwd); + const openRunStore = deps.openRunStore; + if (openRunStore !== undefined) { + opened = acquireNativeResource( + (captureClose) => openRunStore(runWorkflow, homeDir, deps.global.cwd, captureClose), + { + assertAdmitting: () => lifetime.checkpoint(), + captureClose: (close) => { + closeHistory = close; + }, + cleanupUncertain: (cause) => lifetime.cleanupFailed(cause), + isStoppedFailure: (cause) => lifetime.isStoppedFailure(cause), + }, + ); + } } catch (err) { // Classify only this open operation; engine/work faults retain their own boundary. throw storageBoundaryError(err); @@ -549,7 +565,7 @@ async function runOwned( } if (!mcpAcknowledged) await new Promise(() => {}); try { - opened?.close(); + closeHistory?.(); } catch (cause) { lifetime.cleanupFailed(storageBoundaryError(cause)); warnTeardown(deps.io, 'history database'); diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 12aca20a..cf06cc4b 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -1,4 +1,4 @@ -import type { CommandLifetime } from '../process/command-lifetime.js'; +import { acquireCommandResource, type CommandLifetime } from '../process/command-lifetime.js'; import type { RunEvent } from '@relavium/shared'; import type { Db, RunHistoryReader, RunRecord, StepRecord } from '@relavium/db'; @@ -26,7 +26,10 @@ export interface StatusCommandDeps { readonly commandLifetime?: CommandLifetime; readonly io: CliIo; readonly global: GlobalOptions; - readonly openDb?: (homeDir: string) => { db: Db; close: () => void }; + readonly openDb?: ( + homeDir: string, + captureClose?: (close: () => void) => void, + ) => { db: Db; close: () => void }; /** Injected in tests; production reads `~/.relavium/terminal-outbox.ndjson` through the file outbox. */ readonly readTerminalOutbox?: (homeDir: string) => Promise; } @@ -83,8 +86,9 @@ export async function statusCommand(deps: StatusCommandDeps): Promise cwd: deps.global.cwd, configPath: deps.global.configPath, }); - const { reader, close } = openHistoryReader(homeDir, deps.openDb); - deps.commandLifetime?.adoptCleanup(close); + const { reader, close } = acquireCommandResource(deps.commandLifetime, (captureClose) => + openHistoryReader(homeDir, deps.openDb, captureClose), + ); // Runs whose TERMINAL is held in the outbox (ADR-0078 §4/§5) — read, never drained. // // A run in that state is still `running` in the derived projection, so it appears here as an ordinary diff --git a/apps/cli/src/commands/surface-db-acquisition.test.ts b/apps/cli/src/commands/surface-db-acquisition.test.ts new file mode 100644 index 00000000..7a2b8854 --- /dev/null +++ b/apps/cli/src/commands/surface-db-acquisition.test.ts @@ -0,0 +1,186 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; + +const surfaces = ['run', 'gate', 'chat', 'chat-resume', 'home', 'agent', 'pricing'] as const; + +describe('original surface custody of partially opened SQLite', () => { + it.each([ + ...surfaces.flatMap((surface) => + [false, true].map((closeFails) => ({ surface, closeFails, closeRecovers: false })), + ), + { surface: 'pricing' as const, closeFails: true, closeRecovers: true }, + ])( + '$surface preserves partial acquisition; close failure=$closeFails; later release=$closeRecovers', + async ({ surface, closeFails, closeRecovers }) => { + const root = mkdtempSync(join(tmpdir(), 'relavium-surface-db-')); + const actual = await import('@relavium/db'); + let faultEnabled = !['chat', 'agent'].includes(surface); + let native: ReturnType | undefined; + let dispose: (() => void) | undefined; + let attempts = 0; + const primary = new Error('synthetic migration refusal'); + const secondary = new Error('synthetic native close refusal'); + vi.stubEnv('HOME', root); + vi.stubEnv('USERPROFILE', root); + writeFileSync(join(root, 'config.toml'), ''); + const agent = join(root, 'synthetic.agent.yaml'); + writeFileSync( + agent, + 'id: synthetic\nprovider: anthropic\nmodel: claude-sonnet-4-6\nsystem_prompt: Synthetic test.\ntools: []\n', + ); + const workflow = join(root, 'synthetic.relavium.yaml'); + writeFileSync( + workflow, + `schema_version: '1.0' +workflow: + id: synthetic + nodes: + - { id: start, type: input } + - { id: end, type: output } + edges: + - { from: start, to: end } +`, + ); + vi.doMock('@relavium/db', () => ({ + ...actual, + createClient: (...args: Parameters) => { + const opened = actual.createClient(...args); + if (faultEnabled) { + native = opened; + const nativeClose = opened.sqlite.close.bind(opened.sqlite); + dispose = () => { + nativeClose(); + }; + opened.sqlite.close = () => { + attempts++; + if (closeFails && (!closeRecovers || attempts === 1)) throw secondary; + return nativeClose(); + }; + } + return opened; + }, + runMigrations: (...args: Parameters) => { + if (faultEnabled) throw primary; + return actual.runMigrations(...args); + }, + })); + vi.resetModules(); + try { + const signals = await import('../process/signals.js'); + const removeSignals = vi.fn(); + vi.spyOn(signals, 'defaultSubscribeSignals').mockReturnValue(removeSignals); + const { invocationFixture } = await import('../process/invocation.test-support.js'); + const { captureIo } = await import('../test-support.js'); + const { scriptedResolver } = await import('../chat/test-support.js'); + const session = await import('../history/session-open.js'); + const nativeOpen = session.openSessionStore; + vi.spyOn(session, 'openSessionStore').mockImplementation((home, captureClose) => { + faultEnabled = true; + return nativeOpen(home, captureClose); + }); + const openSessionStore = session.openSessionStore; + const f = invocationFixture(); + const captured = captureIo(); + const common = { + invocation: f.invocation, + io: { ...captured.io, stdin: Readable.from(['synthetic prompt']) }, + global: { + cwd: root, + configPath: join(root, 'config.toml'), + json: true, + color: false, + verbosity: 'normal' as const, + }, + providers: scriptedResolver([]), + }; + const drive = async () => { + switch (surface) { + case 'run': { + const { runCommand } = await import('./run.js'); + const { openHistoryStore } = await import('../history/open.js'); + return runCommand( + { workflow, input: [], allowMcpStdio: [] }, + { ...common, openRunStore: openHistoryStore }, + ); + } + case 'gate': { + const { gateCommand } = await import('./gate.js'); + return gateCommand({ runId: 'synthetic-run', approve: true }, common); + } + case 'chat': { + const { chatCommand } = await import('./chat.js'); + return chatCommand({ agent }, { ...common, openSessionStore }); + } + case 'chat-resume': { + const { chatResumeCommand } = await import('./chat.js'); + return chatResumeCommand( + { sessionId: 'synthetic-session' }, + { ...common, openSessionStore }, + ); + } + case 'home': { + const { driveHome } = await import('../home/drive-home.js'); + return driveHome({ ...common, openSessionStore }); + } + case 'agent': { + const { agentRunCommand } = await import('./agent-run.js'); + return agentRunCommand({ agent, input: [], allowMcpStdio: [] }, common); + } + case 'pricing': { + const { loadUserPricingOverlay } = await import('../engine/pricing-overlay.js'); + return loadUserPricingOverlay(root, captured.io, f.invocation); + } + } + }; + let settled = false; + const result = drive().then( + (value) => { + settled = true; + return value; + }, + (cause: unknown) => { + settled = true; + return cause; + }, + ); + await vi.waitFor(() => + expect(attempts, 'ACTUAL_SURFACE_REACHED_SQLITE_SETUP').toBeGreaterThan(0), + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(captured.out()).toBe(''); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.budget.deadline()).toBe(3_000); + if (closeFails && !closeRecovers) { + expect(native?.sqlite.open).toBe(true); + expect(attempts, 'PARTIAL_SURFACE_RETAINS_NATIVE_CLOSE_CAPABILITY').toBe(2); + expect(f.registry.activeOwners).toBe(1); + expect(removeSignals).not.toHaveBeenCalled(); + // A synchronous transient operation rejects immediately while its native obligation stays owned. + expect(settled).toBe(surface === 'pricing'); + if (surface === 'pricing') + expect(await result).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + f.advance(3_000); + expect(await f.invocation.finiteExit.exited).toBe(9); + expect(native?.sqlite.open).toBe(true); + expect(f.registry.activeOwners).toBe(1); + } else { + expect(await result).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + expect(native?.sqlite.open).toBe(false); + expect(attempts).toBe(closeRecovers ? 2 : 1); + expect(f.registry.observations.hasInfrastructureFailure()).toBe(true); + expect(f.registry.activeOwners).toBe(0); + } + } finally { + if (native?.sqlite.open) dispose?.(); // Test disposal supplies no product retirement ACK. + vi.restoreAllMocks(); + vi.doUnmock('@relavium/db'); + vi.resetModules(); + vi.unstubAllEnvs(); + rmSync(root, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/apps/cli/src/db/open.test.ts b/apps/cli/src/db/open.test.ts index 2a368d67..86b4ea09 100644 --- a/apps/cli/src/db/open.test.ts +++ b/apps/cli/src/db/open.test.ts @@ -36,6 +36,51 @@ afterEach(() => { const modeOf = (path: string): number => statSync(path).mode & 0o777; +it('hands off native close before setup and preserves primary plus secondary cleanup failure', async () => { + const actual = await import('@relavium/db'); + let client: ReturnType | undefined; + let release: (() => void) | undefined; + let captured: (() => void) | undefined; + const primary = new Error('synthetic setup cause'); + const secondary = new Error('synthetic close cause'); + vi.doMock('@relavium/db', () => ({ + ...actual, + createClient: (...args: Parameters) => { + client = actual.createClient(...args); + release = client.sqlite.close.bind(client.sqlite); + client.sqlite.close = () => { + throw secondary; + }; + return client; + }, + runMigrations: () => { + expect(captured, 'NATIVE_DATABASE_CLOSE_HANDOFF_PRECEDES_SETUP').toBeTypeOf('function'); + throw primary; + }, + })); + vi.resetModules(); + try { + const { openLocalDb } = await import('./open.js'); + let failure: unknown; + try { + openLocalDb(home, undefined, (close) => { + captured = close; + }); + } catch (cause) { + failure = cause; + } + expect(failure).toMatchObject({ + code: 'cli_internal', + exitCode: 9, + cause: { cause: primary, errors: [primary, secondary] }, + }); + expect(client?.sqlite.open).toBe(true); + expect(captured).toBeTypeOf('function'); + } finally { + if (client?.sqlite.open) release?.(); + } +}); + describe('openLocalDb — the at-rest 0600 (#28, ADR-0050)', () => { it.skipIf(!POSIX)('applies 0600 on a successful open', async () => { const { openLocalDb } = await import('./open.js'); diff --git a/apps/cli/src/db/open.ts b/apps/cli/src/db/open.ts index f1e2ebe9..afdb42c7 100644 --- a/apps/cli/src/db/open.ts +++ b/apps/cli/src/db/open.ts @@ -10,6 +10,7 @@ import { } from '../process/owned-storage.js'; import { processIo } from '../process/io.js'; import { CHECKPOINT_DEFERRED } from './privacy-notice.js'; +import { CliError } from '../process/cli-error.js'; /** An opened local database plus the handle to close its SQLite connection. */ export interface OpenedDb { @@ -26,21 +27,34 @@ export interface OpenedDb { export function openLocalDb( homeDir: string, notice: (text: string) => void | Promise = (text) => processIo().writeErrAcknowledged(text), + captureClose?: (close: () => void) => void, ): OpenedDb { const dir = globalConfigDir(homeDir); mkdirForStorage(dir); chmodForStorage(dir, 0o700); const path = join(globalConfigDir(homeDir), 'history.db'); let client: ReturnType; + let nativeClose: (() => void) | undefined; try { - client = createClient(path); + client = createClient(path, (close) => { + nativeClose = close; + captureClose?.(close); + }); } catch (error) { throw storageBoundaryError(error); } let checkpoint: SessionEffectCheckpoint; + const close = + nativeClose ?? + (() => { + if (client.sqlite.open) client.sqlite.close(); + }); // If setup (migrations / the at-rest chmod) throws, close the just-opened handle before propagating — // otherwise the SQLite connection leaks for the lifetime of the failing process. try { + // The original owner receives the actual native capability BEFORE setup can throw. + // Legacy injected factories may not implement the optional early handoff. + if (nativeClose === undefined) captureClose?.(close); // #28: the 0600 runs BEFORE the migrations, and again after. Before, because `createClient` has already // CREATED the file at the process umask — typically 644 — and a migration that throws (disk full, an // interrupted first run) used to leave it there, world-readable, for the lifetime of the install. The @@ -52,7 +66,15 @@ export function openLocalDb( checkpoint = runMigrations(client.db, { dbPath: client.path }); hardenAtRest(path); } catch (err) { - client.sqlite.close(); + try { + close(); + } catch (cleanupCause) { + throw new CliError('cli_internal', '', { + cause: new AggregateError([err, cleanupCause], 'Local database cleanup failed.', { + cause: err, + }), + }); + } throw storageBoundaryError(err); } if (checkpoint === 'deferred') { @@ -69,11 +91,7 @@ export function openLocalDb( db: client.db, // Idempotent: better-sqlite3's close() throws on an already-closed handle, so guard on `.open` // — a double close (e.g. an error-recovery path that also closes in a finally) is then a no-op. - close: () => { - if (client.sqlite.open) { - client.sqlite.close(); - } - }, + close, }; } diff --git a/apps/cli/src/engine/pricing-overlay.test.ts b/apps/cli/src/engine/pricing-overlay.test.ts index 57f9f475..547fc233 100644 --- a/apps/cli/src/engine/pricing-overlay.test.ts +++ b/apps/cli/src/engine/pricing-overlay.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -15,6 +15,7 @@ import { clearCatalogRefresh, priceModel } from '@relavium/llm'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { captureIo } from '../test-support.js'; +import { toUserFacing } from '../process/errors.js'; import { openLocalDb } from '../db/open.js'; import { catalogModelToRow } from './catalog-metadata.js'; import { @@ -158,12 +159,24 @@ describe('loadUserPricingOverlay (self-contained transient open)', () => { expect(overlay?.get('acme-custom-1')?.inputPerMtokMicrocents).toBe(300_000_000); }); - it('returns undefined NON-FATALLY when the db path is unopenable (the surface reports the fault itself)', () => { + it('degrades a cleaned native storage refusal without inventing free pricing', () => { + mkdirSync(join(home, '.relavium')); + writeFileSync(join(home, '.relavium', 'history.db'), 'synthetic corrupt header'.repeat(8)); + expect(loadUserPricingOverlay(home, captureIo().io)).toBeUndefined(); + }); + + it('does not swallow an unclassified acquisition fault as an optional cache miss', () => { // Put a regular FILE where the home dir's `.relavium/` would go: `ensureGlobalConfigDir` can't `mkdir` a - // directory under a file (ENOTDIR), so `openLocalDb` throws — and the loader degrades to `undefined` rather - // than propagating (the surface's own session/run store open is the authoritative fault report, a clean exit 2). + // directory under a file (ENOTDIR). This lies outside the Accepted operational-storage vocabulary. const blocker = join(home, 'blocker'); writeFileSync(blocker, 'not a directory'); - expect(loadUserPricingOverlay(blocker, captureIo().io)).toBeUndefined(); + let failure: unknown; + try { + loadUserPricingOverlay(blocker, captureIo().io); + } catch (cause) { + failure = cause; + } + expect(failure, 'UNCLASSIFIED_PRICING_ACQUISITION_MUST_ESCAPE').toBeDefined(); + expect(toUserFacing(failure)).toMatchObject({ code: 'cli_internal', exitCode: 9 }); }); }); diff --git a/apps/cli/src/engine/pricing-overlay.ts b/apps/cli/src/engine/pricing-overlay.ts index 9e48137a..e98e2eef 100644 --- a/apps/cli/src/engine/pricing-overlay.ts +++ b/apps/cli/src/engine/pricing-overlay.ts @@ -2,6 +2,7 @@ import { closeQuietly } from '../process/close-quietly.js'; import type { CliIo } from '../process/io.js'; import type { CliInvocation } from '../process/invocation.js'; import { withSynchronousResource } from '../process/synchronous-resource.js'; +import { toUserFacing } from '../process/errors.js'; import { randomUUID } from 'node:crypto'; import { @@ -90,9 +91,9 @@ function readPricingRows(db: Db): PricingOverlay { /** * Load the overlay via a SELF-CONTAINED transient open→read→close, for a surface with no db handle of its own yet - * (`relavium chat`, one-shot `agent run`). Deliberately NON-FATAL: an unopenable/unmigratable `history.db` yields - * `undefined` (cost governance degrades to the no-overlay behavior — unknown models `allow` loudly), so the db - * fault surfaces through the surface's OWN store-open error path (a clean exit 2), not an opaque throw here. The + * (`relavium chat`, one-shot `agent run`). A classified operational storage refusal yields `undefined`; + * an unknown acquisition or failed native cleanup remains an infrastructure failure. The authoritative + * store reports its own operational refusal. The * transient handle closes before the surface opens its real store — sequential opens of the one SQLite file. */ export function loadUserPricingOverlay( @@ -102,12 +103,13 @@ export function loadUserPricingOverlay( ): PricingOverlay | undefined { return withSynchronousResource( invocation, - () => { + (captureClose) => { let opened: { db: Db; close: () => void }; try { - opened = openLocalDb(homeDir); - } catch { - // Preserve optional open/read degradation; the authoritative store reports its own failure. + opened = openLocalDb(homeDir, undefined, captureClose); + } catch (cause) { + // An optional cache cannot launder partial construction or a failed native close into success. + if (toUserFacing(cause).code !== 'storage_unavailable') throw cause; return { db: undefined, close: () => undefined }; } return { diff --git a/apps/cli/src/history/open.ts b/apps/cli/src/history/open.ts index 40c0acae..d938b7e2 100644 --- a/apps/cli/src/history/open.ts +++ b/apps/cli/src/history/open.ts @@ -48,8 +48,9 @@ export function openHistoryStore( workflow: WorkflowDefinition, homeDir: string, projectRoot: string, + captureClose?: (close: () => void) => void, ): OpenedHistory { - const { db, close } = openLocalDb(homeDir); + const { db, close } = openLocalDb(homeDir, undefined, captureClose); const store = createRunHistoryStore(db, { uuid: () => randomUUID(), now: () => Date.now(), diff --git a/apps/cli/src/history/reader.ts b/apps/cli/src/history/reader.ts index 7c85010e..9e1b03c3 100644 --- a/apps/cli/src/history/reader.ts +++ b/apps/cli/src/history/reader.ts @@ -17,13 +17,26 @@ export interface OpenedReader { */ export function openHistoryReader( homeDir: string, - openDb: (homeDir: string) => { db: Db; close: () => void } = openLocalDb, + openDb?: ( + homeDir: string, + captureClose?: (close: () => void) => void, + ) => { db: Db; close: () => void }, + captureClose?: (close: () => void) => void, ): OpenedReader { let opened: { db: Db; close: () => void }; + let nativeClose: (() => void) | undefined; + const capture = (close: () => void): void => { + nativeClose = close; + captureClose?.(close); + }; try { - opened = openDb(homeDir); + opened = + openDb === undefined ? openLocalDb(homeDir, undefined, capture) : openDb(homeDir, capture); } catch (err) { throw storageBoundaryError(err); } - return { reader: createRunHistoryReader(opened.db), close: opened.close }; + // A legacy injected method may depend on its original receiver. Capture it before reader setup too. + const close = nativeClose ?? opened.close.bind(opened); + if (nativeClose === undefined) captureClose?.(close); + return { reader: createRunHistoryReader(opened.db), close }; } diff --git a/apps/cli/src/history/session-open.ts b/apps/cli/src/history/session-open.ts index 7cac272c..0d5a8619 100644 --- a/apps/cli/src/history/session-open.ts +++ b/apps/cli/src/history/session-open.ts @@ -22,10 +22,13 @@ export interface OpenedSessionStore { * Owned storage-open faults are operational 1. Unknown native/import/wiring faults are * infrastructure 9; neither can be diagnosed from arbitrary messages or cause trees. */ -export function openSessionStore(homeDir: string): OpenedSessionStore { +export function openSessionStore( + homeDir: string, + captureClose?: (close: () => void) => void, +): OpenedSessionStore { let opened: { db: Db; close: () => void }; try { - opened = openLocalDb(homeDir); + opened = openLocalDb(homeDir, undefined, captureClose); } catch (err) { throw storageBoundaryError(err); } diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index 1c62b5a2..f0a1962d 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -125,7 +125,10 @@ export interface HomeDeps { /** Injectable RESUMED-session builder — used by the in-Home `/models` reseat (ADR-0059). Default {@link buildResumedChatSession}. */ readonly buildResumedSession?: typeof buildResumedChatSession; /** Injectable session-store opener (tests pass an in-memory store). Default {@link openSessionStore}. */ - readonly openSessionStore?: (homeDir: string) => OpenedSessionStore; + readonly openSessionStore?: ( + homeDir: string, + captureClose?: (close: () => void) => void, + ) => OpenedSessionStore; readonly mcpSecretResolver?: McpSecretResolver; /** The `/doctor` probes (2.5.C S5) — production assembles the real probes; a test injects a fake. */ readonly doctorProbes?: DoctorProbes; @@ -235,8 +238,12 @@ async function driveHomeBody(deps: HomeDeps, ownership?: SessionHomeLifetime): P const keychain = createOsKeychainStore(); const mcpSecretResolver = deps.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env, keychain); - const opened = (deps.openSessionStore ?? openSessionStore)(homeDir); - ownership?.adoptStore(opened); + const opened = + ownership === undefined + ? (deps.openSessionStore ?? openSessionStore)(homeDir) + : ownership.acquireStore((captureClose) => + (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose), + ); // The provider resolver + `/doctor` probes are built INSIDE the try below — once `opened.db` + the `providerStore` // exist — so the resolver is STORE-AWARE (a custom `base_url` rebinds to its validated endpoint, 2.5.G S9); a test // still injects `deps.providers` / `deps.doctorProbes` to bypass the keychain/network. diff --git a/apps/cli/src/home/session-home-lifetime.ts b/apps/cli/src/home/session-home-lifetime.ts index a16ca1db..f2456d40 100644 --- a/apps/cli/src/home/session-home-lifetime.ts +++ b/apps/cli/src/home/session-home-lifetime.ts @@ -1,3 +1,4 @@ +import { acquireNativeResource } from '../process/acquire-native-resource.js'; import type { CliInvocation } from '../process/invocation.js'; import type { ExitCode } from '../process/exit-codes.js'; import { defaultSubscribeSignals } from '../process/signals.js'; @@ -33,6 +34,22 @@ export class SessionHomeLifetime { this.hosts = new SessionHosts(this.surface, 'home'); } + /** Capture the shared connection before native setup; release remains after every original host/tail. */ + acquireStore void }>( + factory: (captureClose: (close: () => void) => void) => Resource, + ): Resource { + if (this.#store !== undefined) throw new CliError('cli_internal', ''); + return acquireNativeResource(factory, { + assertAdmitting: this.surface.work.assertAdmitting, + captureClose: (close) => { + if (this.#store !== undefined) throw new CliError('cli_internal', ''); + this.#store = { close }; + }, + cleanupUncertain: this.surface.work.cleanupUncertain, + isStoppedFailure: this.surface.work.isStoppedFailure, + }); + } + adoptStore(store: { readonly close: () => void }): void { if (this.#store !== undefined) throw new CliError('cli_internal', ''); this.#store = store; diff --git a/apps/cli/src/process/acquire-native-resource.test.ts b/apps/cli/src/process/acquire-native-resource.test.ts new file mode 100644 index 00000000..e50a34bb --- /dev/null +++ b/apps/cli/src/process/acquire-native-resource.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; +import { acquireNativeResource } from './acquire-native-resource.js'; + +describe('native resource handoff to an existing owner', () => { + it('preserves the original owner cancellation identity before any resource is acquired', () => { + const cancelled = new Error('synthetic original cancellation'); + const cleanupUncertain = vi.fn(); + expect(() => + acquireNativeResource( + () => { + throw cancelled; + }, + { + assertAdmitting: () => undefined, + captureClose: vi.fn(), + cleanupUncertain, + isStoppedFailure: (cause) => cause === cancelled, + }, + ), + ).toThrow(cancelled); + expect(cleanupUncertain).not.toHaveBeenCalled(); + }); + + it('retains an early native capability instead of a returned replacement wrapper', () => { + const nativeClose = vi.fn(); + const replacement = vi.fn(); + const retained: (() => void)[] = []; + const resource = acquireNativeResource( + (capture) => { + capture(nativeClose); + capture(nativeClose); + return { close: replacement }; + }, + { + assertAdmitting: () => undefined, + captureClose: (close) => { + retained.push(close); + }, + cleanupUncertain: vi.fn(), + }, + ); + Object.defineProperty(resource, 'close', { value: () => undefined }); + expect(retained).toEqual([nativeClose]); + retained[0]?.(); + expect(nativeClose).toHaveBeenCalledOnce(); + expect(replacement).not.toHaveBeenCalled(); + }); + + it('does not certify an unknown partial acquisition without a native capability', () => { + const cause = new Error('synthetic construction refusal'); + const cleanupUncertain = vi.fn(); + const captureClose = vi.fn(); + expect(() => + acquireNativeResource( + () => { + throw cause; + }, + { + assertAdmitting: () => undefined, + captureClose, + cleanupUncertain, + }, + ), + ).toThrow(cause); + expect(cleanupUncertain).toHaveBeenCalledExactlyOnceWith(cause); + expect(captureClose).not.toHaveBeenCalled(); + }); + + it('a failed setup with a captured connection leaves retirement to its actual native release', () => { + const cause = new Error('synthetic setup refusal'); + const close = vi.fn(); + const cleanupUncertain = vi.fn(); + const captureClose = vi.fn(); + expect(() => + acquireNativeResource( + (capture) => { + capture(close); + throw cause; + }, + { assertAdmitting: () => undefined, captureClose, cleanupUncertain }, + ), + ).toThrow(cause); + expect(captureClose).toHaveBeenCalledExactlyOnceWith(close); + expect(cleanupUncertain).not.toHaveBeenCalled(); + expect(close).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/cli/src/process/acquire-native-resource.ts b/apps/cli/src/process/acquire-native-resource.ts new file mode 100644 index 00000000..c43fb54a --- /dev/null +++ b/apps/cli/src/process/acquire-native-resource.ts @@ -0,0 +1,43 @@ +import { toUserFacing } from './errors.js'; + +interface NativeResourceCustody { + readonly assertAdmitting: () => void; + readonly captureClose: (close: () => void) => void; + readonly cleanupUncertain: (cause: unknown) => void; + readonly isStoppedFailure?: (cause: unknown) => boolean; +} + +/** Transfer partial acquisition to its existing owner; this helper never creates or retires an owner. */ +export function acquireNativeResource void }>( + factory: (captureClose: (close: () => void) => void) => Resource, + custody: NativeResourceCustody, +): Resource { + custody.assertAdmitting(); + const captured = new Set<() => void>(); + const captureClose = (close: () => void): void => { + if (captured.has(close)) return; + custody.captureClose(close); + captured.add(close); + }; + let resource: Resource; + try { + resource = factory(captureClose); + // A legacy injected factory has no partial-handoff protocol. Preserve its receiver on return. + // An early handoff already owns the native connection; a returned wrapper is not a second resource. + if (captured.size === 0) { + const nativeClose = resource.close; + captureClose(() => Reflect.apply(nativeClose, resource, [])); + } + } catch (cause) { + const code = toUserFacing(cause).code; + if ( + captured.size === 0 && + custody.isStoppedFailure?.(cause) !== true && + (code === 'cli_internal' || code === 'output_unavailable') + ) + custody.cleanupUncertain(cause); + throw cause; + } + custody.assertAdmitting(); + return resource; +} diff --git a/apps/cli/src/process/command-lifetime.test.ts b/apps/cli/src/process/command-lifetime.test.ts index e9b72a1d..553ff339 100644 --- a/apps/cli/src/process/command-lifetime.test.ts +++ b/apps/cli/src/process/command-lifetime.test.ts @@ -27,6 +27,100 @@ function fixture() { } describe('management command lifetime', () => { + it('adopts an early native close once across the returned-resource handoff', async () => { + const f = fixture(); + const close = vi.fn(); + await f.lifetime.execute(() => { + f.lifetime.acquire((captureClose) => { + captureClose(close); + captureClose(close); + return { close }; + }); + return Promise.resolve(0); + }); + expect(close).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + }); + + it('preserves the receiver of a legacy returned native resource', async () => { + const f = fixture(); + const resource = { + closed: false, + close() { + this.closed = true; + }, + }; + await f.lifetime.execute(() => { + expect(f.lifetime.acquire(() => resource)).toBe(resource); + return Promise.resolve(0); + }); + expect(resource.closed).toBe(true); + expect(f.registry.activeOwners).toBe(0); + }); + + it('retains unknown partial acquisition and signal custody through the original deadline', async () => { + const f = fixture(); + let settled = false; + const command = f.lifetime.execute(() => { + f.lifetime.acquire(() => { + throw new Error('synthetic unknown partial creation'); + }); + return Promise.resolve(0); + }); + void command.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await reactions(); + expect(f.registry.activeOwners, 'UNKNOWN_PARTIAL_COMMAND_ACQUISITION_IS_NOT_RETIREMENT').toBe( + 1, + ); + expect(f.removeSignals).not.toHaveBeenCalled(); + expect(settled).toBe(false); + f.advance(3_000); + expect(await f.invocation.finiteExit.exited).toBe(9); + expect(f.registry.activeOwners).toBe(1); + }); + + it('preserves a classified no-resource storage refusal', async () => { + const f = fixture(); + const refusal = new CliError('storage_unavailable', ''); + await expect( + f.lifetime.execute(() => { + f.lifetime.acquire(() => { + throw refusal; + }); + return Promise.resolve(0); + }), + ).rejects.toBe(refusal); + expect(f.registry.activeOwners).toBe(0); + expect(f.removeSignals).toHaveBeenCalledOnce(); + expect(f.registry.budget.deadline()).toBeUndefined(); + }); + + it('adopts a resource that returns after reentrant stop before admitting business use', async () => { + const f = fixture(); + const close = vi.fn(); + const use = vi.fn(); + await f.lifetime.execute(() => { + f.lifetime.acquire((captureClose) => { + captureClose(close); + f.signal(15); + return { close }; + }); + use(); + return Promise.resolve(0); + }); + expect(use).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + expect(await f.invocation.finiteExit.exited).toBe(143); + }); + it.each([0, 1, 2] as const)( 'preserves ordinary %s with a truthful no-health owner', async (code) => { diff --git a/apps/cli/src/process/command-lifetime.ts b/apps/cli/src/process/command-lifetime.ts index dfc988af..700ff394 100644 --- a/apps/cli/src/process/command-lifetime.ts +++ b/apps/cli/src/process/command-lifetime.ts @@ -6,6 +6,7 @@ import type { CliIo } from './io.js'; import { InvocationWorkOwner } from './invocation-work-owner.js'; import { defaultSubscribeSignals } from './signals.js'; import { subscribeWithSignalCustody } from './signal-custody.js'; +import { acquireNativeResource } from './acquire-native-resource.js'; /** One management operation, with no AgentSession label or workflow receipt authority. */ export class CommandLifetime { @@ -86,6 +87,21 @@ export class CommandLifetime { }); }; + /** Capture partial native resources before setup can fail; legacy injected openers adopt on return. */ + acquire void }>( + factory: (captureClose: (close: () => void) => void) => Resource, + ): Resource { + return acquireNativeResource(factory, { + assertAdmitting: () => this.work.assertAdmitting(), + captureClose: this.adoptCleanup, + isStoppedFailure: this.work.isStoppedFailure, + cleanupUncertain: (cause) => { + this.#cleanupFailed = true; + this.work.cleanupUncertain(cause); + }, + }); + } + async execute(body: () => Promise): Promise { let result: ExitCode = EXIT_CODES.success; let failure: { readonly cause: unknown } | undefined; @@ -146,3 +162,11 @@ export function withCommandLifetime( const lifetime = new CommandLifetime(ctx.invocation, ctx.io); return lifetime.execute(() => body({ ...ctx, io: lifetime.io, commandLifetime: lifetime })); } + +/** No opener selection/classification policy here; this only transfers exact acquisition custody. */ +export function acquireCommandResource void }>( + lifetime: CommandLifetime | undefined, + factory: (captureClose: (close: () => void) => void) => Resource, +): Resource { + return lifetime === undefined ? factory(() => {}) : lifetime.acquire(factory); +} diff --git a/apps/cli/src/process/invocation-work-owner.ts b/apps/cli/src/process/invocation-work-owner.ts index badcde1b..446960df 100644 --- a/apps/cli/src/process/invocation-work-owner.ts +++ b/apps/cli/src/process/invocation-work-owner.ts @@ -72,6 +72,11 @@ export class InvocationWorkOwner { return this.#firstFailure; } + /** Cleanup consumers must retain signal custody when no positive native release fact exists. */ + get hasCleanupUncertainty(): boolean { + return this.#cleanupUncertain; + } + /** Positive actual cleanup fact, shared with the invocation registry; expiry/rejection never resolves it. */ get retired(): Promise { return this.#retired; diff --git a/apps/cli/src/process/native-input.test.ts b/apps/cli/src/process/native-input.test.ts new file mode 100644 index 00000000..30f4493f --- /dev/null +++ b/apps/cli/src/process/native-input.test.ts @@ -0,0 +1,109 @@ +import { Readable } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import { CapturedNativeInput } from './native-input.js'; + +class HeldInput extends Readable { + destructions = 0; + release: () => void = () => {}; + override _read(): void {} + override _destroy(cause: Error | null, done: (cause: Error | null) => void): void { + this.destructions++; + this.release = () => done(cause); + } +} + +describe('shared consumed native input authority', () => { + it('a failed abort-listener removal still joins native delivery and retains cleanup uncertainty', async () => { + const input = new HeldInput(); + const controller = new AbortController(); + const failure = vi.fn(); + const remove = controller.signal.removeEventListener.bind(controller.signal); + controller.signal.removeEventListener = () => { + throw new Error('synthetic listener removal refusal'); + }; + const captured = new CapturedNativeInput( + input, + { signal: controller.signal, onFailure: failure }, + 'Synthetic stop.', + ); + captured.open(); + let settled = false; + void captured.close().then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(input.destructions).toBe(1); + expect(settled, 'FAILED_ABORT_REMOVAL_CANNOT_RELEASE_CONSUMED_INPUT').toBe(false); + expect(failure).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ code: 'cli_internal', exitCode: 9 }), + ); + input.release(); + await new Promise((resolve) => setImmediate(resolve)); + expect(input.closed).toBe(true); + expect(settled, 'DELIVERED_CLOSE_DOES_NOT_CLEAR_CLEANUP_UNCERTAINTY').toBe(false); + } finally { + controller.signal.removeEventListener = remove; + input.release(); + } + }); + + it('joins one actual close across cancellation and repeated cleanup, preserving prior callbacks', async () => { + const input = new HeldInput(); + const controller = new AbortController(); + const failure = vi.fn(); + const priorError = vi.fn(); + input.on('error', priorError); + const captured = new CapturedNativeInput( + input, + { signal: controller.signal, onFailure: failure }, + 'Synthetic stop.', + ); + captured.open(); + controller.abort(new Error('synthetic invocation stop')); + const closing = captured.close(); + expect(captured.close()).toBe(closing); + captured.requestClose(); + let joined = false; + void closing.then(() => { + joined = true; + }); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(input.destructions).toBe(1); + expect(input.destroyed).toBe(true); + expect(input.closed).toBe(false); + expect(joined, 'CONSUMED_INPUT_REQUEST_IS_NOT_CLOSE_ACK').toBe(false); + } finally { + input.release(); + } + await closing; + expect(input.closed).toBe(true); + expect(priorError).toHaveBeenCalledExactlyOnceWith(captured.cancelled); + expect(input.listeners('error')).toEqual([priorError]); + expect(failure).not.toHaveBeenCalled(); + }); + + it('stopped-before-acquisition leaves the unconsumed native input untouched', async () => { + const input = new HeldInput(); + const controller = new AbortController(); + const reason = new Error('synthetic stop before acquisition'); + controller.abort(reason); + const captured = new CapturedNativeInput( + input, + { signal: controller.signal, onFailure: vi.fn() }, + 'Synthetic stop.', + ); + expect(() => captured.open()).toThrow(reason); + await captured.close(); + expect(input.destructions).toBe(0); + expect(input.eventNames()).toEqual([]); + input.destroy(); + input.release(); + }); +}); diff --git a/apps/cli/src/process/native-input.ts b/apps/cli/src/process/native-input.ts new file mode 100644 index 00000000..cf7b8fa1 --- /dev/null +++ b/apps/cli/src/process/native-input.ts @@ -0,0 +1,100 @@ +import { Readable } from 'node:stream'; +import { CliError } from './cli-error.js'; + +interface InputLifecycle { + readonly signal: AbortSignal; + readonly onFailure: (cause: unknown) => void; +} + +/** One close-delivery authority for a consumed native input; generic iterables owe their own return. */ +export class CapturedNativeInput { + readonly cancelled: Error; + readonly #native: Readable | undefined; + readonly #lifecycle: InputLifecycle | undefined; + readonly #closed: Promise | undefined; + #acknowledgeClose: () => void = () => {}; + #started = false; + #destroyEntered = false; + #cleanupFailed = false; + #joining: Promise | undefined; + + constructor(input: unknown, lifecycle: InputLifecycle | undefined, cancellationMessage: string) { + this.#native = input instanceof Readable ? input : undefined; + this.#lifecycle = lifecycle; + this.cancelled = new Error(cancellationMessage); + this.#closed = + this.#native === undefined + ? undefined + : new Promise((resolve) => { + this.#acknowledgeClose = resolve; + }); + } + + readonly #onClose = (): void => this.#acknowledgeClose(); + // Cancellation can enter before the iterator installs its own error observer. Read classification + // still belongs to the caller; this guard prevents an unobserved error during partial acquisition. + readonly #onError = (): void => {}; + readonly #stop = (): void => this.requestClose(this.cancelled); + + #cleanupFailure(cause: unknown): void { + this.#cleanupFailed = true; + this.#lifecycle?.onFailure(new CliError('cli_internal', '', { cause })); + } + + /** Reserve native observation before the caller enters any iterator factory or first next. */ + open(): void { + this.#lifecycle?.signal.throwIfAborted(); + this.#started = true; + try { + this.#native?.on('error', this.#onError); + this.#native?.once('close', this.#onClose); + // Socket.closed (including internal closeEmitted/finished) is not an owned delivery receipt. + if (this.#native?.closed === true) throw new CliError('cli_internal', ''); + } catch (cause) { + this.#cleanupFailure(cause); + throw cause; + } + this.#lifecycle?.signal.addEventListener('abort', this.#stop, { once: true }); + if (this.#lifecycle?.signal.aborted === true) this.#stop(); + } + + /** Independent, finite cancellation request. Set the latch before native entry/reentry. */ + requestClose(reason?: Error): void { + if (!this.#started || this.#native === undefined || this.#destroyEntered) return; + this.#destroyEntered = true; + try { + this.#native.destroy(reason); + } catch (cause) { + this.#cleanupFailure(cause); + } + } + + /** Publish one join before callbacks; destruction/EOF/iterator return/deadline never grants ACK. */ + close(): Promise { + this.#joining ??= Promise.resolve().then(() => this.#finish()); + return this.#joining; + } + + async #finish(): Promise { + if (!this.#started) return; + this.requestClose(); + try { + this.#lifecycle?.signal.removeEventListener('abort', this.#stop); + } catch (cause) { + this.#cleanupFailure(cause); + } + await this.#closed; + for (const [event, callback] of [ + ['close', this.#onClose], + ['error', this.#onError], + ] as const) { + try { + this.#native?.removeListener(event, callback); + } catch (cause) { + this.#cleanupFailure(cause); + } + } + // The original owner's physical exit still bounds uncertainty; no second timer fabricates ACK. + if (this.#cleanupFailed) await new Promise(() => {}); + } +} diff --git a/apps/cli/src/process/synchronous-resource.ts b/apps/cli/src/process/synchronous-resource.ts index b1555e75..2371d7c1 100644 --- a/apps/cli/src/process/synchronous-resource.ts +++ b/apps/cli/src/process/synchronous-resource.ts @@ -1,6 +1,7 @@ import { CliError, toUserFacing } from './errors.js'; import type { CliInvocation } from './invocation.js'; import type { OwnerRetirement } from './invocation-registry.js'; +import { acquireNativeResource } from './acquire-native-resource.js'; /** * Short trusted native acquisitions remain synchronous: their positive close precedes the @@ -8,11 +9,11 @@ import type { OwnerRetirement } from './invocation-registry.js'; */ export function withSynchronousResource void }, Result>( invocation: CliInvocation | undefined, - acquire: () => Resource, + acquire: (captureClose: (close: () => void) => void) => Resource, use: (resource: Resource) => Result, ): Result { if (invocation === undefined) { - const resource = acquire(); + const resource = acquire(() => {}); try { return use(resource); } finally { @@ -24,7 +25,7 @@ export function withSynchronousResource complete = resolve; }); let resource: Resource | undefined; - let close: (() => void) | undefined; + const closes: (() => void)[] = []; let active = true; let attempted = false; let uncertain = false; @@ -32,8 +33,17 @@ export function withSynchronousResource if (active || attempted || uncertain) return; attempted = true; try { - close?.(); - close = undefined; + const failures: unknown[] = []; + for (const close of closes) { + try { + close(); + } catch (cause) { + failures.push(cause); + } + } + if (failures.length !== 0) + throw new AggregateError(failures, 'Native resource cleanup failed.'); + closes.length = 0; resource = undefined; complete({ kind: 'settled' }); } catch (cause) { @@ -56,10 +66,15 @@ export function withSynchronousResource try { invocation.registry.assertAdmitting(); try { - resource = acquire(); - const acquired = resource; - const nativeClose = acquired.close; - close = () => Reflect.apply(nativeClose, acquired, []); + resource = acquireNativeResource(acquire, { + assertAdmitting: () => invocation.registry.assertAdmitting(), + captureClose: (close) => { + closes.push(close); + }, + cleanupUncertain: () => { + uncertain = true; + }, + }); } catch (cause) { // Trusted owned acquisition adapters either return their close capability or classify // a cleaned-up refusal. Unknown partial construction never certifies resource retirement. @@ -67,7 +82,6 @@ export function withSynchronousResource toUserFacing(cause).code === 'cli_internal' || toUserFacing(cause).code === 'output_unavailable' ) { - uncertain = true; invocation.finiteExit.fail('cli_internal'); } throw cause; diff --git a/apps/cli/src/secrets/fixtures/native-close-child.ts b/apps/cli/src/secrets/fixtures/native-close-child.ts index 6435af90..48c3ea45 100644 --- a/apps/cli/src/secrets/fixtures/native-close-child.ts +++ b/apps/cli/src/secrets/fixtures/native-close-child.ts @@ -4,10 +4,27 @@ import { createCliInvocation } from '../../process/invocation.js'; import { toUserFacing } from '../../process/errors.js'; import { captureIo } from '../../test-support.js'; import { readSecretFromStdin } from '../read-secret.js'; +import { agentRunCommand } from '../../commands/agent-run.js'; +import { join } from 'node:path'; const mode = process.argv[2]; -if (!['factory', 'cap', 'cancel', 'predestroyed'].includes(mode ?? '')) +if ( + ![ + 'factory', + 'cap', + 'cancel', + 'predestroyed', + 'agent-eof', + 'agent-factory', + 'agent-cancel', + 'agent-predestroyed', + ].includes(mode ?? '') +) throw new Error('Fixture mode required.'); +const agent = mode?.startsWith('agent-') === true; +const action = agent ? mode?.slice('agent-'.length) : mode; +const root = process.argv[3]; +if (agent && root === undefined) throw new Error('Isolated agent fixture root required.'); const input = process.stdin; let releaseClose: (() => void) | undefined; let enteredClose: () => void = () => {}; @@ -49,46 +66,68 @@ const invocation = createCliInvocation({ }, setExitCode: () => {}, }); -const owner = new CommandLifetime(invocation, { ...captureIo().io, stdin: input }, () => () => {}); -owner.adoptCleanup(() => { +const io = { ...captureIo().io, stdin: input }; +const owner = agent ? undefined : new CommandLifetime(invocation, io, () => () => {}); +owner?.adoptCleanup(() => { cleaned = true; }); process.on('message', (message: unknown) => { if (message === 'release') releaseClose?.(); }); -if (mode === 'predestroyed') { +if (action === 'predestroyed') { input.destroy(); await heldClose; } -if (mode === 'factory') +if (action === 'factory') input[Symbol.asyncIterator] = () => { throw new Error('synthetic native iterator factory refusal'); }; -const reading = owner - .execute(async () => { - await readSecretFromStdin(undefined, input, { - signal: owner.work.signal, - onFailure: owner.work.failure, - }); - return 0; - }) - .then( - (returned) => { - settled = true; - result = { returned }; - }, - (cause: unknown) => { - settled = true; - const facing = toUserFacing(cause); - result = { code: facing.code, exitCode: facing.exitCode }; - }, - ); -if (mode === 'cancel') invocation.finiteExit.signal(15); +const operation = + agent && root !== undefined + ? agentRunCommand( + { + agent: 'synthetic', + fixture: join(root, 'absent-synthetic-cassette.json'), + input: [], + allowMcpStdio: [], + }, + { + invocation, + io, + global: { + cwd: root, + configPath: join(root, 'config.toml'), + json: false, + color: false, + verbosity: 'normal', + }, + }, + ) + : owner?.execute(async () => { + await readSecretFromStdin(undefined, input, { + signal: owner.work.signal, + onFailure: owner.work.failure, + }); + return 0; + }); +if (operation === undefined) throw new Error('Fixture owner required.'); +const reading = operation.then( + (returned) => { + settled = true; + result = { returned }; + }, + (cause: unknown) => { + settled = true; + const facing = toUserFacing(cause); + result = { code: facing.code, exitCode: facing.exitCode }; + }, +); +if (action === 'cancel') invocation.finiteExit.signal(15); await heldClose; // Flush promise continuations: public Socket.closed may already be true while delivery stays held. await new Promise((resolve) => setImmediate(resolve)); process.send?.({ phase: 'held', ...snapshot() }); await reading; // Signal mode specifically owes the actual finite-exit port, not an IPC/natural-exit race. -if (mode === 'cancel') await new Promise(() => {}); +if (action === 'cancel' || (agent && action === 'factory')) await new Promise(() => {}); process.send?.({ phase: 'settled', ...snapshot() }, () => process.disconnect()); diff --git a/apps/cli/src/secrets/read-secret.ts b/apps/cli/src/secrets/read-secret.ts index 0f2e5af0..ce912f6b 100644 --- a/apps/cli/src/secrets/read-secret.ts +++ b/apps/cli/src/secrets/read-secret.ts @@ -1,5 +1,5 @@ import { CliError } from '../process/errors.js'; -import { Readable } from 'node:stream'; +import { CapturedNativeInput } from '../process/native-input.js'; /** * Read a secret (an API key) from **stdin** for `relavium provider set-key` (2.C). The key is deliberately @@ -68,54 +68,12 @@ export async function readSecretFromStdin( let iterator: AsyncIterator | undefined; let finished = false; - const native = input instanceof Readable ? input : undefined; - const cancelled = new Error('Secret input stopped.'); - let acknowledgeClose: () => void = () => undefined; - // Iterator return can settle before _destroy acknowledges close, or fail before its cleanup starts. - // Reserve the actual native-close obligation BEFORE iterator construction/first next. - const closing = - native === undefined - ? undefined - : new Promise((resolve) => { - acknowledgeClose = resolve; - }); - const onClose = (): void => acknowledgeClose(); - // An abort can enter before the iterator installs its own error observer. The iterator still owns - // read-error classification; this guard only prevents an unobserved cancellation during acquisition. - const onNativeError = (): void => {}; - let destroyEntered = false; - let cleanupFailed = false; - const cleanupFailure = (cause: unknown): void => { - cleanupFailed = true; - lifecycle?.onFailure(new CliError('cli_internal', '', { cause })); - }; - const requestClose = (reason?: Error): void => { - if (native === undefined || destroyEntered) return; - destroyEntered = true; - try { - native.destroy(reason); - } catch (cause) { - cleanupFailure(cause); - } - }; - const stop = (): void => requestClose(cancelled); + const captured = new CapturedNativeInput(input, lifecycle, 'Secret input stopped.'); const chunks: Buffer[] = []; let totalBytes = 0; try { try { - native?.on('error', onNativeError); - native?.once('close', onClose); - // Socket.closed can become true BEFORE its actual close delivery. An unobserved preclosed - // source supplies no receipt; retain uncertainty rather than inventing already-complete ACK. - if (native?.closed === true) throw new CliError('cli_internal', ''); - } catch (cause) { - // Destruction is independently attempted in finally even if close-observer acquisition failed. - cleanupFailure(cause); - throw readFailure(cause); - } - lifecycle?.signal.addEventListener('abort', stop, { once: true }); - if (lifecycle?.signal.aborted === true) stop(); - try { + captured.open(); lifecycle?.signal.throwIfAborted(); iterator = input[Symbol.asyncIterator](); } catch (cause) { @@ -142,7 +100,7 @@ export async function readSecretFromStdin( } else if (typeof chunk === 'string') size = chunk.length; } } catch (cause) { - if (cause === cancelled) lifecycle?.signal.throwIfAborted(); + if (cause === captured.cancelled) lifecycle?.signal.throwIfAborted(); throw readFailure(cause); } if (done) { @@ -173,7 +131,7 @@ export async function readSecretFromStdin( } finally { // Enter native cancellation before awaiting any iterator cleanup; a partial or foreign iterator // must not prevent the independent finite destruction request for the consumed pipe. - requestClose(); + captured.requestClose(); if (!finished && iterator !== undefined) { try { await iterator.return?.(); @@ -181,23 +139,7 @@ export async function readSecretFromStdin( // Best-effort release must not replace the primary refusal with foreign cleanup text. } } - // A consumed non-TTY source is never reused as interactive authority. Closing it also covers - // partial iterator acquisition and early cap refusal, independently of iterator.return's result. - lifecycle?.signal.removeEventListener('abort', stop); - await closing; - for (const [event, callback] of [ - ['close', onClose], - ['error', onNativeError], - ] as const) { - try { - native?.removeListener(event, callback); - } catch (cause) { - cleanupFailure(cause); - } - } - // A failed cleanup step cannot acknowledge retirement. The original invocation's finite exit - // still bounds the process; neither an iterator return nor a destroy request invents close ACK. - if (cleanupFailed) await new Promise(() => {}); + await captured.close(); } lifecycle?.signal.throwIfAborted(); // Decode once to preserve code points split across Buffer chunks; only callers may trim values. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index b5b02deb..90ae7a7a 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -979,10 +979,22 @@ remains owed. Command database cleanup follows roots and transferred work, with independent releases entered before any join. A failed close retains uncertainty and signal custody. Short resolver/pricing reads keep synchronous close-before-next-open -ordering and retain their exact close capability if release fails; optional -pricing open/read degradation remains unchanged. This is lifecycle integration, +ordering and retain their exact close capability if release fails; classified +operational pricing refusals may still degrade. This is lifecycle integration, not the later sole-opener refactor. +The original command, workflow or session surface captures a database's actual +close capability before native setup, including partial acquisition through +injected production openers. Returned wrappers cannot replace that capability. +Release follows the owner's existing host/root/tail joins and precedes removal +of its signal custody. A failed or unobservable release retains the same original +finite-exit obligation. SQLite construction details have their canonical home in +the [database contract](../shared-core/database-schema.md#concurrency--transaction-behavior). +Optional pricing reads may degrade a classified operational storage refusal; +unknown acquisition faults and failed native cleanup remain infrastructure 9. +A later successful release permits retirement without erasing an earlier +infrastructure observation. + The standalone create wizard binds installed Clack prompts to captured streams and checks admission between answers and before writing. Its already-entered terminal restoration uses the original captured control capability, separately diff --git a/docs/reference/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index 28b8fd45..8e7bf73b 100644 --- a/docs/reference/shared-core/database-schema.md +++ b/docs/reference/shared-core/database-schema.md @@ -1332,6 +1332,16 @@ Byte/format verification and exact host shapes live in the ## Concurrency & transaction behavior +The Node factory `createClient(path, captureClose?)` can hand its idempotent native +close capability to the original host synchronously, immediately after the SQLite +constructor and before PRAGMAs or Drizzle setup. CLI openers forward this optional +handoff before hardening, migrations or store construction can fail. A setup +failure attempts native release and preserves its primary cause. If release also +fails, both causes survive in an `AggregateError`; the escaping open failure does +not inherit operational storage provenance. A returned handle, swallowed cleanup +error or elapsed shutdown budget is not evidence of native release. CLI ownership +and refusal projection follow the [CLI lifecycle contract](../cli/commands.md#management-ownership-implementation-checkpoint). + `history.db` is a **single shared file** two concurrent `relavium` processes may write at once — e.g. a `run` persisting events while a `chat` refreshes the live model catalog ([ADR-0064](../../decisions/0064-live-model-catalog.md) §5). The Node/CLI path (`better-sqlite3`, [ADR-0021](../../decisions/0021-node-sqlite-driver-better-sqlite3.md), re-affirmed by [ADR-0067](../../decisions/0067-node-supported-floor-22-reaffirm-better-sqlite3.md)) hardens this at the connection and the transaction level; this is the one canonical home for the policy that `packages/db/src/retry.ts` and the store doc-comments cite. - **Connection PRAGMAs** ([`client.ts`](../../../packages/db/src/client.ts)): `journal_mode = WAL` (readers never block the single writer, and vice-versa), `busy_timeout = 5000` (SQLite's built-in busy handler waits up to 5 s for a contended lock before returning `SQLITE_BUSY`), `synchronous = NORMAL` (the recommended durability/throughput trade-off under WAL), and `foreign_keys = ON`. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 7196cc65..3ecd120a 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1159,7 +1159,13 @@ records complete NONCLEAN coverage of all 255 paths and the verified corrections no clean-round acceptance is inferred. The [second cumulative round and native-input corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-second-cumulative-review-and-native-input-corrections--2026-10-17) record complete NONCLEAN coverage of all 262 paths, the supplemental native-input finding and subsequent fixes. Fresh complete reviews/security/full-CI/platform -acceptance remain open. Step 8d proceeds automatically. The +acceptance remain open. The [third cumulative review and partial-acquisition corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-third-cumulative-review-and-partial-acquisition-corrections--2026-10-17) +record complete NONCLEAN coverage of 269 paths at `ec8ba368`, source-only collection, +shared consumed-input custody and pre-setup database ownership across the actual +shipping surfaces. Earlier CI failures retain their exact attribution; the macOS +packed failure is not cleared by local reproduction passes. Two NEW complete clean +rounds, a NEW separate clean security sitting and current CI/platform acceptance +remain open. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 7e5a099b..e29bf40d 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -6392,3 +6392,94 @@ Two NEW complete cumulative clean rounds after the final material fix, a NEW separate clean security sitting, fresh full CI and the four same-artifact platform consumers remain open. Whole Step 8 and Steps 9–19 remain open. No user decision, ADR approval, credential, provider capture or billing action is pending. + + +## Wave 3 Step 8d third cumulative review and partial-acquisition corrections — 2026-10-17 + +**Complete cumulative round 3 is NONCLEAN at `ec8ba368`; Step 8d remains open.** +Three independent complete slices join all 269 cumulative paths from `d9625ff0` +without omission or overlap: 73 session/terminal, 125 workflow/management and +71 native/tooling/docs. The local Git-ignored aggregate SHA-256 is +`ceb0e5610e795f23fda760599e2cea96caf409c60f6a08f200b49b3c65748d0d`. +A clean slice is not a clean round. One verified Blocker and two verified High +findings require the subsequent corrections below. + +- The permanent usage collector now copies the actual source graph into an owned + install-only tree, with no build outputs or workspace-package links. It uses + the canonical source aliases and refuses transformations into the original + workspace. A no-alias negative control establishes that the local built graph + cannot supply the proof. Actual collection still pins 17 files / 446 cases; + the independent runtime native-child inventory stays 71. Required install-only + CI jobs gain no build prerequisite or weakened gate. +- Secret stdin and one-shot agent input share captured native close observation + before iterator acquisition. EOF, partial setup, cancellation and unobserved + preclosed sources cannot supply close ACK from public flags. Independent native + cancellation and callback removal retain their own uncertainty. A failed abort + listener removal still joins actual close and cannot retire the input. The + caller's operator-TTY refusal remains before consumption. +- Every shipping database route now transfers its actual native close capability + to its original owner immediately after SQLite construction, before PRAGMAs, + hardening, migrations or store construction can throw. Production-injected + openers forward the same optional handoff. Legacy returned methods preserve + their receiver; returned wrappers cannot replace an early native capability. + Workflow, management, chat/Home, one-shot journal and transient resolver/pricing + owners retain their existing identities and cleanup order. The sole-opener + refactor remains Step 18. Primary and secondary startup failures survive + together; failed release cannot inherit operational storage provenance. +- Optional pricing degradation no longer swallows unknown acquisition or native + cleanup faults. A later successful release can retire the actual resource while + retaining the earlier infrastructure observation. A one-shot database tail + closes before its surface removes signal custody; unknown partial construction + retains that custody through the same original finite-exit obligation. + +Thirty-three real SQLite controls exercise management, workflow/gate, fresh and +resumed chat, Home, one-shot journal and optional pricing paths. Sixteen native +failure cases break at named semantic assertions when only original-owner handoff +is removed; exact restoration passes all 33. The native-constructor controls fail +before the pre-PRAGMA fix and pass afterward. Four actual agent child controls +cover EOF, iterator-factory refusal, cancellation and preclosed input; restoring +only the previous agent reader breaks the EOF ownership assertion. The first +removal attempt instead hit an unhandled IPC EPIPE and is excluded: the parent +fixture now observes release-send failure and the repeated removal reaches the +semantic assertion. The 31-case secret iterator cleanup mechanism retains its +fixed/removal/restored proof, with the selected-family report explicitly +`complete: false`. + +CI run `38984767780` at `ec8ba368` remains failed. Its install-only filesystem and +secrets jobs exposed the collector defect before their families ran. Coverage +exposed a Node-24-incompatible test continuation: a closed readline interface +cannot be resumed there. The corrected control resumes its captured native input, +proves that late activity happened, and retains all final cleanup assertions. +Independent Node 24 focused execution passes. This is a test correction, not a +relaxation of product sealing or native cleanup. + +That run's current-Windows advisory and Linux/Windows/Node-22.0.0 packed consumers +pass. Its macOS packed leg remains failed with an unexpected infrastructure 9; +its initiating cause is not established. The exact package and matching official +Node 24.21.0 pass eleven instrumented local cohorts and one untouched eight-case +cohort, but the local OS differs from the remote image. These local passes neither +replace the remote failure nor establish a flake. No package assertion, native +close/readiness control or watchdog is relaxed. + +At the final frozen 42-path product/test/tooling source, author workspace +lint/typecheck passes seventeen tasks (fifteen cached); tests pass eleven tasks +(ten cached), with fresh CLI execution of **332 files / 5,394 passing tests / +eleven reported skips**. Build passes six tasks (five cached); the finite CLI +graph has 25 outputs / 407 inputs / seventeen declared externals. Tools +lint/typecheck, all **512 acceptance-tool tests**, seam/purity, test isolation, +**177 compiled bootstrap controls** and compiled CLI smoke pass. Node 24.19.0 +passes twelve focused native-input/readline cases; official Node 22.0.0 passes +43 focused reader/acquisition cases. These are author checks, not remote package +or independent-review acceptance. The local Git-ignored source pin SHA-256 is +`4a7cb298512fba75fb35ae616feae765f532705b04f7d784b5d4cebc82acdcc1`. + +Earlier lint/type/fixture failures and the full workspace's two obsolete +immediate-retirement controls retain their historical scopes. Those injected +unknown-open controls now use fake physical-exit ports, verify retained signal +custody and independently joined MCP cleanup, and cannot terminate the real test +worker. No timeout, production shutdown budget or required gate was increased. + +Two NEW complete cumulative clean rounds after the last material fix, +a NEW separate clean security sitting, fresh full CI and all four same-artifact +platform consumers remain required. Whole Step 8 and Steps 9–19 remain open. +No user decision, ADR approval, provider, key, billing or capture action is pending. diff --git a/packages/db/src/client-acquisition.test.ts b/packages/db/src/client-acquisition.test.ts new file mode 100644 index 00000000..d01f9c5b --- /dev/null +++ b/packages/db/src/client-acquisition.test.ts @@ -0,0 +1,70 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import Database from 'better-sqlite3'; +import { describe, expect, it, vi } from 'vitest'; +import { createClient, DbOpenError } from './client.js'; +import { isOwnedStorageFailure } from './storage-failure-facts.js'; + +describe('native SQLite custody during construction', () => { + it.each([false, true])( + 'captures before the first failing PRAGMA; close failure=%s', + (closeFails) => { + const root = mkdtempSync(join(tmpdir(), 'relavium-client-acquisition-')); + const path = join(root, 'corrupt.db'); + writeFileSync(path, 'synthetic invalid SQLite header'.repeat(8)); + const nativeClose: unknown = Object.getOwnPropertyDescriptor( + Database.prototype, + 'close', + )?.value; + if (typeof nativeClose !== 'function') + throw new Error('Native SQLite close fixture is unavailable.'); + const secondary = new Error('synthetic native close refusal'); + const native: { connection?: Database.Database } = {}; + let captured: (() => void) | undefined; + const close = vi.spyOn(Database.prototype, 'close').mockImplementation(function ( + this: Database.Database, + ) { + native.connection = this; + if (closeFails) throw secondary; + Reflect.apply(nativeClose, this, []); + return this; + }); + try { + let failure: unknown; + try { + createClient(path, (release) => { + captured = release; + expect(close).not.toHaveBeenCalled(); + }); + } catch (cause) { + failure = cause; + } + expect(captured, 'SQLITE_CAPABILITY_PRECEDES_PRAGMA_FAILURE').toBeTypeOf('function'); + expect(failure).toBeInstanceOf(DbOpenError); + expect(native.connection?.open).toBe(closeFails); + expect(isOwnedStorageFailure(failure), 'FAILED_NATIVE_CLEANUP_IS_NOT_OPERATIONAL').toBe( + !closeFails, + ); + if (failure instanceof DbOpenError) { + if (closeFails) { + expect(failure.cause).toBeInstanceOf(AggregateError); + if (failure.cause instanceof AggregateError) { + expect(failure.cause.errors[0]).toBeInstanceOf(Database.SqliteError); + expect(failure.cause.errors[1]).toBe(secondary); + expect(failure.cause.cause).toBe(failure.cause.errors[0]); + } + } else { + expect(failure.cause).toBeInstanceOf(Database.SqliteError); + captured?.(); + expect(close).toHaveBeenCalledOnce(); + } + } + } finally { + close.mockRestore(); + if (native.connection?.open) Reflect.apply(nativeClose, native.connection, []); + rmSync(root, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 2f4be914..f757c986 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -111,7 +111,10 @@ export class DbOpenError extends RelaviumError { * - `synchronous = NORMAL` — the recommended durability/throughput trade-off under WAL. * - `secure_delete = ON` — overwrite deleted session-effect payload bytes; WAL erasure also needs a checkpoint. */ -export function createClient(path = ':memory:'): DbClient { +export function createClient( + path = ':memory:', + captureClose?: (close: () => void) => void, +): DbClient { // SQLite URI filenames (`file:…`) are NOT supported: better-sqlite3 needs `{ uri: true }` to // interpret them and otherwise silently creates a literal file of that name. Reject up front // rather than open the wrong database — pass ':memory:' or a plain filesystem path. @@ -122,18 +125,20 @@ export function createClient(path = ':memory:'): DbClient { path, ); } - let sqlite: Database.Database | undefined; + let close: (() => void) | undefined; try { // Create the parent directory for a real file path so a first-run open doesn't fail on a // missing folder (inside the try so a filesystem error is classified the same way). if (path !== ':memory:') { withOwnedFilesystem(() => mkdirSync(dirname(path), { recursive: true })); } - // A local binding as well as the outer `let`: the outer one is what the `catch` closes, and inside a - // closure TypeScript cannot narrow it away from `undefined`, which would turn a real invariant into an - // optional chain that silently skips the pragma. const opened = withOwnedSqlite(() => new Database(path)); - sqlite = opened; + close = () => { + if (opened.open) opened.close(); + }; + // The original invocation owns this exact native capability before PRAGMAs or Drizzle can fail. + // Returning a DbClient is too late to transfer a partially constructed connection. + captureClose?.(close); // The PRAGMAs and the Drizzle bind are INSIDE the try: any of them can fail (a corrupt header surfaces on // the first `journal_mode` write, not on open), and outside it a failure would leak the just-opened // connection for the process lifetime AND escape as an untyped driver error — past both this factory's @@ -160,19 +165,27 @@ export function createClient(path = ':memory:'): DbClient { return { db, sqlite: opened, path }; } catch (err) { // Close what we opened before propagating, or the connection leaks on every failed setup. + let cleanupFailure: { readonly cause: unknown } | undefined; try { - sqlite?.close(); - } catch { - /* already failing; a secondary close error must not replace the real cause */ + close?.(); + } catch (cause) { + cleanupFailure = { cause }; } // A STABLE, generic message: `err.message` from better-sqlite3 routinely contains the absolute file path, // and this message is surfaced verbatim to the user by the CLI's history openers — interpolating it would // put the OS username back in user-facing output, which is the very thing moving the path to `.path` was // for. The driver's own error is preserved in full via `cause` for diagnosis. const failure = new DbOpenError('open_failed', 'could not open the SQLite database', path, { - cause: err, + cause: + cleanupFailure === undefined + ? err + : new AggregateError([err, cleanupFailure.cause], 'SQLite startup cleanup failed.', { + cause: err, + }), }); - transferStorageFailure(failure, err); + // Native cleanup uncertainty is infrastructure, even when the primary failure was operational. + // Preserve both causes without laundering the failed release through storage provenance. + if (cleanupFailure === undefined) transferStorageFailure(failure, err); throw failure; } } diff --git a/tools/usage-coverage-controls/workload.test.mjs b/tools/usage-coverage-controls/workload.test.mjs index 8a7255b8..d1f5f3fa 100644 --- a/tools/usage-coverage-controls/workload.test.mjs +++ b/tools/usage-coverage-controls/workload.test.mjs @@ -2,21 +2,26 @@ import assert from 'node:assert/strict'; import { Buffer } from 'node:buffer'; import { execFileSync } from 'node:child_process'; import { + existsSync, + mkdirSync, mkdtempSync, readFileSync, + readdirSync, realpathSync, rmSync, + statSync, symlinkSync, writeFileSync, } from 'node:fs'; import Module, { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { permanentFiles } from './mutations.mjs'; import { aliases, + captureSource, digest, instrument, rejectUnknownAlias, @@ -92,22 +97,93 @@ test('the complete native/scale workload has its own calculated bound and exact }); test('the pinned permanent inventory matches actual installed Vitest collection', () => { - const repository = fileURLToPath(new URL('../../', import.meta.url)); - const scratch = mkdtempSync(join(tmpdir(), 'relavium-usage-inventory-')); + const repository = realpathSync(fileURLToPath(new URL('../../', import.meta.url))); + const scratch = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-usage-inventory-'))); + const graph = join(scratch, 'source'); const output = join(scratch, 'collected.json'); + const config = join(scratch, 'vitest.config.mjs'); try { + captureSource(repository, graph); + const homes = [ + '', + ...new Set( + [...Object.values(aliases), ...permanentFiles].map((file) => file.split('/src/')[0]), + ), + ]; + // Exercise a clean install-only tree even when the developer has build outputs. + // Link public external packages only: workspace aliases must resolve the copied source. + for (const home of homes) { + assert.equal(existsSync(join(graph, home, 'dist')), false); + const original = join(repository, home, 'node_modules'); + const destination = join(graph, home, 'node_modules'); + mkdirSync(destination, { recursive: true }); + if (!existsSync(original)) continue; + for (const entry of readdirSync(original)) { + if (entry.startsWith('.') || entry === '@relavium') continue; + const target = realpathSync(join(original, entry)); + assert.ok(statSync(target).isDirectory()); + symlinkSync( + target, + join(destination, entry), + process.platform === 'win32' ? 'junction' : 'dir', + ); + } + } + const common = { + root: graph, + test: { + environment: 'node', + include: permanentFiles, + passWithNoTests: false, + fileParallelism: false, + maxWorkers: 1, + }, + }; + const collect = () => + execFileSync( + process.execPath, + [ + join(repository, 'node_modules/vitest/vitest.mjs'), + 'list', + '--config', + config, + `--json=${output}`, + ], + { cwd: graph, timeout: 30_000, maxBuffer: 4 * 1024 * 1024, stdio: 'pipe' }, + ); + writeFileSync(config, `export default ${JSON.stringify(common)};`); + assert.throws( + collect, + (cause) => + cause instanceof Error && + 'stderr' in cause && + /Failed to resolve|Cannot find|Failed to load/.test(String(cause.stderr)), + 'A source-only collector without aliases must refuse missing workspace packages', + ); + const mappings = Object.entries(aliases) + .map( + ([name, path]) => + `{find:new RegExp(${JSON.stringify('^' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '$')}),replacement:${JSON.stringify(join(graph, path))}}`, + ) + .join(','); + const forbidden = homes + .filter(Boolean) + .map((home) => join(repository, home).replaceAll('\\', '/') + '/'); + writeFileSync( + config, + `import {existsSync,realpathSync} from 'node:fs'; + export default {...${JSON.stringify(common)},resolve:{alias:[${mappings}]}, + plugins:[{name:'no-original-workspace',enforce:'pre',transform(_code,id){ + const file=id.split('?')[0];if(existsSync(file)){ + const physical=realpathSync(file).replaceAll('\\\\','/'); + if(${JSON.stringify(forbidden)}.some(root=>physical.startsWith(root))) + throw new Error('PERMANENT_COLLECTOR_WORKSPACE_ESCAPE'); + } + }}]};`, + ); // Vitest's optional --json argument is a filename, including the literal string "true". // Always give an owned output path: no filter may become the reporter's destination. - execFileSync( - process.execPath, - [ - join(repository, 'node_modules/vitest/vitest.mjs'), - 'list', - `--json=${output}`, - ...permanentFiles, - ], - { cwd: repository, timeout: 30_000, maxBuffer: 4 * 1024 * 1024, stdio: 'pipe' }, - ); + collect(); const collected = JSON.parse(readFileSync(output, 'utf8')); assert.ok(Array.isArray(collected)); assert.equal( @@ -116,8 +192,12 @@ test('the pinned permanent inventory matches actual installed Vitest collection' 'Permanent collected case inventory drift', ); assert.deepEqual( - [...new Set(collected.map((item) => item.file))].sort(), - permanentFiles.map((file) => join(repository, file)).sort(), + [ + ...new Set( + collected.map((item) => relative(graph, realpathSync(item.file)).replaceAll('\\', '/')), + ), + ].sort(), + [...permanentFiles].sort(), 'Permanent collected file inventory drift', ); } finally { From ad743ba5819361ad070a27ee31f0fb5751b823d4 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 04:33:40 +0300 Subject: [PATCH 115/222] fix(cli): retain auxiliary native resources through final cleanup Transfer the migration-lock SQLite close into the original opener's composite before setup, preserving independent release and failed-cleanup classification. Use the original remaining MCP budget at workflow publication and all three finalizers, and refuse default MCP prompting after one-shot stdin consumption. Add actual native-handle, manager and command controls with paired positive paths and named removal witnesses. Update the canonical CLI/DB contracts and record complete NONCLEAN round 4 without claiming Step 8d acceptance. The DB handoff and already-catalogued development typing changes are atomic with the CLI ownership correction; no runtime dependency or resolved version changes. Validation: workspace lint/typecheck/test, build, 512 tooling tests, schema, format, seam/purity/isolation, 177 compiled bootstrap controls and CLI smoke. Restored focused controls pass 246 CLI and 14 DB cases after five removals. Refs: ADR-0110, ADR-0073, Wave 3 Step 8d Co-Authored-By: Codex --- AGENTS.md | 9 +- CLAUDE.md | 9 +- apps/cli/package.json | 1 + apps/cli/src/commands/agent-run.test.ts | 85 ++++---- apps/cli/src/commands/agent-run.ts | 16 +- apps/cli/src/commands/budget.test.ts | 22 +- apps/cli/src/commands/gate.test.ts | 33 ++- apps/cli/src/commands/gate.ts | 9 +- apps/cli/src/commands/run.test.ts | 32 +++ apps/cli/src/commands/run.ts | 9 +- .../workflow-expired-cleanup.test-support.ts | 117 +++++++++++ .../cli/src/db/migration-lock-custody.test.ts | 198 ++++++++++++++++++ apps/cli/src/db/open.test.ts | 2 +- apps/cli/src/db/open.ts | 50 +++-- apps/cli/src/engine/mcp-servers.ts | 2 +- .../engine/mcp-workflow-preparation.test.ts | 123 +++++++++++ docs/reference/cli/commands.md | 17 +- docs/reference/shared-core/database-schema.md | 10 + docs/roadmap/current.md | 9 +- .../phase-2.5.5-hardening-and-remediation.md | 79 +++++++ packages/db/src/client.ts | 24 ++- packages/db/src/migrate-lock.test.ts | 57 +++++ packages/db/src/migrate-lock.ts | 36 +++- pnpm-lock.yaml | 3 + 24 files changed, 859 insertions(+), 93 deletions(-) create mode 100644 apps/cli/src/commands/workflow-expired-cleanup.test-support.ts create mode 100644 apps/cli/src/db/migration-lock-custody.test.ts create mode 100644 apps/cli/src/engine/mcp-workflow-preparation.test.ts diff --git a/AGENTS.md b/AGENTS.md index 63d9c895..9d5b798c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,8 +105,13 @@ platform consumers remain attributed to `ab50be11`, with independently verified source/dependency equivalence. Step 8c workflow/preparation/terminal integration is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four -same-tarball platform consumers pass at that reviewed source. Whole Step 8, -session/terminal integration and product fatal activation in 8d remain open. +same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. +Step 8d session/terminal integration and product fatal activation are implemented +but not independently accepted. Its first four complete cumulative rounds are +NONCLEAN; the three fourth-round findings have implementation corrections and +passing author gates. Two NEW complete cumulative clean rounds after the last +material fix, a NEW separate clean security sitting and fresh full CI plus all +four same-artifact package consumers remain required. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index 6c5ba512..3492bfef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -194,8 +194,13 @@ platform consumers remain attributed to `ab50be11`, with independently verified source/dependency equivalence. Step 8c workflow/preparation/terminal integration is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four -same-tarball platform consumers pass at that reviewed source. Whole Step 8, -session/terminal integration and product fatal activation in 8d remain open. +same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. +Step 8d session/terminal integration and product fatal activation are implemented +but not independently accepted. Its first four complete cumulative rounds are +NONCLEAN; the three fourth-round findings have implementation corrections and +passing author gates. Two NEW complete cumulative clean rounds after the last +material fix, a NEW separate clean security sitting and fresh full CI plus all +four same-artifact package consumers remain required. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/apps/cli/package.json b/apps/cli/package.json index 0b1f78f9..101ab679 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -69,6 +69,7 @@ "@relavium/llm": "workspace:*", "@relavium/mcp": "workspace:*", "@relavium/shared": "workspace:*", + "@types/better-sqlite3": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", "eslint": "catalog:", diff --git a/apps/cli/src/commands/agent-run.test.ts b/apps/cli/src/commands/agent-run.test.ts index db188b90..8cc707cb 100644 --- a/apps/cli/src/commands/agent-run.test.ts +++ b/apps/cli/src/commands/agent-run.test.ts @@ -22,6 +22,7 @@ import * as signalPorts from '../process/signals.js'; import * as sessionOpen from '../history/session-open.js'; import * as consentPrompts from '../mcp/consent-prompt.js'; import { mcpConsentPath } from '../engine/mcp-consent-gate.js'; +import { fingerprint, resolveStdioSpawn } from '../engine/mcp-consent.js'; import type { GlobalOptions } from '../process/options.js'; import { captureIo, parseNdjson } from '../test-support.js'; import { agentRunCommand, readAllStdin, type AgentRunCommandDeps } from './agent-run.js'; @@ -443,58 +444,62 @@ describe('agentRunCommand (2.Q)', () => { }); it.each([false, true])( - 'the default MCP gate owns a late consent answer (stopped=%s)', - async (stopped) => { + 'consumed one-shot stdin never authorizes a second MCP prompt (preapproved=%s)', + async (preapproved) => { writeFileSync( agentPath(), `${AGENT_YAML}\nmcp_servers:\n - id: synthetic\n transport: stdio\n command: ${JSON.stringify(process.execPath)}\n`, ); - let answer: (approved: boolean) => void = () => undefined; - const pendingAnswer = new Promise((resolve) => { - answer = resolve; - }); - let entered: () => void = () => undefined; - const prompted = new Promise((resolve) => { - entered = resolve; - }); - const prompt = vi.fn(async () => { - entered(); - return pendingAnswer; - }); - vi.spyOn(consentPrompts, 'createBoundConsentPrompter').mockReturnValue(prompt); + // An immediate negative answer makes removal fail a named assertion instead of hanging. + const prompt = vi.fn(() => Promise.resolve(false)); + const createPrompt = vi + .spyOn(consentPrompts, 'createBoundConsentPrompter') + .mockReturnValue(prompt); const { d, control } = deps('synthetic prompt', { providers: scriptedResolver([textTurn('done')]), }); - const start = vi.fn(() => realStartMcpClient([])); // A real empty manager; no native child or network. - const action = agentRunCommand( - { agent: agentPath(), input: [], allowMcpStdio: [] }, - { - ...d, - io: { ...d.io, stdinIsTty: true, stdoutIsTty: true }, - buildSession: (options) => buildChatSession({ ...options, startMcpClient: start }), - }, + const spawn = await resolveStdioSpawn( + { serverId: 'synthetic', command: process.execPath, provenance: { kind: 'inline' } }, + cwd, ); + const start = vi.fn(() => realStartMcpClient([])); // Real manager, no native spawn/network. + let failure: unknown; + let result: number | undefined; try { - await prompted; - expect(prompt).toHaveBeenCalledOnce(); - expect(start).not.toHaveBeenCalled(); - if (stopped) { - control.invocation.finiteExit.signal(15); - await reactions(); - expect(control.registry.activeOwners).toBe(3); - } - } finally { - answer(true); + result = await agentRunCommand( + { agent: agentPath(), input: [], allowMcpStdio: preapproved ? [fingerprint(spawn)] : [] }, + { + ...d, + // EOF closed the actual stream; these stale booleans must not authorize another read. + io: { ...d.io, stdinIsTty: true, stdoutIsTty: true, env: {} }, + buildSession: (options) => buildChatSession({ ...options, startMcpClient: start }), + }, + ); + } catch (cause) { + failure = cause; + } + if (!(d.io.stdin instanceof Readable)) + throw new Error('Expected the actual native input fixture.'); + expect(d.io.stdin.closed).toBe(true); + expect(d.io.stdin.destroyed).toBe(true); + expect( + createPrompt, + 'AGENT_CONSUMED_STDIN_HAS_NO_MCP_PROMPT_AUTHORITY', + ).not.toHaveBeenCalled(); + expect(prompt).not.toHaveBeenCalled(); + expect(existsSync(mcpConsentPath(home))).toBe(false); + expect(start).toHaveBeenCalledTimes(preapproved ? 1 : 0); + if (preapproved) { + expect(failure).toBeUndefined(); + expect(result).toBe(0); + } else { + expect(failure).toMatchObject({ code: 'invalid_invocation', exitCode: 2 }); + expect(result).toBeUndefined(); } - expect(await action).toBe(stopped ? 4 : 0); await reactions(); - expect(existsSync(mcpConsentPath(home)), 'AGENT_LATE_CONSENT_CANNOT_APPEND_GRANT').toBe( - !stopped, - ); - expect(start).toHaveBeenCalledTimes(stopped ? 0 : 1); expect(control.registry.activeOwners).toBe(0); - if (stopped) expect(control.exit).toHaveBeenCalledExactlyOnceWith(143); - else expect(control.exit).not.toHaveBeenCalled(); + expect(control.registry.budget.deadline()).toBeUndefined(); + expect(control.exit).not.toHaveBeenCalled(); }, ); diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index 1389f50a..8c8c3140 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -2,7 +2,6 @@ import { acquireNativeResource } from '../process/acquire-native-resource.js'; import type { CliInvocation } from '../process/invocation.js'; import { SessionHostOwner } from '../chat/session-host-owner.js'; import { SessionSurfaceLifetime } from '../chat/session-surface-lifetime.js'; -import { runSessionPrompt } from '../chat/session-prompt.js'; import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; import { randomUUID } from 'node:crypto'; import { StringDecoder } from 'node:string_decoder'; @@ -18,7 +17,6 @@ import { buildChatSession, type BuiltChatSession } from '../chat/session-host.js import { createConsentGate } from '../engine/mcp-consent-gate.js'; import type { StdioConsentGate } from '../engine/mcp-servers.js'; -import { createBoundConsentPrompter } from '../mcp/consent-prompt.js'; import { loadResolvedConfig } from '../config/load.js'; import { admitFsInvocation, admitChatFsPolicy } from '../engine/tool-host/fs-admission.js'; import { surfaceMcpSkipped } from '../engine/mcp-servers.js'; @@ -190,21 +188,13 @@ async function agentRunOwned( consentGate: deps.consentGate ?? createConsentGate({ - io: deps.io, + // Mandatory one-shot input has consumed and closed this stream. A captured TTY flag + // grants no second read; only an existing grant or an explicit digest may authorize MCP. + io: { ...deps.io, stdinIsTty: false }, global: deps.global, homeDir, allowedDigests: args.allowMcpStdio, signal: workOwner.signal, - prompt: (subject) => { - const prompt = createBoundConsentPrompter(deps.io); - return runSessionPrompt( - lifetime.invocation, - deps.io, - workOwner.assertAdmitting, - workOwner.failure, - (signal) => prompt(subject, { signal }), - ); - }, }), // ADR-0071 §6: a one-shot invoke has no transcript and no picker, so a withheld tier would otherwise vanish // completely — the turn runs, the authored knob does nothing, and the bill arrives at the provider's default. diff --git a/apps/cli/src/commands/budget.test.ts b/apps/cli/src/commands/budget.test.ts index e1ddcafd..fbf08e15 100644 --- a/apps/cli/src/commands/budget.test.ts +++ b/apps/cli/src/commands/budget.test.ts @@ -1,4 +1,5 @@ import { createCliInvocation } from '../process/invocation.js'; +import { workflowFinalizerControl } from './workflow-expired-cleanup.test-support.js'; import { toUserFacing } from '../process/errors.js'; import { witnessNativeToolProcess } from './native-forwarding.test-support.js'; import { randomUUID } from 'node:crypto'; @@ -72,7 +73,7 @@ beforeEach(() => { persistPrice(PRICE); }); afterEach(() => { - client.sqlite.close(); + if (client.sqlite.open) client.sqlite.close(); rmSync(root, { recursive: true, force: true }); }); /** Production's early price read must use actual durable user pricing, not the engine test override. */ @@ -244,6 +245,25 @@ function deps(io: ReturnType['io']): GateCommandDeps { }, }; } +it.each([0, 1])( + 'budget resume finalizer respects the original MCP budget (%s ms left)', + async (remaining) => { + const paused = await seed({ mcp: true }); + const control = workflowFinalizerControl(remaining, client.sqlite); + await control.verify( + budgetCommand( + { runId: paused.runId, approveAmount: String(paused.amount) }, + { + ...deps(captureIo().io), + ...control, + consentGate: () => Promise.resolve(new Map()), + openDb: () => ({ db: client.db, close: control.closeDatabase }), + }, + ), + ); + }, +); + for (const abort of [false, true]) { it(`native SQLite budget resume is binary and amount bounded (abort=${abort})`, async () => { const paused = await seed(); diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index d730b36b..5dfce223 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -1,4 +1,5 @@ import { invocationFixture } from '../process/invocation.test-support.js'; +import { workflowFinalizerControl } from './workflow-expired-cleanup.test-support.js'; import { createCliInvocation } from '../process/invocation.js'; import { witnessNativeToolProcess } from './native-forwarding.test-support.js'; import { randomUUID } from 'node:crypto'; @@ -285,7 +286,7 @@ describe('gateCommand', () => { db = client.db; }); afterEach(() => { - client.sqlite.close(); + if (client.sqlite.open) client.sqlite.close(); }); /** The injected deps for a resume over the SHARED in-memory db (no-op close — the db spans the whole test). */ @@ -347,6 +348,36 @@ describe('gateCommand', () => { return { runId, gateIds }; } + it.each([0, 1])( + 'gate finalizer respects the original MCP budget (%s ms left)', + async (remaining) => { + const yaml = GATED.replace( + ' nodes:', + ` agents: + - id: unused + model: claude-sonnet-4-6 + provider: anthropic + system_prompt: synthetic + tools: [mcp_fs_read] + mcp_servers: [{ id: fs, transport: stdio, command: synthetic-never-spawned }] + nodes:`, + ); + const paused = await setupPausedRun(yaml); + const control = workflowFinalizerControl(remaining, client.sqlite); + await control.verify( + gateCommand( + { runId: paused.runId, approve: true }, + { + ...deps(captureIo().io), + ...control, + consentGate: () => Promise.resolve(new Map()), + openDb: () => ({ db, close: control.closeDatabase }), + }, + ), + ); + }, + ); + it('forwards the resuming invocation before the actual engine build resolves', async () => { const { runId } = await setupPausedRun(); const invocation = createCliInvocation(); diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index 752765ed..edb4686c 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -64,6 +64,7 @@ import { } from '../gate/budget.js'; import { connectWorkflowMcp, + closePreparedMcp, workflowDeclaresMcp, surfaceMcpSkipped, type StdioConsentGate, @@ -647,7 +648,13 @@ async function resumeGateOwned( throw err; } finally { try { - await mcpRuntime?.client.close(reportMcpCloseFailure); + if (mcpRuntime !== undefined) { + await closePreparedMcp( + mcpRuntime.client, + reportMcpCloseFailure, + lifetime.invocation.registry.budget, + ); + } } catch (cause) { mcpAcknowledged = false; lifetime.cleanupFailed(cause); diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts index b50bfa78..6897ab74 100644 --- a/apps/cli/src/commands/run.test.ts +++ b/apps/cli/src/commands/run.test.ts @@ -1,4 +1,5 @@ import { invocationFixture } from '../process/invocation.test-support.js'; +import { workflowFinalizerControl } from './workflow-expired-cleanup.test-support.js'; import { createCliInvocation } from '../process/invocation.js'; import { witnessNativeToolProcess } from './native-forwarding.test-support.js'; import { randomUUID } from 'node:crypto'; @@ -1845,6 +1846,37 @@ workflow: expect(engineBuilt).toBe(false); // the connect failed before the engine was ever built (no leak) }); + it.each([0, 1])( + 'run finalizer respects the original MCP budget (%s ms left)', + async (remaining) => { + const client = createClient(':memory:'); + runMigrations(client.db); + const control = workflowFinalizerControl(remaining, client.sqlite); + try { + await control.verify( + runCommand( + { + workflow: writeWorkflow('expiry.relavium.yaml', MCP_WF), + input: [], + allowMcpStdio: [], + }, + deps(captureIo().io, globalOptions(), { + ...control, + providers: scriptedResolver([textTurn('unused')]), + consentGate: PASS_CONSENT, + openRunStore: (workflow) => ({ + ...historyOpenRunStore(client.db)(workflow, home, root), + close: control.closeDatabase, + }), + }), + ), + ); + } finally { + if (client.sqlite.open) client.sqlite.close(); // Test disposal does not acknowledge the pending owner. + } + }, + ); + it('tears the MCP connection down even when the engine fails AFTER a successful connect (run-terminal teardown)', async () => { const path = writeWorkflow('mcp-fail.relavium.yaml', MCP_WF); const { io } = captureIo(); diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index f18e6267..1525a591 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -36,6 +36,7 @@ import { sweepCommittedEffects } from '../engine/effect-retention.js'; import { createCliHost } from '../engine/host.js'; import { connectWorkflowMcp, + closePreparedMcp, type StdioConsentGate, surfaceMcpSkipped, type WorkflowMcpRuntime, @@ -550,7 +551,13 @@ async function runOwned( // Cleanup is joined directly, never via a registry promise that depends on this finally. // Neither cancellation nor a deadline certifies the engine/MCP/DB or terminal released. try { - await mcpRuntime?.client.close(reportMcpCloseFailure); + if (mcpRuntime !== undefined) { + await closePreparedMcp( + mcpRuntime.client, + reportMcpCloseFailure, + lifetime.invocation.registry.budget, + ); + } } catch (cause) { mcpAcknowledged = false; lifetime.cleanupFailed(cause); diff --git a/apps/cli/src/commands/workflow-expired-cleanup.test-support.ts b/apps/cli/src/commands/workflow-expired-cleanup.test-support.ts new file mode 100644 index 00000000..e302e593 --- /dev/null +++ b/apps/cli/src/commands/workflow-expired-cleanup.test-support.ts @@ -0,0 +1,117 @@ +import { startMcpClient } from '@relavium/mcp'; +import type { DbClient } from '@relavium/db'; +import { expect, vi } from 'vitest'; + +import { invocationFixture } from '../process/invocation.test-support.js'; +import type { RunCommandDeps } from './run.js'; + +/** Actual manager + actual SQLite witness, shared by the three original workflow entry points. */ +export function workflowFinalizerControl(remaining: number, sqlite: DbClient['sqlite']) { + const f = invocationFixture(); + const primary = new Error( + 'synthetic engine construction failure after MCP and database acquisition', + ); + const removeSignals = vi.fn(); + let entered: () => void = () => undefined; + const enteredBuild = new Promise((resolve) => { + entered = resolve; + }); + let release: () => void = () => undefined; + const nativeClosed = new Promise((resolve) => { + release = resolve; + }); + const closeMcp = vi.fn(() => nativeClosed); + const closeDatabase = vi.fn(() => { + if (sqlite.open) sqlite.close(); + }); + const start: NonNullable = ( + _configs, + signal, + work, + lifecycle, + ) => + startMcpClient( + [ + { + id: 'fs', + open: () => + Promise.resolve({ + listTools: () => + Promise.resolve([ + { + name: 'read', + description: 'Read fixture', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + }, + ]), + callTool: () => Promise.resolve({ content: [], isError: false }), + close: closeMcp, + }), + }, + ], + signal, + work, + lifecycle, + ); + const build: NonNullable = () => { + f.invocation.finiteExit.fail('cli_internal'); + f.advance(3000 - remaining); + entered(); + return Promise.reject(primary); + }; + return { + invocation: f.invocation, + subscribeSignals: () => removeSignals, + startMcpClient: start, + buildEngine: build, + closeDatabase, + async verify(action: Promise): Promise { + let settled = false; + const result = action.then( + (value) => { + settled = true; + return value; + }, + (cause: unknown) => { + settled = true; + return cause; + }, + ); + try { + await enteredBuild; + await new Promise((resolve) => setImmediate(resolve)); + expect( + closeMcp, + 'WORKFLOW_FINALIZER_NEVER_STARTS_MCP_CLOSE_AFTER_EXPIRY', + ).toHaveBeenCalledTimes(remaining === 0 ? 0 : 1); + expect(settled, 'WORKFLOW_FINALIZER_ACK_PRECEDES_DATABASE_RELEASE').toBe(false); + expect(closeDatabase).not.toHaveBeenCalled(); + expect(sqlite.open).toBe(true); + expect(removeSignals).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBeGreaterThan(0); + expect(f.registry.budget.deadline()).toBe(3000); + if (remaining === 0) { + expect(await f.invocation.finiteExit.exited).toBe(9); + release(); // An unentered close cannot acknowledge anything. + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + expect(sqlite.open).toBe(true); + expect(removeSignals).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBeGreaterThan(0); + } else { + expect(f.registry.budget.remainingMs()).toBe(1); + release(); + expect(await result).toBe(primary); + expect(closeDatabase).toHaveBeenCalledOnce(); + expect(sqlite.open).toBe(false); + expect(removeSignals).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.exitCode()).toBe(9); + } + expect(f.registry.budget.deadline()).toBe(3000); + } finally { + release(); + } + }, + }; +} diff --git a/apps/cli/src/db/migration-lock-custody.test.ts b/apps/cli/src/db/migration-lock-custody.test.ts new file mode 100644 index 00000000..91414f71 --- /dev/null +++ b/apps/cli/src/db/migration-lock-custody.test.ts @@ -0,0 +1,198 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import Database from 'better-sqlite3'; +import { describe, expect, it, vi } from 'vitest'; + +import { CommandLifetime } from '../process/command-lifetime.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import { openLocalDb } from './open.js'; + +const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +describe('original database acquisition also owns the separate native migration lock', () => { + it.each(['storage', 'opaque', 'native'] as const)( + 'retains failed lock release and signals without operational laundering (%s)', + async (kind) => { + const home = mkdtempSync(join(tmpdir(), 'relavium-migration-custody-')); + const f = invocationFixture(); + const removeSignals = vi.fn(); + const lifetime = new CommandLifetime(f.invocation, captureIo().io, () => removeSignals); + const nativeClose: unknown = Object.getOwnPropertyDescriptor( + Database.prototype, + 'close', + )?.value; + if (typeof nativeClose !== 'function') + throw new Error('Native close fixture is unavailable.'); + const nativeExec: unknown = Object.getOwnPropertyDescriptor( + Database.prototype, + 'exec', + )?.value; + if (typeof nativeExec !== 'function') throw new Error('Native exec fixture is unavailable.'); + const handles: { main?: Database.Database; lock?: Database.Database } = {}; + let iterator: ReturnType | undefined; + let closeFailure: unknown; + let lockCloseAttempts = 0; + const exec = vi.spyOn(Database.prototype, 'exec').mockImplementation(function ( + this: Database.Database, + sql: string, + ) { + Reflect.apply(nativeExec, this, [sql]); + if (kind === 'native' && this.name.endsWith('.migrate.lock') && sql === 'BEGIN EXCLUSIVE') { + handles.lock = this; + iterator = this.prepare('SELECT 1 AS value').iterate(); + iterator.next(); // Actual native busy-close refusal, not a replacement Error. + } + return this; + }); + const close = vi.spyOn(Database.prototype, 'close').mockImplementation(function ( + this: Database.Database, + ) { + if (this.name.endsWith('.migrate.lock')) { + handles.lock = this; + lockCloseAttempts++; + try { + if (kind === 'storage') + throw new Database.SqliteError('synthetic lock close refusal', 'SQLITE_IOERR_CLOSE'); + if (kind === 'opaque') throw new Error('synthetic lock close refusal'); + Reflect.apply(nativeClose, this, []); + return this; + } catch (cause) { + closeFailure = cause; + throw cause; + } + } + handles.main = this; + Reflect.apply(nativeClose, this, []); + return this; + }); + let settled = false; + const action = lifetime.execute(async () => { + lifetime.acquire((capture) => openLocalDb(home, () => {}, capture)); + await Promise.resolve(); + return 0; + }); + void action.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + try { + await reactions(); + expect(handles.main).toBeInstanceOf(Database); + expect(handles.lock).toBeInstanceOf(Database); + expect(handles.main).not.toBe(handles.lock); + expect(handles.main?.open, 'LOCK_FAILURE_DOES_NOT_SKIP_INDEPENDENT_MAIN_RELEASE').toBe( + false, + ); + expect(handles.lock?.open).toBe(true); + if (kind === 'native') expect(closeFailure).toBeInstanceOf(TypeError); + else expect(handles.lock?.prepare('SELECT 1 AS value').get()).toEqual({ value: 1 }); + expect(settled, 'MIGRATION_LOCK_NATIVE_CLOSE_REMAINS_ORIGINAL_OWNER_OBLIGATION').toBe( + false, + ); + expect(f.registry.activeOwners).toBe(1); + expect(lockCloseAttempts).toBe(3); // Lock scope, opener recovery, original owner's cleanup. + expect(removeSignals).not.toHaveBeenCalled(); + expect(f.registry.observations.exitCode(), 'FAILED_LOCK_CLOSE_IS_INFRASTRUCTURE').toBe(9); + expect(f.registry.budget.deadline()).toBe(3000); + f.advance(3000); + expect(await f.invocation.finiteExit.exited).toBe(9); + expect(handles.lock?.open).toBe(true); + expect(f.registry.activeOwners).toBe(1); + expect(removeSignals).not.toHaveBeenCalled(); + } finally { + exec.mockRestore(); + close.mockRestore(); + iterator?.return?.(); + // Harness disposal is not an acknowledgement to the still-uncertain product owner. + if (handles.lock?.open) Reflect.apply(nativeClose, handles.lock, []); + if (handles.main?.open) Reflect.apply(nativeClose, handles.main, []); + rmSync(home, { recursive: true, force: true }); + } + }, + ); + + it('later actual release cannot erase an earlier lock cleanup infrastructure failure', async () => { + const home = mkdtempSync(join(tmpdir(), 'relavium-migration-retry-')); + const f = invocationFixture(); + const removeSignals = vi.fn(); + const lifetime = new CommandLifetime(f.invocation, captureIo().io, () => removeSignals); + const nativeClose: unknown = Object.getOwnPropertyDescriptor( + Database.prototype, + 'close', + )?.value; + if (typeof nativeClose !== 'function') throw new Error('Native close fixture is unavailable.'); + const handles: { main?: Database.Database; lock?: Database.Database } = {}; + let lockAttempts = 0; + const close = vi.spyOn(Database.prototype, 'close').mockImplementation(function ( + this: Database.Database, + ) { + if (this.name.endsWith('.migrate.lock')) { + handles.lock = this; + if (++lockAttempts === 1) + throw new Database.SqliteError('synthetic first release failure', 'SQLITE_IOERR_CLOSE'); + } else handles.main = this; + Reflect.apply(nativeClose, this, []); + return this; + }); + try { + await expect( + lifetime.execute(async () => { + lifetime.acquire((capture) => openLocalDb(home, () => {}, capture)); + await Promise.resolve(); + return 0; + }), + ).rejects.toMatchObject({ code: 'cli_internal', exitCode: 9 }); + expect(lockAttempts).toBe(2); + expect(handles.lock?.open).toBe(false); + expect(handles.main?.open).toBe(false); + expect(f.registry.activeOwners).toBe(0); + expect(removeSignals).toHaveBeenCalledOnce(); + expect(f.registry.observations.exitCode()).toBe(9); + } finally { + close.mockRestore(); + if (handles.lock?.open) Reflect.apply(nativeClose, handles.lock, []); + if (handles.main?.open) Reflect.apply(nativeClose, handles.main, []); + rmSync(home, { recursive: true, force: true }); + } + }); + + it('transfers one composite before either connection setup and returns that same capability', () => { + const home = mkdtempSync(join(tmpdir(), 'relavium-migration-composite-')); + const nativePragma: unknown = Object.getOwnPropertyDescriptor( + Database.prototype, + 'pragma', + )?.value; + if (typeof nativePragma !== 'function') + throw new Error('Native pragma fixture is unavailable.'); + const capture = vi.fn<(close: () => void) => void>(); + const handles = new Set(); + const pragma = vi.spyOn(Database.prototype, 'pragma').mockImplementation(function ( + this: Database.Database, + ...args: Parameters + ) { + expect(capture, 'DATABASE_COMPOSITE_PRECEDES_EVERY_NATIVE_SETUP').toHaveBeenCalledOnce(); + handles.add(this); + return Reflect.apply(nativePragma, this, args); + }); + let opened: ReturnType | undefined; + try { + opened = openLocalDb(home, () => {}, capture); + expect(handles.size).toBe(2); + expect(capture).toHaveBeenCalledExactlyOnceWith(opened.close); + expect([...handles].filter((handle) => handle.open)).toEqual([opened.db.$client]); + opened.close(); + expect([...handles].every((handle) => !handle.open)).toBe(true); + expect(() => opened?.close()).not.toThrow(); + } finally { + pragma.mockRestore(); + opened?.close(); + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/db/open.test.ts b/apps/cli/src/db/open.test.ts index 86b4ea09..4bbf6c04 100644 --- a/apps/cli/src/db/open.test.ts +++ b/apps/cli/src/db/open.test.ts @@ -72,7 +72,7 @@ it('hands off native close before setup and preserves primary plus secondary cle expect(failure).toMatchObject({ code: 'cli_internal', exitCode: 9, - cause: { cause: primary, errors: [primary, secondary] }, + cause: { cause: primary, errors: [primary, { errors: [secondary] }] }, }); expect(client?.sqlite.open).toBe(true); expect(captured).toBeTypeOf('function'); diff --git a/apps/cli/src/db/open.ts b/apps/cli/src/db/open.ts index afdb42c7..791616e8 100644 --- a/apps/cli/src/db/open.ts +++ b/apps/cli/src/db/open.ts @@ -12,7 +12,7 @@ import { processIo } from '../process/io.js'; import { CHECKPOINT_DEFERRED } from './privacy-notice.js'; import { CliError } from '../process/cli-error.js'; -/** An opened local database plus the handle to close its SQLite connection. */ +/** An opened local database plus the close capability for all of its actual native connections. */ export interface OpenedDb { readonly db: Db; readonly close: () => void; @@ -34,27 +34,48 @@ export function openLocalDb( chmodForStorage(dir, 0o700); const path = join(globalConfigDir(homeDir), 'history.db'); let client: ReturnType; - let nativeClose: (() => void) | undefined; - try { - client = createClient(path, (close) => { - nativeClose = close; + const nativeCloses = new Set<() => void>(); + const close = (): void => { + const failures: unknown[] = []; + // A failed auxiliary close must not prevent an independent main-connection release. + for (const release of [...nativeCloses].reverse()) { + try { + release(); + nativeCloses.delete(release); + } catch (cause) { + failures.push(cause); + } + } + if (failures.length !== 0) { + throw new AggregateError(failures, 'Local database cleanup failed.'); + } + }; + let transferred = false; + const captureNativeClose = (release: () => void): void => { + nativeCloses.add(release); + if (!transferred) { + transferred = true; + // Transfer one composite, not competing callbacks that can overwrite the original main handle. + // Later auxiliary construction adds to this already-owned capability before its own setup. captureClose?.(close); - }); + } + }; + try { + client = createClient(path, captureNativeClose); } catch (error) { throw storageBoundaryError(error); } let checkpoint: SessionEffectCheckpoint; - const close = - nativeClose ?? - (() => { - if (client.sqlite.open) client.sqlite.close(); - }); // If setup (migrations / the at-rest chmod) throws, close the just-opened handle before propagating — // otherwise the SQLite connection leaks for the lifetime of the failing process. try { // The original owner receives the actual native capability BEFORE setup can throw. // Legacy injected factories may not implement the optional early handoff. - if (nativeClose === undefined) captureClose?.(close); + if (!transferred) { + captureNativeClose(() => { + if (client.sqlite.open) client.sqlite.close(); + }); + } // #28: the 0600 runs BEFORE the migrations, and again after. Before, because `createClient` has already // CREATED the file at the process umask — typically 644 — and a migration that throws (disk full, an // interrupted first run) used to leave it there, world-readable, for the lifetime of the install. The @@ -63,7 +84,10 @@ export function openLocalDb( hardenAtRest(path); // The batch is serialized across processes (ADR-0073): two `relavium` invocations racing a fresh // `history.db` wait for each other instead of one dying on a duplicate `CREATE TABLE` (#99). - checkpoint = runMigrations(client.db, { dbPath: client.path }); + checkpoint = runMigrations(client.db, { + dbPath: client.path, + captureClose: captureNativeClose, + }); hardenAtRest(path); } catch (err) { try { diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index d19f8233..8eef7cee 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -1036,7 +1036,7 @@ export async function connectWorkflowMcp( // Best-effort: a teardown rejection must NOT replace the original augmentation error (preserve the primary). opts.onFailure?.(err); try { - await client.close(opts.onCloseError); + await closePreparedMcp(client, opts.onCloseError, opts.budget); } catch (cause) { if (opts.onCloseError !== undefined) opts.onCloseError('', cause); else opts.onFailure?.(cause); diff --git a/apps/cli/src/engine/mcp-workflow-preparation.test.ts b/apps/cli/src/engine/mcp-workflow-preparation.test.ts new file mode 100644 index 00000000..87d71ac1 --- /dev/null +++ b/apps/cli/src/engine/mcp-workflow-preparation.test.ts @@ -0,0 +1,123 @@ +import { parseWorkflow } from '@relavium/core'; +import { startMcpClient } from '@relavium/mcp'; +import { describe, expect, it, vi } from 'vitest'; + +import { WorkflowLifetime } from '../commands/workflow-lifetime.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import { EgressWorkScope } from './egress-work.js'; +import { connectWorkflowMcp } from './mcp-servers.js'; + +const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +function gate() { + let release: () => void = () => undefined; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +const def = parseWorkflow(`schema_version: '1.0' +workflow: + id: late-workflow-client + agents: + - id: worker + model: claude-sonnet-4-6 + provider: anthropic + system_prompt: synthetic + mcp_servers: + - { id: owned, transport: stdio, command: synthetic-never-spawned } + nodes: + - { id: start, type: input } + - { id: end, type: output } + edges: + - { from: start, to: end } +`); + +describe('workflow publication keeps the original MCP close budget', () => { + it.each([0, 1])( + 'late successful manager with %s ms remaining never renews cleanup', + async (remaining) => { + const f = invocationFixture(); + const removeSignals = vi.fn(); + const lifetime = new WorkflowLifetime(f.invocation, captureIo().io, () => removeSignals); + const work = new EgressWorkScope(); + const constructed = gate(); + const closed = gate(); + const close = vi.fn(() => closed.promise); + let settled = false; + const action = lifetime.execute(async () => { + try { + await connectWorkflowMcp(def, { + cwd: '/tmp', + consentGate: () => Promise.resolve(new Map()), + assertAdmitting: () => lifetime.checkpoint(), + onFailure: (cause) => lifetime.failure(cause), + onCloseError: (_id, cause) => lifetime.cleanupFailed(cause), + connectSignal: lifetime.signal, + budget: f.registry.budget, + work, + startMcpClient: async (_configs, signal, scope, lifecycle) => { + const client = await startMcpClient( + [ + { + id: 'owned', + open: () => + Promise.resolve({ + listTools: () => Promise.resolve([]), + callTool: () => Promise.resolve({ content: [], isError: false }), + close, + }), + }, + ], + signal, + scope, + lifecycle, + ); + f.invocation.finiteExit.fail('cli_internal'); + f.advance(3000 - remaining); + constructed.release(); + return client; + }, + }); + throw new Error('Stopped manager must never publish.'); + } finally { + work.seal(); + await work.done; + } + }); + void action.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await constructed.promise; + await reactions(); + expect(close, 'WORKFLOW_LATE_CLIENT_CANNOT_START_CLOSE_AFTER_EXPIRY').toHaveBeenCalledTimes( + remaining === 0 ? 0 : 1, + ); + expect(settled, 'WORKFLOW_LATE_CLIENT_CLOSE_IS_ACTUAL_ACK').toBe(false); + expect(f.registry.activeOwners).toBeGreaterThan(0); + expect(removeSignals).not.toHaveBeenCalled(); + expect(f.registry.budget.deadline()).toBe(3000); + if (remaining === 0) { + expect(await f.invocation.finiteExit.exited).toBe(9); + closed.release(); // No close was entered; this cannot acknowledge the product obligation. + await reactions(); + expect(settled).toBe(false); + expect(f.registry.activeOwners).toBeGreaterThan(0); + } else { + expect(f.registry.budget.remainingMs()).toBe(1); + closed.release(); + expect(await action).toBe(9); + expect(f.registry.activeOwners).toBe(0); + expect(removeSignals).toHaveBeenCalledOnce(); + expect(f.registry.budget.deadline()).toBe(3000); + } + }, + ); +}); diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 90ae7a7a..7eb52764 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -912,13 +912,20 @@ turn failures retain exit 1, invocation refusals retain exit 2, and unexpected infrastructure failures use exit 9. This surface has no workflow health authority to invent exits 5–8. -Bound MCP consent uses captured prompt streams and the host cancellation signal. +Interactive session MCP consent uses captured prompt streams and the host cancellation signal. A late answer cannot append a grant. Prompt custody joins actual completion and acknowledged cursor delivery; a fulfilled Clack prompt is not aborted again during normal cleanup. Best-effort retention warnings retain their actual delivery tail and output-failure classification. The optional builder ownership port preserves accounting/publication failures across repeated host-safe-close checks. +One-shot `agent run` first consumes and closes its mandatory prompt input. Its +default MCP gate therefore has no remaining stdin prompt authority, even if +captured TTY metadata still says interactive. An existing persistent grant or +exact `--allow-mcp-stdio` digest can authorize the declared server; otherwise the +gate refuses with exit 2 before starting a client. An explicitly supplied host +consent gate retains its own authority; the default never reopens ambient input. + Fresh, resumed, cleared and reseated standalone chats, and every actual pending, late or superseded Home chat, now retain their construction and post-build persistence/view setup independently of the UI's selected session. Equal durable @@ -1021,6 +1028,14 @@ absolute budget. A late engine/handle is adopted before further work and drains without acquiring new input after stop. A late consent answer cannot append a grant or enter secrets, MCP or business work. +A successfully constructed MCP client returned after stop, and the finalizers +of `run`, human-gate resume and budget resume, share the invocation's original +remaining budget. At zero they retain the unacknowledged client and database +obligations without entering another asynchronous SDK close. A close entered +before expiry still needs its actual acknowledgement before history can close +and signal custody can be removed; neither expiry nor a cancellation request is +that acknowledgement. + The driver keeps one primary reader across prompts. Actual departure health can progress while input is held; a rejected reader is never positive primary done. Publish actual money/effect/terminal health before rendering, finalization and diff --git a/docs/reference/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index 8e7bf73b..71666dde 100644 --- a/docs/reference/shared-core/database-schema.md +++ b/docs/reference/shared-core/database-schema.md @@ -1342,6 +1342,16 @@ not inherit operational storage provenance. A returned handle, swallowed cleanup error or elapsed shutdown budget is not evidence of native release. CLI ownership and refusal projection follow the [CLI lifecycle contract](../cli/commands.md#management-ownership-implementation-checkpoint). +`runMigrations(db, { dbPath, captureClose? })` forwards the separate migration-lock +connection's idempotent native close immediately after its constructor and before +its PRAGMA or lock acquisition. The CLI opener transfers one composite close to +its original owner before main-connection setup, adds each auxiliary capability +to that same composite, and returns the same capability. It attempts independent +releases even if another fails, removes only successfully released capabilities, +and retains failed releases for the original owner's cleanup. A lock-close +failure preserves any migration failure alongside it and never acquires +operational SQLite provenance, even if the native error is storage-shaped. + `history.db` is a **single shared file** two concurrent `relavium` processes may write at once — e.g. a `run` persisting events while a `chat` refreshes the live model catalog ([ADR-0064](../../decisions/0064-live-model-catalog.md) §5). The Node/CLI path (`better-sqlite3`, [ADR-0021](../../decisions/0021-node-sqlite-driver-better-sqlite3.md), re-affirmed by [ADR-0067](../../decisions/0067-node-supported-floor-22-reaffirm-better-sqlite3.md)) hardens this at the connection and the transaction level; this is the one canonical home for the policy that `packages/db/src/retry.ts` and the store doc-comments cite. - **Connection PRAGMAs** ([`client.ts`](../../../packages/db/src/client.ts)): `journal_mode = WAL` (readers never block the single writer, and vice-versa), `busy_timeout = 5000` (SQLite's built-in busy handler waits up to 5 s for a contended lock before returning `SQLITE_BUSY`), `synchronous = NORMAL` (the recommended durability/throughput trade-off under WAL), and `foreign_keys = ON`. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 3ecd120a..73e85cf3 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1165,7 +1165,14 @@ shared consumed-input custody and pre-setup database ownership across the actual shipping surfaces. Earlier CI failures retain their exact attribution; the macOS packed failure is not cleared by local reproduction passes. Two NEW complete clean rounds, a NEW separate clean security sitting and current CI/platform acceptance -remain open. Step 8d proceeds automatically. The +remain open. The [fourth cumulative review and native-resource corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-fourth-cumulative-review-and-native-resource-corrections--2026-10-17) +records complete NONCLEAN coverage of 279 paths at `db825377`: three verified High +findings cover consumed one-shot input, late workflow MCP close admission and the +separate native migration-lock connection. All four same-artifact package consumers +pass at that source; this does not grant review acceptance or erase the earlier +unexplained macOS failure. Two NEW complete cumulative clean rounds after the last +material fix, a NEW separate clean security sitting and fresh full CI/platform +acceptance remain required. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index e29bf40d..2ea70e86 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -6483,3 +6483,82 @@ Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting, fresh full CI and all four same-artifact platform consumers remain required. Whole Step 8 and Steps 9–19 remain open. No user decision, ADR approval, provider, key, billing or capture action is pending. + +### Wave 3 Step 8d fourth cumulative review and native-resource corrections — 2026-10-17 + +**Implementation correction checkpoint; not Step 8d acceptance.** Three NEW +complete cumulative reviewers cover all **279 paths / 1,506 zero-context hunks** +at `db825377a6b213d8a63a1ed0b39a2a63b9193982`, against the unchanged cumulative base +`d9625ff0a641197c6ed45968df99094a25db88d6`. Their disjoint 73/129/77-path slices +join exactly, with every assigned physical source hash checked. The session slice +is scoped CLEAN; workflow/management and native/tools/docs are NONCLEAN, with +three verified High findings. The local Git-ignored aggregate SHA-256 is +`13554160ab36a9fd076e5b26dc14e0983e9ce4ad0aeea70a378a90e14b9278fd`. +Rounds 1–4 remain NONCLEAN; a clean slice is not whole-increment acceptance. + +- **WM-R4-1, consumed one-shot input:** the default `agent run` gate previously + treated stale TTY metadata as permission to prompt after mandatory stdin had + reached actual EOF/close. An actual command/default-builder/installed-Clack + reviewer probe remained pending without spawning MCP until its original signal + cancelled it. The corrected default has no second input authority; unapproved + stdio refuses before prompt/client construction. The paired exact-digest control + succeeds through the real builder and manager without writing a grant. Explicit + injected host consent retains its own authority. +- **WM-R4-2, expired successful-client cleanup:** workflow late publication and + all three actual run/human-gate/budget-resume finalizers now use the original + remaining budget, as the agent connector already did. Expiry cannot start a new + asynchronous SDK close or release the database/signals. Paired actual-manager + controls exercise zero remaining time and a close entered with one millisecond + remaining; the latter holds real close acknowledgement before actual SQLite + release. The original absolute deadline is unchanged. +- **N4-01, auxiliary SQLite custody:** the migration lock creates a second native + connection, distinct from history. Its close capability is now captured before + PRAGMA/lock acquisition and added to the one composite already transferred to + the original opener owner. Failed auxiliary release does not skip independent + main release, retire the owner, remove signals or gain operational storage + provenance. Primary and cleanup causes survive. Actual installed-driver + held-iterator close refusal, opaque/storage-shaped injections, later successful + release and normal two-connection closure have permanent controls. The held + iterator is an injected schedule, not a claim that ordinary migration creates + it or that an OS disk fault was measured. + +Independent reviewer execution remains scoped: 697 session tests; 56 workflow +preparation/acquisition tests; 117 native source tests, 54 tooling tests and three +actual-handle reproductions. Counts overlap author selections and are not summed +into a new whole-suite claim. Root independently reproduced all three lock-close +cases. Author regression execution passes 289 CLI cases in ten complete files +and fourteen DB cases in three complete files before final whole-workspace gates. +Five separate author removal exercises break the named forwarding, native lock +handoff, consumed-input and workflow close controls; each restores exact original +bytes. The initial malformed forwarding mutation and the initial obscured native +witness are rejected harness attempts, retained locally and not acceptance +results. Filtered unselected cases are not product skips or whole-suite execution. + +The CLI declares the already-catalogued SQLite development type package for its +actual native-handle tests; no runtime package or resolved version changes. +Frozen offline lockfile validation and CLI/DB typechecks pass. Canonical behavior +belongs in the [CLI lifecycle contract](../../reference/cli/commands.md#management-ownership-implementation-checkpoint) +and [database contract](../../reference/shared-core/database-schema.md#concurrency--transaction-behavior). + +At source `db825377`, required packed Linux/macOS/Windows and minimum Node 22.0.0 +consumers and their aggregate pass in run `38989000942`. Broader CI was still +running at this checkpoint. These new remote passes do not rewrite the earlier +unexplained `ec8ba368` macOS failure or count as post-correction evidence. +Two NEW complete cumulative clean rounds after the last material fix, a NEW +separate clean security sitting, fresh full CI and all four same-artifact package +consumers remain required. Whole Step 8 and Steps 9–19 remain open. No user, +ADR, provider, key, billing or capture decision is pending. + +**Final author source gates for this correction.** Workspace lint/typecheck passes +seventeen tasks (fifteen cached); tests pass eleven tasks (nine cached), with +fresh CLI **334 files / 5,407 passing tests / eleven existing reported skips** +and fresh DB **64 files / 1,062 passing tests / no skips**. Build passes six tasks +(five cached); bundle closure remains 25 outputs / 407 inputs / seventeen declared +externals. All 512 acceptance-tool tests, tools lint/typecheck, schema sync, +format, seam/purity, test isolation, 177 compiled bootstrap controls and compiled +CLI smoke pass. The final five removal exercises restore exact bytes; restored +execution passes 246 CLI and fourteen DB cases with no failures/skips. Earlier +author lint failures are corrected, not waived. The local 18-path product/test/ +configuration source pin SHA-256 is `c33fe30d7ee58825a924e2d1ac81f64a94ab0c474cc8d2b890ab9dae407585d0`. These are author gates; +independent cumulative review, security and post-correction remote acceptance +remain open. diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index f757c986..975b84ef 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -206,6 +206,8 @@ export interface RunMigrationsOptions { * existing caller, and the ones that matter — the real `history.db` openers — are a short, reviewable list. */ readonly dbPath?: string; + /** Adds the separate migration-lock native close to the original database acquisition. */ + readonly captureClose?: (close: () => void) => void; } /** @@ -219,13 +221,17 @@ export interface RunMigrationsOptions { */ export function runMigrations(db: Db, options: RunMigrationsOptions = {}): SessionEffectCheckpoint { requireSessionEffectTransactionOwnership(db); - return withMigrationLock(options.dbPath, () => { - migrateWithOwnedStorageFacts(db, MIGRATIONS_DIR); - if (hasOwnedClient(db)) { - withOwnedSqlite(() => initializeSessionEffectTurnKeys(db)); - return withOwnedSqlite(() => clearLegacySessionEffectResults(db)); - } - initializeSessionEffectTurnKeys(db); - return clearLegacySessionEffectResults(db); - }); + return withMigrationLock( + options.dbPath, + () => { + migrateWithOwnedStorageFacts(db, MIGRATIONS_DIR); + if (hasOwnedClient(db)) { + withOwnedSqlite(() => initializeSessionEffectTurnKeys(db)); + return withOwnedSqlite(() => clearLegacySessionEffectResults(db)); + } + initializeSessionEffectTurnKeys(db); + return clearLegacySessionEffectResults(db); + }, + options, + ); } diff --git a/packages/db/src/migrate-lock.test.ts b/packages/db/src/migrate-lock.test.ts index 1c179c14..aeb396ce 100644 --- a/packages/db/src/migrate-lock.test.ts +++ b/packages/db/src/migrate-lock.test.ts @@ -6,6 +6,7 @@ import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { withMigrationLock } from './migrate-lock.js'; +import { isOwnedStorageFailure } from './storage-failure-facts.js'; /** * The ADR-0073 migration lock. It is an OS advisory lock (`BEGIN EXCLUSIVE` on a dedicated SQLite lock file), @@ -31,6 +32,62 @@ afterEach(() => { }); describe('withMigrationLock (ADR-0073)', () => { + it.each([false, true])( + 'preserves the primary while failed native release stays infrastructure (body fails=%s)', + (bodyFails) => { + const lock = new Database(lockPath); + const nativeClose = lock.close.bind(lock); + const primary = new Error('synthetic migration failure'); + const secondary = new Database.SqliteError('synthetic close failure', 'SQLITE_IOERR_CLOSE'); + let captured: (() => void) | undefined; + let setupEntered = false; + let capturedBeforeSetup = false; + const nativePragma = lock.pragma.bind(lock); + lock.pragma = (...args: Parameters) => { + capturedBeforeSetup = typeof captured === 'function'; + setupEntered = true; + return nativePragma(...args); + }; + lock.close = () => { + throw secondary; + }; + try { + let failure: unknown; + try { + withMigrationLock( + dbPath, + () => { + if (bodyFails) throw primary; + return 'ok'; + }, + { + openLock: () => lock, + captureClose: (close) => { + captured = close; + }, + }, + ); + } catch (cause) { + failure = cause; + } + expect(capturedBeforeSetup, 'LOCK_CLOSE_CAPABILITY_PRECEDES_SETUP').toBe(true); + expect(setupEntered).toBe(true); + expect(failure).toBeInstanceOf(AggregateError); + if (!(failure instanceof AggregateError)) throw new Error('Expected cleanup failure.'); + expect(failure.errors).toEqual(bodyFails ? [primary, secondary] : [secondary]); + expect(failure.cause).toBe(bodyFails ? primary : secondary); + expect(isOwnedStorageFailure(failure)).toBe(false); + expect(lock.open).toBe(true); + lock.close = nativeClose; + captured?.(); + expect(lock.open).toBe(false); + expect(() => captured?.()).not.toThrow(); + } finally { + if (lock.open) nativeClose(); + } + }, + ); + it('runs the body and releases the lock afterwards', () => { expect(withMigrationLock(dbPath, () => 'ok')).toBe('ok'); // Released: a second acquisition succeeds promptly rather than waiting out the timeout. diff --git a/packages/db/src/migrate-lock.ts b/packages/db/src/migrate-lock.ts index 4060b248..e2bdc2b9 100644 --- a/packages/db/src/migrate-lock.ts +++ b/packages/db/src/migrate-lock.ts @@ -65,6 +65,8 @@ export function withMigrationLock( readonly openLock?: (path: string) => Database.Database; /** Acquisition timeout override — tests drive the refused-acquisition branch without a real 10 s wait. */ readonly timeoutMs?: number; + /** Transfer this separate native connection to the original acquisition before setup can fail. */ + readonly captureClose?: (close: () => void) => void; } = {}, ): T { if (dbPath === undefined || dbPath === ':memory:') { @@ -78,7 +80,11 @@ export function withMigrationLock( // The lock file cannot be opened at all (a read-only directory). Reconciling beats refusing to start. return runReconciling(run); } - try { + const close = (): void => { + if (lock.open) lock.close(); + }; + const runLocked = (): T => { + deps.captureClose?.(close); const configureTimeout = (): unknown => lock.pragma(`busy_timeout = ${deps.timeoutMs ?? LOCK_TIMEOUT_MS}`); if (deps.openLock === undefined) withOwnedSqlite(configureTimeout); @@ -94,15 +100,33 @@ export function withMigrationLock( return run(); } finally { // Release. A throw here must not mask the migration's own outcome, and `close()` below releases the OS - // lock regardless — so this is best-effort by design, not a swallowed failure. + // lock on successful close — that release is independently checked below. try { lock.exec('COMMIT'); } catch { - /* the close below releases the OS lock either way */ + /* Native close below owns release; its failure is reported separately. */ } } - } finally { - if (deps.openLock === undefined) withOwnedSqlite(() => lock.close()); - else lock.close(); + }; + let outcome: + | { readonly kind: 'value'; readonly value: T } + | { readonly kind: 'failure'; readonly cause: unknown }; + try { + outcome = { kind: 'value', value: runLocked() }; + } catch (cause) { + outcome = { kind: 'failure', cause }; + } + try { + close(); + } catch (cleanupCause) { + // Failed native release is infrastructure, never an operational SQLite-origin fact. + // The original acquisition retains the same capability for its actual settlement. + throw new AggregateError( + outcome.kind === 'value' ? [cleanupCause] : [outcome.cause, cleanupCause], + 'Migration lock cleanup failed.', + { cause: outcome.kind === 'value' ? cleanupCause : outcome.cause }, + ); } + if (outcome.kind === 'failure') throw outcome.cause; + return outcome.value; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69c81e2f..cd5ce6a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,6 +207,9 @@ importers: '@relavium/shared': specifier: workspace:* version: link:../../packages/shared + '@types/better-sqlite3': + specifier: 'catalog:' + version: 7.6.13 '@types/node': specifier: 'catalog:' version: 22.19.19 From 4b7ba2164d119d72a0fccaea742a6e261f03576f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 04:58:36 +0300 Subject: [PATCH 116/222] fix(mcp): retain live close budgets across deferred manager entry Preserve the original invocation budget for successful MCP clients until actual transport-close factories enter, including deferred microtasks and per-connection exhaustion. Keep successful reporters separate from failed-start observers and preserve the shared reentrant close join. Add manager and real workflow-finalizer regressions; update the exact secrets workload pins after new cases. Record cumulative review findings, author gates and the unresolved Windows timeout without claiming Step 8d acceptance. No timeout, shutdown budget or control is relaxed. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- AGENTS.md | 9 +- CLAUDE.md | 9 +- apps/cli/src/commands/budget.test.ts | 12 ++- apps/cli/src/commands/gate.test.ts | 12 ++- apps/cli/src/commands/run.test.ts | 12 ++- .../workflow-expired-cleanup.test-support.ts | 24 ++++- docs/reference/shared-core/mcp-integration.md | 7 +- docs/roadmap/current.md | 10 +- .../phase-2.5.5-hardening-and-remediation.md | 94 +++++++++++++++++++ .../mcp/src/manager-startup-cleanup.test.ts | 84 +++++++++++++++++ packages/mcp/src/manager.ts | 12 ++- tools/secrets-controls/README.md | 4 +- tools/secrets-controls/mutations.mjs | 6 +- 13 files changed, 263 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9d5b798c..20d17ddc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,9 +107,12 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first four complete cumulative rounds are -NONCLEAN; the three fourth-round findings have implementation corrections and -passing author gates. Two NEW complete cumulative clean rounds after the last +but not independently accepted. Its first five complete cumulative rounds are +NONCLEAN; the fourth-round findings have implementation corrections and passing +author gates. The fifth round finds a deferred MCP transport-close budget gap; +its correction and strict causal-workload pin updates pass author validation. +An isolated Windows advisory timeout remains unexplained; fresh evidence is +required. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. Step 8 proceeds as recorded serial increments 8a–8d; native diff --git a/CLAUDE.md b/CLAUDE.md index 3492bfef..8d8517bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,9 +196,12 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first four complete cumulative rounds are -NONCLEAN; the three fourth-round findings have implementation corrections and -passing author gates. Two NEW complete cumulative clean rounds after the last +but not independently accepted. Its first five complete cumulative rounds are +NONCLEAN; the fourth-round findings have implementation corrections and passing +author gates. The fifth round finds a deferred MCP transport-close budget gap; +its correction and strict causal-workload pin updates pass author validation. +An isolated Windows advisory timeout remains unexplained; fresh evidence is +required. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. Step 8 proceeds as recorded serial increments 8a–8d; native diff --git a/apps/cli/src/commands/budget.test.ts b/apps/cli/src/commands/budget.test.ts index fbf08e15..e36d413c 100644 --- a/apps/cli/src/commands/budget.test.ts +++ b/apps/cli/src/commands/budget.test.ts @@ -245,11 +245,15 @@ function deps(io: ReturnType['io']): GateCommandDeps { }, }; } -it.each([0, 1])( - 'budget resume finalizer respects the original MCP budget (%s ms left)', - async (remaining) => { +it.each([ + { remaining: 0, queuedExpiry: false }, + { remaining: 1, queuedExpiry: false }, + { remaining: 1, queuedExpiry: true }, +])( + 'budget resume finalizer respects the original MCP budget ($remaining ms left, queued expiry=$queuedExpiry)', + async ({ remaining, queuedExpiry }) => { const paused = await seed({ mcp: true }); - const control = workflowFinalizerControl(remaining, client.sqlite); + const control = workflowFinalizerControl(remaining, client.sqlite, queuedExpiry); await control.verify( budgetCommand( { runId: paused.runId, approveAmount: String(paused.amount) }, diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index 5dfce223..86b67fc7 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -348,9 +348,13 @@ describe('gateCommand', () => { return { runId, gateIds }; } - it.each([0, 1])( - 'gate finalizer respects the original MCP budget (%s ms left)', - async (remaining) => { + it.each([ + { remaining: 0, queuedExpiry: false }, + { remaining: 1, queuedExpiry: false }, + { remaining: 1, queuedExpiry: true }, + ])( + 'gate finalizer respects the original MCP budget ($remaining ms left, queued expiry=$queuedExpiry)', + async ({ remaining, queuedExpiry }) => { const yaml = GATED.replace( ' nodes:', ` agents: @@ -363,7 +367,7 @@ describe('gateCommand', () => { nodes:`, ); const paused = await setupPausedRun(yaml); - const control = workflowFinalizerControl(remaining, client.sqlite); + const control = workflowFinalizerControl(remaining, client.sqlite, queuedExpiry); await control.verify( gateCommand( { runId: paused.runId, approve: true }, diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts index 6897ab74..f05394c5 100644 --- a/apps/cli/src/commands/run.test.ts +++ b/apps/cli/src/commands/run.test.ts @@ -1846,12 +1846,16 @@ workflow: expect(engineBuilt).toBe(false); // the connect failed before the engine was ever built (no leak) }); - it.each([0, 1])( - 'run finalizer respects the original MCP budget (%s ms left)', - async (remaining) => { + it.each([ + { remaining: 0, queuedExpiry: false }, + { remaining: 1, queuedExpiry: false }, + { remaining: 1, queuedExpiry: true }, + ])( + 'run finalizer respects the original MCP budget ($remaining ms left, queued expiry=$queuedExpiry)', + async ({ remaining, queuedExpiry }) => { const client = createClient(':memory:'); runMigrations(client.db); - const control = workflowFinalizerControl(remaining, client.sqlite); + const control = workflowFinalizerControl(remaining, client.sqlite, queuedExpiry); try { await control.verify( runCommand( diff --git a/apps/cli/src/commands/workflow-expired-cleanup.test-support.ts b/apps/cli/src/commands/workflow-expired-cleanup.test-support.ts index e302e593..bc7724b5 100644 --- a/apps/cli/src/commands/workflow-expired-cleanup.test-support.ts +++ b/apps/cli/src/commands/workflow-expired-cleanup.test-support.ts @@ -6,7 +6,11 @@ import { invocationFixture } from '../process/invocation.test-support.js'; import type { RunCommandDeps } from './run.js'; /** Actual manager + actual SQLite witness, shared by the three original workflow entry points. */ -export function workflowFinalizerControl(remaining: number, sqlite: DbClient['sqlite']) { +export function workflowFinalizerControl( + remaining: number, + sqlite: DbClient['sqlite'], + queuedExpiry = false, +) { const f = invocationFixture(); const primary = new Error( 'synthetic engine construction failure after MCP and database acquisition', @@ -52,7 +56,19 @@ export function workflowFinalizerControl(remaining: number, sqlite: DbClient['sq signal, work, lifecycle, - ); + ).then((client) => { + if (queuedExpiry) { + const close = client.close.bind(client); + client.close = (report) => { + // Another already-queued owner's work consumes the last millisecond before closeAll's + // deferred transport entry. Forward the real manager's exact returned close promise. + expect(f.registry.budget.remainingMs()).toBe(1); + queueMicrotask(() => f.advance(3000)); + return close(report); + }; + } + return client; + }); const build: NonNullable = () => { f.invocation.finiteExit.fail('cli_internal'); f.advance(3000 - remaining); @@ -83,14 +99,14 @@ export function workflowFinalizerControl(remaining: number, sqlite: DbClient['sq expect( closeMcp, 'WORKFLOW_FINALIZER_NEVER_STARTS_MCP_CLOSE_AFTER_EXPIRY', - ).toHaveBeenCalledTimes(remaining === 0 ? 0 : 1); + ).toHaveBeenCalledTimes(remaining === 0 || queuedExpiry ? 0 : 1); expect(settled, 'WORKFLOW_FINALIZER_ACK_PRECEDES_DATABASE_RELEASE').toBe(false); expect(closeDatabase).not.toHaveBeenCalled(); expect(sqlite.open).toBe(true); expect(removeSignals).not.toHaveBeenCalled(); expect(f.registry.activeOwners).toBeGreaterThan(0); expect(f.registry.budget.deadline()).toBe(3000); - if (remaining === 0) { + if (remaining === 0 || queuedExpiry) { expect(await f.invocation.finiteExit.exited).toBe(9); release(); // An unentered close cannot acknowledge anything. await new Promise((resolve) => setImmediate(resolve)); diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index 7c1997c3..2bdd13eb 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -209,9 +209,14 @@ argument, separately from its existing third work-transfer argument. Its `onCloseError(serverId, cause)` observes each failed-start connection close refusal immediately, including while a different close is held. Its read-only `budget.remainingMs()` uses the invocation's existing remaining budget. The -manager checks it before each new close; zero remaining time enters no new +manager retains that same live budget for successfully initialized clients too, +across its deferred shared-close entry, and checks it immediately before each +actual transport-close factory; zero remaining time enters no new asynchronous close and supplies no cleanup acknowledgement. Ordinary startup without these ports retains its existing actual joins and typed refusal. +Successful-client failures still use each supplied `close(onCloseError)` reporter +once; the failed-start lifecycle observer is not delivered a second time for an +ordinary successful-client close. Failed-start cleanup transfers its own work scope before server construction. All exact entered closes remain joined even if the lifecycle reporter throws. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 73e85cf3..243eb6cd 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1172,7 +1172,15 @@ separate native migration-lock connection. All four same-artifact package consum pass at that source; this does not grant review acceptance or erase the earlier unexplained macOS failure. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI/platform -acceptance remain required. Step 8d proceeds automatically. The +acceptance remain required. The [fifth cumulative review and deferred-close correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-fifth-cumulative-review-and-deferred-close-correction--2026-10-17) +records complete NONCLEAN coverage of 285 paths / 1,526 independently hash-checked +hunks at `ad743ba5`, with one corroborated manager microtask-expiry finding. +All four exact-package consumers pass there and their physical artifacts are +independently inspected, but broader CI exposes an obsolete causal workload pin +and an unexplained Windows advisory timeout. The implementation correction +preserves the live budget through actual transport entry and updates strict +workload counts; new reviews/security/full CI and Windows evidence remain open. +Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 2ea70e86..fe86a497 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -6562,3 +6562,97 @@ author lint failures are corrected, not waived. The local 18-path product/test/ configuration source pin SHA-256 is `c33fe30d7ee58825a924e2d1ac81f64a94ab0c474cc8d2b890ab9dae407585d0`. These are author gates; independent cumulative review, security and post-correction remote acceptance remain open. + +### Wave 3 Step 8d fifth cumulative review and deferred-close correction — 2026-10-17 + +**NONCLEAN; no Step 8d acceptance.** Three NEW complete cumulative reviewers cover +all **285 paths / 1,526 zero-context hunks** at +`ad743ba5819361ad070a27ee31f0fb5751b823d4`, against the same `d9625ff0` base. +Root independently verifies every physical file, complete diff and individual +hunk hash, with disjoint 73/131/81-path coverage. The local Git-ignored aggregate +SHA-256 is `56250341253e61c55c6788f142a1998bde576ffb378bc582be8f660c91d34adc`. +Session/terminal is scoped CLEAN; the other two slices corroborate the same High +**WM-R5-1**, counted once. R4's three original schedules are repaired; all first +five complete rounds remain NONCLEAN. + +The helper admitted a successful MCP manager close with one millisecond left, +but the manager deferred actual connection entry and discarded the lifecycle +budget after successful startup. An already queued microtask could consume that +millisecond before transport/SDK close began. Root and both relevant reviewers +reproduce the paired helper schedule; independent reviewer probes additionally +exercise actual `runCommand`, the real manager and real SQLite. These are +synthetic connections/fake clock schedules, not native MCP subprocesses. They +establish forbidden late close entry, not a renewed deadline, false ACK or survival +past physical exit. + +The manager now retains the same live budget across deferred successful close +and checks it at every actual transport-close factory. Successful-client reporters +remain distinct from failed-start observers, preserving once-per-callback delivery +and the shared reentrant close promise. Permanent controls cover queued expiry, +remaining-time positive acknowledgement, per-connection exhaustion and reporter +identity; actual run/human/budget finalizers add the queued-expiry schedule beside +their zero/one-millisecond pairs. No shutdown budget or timeout increases. +Canonical semantics live in the [MCP integration contract](../../reference/shared-core/mcp-integration.md#failed-start-cleanup-lifecycle). + +Reviewer execution retains its separate scopes: 697 session tests; 405 workflow +cases plus two selected budget cases; 159 native cases and 66 tool cases; four +shared defect-observation probes. These overlapping counts are not a new suite +aggregate or the separate security sitting. The root-read complete reports remain +local and ignored. + +At `ad743ba5`, all four packed consumers/aggregate pass. Root downloads the actual +five GitHub artifacts, verifies each GitHub ZIP digest, rechecks tar bytes/public +inventory and all seventy installed files per consumer, and binds every report +to the actual remote run/attempt/source. The common tarball SHA-256 is +`487870990752ce6559f9cdec979702bff0a8ba8670e439223f434e3f6c40554f`; +the independent local physical-audit record SHA-256 is +`e0f503fb4adf29f414f54e7cad40d7bc9a5f29bf53e13455ee00c270cb2f37a2`. +This is artifact inspection, not a new remote consumer execution. Current Node +consumers use 24.21.0; the separate minimum leg is exactly 22.0.0. + +Broader CI `38991418424` is not accepted: required secrets controls correctly +reject an obsolete 80-case run-file pin after R4 added two cases, and the Windows +advisory times out once in benign-alias resume (5,143 ms against 5,000). The +secrets pins are updated to the actual final 83 run / 71 gate cases, including +this correction's added schedule, while exact-count/identity/runtime-error and +fixed/removal/restored checks remain strict. No control or assertion is removed. +The same Windows case passed at prior `db825377` in 437 ms; local macOS controls +do not explain or clear the current Windows failure. Fresh Windows evidence +remains required; no timeout relaxation or inferred product repair is recorded. + +Two NEW complete cumulative clean rounds after the last material correction, +a NEW clean separate security sitting and fresh full CI/platform evidence remain +required. Whole Step 8 and Steps 9–19 remain open. No new ADR or user decision, +provider, credential, billing or production custody setup is needed here. + + +**Final author gates for the R5 correction.** Seventeen workspace lint/typecheck +tasks pass (twelve cached); eleven test tasks pass (nine cached), with fresh CLI +**334 files / 5,410 passing tests / eleven established reported skips** and fresh +MCP **twenty files / 246 passing tests / no skips**. Build/format passes seven +tasks (five cached); all 512 acceptance-tool tests, tools lint/typecheck, schema +sync, seam/purity, test isolation, 25-output bundle closure, 177 compiled bootstrap +controls and compiled CLI smoke pass. The new regressions first break at the two +named MCP assertions and all three actual workflow-finalizer assertions before +the fix; their corrected complete/selected executions pass. Filtered unselected +cases are not product skips or whole-suite evidence. + +Three affected secrets controls independently pass fixed/removal/restored at +83/71/83 complete-file cases, **nine phases / 711 executions**. Each removal +breaks its named witness, every restored source digest equals its fixed digest, +and the isolated graph reports developer sources unchanged. This selected run +honestly retains `complete: false`; it is not acceptance of the full secrets +family. An initial unsupported argument invocation is a rejected setup, not a +product result. The local causal-summary SHA-256 is +`aca1ee8f4a921f86afdcc175b9a247d516aaae4766c7e1739c5314206157a551`; +the seven-path product/test/control author pin SHA-256 is +`92bd5f22ec12fd8fda5db4e577044d0baa53bfdced9275f579025e7116c0385f`. + +Separate read-only Windows triage finds no verified defect. The unchanged +original eight-case suite passes locally, and an immutable `ad743ba5` snapshot +verifies all eight acquired native handles close with zero remaining product +owners. Local macOS timings cannot explain the remote Windows timeout. The local +triage report SHA-256 is +`edf43b42f6ab90043e05cf2445d213bdf71146aed32a798de1a37c690a0d4702`. +These are author/triage records; rounds 1–5 remain NONCLEAN and new cumulative +review, separate security and post-correction CI/platform acceptance remain open. diff --git a/packages/mcp/src/manager-startup-cleanup.test.ts b/packages/mcp/src/manager-startup-cleanup.test.ts index 44809484..2ca835a4 100644 --- a/packages/mcp/src/manager-startup-cleanup.test.ts +++ b/packages/mcp/src/manager-startup-cleanup.test.ts @@ -15,6 +15,90 @@ function gate() { const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); +describe('successful manager cleanup keeps its original live budget at transport entry', () => { + it.each([false, true])( + 'rechecks the deferred close boundary (queued expiry=%s)', + async (expire) => { + let remaining = 1; + const closed = gate(); + const close = vi.fn(() => closed.promise); + const manager = await startMcpClient([server('one', close)], undefined, undefined, { + budget: { remainingMs: () => remaining }, + }); + if (expire) + queueMicrotask(() => { + remaining = 0; + }); + let settled = false; + const closing = manager.close().then(() => { + settled = true; + }); + await reactions(); + expect(close, 'MCP_SUCCESSFUL_MANAGER_RECHECKS_DEFERRED_CLOSE_BUDGET').toHaveBeenCalledTimes( + expire ? 0 : 1, + ); + expect(settled).toBe(false); + closed.release(); + if (expire) { + await reactions(); + expect(settled, 'MCP_SUCCESSFUL_MANAGER_EXPIRY_IS_NOT_ACK').toBe(false); + } else { + await closing; + expect(settled).toBe(true); + } + }, + ); + + it('rechecks every successful connection when an earlier close consumes the last budget', async () => { + let remaining = 1; + const first = vi.fn(() => { + remaining = 0; + return Promise.resolve(); + }); + const second = vi.fn(() => Promise.resolve()); + const manager = await startMcpClient( + [server('one', first), server('two', second)], + undefined, + undefined, + { + budget: { remainingMs: () => remaining }, + }, + ); + let settled = false; + void manager.close().then(() => { + settled = true; + }); + await reactions(); + expect(first).toHaveBeenCalledOnce(); + expect(second, 'MCP_SUCCESSFUL_MANAGER_RECHECKS_EACH_TRANSPORT_CLOSE').not.toHaveBeenCalled(); + expect(settled).toBe(false); + }); + + it('keeps successful-client reporters distinct from failed-start lifecycle observers', async () => { + const failure = new Error('synthetic transport close failure'); + const report = vi.fn(); + const manager = await startMcpClient( + [server('one', () => Promise.reject(failure))], + undefined, + undefined, + { + onCloseError: report, + budget: { remainingMs: () => 1 }, + }, + ); + await manager.close(report); + expect( + report, + 'MCP_SUCCESSFUL_CLOSE_REPORTER_IS_NOT_DELIVERED_TWICE', + ).toHaveBeenCalledExactlyOnceWith('one', failure); + await manager.close(report); + expect(report).toHaveBeenCalledOnce(); + const late = vi.fn(); + await manager.close(late); + expect(late).toHaveBeenCalledExactlyOnceWith('one', failure); + }); +}); + function server( id: string, close: McpConnection['close'], diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index 21ff8a26..7e2ef4bd 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -80,11 +80,11 @@ export interface McpClient { readonly childPids: readonly number[]; } -/** Host-only failed-start cleanup observations; no SDK/native handle or receipt authority crosses here. */ +/** Host-only failed-start observations and live close budget; no native handle or receipt authority. */ export interface McpStartupLifecycle { /** Called immediately for each actual close refusal, even while another connection is still closing. */ readonly onCloseError?: (serverId: string, cause: unknown) => void; - /** The original live budget. Expiry permits no new async close and grants no cleanup acknowledgement. */ + /** Applies to every actual close, including successful startup and deferred manager entry. */ readonly budget?: { readonly remainingMs: () => number | undefined }; } @@ -132,12 +132,18 @@ export async function startMcpClient( void observerFailure.catch(() => undefined); const close = (onCloseError?: (serverId: string, cause: unknown) => void): Promise => { // Publish the shared join before entering custom close factories, including reentry. + // Successful clients keep the same live budget across this deferred entry. Their reporter + // still belongs to close(onCloseError), separately from the failed-start lifecycle observer. closing ??= Promise.resolve().then(() => closeAll( connections, closeFailures, failedStartup ? failedCleanup : undefined, - failedStartup ? lifecycle : undefined, + failedStartup + ? lifecycle + : lifecycle?.budget === undefined + ? undefined + : { budget: lifecycle.budget }, (cause) => rejectObserver( new Error('MCP startup cleanup observation failed.', { diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index 9ed8757e..4e76b2cb 100644 --- a/tools/secrets-controls/README.md +++ b/tools/secrets-controls/README.md @@ -68,7 +68,7 @@ The eleven Step 4 controls remove separate production mechanisms: The Step 4 declarations require 51 additional named assertion witnesses. Every selected workload remains a complete test file: 33 input cases, 14 action-forwarding -cases, 32 dispatch cases, 80 run-command cases, three run-consent cases, 68 gate cases, +cases, 32 dispatch cases, 83 run-command cases, three run-consent cases, 71 gate cases, 28 top-level parser cases, or 131 native integration cases, as declared per control. The coordinator independently enforces exact case counts, unique complete case identities and identical fixed/removal/restored inventories. It accepts only a @@ -121,7 +121,7 @@ permanent negative cases; no notice is produced before a successful export. Four snapshot controls remove the finite scrubber detection site and the actual `create`, `import`, and `run` warning calls. Each has a named value-free assertion in -its complete 10-case detector, 11-case create, 11-case import or 80-case run file. +its complete 10-case detector, 11-case create, 11-case import or 83-case run file. The adjacent tests check preserved authored files and admitted run snapshots, JSON stdout, and scan-budget/incomplete notices. These controls certify warning delivery for the planted supported shapes at these entry points, not universal secret diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index 7c2085a9..13d696ac 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -347,7 +347,7 @@ export const mutations = [ ' const raw = await read();', " const raw = await (deps.readSecretInput?.() ?? Promise.resolve(''));", ['apps/cli/src/commands/run.test.ts'], - 80, + 83, 'uses the real default reader exactly once and suppresses even an injected gate selector', 'SECRETS_CAUSAL_RUN_DEFAULT_READER', ), @@ -367,7 +367,7 @@ export const mutations = [ ' const mayPrompt = args.secretStdin !== true;', ' const mayPrompt = true;', ['apps/cli/src/commands/gate.test.ts'], - 68, + 71, 'refuses unapproved stdio after a real read despite stale TTY, without prompting or writing a grant', 'SECRETS_CAUSAL_RESUME_CONSUMED_STDIN_CONSENT', ), @@ -581,7 +581,7 @@ export const mutations = [ ' warnSnapshotLiterals(def, deps.io);\n', '', ['apps/cli/src/commands/run.test.ts'], - 80, + 83, 'warns after admission before services and preserves the actual augmented frozen workflow', 'SNAPSHOT_CAUSAL_RUN_CALLER', ), From 4c36bfba9dc10f1f193312d81a5e41a8a53cd789 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 05:31:54 +0300 Subject: [PATCH 117/222] fix(mcp): notify close faults before sibling cleanup settles Register successful-client reporters before shared cleanup and deliver each server fault promptly, including late and reentrant observers. Preserve the original budget, actual sibling joins and reporter failure identity. An actual workflow/SQLite regression now starts fatal shutdown before a held sibling releases. Removing only the repair breaks five permanent cases. Failed-start observer ownership remains separate. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- .../commands/workflow-mcp-close-fault.test.ts | 130 ++++++++++++++++++ docs/reference/shared-core/mcp-integration.md | 10 +- .../mcp/src/manager-startup-cleanup.test.ts | 89 ++++++++++++ packages/mcp/src/manager.ts | 91 ++++++++---- 4 files changed, 293 insertions(+), 27 deletions(-) create mode 100644 apps/cli/src/commands/workflow-mcp-close-fault.test.ts diff --git a/apps/cli/src/commands/workflow-mcp-close-fault.test.ts b/apps/cli/src/commands/workflow-mcp-close-fault.test.ts new file mode 100644 index 00000000..8dc490d2 --- /dev/null +++ b/apps/cli/src/commands/workflow-mcp-close-fault.test.ts @@ -0,0 +1,130 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { startMcpClient, type McpConnection } from '@relavium/mcp'; +import { RunEventSchema } from '@relavium/shared'; +import { expect, it, vi } from 'vitest'; + +import { openHistoryStore } from '../history/open.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import { runCommand } from './run.js'; + +it('starts the original fatal clock at a successful manager close fault before a held sibling joins', async () => { + const root = mkdtempSync(join(tmpdir(), 'relavium-workflow-close-fault-')); + const configPath = join(root, 'config.toml'); + writeFileSync(configPath, ''); + const workflow = join(root, 'close-fault.relavium.yaml'); + writeFileSync( + workflow, + `schema_version: '1.0' +workflow: + id: close-fault + agents: + - id: unused + provider: anthropic + model: claude-sonnet-4-6 + system_prompt: synthetic + mcp_servers: [{id: synthetic, transport: stdio, command: synthetic-never-spawned}] + nodes: + - {id: start, type: input} + - {id: end, type: output} + edges: + - {from: start, to: end} +`, + ); + const f = invocationFixture(); + const output = captureIo(); + let release = (): void => undefined; + const held = new Promise((resolve) => { + release = resolve; + }); + let entered = (): void => undefined; + const started = new Promise((resolve) => { + entered = resolve; + }); + const fault = new Error('synthetic connection close failure'); + const bad = vi.fn(() => Promise.reject(fault)); + const pending = vi.fn(() => { + entered(); + return held; + }); + const connection = (close: McpConnection['close']): McpConnection => ({ + listTools: () => Promise.resolve([]), + callTool: () => Promise.resolve({ content: [], isError: false }), + close, + }); + let store: ReturnType | undefined; + let settled = false; + const resolveProvider = vi.fn(() => undefined); + const command = runCommand( + { workflow, input: [], allowMcpStdio: [] }, + { + invocation: f.invocation, + io: output.io, + global: { cwd: root, configPath, color: false, json: true, verbosity: 'normal' }, + providers: { resolveProvider, keyFor: () => 'synthetic-unused' }, + subscribeSignals: () => () => undefined, + consentGate: () => Promise.resolve(new Map()), + startMcpClient: (_configs, signal, work, lifecycle) => + startMcpClient( + [ + { id: 'bad', open: () => Promise.resolve(connection(bad)) }, + { id: 'held', open: () => Promise.resolve(connection(pending)) }, + ], + signal, + work, + lifecycle, + ), + openRunStore: (definition, _home, project, captureClose) => { + store = openHistoryStore(definition, root, project, captureClose); + return store; + }, + }, + ); + void command.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + try { + await started; + await new Promise((resolve) => setImmediate(resolve)); + expect(bad).toHaveBeenCalledOnce(); + expect(pending).toHaveBeenCalledOnce(); + expect(resolveProvider).not.toHaveBeenCalled(); + const events = output + .out() + .trim() + .split('\n') + .map((line) => RunEventSchema.parse(JSON.parse(line))); + expect(events.some((event) => event.type === 'run:completed')).toBe(true); + expect( + f.registry.observations.hasInfrastructureFailure(), + 'WORKFLOW_MCP_CLOSE_FAULT_STARTS_FATAL_BEFORE_HELD_SIBLING', + ).toBe(true); + expect(f.registry.budget.deadline()).toBe(3000); + expect(settled).toBe(false); + expect(store?.db.$client.open).toBe(true); + f.advance(3000); + expect(await f.invocation.finiteExit.exited).toBe(9); + expect(store?.db.$client.open).toBe(true); + release(); + await new Promise((resolve) => setImmediate(resolve)); + expect(f.registry.budget.deadline()).toBe(3000); + expect(settled).toBe(false); + expect( + store?.db.$client.open, + 'WORKFLOW_REJECTED_MCP_CLOSE_NEVER_ACKNOWLEDGES_DATABASE_RELEASE', + ).toBe(true); + } finally { + release(); + // Harness disposal cannot certify the intentionally retained product close obligation. + store?.close(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index 2bdd13eb..261ac6a0 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -214,9 +214,13 @@ across its deferred shared-close entry, and checks it immediately before each actual transport-close factory; zero remaining time enters no new asynchronous close and supplies no cleanup acknowledgement. Ordinary startup without these ports retains its existing actual joins and typed refusal. -Successful-client failures still use each supplied `close(onCloseError)` reporter -once; the failed-start lifecycle observer is not delivered a second time for an -ordinary successful-client close. +Successful-client failures use each supplied `close(onCloseError)` reporter once +per failed server, as soon as that fault occurs, even while another close remains +held. A late reporter immediately receives retained faults; reentering with the +same callback cannot redeliver one. Reporting never acknowledges cleanup: every +entered close remains joined, and a throwing reporter's original exception is +returned only after those joins settle. The failed-start lifecycle observer is not +delivered a second time for an ordinary successful-client close. Failed-start cleanup transfers its own work scope before server construction. All exact entered closes remain joined even if the lifecycle reporter throws. diff --git a/packages/mcp/src/manager-startup-cleanup.test.ts b/packages/mcp/src/manager-startup-cleanup.test.ts index 2ca835a4..9ec2ee68 100644 --- a/packages/mcp/src/manager-startup-cleanup.test.ts +++ b/packages/mcp/src/manager-startup-cleanup.test.ts @@ -15,6 +15,95 @@ function gate() { const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); +describe('successful close reporters observe faults before sibling joins', () => { + it.each(['synchronous', 'rejected'])( + 'reports a %s close fault promptly and retains the reentrant shared join', + async (mode) => { + const fault = new Error('synthetic connection close failure'); + const held = gate(); + const manager = await startMcpClient([ + server('bad', () => { + if (mode === 'synchronous') throw fault; + return Promise.reject(fault); + }), + server('held', () => held.promise), + ]); + const reentries: Promise[] = []; + const report = vi.fn(() => { + reentries.push(manager.close(report)); + }); + let settled = false; + const closing = manager.close(report).then(() => { + settled = true; + }); + await reactions(); + expect( + report, + 'MCP_SUCCESSFUL_CLOSE_REPORT_BEFORE_HELD_SIBLING', + ).toHaveBeenCalledExactlyOnceWith('bad', fault); + expect(settled, 'MCP_SUCCESSFUL_CLOSE_REPORT_IS_NOT_ACK').toBe(false); + held.release(); + await Promise.all([closing, ...reentries]); + expect(settled).toBe(true); + expect(report).toHaveBeenCalledOnce(); + }, + ); + + it('replays retained faults immediately to a reporter attaching while another close is held', async () => { + const fault = new Error('synthetic earlier close failure'); + const held = gate(); + const manager = await startMcpClient([ + server('bad', () => Promise.reject(fault)), + server('held', () => held.promise), + ]); + const first = manager.close(); + await reactions(); + const report = vi.fn(); + const late = manager.close(report); + const repeated = manager.close(report); + expect( + report, + 'MCP_SUCCESSFUL_CLOSE_LATE_REPORT_BEFORE_HELD_SIBLING', + ).toHaveBeenCalledExactlyOnceWith('bad', fault); + held.release(); + await Promise.all([first, late, repeated]); + await manager.close(report); + expect(report).toHaveBeenCalledOnce(); + }); + + it('joins every entered close despite a reporter exception and preserves that exception', async () => { + const fault = new Error('synthetic connection close failure'); + const observerFault = new Error('synthetic observer failure'); + const held = gate(); + const otherClose = vi.fn(() => held.promise); + const manager = await startMcpClient([ + server('bad', () => Promise.reject(fault)), + server('held', otherClose), + ]); + const report = vi.fn(() => { + throw observerFault; + }); + let rejected = false; + const closing = manager.close(report); + void closing.catch(() => { + rejected = true; + }); + const healthy = vi.fn(); + const other = manager.close(healthy); + await reactions(); + expect(report).toHaveBeenCalledExactlyOnceWith('bad', fault); + expect(healthy).toHaveBeenCalledExactlyOnceWith('bad', fault); + expect(otherClose).toHaveBeenCalledOnce(); + expect(rejected, 'MCP_REPORTER_FAILURE_DOES_NOT_SKIP_RAW_CLOSE_JOIN').toBe(false); + held.release(); + await expect(closing).rejects.toBe(observerFault); + await other; + await expect(manager.close(report)).rejects.toBe(observerFault); + expect(report).toHaveBeenCalledOnce(); + await expect(manager.close()).resolves.toBeUndefined(); + }); +}); + describe('successful manager cleanup keeps its original live budget at transport entry', () => { it.each([false, true])( 'rechecks the deferred close boundary (queued expiry=%s)', diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index 7e2ef4bd..1dbab688 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -65,8 +65,10 @@ export interface McpClient { * Tear down every connection (idempotent). * * Every concurrent/repeated close joins the same cleanup. Teardown faults are retained and - * reported once to each supplied `onCloseError` callback; successful connections are removed - * only after their exact close completes. The default remains best-effort reporting, not + * reported promptly once per server to each supplied `onCloseError` callback, even while + * another close is held. Late reporters receive retained faults. Successful connections are removed + * only after their exact close completes. Reporting is not a cleanup join; reporter exceptions + * are returned after all entered closes settle. The default remains best-effort reporting, not * a semantic receipt or proof that a failed transport acknowledged native resource closure. */ close(onCloseError?: (serverId: string, cause: unknown) => void): Promise; @@ -97,7 +99,13 @@ export async function startMcpClient( ): Promise { const connections = new Map(); const closeFailures = new Map(); - const reported = new WeakSet<(serverId: string, cause: unknown) => void>(); + type CloseReporter = NonNullable[0]>; + interface ReportState { + readonly servers: Set; + failure?: { readonly cause: unknown }; + } + const reported = new WeakMap(); + const activeReporters = new Map(); const toolDefs: ToolDef[] = []; const toolIdsByServer = new Map(); const skipped: ManagerSkippedTool[] = []; @@ -125,37 +133,72 @@ export async function startMcpClient( let failedStartup = false; let startupFailure: { readonly cause: unknown } | undefined; let closing: Promise | undefined; + let closeFinished = false; let rejectObserver: (cause: Error) => void = () => undefined; const observerFailure = new Promise((_resolve, reject) => { rejectObserver = reject; }); void observerFailure.catch(() => undefined); + const deliver = ( + report: CloseReporter, + state: ReportState, + serverId: string, + cause: unknown, + ): void => { + if (state.servers.has(serverId)) return; + // Mark before entering caller code: a reporter may reenter close with the same identity. + state.servers.add(serverId); + try { + report(serverId, cause); + } catch (observerCause) { + // A callback cannot abandon sibling cleanup or erase its own original failure. + state.failure ??= { cause: observerCause }; + } + }; + const reportCloseFailure = (serverId: string, cause: unknown): void => { + for (const [report, state] of [...activeReporters]) deliver(report, state, serverId, cause); + }; const close = (onCloseError?: (serverId: string, cause: unknown) => void): Promise => { // Publish the shared join before entering custom close factories, including reentry. // Successful clients keep the same live budget across this deferred entry. Their reporter // still belongs to close(onCloseError), separately from the failed-start lifecycle observer. - closing ??= Promise.resolve().then(() => - closeAll( - connections, - closeFailures, - failedStartup ? failedCleanup : undefined, - failedStartup - ? lifecycle - : lifecycle?.budget === undefined - ? undefined - : { budget: lifecycle.budget }, - (cause) => - rejectObserver( - new Error('MCP startup cleanup observation failed.', { - cause: { primary: startupFailure?.cause, observer: cause }, - }), - ), - ), - ); + closing ??= Promise.resolve() + .then(() => + closeAll( + connections, + closeFailures, + failedStartup ? failedCleanup : undefined, + failedStartup + ? lifecycle + : { + ...(lifecycle?.budget === undefined ? {} : { budget: lifecycle.budget }), + onCloseError: reportCloseFailure, + }, + (cause) => + rejectObserver( + new Error('MCP startup cleanup observation failed.', { + cause: { primary: startupFailure?.cause, observer: cause }, + }), + ), + ), + ) + .finally(() => { + closeFinished = true; + activeReporters.clear(); + }); + let state: ReportState | undefined; + if (onCloseError !== undefined) { + state = reported.get(onCloseError); + if (state === undefined) { + state = { servers: new Set() }; + reported.set(onCloseError, state); + } + if (!closeFinished) activeReporters.set(onCloseError, state); + // A caller attaching during a held sibling close must see earlier faults now too. + for (const [serverId, cause] of closeFailures) deliver(onCloseError, state, serverId, cause); + } return closing.then(() => { - if (onCloseError === undefined || reported.has(onCloseError)) return; - reported.add(onCloseError); - for (const [serverId, cause] of closeFailures) onCloseError(serverId, cause); + if (state?.failure !== undefined) throw state.failure.cause; }); }; const settled = await Promise.allSettled( From bd9bf4cff23c69b146d32524f0bb5b903c583df6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 05:31:54 +0300 Subject: [PATCH 118/222] test(repo): retain native signal evidence in packed controls Observe actual native signal outcomes without changing their receiver, arguments, result or throwable. Keep bounded native errno and passive uncaught occurrence in failed-worker diagnostics; reject incomplete or nonterminal evidence and preserve every original outcome/cleanup bound. The failed macOS initiating fault remains unexplained. Exact-package local triage and the new eight-case tooling control are not remote acceptance. All 516 acceptance-tool cases and tools lint/typecheck pass. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- tools/cli-package/README.md | 11 ++ tools/cli-package/consumer-lifetime.test.mjs | 2 +- .../cli-package/smoke/failure-diagnostic.mjs | 34 +++- tools/cli-package/smoke/native-process.mjs | 12 +- .../smoke/native-signal-diagnostic.mjs | 112 +++++++++++++ .../smoke/native-spawn-witness.mjs | 17 ++ .../smoke/native-spawn-witness.test.mjs | 156 ++++++++++++++++++ tools/cli-package/smoke/preload.mjs | 4 +- 8 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 tools/cli-package/smoke/native-signal-diagnostic.mjs diff --git a/tools/cli-package/README.md b/tools/cli-package/README.md index 864741bb..d2b4e996 100644 --- a/tools/cli-package/README.md +++ b/tools/cli-package/README.md @@ -58,6 +58,17 @@ transcripts or raw child output. It remains failure evidence: the parent still j the worker and removes its installation, and no success receipt is published. An unavailable diagnostic sink does not replace the original failure. +The native-edge witness also forwards the actual `process.kill` receiver, +arguments, return and throwable unchanged while recording bounded monotonic time, +creation-owned target identity and own-data native errno. A passive +`uncaughtExceptionMonitor` records occurrence without handling the event or +inspecting exception text. Journal/clock/descriptor failure never masks the native +outcome; missing, failed, duplicate or nonterminal completion evidence refuses +the control. The failed-worker projection admits only its fixed diagnostic fields. +These observations improve failure attribution; they do not relax a native-close, +exit-code, watchdog, timing or cleanup assertion, or classify an unresolved failure +as harmless. + Required PR/push consumers use current `.nvmrc` Linux/macOS/Windows and exact Node 22.0.0 Linux. The aggregate reinspects the tarball and requires all four unique matching receipts plus successful pack/matrix jobs. Missing, failed, skipped, diff --git a/tools/cli-package/consumer-lifetime.test.mjs b/tools/cli-package/consumer-lifetime.test.mjs index 7f222285..420e9b4a 100644 --- a/tools/cli-package/consumer-lifetime.test.mjs +++ b/tools/cli-package/consumer-lifetime.test.mjs @@ -30,7 +30,7 @@ async function fixture( for (const name of ['process.mjs', 'files.mjs']) copyFileSync(new URL(name, import.meta.url), join(root, name)); copyFileSync(new URL('smoke/index.mjs', import.meta.url), join(smoke, 'index.mjs')); - for (const name of ['failure-diagnostic.mjs', 'cases.mjs']) + for (const name of ['failure-diagnostic.mjs', 'native-signal-diagnostic.mjs', 'cases.mjs']) copyFileSync(new URL(`smoke/${name}`, import.meta.url), join(smoke, name)); const require = createRequire(new URL('../../packages/db/package.json', import.meta.url)); const binary = join( diff --git a/tools/cli-package/smoke/failure-diagnostic.mjs b/tools/cli-package/smoke/failure-diagnostic.mjs index 3ba9e1d6..383367a6 100644 --- a/tools/cli-package/smoke/failure-diagnostic.mjs +++ b/tools/cli-package/smoke/failure-diagnostic.mjs @@ -1,6 +1,7 @@ import { Buffer } from 'node:buffer'; import { writeSync } from 'node:fs'; import { REQUIRED_SMOKE_CASES } from './cases.mjs'; +import { nativeSignalCode } from './native-signal-diagnostic.mjs'; export const MAX_WORKER_FAILURE_BYTES = 8192; const modes = new Set([ @@ -50,6 +51,9 @@ const operations = new Set([ 'native-bootstrap-fatal-injected', 'native-bootstrap-background-injected', 'native-bootstrap-output-injected', + 'native-signal-observed', + 'native-uncaught-exception-observed', + 'native-signal-diagnostics-complete', ]); const object = (value) => (value !== null && typeof value === 'object' ? value : {}); const list = (value, maximum) => (Array.isArray(value) ? value.slice(0, maximum) : []); @@ -83,11 +87,39 @@ function observation(raw) { pid: pid(value.pid), parentPid: pid(value.parentPid), code: code(value.code), - signal: signal(value.signal), + signal: value.signal === 0 ? 0 : signal(value.signal), detached: bool(value.detached), shell: bool(value.shell), synthetic: bool(value.synthetic), error: errorText(value.reason, 128), + ...(value.operation === 'native-signal-observed' + ? { + owned: bool(value.owned), + target: value.owned === true && value.target !== 0 ? code(value.target) : undefined, + outcome: ['returned', 'threw'].includes(value.outcome) ? value.outcome : undefined, + returned: bool(value.returned), + nativeCode: value.outcome === 'threw' ? nativeSignalCode(value.nativeCode) : undefined, + errno: + Number.isSafeInteger(value.errno) && Math.abs(value.errno) <= 10000 + ? value.errno + : undefined, + } + : {}), + ...(value.operation === 'native-signal-diagnostics-complete' + ? { + complete: bool(value.complete), + observations: + Number.isSafeInteger(value.observations) && + value.observations >= 0 && + value.observations <= 64 + ? value.observations + : undefined, + } + : {}), + milliseconds: + Number.isFinite(value.milliseconds) && value.milliseconds >= 0 && value.milliseconds <= 1e12 + ? value.milliseconds + : undefined, }; } function nativeMode(raw) { diff --git a/tools/cli-package/smoke/native-process.mjs b/tools/cli-package/smoke/native-process.mjs index 32d1a935..e8f69262 100644 --- a/tools/cli-package/smoke/native-process.mjs +++ b/tools/cli-package/smoke/native-process.mjs @@ -17,6 +17,10 @@ import { fileURLToPath } from 'node:url'; import { readOrdinaryFile, sha256 } from '../files.mjs'; import { ndjson, poll, startOwned } from './process.mjs'; import { projectRuntimeStderr } from './runtime-stderr.mjs'; +import { + assertNativeSignalDiagnosticComplete, + SIGNAL_DIAGNOSTIC_OPERATIONS, +} from './native-signal-diagnostic.mjs'; const fixture = (name) => fileURLToPath(new URL(name, import.meta.url)); const positivePid = (pid) => Number.isSafeInteger(pid) && pid > 0; @@ -416,6 +420,7 @@ export async function assertInstalledNativeProcesses(context, { bootstrap = fals 'The public offline turn completion differs from its actual lifetime', ); const observed = records(log); + assertNativeSignalDiagnosticComplete(observed); if (bootstrap) { assert.ok( observed.some((item) => item.operation === `native-${gate}-close-delivered`), @@ -457,10 +462,15 @@ export async function assertInstalledNativeProcesses(context, { bootstrap = fals sibling.assertAlive(); if (mode === 'facility-refusal') { assert.deepEqual( - observed, + observed.filter((item) => !SIGNAL_DIAGNOSTIC_OPERATIONS.has(item.operation)), [{ operation: 'native-facility-access-refused', synthetic: true }], 'One synthetic facility access refusal must precede any target/helper entry', ); + assert.equal( + observed.at(-1).observations, + 0, + 'Invalid facility entered native signaling', + ); assert.ok( (result.stdout + result.stderr).includes('Windows subprocess teardown is unavailable'), 'Fixed Windows facility refusal missing', diff --git a/tools/cli-package/smoke/native-signal-diagnostic.mjs b/tools/cli-package/smoke/native-signal-diagnostic.mjs new file mode 100644 index 00000000..ea0a8466 --- /dev/null +++ b/tools/cli-package/smoke/native-signal-diagnostic.mjs @@ -0,0 +1,112 @@ +/** Passive native-edge evidence. Observation failure never changes a signal's result or throwable. */ +import { performance } from 'node:perf_hooks'; + +const nativeCodes = new Set(['ESRCH', 'EPERM', 'EACCES', 'EINVAL', 'ENOSYS', 'EIO']); +export const nativeSignalCode = (value) => (nativeCodes.has(value) ? value : 'other'); +export const SIGNAL_DIAGNOSTIC_OPERATIONS = new Set([ + 'native-signal-observed', + 'native-uncaught-exception-observed', + 'native-signal-diagnostics-complete', +]); + +export function createNativeSignalDiagnostic({ record, owns, now = () => performance.now() }) { + let complete = true; + let observations = 0; + let finished = false; + const write = (value) => { + try { + record(value); + } catch { + // The terminal marker (or its absence) makes this failed evidence unusable to the parent. + complete = false; + } + }; + const observe = (operation, fields) => { + try { + if (finished) { + complete = false; + write({ operation: 'native-signal-diagnostics-complete', complete, observations }); + return; + } + if (observations >= 64) { + complete = false; + return; + } + const milliseconds = now(); + if (!Number.isFinite(milliseconds) || milliseconds < 0) { + complete = false; + return; + } + observations++; + write({ operation, milliseconds, ...fields }); + } catch { + complete = false; + } + }; + return Object.freeze({ + wrap(signal) { + return function (...args) { + let returned; + let failure; + try { + returned = Reflect.apply(signal, this, args); + return returned; + } catch (cause) { + failure = { cause }; + throw cause; + } finally { + // No observer, clock, descriptor or journal failure may mask the native outcome. + try { + const target = args[0]; + const owned = Number.isSafeInteger(target) && owns(target) === true; + const cause = failure?.cause; + const own = (key) => + cause !== null && (typeof cause === 'object' || typeof cause === 'function') + ? Object.getOwnPropertyDescriptor(cause, key)?.value + : undefined; + const code = own('code'); + const errno = own('errno'); + observe('native-signal-observed', { + owned, + ...(owned ? { target } : {}), + signal: args[1] === 0 || args[1] === 'SIGKILL' ? args[1] : 'other', + outcome: failure === undefined ? 'returned' : 'threw', + ...(failure === undefined + ? { returned: typeof returned === 'boolean' ? returned : 'other' } + : { + nativeCode: nativeSignalCode(code), + ...(Number.isSafeInteger(errno) && Math.abs(errno) <= 10000 ? { errno } : {}), + }), + }); + } catch { + complete = false; + } + } + }; + }, + uncaught() { + // Occurrence only: no thrown message, name, stack, cause or handling authority. + observe('native-uncaught-exception-observed', {}); + }, + finish() { + if (finished) return; + finished = true; + write({ operation: 'native-signal-diagnostics-complete', complete, observations }); + }, + }); +} + +/** A terminal marker cannot certify evidence emitted by a later exit reaper. */ +export function assertNativeSignalDiagnosticComplete(observed) { + const last = observed.at(-1); + if ( + last?.operation !== 'native-signal-diagnostics-complete' || + last.complete !== true || + last.observations !== + observed.filter((item) => + ['native-signal-observed', 'native-uncaught-exception-observed'].includes(item.operation), + ).length || + observed.filter((item) => item.operation === 'native-signal-diagnostics-complete').length !== 1 + ) + throw new Error('Native signal diagnostic evidence is incomplete'); +} diff --git a/tools/cli-package/smoke/native-spawn-witness.mjs b/tools/cli-package/smoke/native-spawn-witness.mjs index 8aa0f1ae..61f8054a 100644 --- a/tools/cli-package/smoke/native-spawn-witness.mjs +++ b/tools/cli-package/smoke/native-spawn-witness.mjs @@ -3,6 +3,7 @@ import { Buffer } from 'node:buffer'; import { appendFileSync, existsSync, lstatSync, readFileSync, statSync } from 'node:fs'; import { isAbsolute } from 'node:path'; import { clearInterval, setImmediate, setInterval } from 'node:timers'; +import { createNativeSignalDiagnostic } from './native-signal-diagnostic.mjs'; /** A stdout marker alone is insufficient: match the actual root, its created child and that child's ACK. */ export function assertNativeFixtureReady(observed, pid, mode) { @@ -74,6 +75,11 @@ export function createNativeSpawnWitness(configuration, spawn) { let pending = []; let gateTimer; let gateTicks = 0; + const ownedSignalTargets = new Set(); + const signalDiagnostic = createNativeSignalDiagnostic({ + record, + owns: (target) => ownedSignalTargets.has(target), + }); // Observe the actual synchronous removal. A later setImmediate would be skipped by a // legitimate fatal process exit even after the creation owner had already retired. const onExitRemoved = (event, listener) => { @@ -117,6 +123,14 @@ export function createNativeSpawnWitness(configuration, spawn) { }; } return Object.freeze({ + installSignalDiagnostics() { + const finish = () => signalDiagnostic.finish(); + process.on('exit', finish); + // This fixture observer is baseline, never an extra product creation-owner callback. + initialExit.add(finish); + process.on('uncaughtExceptionMonitor', () => signalDiagnostic.uncaught()); + process.kill = signalDiagnostic.wrap(process.kill); + }, /** Synthetic verifier-boundary refusal; healthy modes retain the original native function. */ wrapFacilityAccess(access) { if (config.mode !== 'facility-refusal') return access; @@ -156,6 +170,8 @@ export function createNativeSpawnWitness(configuration, spawn) { exitReaper = additions[0]; target = spawn(executable, args, options); assert.ok(Number.isSafeInteger(target.pid) && target.pid > 0); + ownedSignalTargets.add(target.pid); + if (options.detached) ownedSignalTargets.add(-target.pid); record({ operation: 'native-target-spawn', pid: target.pid, @@ -228,6 +244,7 @@ export function createNativeSpawnWitness(configuration, spawn) { assert.deepEqual(options, { shell: false, windowsHide: true, stdio: 'ignore' }); helperEntered = true; const helper = spawn(executable, args, options); + if (Number.isSafeInteger(helper.pid) && helper.pid > 0) ownedSignalTargets.add(helper.pid); record({ operation: 'native-helper-spawn', pid: helper.pid, diff --git a/tools/cli-package/smoke/native-spawn-witness.test.mjs b/tools/cli-package/smoke/native-spawn-witness.test.mjs index 812ae4ee..cdf9b7e4 100644 --- a/tools/cli-package/smoke/native-spawn-witness.test.mjs +++ b/tools/cli-package/smoke/native-spawn-witness.test.mjs @@ -21,6 +21,162 @@ import { setImmediate } from 'node:timers'; import { setTimeout as sleep } from 'node:timers/promises'; import { assertNativeFixtureReady, createNativeSpawnWitness } from './native-spawn-witness.mjs'; import { formatInstalledWorkerFailure } from './failure-diagnostic.mjs'; +import { + assertNativeSignalDiagnosticComplete, + createNativeSignalDiagnostic, +} from './native-signal-diagnostic.mjs'; + +test('signal evidence preserves exact receiver, arguments, result and throwable without reading error text', () => { + const rows = []; + const d = createNativeSignalDiagnostic({ + record: (row) => rows.push(row), + owns: (pid) => pid === -40, + now: () => 12, + }); + const receiver = {}; + const result = {}; + const native = function (...args) { + assert.equal(this, receiver); + assert.deepEqual(args, [-40, 'SIGKILL']); + return result; + }; + assert.equal(d.wrap(native).call(receiver, -40, 'SIGKILL'), result); + const error = Object.assign(new Error('PRIVATE_NATIVE_MESSAGE'), { code: 'EPERM', errno: -1 }); + Object.defineProperty(error, 'message', { + get: () => { + throw new Error('Text must never be inspected'); + }, + }); + assert.throws( + () => + d.wrap(() => { + throw error; + })(-40, 0), + (cause) => cause === error, + ); + assert.equal(d.wrap(() => false)(99, 'SIGKILL'), false); + d.finish(); + assertNativeSignalDiagnosticComplete(rows); + assert.equal(rows[1].nativeCode, 'EPERM'); + assert.equal(rows[1].errno, -1); + assert.equal(rows[2].owned, false); + assert.equal(rows[2].target, undefined); + assert.doesNotMatch(JSON.stringify(rows), /PRIVATE_|message|stack/); +}); + +test('observation failure never replaces native success or failure and refuses complete evidence', () => { + for (const mode of ['journal', 'clock', 'descriptor']) { + const rows = []; + const d = createNativeSignalDiagnostic({ + record: (row) => { + if (mode === 'journal' && row.operation !== 'native-signal-diagnostics-complete') + throw new Error('Synthetic journal refusal'); + rows.push(row); + }, + owns: () => true, + now: () => (mode === 'clock' ? NaN : 1), + }); + const error = + mode === 'descriptor' + ? new Proxy( + {}, + { + getOwnPropertyDescriptor() { + throw new Error('Synthetic descriptor refusal'); + }, + }, + ) + : new Error('Synthetic native fault'); + assert.equal(d.wrap(() => true)(-40, 'SIGKILL'), true); + assert.throws( + () => + d.wrap(() => { + throw error; + })(-40, 'SIGKILL'), + (cause) => cause === error, + ); + d.finish(); + assert.throws(() => assertNativeSignalDiagnosticComplete(rows), /incomplete/); + } +}); + +test('signal evidence rejects missing, duplicate, over-bound and post-marker observations', () => { + const make = () => { + const rows = []; + return { + rows, + d: createNativeSignalDiagnostic({ + record: (row) => rows.push(row), + owns: () => true, + now: () => 1, + }), + }; + }; + assert.throws(() => assertNativeSignalDiagnosticComplete([]), /incomplete/); + const normal = make(); + normal.d.uncaught(); + normal.d.finish(); + assertNativeSignalDiagnosticComplete(normal.rows); + assert.throws( + () => assertNativeSignalDiagnosticComplete([...normal.rows, normal.rows.at(-1)]), + /incomplete/, + ); + normal.d.wrap(() => true)(40, 0); + assert.throws(() => assertNativeSignalDiagnosticComplete(normal.rows), /incomplete/); + const bounded = make(); + const signal = bounded.d.wrap(() => true); + for (let i = 0; i < 65; i++) assert.equal(signal(40, 0), true); + bounded.d.finish(); + assert.equal(bounded.rows.length, 65); + assert.throws(() => assertNativeSignalDiagnosticComplete(bounded.rows), /incomplete/); +}); + +test('worker failure retains bounded native errno and observation completeness without opaque properties', () => { + const row = { + operation: 'native-signal-observed', + owned: true, + target: -40, + signal: 'SIGKILL', + outcome: 'threw', + nativeCode: 'EPERM', + errno: -1, + milliseconds: 2, + message: 'PRIVATE_NATIVE_MESSAGE', + cause: 'PRIVATE_CAUSE', + }; + const diagnostic = JSON.parse( + formatInstalledWorkerFailure({ + cases: [ + { + id: 'actual-native-process-owner', + passed: false, + evidence: { + outcomes: [ + { + mode: 'injected-error', + observed: [ + row, + { + operation: 'native-signal-diagnostics-complete', + complete: true, + observations: 1, + }, + ], + }, + ], + }, + }, + ], + }), + ); + const observed = diagnostic.cases.find((item) => item.id === 'actual-native-process-owner') + .native[0].observed; + assert.equal(observed[0].nativeCode, 'EPERM'); + assert.equal(observed[0].target, -40); + assert.equal(observed[0].errno, -1); + assert.equal(observed[1].complete, true); + assert.doesNotMatch(JSON.stringify(diagnostic), /PRIVATE_/); +}); /** Explicit simulated event/port tests. These execute neither product code nor Windows taskkill. */ function harness(mode = 'injected-error', gate = 'none') { diff --git a/tools/cli-package/smoke/preload.mjs b/tools/cli-package/smoke/preload.mjs index c748fa65..582b2696 100644 --- a/tools/cli-package/smoke/preload.mjs +++ b/tools/cli-package/smoke/preload.mjs @@ -38,8 +38,10 @@ const originalSpawn = child.spawn; const nativeWitness = process.env.RELAVIUM_PACKAGE_SMOKE_NATIVE ? createNativeSpawnWitness(process.env.RELAVIUM_PACKAGE_SMOKE_NATIVE, originalSpawn) : undefined; -if (nativeWitness !== undefined) +if (nativeWitness !== undefined) { + nativeWitness.installSignalDiagnostics(); fsPromises.access = nativeWitness.wrapFacilityAccess(fsPromises.access); +} child.spawn = (...args) => { const native = nativeWitness?.trySpawn(...args); if (native !== undefined) { From 86f0422ac1d5010986f89e48dc8ddcb8f7547936 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 05:31:54 +0300 Subject: [PATCH 119/222] docs(docs): reconcile activated session lifecycle references Correct chat infrastructure exit and Home repeated-signal/database-release claims to the canonical actual-join contract. Keep ordinary turn, deliberate chat-end and ownerless library compatibility semantics distinct. Record complete NONCLEAN R6 coverage, all verified corrections, removal and author gates, exact-package macOS triage and its unresolved required failure. New cumulative reviews, security and full CI/platform gates remain open. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- AGENTS.md | 17 +-- CLAUDE.md | 17 +-- docs/reference/cli/chat-session.md | 4 +- docs/reference/cli/home.md | 13 ++- docs/roadmap/current.md | 10 +- .../phase-2.5.5-hardening-and-remediation.md | 107 ++++++++++++++++++ 6 files changed, 143 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 20d17ddc..0c8866ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,14 +107,15 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first five complete cumulative rounds are -NONCLEAN; the fourth-round findings have implementation corrections and passing -author gates. The fifth round finds a deferred MCP transport-close budget gap; -its correction and strict causal-workload pin updates pass author validation. -An isolated Windows advisory timeout remains unexplained; fresh evidence is -required. Two NEW complete cumulative clean rounds after the last -material fix, a NEW separate clean security sitting and fresh full CI plus all -four same-artifact package consumers remain required. +but not independently accepted. Its first six complete cumulative rounds are +NONCLEAN. R5's deferred budget repair passes independent adverse schedules; R6 +finds successful MCP failure reporting delayed behind a held sibling, plus stale +chat/Home lifecycle references. Corrections and passive native-test diagnostics +pass final author validation; fresh independent reviews remain required. New Windows evidence passes; the required +macOS package failure remains unexplained, despite exact-package local triage. +Two NEW complete cumulative clean rounds after the last material fix, a NEW +separate clean security sitting and fresh full CI plus all four same-artifact +package consumers remain required. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index 8d8517bc..54d9819f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,14 +196,15 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first five complete cumulative rounds are -NONCLEAN; the fourth-round findings have implementation corrections and passing -author gates. The fifth round finds a deferred MCP transport-close budget gap; -its correction and strict causal-workload pin updates pass author validation. -An isolated Windows advisory timeout remains unexplained; fresh evidence is -required. Two NEW complete cumulative clean rounds after the last -material fix, a NEW separate clean security sitting and fresh full CI plus all -four same-artifact package consumers remain required. +but not independently accepted. Its first six complete cumulative rounds are +NONCLEAN. R5's deferred budget repair passes independent adverse schedules; R6 +finds successful MCP failure reporting delayed behind a held sibling, plus stale +chat/Home lifecycle references. Corrections and passive native-test diagnostics +pass final author validation; fresh independent reviews remain required. New Windows evidence passes; the required +macOS package failure remains unexplained, despite exact-package local triage. +Two NEW complete cumulative clean rounds after the last material fix, a NEW +separate clean security sitting and fresh full CI plus all four same-artifact +package consumers remain required. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/docs/reference/cli/chat-session.md b/docs/reference/cli/chat-session.md index bbabae4f..f116f3b2 100644 --- a/docs/reference/cli/chat-session.md +++ b/docs/reference/cli/chat-session.md @@ -246,7 +246,7 @@ A reasoning model streams its "thinking" over the `agent:reasoning` event (EA6, ### Actionable error recovery (2.5.H) -A **failed** turn is never a terminal (only `session:cancelled` ends a session), so a failure always leaves the REPL live. Beyond showing the closed `ErrorCode`, the chat renders a **one-line, secret-free recovery hint** for each operational class that makes the next step + **"the session is still active"** explicit — extending the "say so plainly" philosophy from the 2.5.A capability gap to the transport / quota / limit classes: +A classified **failed** turn is not a session terminal, so the REPL remains live unless a separate infrastructure fault stops the invocation. Unexpected CLI and failed-output faults follow the [failure classification and remedies](commands.md#failure-classification-and-remedies), rather than the ordinary turn-recovery policy. Beyond showing the closed `ErrorCode`, the chat renders a **one-line, secret-free recovery hint** for each operational class that makes the next step + **"the session is still active"** explicit — extending the "say so plainly" philosophy from the 2.5.A capability gap to the transport / quota / limit classes: - `provider_rate_limit` (429) / `provider_unavailable` — Relavium already retried with backoff + fallback-chain failover; resend. - `provider_auth` — check the key or **unlock the OS keychain** if it locked mid-session, then resend. @@ -279,7 +279,7 @@ The session namespace is **disjoint** from the run namespace (keyed by `sessionI ## Exit code 4 -A `relavium chat` REPL ends with code **`4`** — the canonical **chat-session-ended** code defined in [commands.md](commands.md#exit-codes) — on any of: `/exit`, `/cancel` (or **Ctrl-C** in TTY mode), or an **input-stream EOF** (the user closes stdin — in plain non-TTY mode, or under `--json`). It is deliberately distinct from a successful workflow run (`0`) and a hard failure (`1`) so a wrapper script can tell "the user ended the chat" apart from either. A crash or an unrecoverable provider error still exits `1`; bad arguments still exit `2`. (The one-shot `relavium agent run` is **not** a REPL — it exits with the turn's outcome, `0`/`1`, never `4`.) +A `relavium chat` REPL ends with code **`4`** — the canonical **chat-session-ended** code defined in [commands.md](commands.md#exit-codes) — on any of: `/exit`, `/cancel` (or **Ctrl-C** in TTY mode), or an **input-stream EOF** (the user closes stdin — in plain non-TTY mode, or under `--json`). This distinguishes deliberate session end from workflow and turn outcomes. Bad arguments retain exit `2`; an ordinary classified failed turn follows the recovery policy above. An unexpected CLI crash or failed output instead uses infrastructure exit **`9`**, with the [canonical classification, precedence and inspect-before-repeat remedy](commands.md#failure-classification-and-remedies). The one-shot `relavium agent run` is **not** a REPL: its ordinary turn outcome is `0`/`1`, never `4`, while infrastructure faults still use `9` and external signals retain their documented meanings. ## API keys diff --git a/docs/reference/cli/home.md b/docs/reference/cli/home.md index 851c8781..59a395c8 100644 --- a/docs/reference/cli/home.md +++ b/docs/reference/cli/home.md @@ -29,9 +29,10 @@ A Home build/config fault uses the same [CLI failure classification](commands.md as a command: invalid config/invocation remains exit `2`, known operational faults retain exit `1`, and an unexpected escaping throw uses the fixed `cli_internal` projection and exit `9`, without its incoming message, cause or stack in that -projection. The legacy broad `internal` code is not remapped wholesale. Connecting -caught Home/session faults and the complete fatal coordinator remains Step 8d; -this does not turn a classified failed chat turn into a fatal CLI failure. +projection. The legacy broad `internal` code is not remapped wholesale. Step 8d +implements caught Home/session fault integration and the complete fatal coordinator; +its independent acceptance remains pending. This does not turn a classified failed +chat turn into a fatal CLI failure. ## First-run onboarding wizard (2.5.G S8) @@ -143,14 +144,14 @@ Below **80×24** the Home **degrades** to a single line — `Terminal too small ## Signal lifecycle & exit codes -The Home owns **one signal lifecycle (SIGINT/SIGTERM/SIGHUP/SIGQUIT)** covering the Home, the in-Home chat, and MCP teardown (`closeMcp`), plus a synchronous `process.on('exit')` net behind all of them: +The invocation-backed Home shares **one signal lifecycle (SIGINT/SIGTERM/SIGHUP/SIGQUIT)** across the Home, pending/replaced chat hosts and MCP teardown. Its actual owners follow the [canonical invocation ordering](commands.md#invocation-owners-and-fatal-ordering); the synchronous native exit reap is a last-resort request, not a cleanup acknowledgement: | Outcome | How | Exit code | | --- | --- | --- | | **Clean Home exit** | Ctrl-C / EOF in `home` mode | `0` | -| **Signal-driven** | an external SIGINT / SIGTERM / SIGHUP / SIGQUIT (`kill -INT`, closing the terminal window, a parent's signal) | `128 + signo` — **`130`** (SIGINT) / **`143`** (SIGTERM) / **`129`** (SIGHUP) / **`131`** (SIGQUIT) | +| **Signal-driven, without a stronger infrastructure fault** | an external SIGINT / SIGTERM / SIGHUP / SIGQUIT (`kill -INT`, closing the terminal window, a parent's signal) | `128 + signo` — **`130`** (SIGINT) / **`143`** (SIGTERM) / **`129`** (SIGHUP) / **`131`** (SIGQUIT); infrastructure faults follow the [canonical precedence](commands.md#failure-classification-and-remedies) | -On an external signal the handler restores the terminal (unmount ink, disable bracketed paste, disable mouse reporting), tears the live chat — or an in-flight build — down **bounded** (a stuck MCP teardown can't hang the exit; a second signal force-exits immediately), closes the db once, and exits `128+signo` so a shell pipeline still detects the interruption. +An external signal stops admission and requests terminal restoration and cleanup of every actual host, including an in-flight build. Repeated signals share the original finite deadline and cannot restart cleanup or force an immediate second-signal exit. The physical exit backstop bounds the invocation; it does not certify terminal, MCP or database release. The shared database closes only after actual host/work settlement, and failed or held cleanup retains its owners and signal custody. Infrastructure faults may strengthen the final disposition; read the [canonical remedies](commands.md#failure-classification-and-remedies) before repeating uncertain work. The ownerless library compatibility path is not this product invocation lifecycle. A keyboard Ctrl-C does **not** normally reach the process as SIGINT (raw mode), so the controller handles it (Home → `0`, chat → `/cancel`). There is **one exception**: while a `/scrollback` or `/edit` suspension owns the terminal, ink has turned raw mode off and the kernel delivers a real SIGINT. That signal belongs to the hatch — the Home's handler drops it (`suspendPort.isSuspended()`), the hatch's own listener resumes the renderer, and the session survives. An external SIGTERM/SIGHUP/SIGQUIT still tears down, suspended or not. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 243eb6cd..9d6acde8 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1180,7 +1180,15 @@ independently inspected, but broader CI exposes an obsolete causal workload pin and an unexplained Windows advisory timeout. The implementation correction preserves the live budget through actual transport entry and updates strict workload counts; new reviews/security/full CI and Windows evidence remain open. -Step 8d proceeds automatically. The +The [sixth cumulative review and first-fault correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-sixth-cumulative-review-and-first-fault-correction--2026-10-17) +records full NONCLEAN coverage of the same 285 paths / 1,526 hunks at `4b7ba216`. +Successful MCP close failures must reach their owner before a held sibling joins; +chat/Home references also require lifecycle reconciliation. The correction keeps +actual close joins and the original deadline, with permanent adverse controls. +New Windows evidence passes; required macOS packaging remains failed and its +initiating fault unresolved. Passive test-only native diagnostics preserve all +original outcome/cleanup assertions. New clean reviews, security and exact-source +CI/platform gates remain required. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index fe86a497..e80247ca 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -6656,3 +6656,110 @@ triage report SHA-256 is `edf43b42f6ab90043e05cf2445d213bdf71146aed32a798de1a37c690a0d4702`. These are author/triage records; rounds 1–5 remain NONCLEAN and new cumulative review, separate security and post-correction CI/platform acceptance remain open. + + +### Wave 3 Step 8d sixth cumulative review and first-fault correction — 2026-10-17 + +**NONCLEAN; implementation correction, not acceptance.** Three NEW complete +cumulative reviewers cover all **285 paths / 1,526 zero-context hunks** at +`4b7ba2164d119d72a0fccaea742a6e261f03576f`, against the same `d9625ff0` base. +Root reads every full report and independently rejoins all 73/131/81 physical +file, complete diff and individual hunk hashes. The local ignored aggregate +SHA-256 is `daf8739b459e0fda0e47320a1480d6681599916ac6fef57739252f3c4700482d`. +All three slices are NONCLEAN. Three verified High findings, independently +corroborated and counted once each, require correction: + +- **WM-R6-1:** a successfully initialized manager retains an actual connection + close refusal but reports it only after every sibling close settles. Actual + `runCommand`, the default engine, real SQLite and the actual manager reproduce + no infrastructure observation/deadline/exit at a manually advanced 10,000 ms; + releasing the held sibling then creates the first deadline at 13,000. Root + independently reruns the same three-probe source selection. Its initial wrong + configuration collected zero tests and is rejected setup, not evidence. The + two paired R5 controls independently confirm its deferred-budget fix. +- **F1:** the current chat reference still promises crash exit 1 and excludes + infrastructure 9 from its one-shot wording. The correction preserves ordinary + classified failed turns, deliberate chat end and invalid invocation, while + linking the canonical infrastructure/remedy contract. This adjacent unchanged + reference was supporting context, not an extra assigned path in the review. +- **F2:** the current Home reference still promises immediate second-signal exit + and timer-bounded cleanup followed by DB closure. The corrected product reference + describes the original deadline and actual joins, keeps infrastructure precedence + visible, and distinguishes the ownerless library compatibility path. + +Successful MCP reporters are now registered before shared cleanup, notified once +per failed server as each fault occurs, and replay retained faults immediately +when attached late. Mark-before-callback prevents duplicate reentry. Every actual +close remains joined; a reporter exception is retained until those joins settle, +and failed-start observation remains separate. The original live budget still +controls actual entry. Four new manager schedules and an actual workflow/SQLite +regression pass; replacing only the manager with its pre-fix bytes breaks exactly +five permanent cases, then restores the fixed bytes. This is an author removal +exercise, not a new formal whole-family causal certificate. The run/gate secrets +workloads remain 83/71; no control inventory is weakened. + +Reviewer scopes remain distinct and overlap author selections: 697 session cases; +467 workflow cases plus three probes; 163 native source cases, 66 tooling cases, +four paired correction probes and three shared probes. This is neither full CI +nor the separately required security sitting. All first six rounds remain NONCLEAN. + +At `4b7ba216`, minimum/current Linux and current Windows installed consumers pass; +required macOS and therefore the packed aggregate fail. The new Windows advisory +passes, including all eight benign-alias cases, without changing the old 5-second +bound. This does not explain the prior `ad743ba5` timeout. The current macOS +injected-error control returns fixed infrastructure 9 where ordinary native +outcomes still require 0/1. Actual target/descendant readiness, native close, +listener removal and fixture/sentinel cleanup are recorded, without a watchdog; +the initiating fault remains unknown. Node there is actually **24.20.0**, not a +version inferred from another platform's current-runtime leg. + +Independent immutable-source triage verifies the actual tarball +`0a1a38868a581806d026bd707e43634b39d790ac2cfcda7d3cd4b183763b56a3`, all +seventy installed files and official Node 24.20.0. Thirty-one local injected-error +executions and 96 native subcases pass, including an unchanged complete eight-case +worker. Local macOS 27.0.1 differs from the CI host's 26.6.2. Invalid canonical-path/ +module-context attempts and their cleanup limits are retained separately. These +passes do not clear either failed macOS source. The ignored triage report SHA-256 +is `db3004eb5f27425c54c6c68a1afcb44790219348f229885b59b8814a28831069`, +and its 89-file evidence manifest SHA-256 is +`80b045f29009e523e41056fe2f424c98ca78946d0ca101f892d36d70632927b2`. + +The existing installed-test witness adds passive actual signal/uncaught-occurrence +observations with bounded native-origin facts and a required terminal completeness +record. It forwards the original receiver, arguments, result and throwable, +retains observation failure without masking the native result, and adds no +product handler/registry/exit authority. No exit, acknowledgement, readiness, +watchdog or timeout assertion is relaxed. The new tooling is locally exercised +against the unchanged old tarball with its complete eight-case worker and all +seventy installed bytes preserved; this is tooling validation, not a newly built +post-manager-fix artifact or remote platform acceptance. Required future macOS +execution remains open; no unobserved errno is normalized or declared harmless. + +Two NEW complete cumulative clean reviews after the last material correction, +a NEW separate clean security sitting and fresh full CI/all four exact-package +platform consumers remain required. Whole Step 8 and Steps 9–19 remain open. +No ADR approval, user decision, provider, credential, billing, capture or production +recipient setup is pending. Root continues automatically. + + +**Final author gates for the R6 correction.** Seventeen workspace lint/typecheck +tasks pass (twelve cached); eleven workspace test tasks pass (nine cached). +Fresh CLI execution is **335 files / 5,412 passing tests / ten reported skips**; +fresh MCP is **twenty files / 250 passing tests / no skips**. The previously +CI-skipped local wrap-shape case runs and passes in this non-CI author environment; +this explains the changed CLI skip count without deleting or weakening a case. +All **516 acceptance-tool tests**, tools lint/typecheck, schema sync, seam/purity, +714-file test isolation, build/format, 25-output closure, 177 compiled bootstrap +controls and compiled CLI smoke pass. Build/format has seven tasks, five cached. +The initial copied-tool fixture import failures and missing performance import +lint failure are repaired, not waived. A missing old temporary artifact path is +rejected local setup before install; the retained artifact is used for the valid +new-diagnostic worker. Root independently verifies all 89 sealed triage files. + +The ten-path product/test/tool author pin SHA-256 is +`794c04657e8ef264f718805cc53d0c02a922b20474aa0160df5322e5208e9c3f`. +These are author gates; independent cumulative review, security and fresh required +remote acceptance remain open. At the previous source, wire/usage/secrets controls, +coverage and Node-floor/Windows advisories have passed while filesystem controls +are still running; required macOS/packed aggregation remain failed. No whole-CI +success or post-correction artifact evidence is inferred from that partial run. From 78b601b9d36d45ffc34099a437f062151b737b40 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 06:17:17 +0300 Subject: [PATCH 120/222] fix(repo): validate observed native group refusal outcomes Require the witnessed Darwin owned-group EPERM-after-success schedule to retain infrastructure 9, one fixed diagnostic and zero completed turns. Keep ordinary 0/1 expectations and every actual-close, descendant, sentinel and cleanup check. Do not normalize product signal errors or retrospectively pass failed CI. Validation: 547 tool tests, complete unchanged installed eight-case worker, and selected actual-native refusal/ordinary controls. Fresh remote gates remain. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- tools/cli-package/README.md | 15 ++- tools/cli-package/smoke/native-process.mjs | 93 +++++++++++++++- .../cli-package/smoke/native-process.test.mjs | 105 ++++++++++++++++++ 3 files changed, 206 insertions(+), 7 deletions(-) diff --git a/tools/cli-package/README.md b/tools/cli-package/README.md index d2b4e996..f66bafaa 100644 --- a/tools/cli-package/README.md +++ b/tools/cli-package/README.md @@ -65,9 +65,18 @@ creation-owned target identity and own-data native errno. A passive inspecting exception text. Journal/clock/descriptor failure never masks the native outcome; missing, failed, duplicate or nonterminal completion evidence refuses the control. The failed-worker projection admits only its fixed diagnostic fields. -These observations improve failure attribution; they do not relax a native-close, -exit-code, watchdog, timing or cleanup assertion, or classify an unresolved failure -as harmless. +These observations improve failure attribution and never classify a native refusal +as harmless. Fault-free nonbootstrap cases still require 0/1 and one completed turn. +The Darwin injected-error control additionally recognizes only the fully observed +same-owned-group sequence: successful SIGKILL, EPERM/-1, actual target close, then +ESRCH/-3 and delivered close. That failure outcome requires exact infrastructure 9, +one fixed `cli_internal` diagnostic and **zero** completed turns. Missing/incomplete +evidence, a foreign group, another fault/mode, or unexplained 9 refuses. All original +readiness, actual-close, descendant absence, sibling survival, watchdog, timing, +cleanup and forbidden-service assertions still execute. A historical failed run +cannot acquire these unexecuted postconditions retrospectively; fresh execution is +required. This tests correct failure classification, not an ordinary successful +turn or the unverified kernel cause of EPERM. Required PR/push consumers use current `.nvmrc` Linux/macOS/Windows and exact Node 22.0.0 Linux. The aggregate reinspects the tarball and requires all four unique diff --git a/tools/cli-package/smoke/native-process.mjs b/tools/cli-package/smoke/native-process.mjs index e8f69262..7f4c57fb 100644 --- a/tools/cli-package/smoke/native-process.mjs +++ b/tools/cli-package/smoke/native-process.mjs @@ -80,6 +80,76 @@ function records(path) { assert.ok(statSync(path).isFile() && statSync(path).size <= 65536); return ndjson(readFileSync(path, 'utf8')); } + +/** A real, narrowly witnessed native refusal is an infrastructure outcome, never ordinary success. */ +export function assertObservedGroupRefusal({ platform, mode, result, observed, runtime, cliPid }) { + assert.equal(platform, 'darwin'); + assert.equal(mode, 'injected-error'); + assert.equal(result.code, 9); + assert.equal(result.signal, null); + assertNativeSignalDiagnosticComplete(observed); + const started = observed.filter((item) => item.operation === 'native-target-spawn'); + assert.equal(started.length, 1); + const pid = started[0].pid; + assert.ok(positivePid(pid)); + assert.equal(started[0].detached, true); + assert.equal(started[0].exitCallbacksBeforeEntry, 1); + const signals = observed.filter((item) => item.operation === 'native-signal-observed'); + assert.equal(signals.length, 3); + for (const item of signals) { + assert.equal(item.owned, true); + assert.equal(item.target, -pid); + assert.equal(item.signal, 'SIGKILL'); + assert.ok(Number.isFinite(item.milliseconds) && item.milliseconds >= 0); + } + assert.ok(signals[0].milliseconds <= signals[1].milliseconds); + assert.ok(signals[1].milliseconds <= signals[2].milliseconds); + assert.deepEqual( + signals.map(({ outcome, returned, nativeCode, errno }) => ({ + outcome, + returned, + nativeCode, + errno, + })), + [ + { outcome: 'returned', returned: true, nativeCode: undefined, errno: undefined }, + { outcome: 'threw', returned: undefined, nativeCode: 'EPERM', errno: -1 }, + { outcome: 'threw', returned: undefined, nativeCode: 'ESRCH', errno: -3 }, + ], + ); + assert.equal( + observed.some((item) => item.operation === 'native-uncaught-exception-observed'), + false, + ); + const closed = observed.filter((item) => item.operation === 'native-target-actual-close'); + assert.equal(closed.length, 1); + assert.equal(closed[0].pid, pid); + assert.equal(closed[0].code, null); + assert.equal(closed[0].signal, 'SIGKILL'); + assert.ok(observed.indexOf(signals[1]) < observed.indexOf(closed[0])); + assert.ok(observed.indexOf(closed[0]) < observed.indexOf(signals[2])); + const delivered = observed.filter((item) => item.operation === 'native-target-close-delivered'); + assert.equal(delivered.length, 2); + for (const item of delivered) { + assert.equal(item.pid, pid); + assert.ok(observed.indexOf(signals[2]) < observed.indexOf(item)); + } + const projected = projectRuntimeStderr(result.stderr, { runtime, pid: cliPid }); + assert.deepEqual(ndjson(projected.diagnostics), [ + { + type: 'error', + code: 'cli_internal', + message: + 'An unexpected CLI failure occurred. Use --verbose for safe diagnostics. Inspect existing work before repeating paid or effectful operations.', + }, + ]); + assert.equal( + ndjson(result.stdout).filter((event) => event.type === 'session:turn_completed').length, + 0, + 'A native infrastructure refusal must not publish an ordinary completed turn', + ); + return projected.runtimeWarnings; +} /** Actual close releases PID authority, even if a later liveness sample would see that number reused. */ export function pendingNativeFixturePids(log) { const observed = records(log); @@ -412,15 +482,27 @@ export async function assertInstalledNativeProcesses(context, { bootstrap = fals } const result = await owned.result(); assert.equal(result.signal, null, result.stderr); + const observed = records(log); + assertNativeSignalDiagnosticComplete(observed); + // No native fault still requires the original 0/1 result. Do not normalize EPERM, + // drop the final reap, admit arbitrary 9 or retrospectively pass an old failed run. + const nativeGroupRefusal = !bootstrap && result.code === 9; + if (nativeGroupRefusal) + outcome.runtimeWarnings = assertObservedGroupRefusal({ + platform: process.platform, + mode, + result, + observed, + runtime: process.version, + cliPid: owned.child.pid, + }); if (bootstrap) assert.equal(result.code, 9, result.stderr); - else assert.ok([0, 1].includes(result.code), result.stderr); + else if (!nativeGroupRefusal) assert.ok([0, 1].includes(result.code), result.stderr); assert.equal( ndjson(result.stdout).filter((event) => event.type === 'session:turn_completed').length, - bootstrap && mode !== 'bootstrap-background' ? 0 : 1, + nativeGroupRefusal || (bootstrap && mode !== 'bootstrap-background') ? 0 : 1, 'The public offline turn completion differs from its actual lifetime', ); - const observed = records(log); - assertNativeSignalDiagnosticComplete(observed); if (bootstrap) { assert.ok( observed.some((item) => item.operation === `native-${gate}-close-delivered`), @@ -534,6 +616,9 @@ export async function assertInstalledNativeProcesses(context, { bootstrap = fals actualNativeClose: mode !== 'facility-refusal', syntheticNativeError: mode === 'injected-error', syntheticFacilityAccessRefusal: mode === 'facility-refusal', + disposition: nativeGroupRefusal + ? 'observed-native-group-refusal' + : 'original-mode-outcome', ownedDescendantClosed: bootstrap || mode === 'injected-error' || mode === 'leader-exit', siblingSurvived: true, observed, diff --git a/tools/cli-package/smoke/native-process.test.mjs b/tools/cli-package/smoke/native-process.test.mjs index 6896f73e..ff62ab43 100644 --- a/tools/cli-package/smoke/native-process.test.mjs +++ b/tools/cli-package/smoke/native-process.test.mjs @@ -16,6 +16,7 @@ import { test } from 'node:test'; import { clearTimeout, setTimeout } from 'node:timers'; import { fileURLToPath } from 'node:url'; import { + assertObservedGroupRefusal, copyFixtureNode, copyFixtureRuntimeSupport, createFixtureSentinel, @@ -24,6 +25,110 @@ import { } from './native-process.mjs'; import { explicitEnvironment, ndjson, poll } from './process.mjs'; +function observedGroupRefusal() { + return { + platform: 'darwin', + mode: 'injected-error', + runtime: process.version, + cliPid: 60, + result: { + code: 9, + signal: null, + stdout: '{"type":"session:cancelled"}\n', + stderr: + '{"type":"error","code":"cli_internal","message":"An unexpected CLI failure occurred. Use --verbose for safe diagnostics. Inspect existing work before repeating paid or effectful operations."}\n', + }, + observed: [ + { operation: 'native-target-spawn', pid: 40, detached: true, exitCallbacksBeforeEntry: 1 }, + { + operation: 'native-signal-observed', + target: -40, + owned: true, + signal: 'SIGKILL', + outcome: 'returned', + returned: true, + milliseconds: 1, + }, + { + operation: 'native-signal-observed', + target: -40, + owned: true, + signal: 'SIGKILL', + outcome: 'threw', + nativeCode: 'EPERM', + errno: -1, + milliseconds: 2, + }, + { operation: 'native-target-actual-close', pid: 40, code: null, signal: 'SIGKILL' }, + { + operation: 'native-signal-observed', + target: -40, + owned: true, + signal: 'SIGKILL', + outcome: 'threw', + nativeCode: 'ESRCH', + errno: -3, + milliseconds: 3, + }, + { operation: 'native-target-close-delivered', pid: 40 }, + { operation: 'native-target-close-delivered', pid: 40 }, + { operation: 'native-signal-diagnostics-complete', complete: true, observations: 3 }, + ], + }; +} + +test('observed owned-group refusal requires sticky infrastructure 9 and no ordinary completion', () => { + assert.deepEqual(assertObservedGroupRefusal(observedGroupRefusal()), []); +}); + +for (const [name, mutate] of [ + ['a different platform', (v) => (v.platform = 'linux')], + ['a different native mode', (v) => (v.mode = 'leader-exit')], + ['a swallowed fault', (v) => (v.result.code = 0)], + ['a signal exit', (v) => (v.result.signal = 'SIGTERM')], + ['a false completed turn', (v) => (v.result.stdout += '{"type":"session:turn_completed"}\n')], + ['a different diagnostic', (v) => (v.result.stderr = '{"code":"output_unavailable"}\n')], + ['private diagnostic text', (v) => (v.result.stderr += 'PRIVATE_NATIVE_CANARY\n')], + ['an extra diagnostic', (v) => (v.result.stderr += v.result.stderr)], + ['missing complete evidence', (v) => v.observed.pop()], + ['truncated evidence', (v) => (v.observed.at(-1).complete = false)], + ['a wrong observation count', (v) => (v.observed.at(-1).observations = 2)], + ['a second creation', (v) => v.observed.unshift({ ...v.observed[0], pid: 41 })], + ['missing group authority', (v) => (v.observed[0].detached = false)], + ['missing creation registration', (v) => (v.observed[0].exitCallbacksBeforeEntry = 0)], + ['foreign signal ownership', (v) => (v.observed[2].owned = false)], + ['a different group', (v) => (v.observed[2].target = -41)], + ['a positive PID', (v) => (v.observed[2].target = 40)], + ['an unrelated signal', (v) => (v.observed[2].signal = 0)], + ['an unproved first request', (v) => (v.observed[1].returned = false)], + ['a different refusal', (v) => (v.observed[2].nativeCode = 'EACCES')], + ['missing native errno', (v) => delete v.observed[2].errno], + ['a nonfinite clock', (v) => (v.observed[2].milliseconds = NaN)], + ['a reversed clock', (v) => (v.observed[2].milliseconds = 4)], + ['missing later absence', (v) => (v.observed[4].nativeCode = 'EPERM')], + ['a foreign target close', (v) => (v.observed[3].pid = 41)], + ['a wrong native close', (v) => (v.observed[3].signal = 'SIGTERM')], + ['an absent close delivery', (v) => v.observed.splice(5, 1)], + ['a foreign close delivery', (v) => (v.observed[5].pid = 41)], + ['premature close delivery', (v) => v.observed.splice(1, 0, v.observed.splice(5, 1)[0])], + [ + 'an uncaught native error', + (v) => { + v.observed.splice(-1, 0, { + operation: 'native-uncaught-exception-observed', + milliseconds: 4, + }); + v.observed.at(-1).observations++; + }, + ], +]) { + test(`observed group refusal rejects ${name}`, () => { + const vector = observedGroupRefusal(); + mutate(vector); + assert.throws(() => assertObservedGroupRefusal(vector)); + }); +} + test('each fixture sentinel owns a separate bounded lifetime and joins native close', async () => { const root = mkdtempSync(join(tmpdir(), 'relavium-native-sentinel-test-')); try { From 1688ade350d0ffa520ffe99f9bd2f21d5a614b13 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 06:17:25 +0300 Subject: [PATCH 121/222] test(db): join fixture close after native error A started-child error and process exit do not acknowledge stderr/IPC/native closure. Keep cancellation eligible until actual close and report bounded fixed startup phases. A real started-child regression rejects the old error-as-ended guard without weakening the existing readiness deadline or case inventory. Validation: all 64 DB files and 1,062 tests pass; the selected guard removal fails the named semantic assertion and restores the exact original bytes. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- .../src/fixtures/media-temporary-register.ts | 2 + .../db/src/fixtures/media-temporary-worker.ts | 6 ++ .../db/src/media-store-temporaries.test.ts | 96 +++++++++++++++---- 3 files changed, 84 insertions(+), 20 deletions(-) diff --git a/packages/db/src/fixtures/media-temporary-register.ts b/packages/db/src/fixtures/media-temporary-register.ts index 99c87350..64cdec05 100644 --- a/packages/db/src/fixtures/media-temporary-register.ts +++ b/packages/db/src/fixtures/media-temporary-register.ts @@ -1,5 +1,7 @@ import { register } from 'node:module'; +process.send?.({ phase: 'loader-entry' }); + // register() is available at the supported Node 22.13 floor; registerHooks() is not. // A causal child inherits the phase runtime and must use the same pinned source/dependency // loader as its Vitest parent. Ordinary native tests keep the floor-compatible fixture loader. diff --git a/packages/db/src/fixtures/media-temporary-worker.ts b/packages/db/src/fixtures/media-temporary-worker.ts index 1b60cd02..7c850ef2 100644 --- a/packages/db/src/fixtures/media-temporary-worker.ts +++ b/packages/db/src/fixtures/media-temporary-worker.ts @@ -2,6 +2,8 @@ import fs from 'node:fs'; import { syncBuiltinESMExports } from 'node:module'; import { basename } from 'node:path'; +process.send?.({ phase: 'worker-entry' }); + const root = process.argv[2]; const mode = process.argv[3]; const stage = process.argv[4]; @@ -54,11 +56,13 @@ fs.promises.open = async (path, ...args) => { const file = await nativeOpen(path, ...args); if (basename(String(path)).startsWith('recovery-v1.')) await barrier('recovery'); if (basename(String(path)).startsWith('media-maintenance-v1.')) await barrier('maintenance'); + if (basename(String(path)).startsWith('media-v1.')) report({ phase: 'producer-open' }); if (basename(String(path)).startsWith('media-v1.')) await barrier('producer-open'); return file; }; syncBuiltinESMExports(); const { FilesystemMediaStore } = await import('../media-store.js'); +report({ phase: 'store-imported' }); const store = new FilesystemMediaStore(root); try { if (mode === 'producer') { @@ -66,12 +70,14 @@ try { (async function* () { yield new Uint8Array([1, 2]); report({ stage: 'writing', pid: process.pid }); + report({ phase: 'writing' }); await released; yield new Uint8Array([3, 4]); })(), 'audio/L16', ); report({ done: true, handle }); + report({ phase: 'done' }); } else if (mode === 'sweep') report({ done: true, result: await store.sweepTemporaries() }); else if (mode === 'legacy') report({ diff --git a/packages/db/src/media-store-temporaries.test.ts b/packages/db/src/media-store-temporaries.test.ts index a57396cc..1d01da05 100644 --- a/packages/db/src/media-store-temporaries.test.ts +++ b/packages/db/src/media-store-temporaries.test.ts @@ -30,7 +30,7 @@ vi.setConfig({ testTimeout: 15_000, hookTimeout: 15_000 }); interface Worker { readonly child: ChildProcess; - readonly exited: Promise; + readonly closed: Promise; wait: (stage: string) => Promise>; release: () => void; stop: () => Promise; @@ -62,6 +62,11 @@ function worker(mode: string, stage?: string): Worker { }, ); let diagnostic = ''; + const phases: Array<{ phase: string; milliseconds: number }> = []; + const started = performance.now(); + let exitCode: number | null = null; + let exitSignal: string | null = null; + let faulted = false; child.stderr?.on('data', (chunk: Buffer) => { diagnostic = (diagnostic + chunk.toString()).slice(-4000); }); @@ -69,19 +74,36 @@ function worker(mode: string, stage?: string): Worker { const pending = new Set<() => void>(); child.on('message', (message: unknown) => { if (typeof message === 'object' && message !== null) { - messages.push(Object.fromEntries(Object.entries(message))); + const value = Object.fromEntries(Object.entries(message)); + messages.push(value); + if ( + typeof value['phase'] === 'string' && + [ + 'loader-entry', + 'worker-entry', + 'store-imported', + 'producer-open', + 'writing', + 'done', + ].includes(value['phase']) && + phases.length < 32 + ) + phases.push({ phase: value['phase'], milliseconds: performance.now() - started }); for (const notify of pending) notify(); } }); let ended = false; - const exited = new Promise((resolve) => { - child.once('error', (error) => { + const closed = new Promise((resolve) => { + child.on('error', (error) => { diagnostic = error.message; - ended = true; + faulted = true; for (const notify of pending) notify(); - resolve(); }); - child.once('exit', () => { + child.once('exit', (code, signal) => { + exitCode = code; + exitSignal = signal; + }); + child.once('close', () => { ended = true; for (const notify of pending) notify(); resolve(); @@ -89,12 +111,16 @@ function worker(mode: string, stage?: string): Worker { }); const result: Worker = { child, - exited, + closed, wait: (stage) => new Promise((resolve, reject) => { const timer = setTimeout(() => { pending.delete(check); - reject(new Error(`native fixture did not reach ${stage}: ${diagnostic}`)); + reject( + new Error( + `native fixture did not reach ${stage}: ${JSON.stringify({ phases, exitCode, exitSignal, closed: ended, faulted })}: ${diagnostic}`, + ), + ); }, 5000); const check = (): void => { const match = messages.find((message) => @@ -104,7 +130,11 @@ function worker(mode: string, stage?: string): Worker { clearTimeout(timer); pending.delete(check); resolve(match); - } else if (ended) { + } else if ( + ended || + faulted || + messages.some((message) => message['error'] !== undefined) + ) { clearTimeout(timer); pending.delete(check); reject(new Error(`native fixture exited before ${stage}: ${diagnostic}`)); @@ -118,7 +148,7 @@ function worker(mode: string, stage?: string): Worker { }, stop: async () => { if (!ended) child.kill('SIGKILL'); - await exited; + await closed; }, }; workers.push(result); @@ -273,7 +303,33 @@ it('uses exclusive private owner records and only absent old producers are remov producer.release(); const published = await producer.wait('done'); expect(published['handle']).toMatch(/^media:\/\/sha256-/); - await producer.exited; + await producer.closed; + // An error is a diagnostic, including on a real already-started fixture. It must + // neither suppress owned cancellation nor acknowledge the stderr/IPC close. + const faultedProducer = worker('producer'); + await faultedProducer.wait('writing'); + const kill = vi.spyOn(faultedProducer.child, 'kill'); + let actualClose = false; + const actualClosed = new Promise((resolve) => + faultedProducer.child.once('close', () => { + actualClose = true; + resolve(); + }), + ); + try { + faultedProducer.child.emit('error', new Error('synthetic already-started fixture error')); + const stopped = faultedProducer.stop(); + expect(kill, 'MEDIA_FIXTURE_ERROR_IS_NOT_CLOSE').toHaveBeenCalledExactlyOnceWith('SIGKILL'); + await stopped; + expect(actualClose, 'MEDIA_FIXTURE_ERROR_IS_NOT_CLOSE').toBe(true); + } finally { + if (!actualClose) faultedProducer.child.kill('SIGKILL'); + await actualClosed; + kill.mockRestore(); + } + // The interrupted writer is a separate old-producer vector below. + for (const name of readdirSync(root).filter((entry) => entry.startsWith('media-v1.'))) + rmSync(join(root, name)); const second = await deadProducer(); const result = await new FilesystemMediaStore(root).sweepTemporaries(); expect(result).toEqual({ removed: 1, retained: 0, reasons: {} }); @@ -327,7 +383,7 @@ it('two real sweepers atomically claim one old producer without double deletion first.release(); second.release(); const results = await Promise.all([first.wait('done'), second.wait('done')]); - await Promise.all([first.exited, second.exited]); + await Promise.all([first.closed, second.closed]); const removed = results .map((value) => value['result']) .reduce( @@ -465,7 +521,7 @@ it('maintenance marker blocks a concurrent new producer and is held through dele done: true, result: { removed: 1 }, }); - await maintainer.exited; + await maintainer.closed; expect(readdirSync(root)).toEqual([]); }); @@ -479,10 +535,10 @@ it('producer post-open recheck refuses a maintainer admitted after its first che done: true, error: 'MediaTemporaryMaintenanceError', }); - await producer.exited; + await producer.closed; maintainer.release(); expect(await maintainer.wait('done')).toMatchObject({ done: true, result: { removed: 0 } }); - await maintainer.exited; + await maintainer.closed; expect(readdirSync(root)).toEqual([]); }); @@ -523,7 +579,7 @@ it('revalidates the exact original identity after initial claim rename', async ( writeFileSync(blob, new Uint8Array([7, 8])); claimant.release(); const result = await claimant.wait('done'); - await claimant.exited; + await claimant.closed; expect( { result, exists: existsSync(blob), size: existsSync(blob) ? statSync(blob).size : undefined }, 'MEDIA_TEMP_INITIAL_CLAIM_IDENTITY', @@ -545,7 +601,7 @@ it('two real recovery sweepers retain competing markers and eventually remove on first.release(); second.release(); const results = await Promise.all([first.wait('done'), second.wait('done')]); - await Promise.all([first.exited, second.exited]); + await Promise.all([first.closed, second.closed]); let removed = 0; for (const result of results) { const counts = result['result']; @@ -614,10 +670,10 @@ it.each(['directory', 'rmdir'])( } retaining.release(); const winner = await retaining.wait('done'); - await retaining.exited; + await retaining.closed; deleting.release(); const loser = await deleting.wait('done'); - await deleting.exited; + await deleting.closed; expect( { winner: winner['result'], loser: loser['result'], remaining: readdirSync(root) }, 'MEDIA_TEMP_RECOVERY_CLEANUP_WINNER', From 095fb453010ec947b7a5c4829a4af3b9e696cb54 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 06:17:25 +0300 Subject: [PATCH 122/222] test(cli): retain native and acquisition case ownership Runner cleanup joins raw cases and actual native close before replacing homes, mocks or environment. Seal the original case after every failure, preserve its signal and remaining budget, and retain every exact SQLite disposal capability. Prepare pure module transformation outside unchanged native semantic deadlines. Preserve failed-close product uncertainty and sticky native EPERM diagnosis. Validation: 335 CLI files, 5,413 passing tests and ten established local skips; no outcome, close-count assertion, case deadline or admission wait is relaxed. Refs: ADR-0110, ADR-0103, Wave 3 Step 8d Co-Authored-By: Codex --- .../commands/partial-db-acquisition.test.ts | 271 +++++++----- .../commands/surface-db-acquisition.test.ts | 390 +++++++++++------- .../engine/fixtures/media-prune-producer.ts | 6 +- .../engine/fixtures/media-prune-register.ts | 2 + .../src/process/native-process-owner.test.ts | 27 ++ apps/cli/src/run-media-prune.test.ts | 130 +++++- 6 files changed, 551 insertions(+), 275 deletions(-) diff --git a/apps/cli/src/commands/partial-db-acquisition.test.ts b/apps/cli/src/commands/partial-db-acquisition.test.ts index a5e02e58..bb8370dc 100644 --- a/apps/cli/src/commands/partial-db-acquisition.test.ts +++ b/apps/cli/src/commands/partial-db-acquisition.test.ts @@ -1,7 +1,24 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +let rawCase: Promise | undefined; +let currentSignal: AbortSignal | undefined; +beforeEach((context) => { + currentSignal = context.signal; +}); +afterEach(async () => { + // A Vitest deadline rejects its wrapper, not this scenario's late module continuation. + // Do not let the next case replace mocks/environment before the raw case cleans up. + await rawCase?.catch(() => undefined); + rawCase = undefined; +}); +beforeAll(async () => { + // Source transformation is setup; the unchanged five-second case tests native custody. + await import('./dispatch.js'); + await import('./gate.js'); +}); const routes = [ { id: 'gate.list', positionals: [], options: {} }, @@ -22,21 +39,34 @@ const routes = [ describe('management custody before local database setup', () => { it.each(routes.flatMap((route) => [false, true].map((closeFails) => ({ ...route, closeFails }))))( '$id joins actual SQLite after setup fails; close failure=$closeFails', - async ({ id, positionals, options, closeFails }) => { - const root = mkdtempSync(join(tmpdir(), 'relavium-partial-db-')); - const actual = await import('@relavium/db'); - let opened: ReturnType | undefined; - let release: (() => void) | undefined; - let closeAttempts = 0; - const setupFailure = new Error('synthetic database setup failure'); - const closeFailure = new Error('synthetic native close failure'); - vi.stubEnv('HOME', root); - vi.stubEnv('USERPROFILE', root); - writeFileSync(join(root, 'config.toml'), ''); - mkdirSync(join(root, '.relavium', 'workflows'), { recursive: true }); - writeFileSync( - join(root, '.relavium', 'workflows', 'synthetic.relavium.yaml'), - `schema_version: '1.0' + ({ id, positionals, options, closeFails }) => { + const signal = currentSignal; + if (signal === undefined) throw new Error('Synthetic case context was not captured'); + rawCase = (async () => { + const root = mkdtempSync(join(tmpdir(), 'relavium-partial-db-')); + let sealed = false; + const assertCurrent = (): void => { + signal.throwIfAborted(); + if (sealed) throw new Error('Synthetic database case no longer admits work'); + }; + let disposeCommand: (() => Promise) | undefined; + let opened: ReturnType | undefined; + const nativeDisposals = new Set<() => void>(); + let primary: { readonly cause: unknown } | undefined; + const failures: unknown[] = []; + try { + const actual = await import('@relavium/db'); + assertCurrent(); + let closeAttempts = 0; + const setupFailure = new Error('synthetic database setup failure'); + const closeFailure = new Error('synthetic native close failure'); + vi.stubEnv('HOME', root); + vi.stubEnv('USERPROFILE', root); + writeFileSync(join(root, 'config.toml'), ''); + mkdirSync(join(root, '.relavium', 'workflows'), { recursive: true }); + writeFileSync( + join(root, '.relavium', 'workflows', 'synthetic.relavium.yaml'), + `schema_version: '1.0' workflow: id: synthetic nodes: @@ -45,94 +75,133 @@ workflow: edges: - { from: start, to: end } `, - ); - vi.doMock('@relavium/db', () => ({ - ...actual, - createClient: (...args: Parameters) => { - const client = actual.createClient(...args); - opened = client; - const nativeClose = client.sqlite.close.bind(client.sqlite); - release = () => { - nativeClose(); - }; - client.sqlite.close = () => { - closeAttempts++; - if (closeFails) throw closeFailure; - return nativeClose(); - }; - return client; - }, - runMigrations: () => { - throw setupFailure; - }, - })); - vi.resetModules(); - try { - const { executeCommand } = await import('./dispatch.js'); - const { CommandLifetime } = await import('../process/command-lifetime.js'); - const { invocationFixture } = await import('../process/invocation.test-support.js'); - const { captureIo } = await import('../test-support.js'); - const f = invocationFixture(); - const captured = captureIo(); - const removeSignals = vi.fn(); - const lifetime = new CommandLifetime(f.invocation, captured.io, () => removeSignals); - let settled = false; - const command = lifetime.execute(() => - executeCommand( - id, - { positionals, options }, - { - io: lifetime.io, - invocation: f.invocation, - commandLifetime: lifetime, - global: { - cwd: root, - configPath: join(root, 'config.toml'), - json: true, - color: false, - verbosity: 'normal', + ); + vi.doMock('@relavium/db', () => ({ + ...actual, + createClient: (...args: Parameters) => { + assertCurrent(); + const client = actual.createClient(...args); + opened = client; + const nativeClose = client.sqlite.close.bind(client.sqlite); + nativeDisposals.add(() => { + if (client.sqlite.open) nativeClose(); + }); + client.sqlite.close = () => { + closeAttempts++; + if (closeFails) throw closeFailure; + return nativeClose(); + }; + return client; + }, + runMigrations: () => { + throw setupFailure; + }, + })); + vi.resetModules(); + const { executeCommand } = await import('./dispatch.js'); + assertCurrent(); + const { CommandLifetime } = await import('../process/command-lifetime.js'); + assertCurrent(); + const { invocationFixture } = await import('../process/invocation.test-support.js'); + assertCurrent(); + const { captureIo } = await import('../test-support.js'); + assertCurrent(); + const f = invocationFixture(); + const captured = captureIo(); + const removeSignals = vi.fn(); + const lifetime = new CommandLifetime(f.invocation, captured.io, () => removeSignals); + let settled = false; + const command = lifetime.execute(() => + executeCommand( + id, + { positionals, options }, + { + io: lifetime.io, + invocation: f.invocation, + commandLifetime: lifetime, + global: { + cwd: root, + configPath: join(root, 'config.toml'), + json: true, + color: false, + verbosity: 'normal', + }, }, + ), + ); + const outcome = command.then( + (value) => { + settled = true; + return value; }, - ), - ); - const outcome = command.then( - (value) => { - settled = true; - return value; - }, - (cause: unknown) => { - settled = true; - return cause; - }, - ); - await new Promise((resolve) => setImmediate(resolve)); - expect(captured.out()).toBe(''); - expect(f.registry.observations.exitCode()).toBe(9); - expect(f.registry.budget.deadline()).toBe(3_000); - if (closeFails) { - expect(settled, 'PARTIAL_DATABASE_FAILURE_RETAINS_ACTUAL_NATIVE_CLOSE').toBe(false); - expect(opened?.sqlite.open).toBe(true); - expect(f.registry.activeOwners).toBe(1); - expect(removeSignals).not.toHaveBeenCalled(); - expect(closeAttempts).toBe(2); // Opener's immediate release and adopted actual-handle cleanup. - f.advance(3_000); - expect(await f.invocation.finiteExit.exited).toBe(9); - expect(f.registry.activeOwners).toBe(1); - expect(opened?.sqlite.open).toBe(true); - } else { - expect(await outcome).toMatchObject({ code: 'cli_internal', exitCode: 9 }); - expect(opened?.sqlite.open).toBe(false); - expect(f.registry.activeOwners).toBe(0); - expect(removeSignals).toHaveBeenCalledOnce(); - expect(closeAttempts).toBe(1); + (cause: unknown) => { + settled = true; + return cause; + }, + ); + disposeCommand = async () => { + // Stop admission before any pending route import resumes. A proved failed + // native close intentionally has no product ACK; harness disposal below is distinct. + void f.registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + f.advance(3_000); + if (!opened?.sqlite.open) await outcome; + }; + await new Promise((resolve) => setImmediate(resolve)); + expect(captured.out()).toBe(''); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.budget.deadline()).toBe(3_000); + if (closeFails) { + expect(settled, 'PARTIAL_DATABASE_FAILURE_RETAINS_ACTUAL_NATIVE_CLOSE').toBe(false); + expect(opened?.sqlite.open).toBe(true); + expect(f.registry.activeOwners).toBe(1); + expect(removeSignals).not.toHaveBeenCalled(); + expect(closeAttempts).toBe(2); // Opener's immediate release and adopted actual-handle cleanup. + f.advance(3_000); + expect(await f.invocation.finiteExit.exited).toBe(9); + expect(f.registry.activeOwners).toBe(1); + expect(opened?.sqlite.open).toBe(true); + } else { + expect(await outcome).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + expect(opened?.sqlite.open).toBe(false); + expect(f.registry.activeOwners).toBe(0); + expect(removeSignals).toHaveBeenCalledOnce(); + expect(closeAttempts).toBe(1); + } + } catch (cause) { + primary = { cause }; + } finally { + sealed = true; + try { + await disposeCommand?.(); + } catch (cause) { + failures.push(cause); + } + // Every exact captured native handle is administrative disposal, never a product ACK. + for (const close of nativeDisposals) { + try { + close(); + } catch (cause) { + failures.push(cause); + } + } + for (const cleanup of [ + () => vi.doUnmock('@relavium/db'), + () => vi.resetModules(), + () => vi.unstubAllEnvs(), + () => rmSync(root, { recursive: true, force: true }), + ]) { + try { + cleanup(); + } catch (cause) { + failures.push(cause); + } + } } - } finally { - if (opened?.sqlite.open) release?.(); // Harness disposal never acknowledges the failed owner. - vi.doUnmock('@relavium/db'); - vi.resetModules(); - vi.unstubAllEnvs(); - rmSync(root, { recursive: true, force: true }); - } + if (primary !== undefined) throw primary.cause; + if (failures.length !== 0) + throw new AggregateError(failures, 'Synthetic database fixture cleanup failed'); + })(); + return rawCase; }, ); }); diff --git a/apps/cli/src/commands/surface-db-acquisition.test.ts b/apps/cli/src/commands/surface-db-acquisition.test.ts index 7a2b8854..4ed0d62f 100644 --- a/apps/cli/src/commands/surface-db-acquisition.test.ts +++ b/apps/cli/src/commands/surface-db-acquisition.test.ts @@ -2,7 +2,28 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Readable } from 'node:stream'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +let rawCase: Promise | undefined; +let currentSignal: AbortSignal | undefined; +beforeEach((context) => { + currentSignal = context.signal; +}); +afterEach(async () => { + await rawCase?.catch(() => undefined); + rawCase = undefined; +}); +beforeAll(async () => { + // Warm source transformation, never a command/service, outside the native case budget. + await Promise.all([ + import('./run.js'), + import('./gate.js'), + import('./chat.js'), + import('./agent-run.js'), + import('../home/drive-home.js'), + import('../engine/pricing-overlay.js'), + ]); +}); const surfaces = ['run', 'gate', 'chat', 'chat-resume', 'home', 'agent', 'pricing'] as const; @@ -14,27 +35,40 @@ describe('original surface custody of partially opened SQLite', () => { { surface: 'pricing' as const, closeFails: true, closeRecovers: true }, ])( '$surface preserves partial acquisition; close failure=$closeFails; later release=$closeRecovers', - async ({ surface, closeFails, closeRecovers }) => { - const root = mkdtempSync(join(tmpdir(), 'relavium-surface-db-')); - const actual = await import('@relavium/db'); - let faultEnabled = !['chat', 'agent'].includes(surface); - let native: ReturnType | undefined; - let dispose: (() => void) | undefined; - let attempts = 0; - const primary = new Error('synthetic migration refusal'); - const secondary = new Error('synthetic native close refusal'); - vi.stubEnv('HOME', root); - vi.stubEnv('USERPROFILE', root); - writeFileSync(join(root, 'config.toml'), ''); - const agent = join(root, 'synthetic.agent.yaml'); - writeFileSync( - agent, - 'id: synthetic\nprovider: anthropic\nmodel: claude-sonnet-4-6\nsystem_prompt: Synthetic test.\ntools: []\n', - ); - const workflow = join(root, 'synthetic.relavium.yaml'); - writeFileSync( - workflow, - `schema_version: '1.0' + ({ surface, closeFails, closeRecovers }) => { + const signal = currentSignal; + if (signal === undefined) throw new Error('Synthetic case context was not captured'); + rawCase = (async () => { + const root = mkdtempSync(join(tmpdir(), 'relavium-surface-db-')); + let sealed = false; + const assertCurrent = (): void => { + signal.throwIfAborted(); + if (sealed) throw new Error('Synthetic surface case no longer admits work'); + }; + let native: ReturnType | undefined; + const nativeDisposals = new Set<() => void>(); + let primary: { readonly cause: unknown } | undefined; + const failures: unknown[] = []; + let disposeCommand: (() => Promise) | undefined; + try { + const actual = await import('@relavium/db'); + assertCurrent(); + let faultEnabled = !['chat', 'agent'].includes(surface); + let attempts = 0; + const primary = new Error('synthetic migration refusal'); + const secondary = new Error('synthetic native close refusal'); + vi.stubEnv('HOME', root); + vi.stubEnv('USERPROFILE', root); + writeFileSync(join(root, 'config.toml'), ''); + const agent = join(root, 'synthetic.agent.yaml'); + writeFileSync( + agent, + 'id: synthetic\nprovider: anthropic\nmodel: claude-sonnet-4-6\nsystem_prompt: Synthetic test.\ntools: []\n', + ); + const workflow = join(root, 'synthetic.relavium.yaml'); + writeFileSync( + workflow, + `schema_version: '1.0' workflow: id: synthetic nodes: @@ -43,144 +77,190 @@ workflow: edges: - { from: start, to: end } `, - ); - vi.doMock('@relavium/db', () => ({ - ...actual, - createClient: (...args: Parameters) => { - const opened = actual.createClient(...args); - if (faultEnabled) { - native = opened; - const nativeClose = opened.sqlite.close.bind(opened.sqlite); - dispose = () => { - nativeClose(); - }; - opened.sqlite.close = () => { - attempts++; - if (closeFails && (!closeRecovers || attempts === 1)) throw secondary; - return nativeClose(); - }; - } - return opened; - }, - runMigrations: (...args: Parameters) => { - if (faultEnabled) throw primary; - return actual.runMigrations(...args); - }, - })); - vi.resetModules(); - try { - const signals = await import('../process/signals.js'); - const removeSignals = vi.fn(); - vi.spyOn(signals, 'defaultSubscribeSignals').mockReturnValue(removeSignals); - const { invocationFixture } = await import('../process/invocation.test-support.js'); - const { captureIo } = await import('../test-support.js'); - const { scriptedResolver } = await import('../chat/test-support.js'); - const session = await import('../history/session-open.js'); - const nativeOpen = session.openSessionStore; - vi.spyOn(session, 'openSessionStore').mockImplementation((home, captureClose) => { - faultEnabled = true; - return nativeOpen(home, captureClose); - }); - const openSessionStore = session.openSessionStore; - const f = invocationFixture(); - const captured = captureIo(); - const common = { - invocation: f.invocation, - io: { ...captured.io, stdin: Readable.from(['synthetic prompt']) }, - global: { - cwd: root, - configPath: join(root, 'config.toml'), - json: true, - color: false, - verbosity: 'normal' as const, - }, - providers: scriptedResolver([]), - }; - const drive = async () => { - switch (surface) { - case 'run': { - const { runCommand } = await import('./run.js'); - const { openHistoryStore } = await import('../history/open.js'); - return runCommand( - { workflow, input: [], allowMcpStdio: [] }, - { ...common, openRunStore: openHistoryStore }, - ); - } - case 'gate': { - const { gateCommand } = await import('./gate.js'); - return gateCommand({ runId: 'synthetic-run', approve: true }, common); - } - case 'chat': { - const { chatCommand } = await import('./chat.js'); - return chatCommand({ agent }, { ...common, openSessionStore }); + ); + vi.doMock('@relavium/db', () => ({ + ...actual, + createClient: (...args: Parameters) => { + assertCurrent(); + const opened = actual.createClient(...args); + if (faultEnabled) { + native = opened; + const nativeClose = opened.sqlite.close.bind(opened.sqlite); + nativeDisposals.add(() => { + if (opened.sqlite.open) nativeClose(); + }); + opened.sqlite.close = () => { + attempts++; + if (closeFails && (!closeRecovers || attempts === 1)) throw secondary; + return nativeClose(); + }; + } + return opened; + }, + runMigrations: (...args: Parameters) => { + if (faultEnabled) throw primary; + return actual.runMigrations(...args); + }, + })); + vi.resetModules(); + const signals = await import('../process/signals.js'); + assertCurrent(); + const removeSignals = vi.fn(); + vi.spyOn(signals, 'defaultSubscribeSignals').mockReturnValue(removeSignals); + const { invocationFixture } = await import('../process/invocation.test-support.js'); + assertCurrent(); + const { captureIo } = await import('../test-support.js'); + assertCurrent(); + const { scriptedResolver } = await import('../chat/test-support.js'); + assertCurrent(); + const session = await import('../history/session-open.js'); + assertCurrent(); + const nativeOpen = session.openSessionStore; + vi.spyOn(session, 'openSessionStore').mockImplementation((home, captureClose) => { + faultEnabled = true; + return nativeOpen(home, captureClose); + }); + const openSessionStore = session.openSessionStore; + const f = invocationFixture(); + const captured = captureIo(); + const common = { + invocation: f.invocation, + io: { ...captured.io, stdin: Readable.from(['synthetic prompt']) }, + global: { + cwd: root, + configPath: join(root, 'config.toml'), + json: true, + color: false, + verbosity: 'normal' as const, + }, + providers: scriptedResolver([]), + }; + const drive = async () => { + switch (surface) { + case 'run': { + const { runCommand } = await import('./run.js'); + const { openHistoryStore } = await import('../history/open.js'); + assertCurrent(); + return runCommand( + { workflow, input: [], allowMcpStdio: [] }, + { ...common, openRunStore: openHistoryStore }, + ); + } + case 'gate': { + const { gateCommand } = await import('./gate.js'); + assertCurrent(); + return gateCommand({ runId: 'synthetic-run', approve: true }, common); + } + case 'chat': { + const { chatCommand } = await import('./chat.js'); + assertCurrent(); + return chatCommand({ agent }, { ...common, openSessionStore }); + } + case 'chat-resume': { + const { chatResumeCommand } = await import('./chat.js'); + assertCurrent(); + return chatResumeCommand( + { sessionId: 'synthetic-session' }, + { ...common, openSessionStore }, + ); + } + case 'home': { + const { driveHome } = await import('../home/drive-home.js'); + assertCurrent(); + return driveHome({ ...common, openSessionStore }); + } + case 'agent': { + const { agentRunCommand } = await import('./agent-run.js'); + assertCurrent(); + return agentRunCommand({ agent, input: [], allowMcpStdio: [] }, common); + } + case 'pricing': { + const { loadUserPricingOverlay } = await import('../engine/pricing-overlay.js'); + assertCurrent(); + return loadUserPricingOverlay(root, captured.io, f.invocation); + } } - case 'chat-resume': { - const { chatResumeCommand } = await import('./chat.js'); - return chatResumeCommand( - { sessionId: 'synthetic-session' }, - { ...common, openSessionStore }, - ); - } - case 'home': { - const { driveHome } = await import('../home/drive-home.js'); - return driveHome({ ...common, openSessionStore }); - } - case 'agent': { - const { agentRunCommand } = await import('./agent-run.js'); - return agentRunCommand({ agent, input: [], allowMcpStdio: [] }, common); + }; + let settled = false; + const result = drive().then( + (value) => { + settled = true; + return value; + }, + (cause: unknown) => { + settled = true; + return cause; + }, + ); + disposeCommand = async () => { + void f.registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + f.advance(3_000); + // Before a failed native close exists, join the full import/preparation root. + // Otherwise the deliberately unacknowledged product result remains pending. + if (!native?.sqlite.open) await result; + }; + await vi.waitFor(() => + expect(attempts, 'ACTUAL_SURFACE_REACHED_SQLITE_SETUP').toBeGreaterThan(0), + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(captured.out()).toBe(''); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.budget.deadline()).toBe(3_000); + if (closeFails && !closeRecovers) { + expect(native?.sqlite.open).toBe(true); + expect(attempts, 'PARTIAL_SURFACE_RETAINS_NATIVE_CLOSE_CAPABILITY').toBe(2); + expect(f.registry.activeOwners).toBe(1); + expect(removeSignals).not.toHaveBeenCalled(); + // A synchronous transient operation rejects immediately while its native obligation stays owned. + expect(settled).toBe(surface === 'pricing'); + if (surface === 'pricing') + expect(await result).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + f.advance(3_000); + expect(await f.invocation.finiteExit.exited).toBe(9); + expect(native?.sqlite.open).toBe(true); + expect(f.registry.activeOwners).toBe(1); + } else { + expect(await result).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + expect(native?.sqlite.open).toBe(false); + expect(attempts).toBe(closeRecovers ? 2 : 1); + expect(f.registry.observations.hasInfrastructureFailure()).toBe(true); + expect(f.registry.activeOwners).toBe(0); + } + } catch (cause) { + primary = { cause }; + } finally { + sealed = true; + try { + await disposeCommand?.(); + } catch (cause) { + failures.push(cause); + } + for (const close of nativeDisposals) { + try { + close(); + } catch (cause) { + failures.push(cause); } - case 'pricing': { - const { loadUserPricingOverlay } = await import('../engine/pricing-overlay.js'); - return loadUserPricingOverlay(root, captured.io, f.invocation); + } + for (const cleanup of [ + () => vi.restoreAllMocks(), + () => vi.doUnmock('@relavium/db'), + () => vi.resetModules(), + () => vi.unstubAllEnvs(), + () => rmSync(root, { recursive: true, force: true }), + ]) { + try { + cleanup(); + } catch (cause) { + failures.push(cause); } } - }; - let settled = false; - const result = drive().then( - (value) => { - settled = true; - return value; - }, - (cause: unknown) => { - settled = true; - return cause; - }, - ); - await vi.waitFor(() => - expect(attempts, 'ACTUAL_SURFACE_REACHED_SQLITE_SETUP').toBeGreaterThan(0), - ); - await new Promise((resolve) => setImmediate(resolve)); - expect(captured.out()).toBe(''); - expect(f.registry.observations.exitCode()).toBe(9); - expect(f.registry.budget.deadline()).toBe(3_000); - if (closeFails && !closeRecovers) { - expect(native?.sqlite.open).toBe(true); - expect(attempts, 'PARTIAL_SURFACE_RETAINS_NATIVE_CLOSE_CAPABILITY').toBe(2); - expect(f.registry.activeOwners).toBe(1); - expect(removeSignals).not.toHaveBeenCalled(); - // A synchronous transient operation rejects immediately while its native obligation stays owned. - expect(settled).toBe(surface === 'pricing'); - if (surface === 'pricing') - expect(await result).toMatchObject({ code: 'cli_internal', exitCode: 9 }); - f.advance(3_000); - expect(await f.invocation.finiteExit.exited).toBe(9); - expect(native?.sqlite.open).toBe(true); - expect(f.registry.activeOwners).toBe(1); - } else { - expect(await result).toMatchObject({ code: 'cli_internal', exitCode: 9 }); - expect(native?.sqlite.open).toBe(false); - expect(attempts).toBe(closeRecovers ? 2 : 1); - expect(f.registry.observations.hasInfrastructureFailure()).toBe(true); - expect(f.registry.activeOwners).toBe(0); } - } finally { - if (native?.sqlite.open) dispose?.(); // Test disposal supplies no product retirement ACK. - vi.restoreAllMocks(); - vi.doUnmock('@relavium/db'); - vi.resetModules(); - vi.unstubAllEnvs(); - rmSync(root, { recursive: true, force: true }); - } + if (primary !== undefined) throw primary.cause; + if (failures.length !== 0) + throw new AggregateError(failures, 'Synthetic surface fixture cleanup failed'); + })(); + return rawCase; }, ); }); diff --git a/apps/cli/src/engine/fixtures/media-prune-producer.ts b/apps/cli/src/engine/fixtures/media-prune-producer.ts index d4ad3195..7b9a9e53 100644 --- a/apps/cli/src/engine/fixtures/media-prune-producer.ts +++ b/apps/cli/src/engine/fixtures/media-prune-producer.ts @@ -1,4 +1,6 @@ -import { FilesystemMediaStore } from '@relavium/db'; +process.send?.({ phase: 'worker-entry' }); +const { FilesystemMediaStore } = await import('@relavium/db'); +process.send?.({ phase: 'store-imported' }); const root = process.argv[2]; if (root === undefined) throw new Error('Missing synthetic CAS root'); @@ -14,12 +16,14 @@ try { (async function* () { yield new Uint8Array([1, 2]); process.send?.({ stage: 'writing', pid: process.pid }); + process.send?.({ phase: 'writing' }); await released; yield new Uint8Array([3, 4]); })(), 'audio/L16', ); process.send?.({ done: true, handle }); + process.send?.({ phase: 'done' }); } finally { process.disconnect?.(); } diff --git a/apps/cli/src/engine/fixtures/media-prune-register.ts b/apps/cli/src/engine/fixtures/media-prune-register.ts index b3daa8c0..99b11d2e 100644 --- a/apps/cli/src/engine/fixtures/media-prune-register.ts +++ b/apps/cli/src/engine/fixtures/media-prune-register.ts @@ -1,5 +1,7 @@ import { register } from 'node:module'; +process.send?.({ phase: 'loader-entry' }); + // register() is available at the supported Node 22.13 floor; registerHooks() is not. // Causal children inherit the phase runtime and must use its pinned source/dependency loader. if (process.env['RELAVIUM_USAGE_RUNTIME'] === undefined) diff --git a/apps/cli/src/process/native-process-owner.test.ts b/apps/cli/src/process/native-process-owner.test.ts index 1224e355..6af6fa66 100644 --- a/apps/cli/src/process/native-process-owner.test.ts +++ b/apps/cli/src/process/native-process-owner.test.ts @@ -198,6 +198,33 @@ describe('creation-owned native processes', () => { await lease.whenClosed; }); + it('retains a later group EPERM after successful cancellation and joins the actual close', async () => { + const h = harness('darwin'); + const lease = await h.controller.reserve('group'); + const target = child(); + lease.bindChild(target); + const closed = lease.stop(); + expect(h.signal.mock.calls).toEqual([[-40, 'SIGKILL']]); + h.signal.mockImplementationOnce(() => { + throw Object.assign(new Error('synthetic native refusal'), { code: 'EPERM', errno: -1 }); + }); + target.emit('exit', null); + expect(h.registry.observations.exitCode(), 'NATIVE_GROUP_REFUSAL_RETAINED').toBe(9); + expect(h.registry.activeOwners).toBe(1); + h.signal.mockImplementationOnce(() => { + throw Object.assign(new Error('synthetic native absence'), { code: 'ESRCH', errno: -3 }); + }); + target.emit('close', null); + await closed; + expect(h.signal.mock.calls).toEqual([ + [-40, 'SIGKILL'], + [-40, 'SIGKILL'], + [-40, 'SIGKILL'], + ]); + expect(h.registry.activeOwners).toBe(0); + expect(h.registry.observations.exitCode(), 'NATIVE_GROUP_REFUSAL_RETAINED').toBe(9); + }); + it('keeps a Windows helper and target as two independent native close obligations', async () => { const h = harness('win32'); const lease = await h.controller.reserve('pid'); diff --git a/apps/cli/src/run-media-prune.test.ts b/apps/cli/src/run-media-prune.test.ts index 4917accd..fe6dd187 100644 --- a/apps/cli/src/run-media-prune.test.ts +++ b/apps/cli/src/run-media-prune.test.ts @@ -22,6 +22,7 @@ const isolated = vi.hoisted(() => ({ throw new Error('PRIVATE_SERVICE_SENTINEL'); }), })); +const nativeCleanups = new Set<() => Promise>(); vi.mock('node:os', async (original) => ({ ...(await original()), homedir: () => isolated.home, @@ -51,7 +52,13 @@ beforeEach(() => { isolated.home = mkdtempSync(join(tmpdir(), 'private-media-maintenance-')); isolated.services.mockClear(); }); -afterEach(() => rmSync(isolated.home, { recursive: true, force: true })); +afterEach(async () => { + // Runner timeout aborts a context, not its raw async body. Join its actual child/work + // before another test replaces the mocked home or removes this case's directory. + await Promise.all([...nativeCleanups].map((close) => close())); + nativeCleanups.clear(); + rmSync(isolated.home, { recursive: true, force: true }); +}); const nonce = '12345678-1234-4234-8234-123456789abc'; function legacy() { const root = join(isolated.home, '.relavium', 'media'); @@ -136,8 +143,14 @@ it('MEDIA_MAINTENANCE_LIVE: a current new producer prevents every legacy deletio expect(isolated.services).toHaveBeenCalledTimes(0); }); -it('MEDIA_MAINTENANCE_NATIVE_WRITER: an actual held producer survives refusal and then publishes', async () => { +it('MEDIA_MAINTENANCE_NATIVE_WRITER: an actual held producer survives refusal and then publishes', async (context) => { + context.signal.throwIfAborted(); const f = legacy(); + const originalHome = isolated.home; + const deadline = performance.now() + 5000; + const remaining = (): number => Math.max(0, deadline - performance.now()); + let sealed = false; + let work: Promise = Promise.resolve(); const child = fork( fileURLToPath(new URL('./engine/fixtures/media-prune-producer.ts', import.meta.url)), [f.root], @@ -151,14 +164,71 @@ it('MEDIA_MAINTENANCE_NATIVE_WRITER: an actual held producer survives refusal an }, ); let diagnostic = ''; + const phases: Array<{ phase: string; milliseconds: number }> = []; + const started = performance.now(); + let actualClose = false; + let exitCode: number | null = null; + let exitSignal: string | null = null; + let disconnected = false; child.stderr?.on('data', (chunk: Buffer) => { - diagnostic += chunk.toString(); + diagnostic = (diagnostic + chunk.toString()).slice(-4000); + }); + child.once('exit', (code, signal) => { + exitCode = code; + exitSignal = signal; }); - const exited = new Promise((resolve) => child.once('exit', () => resolve())); + child.once('disconnect', () => { + disconnected = true; + }); + const closed = new Promise((resolve) => + child.once('close', () => { + actualClose = true; + resolve(); + }), + ); + child.on('message', (message: unknown) => { + if ( + typeof message === 'object' && + message !== null && + 'phase' in message && + typeof message.phase === 'string' && + ['loader-entry', 'worker-entry', 'store-imported', 'writing', 'done'].includes( + message.phase, + ) && + phases.length < 32 + ) + phases.push({ phase: message.phase, milliseconds: performance.now() - started }); + }); + let closing: Promise | undefined; + const close = (): Promise => { + sealed = true; + closing ??= Promise.resolve().then(async () => { + if (!actualClose) child.kill('SIGKILL'); + await closed; + await work.catch(() => undefined); + }); + return closing; + }; + nativeCleanups.add(close); + const assertCurrent = (): void => { + context.signal.throwIfAborted(); + if (sealed || isolated.home !== originalHome || remaining() === 0) + throw new Error('Synthetic native case no longer admits work'); + }; + const onAbort = (): void => { + // The runner-owned afterEach still observes this same cleanup result. + void close().catch(() => undefined); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); const writing = new Promise((resolve, reject) => { const timer = setTimeout( - () => reject(new Error('Synthetic producer readiness deadline')), - 5000, + () => + reject( + new Error( + `Synthetic producer readiness deadline: ${JSON.stringify({ phases, actualClose, exitCode, exitSignal, disconnected })}`, + ), + ), + remaining(), ); child.on('message', (message: unknown) => { if ( @@ -182,19 +252,19 @@ it('MEDIA_MAINTENANCE_NATIVE_WRITER: an actual held producer survives refusal an }); try { await writing; + assertCurrent(); const io = captureIo(); - expect( - await run( - ['node', 'relavium', 'media', 'prune-temporaries', '--legacy-offline', '--force'], - io.io, - ), - 'MEDIA_MAINTENANCE_NATIVE_WRITER', - ).toBe(2); + work = run( + ['node', 'relavium', 'media', 'prune-temporaries', '--legacy-offline', '--force'], + io.io, + ); + expect(await work, 'MEDIA_MAINTENANCE_NATIVE_WRITER').toBe(2); + assertCurrent(); expect(existsSync(f.first)).toBe(true); const done = new Promise((resolve, reject) => { const timer = setTimeout( () => reject(new Error('Synthetic producer completion deadline')), - 5000, + remaining(), ); child.on('message', (message: unknown) => { if ( @@ -214,14 +284,16 @@ it('MEDIA_MAINTENANCE_NATIVE_WRITER: an actual held producer survives refusal an }); child.send('release'); const result = await done; + assertCurrent(); if (typeof result !== 'object' || result === null || !('handle' in result)) throw new Error('Missing native producer result'); expect(result.handle, 'MEDIA_MAINTENANCE_NATIVE_WRITER').toMatch(/^media:\/\/sha256-/); - await exited; + await closed; expect(child.exitCode).toBe(0); } finally { - if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); - await exited; + await close(); + context.signal.removeEventListener('abort', onAbort); + nativeCleanups.delete(close); } }); @@ -249,7 +321,29 @@ for (const kind of ['root', 'shard', 'file-symlink', 'hardlink'] as const) { ['node', 'relavium', 'media', 'prune-temporaries', '--legacy-offline', '--force', '--json'], io.io, ), - 'MEDIA_MAINTENANCE_CONTAINMENT', + `MEDIA_MAINTENANCE_CONTAINMENT ${JSON.stringify({ + diagnosticCodes: io + .err() + .split('\n') + .filter(Boolean) + .map((line) => { + try { + const value: unknown = JSON.parse(line); + if ( + typeof value === 'object' && + value !== null && + 'code' in value && + ['invalid_invocation', 'internal', 'cli_internal', 'storage_unavailable'].includes( + String(value.code), + ) + ) + return value.code; + } catch { + /* Only fixed structured diagnostic codes belong in this test's failure evidence. */ + } + return 'unclassified'; + }), + })}`, ).toBe(kind === 'root' ? 0 : 9); // Initial root aliases are admitted and pinned by ADR-0105; linked descendants are refused. expect(readFileSync(witness, 'utf8')).toBe('PRIVATE_OUTSIDE_BYTES'); From 09740575c36769484e3227d74c9de6780ec4fc34 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 06:17:25 +0300 Subject: [PATCH 123/222] docs(docs): record seventh cumulative step 8d correction Join complete 288-path cumulative review, the observed macOS native refusal, and independent fixture-contamination triage with their exact source limits. Record current author validation while preserving every historical failed run. Step 8d remains open for two new clean cumulative rounds, separate security and fresh full CI plus all four same-artifact platforms. No new decision, provider operation or production release setup is pending. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- AGENTS.md | 15 ++- CLAUDE.md | 15 ++- ...d-discovery-have-explicit-compatibility.md | 10 ++ docs/roadmap/current.md | 12 +- .../phase-2.5.5-hardening-and-remediation.md | 121 ++++++++++++++++++ 5 files changed, 160 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0c8866ec..5301217c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,12 +107,15 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first six complete cumulative rounds are -NONCLEAN. R5's deferred budget repair passes independent adverse schedules; R6 -finds successful MCP failure reporting delayed behind a held sibling, plus stale -chat/Home lifecycle references. Corrections and passive native-test diagnostics -pass final author validation; fresh independent reviews remain required. New Windows evidence passes; the required -macOS package failure remains unexplained, despite exact-package local triage. +but not independently accepted. Its first seven complete cumulative rounds are +NONCLEAN. R6's successful-MCP first-fault and canonical lifecycle corrections pass +R7 review. R7 identifies a required-control expectation defect: directly observed +Darwin group EPERM correctly retains infrastructure 9, while the smoke requires +0/1. The narrow control correction preserves the native fault and actual joins. +Fresh Windows advisory and local full-suite failures also expose fixture ownership +and late-case contamination; their corrections remain under author validation. +Older unexplained platform failures retain their historical scope. No failed run +is retrospectively accepted; current required platform/full-CI evidence stays open. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. diff --git a/CLAUDE.md b/CLAUDE.md index 54d9819f..c98c8ba7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,12 +196,15 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first six complete cumulative rounds are -NONCLEAN. R5's deferred budget repair passes independent adverse schedules; R6 -finds successful MCP failure reporting delayed behind a held sibling, plus stale -chat/Home lifecycle references. Corrections and passive native-test diagnostics -pass final author validation; fresh independent reviews remain required. New Windows evidence passes; the required -macOS package failure remains unexplained, despite exact-package local triage. +but not independently accepted. Its first seven complete cumulative rounds are +NONCLEAN. R6's successful-MCP first-fault and canonical lifecycle corrections pass +R7 review. R7 identifies a required-control expectation defect: directly observed +Darwin group EPERM correctly retains infrastructure 9, while the smoke requires +0/1. The narrow control correction preserves the native fault and actual joins. +Fresh Windows advisory and local full-suite failures also expose fixture ownership +and late-case contamination; their corrections remain under author validation. +Older unexplained platform failures retain their historical scope. No failed run +is retrospectively accepted; current required platform/full-CI evidence stays open. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index c9996237..2c3906a0 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -291,3 +291,13 @@ preparation, driver and terminal ownership are independently accepted for 8c. Session lifetimes, final diagnostic acknowledgement and all-surface activation remain 8d. Whole Step 8 and later Wave 3 work remain open; no new decision, dependency or deferral is added. + +## Native-fault acceptance clarification — 2026-10-17 + +The [seventh cumulative Step 8d review](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-seventh-cumulative-review-and-native-outcome-correction--2026-10-17) +identifies a required-control expectation defect, not a change to this decision. +An actually observed owned-group signal refusal remains infrastructure 9 despite +an earlier successful request or later closure. The installed control distinguishes +that narrowly evidenced failure outcome from ordinary turn success while retaining +all actual-lifetime and cleanup obligations. Earlier failed runs stay failed; +fresh independent correctness/security/platform acceptance remains open. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 9d6acde8..76127b2a 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1188,7 +1188,17 @@ actual close joins and the original deadline, with permanent adverse controls. New Windows evidence passes; required macOS packaging remains failed and its initiating fault unresolved. Passive test-only native diagnostics preserve all original outcome/cleanup assertions. New clean reviews, security and exact-source -CI/platform gates remain required. Step 8d proceeds automatically. The +CI/platform gates remain required. The +[seventh cumulative review and native-outcome correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-seventh-cumulative-review-and-native-outcome-correction--2026-10-17) +records complete NONCLEAN review of 288 paths / 1,534 verified hunks at `86f0422a`. +Its one tooling finding concerns a directly observed Darwin group EPERM: the +product correctly retains infrastructure 9, but the installed smoke assumes 0/1. +The narrow validator correction requires exact fault/closure evidence and no +completed turn; it does not normalize EPERM or waive a historical failed run. +Separate Windows/local-suite triage identifies fixture close and late-case +ownership defects. Corrections are implemented with original deadlines and +assertions; fresh author, cumulative-review, security and required CI/platform +gates remain staged. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index e80247ca..69e40c55 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -6763,3 +6763,124 @@ remote acceptance remain open. At the previous source, wire/usage/secrets contro coverage and Node-floor/Windows advisories have passed while filesystem controls are still running; required macOS/packed aggregation remain failed. No whole-CI success or post-correction artifact evidence is inferred from that partial run. + +### Wave 3 Step 8d seventh cumulative review and native-outcome correction — 2026-10-17 + +**Implemented correction; independent acceptance remains open.** Three NEW +read-only reviewers assess the complete original `d9625ff0` base through +`86f0422ac1d5010986f89e48dc8ddcb8f7547936`. Root independently recomputes all +288 physical-file hashes, full zero-context diffs and 1,534 individual hunk hashes, +then verifies disjoint complete coverage: session/terminal 73 paths / 505 hunks, +workflow/management 132 / 791, native/tools/docs 83 / 238. R7 is **NONCLEAN**, with +one confirmed Medium tooling finding, `WNT-R7-1`; the other two complete slices +are clean. No clean slice is inherited as a new whole review. The local aggregate +SHA-256 is `b9f0144911010f50cec5ddabdc85c252dc6194e8f234e84e7cc5bcf37e337292`. +All seven historical complete rounds remain NONCLEAN. + +The required macOS job `117286651653`, run `38995305462`, at the reviewed source +now records the actual initiating native fact. The same explicitly created +detached group receives a successful SIGKILL, then a SIGKILL refusal with own +`EPERM`/`-1`; actual target close follows, then the final reap receives `ESRCH`/`-3`. +The passive diagnostic is complete, and product infrastructure 9 is retained. +Prior successful signaling or later absence cannot erase a real refusal. The +product's two final reaps and non-ESRCH failure rule remain unchanged. The +unconditional nonbootstrap smoke expectation rejects 9 before its remaining +postconditions run. Two independent readings and an actual-source protocol probe +corroborate this control defect. Current XNU source suggests a possible group-state +explanation, but the runner's exact kernel/member states are unverified; no +harmlessness, zombie-cause or flake conclusion is drawn. + +The [installed-package control](../../../tools/cli-package/README.md) now accepts only +that narrowly witnessed Darwin injected-error **failure outcome**: complete +three-signal evidence tied to the actual creation, exact infrastructure 9 with one +fixed `cli_internal` diagnostic, actual target/delivered closure and **zero** +completed turns. Fault-free cases retain 0/1 and one completed turn. All original +readiness, descendant absence, sibling survival, watchdog, timing, cleanup, +compiled-byte and forbidden-service assertions still execute. Missing/foreign/ +extra fault evidence or an unexplained 9 refuses. This is correct failure +classification, not ordinary success or retrospective acceptance of a failed CI +run. The permanent validator has thirty adverse vectors; a separate native-owner +preservation case pins sticky 9 through explicitly simulated native close without +adding a product fix. + +Root safely downloads the exact reviewed package, verifies GitHub ZIP digest +`2aed0e470de56b5948137d93249deef63679ffac7a3d57ff8433e63778007d14`, +tarball `7b20f20ba6d073fcb50f682e6a1cde7f19042e0dd2797e09c9733f59f39e9acc` +and all seventy installed files. A synthetic native-edge EPERM after an actual +successful group kill makes the original control fail semantically. It establishes +actual infrastructure cancellation with zero completed turns. The corrected +control then executes all three nonbootstrap modes, preserving every postcondition: +9 for the witnessed refusal and ordinary 0/0 for the other modes. A separate +unchanged-installation complete eight-case worker passes. Both retain byte identity +and actual owned cleanup; the installation is removed. These are local author +controls on Node 24.20.0, not fresh remote 24.21.0 or full-CI acceptance. An initial +experimental injection also affected an unrelated mode and is correctly rejected; +that invalid experiment is retained separately rather than counted as a pass. + +**Separate fixture-lifetime corrections.** Fresh Windows advisory job +`117286543561` fails a native DB readiness wait, the CLI native-writer case deadline +and the following root-alias expectation (0 expected, 2 observed). The required +Windows packed consumer passes at this source; it does not clear the advisory. +Three restricted actual-source/native probes establish pre-existing fixture +problems: error/exit was treated as child retirement, and a raw timed-out test can +enter the next case's mocked home. Holding that late native maintenance operation +reproduces `maintenance_active`/2 at the next root. This is a feasible source +schedule, not proof of the remote failure's exact cause. The local full workspace +run separately exposes two acquisition-test files: a first-case timeout, then +close count four instead of two, plus an acquisition admission wait failure. Two +restricted actual SQLite probes reproduce late-import reuse of the next mock and +preserve exact count two when the original case is sealed. + +Corrections retain the existing five-second readiness/case limits, one-second +admission wait, exact outcomes and close-count assertions. Native fixture cleanup +joins actual **close**, with runner-owned cleanup before home replacement. The +writer captures its original signal/home, seals after timeout and uses one +remaining case budget instead of renewed phase budgets. Bounded fixed phase, +exit/close and diagnostic-code facts improve future failure attribution. The DB +fixture's real started-child error regression requires cancellation and actual +close; restoring the erroneous error-as-ended guard fails its named semantic +assertion and byte-exact restoration is retained. Acquisition cases now retain +their raw scenario through runner cleanup, check the original captured signal +and an explicit cleanup seal after imports/before native admission, join pending +startup before mock reset and retain every exact native disposal capability. +Pure module transformation is prepared before the unchanged semantic case timer. +Deliberately unacknowledged failed-close product results remain pending; manual +harness disposal is never a product ACK. No product runtime or accepted decision +is changed by these fixture repairs. + +The ignored Windows triage report/evidence SHA-256 values are +`6edb030e22389ac53c85ea64fc056be9258a34cfdc34477ad26b82feb68a16db` / +`6d22bf164ac290e61a0280d8f9a195bc392457fab0244680f9aced89c07f711f`; +the acquisition triage values are +`af7f63d915e800280950cbd6f93a7d25b85b87b053f58a5cdbaf7abf5da877e3` / +`ac2f71b9a91e0d6e5aa7ce4c726ab71dff440bdbcca0aa6c4989aab99966df49`. +Their original sources and scope limits are preserved; they do not review the +subsequent author corrections. Initial author import/context setup mistakes are +repaired before the valid focused controls, not waived as successful executions. +Author validation continues on the corrected source. Historical red runs remain +red; no exact Windows readiness/root-cause diagnosis is inferred from local green. + +Two NEW complete cumulative clean rounds after the final material correction, +a NEW separate clean security sitting and fresh full CI plus all four actual +same-artifact consumers remain required. Whole Step 8 and Steps 9–19 remain open. +No ADR approval, user decision, billing, provider/keychain operation, capture, +production recipient or release upload is pending. Work continues automatically. + +**Final author gates for the R7 correction.** The corrected source passes all +seventeen workspace lint/typecheck tasks (fifteen cached) and eleven workspace +test tasks (nine cached). Fresh DB execution is **64 files / 1,062 passing tests**; +fresh CLI is **335 files / 5,413 passing tests / ten established local skips**. +All **547 acceptance-tool tests**, final tools lint/typecheck, schema sync, +seam/purity, 714-file test-isolation/ownership, seven build/format tasks +(five cached), 25-output compiled closure and 177 compiled bootstrap controls pass. +The earlier full-suite, context-setup and cleanup-lint failures remain retained +and repaired; they are not waived or counted as positive controls. + +The eleven-path source seal SHA-256 is +`c5e27edb6dce7c57dceca4abaa1590c7f6e263d0b0e55614b96f7f64350212ac`; +all sealed bytes are independently checked unchanged after final gates. The +separate eight-case exact-package author worker and selected native/fixture +removals retain the narrower scopes recorded above. Fresh required remote CI, +all four actual same-artifact platforms, two NEW clean cumulative reviews and a +NEW clean security sitting remain open. This author checkpoint grants no Step 8d, +whole Step 8 or later-increment acceptance. From 84e159e2e958ab614f2d8ef19404d7b3e07de16f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 06:51:58 +0300 Subject: [PATCH 124/222] test(cli): own timed-out coexistence work and native setup Capture exact SQLite close capabilities before setup and keep the raw case joined from runner cleanup. Bridge case cancellation to the real workflow stop before joining either writer; retain primary and secondary cleanup failures. Preserve five-second bounds and all existing semantics. Add real SQLite setup, held-build cancellation and cleanup-fault controls. The Windows advisory initiating delay remains unclassified. Refs: ADR-0110, ADR-0103, Wave 3 Step 8d Co-Authored-By: Codex --- apps/cli/src/harness/concurrency.e2e.test.ts | 388 +++++++++++++++---- 1 file changed, 322 insertions(+), 66 deletions(-) diff --git a/apps/cli/src/harness/concurrency.e2e.test.ts b/apps/cli/src/harness/concurrency.e2e.test.ts index 20ee8d56..67cfcc28 100644 --- a/apps/cli/src/harness/concurrency.e2e.test.ts +++ b/apps/cli/src/harness/concurrency.e2e.test.ts @@ -12,11 +12,13 @@ import { createRunHistoryStore, createSessionStore, runMigrations, + type DbClient, } from '@relavium/db'; import { RunEventSchema, type AgentSessionRecord, type SessionMessage } from '@relavium/shared'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { runCommand } from '../commands/run.js'; +import { buildEngine } from '../engine/build-engine.js'; import type { OpenedHistory } from '../history/open.js'; import type { GlobalOptions } from '../process/options.js'; import { captureIo } from '../test-support.js'; @@ -122,80 +124,334 @@ function runChild(args: readonly string[]): SpawnedChild { return { ready, done }; } -describe('concurrency e2e (2.5.I S3) — a run and a chat share one history.db', () => { - it('coexistence: a real run and a real chat session write the same file concurrently — both land', async () => { - const dir = mkdtempSync(join(tmpdir(), 'relavium-concurrency-')); - const dbPath = join(dir, 'history.db'); - const runClient = createClient(dbPath); - runMigrations(runClient.db); - const chatClient = createClient(dbPath); // a SEPARATE connection — the chat process's handle +interface CoexistencePorts { + readonly migrate?: typeof runMigrations; + readonly build?: typeof buildEngine; + readonly onClient?: (client: DbClient) => void; + readonly onDirectory?: (directory: string) => void; + readonly onResult?: (code: number, stdout: string, stderr: string) => void; +} + +/** Keep the raw case, not Vitest's timeout wrapper, owed until its actual cleanup completes. */ +let rawCase: Promise | undefined; +afterEach(async () => { + await rawCase?.catch(() => undefined); // The original test already observes its failure. + rawCase = undefined; +}); +function startCoexistenceCase(signal: AbortSignal, ports: CoexistencePorts = {}): Promise { + rawCase = Promise.resolve().then(() => coexistenceCase(signal, ports)); + return rawCase; +} + +async function coexistenceCase(signal: AbortSignal, ports: CoexistencePorts): Promise { + signal.throwIfAborted(); + const dir = mkdtempSync(join(tmpdir(), 'relavium-concurrency-')); + const dbPath = join(dir, 'history.db'); + const nativeCloses: Array<() => void> = []; + const phases: Array<{ readonly phase: string; readonly milliseconds: number }> = []; + const began = performance.now(); + const phase = (name: string): void => { + if (phases.length < 24) + phases.push({ phase: name, milliseconds: Math.round(performance.now() - began) }); + }; + let runWork: ReturnType | undefined; + let chatWork: Promise | undefined; + let cancelRun = (): void => {}; + let primary: { readonly cause: unknown } | undefined; + const cleanupFailures: unknown[] = []; + phase('case-enter'); + try { + ports.onDirectory?.(dir); + phase('run-open-enter'); + const runClient = createClient(dbPath, (close) => nativeCloses.push(close)); + ports.onClient?.(runClient); + phase('run-open-done'); + phase('migrations-enter'); + (ports.migrate ?? runMigrations)(runClient.db); + phase('migrations-done'); + signal.throwIfAborted(); + phase('chat-open-enter'); + const chatClient = createClient(dbPath, (close) => nativeCloses.push(close)); + ports.onClient?.(chatClient); + phase('chat-open-done'); const TURNS = 12; + const sessions = createSessionStore(chatClient.db); + const sessionId = 'sess-coexist'; + sessions.createSession(session(sessionId)); + + // A separate connection writes the transcript, yielding between actual writes. + chatWork = (async () => { + for (let seq = 0; seq < TURNS; seq += 1) { + signal.throwIfAborted(); + sessions.appendMessage(message(sessionId, seq)); + sessions.updateSession(session(sessionId, seq + 1)); + await Promise.resolve(); + } + phase('chat-writes-done'); + })(); + const openRunStore = (workflow: WorkflowDefinition): OpenedHistory => ({ + store: createRunHistoryStore(runClient.db, { + uuid: () => randomUUID(), + now: () => Date.now(), + workflow: { + slug: workflow.workflow.id, + name: workflow.workflow.name ?? workflow.workflow.id, + definitionJson: JSON.stringify(workflow), + }, + }), + db: runClient.db, + terminalOutboxPath: join(dir, 'terminal-outbox.ndjson'), + // This fixture closes the native connection only after both raw writers settle. + close: () => {}, + }); + const runIo = captureIo(); + phase('run-command-enter'); + runWork = runCommand( + { workflow: join(FIXTURES_DIR, 'sequential.relavium.yaml'), input: [], allowMcpStdio: [] }, + { + io: runIo.io, + global: globalOptions(), + openRunStore, + buildEngine: async (options) => { + phase('build-enter'); + const engine = await (ports.build ?? buildEngine)(options); + phase('build-done'); + return engine; + }, + subscribeSignals: (onSignal) => { + let requested = false; + const stop = (): void => { + if (requested) return; + requested = true; + phase('case-stop-request'); + onSignal(2); // The shipping WorkflowLifetime cooperative stop, not a forced close. + }; + cancelRun = stop; + signal.addEventListener('abort', stop, { once: true }); + if (signal.aborted) stop(); + return () => { + signal.removeEventListener('abort', stop); + cancelRun = () => {}; + }; + }, + }, + ).then((code) => { + phase('run-command-done'); + ports.onResult?.(code, runIo.out(), runIo.err()); + return code; + }); + const [runCode] = await Promise.all([runWork, chatWork]); + phase('both-writers-done'); + signal.throwIfAborted(); + // The run completed cleanly and its events persisted (stdout-pure --json contract). + expect(runCode).toBe(0); + expect(runIo.err()).toBe(''); + const runEvents = runIo + .out() + .trimEnd() + .split('\n') + .map((line) => RunEventSchema.parse(JSON.parse(line))); + expect(runEvents[0]?.type).toBe('run:started'); + expect(runEvents.at(-1)?.type).toBe('run:completed'); + + // The chat transcript persisted intact + the loadFull snapshot is consistent with the session totals. + const full = sessions.loadFull(sessionId); + expect(full?.messages.map((m) => m.sequenceNumber)).toEqual( + Array.from({ length: TURNS }, (_, i) => i), + ); + expect(full?.session.totalOutputTokens).toBe(TURNS); + phase('assertions-done'); + } catch (cause) { + primary = { cause }; + } finally { + if (primary !== undefined || signal.aborted) { + try { + cancelRun(); + } catch (cause) { + cleanupFailures.push(cause); + } + } + // Promise.all's first rejection, context abort and the test timeout are not writer joins. + const settled = await Promise.allSettled([runWork, chatWork]); + for (const result of settled) + if (result.status === 'rejected' && result.reason !== primary?.cause) + cleanupFailures.push(result.reason); + phase('raw-writers-joined'); + for (const close of nativeCloses) { + phase('native-close-enter'); + try { + close(); + phase('native-close-done'); + } catch (cause) { + cleanupFailures.push(cause); + } + } try { - const sessions = createSessionStore(chatClient.db); - const sessionId = 'sess-coexist'; - sessions.createSession(session(sessionId)); + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + phase('directory-removed'); + } catch (cause) { + cleanupFailures.push(cause); + } + if (primary !== undefined || cleanupFailures.length > 0 || signal.aborted) + console.error( + 'CONCURRENCY_FIXTURE_PHASES ' + + JSON.stringify({ phases, cleanupFailures: cleanupFailures.length }), + ); + } + if (primary !== undefined) { + if (cleanupFailures.length > 0) + throw new AggregateError( + [primary.cause, ...cleanupFailures], + 'Concurrency fixture failed and cleanup also failed.', + { cause: primary.cause }, + ); + throw primary.cause; + } + if (cleanupFailures.length > 0) + throw new AggregateError(cleanupFailures, 'Concurrency fixture cleanup failed.'); +} - // The chat side: append a transcript, yielding between turns so the run interleaves at await points. - const chatWrites = (async () => { - for (let seq = 0; seq < TURNS; seq += 1) { - sessions.appendMessage(message(sessionId, seq)); - sessions.updateSession(session(sessionId, seq + 1)); - await Promise.resolve(); - } - })(); +describe('concurrency e2e (2.5.I S3) — a run and a chat share one history.db', () => { + it('coexistence: a real run and a real chat session write the same file concurrently — both land', ({ + signal, + }) => startCoexistenceCase(signal)); - // The run side: the REAL `runCommand` persisting to its OWN connection on the same file. - const openRunStore = (workflow: WorkflowDefinition): OpenedHistory => ({ - store: createRunHistoryStore(runClient.db, { - uuid: () => randomUUID(), - now: () => Date.now(), - workflow: { - slug: workflow.workflow.id, - name: workflow.workflow.name ?? workflow.workflow.id, - definitionJson: JSON.stringify(workflow), + it('setup refusal closes the original native connection and removes its directory', async ({ + signal, + }) => { + const failure = new Error('synthetic migration setup refusal'); + const clients: DbClient[] = []; + let directory: string | undefined; + try { + await expect( + startCoexistenceCase(signal, { + onClient: (client) => clients.push(client), + onDirectory: (path) => { + directory = path; + }, + migrate: () => { + throw failure; }, }), - db: runClient.db, - // CR-92's outbox lives beside history.db; a fixture points it at the temp home so nothing - // touches the developer's ~/.relavium (ADR-0078 §4). - terminalOutboxPath: join(dir, 'terminal-outbox.ndjson'), - close: () => {}, - }); - const runIo = captureIo(); - const runPromise = runCommand( - { workflow: join(FIXTURES_DIR, 'sequential.relavium.yaml'), input: [], allowMcpStdio: [] }, - { io: runIo.io, global: globalOptions(), openRunStore }, - ); - - const [runCode] = await Promise.all([runPromise, chatWrites]); + ).rejects.toBe(failure); + expect(clients).toHaveLength(1); + expect(clients[0]?.sqlite.open, 'CONCURRENCY_SETUP_NATIVE_CLOSE').toBe(false); + expect(directory).toBeDefined(); + expect(directory !== undefined && existsSync(directory)).toBe(false); + } finally { + // Separate administrative recovery keeps a removal-control failure from leaking its real handle. + for (const client of clients) if (client.sqlite.open) client.sqlite.close(); + if (directory !== undefined) + rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }); - // The run completed cleanly and its events persisted (stdout-pure --json contract). - expect(runCode).toBe(0); - expect(runIo.err()).toBe(''); - const runEvents = runIo - .out() - .trimEnd() - .split('\n') - .map((line) => RunEventSchema.parse(JSON.parse(line))); - expect(runEvents[0]?.type).toBe('run:started'); - expect(runEvents.at(-1)?.type).toBe('run:completed'); + it('case abort seals a held real build and joins writers before native cleanup', async () => { + const controller = new AbortController(); + const reason = new Error('synthetic case abort'); + const clients: DbClient[] = []; + let directory: string | undefined; + let signalEntered = (): void => {}; + const entered = new Promise((resolve) => { + signalEntered = resolve; + }); + let release = (): void => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + let result: + | { readonly code: number; readonly stdout: string; readonly stderr: string } + | undefined; + let settled = false; + const work = startCoexistenceCase(controller.signal, { + onClient: (client) => clients.push(client), + onDirectory: (path) => { + directory = path; + }, + build: async (options) => { + signalEntered(); + await held; + return buildEngine(options); + }, + onResult: (code, stdout, stderr) => { + result = { code, stdout, stderr }; + }, + }); + // Observe the raw rejection immediately; it remains owed while the build is held. + const observed = work.then( + () => { + settled = true; + return undefined; + }, + (cause: unknown) => { + settled = true; + return cause; + }, + ); + try { + await Promise.race([ + entered, + observed.then((cause) => { + throw new Error('Synthetic build was not entered.', { cause }); + }), + ]); + controller.abort(reason); + await Promise.resolve(); + expect(settled).toBe(false); + expect(clients).toHaveLength(2); + expect(clients.every((client) => client.sqlite.open)).toBe(true); + expect(directory !== undefined && existsSync(directory)).toBe(true); + release(); + expect(await observed).toBe(reason); + expect(result, 'CONCURRENCY_ABORT_PREVENTS_RUN_ENTRY').toEqual({ + code: 1, + stdout: '', + stderr: '', + }); + expect(clients.every((client) => !client.sqlite.open)).toBe(true); + expect(directory !== undefined && existsSync(directory)).toBe(false); + } finally { + controller.abort(reason); + release(); + await observed; + } + }); - // The chat transcript persisted intact + the loadFull snapshot is consistent with the session totals. - const full = sessions.loadFull(sessionId); - expect(full?.messages.map((m) => m.sequenceNumber)).toEqual( - Array.from({ length: TURNS }, (_, i) => i), - ); - expect(full?.session.totalOutputTokens).toBe(TURNS); + it('cleanup failure retains the original setup fault and actual native close outcome', async ({ + signal, + }) => { + const primary = new Error('synthetic setup fault'); + const secondary = new Error('synthetic close fault after native release'); + const clients: DbClient[] = []; + let directory: string | undefined; + try { + const failure = await startCoexistenceCase(signal, { + onDirectory: (path) => { + directory = path; + }, + onClient: (client) => { + clients.push(client); + const close = client.sqlite.close.bind(client.sqlite); + client.sqlite.close = () => { + close(); + throw secondary; + }; + }, + migrate: () => { + throw primary; + }, + }).catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(AggregateError); + if (!(failure instanceof AggregateError)) throw new Error('Expected both fixture failures.'); + expect(failure.cause).toBe(primary); + expect(failure.errors).toEqual([primary, secondary]); + expect(clients).toHaveLength(1); + expect(clients[0]?.sqlite.open).toBe(false); + expect(directory !== undefined && existsSync(directory)).toBe(false); } finally { - try { - runClient.sqlite.close(); - } finally { - try { - chatClient.sqlite.close(); - } finally { - rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); - } - } + for (const client of clients) if (client.sqlite.open) client.sqlite.close(); + if (directory !== undefined) + rmSync(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } }); From 40086e71e2d6639269bb0d2ee86cac65c7cee7f3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 06:52:06 +0300 Subject: [PATCH 125/222] docs(docs): reconcile one-shot teardown and eighth review Qualify ordinary one-shot outcomes with infrastructure exit 9 and the creation-owned finite cleanup limits. Record the complete nonclean R8, independent physical package audit, separate Windows fixture triage and source-attributed author gates without accepting Step 8d. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- AGENTS.md | 18 +-- CLAUDE.md | 18 +-- docs/reference/cli/commands.md | 4 +- docs/roadmap/current.md | 12 +- .../phase-2.5.5-hardening-and-remediation.md | 123 ++++++++++++++++++ 5 files changed, 154 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5301217c..4445fa3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,15 +107,15 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first seven complete cumulative rounds are -NONCLEAN. R6's successful-MCP first-fault and canonical lifecycle corrections pass -R7 review. R7 identifies a required-control expectation defect: directly observed -Darwin group EPERM correctly retains infrastructure 9, while the smoke requires -0/1. The narrow control correction preserves the native fault and actual joins. -Fresh Windows advisory and local full-suite failures also expose fixture ownership -and late-case contamination; their corrections remain under author validation. -Older unexplained platform failures retain their historical scope. No failed run -is retrospectively accepted; current required platform/full-CI evidence stays open. +but not independently accepted. Its first eight complete cumulative rounds are +NONCLEAN. R8 accepts the earlier MCP/native-outcome and fixture corrections in its +reviewed scope, but finds a stale one-shot command summary. That canonical summary +is corrected alongside independently reproduced coexistence-fixture setup/timeout +ownership gaps; fresh reviews of these corrections remain required. All four +same-artifact package consumers pass at `09740575`; its separate Windows advisory +coexistence timeout remains failed and its exact cause unclassified. Original +five-second case bounds and semantic assertions are preserved. No historical +failure is retrospectively accepted; fresh final-source full CI remains required. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. diff --git a/CLAUDE.md b/CLAUDE.md index c98c8ba7..888b01f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,15 +196,15 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first seven complete cumulative rounds are -NONCLEAN. R6's successful-MCP first-fault and canonical lifecycle corrections pass -R7 review. R7 identifies a required-control expectation defect: directly observed -Darwin group EPERM correctly retains infrastructure 9, while the smoke requires -0/1. The narrow control correction preserves the native fault and actual joins. -Fresh Windows advisory and local full-suite failures also expose fixture ownership -and late-case contamination; their corrections remain under author validation. -Older unexplained platform failures retain their historical scope. No failed run -is retrospectively accepted; current required platform/full-CI evidence stays open. +but not independently accepted. Its first eight complete cumulative rounds are +NONCLEAN. R8 accepts the earlier MCP/native-outcome and fixture corrections in its +reviewed scope, but finds a stale one-shot command summary. That canonical summary +is corrected alongside independently reproduced coexistence-fixture setup/timeout +ownership gaps; fresh reviews of these corrections remain required. All four +same-artifact package consumers pass at `09740575`; its separate Windows advisory +coexistence timeout remains failed and its exact cause unclassified. Original +five-second case bounds and semantic assertions are preserved. No historical +failure is retrospectively accepted; fresh final-source full CI remains required. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 7eb52764..d2d03a8a 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -467,8 +467,8 @@ echo "review it" | relavium agent run code-reviewer --fixture ./fixtures/review. - `--allow-mcp-stdio ` (repeatable) authorizes a **local MCP program** for this invocation only. `agent run` is the one-shot, pipeline-facing member of the chat family — its stdin carries the prompt, so it can never prompt for consent, and an agent declaring an unapproved `stdio` MCP server exits `2` before anything spawns. See [Local MCP servers need consent](#local-mcp-servers-need-consent). - `--allow-fs-root` uses the [global invocation contract](#global-options), including when no selector config exists. - **The transcript is not persisted** — a stateless invoke (no transcript or resumable session; only an already-tombstoned durable effect-identity bookkeeping row), unlike the REPL. It does still **open `history.db`** to attach the effect journal ([effect-journal.md](../shared-core/effect-journal.md)): an external effect is carried forward by the target, not by the run, so an unattached journal would refuse every effectful tool on this surface. Its effect metadata is read at teardown for the owned committed-row sweep, never for replay. A `history.db` that cannot be opened fails the invocation. -- The exit code is the **turn's outcome**: `0` on success, `1` on a turn error; an invocation fault is `2`. It is **never** `4` (that is the interactive REPL's session-ended code). A termination **signal** exits `128+signo` (`130` on Ctrl-C) after tearing any MCP servers down — this surface has no cooperative-cancel contract to preserve, unlike `run` ([ADR-0088](../../decisions/0088-the-mcp-boundary-is-hostile.md) §1.3). -- **Ctrl-C leaves no orphan.** Any `stdio` MCP server this invocation spawned is reaped before the process exits, including on a `kill`, a closed terminal window, or a second impatient Ctrl-C. +- Ordinary exits reflect the **turn's outcome**: `0` on success, `1` on a classified turn error; an invocation fault is `2`. Unexpected infrastructure or output failure uses `9`. It is **never** `4` (the interactive REPL's session-ended code). A termination **signal** retains `128+signo` (`130` on Ctrl-C) unless a stronger observed failure applies; see [failure classification and remedies](#failure-classification-and-remedies). This one-shot surface has no workflow receipt/effect health authority for exits `5`–`8`. +- **Teardown stays with the original invocation.** Cancellation requests do not acknowledge MCP or database closure, including on a second Ctrl-C. The finite backstop preserves cleanup uncertainty; it cannot guarantee that every descendant is reaped. See [native crash teardown scope](#native-crash-teardown-scope) for actual-close, process-ownership and containment limits. ### `relavium provider` diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 76127b2a..512fa68d 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1198,7 +1198,17 @@ completed turn; it does not normalize EPERM or waive a historical failed run. Separate Windows/local-suite triage identifies fixture close and late-case ownership defects. Corrections are implemented with original deadlines and assertions; fresh author, cumulative-review, security and required CI/platform -gates remain staged. Step 8d proceeds automatically. The +gates remain staged. The +[eighth cumulative review and coexistence-fixture correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-eighth-cumulative-review-and-coexistence-fixture-correction--2026-10-17) +records complete NONCLEAN coverage of 293 paths / 1,578 independently verified +hunks at `09740575`. One current one-shot summary contradicts the implemented +exit 9/finite cleanup contract; its correction changes no runtime policy. +Separate actual-SQLite triage reproduces setup and timeout ownership gaps in the +coexistence fixture, with the original five-second deadline preserved. All four +same-artifact consumers pass at that reviewed source; its separate Windows +advisory timeout remains failed and its exact initiating delay unclassified. +Two NEW clean complete cumulative rounds, a NEW clean security sitting and fresh +final-source CI/platform evidence remain required. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 69e40c55..6a91ccca 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -6884,3 +6884,126 @@ removals retain the narrower scopes recorded above. Fresh required remote CI, all four actual same-artifact platforms, two NEW clean cumulative reviews and a NEW clean security sitting remain open. This author checkpoint grants no Step 8d, whole Step 8 or later-increment acceptance. + + +### Wave 3 Step 8d eighth cumulative review and coexistence-fixture correction — 2026-10-17 + +**R8 is complete and NONCLEAN; Step 8d and whole Step 8 remain open.** The root +read all three new cumulative reports and independently recomputed every physical +file, complete U0 diff and hunk identity at frozen `09740575`, from the original +Step 8 base `d9625ff0`. Coverage is disjoint and complete: **293 paths / 1,578 hunks**. +The inventory SHA-256 is +`aca454b00ff8b040f3b1880f1e98291c829c57a71bf270c26e1e3819ec96affe`; +the independently joined aggregate is +`5cfac59a82a137bfc1c762e69e21da36ea4ab54b9feddc9ea97dc0063a440736`. + +| Complete cumulative slice | Paths / hunks | Fresh execution, separately attributed | Verdict | +|---|---:|---|---| +| Session/terminal | 73 / 505 | 35 files / 697 passes, zero skips | CLEAN | +| Workflow/management | 134 / 809 | 37 files / 489 passes, one established non-Windows skip | CLEAN | +| Native/tools/docs | 86 / 264 | 19 source files / 228 passes; 101 tooling passes | NONCLEAN: WNT-R8-1 | + +Selections overlap between reviewers; no deduplicated combined test total is +claimed. These source/protocol executions do not certify operator TTYs, actual +Windows execution, full CI or the separately required security sitting. Report +SHA-256 values, in table order, are +`e61f634eca969f2bf689d5764781fa68d415ac2c929114b44204fa2b56b99c6a`, +`1720af1abf90314aecafda914c3b433070e838e349eebdd73f2b684c5ac56367` and +`ad15f1da99581e2f569c0ee29895d0de452e4d298691bfd6da78c88bb8b0b9ec`. +Their independently checked coverage-ledger hashes are +`feda555fbc16596bb51b0f62f58585d98f7d3764ae033eb4549bc9dcfddd3691`, +`1362e03eb660d1803d9e7711e59944d917d96aedecac790e1dac028b9e21652c` and +`6baf0efcd3594b4051cb655ed66b487ea3048ee345b96016054a298443afc579`. +Records remain local and Git-ignored. + +**WNT-R8-1, Medium, independently corroborated.** The current one-shot command +summary omitted infrastructure exit 9 and promised unconditional MCP reaping, +contradicting the implemented finite cleanup contract and the same canonical +file's detailed sections. The correction qualifies ordinary outcomes and links +[the one-shot contract](../../reference/cli/commands.md#relavium-agent-run) to +its sole failure/teardown authorities. It changes no product runtime policy and +introduces no ADR decision or universal descendant-containment claim. + +**Fresh physical package evidence at the reviewed source.** Push CI +`38998232222`, attempt 1, records successful pack, all four installed consumers +and required aggregation at `09740575`. The root downloaded and SHA-checked all +five GitHub ZIPs, bounded their extraction, independently inspected every tar +member and reran strict receipt aggregation against observed job/source/origin +metadata. The native reviewer separately inspected those physical bytes and +receipts. The actual tarball is **445,642 bytes / 70 public files**, SHA-256 +`f0c2d8abccaeed8df245bfb08c86da0faaf71d67e69bb68c678d91d452915b5b`. +Current Linux/macOS/Windows use Node 24.21.0; minimum Linux uses exact Node 22.0.0. +Each report binds eight installed cases and 177 bootstrap controls to that same +artifact. Root physical-audit SHA-256: +`7a0d38ff0cbd8904773b5d1fe0dd95790e3aab540080171953350025e91ca9e0`. +Inspection is not a new consumer execution or final-source acceptance. + +**Separate Windows advisory remains failed.** Job `117295420447` in that run +passes the previously repaired DB/media suites but times out the first +coexistence case at its original **5,000 ms** bound (reported duration 10,452 ms). +The cross-process case passes. The remote log contains no identifying phase; +SQLite contention, WebAssembly startup and scheduler delay are not established +causes. No flake, waiver or retrospective pass is inferred. + +A new independent ignored triage uses exact current source, actual file SQLite +and real `runCommand`. Six separately retained controls reproduce two fixture +gaps: setup before the cleanup scope leaks the first native connection on an +injected setup fault; Vitest timeout aborts its context but leaves the raw run +and both connections live. Explicit raw release/join permits the original run +assertions to pass after the timeout. Guarded setup closes the original handle +while retaining the same injected failure. A captured context-abort bridge to +shipping cooperative stop prevents engine entry, retains the failed timeout, +returns actual run 1 with no events and joins both closes. These local causal +facts do not explain the remote delay. The original source/probe/config/log +bytes are independently hash-verified; triage report/manifest SHA-256 values are +`11c31edbda2e3aadbd37550603ffb215ffa673644d9b3dd8d1d8a22fc0b1c124` / +`62bec05ae9782f5cc74280438003c7b3111eca01ebb108930e0e9becff9ca722`. +One preliminary duplicate baseline is explicitly excluded from the six controls. + +The author correction retains the raw case before entry and awaits it from +runner `afterEach`. Exact SQLite close capabilities are captured before factory +setup; migration and the second open remain within cleanup ownership. The test +context abort reaches the original cooperative run stop; rejection of one writer +still joins both actual writers before either native close. Fixed bounded phase +timings contain no path, transcript or raw failure text. Three permanent controls +pin actual setup cleanup, a held real build's cancellation/late settlement and +preservation of the original fault alongside a native cleanup failure. The +unchanged normal run, NDJSON, transcript and cross-process assertions remain. +Neither case/readiness bounds nor workload scheduling are extended. No speculative +WebAssembly warmup or serialization change is applied. Author gates and fresh +final-source Windows evidence are required; the initiating remote delay remains +unclassified. + +Two NEW complete cumulative clean rounds after the last material correction, a +NEW separate clean security sitting and fresh full CI plus all four actual +same-artifact platforms remain required. Historical R1–R8 and failed CI results +keep their source scopes. No Step 8d, whole Step 8 or later-step acceptance is +inferred. No user decision, ADR approval, credential, billing, provider capture, +production recipient or release upload is pending. Work continues automatically. + + +**Final author gates for the R8 correction.** All seventeen workspace lint/typecheck +tasks pass (fifteen cached); all eleven workspace test tasks pass (ten cached). +The fresh CLI run records **335 files / 5,416 passes / ten established local skips**. +Final build/format has seven successful tasks (five cached). Seam/engine-dependency +fences, 714-file isolation/ownership, 25-output/407-input/17-external compiled +closure, 177 bootstrap controls and compiled CLI smoke pass. No runtime source, +manifest, lockfile, tool or ADR body changes in this correction. A mistaken +nonexistent purity-script invocation and the initial test lint failure are retained +as invalid/failed attempts, then corrected before the final gates; neither is +counted as a pass. + +The final test-source seal is +`503966adb470b0d32db8039a1b52c36579f503ae6df6251ea56d1cabd86564f1`; +its exact source bytes are independently checked unchanged after all gates. Two +selected author removals independently disable actual native close and cooperative +stop delivery in private byte copies. Each breaks its precise named semantic +assertion; neither setup/timeout/nonzero alone certifies removal. The complete +five-case fixed and byte-restored files pass. Filtered removal runs each execute +one case with four explicit selection skips, not a full-file acceptance. Final +causal-result SHA-256: +`34ef08c1aceb2dffeef8ceaaf8b183363563316ebf479581902f05f5ceeb8fe7`. +Initial copy-config over-selection and a rejected non-unique mutation anchor are +excluded from that causal matrix. These author controls do not replace independent +reviews or explain the remote Windows timeout. Fresh final-source CI/platforms, +two NEW clean cumulative rounds and a NEW clean security sitting remain required. From 35b13d00cb3b10b1f99a301d69e9b682634f7d4f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 07:26:16 +0300 Subject: [PATCH 126/222] fix(cli): observe caught one-shot and home writer failures Observe captured sink refusal before passive bus isolation and seal later rendering and paid entry. Preserve the real turn error and original native, producer, usage and database joins. Report unexpected Home default-writer failure before picker recovery while retaining known config refusals. Thirteen permanent regressions pass; ten precise semantic assertions fail against the old runtime with zero runner unhandled errors. Workspace gates, build, compiled closure, bootstrap and CLI smoke pass. Refs: ADR-0110, ADR-0103, Wave 3 Step 8d Co-Authored-By: Codex --- apps/cli/src/commands/agent-run.test.ts | 128 +++++++++++++++++++++ apps/cli/src/commands/agent-run.ts | 75 +++++++++--- apps/cli/src/home/drive-home.test.ts | 69 +++++++++++ apps/cli/src/home/drive-home.tsx | 29 +++-- apps/cli/src/render/tui/home-controller.ts | 3 +- 5 files changed, 275 insertions(+), 29 deletions(-) diff --git a/apps/cli/src/commands/agent-run.test.ts b/apps/cli/src/commands/agent-run.test.ts index 8cc707cb..e222df26 100644 --- a/apps/cli/src/commands/agent-run.test.ts +++ b/apps/cli/src/commands/agent-run.test.ts @@ -26,6 +26,7 @@ import { fingerprint, resolveStdioSpawn } from '../engine/mcp-consent.js'; import type { GlobalOptions } from '../process/options.js'; import { captureIo, parseNdjson } from '../test-support.js'; import { agentRunCommand, readAllStdin, type AgentRunCommandDeps } from './agent-run.js'; +import { executeCommand } from './dispatch.js'; /** A tiny structural guard so an NDJSON event's nested `error.code` is read without an unsafe `as` cast. */ function isRecord(value: unknown): value is Record { @@ -583,6 +584,133 @@ describe('agentRunCommand (2.Q)', () => { const agentPath = (): string => join(cwd, 'coder.agent.yaml'); + it.each([ + ['direct', false, false], + ['direct', true, false], + ['dispatch', false, false], + ['dispatch', true, false], + ['direct', false, true], + ['direct', true, true], + ['dispatch', false, true], + ['dispatch', true, true], + ] as const)( + 'observes a refusing renderer through %s (json=%s, failed turn=%s) before the passive bus can hide it', + async (route, json, failedTurn) => { + const fixture = join(cwd, 'render-refusal.json'); + writeFileSync( + fixture, + JSON.stringify({ ...CASSETTE, calls: failedTurn ? [] : CASSETTE.calls }), + ); + const { d, control, out, err } = deps('synthetic prompt', { json }); + const failure = new Error('PRIVATE_CAPTURED_SINK_FAILURE'); + let rejectedWrites = 0; + const io = { + ...d.io, + writeOut: (text: string) => { + // A failed JSON turn reaches its real terminal event before this fault. The ordinary + // error must be recorded independently of rendering; successful turns refuse at first output. + if (json && failedTurn && !text.includes('"type":"session:turn_completed"')) { + d.io.writeOut(text); + return; + } + rejectedWrites++; + expect(control.registry.activeOwners).toBe(2); + throw failure; + }, + }; + const result = + route === 'direct' + ? await agentRunCommand( + { agent: agentPath(), input: [], allowMcpStdio: [], fixture }, + { ...d, io }, + ) + : await executeCommand( + 'agent.run', + { positionals: [agentPath()], options: { fixture } }, + { invocation: control.invocation, global: d.global, io }, + ); + expect(result, 'AGENT_PASSIVE_OUTPUT_FAILURE_REACHES_ORIGINAL_OWNER').toBe(9); + expect(control.registry.observations.infrastructureCode()).toBe('output_unavailable'); + expect(control.registry.budget.deadline()).toBe(3000); + expect(rejectedWrites, 'AGENT_OUTPUT_SEALS_BEFORE_CANCELLATION_REENTRY').toBe(1); + expect(control.registry.activeOwners).toBe(0); + expect(out() + err()).not.toContain(failure.message); + }, + ); + + it.each([false, true])( + 'keeps an ordinary failed turn at 1 through shipping dispatch (json=%s)', + async (json) => { + const fixture = join(cwd, 'healthy-output-failed-turn.json'); + writeFileSync(fixture, JSON.stringify({ ...CASSETTE, calls: [] })); + const { d, control, out, err } = deps('synthetic prompt', { json }); + expect( + await executeCommand( + 'agent.run', + { positionals: [agentPath()], options: { fixture } }, + { invocation: control.invocation, global: d.global, io: d.io }, + ), + ).toBe(1); + expect(out() + err()).not.toBe(''); + expect(control.registry.termination).toBeUndefined(); + expect(control.registry.observations.exitCode()).toBe(1); + expect(control.registry.activeOwners).toBe(0); + }, + ); + + it('seals paid entry after a start-render fault and retains the actual MCP and database join', async () => { + let release: () => void = () => undefined; + const held = new Promise((resolve) => { + release = resolve; + }); + const closeMcp = vi.fn(() => held); + let sendCalls = (): number => -1; + const { d, control, err } = deps('synthetic prompt', { + json: true, + providers: scriptedResolver([textTurn('must not egress')]), + onBuilt: (built) => { + const send = vi.spyOn(built.session, 'sendMessage'); + sendCalls = () => send.mock.calls.length; + }, + }); + const builder = d.buildSession ?? buildChatSession; + const writeOut = vi.fn(() => { + throw new Error('PRIVATE_START_RENDER_FAILURE'); + }); + let settled = false; + const action = agentRunCommand( + { agent: agentPath(), input: [], allowMcpStdio: [] }, + { + ...d, + io: { ...d.io, writeOut }, + buildSession: async (options) => ({ ...(await builder(options)), closeMcp }), + }, + ); + void action.then(() => { + settled = true; + }); + try { + await vi.waitFor(() => expect(closeMcp).toHaveBeenCalledOnce()); + expect(sendCalls(), 'AGENT_START_RENDER_REFUSAL_PREVENTS_TURN_ENTRY').toBe(0); + expect(writeOut).toHaveBeenCalledOnce(); + expect(control.registry.observations.exitCode()).toBe(9); + expect(control.registry.budget.deadline()).toBe(3000); + expect(control.registry.activeOwners).toBe(2); + expect( + productStores[0]?.db.$client.open, + 'AGENT_OUTPUT_FAILURE_RETAINS_NATIVE_DATABASE', + ).toBe(true); + expect(settled).toBe(false); + expect(err()).not.toContain('PRIVATE_START_RENDER_FAILURE'); + } finally { + release(); + await action; + } + expect(await action).toBe(9); + expect(control.registry.activeOwners).toBe(0); + expect(productStores[0]?.db.$client.open).toBe(false); + }); + it.each([false, true])( 'forwards native tool ownership before one-shot startup resolves (offline=%s)', async (offline) => { diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index 8c8c3140..651f5db3 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -2,6 +2,7 @@ import { acquireNativeResource } from '../process/acquire-native-resource.js'; import type { CliInvocation } from '../process/invocation.js'; import { SessionHostOwner } from '../chat/session-host-owner.js'; import { SessionSurfaceLifetime } from '../chat/session-surface-lifetime.js'; +import type { SessionWorkOwner } from '../chat/session-work-owner.js'; import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; import { randomUUID } from 'node:crypto'; import { StringDecoder } from 'node:string_decoder'; @@ -106,20 +107,7 @@ export async function agentRunCommand( { ...deps, invocation: lifetime.invocation, - io: { - ...deps.io, - // Best-effort retention may consume a warning refusal, but actual output remains an - // invocation-owned tail. Neither a pending delivery nor a broken sink is a completion ACK. - writeErrAcknowledged: (text) => - lifetime.work.work.retainWork(async () => { - try { - await deps.io.writeErrAcknowledged(text); - } catch (cause) { - lifetime.work.failure(cause); - throw cause; - } - }), - }, + io: ownOneShotIo(deps.io, lifetime.work), }, lifetime, (close) => { @@ -129,6 +117,41 @@ export async function agentRunCommand( ); } +/** Observe the actual captured sink before a passive listener or best-effort notice can consume its fault. */ +function ownOneShotIo(io: CliIo, owner: SessionWorkOwner): CliIo { + const failed = (cause: unknown): CliError => { + const failure = new CliError('output_unavailable', '', { cause }); + owner.failure(failure); + return failure; + }; + const write = (operation: () => void): void => { + owner.assertAdmitting(); + try { + operation(); + } catch (cause) { + throw failed(cause); + } + }; + const deliver = (operation: () => Promise): Promise => { + owner.assertAdmitting(); + // Retention notices may consume a rejection, but the original invocation still owes actual delivery. + return owner.work.retainWork(async () => { + try { + await operation(); + } catch (cause) { + throw failed(cause); + } + }); + }; + return { + ...io, + writeOut: (text) => write(() => io.writeOut(text)), + writeErr: (text) => write(() => io.writeErr(text)), + writeOutAcknowledged: (text) => deliver(() => io.writeOutAcknowledged(text)), + writeErrAcknowledged: (text) => deliver(() => io.writeErrAcknowledged(text)), + }; +} + async function agentRunOwned( args: AgentRunCommandArgs, deps: AgentRunCommandDeps, @@ -366,7 +389,9 @@ async function agentRunOwned( ); // Render the live stream + run the single turn + tear down — a classified turn failure maps to exit 1. hostOwner.work.assertAdmitting(); - const turnErrorCode = await hostOwner.work.enter(() => runOneShotTurn(built, message, deps)); + const turnErrorCode = await hostOwner.work.enter(() => + runOneShotTurn(built, message, deps, hostOwner.work), + ); return turnErrorCode === undefined ? EXIT_CODES.success : EXIT_CODES.workflowFailed; } catch (cause) { // Setup can fail before a turn root exists. Observe the original fault BEFORE awaiting host cleanup: @@ -437,8 +462,10 @@ async function runOneShotTurn( built: BuiltChatSession, message: string, deps: AgentRunCommandDeps, + owner: SessionWorkOwner, ): Promise { let turnErrorCode: string | undefined; + let renderingStopped = false; const renderer: (event: SessionStreamHandleEvent) => void = deps.global.json ? (event) => deps.io.writeOut(`${stringifyJsonLine(event)}\n`) : // A ONE-SHOT: the session is cancelled in `finally` right after, so suppress the session-continuity recovery @@ -448,10 +475,19 @@ async function runOneShotTurn( try { surfaceMcpSkipped(deps.io, built.mcpSkipped); unsubscribe = built.handle.subscribe((event) => { - renderer(event); if (event.type === 'session:turn_completed' && event.error !== undefined) { turnErrorCode = event.error.code; } + if (renderingStopped || owner.signal.aborted) return; + try { + owner.assertAdmitting(); + renderer(event); + } catch (cause) { + // The bus deliberately isolates passive subscribers. Observe here, before its warning-only + // handler can consume the fault; seal first because cancellation can synchronously emit again. + renderingStopped = true; + owner.failure(cause); + } }); // ADR-0057: `agent run` is a NON-interactive one-shot over the SAME full-capability chat-read-write host // (session-host.ts). There is no user to approve a governed action, so apply the fail-closed `ask` regime @@ -468,16 +504,19 @@ async function runOneShotTurn( prompt: nonInteractiveApprovalPrompt('in a one-shot agent run'), }); applyChatMode(modeEnv, 'ask'); + owner.assertAdmitting(); built.session.start(); + owner.assertAdmitting(); await built.session.sendMessage(message); + owner.assertAdmitting(); } catch (cause) { - built.workOwner?.failure(cause); + owner.failure(cause); throw cause; } finally { try { built.session.cancel(); } catch (cause) { - built.workOwner?.cleanupUncertain(cause); + owner.cleanupUncertain(cause); } finally { unsubscribe(); } diff --git a/apps/cli/src/home/drive-home.test.ts b/apps/cli/src/home/drive-home.test.ts index 81aca183..10c10241 100644 --- a/apps/cli/src/home/drive-home.test.ts +++ b/apps/cli/src/home/drive-home.test.ts @@ -1,4 +1,5 @@ import { createCliInvocation } from '../process/invocation.js'; +import { randomUUID } from 'node:crypto'; import { invocationFixture } from '../process/invocation.test-support.js'; import { holdNativeToolProcess, @@ -51,6 +52,7 @@ vi.mock('ink', async (importOriginal) => ({ ...(await importOriginal()), render: inkRenderer.render, })); +vi.mock('node:crypto', { spy: true }); // Regression for the `provider_auth` bug: the Home built an ENV-ONLY key resolver, so a key stored in the OS // keychain (the normal `relavium provider add` path) was invisible while `relavium chat` (keychain-wired) worked. @@ -138,6 +140,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { }); afterEach(() => { if (client.sqlite.open) client.sqlite.close(); + vi.restoreAllMocks(); }); const global: GlobalOptions = { @@ -616,6 +619,72 @@ describe('driveHome (2.5.B / ADR-0054)', () => { expect(await drivePromise).toBe(EXIT_CODES.success); }, 10000); + it.each(['unexpected', 'known-config-refusal'] as const)( + 'observes an owned /models default-write %s before inline picker recovery', + async (mode) => { + const f = invocationFixture(); + const configFile = join(cwd, 'owned-picker-config.toml'); + let captured: RootAppProps | undefined; + const close = vi.fn(() => client.sqlite.close()); + const { deps } = makeDeps( + (props) => { + captured = props; + }, + { + invocation: f.invocation, + global: { ...global, configPath: configFile }, + openSessionStore: () => ({ store: createSessionStore(client.db), db: client.db, close }), + }, + ); + const drive = driveHome(deps); + const props = captured; + if (props === undefined) throw new Error('Home did not mount'); + try { + props.controller.handleKey('/', {}); + type(props, 'models'); + props.controller.handleKey('', ENTER); + await flush(); + expect(props.controller.getSnapshot().modelPicker?.entries.length).toBeGreaterThan(0); + if (mode === 'unexpected') { + // This native entry is before the real writer's filesystem catch; no errno or arbitrary + // error message is granted configuration-refusal provenance. + vi.mocked(randomUUID).mockImplementationOnce(() => { + throw new Error('PRIVATE_HOME_CONFIG_INVARIANT'); + }); + } else writeFileSync(configFile, '[preferences\n'); + props.controller.handleKey('', ENTER); + if (props.controller.getSnapshot().modelPicker?.phase === 'effort') + props.controller.handleKey('', ENTER); + if (mode === 'unexpected') { + expect(randomUUID).toHaveBeenCalledOnce(); + expect( + f.registry.observations.exitCode(), + 'HOME_DEFAULT_WRITE_FAULT_REACHES_ORIGINAL_OWNER', + ).toBe(9); + expect(f.registry.budget.deadline()).toBe(3000); + expect(await drive).toBe(9); + } else { + expect(props.controller.getSnapshot().modelPicker?.hint).toContain('could not save'); + expect(f.registry.termination).toBeUndefined(); + props.controller.handleKey('', { escape: true }); + props.controller.handleKey('', { escape: true }); + expect(props.controller.getSnapshot().modelPicker).toBeUndefined(); + props.controller.handleKey('c', CTRL_C); + expect(await drive).toBe(0); + } + } finally { + // Failed semantic assertions still close the actual drive before the fixture closes SQLite. + props.controller.handleKey('', { escape: true }); + props.controller.handleKey('', { escape: true }); + props.controller.handleKey('c', CTRL_C); + await drive; + } + expect(close).toHaveBeenCalledOnce(); + expect(client.sqlite.open).toBe(false); + expect(f.registry.activeOwners).toBe(0); + }, + ); + it('a /models write is picked up by the NEXT chat started in the SAME Home process (2.5.G S7 regression)', async () => { // The blocker Sonnet review caught: `startChat` closed over the load-once `config` snapshot, so a `/models` // write never reached a same-process chat even though the notice claimed "applies to your next chat session". diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index f0a1962d..92730b89 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -588,16 +588,25 @@ async function driveHomeBody(deps: HomeDeps, ownership?: SessionHomeLifetime): P // Write to the SAME file the picker re-reads + the started session resolves (honors `--config`), so a `/models` // write is never a silent no-op to a different file (2.5.G S7). The effort rides the SAME atomic write; an // absent `reasoningEffort` (a non-reasoning model) leaves any prior effort default unchanged. - writeDefault: (modelId, provider, reasoningEffort) => - writeGlobalPreferences( - { - defaultModel: modelId, - defaultProvider: provider, // ADR-0059: persist the provider so the next chat skips id inference - ...(reasoningEffort === undefined ? {} : { reasoningEffort }), - }, - homeDir, - deps.global.configPath, - ), + writeDefault: (modelId, provider, reasoningEffort) => { + ownership?.surface.work.assertAdmitting(); + try { + writeGlobalPreferences( + { + defaultModel: modelId, + defaultProvider: provider, // ADR-0059: persist the provider so the next chat skips id inference + ...(reasoningEffort === undefined ? {} : { reasoningEffort }), + }, + homeDir, + deps.global.configPath, + ); + } catch (cause) { + // Known config refusals keep the picker's inline recovery. An unexpected fault must reach + // the original owner before that recovery consumes it and can otherwise permit clean exit. + ownership?.surface.work.failure(cause); + throw cause; + } + }, }; // The SHARED HomeChatSession wiring over a built (FRESH or RESUMED) session + its view store — the persister, diff --git a/apps/cli/src/render/tui/home-controller.ts b/apps/cli/src/render/tui/home-controller.ts index 1883a72c..875f2bee 100644 --- a/apps/cli/src/render/tui/home-controller.ts +++ b/apps/cli/src/render/tui/home-controller.ts @@ -801,7 +801,8 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { // The BARE-Home next-session-default write (ADR-0063 · ADR-0066 §6). Persists the GLOBAL `[preferences].default_model` // and — when the effort sub-step ran for a reasoning model — `[preferences].reasoning_effort` too, in ONE atomic - // write; a write fault keeps the picker open with a secret-free hint rather than crashing. + // write; a known config refusal keeps the picker open with a secret-free hint. The owned host observes + // unexpected writer faults before this UI recovery; an ownerless embedding retains its inline recovery. const writeNextSessionDefault = ( modelId: string, displayName: string, From 4c0fa5b7e17246289ab37e32d7616702d581d9d8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 07:26:21 +0300 Subject: [PATCH 127/222] docs(docs): record ninth review and reconcile signal summary Join all 293 reviewed paths and 1588 cumulative hunks, the two confirmed runtime corrections, semantic old-runtime controls and separately scoped platform evidence. Distinguish cooperative first workflow SIGINT from forced termination in the canonical exit summary. Step 8d remains open. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- AGENTS.md | 19 ++-- CLAUDE.md | 19 ++-- docs/reference/cli/commands.md | 9 +- docs/roadmap/current.md | 14 ++- .../phase-2.5.5-hardening-and-remediation.md | 99 +++++++++++++++++++ 5 files changed, 136 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4445fa3f..700c7ab9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,15 +107,16 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first eight complete cumulative rounds are -NONCLEAN. R8 accepts the earlier MCP/native-outcome and fixture corrections in its -reviewed scope, but finds a stale one-shot command summary. That canonical summary -is corrected alongside independently reproduced coexistence-fixture setup/timeout -ownership gaps; fresh reviews of these corrections remain required. All four -same-artifact package consumers pass at `09740575`; its separate Windows advisory -coexistence timeout remains failed and its exact cause unclassified. Original -five-second case bounds and semantic assertions are preserved. No historical -failure is retrospectively accepted; fresh final-source full CI remains required. +but not independently accepted. Its first nine complete cumulative rounds are +NONCLEAN. R9 independently verifies all 293 paths / 1,588 cumulative hunks and +reproduces two High caught-fault gaps: one-shot passive rendering and bare-Home +`/models` default writing. The corrections observe the original owner before UI/bus +recovery, preserve actual producer/native/accounting joins and keep known refusals +inline. The signal summary is reconciled with existing forced workflow termination. +All four same-artifact consumers and the Windows advisory pass at reviewed +`40086e71`; those results precede the R9 runtime corrections and are not final-source +acceptance. The earlier `09740575` Windows timeout remains failed with exact cause +unclassified. Original five-second bounds and semantic assertions are preserved. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. diff --git a/CLAUDE.md b/CLAUDE.md index 888b01f7..adbb3561 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,15 +196,16 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first eight complete cumulative rounds are -NONCLEAN. R8 accepts the earlier MCP/native-outcome and fixture corrections in its -reviewed scope, but finds a stale one-shot command summary. That canonical summary -is corrected alongside independently reproduced coexistence-fixture setup/timeout -ownership gaps; fresh reviews of these corrections remain required. All four -same-artifact package consumers pass at `09740575`; its separate Windows advisory -coexistence timeout remains failed and its exact cause unclassified. Original -five-second case bounds and semantic assertions are preserved. No historical -failure is retrospectively accepted; fresh final-source full CI remains required. +but not independently accepted. Its first nine complete cumulative rounds are +NONCLEAN. R9 independently verifies all 293 paths / 1,588 cumulative hunks and +reproduces two High caught-fault gaps: one-shot passive rendering and bare-Home +`/models` default writing. The corrections observe the original owner before UI/bus +recovery, preserve actual producer/native/accounting joins and keep known refusals +inline. The signal summary is reconciled with existing forced workflow termination. +All four same-artifact consumers and the Windows advisory pass at reviewed +`40086e71`; those results precede the R9 runtime corrections and are not final-source +acceptance. The earlier `09740575` Windows timeout remains failed with exact cause +unclassified. Original five-second bounds and semantic assertions are preserved. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index d2d03a8a..5aa154da 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -587,10 +587,11 @@ under [ADR-0106](../../decisions/0106-media-publication-requires-bounded-verifie ## Exit codes -CI relies on deterministic exit codes. The table is the closed set for `run` and `gate`; the signal codes -`128+signo` (`129` SIGHUP, `130` SIGINT, `131` SIGQUIT, `143` SIGTERM) belong to the surfaces that end **on** a -signal rather than draining through one — the interactive Home and `agent run`. `relavium run` is deliberately -not one of them: its Ctrl-C is a cooperative cancel that exits `1`. +CI relies on deterministic exit codes. The table covers ordinary `run` and `gate` outcomes. A first workflow +SIGINT requests cooperative cancellation, normally exiting `1` unless a stronger observation applies. A second +SIGINT or a terminating SIGHUP, SIGQUIT or SIGTERM uses `128+signo` (`130`, `129`, `131` or `143`), subject to +the [observed health and infrastructure precedence](#failure-classification-and-remedies). Home and `agent run` +also retain their [owned signal dispositions](#invocation-owners-and-fatal-ordering). | Code | Meaning | diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 512fa68d..862e7c31 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1207,8 +1207,18 @@ Separate actual-SQLite triage reproduces setup and timeout ownership gaps in the coexistence fixture, with the original five-second deadline preserved. All four same-artifact consumers pass at that reviewed source; its separate Windows advisory timeout remains failed and its exact initiating delay unclassified. -Two NEW clean complete cumulative rounds, a NEW clean security sitting and fresh -final-source CI/platform evidence remain required. Step 8d proceeds automatically. The +The [ninth cumulative review and caught-fault correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-ninth-cumulative-review-and-caught-fault-correction--2026-10-17) +records complete NONCLEAN coverage of 293 paths / 1,588 independently verified +hunks at `40086e71`: two High runtime findings in one-shot passive output and owned +Home default writing, plus a Medium signal-summary inconsistency. Corrections +observe original invocation failures before local recovery, seal further entry and +retain actual cleanup joins. New tests fail precisely against the old runtime; +known config refusals and ordinary failed turns retain their classified outcomes. +All four package consumers and the Windows advisory pass at the reviewed source, +which precedes these runtime corrections. The old unexplained Windows failure is +not retrospectively passed. Two NEW clean complete cumulative rounds, a NEW clean +security sitting and fresh final-source CI/platform evidence remain required. +Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 6a91ccca..ac82efc7 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -7007,3 +7007,102 @@ Initial copy-config over-selection and a rejected non-unique mutation anchor are excluded from that causal matrix. These author controls do not replace independent reviews or explain the remote Windows timeout. Fresh final-source CI/platforms, two NEW clean cumulative rounds and a NEW clean security sitting remain required. + +### Wave 3 Step 8d ninth cumulative review and caught-fault correction — 2026-10-17 + +**R9 is complete and NONCLEAN; Step 8d and whole Step 8 remain open.** The root +read all three new complete reports and independently recomputed every physical +file, full U0 diff and hunk from original Step 8 base `d9625ff0` to frozen +`40086e71`. Coverage is disjoint and complete: **293 paths / 1,588 hunks**. +Inventory SHA-256: +`2f5f938dca6c091169835b30dead1d8d5712c6ce37a08438f4af48a754548993`; +independently joined aggregate: +`bd36912c9c8ff457aa09c61a3a44cc3a8616930d8bd69862943a5826a23b0d1c`. + +| Complete cumulative slice | Paths / hunks | Fresh execution, separately attributed | Verdict | +|---|---:|---|---| +| Session/terminal | 73 / 505 | 35 complete files / 697 passes; separate actual Home/onboarding probe / two passes, zero skips | NONCLEAN: ST-R9-1, High | +| Workflow/management | 134 / 818 | 68 complete files / 1,276 passes, one established host skip; independent eight-case probe has six precise failures and two positive passes | NONCLEAN: R9-WM-01, High | +| Native/tools/docs | 86 / 265 | 18 source files / 191 passes; 101 tooling passes; four independent signal probes, zero skips | NONCLEAN: WNT-R9-1, Medium | + +Selections overlap; no combined deduplicated execution total is inferred. In +table order, report SHA-256 values are +`69efbdc45f67a337e529437c1c0f0cc233a4a04e6d3acd73c93e6e3d73e793ee`, +`371e07d09011adeaf07b11fc1c5b1be4f39123e4b0393d77b8f08c86258dbbca` and +`8d9fb2c8f4f3a1350984f37ce0d66cd2979532cb9d4ec077f4ae9d15de605b53`. +Checked coverage-ledger hashes are +`25b5eb65f629db004a268f0e88fe5d8ee6095aa42fd62fb526682935d9de975d`, +`a0511bc6f1ad4ddfdeccd5bf466510b605352d5d2857899ea055ed192f4b1373` and +`30bfc7da2c83dc8065a92d06d1be02662774108c2863836176674846bce1d9df`. +Records remain local and Git-ignored. Native evidence distinguishes fresh source +parents from existing-dist child fixtures. Failed reviewer setup attempts receive +no acceptance credit. Root replays have separate copies; reviewers regenerated +their final reports after an overwritten probe output path. + +**ST-R9-1, High, independently reproduced.** The real Home default writer can +throw unexpectedly before its filesystem refusal adapter. Inline picker recovery +consumed the fault without notifying the original owner, permitting Home exit 0. +The owned callback now checks admission and observes failure before UI recovery. +Known typed config refusals remain inline; ownerless compatibility is preserved. +Onboarding already observes the same fault correctly and needs no runtime change. + +**R9-WM-01, High, independently reproduced through shipping dispatch.** Passive +one-shot render faults were isolated by the event bus and reduced to warnings. +Failed rendering also prevented recording a turn error, changing failed-turn 1 +into success 0. Captured writes now classify actual sink refusal and notify the +original surface owner. The subscriber records the turn error independently, +seals rendering before cancellation reentry and observes its exceptions before +bus isolation. Mandatory host ownership and admission checks prevent turn entry +after a start-render fault. Actual producer, usage, MCP and native database joins +still precede retirement. No workflow health is manufactured. The proof uses an +injected captured-sink refusal with actual dispatch/cassette/SQLite, not OS EPIPE. + +**WNT-R9-1, Medium.** The canonical exit summary now distinguishes cooperative +first SIGINT from second SIGINT and terminating HUP/QUIT/TERM, linking existing +outcome precedence. No runtime signal policy changes. + +**Permanent and causal evidence.** Thirteen new cases cover direct and shipping +dispatch, plain and JSON output, successful and failed turns, known Home config +refusal, unexpected actual-writer failure and a held host-close/database join. +Two complete current-source files pass **113 tests**, zero skips. The same tests +against a private immutable `40086e71` archive produce exactly **ten named semantic +failures / 103 passes**, zero skips. Both executions have owned runner completion +receipts with **zero unhandled errors**. No tracked runtime mutation, timeout, +import failure or mere nonzero exit certifies causal proof. Control artifact: +`cc4ee289b92054b526a4219f079033090955518a13ae23e4f51ebdd6a454836f`. +Fixture finally blocks join actual drives even when semantic assertions fail. +An initial test-only generic mock type error was corrected; an unsupported Python +extraction option was a preparation failure. Neither receives passing credit. + +**Separately attributed reviewed-source platforms.** Push run `39000420576`, +attempt 1, passes pack, all four same-artifact consumers, required aggregation +and Windows advisory at `40086e71`. Root and native reviewer independently inspect +all five ZIPs, 70 public tar members and four bound receipts. Tarball: **445,642 +bytes**, SHA-256 +`3517a497fd5f1dca8d042cfefbc824b4fcb8394acb548e3d9b166f77ee28e283`; +physical audit: +`278d7aa1f8319b241b5767b9044de85b8673cd43c4f80563dbea7542e1d93e26`. +Actual Windows coexistence passes all five cases at the unchanged five-second +bound. These results precede the runtime corrections. The earlier `09740575` +timeout remains failed with exact cause unclassified; a later pass does not +explain it. Required broader CI retains its own observed status. + +Two NEW complete cumulative clean rounds after the last material correction, a +NEW separate clean security sitting and fresh final-source full CI plus all four +actual same-artifact platforms remain required. R1–R9 retain NONCLEAN verdicts. +No Step 8d, whole Step 8, later-step or whole-wave acceptance is inferred. No user +decision, ADR approval, credential, billing, provider capture, production recipient +or release upload is pending. Implementation continues automatically. + + +**Final author gates for the R9 correction.** All seventeen workspace lint/typecheck +tasks pass (fifteen cached); all eleven workspace test tasks pass (ten cached). +The fresh CLI run records **335 files / 5,429 passes / ten established local skips**. +Build/format passes all seven tasks (six cached). Seam and engine-dependency +fences, 714-file isolation/ownership, 25-output/407-input/17-external compiled +closure, 177 bootstrap controls and compiled CLI smoke pass. Product/test source +and the canonical exit summary are sealed separately from append-only status +records; seal SHA-256: +`4ba3e9799c4f2382256425fdc2c83b1fb76c0befae899c5ca5662028d884aced`. +Source pins are checked unchanged after the gates. This does not replace the +required new independent reviews, separate security or final-source CI/platforms. From 8b6d5d79a497454cd3ec16110e42d5d34a6962cc Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 08:26:59 +0300 Subject: [PATCH 128/222] fix(cli): preserve original owners through caught session faults Observe passive Home/chat output and native editor preparation before local recovery can consume unexpected failures. Preserve original checkpoint causes and typed history refusals, separate export clock errors from auxiliary writes, and join filesystem fixtures before releasing their native state and roots. Add permanent native/offline regressions and old-runtime semantic controls. Known inline refusals, original five-second bounds and actual cleanup joins remain. Refs: ADR-0110, ADR-0103, Wave 3 Step 8d Co-Authored-By: Codex --- .../cli/src/chat/session-presentation.test.ts | 106 ++++ apps/cli/src/chat/session-presentation.ts | 59 +++ apps/cli/src/commands/agent-run.ts | 38 +- .../chat-editor-native-failure.test.ts | 163 +++++++ .../src/commands/chat-export-lifetime.test.ts | 131 +++++ apps/cli/src/commands/chat-export.test.ts | 15 +- apps/cli/src/commands/chat-export.ts | 5 +- .../src/commands/chat-passive-failure.test.ts | 170 +++++++ apps/cli/src/commands/chat.ts | 29 +- .../commands/fs-workspace-admission.test.ts | 454 ++++++++++++------ .../src/commands/gate-checkpoint-fold.test.ts | 172 +++++++ .../commands/gate-checkpoint-native.test.ts | 178 +++++++ apps/cli/src/commands/gate.ts | 41 +- apps/cli/src/home/drive-home.test.ts | 81 +++- apps/cli/src/home/drive-home.tsx | 14 +- apps/cli/src/process/errors.test.ts | 23 + apps/cli/src/process/errors.ts | 9 + apps/cli/src/process/owned-storage.ts | 19 + .../src/render/editor-native-lifetime.test.ts | 191 +++++--- apps/cli/src/render/editor.test.ts | 143 ++++++ apps/cli/src/render/editor.ts | 74 ++- apps/cli/src/render/tui/chat-ink.tsx | 5 +- 22 files changed, 1821 insertions(+), 299 deletions(-) create mode 100644 apps/cli/src/chat/session-presentation.test.ts create mode 100644 apps/cli/src/chat/session-presentation.ts create mode 100644 apps/cli/src/commands/chat-editor-native-failure.test.ts create mode 100644 apps/cli/src/commands/chat-export-lifetime.test.ts create mode 100644 apps/cli/src/commands/chat-passive-failure.test.ts create mode 100644 apps/cli/src/commands/gate-checkpoint-fold.test.ts create mode 100644 apps/cli/src/commands/gate-checkpoint-native.test.ts diff --git a/apps/cli/src/chat/session-presentation.test.ts b/apps/cli/src/chat/session-presentation.test.ts new file mode 100644 index 00000000..a81e53ec --- /dev/null +++ b/apps/cli/src/chat/session-presentation.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { SessionStreamHandleEvent } from '@relavium/core'; + +import { CliError } from '../process/errors.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import { ownSessionOutput, ownSessionSubscriber } from './session-presentation.js'; +import { SessionWorkOwner } from './session-work-owner.js'; + +const event: SessionStreamHandleEvent = { + type: 'session:started', + sessionId: 'presentation', + agentRef: 'fixture', + model: 'offline', + context: { workingDir: '/synthetic', fsScopeTier: 'sandboxed' }, + timestamp: '2026-10-17T00:00:00.000Z', + sequenceNumber: 1, +}; + +describe('owned passive session presentation', () => { + it('seals before failure cancellation reenters the passive callback', async () => { + const f = invocationFixture(); + let subscriber: (event: SessionStreamHandleEvent) => void = () => {}; + const owner = new SessionWorkOwner(f.invocation, 'chat', { + cancel: () => subscriber(event), + cleanup: () => Promise.resolve(), + }); + const cause = new TypeError('PRIVATE_VIEW_FAULT'); + const consume = vi.fn(() => { + throw cause; + }); + subscriber = ownSessionSubscriber(owner, consume); + try { + subscriber(event); + subscriber(event); + expect(consume).toHaveBeenCalledOnce(); + expect(owner.firstFailure?.cause).toBe(cause); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.termination).toBeDefined(); + } finally { + await owner.close(); + } + expect(f.registry.activeOwners).toBe(0); + }); + + it('preserves ownerless callback identity and thrown-cause compatibility', () => { + const cause = new Error('legacy callback'); + const consume = () => { + throw cause; + }; + expect(ownSessionSubscriber(undefined, consume)).toBe(consume); + expect(() => ownSessionSubscriber(undefined, consume)(event)).toThrow(cause); + const { io } = captureIo(); + expect(ownSessionOutput(io, undefined)).toBe(io); + }); + + it('keeps a classified inline callback refusal from inventing fatal health', async () => { + const f = invocationFixture(); + const owner = new SessionWorkOwner(f.invocation, 'chat', { + cancel: () => {}, + cleanup: () => Promise.resolve(), + }); + const consume = vi.fn(() => { + throw new CliError('invalid_invocation', 'known refusal'); + }); + try { + const subscribe = ownSessionSubscriber(owner, consume); + subscribe(event); + subscribe(event); + expect(consume).toHaveBeenCalledOnce(); + expect(f.registry.termination).toBeUndefined(); + expect(owner.firstFailure).toBeUndefined(); + } finally { + await owner.close(); + } + }); + + it('retains actual acknowledged output after fatal stop and never uses business admission for restoration', async () => { + const f = invocationFixture(); + const cleanup = vi.fn(() => Promise.resolve()); + const owner = new SessionWorkOwner(f.invocation, 'chat', { cancel: () => {}, cleanup }); + const captured = captureIo(); + let reject: (cause: unknown) => void = () => {}; + const raw = new Promise((_yes, no) => { + reject = no; + }); + const output = ownSessionOutput({ ...captured.io, writeOutAcknowledged: () => raw }, owner); + const delivered = output.writeOutAcknowledged('PRIVATE_OUTPUT').then( + () => undefined, + (cause: unknown) => cause, + ); + owner.failure(new TypeError('separate failure')); + const closing = owner.close(); + await Promise.resolve(); + expect(cleanup).not.toHaveBeenCalled(); + captured.io.writeOut('RESTORE'); + expect(captured.out()).toBe('RESTORE'); + expect(() => output.writeOut('LATE_BUSINESS')).toThrow(); + reject(new Error('PRIVATE_WRITE_FAULT')); + const failure = await delivered; + expect(failure).toMatchObject({ code: 'output_unavailable', exitCode: 9 }); + await closing; + expect(cleanup).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + }); +}); diff --git a/apps/cli/src/chat/session-presentation.ts b/apps/cli/src/chat/session-presentation.ts new file mode 100644 index 00000000..241e7992 --- /dev/null +++ b/apps/cli/src/chat/session-presentation.ts @@ -0,0 +1,59 @@ +import type { SessionStreamHandleEvent } from '@relavium/core'; + +import { CliError } from '../process/errors.js'; +import type { CliIo } from '../process/io.js'; +import type { SessionWorkOwner } from './session-work-owner.js'; + +/** Observe before passive bus isolation; seal before cancellation can synchronously emit again. */ +export function ownSessionSubscriber( + owner: SessionWorkOwner | undefined, + consume: (event: SessionStreamHandleEvent) => void, +): (event: SessionStreamHandleEvent) => void { + if (owner === undefined) return consume; + let stopped = false; + return (event) => { + if (stopped || owner.signal.aborted) return; + try { + owner.assertAdmitting(); + consume(event); + } catch (cause) { + stopped = true; + owner.failure(cause); + } + }; +} + +/** Business output only. Entered terminal restoration retains the original captured capabilities. */ +export function ownSessionOutput(io: CliIo, owner: SessionWorkOwner | undefined): CliIo { + if (owner === undefined) return io; + const failed = (cause: unknown): CliError => { + const failure = new CliError('output_unavailable', '', { cause }); + owner.failure(failure); + return failure; + }; + const write = (operation: () => void): void => { + owner.assertAdmitting(); + try { + operation(); + } catch (cause) { + throw failed(cause); + } + }; + const deliver = (operation: () => Promise): Promise => { + owner.assertAdmitting(); + return owner.work.retainWork(async () => { + try { + await operation(); + } catch (cause) { + throw failed(cause); + } + }); + }; + return { + ...io, + writeOut: (text) => write(() => io.writeOut(text)), + writeErr: (text) => write(() => io.writeErr(text)), + writeOutAcknowledged: (text) => deliver(() => io.writeOutAcknowledged(text)), + writeErrAcknowledged: (text) => deliver(() => io.writeErrAcknowledged(text)), + }; +} diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index 651f5db3..637600c3 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -1,3 +1,4 @@ +import { ownSessionOutput } from '../chat/session-presentation.js'; import { acquireNativeResource } from '../process/acquire-native-resource.js'; import type { CliInvocation } from '../process/invocation.js'; import { SessionHostOwner } from '../chat/session-host-owner.js'; @@ -107,7 +108,7 @@ export async function agentRunCommand( { ...deps, invocation: lifetime.invocation, - io: ownOneShotIo(deps.io, lifetime.work), + io: ownSessionOutput(deps.io, lifetime.work), }, lifetime, (close) => { @@ -117,41 +118,6 @@ export async function agentRunCommand( ); } -/** Observe the actual captured sink before a passive listener or best-effort notice can consume its fault. */ -function ownOneShotIo(io: CliIo, owner: SessionWorkOwner): CliIo { - const failed = (cause: unknown): CliError => { - const failure = new CliError('output_unavailable', '', { cause }); - owner.failure(failure); - return failure; - }; - const write = (operation: () => void): void => { - owner.assertAdmitting(); - try { - operation(); - } catch (cause) { - throw failed(cause); - } - }; - const deliver = (operation: () => Promise): Promise => { - owner.assertAdmitting(); - // Retention notices may consume a rejection, but the original invocation still owes actual delivery. - return owner.work.retainWork(async () => { - try { - await operation(); - } catch (cause) { - throw failed(cause); - } - }); - }; - return { - ...io, - writeOut: (text) => write(() => io.writeOut(text)), - writeErr: (text) => write(() => io.writeErr(text)), - writeOutAcknowledged: (text) => deliver(() => io.writeOutAcknowledged(text)), - writeErrAcknowledged: (text) => deliver(() => io.writeErrAcknowledged(text)), - }; -} - async function agentRunOwned( args: AgentRunCommandArgs, deps: AgentRunCommandDeps, diff --git a/apps/cli/src/commands/chat-editor-native-failure.test.ts b/apps/cli/src/commands/chat-editor-native-failure.test.ts new file mode 100644 index 00000000..bacae769 --- /dev/null +++ b/apps/cli/src/commands/chat-editor-native-failure.test.ts @@ -0,0 +1,163 @@ +import { mkdtemp } from 'node:fs/promises'; +import { pendingTempDirCount } from '../render/editor.js'; +vi.mock('node:fs/promises', { spy: true }); +import { mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createClient, createSessionStore, runMigrations } from '@relavium/db'; +import { AgentSchema } from '@relavium/shared'; +import { expect, it, vi } from 'vitest'; +import { chatResumeCommand, type ChatDriveContext } from './chat.js'; +import { driveInk } from '../render/tui/chat-ink.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { acknowledgeWrite, type CliIo } from '../process/io.js'; +import { scriptedResolver, textTurn } from '../chat/test-support.js'; +import { OwnedTtyInput, OwnedTtyOutput } from '../test-support.js'; +const flush = () => new Promise((r) => setImmediate(r)); +it('actual owned /edit native temp creation fault versus missing editor control', async () => { + for (const faulted of [false, true]) { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-r10-subscriber-'))); + const client = createClient(':memory:'); + runMigrations(client.db); + const store = createSessionStore(client.db); + const f = invocationFixture(); + const input = new OwnedTtyInput(); + const output = new OwnedTtyOutput(); + const stderr = new OwnedTtyOutput(); + let closed = 0; + let faultHits = 0; + let durableReplies = 0; + let id = 0; + const agent = AgentSchema.parse({ + id: 'reviewer', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + system_prompt: 'offline', + }); + const iso = '2026-10-17T00:00:00.000Z'; + store.createSession({ + id: 'review-session', + agentSlug: agent.id, + agentSnapshot: agent, + context: { workingDir: dir, fsScopeTier: 'sandboxed' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: iso, + updatedAt: iso, + }); + const out: string[] = []; + const err: string[] = []; + const io: CliIo = { + env: faulted ? { EDITOR: 'review-must-not-spawn' } : {}, + stdoutIsTty: true, + stdinIsTty: true, + stdin: input, + nativeStdin: input, + nativeStdout: output, + nativeStderr: stderr, + writeOut: (text) => { + out.push(text); + }, + writeErr: (text) => { + err.push(text); + }, + writeOutAcknowledged: async (text) => { + await acknowledgeWrite(output, text); + }, + writeErrAcknowledged: (text) => { + err.push(text); + return Promise.resolve(); + }, + }; + try { + const result = await chatResumeCommand( + { sessionId: 'review-session' }, + { + invocation: f.invocation, + io, + global: { + cwd: dir, + json: false, + color: false, + configPath: join(dir, 'absent.toml'), + verbosity: 'normal', + }, + providers: scriptedResolver([textTurn('SUBSCRIBER_REPLY')]), + now: () => 1750000000000, + uuid: () => `r10-${++id}`, + openSessionStore: () => ({ + db: client.db, + store, + close: () => { + durableReplies = + store.loadFull('review-session')?.messages.filter((m) => m.role === 'assistant') + .length ?? 0; + closed++; + client.sqlite.close(); + }, + }), + lifecycle: { + onProcessExit: () => () => {}, + onInterrupt: () => () => {}, + onTerminationSignal: () => () => {}, + setRawMode: () => {}, + exit: () => {}, + }, + jobControlLifecycle: { + supported: false, + onSuspend: () => () => {}, + onContinue: () => () => {}, + suspendSelf: () => {}, + }, + drive: async (ctx: ChatDriveContext) => { + const beforeTemp = pendingTempDirCount(); + const driving = driveInk(ctx); + try { + await flush(); + await flush(); + await ctx.processLine('hello'); + await flush(); + if (faulted) + vi.mocked(mkdtemp).mockImplementationOnce(() => { + faultHits++; + return Promise.reject(new TypeError('synthetic native temp invariant')); + }); + await ctx.processLine('/edit'); + await flush(); + expect(f.registry.termination !== undefined).toBe(faulted); + expect(pendingTempDirCount()).toBe(beforeTemp); + if (!faulted) + expect( + ctx.store + .getSnapshot() + .state.transcript.some((entry) => entry.text?.includes('set $EDITOR')), + ).toBe(true); + } finally { + input.push('\x03'); + await flush(); + await driving; + } + return driving; + }, + }, + ); + expect(result).toBe(faulted ? 9 : 4); + expect(closed).toBe(1); + expect(durableReplies).toBe(1); + expect(f.registry.termination !== undefined).toBe(faulted); + expect(f.registry.activeOwners).toBe(0); + expect(faultHits).toBe(faulted ? 1 : 0); + expect(err.join('')).not.toContain('background handler failed'); + } finally { + vi.restoreAllMocks(); + input.destroy(); + output.destroy(); + stderr.destroy(); + if (client.sqlite.open) client.sqlite.close(); + rmSync(dir, { recursive: true, force: true }); + } + } +}); diff --git a/apps/cli/src/commands/chat-export-lifetime.test.ts b/apps/cli/src/commands/chat-export-lifetime.test.ts new file mode 100644 index 00000000..dbc416c1 --- /dev/null +++ b/apps/cli/src/commands/chat-export-lifetime.test.ts @@ -0,0 +1,131 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createClient, createSessionStore, runMigrations } from '@relavium/db'; +import { AgentSchema } from '@relavium/shared'; +import { expect, it, vi } from 'vitest'; +import { chatExportCommand } from './chat-export.js'; +import { CommandLifetime } from '../process/command-lifetime.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { CliError, toUserFacing } from '../process/errors.js'; +import { captureIo } from '../test-support.js'; + +it.each(['unexpected_native_clock', 'known_storage_refusal'] as const)( + 'owned post-write provenance: %s', + async (mode) => { + const dir = mkdtempSync(join(tmpdir(), 'relavium-r10-export-')); + const client = createClient(':memory:'); + let closed = false; + try { + runMigrations(client.db); + const store = createSessionStore(client.db); + const iso = '2026-10-17T00:00:00.000Z'; + store.createSession({ + id: 'r10-export', + agentSlug: 'reviewer', + agentSnapshot: AgentSchema.parse({ + id: 'reviewer', + provider: 'anthropic', + model: 'fixture', + system_prompt: 'offline', + }), + context: { workingDir: dir, fsScopeTier: 'sandboxed' }, + status: 'active', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: iso, + updatedAt: iso, + }); + const captured = captureIo(); + const fixture = invocationFixture(); + const lifetime = new CommandLifetime(fixture.invocation, captured.io, () => () => {}); + let rawCause: unknown; + // eslint-disable-next-line @typescript-eslint/unbound-method -- The captured native method is explicitly invoked with its original Date receiver below. + const nativeToISOString = Date.prototype.toISOString; + const timestampObserver = vi + .spyOn(Date.prototype, 'toISOString') + .mockImplementation(function (this: Date) { + try { + return nativeToISOString.call(this); + } catch (cause) { + rawCause = cause; + throw cause; + } + }); + let clockCalls = 0; + let updateCalls = 0; + const actualUpdate = store.updateSession.bind(store); + store.updateSession = (record) => { + updateCalls += 1; + if (mode === 'known_storage_refusal') { + rawCause = new CliError('storage_unavailable', ''); + throw rawCause; + } + actualUpdate(record); + }; + let failed: unknown; + const result = await lifetime + .execute(() => + Promise.resolve( + chatExportCommand( + { sessionId: 'r10-export', force: false }, + { + commandLifetime: lifetime, + io: lifetime.io, + global: { + cwd: dir, + json: false, + color: false, + configPath: undefined, + verbosity: 'normal', + }, + now: () => { + clockCalls += 1; + if (mode === 'unexpected_native_clock') { + // The actual production update timestamp invokes native toISOString with this clock value. + return Number.NaN; + } + return Date.parse(iso); + }, + openSessionStore: (_home, captureClose) => { + const close = () => { + client.sqlite.close(); + closed = true; + }; + captureClose?.(close); + return { store, db: client.db, close }; + }, + }, + ), + ), + ) + .catch((cause: unknown) => { + failed = cause; + return toUserFacing(cause).exitCode; + }); + expect(readFileSync(join(dir, 'r10-export.relavium.yaml'), 'utf8')).toContain('reviewer'); + expect(captured.err().includes('could not mark the session exported')).toBe( + mode === 'known_storage_refusal', + ); + expect(result).toBe(mode === 'unexpected_native_clock' ? 9 : 0); + expect(fixture.registry.termination !== undefined).toBe(mode === 'unexpected_native_clock'); + if (mode === 'unexpected_native_clock') + expect(toUserFacing(failed).code).toBe('cli_internal'); + expect(fixture.registry.activeOwners).toBe(0); + expect(closed).toBe(true); + expect(updateCalls).toBe(mode === 'unexpected_native_clock' ? 0 : 1); + expect(clockCalls).toBe(1); + expect(toUserFacing(rawCause).code).toBe( + mode === 'unexpected_native_clock' ? 'cli_internal' : 'storage_unavailable', + ); + if (mode === 'unexpected_native_clock') expect(rawCause).toBeInstanceOf(RangeError); + timestampObserver.mockRestore(); + } finally { + vi.restoreAllMocks(); + if (!closed) client.sqlite.close(); + rmSync(dir, { recursive: true, force: true }); + } + }, +); diff --git a/apps/cli/src/commands/chat-export.test.ts b/apps/cli/src/commands/chat-export.test.ts index 734449f8..f1b86e96 100644 --- a/apps/cli/src/commands/chat-export.test.ts +++ b/apps/cli/src/commands/chat-export.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { createClient, createSessionStore, runMigrations, type DbClient } from '@relavium/db'; import { AgentSchema, type AgentSessionRecord, type SessionMessage } from '@relavium/shared'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { OpenedSessionStore } from '../history/session-open.js'; import { EXIT_CODES } from '../process/exit-codes.js'; @@ -139,6 +139,19 @@ describe('chatExportCommand (2.P)', () => { expect(opened.store.loadFull('s1')?.session.status).toBe('active'); }); + it('preserves the written artifact but does not swallow a native invalid-timestamp fault as provenance refusal', () => { + const { io, out, err } = captureIo(); + const update = vi.spyOn(opened.store, 'updateSession'); + expect(() => + chatExportCommand({ sessionId: 's1', force: false }, { ...deps(io), now: () => Number.NaN }), + ).toThrow(RangeError); + expect(update).not.toHaveBeenCalled(); + expect(existsSync(join(cwd, 's1.relavium.yaml'))).toBe(true); + expect(err()).not.toContain('could not mark the session exported'); + expect(out()).toBe(''); + update.mockRestore(); + }); + it('writes the scaffold, marks the session exported, and prints the path (exit 0)', () => { const { io, out } = captureIo(); expect(chatExportCommand({ sessionId: 's1', force: false }, deps(io))).toBe(EXIT_CODES.success); diff --git a/apps/cli/src/commands/chat-export.ts b/apps/cli/src/commands/chat-export.ts index 2d7290a7..dba10750 100644 --- a/apps/cli/src/commands/chat-export.ts +++ b/apps/cli/src/commands/chat-export.ts @@ -76,12 +76,13 @@ export function chatExportCommand( // session (no concurrent persister), unlike the in-REPL `/export` which deliberately does not mark the row. // The file write is the durable contract, so a provenance-mark fault degrades to a warning (stderr, so // stdout stays pure under --json) rather than failing an export whose scaffold already landed on disk. + const exportedAt = new Date(now()).toISOString(); try { opened.store.updateSession({ ...result.record, status: 'exported', exportedWorkflowPath: result.path, - updatedAt: new Date(now()).toISOString(), + updatedAt: exportedAt, }); } catch { deps.io.writeErr('note: scaffold written but could not mark the session exported\n'); @@ -92,7 +93,7 @@ export function chatExportCommand( const event = SessionExportedEventSchema.parse({ type: 'session:exported', sessionId: args.sessionId, - timestamp: new Date(now()).toISOString(), + timestamp: exportedAt, sequenceNumber: result.sequenceNumber, workflowPath: result.path, }); diff --git a/apps/cli/src/commands/chat-passive-failure.test.ts b/apps/cli/src/commands/chat-passive-failure.test.ts new file mode 100644 index 00000000..df21e20b --- /dev/null +++ b/apps/cli/src/commands/chat-passive-failure.test.ts @@ -0,0 +1,170 @@ +import { mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { createClient, createSessionStore, runMigrations } from '@relavium/db'; +import { AgentSchema } from '@relavium/shared'; +import { expect, it, vi } from 'vitest'; +import { chatResumeCommand, drivePlain, driveJson, type ChatDriveContext } from './chat.js'; +import { driveInk } from '../render/tui/chat-ink.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { CliError } from '../process/errors.js'; +import { acknowledgeWrite, type CliIo } from '../process/io.js'; +import { scriptedResolver, textTurn } from '../chat/test-support.js'; +import { OwnedTtyInput, OwnedTtyOutput } from '../test-support.js'; +const flush = () => new Promise((r) => setImmediate(r)); +it.each(['plain', 'json', 'ink'] as const)( + 'actual owned chat resume %s passive subscriber faults versus clean control', + async (mode) => { + for (const faulted of [false, true]) { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-r10-subscriber-'))); + const client = createClient(':memory:'); + runMigrations(client.db); + const store = createSessionStore(client.db); + const f = invocationFixture(); + const input = new OwnedTtyInput(); + const output = new OwnedTtyOutput(); + const stderr = new OwnedTtyOutput(); + let closed = 0; + let faultHits = 0; + let durableReplies = 0; + let id = 0; + const agent = AgentSchema.parse({ + id: 'reviewer', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + system_prompt: 'offline', + }); + const iso = '2026-10-17T00:00:00.000Z'; + store.createSession({ + id: 'review-session', + agentSlug: agent.id, + agentSnapshot: agent, + context: { workingDir: dir, fsScopeTier: 'sandboxed' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: iso, + updatedAt: iso, + }); + const out: string[] = []; + const err: string[] = []; + const io: CliIo = { + env: {}, + stdoutIsTty: mode === 'ink', + stdinIsTty: mode === 'ink', + stdin: mode === 'ink' ? input : Readable.from(['hello\n/exit\n']), + ...(mode === 'ink' + ? { nativeStdin: input, nativeStdout: output, nativeStderr: stderr } + : {}), + writeOut: (text) => { + if (faulted && mode !== 'ink' && faultHits === 0 && text.includes('SUBSCRIBER_REPLY')) { + faultHits++; + throw new CliError('output_unavailable', ''); + } + out.push(text); + }, + writeErr: (text) => { + err.push(text); + }, + writeOutAcknowledged: async (text) => { + if (mode === 'ink') await acknowledgeWrite(output, text); + else out.push(text); + }, + writeErrAcknowledged: (text) => { + err.push(text); + return Promise.resolve(); + }, + }; + try { + const result = await chatResumeCommand( + { sessionId: 'review-session' }, + { + invocation: f.invocation, + io, + global: { + cwd: dir, + json: mode === 'json', + color: false, + configPath: join(dir, 'absent.toml'), + verbosity: 'normal', + }, + providers: scriptedResolver([textTurn('SUBSCRIBER_REPLY')]), + now: () => 1750000000000, + uuid: () => `r10-${++id}`, + openSessionStore: () => ({ + db: client.db, + store, + close: () => { + durableReplies = + store.loadFull('review-session')?.messages.filter((m) => m.role === 'assistant') + .length ?? 0; + closed++; + client.sqlite.close(); + }, + }), + lifecycle: { + onProcessExit: () => () => {}, + onInterrupt: () => () => {}, + onTerminationSignal: () => () => {}, + setRawMode: () => {}, + exit: () => {}, + }, + jobControlLifecycle: { + supported: false, + onSuspend: () => () => {}, + onContinue: () => () => {}, + suspendSelf: () => {}, + }, + drive: + mode === 'json' + ? driveJson + : mode === 'plain' + ? drivePlain + : async (ctx: ChatDriveContext) => { + const apply = ctx.store.apply.bind(ctx.store); + const spy = vi.spyOn(ctx.store, 'apply').mockImplementation((event) => { + if (faulted && event.type === 'session:turn_started') { + faultHits++; + throw new Error('synthetic projection invariant'); + } + apply(event); + }); + const driving = driveInk(ctx); + try { + await flush(); + await flush(); + await ctx.processLine('hello'); + await flush(); + expect(f.registry.termination !== undefined).toBe(faulted); + spy.mockRestore(); + } finally { + input.push('\x03'); + await flush(); + await driving; + } + return driving; + }, + }, + ); + expect(result).toBe(faulted ? 9 : 4); + expect(closed).toBe(1); + expect(durableReplies).toBe(faulted ? 0 : 1); + expect(f.registry.termination !== undefined).toBe(faulted); + expect(f.registry.activeOwners).toBe(0); + expect(faultHits).toBe(faulted ? 1 : 0); + expect(err.join('')).not.toContain('background handler failed'); + if (!faulted && mode !== 'ink') expect(out.join('')).toContain('SUBSCRIBER_REPLY'); + } finally { + vi.restoreAllMocks(); + input.destroy(); + output.destroy(); + stderr.destroy(); + if (client.sqlite.open) client.sqlite.close(); + rmSync(dir, { recursive: true, force: true }); + } + } + }, +); diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index cc0c6ee5..f51f520c 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -1,3 +1,4 @@ +import { ownSessionOutput, ownSessionSubscriber } from '../chat/session-presentation.js'; import { SessionChatTerminal } from '../chat/session-chat-terminal.js'; import type { SessionInkMount } from '../chat/session-ink-mount.js'; import type { CliInvocation } from '../process/invocation.js'; @@ -2837,8 +2838,16 @@ export async function runReplLoop( clipboard: (text: string) => copyToClipboard({ writeControl, env: deps.io.env }, text), editor: { env: deps.io.env, - spawnEditor: createNodeSpawnEditor(nativeProcessesForInvocation(deps.invocation)), - createTempDocument: nodeCreateTempDocument, + spawnEditor: createNodeSpawnEditor( + nativeProcessesForInvocation(deps.invocation), + deps.chatLifetime?.surface.work.failure, + ), + createTempDocument: (contents) => + nodeCreateTempDocument( + contents, + deps.chatLifetime?.surface.work.failure, + deps.chatLifetime?.surface.work.cleanupUncertain, + ), onDisposeFailed: (path, error) => { if (deps.chatLifetime !== undefined) { const work = deps.chatLifetime.surface.work; @@ -3030,12 +3039,16 @@ export async function runReplLoop( * TTY is attached (a pipe / CI without `--json`, which is 2.Q); the TTY ink driver overrides `deps.drive`. */ export async function drivePlain(ctx: ChatDriveContext): Promise { + const io = ownSessionOutput(ctx.io, ctx.workOwner); return withSessionReadline( ctx.io.stdin, ctx.workOwner, () => ctx.handle.subscribe( - makePlainPrinter(ctx.io, true, ctx.store.getSnapshot().state.memoryPolicy), + ownSessionSubscriber( + ctx.workOwner, + makePlainPrinter(io, true, ctx.store.getSnapshot().state.memoryPolicy), + ), ), async (rl, closeInput) => { const lines = rl[Symbol.asyncIterator](); // buffer piped lines before any awaited activation can yield. @@ -3073,7 +3086,7 @@ export async function drivePlain(ctx: ChatDriveContext): Promise { + const io = ownSessionOutput(ctx.io, ctx.workOwner); return withSessionReadline( ctx.io.stdin, ctx.workOwner, - () => ctx.handle.subscribe((event) => ctx.io.writeOut(`${stringifyJsonLine(event)}\n`)), + () => + ctx.handle.subscribe( + ownSessionSubscriber(ctx.workOwner, (event) => + io.writeOut(`${stringifyJsonLine(event)}\n`), + ), + ), async (rl, closeInput) => { const lines = rl[Symbol.asyncIterator](); let active = true; diff --git a/apps/cli/src/commands/fs-workspace-admission.test.ts b/apps/cli/src/commands/fs-workspace-admission.test.ts index 96f4cbd2..1ec86cec 100644 --- a/apps/cli/src/commands/fs-workspace-admission.test.ts +++ b/apps/cli/src/commands/fs-workspace-admission.test.ts @@ -1,3 +1,6 @@ +import * as sessionOpen from '../history/session-open.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import type { CliInvocation } from '../process/invocation.js'; import { existsSync, mkdirSync, @@ -41,26 +44,79 @@ vi.mock('../secrets/os-keychain.js', () => ({ }, })); -let base: string; -let home: string; -let workspace: string; -let sensitiveAlias: string; -beforeEach(() => { - base = realpathSync.native(mkdtempSync(join(tmpdir(), 'relavium-workspace-floor-'))); - home = join(base, 'home'); - workspace = join(base, 'neutral-vault'); +interface WorkspaceCase { + readonly base: string; + readonly home: string; + readonly workspace: string; + readonly sensitiveAlias: string; + readonly signal: AbortSignal; + readonly invocation: CliInvocation; + readonly pending: Set>; + readonly removeAbort: () => void; + readonly assertCurrent: () => void; +} +let currentCase: WorkspaceCase; +beforeEach((context) => { + const base = realpathSync.native(mkdtempSync(join(tmpdir(), 'relavium-workspace-floor-'))); + const home = join(base, 'home'); + const workspace = join(base, 'neutral-vault'); for (const path of [home, workspace]) mkdirSync(path); - sensitiveAlias = join(home, '.ssh'); + const sensitiveAlias = join(home, '.ssh'); symlinkSync(workspace, sensitiveAlias, 'junction'); writeFileSync(join(workspace, 'id_ed25519'), 'synthetic key sentinel'); + const { invocation } = invocationFixture(); + const onAbort = (): void => { + invocation.finiteExit.signal(15); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + currentCase = { + base, + home, + workspace, + sensitiveAlias, + invocation, + signal: context.signal, + pending: new Set(), + removeAbort: () => context.signal.removeEventListener('abort', onAbort), + assertCurrent: () => { + context.signal.throwIfAborted(); + invocation.registry.assertAdmitting(); + }, + }; vi.stubEnv('HOME', home); vi.stubEnv('USERPROFILE', home); Object.assign(effects, { history: 0, keys: 0 }); }); -afterEach(() => { +afterEach(async () => { + const fixture = currentCase; + // Runner timeout is cancellation, not raw settlement. Preserve this case's HOME/root until the actual join. + await Promise.allSettled([...fixture.pending]); + if (fixture.invocation.registry.termination !== undefined) + await fixture.invocation.registry.termination; + fixture.removeAbort(); + expect(fixture.invocation.registry.activeOwners).toBe(0); + vi.restoreAllMocks(); vi.unstubAllEnvs(); - rmSync(base, { recursive: true, force: true }); + rmSync(fixture.base, { recursive: true, force: true }); }); +function ownCase(body: (fixture: WorkspaceCase) => Promise): Promise { + const fixture = currentCase; + const raw = Promise.resolve().then(() => { + fixture.assertCurrent(); + return body(fixture); + }); + fixture.pending.add(raw); + return raw; +} +function readSession(fixture: WorkspaceCase) { + fixture.assertCurrent(); + const opened = sessionOpen.openSessionStore(fixture.home); + try { + return opened.store.loadFull('workspace-floor-session'); + } finally { + opened.close(); + } +} const global = (cwd: string): GlobalOptions => ({ cwd, json: false, @@ -73,10 +129,12 @@ const exitDriver: ChatDriver = async (ctx) => { await ctx.processLine('/exit'); return { kind: ctx.stopReason() }; }; -async function seed(cwd: string): Promise { +async function seed(fixture: WorkspaceCase, cwd: string): Promise { + fixture.assertCurrent(); await chatCommand( { agent: undefined }, { + invocation: fixture.invocation, io: captureIo().io, global: global(cwd), providers: scriptedResolver([]), @@ -84,156 +142,270 @@ async function seed(cwd: string): Promise { drive: exitDriver, }, ); + fixture.assertCurrent(); } describe('secret workspace admission before canonical persistence', () => { it.each([[], ['chat'], ['agent', 'run', 'unused-agent.yaml']].map((surface) => ({ surface })))( 'refuses real argv surface %j before history/key/provider/MCP composition', async ({ surface }) => { - const captured = captureIo(); - expect( - await run(['node', 'relavium', '--cwd', sensitiveAlias, ...surface], { - ...captured.io, - stdinIsTty: true, - stdoutIsTty: true, - }), - 'FS_CAUSAL_WORKSPACE_ADMISSION', - ).toBe(2); - expect(effects, 'FS_CAUSAL_WORKSPACE_ADMISSION').toEqual({ history: 0, keys: 0 }); - expect(existsSync(join(home, '.relavium', 'history.db'))).toBe(false); - expect(captured.err()).toContain('Choose another workspace'); - expect(captured.err()).not.toContain(home); - expect(captured.err()).not.toContain('synthetic key sentinel'); + return ownCase(async (fixture) => { + const { home, sensitiveAlias } = fixture; + const captured = captureIo(); + expect( + await run( + ['node', 'relavium', '--cwd', sensitiveAlias, ...surface], + { + ...captured.io, + stdinIsTty: true, + stdoutIsTty: true, + }, + undefined, + fixture.invocation, + ), + 'FS_CAUSAL_WORKSPACE_ADMISSION', + ).toBe(2); + expect(effects, 'FS_CAUSAL_WORKSPACE_ADMISSION').toEqual({ history: 0, keys: 0 }); + expect(existsSync(join(home, '.relavium', 'history.db'))).toBe(false); + expect(captured.err()).toContain('Choose another workspace'); + expect(captured.err()).not.toContain(home); + expect(captured.err()).not.toContain('synthetic key sentinel'); + }); }, ); it('a fresh sensitive alias cannot create the canonical row that would erase its floor on resume', async () => { - const providers = vi.fn(); - await expect( - chatCommand( - { agent: undefined }, - { - io: captureIo().io, - global: global(sensitiveAlias), - resolveKeys: providers, - drive: exitDriver, - }, - ), - ).rejects.toMatchObject({ code: 'invalid_invocation' }); - expect(providers).not.toHaveBeenCalled(); - expect(effects.history).toBe(0); - expect(existsSync(join(home, '.relavium', 'history.db'))).toBe(false); + return ownCase(async (fixture) => { + const { home, sensitiveAlias } = fixture; + const providers = vi.fn(); + await expect( + chatCommand( + { agent: undefined }, + { + invocation: fixture.invocation, + io: captureIo().io, + global: global(sensitiveAlias), + resolveKeys: providers, + drive: exitDriver, + }, + ), + ).rejects.toMatchObject({ code: 'invalid_invocation' }); + expect(providers).not.toHaveBeenCalled(); + expect(effects.history).toBe(0); + expect(existsSync(join(home, '.relavium', 'history.db'))).toBe(false); + }); }); it('also refuses a neutral workspace alias whose canonical target is a credential directory', async () => { - const secret = join(base, '.azure'); - const alias = join(base, 'ordinary-alias'); - mkdirSync(secret); - symlinkSync(secret, alias, 'junction'); - const captured = captureIo(); - expect(await run(['node', 'relavium', '--cwd', alias, 'chat'], captured.io)).toBe(2); - expect(effects).toEqual({ history: 0, keys: 0 }); - expect(captured.err()).toContain('Choose another workspace'); - expect(captured.err()).not.toContain(secret); + return ownCase(async (fixture) => { + const { base } = fixture; + const secret = join(base, '.azure'); + const alias = join(base, 'ordinary-alias'); + mkdirSync(secret); + symlinkSync(secret, alias, 'junction'); + const captured = captureIo(); + expect( + await run( + ['node', 'relavium', '--cwd', alias, 'chat'], + captured.io, + undefined, + fixture.invocation, + ), + ).toBe(2); + expect(effects).toEqual({ history: 0, keys: 0 }); + expect(captured.err()).toContain('Choose another workspace'); + expect(captured.err()).not.toContain(secret); + }); }); it('benign aliases still persist canonical cwd and resume at the original workspace', async () => { - const alias = join(base, 'ordinary-alias'); - symlinkSync(workspace, alias, 'junction'); - await seed(alias); - const opened = openSessionStore(home); - const before = opened.store.loadFull('workspace-floor-session'); - opened.close(); - expect(before?.session.context.workingDir).toBe(workspace); - rmSync(alias); - symlinkSync(home, alias, 'junction'); - expect( - await chatResumeCommand( - { sessionId: 'workspace-floor-session' }, - { - io: captureIo().io, - global: global(home), - providers: scriptedResolver([]), - drive: exitDriver, - }, - ), - ).toBe(4); + return ownCase(async (fixture) => { + const { base, home, workspace } = fixture; + const alias = join(base, 'ordinary-alias'); + symlinkSync(workspace, alias, 'junction'); + await seed(fixture, alias); + const before = readSession(fixture); + expect(before?.session.context.workingDir).toBe(workspace); + rmSync(alias); + symlinkSync(home, alias, 'junction'); + expect( + await chatResumeCommand( + { sessionId: 'workspace-floor-session' }, + { + invocation: fixture.invocation, + io: captureIo().io, + global: global(home), + providers: scriptedResolver([]), + drive: exitDriver, + }, + ), + ).toBe(4); + }); }); it('refuses an existing sensitive canonical workspace on resume after only history recovery', async () => { - await seed(workspace); - // A legacy row can name a sensitive, already canonical directory. No real user history is used. - const canonicalSecret = join(base, '.kube'); - mkdirSync(canonicalSecret); - const client = createClient(join(home, '.relavium', 'history.db')); - client.sqlite - .prepare('UPDATE agent_sessions SET context_json = ?, working_dir = ? WHERE id = ?') - .run( - JSON.stringify({ workingDir: canonicalSecret, fsScopeTier: 'project' }), - canonicalSecret, - 'workspace-floor-session', - ); - client.sqlite.close(); - const beforeStore = openSessionStore(home); - const before = beforeStore.store.loadFull('workspace-floor-session'); - beforeStore.close(); - Object.assign(effects, { history: 0, keys: 0 }); - const captured = captureIo(); - expect( - await run( - ['node', 'relavium', 'chat-resume', 'workspace-floor-session', '--cwd', workspace], - captured.io, - ), - ).toBe(2); - expect(effects).toEqual({ history: 1, keys: 0 }); - expect(captured.err()).toContain('Choose another workspace'); - expect(captured.err()).not.toContain(canonicalSecret); - const afterStore = openSessionStore(home); - expect(afterStore.store.loadFull('workspace-floor-session')).toEqual(before); - afterStore.close(); + return ownCase(async (fixture) => { + const { base, home, workspace } = fixture; + await seed(fixture, workspace); + // A legacy row can name a sensitive, already canonical directory. No real user history is used. + const canonicalSecret = join(base, '.kube'); + mkdirSync(canonicalSecret); + const client = createClient(join(home, '.relavium', 'history.db')); + try { + client.sqlite + .prepare('UPDATE agent_sessions SET context_json = ?, working_dir = ? WHERE id = ?') + .run( + JSON.stringify({ workingDir: canonicalSecret, fsScopeTier: 'project' }), + canonicalSecret, + 'workspace-floor-session', + ); + } finally { + client.sqlite.close(); + } + + const before = readSession(fixture); + Object.assign(effects, { history: 0, keys: 0 }); + const captured = captureIo(); + expect( + await run( + ['node', 'relavium', 'chat-resume', 'workspace-floor-session', '--cwd', workspace], + captured.io, + undefined, + fixture.invocation, + ), + ).toBe(2); + expect(effects).toEqual({ history: 1, keys: 0 }); + expect(captured.err()).toContain('Choose another workspace'); + expect(captured.err()).not.toContain(canonicalSecret); + expect(readSession(fixture)).toEqual(before); + }); }); it('fresh and resumed builders refuse owned lexical provenance even with canonical cwd', async () => { - await seed(workspace); - const opened = openSessionStore(home); - const loaded = opened.store.loadFull('workspace-floor-session'); - opened.close(); - if (loaded === undefined) throw new Error('synthetic seed missing'); - const chat = loadResolvedConfig({ cwd: workspace }).config.chat; - const policy = createFsPathPolicy({ workspaceDir: sensitiveAlias }); - const providers = scriptedResolver([]); - const resolveProvider = vi.spyOn(providers, 'resolveProvider'); - const keyFor = vi.spyOn(providers, 'keyFor'); - const uuid = vi.fn(() => 'never-created'); - const startMcpClient = vi.fn().mockRejectedValue(new Error('unexpected MCP connect')); - const now = () => Date.now(); - await expect( - buildChatSession({ - chat, - fsPolicy: policy, - cwd: workspace, - agentRef: undefined, - projectConfigDir: undefined, - now, - uuid, - providers, - startMcpClient, - }), - ).rejects.toMatchObject({ code: 'invalid_invocation' }); - await expect( - buildResumedChatSession({ - chat, - fsPolicy: policy, - record: loaded.session, - messages: loaded.messages, - now, - providers, - startMcpClient, - }), - ).rejects.toMatchObject({ code: 'invalid_invocation' }); - expect(resolveProvider).not.toHaveBeenCalled(); - expect(keyFor).not.toHaveBeenCalled(); - expect(uuid).not.toHaveBeenCalled(); - expect(startMcpClient).not.toHaveBeenCalled(); + return ownCase(async (fixture) => { + const { workspace, sensitiveAlias } = fixture; + await seed(fixture, workspace); + const loaded = readSession(fixture); + if (loaded === undefined) throw new Error('synthetic seed missing'); + const chat = loadResolvedConfig({ cwd: workspace }).config.chat; + const policy = createFsPathPolicy({ workspaceDir: sensitiveAlias }); + const providers = scriptedResolver([]); + const resolveProvider = vi.spyOn(providers, 'resolveProvider'); + const keyFor = vi.spyOn(providers, 'keyFor'); + const uuid = vi.fn(() => 'never-created'); + const startMcpClient = vi.fn().mockRejectedValue(new Error('unexpected MCP connect')); + const now = () => Date.now(); + await expect( + buildChatSession({ + chat, + fsPolicy: policy, + cwd: workspace, + agentRef: undefined, + projectConfigDir: undefined, + now, + uuid, + providers, + startMcpClient, + }), + ).rejects.toMatchObject({ code: 'invalid_invocation' }); + await expect( + buildResumedChatSession({ + chat, + fsPolicy: policy, + record: loaded.session, + messages: loaded.messages, + now, + providers, + startMcpClient, + }), + ).rejects.toMatchObject({ code: 'invalid_invocation' }); + expect(resolveProvider).not.toHaveBeenCalled(); + expect(keyFor).not.toHaveBeenCalled(); + expect(uuid).not.toHaveBeenCalled(); + expect(startMcpClient).not.toHaveBeenCalled(); + }); }); }); + +it('runner-abort ownership joins a held resumed builder before fixture removal and refuses late driver entry', () => + ownCase(async (fixture) => { + await seed(fixture, fixture.workspace); + let release: () => void = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + let entered = false; + let native: ReturnType['sqlite'] | undefined; + let closed = false; + const drive = vi.fn(exitDriver); + const raw = chatResumeCommand( + { sessionId: 'workspace-floor-session' }, + { + invocation: fixture.invocation, + io: captureIo().io, + global: global(fixture.home), + providers: scriptedResolver([]), + drive, + openSessionStore: (home, captureClose) => { + const opened = openSessionStore(home, captureClose); + native = opened.db.$client; + return { + ...opened, + close: () => { + opened.close(); + closed = true; + }, + }; + }, + buildResumedSession: async (options) => { + entered = true; + await held; + return buildResumedChatSession(options); + }, + }, + ); + const observed = raw.then( + (value) => ({ value }), + (cause: unknown) => ({ cause }), + ); + try { + await vi.waitFor(() => expect(entered).toBe(true)); + fixture.invocation.finiteExit.signal(15); + expect(native?.open).toBe(true); + expect(closed).toBe(false); + expect(existsSync(fixture.base)).toBe(true); + } finally { + release(); + await observed; + await fixture.invocation.registry.termination; + } + expect(drive).not.toHaveBeenCalled(); + expect(native?.open).toBe(false); + expect(fixture.invocation.registry.activeOwners).toBe(0); + expect(existsSync(fixture.base)).toBe(true); + })); + +it('a real SQLite read failure still closes the manually acquired session store', () => + ownCase(async (fixture) => { + await seed(fixture, fixture.workspace); + const original = sessionOpen.openSessionStore; + let native: ReturnType['sqlite'] | undefined; + const spy = vi.spyOn(sessionOpen, 'openSessionStore').mockImplementation((home) => { + const opened = original(home); + native = opened.db.$client; + try { + native.prepare('DROP TABLE agent_sessions').run(); + } catch (cause) { + opened.close(); + throw cause; + } + return opened; + }); + try { + expect(() => readSession(fixture)).toThrow(/no such table/); + expect(native?.open).toBe(false); + } finally { + spy.mockRestore(); + if (native?.open === true) native.close(); + } + })); diff --git a/apps/cli/src/commands/gate-checkpoint-fold.test.ts b/apps/cli/src/commands/gate-checkpoint-fold.test.ts new file mode 100644 index 00000000..3522123c --- /dev/null +++ b/apps/cli/src/commands/gate-checkpoint-fold.test.ts @@ -0,0 +1,172 @@ +import { randomUUID } from 'node:crypto'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, expect, vi } from 'vitest'; +import { parseWorkflow } from '@relavium/core'; +import { RunEventSchema, RunSuspensionCorruptionError } from '@relavium/shared'; +import { createHistoryCheckpointer } from '../engine/checkpointer.js'; +import { + createClient, + runMigrations, + createRunHistoryStore, + createRunLeasePort, +} from '@relavium/db'; +import { buildEngine } from '../engine/build-engine.js'; +import { createCliHost } from '../engine/host.js'; +import { gateCommand, budgetCommand } from './gate.js'; +import { executeCommand } from './dispatch.js'; +import * as databaseOpen from '../db/open.js'; +import { toUserFacing } from '../process/errors.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; + +const workflow = parseWorkflow( + JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'probe-checkpoint', + nodes: [ + { id: 'input', type: 'input' }, + { id: 'gate', type: 'human_gate', gate_type: 'approval' }, + { id: 'output', type: 'output' }, + ], + edges: [ + { from: 'input', to: 'gate' }, + { from: 'gate', to: 'output' }, + ], + }, + }), +); +describe('actual SQLite typed checkpoint fold corruption', () => { + it.each([ + ['gate', 'direct'], + ['gate', 'dispatch'], + ] as const)('%s %s', async (surface, route) => { + const dir = mkdtempSync(join(tmpdir(), 'relavium-r10-checkpoint-')); + const configPath = join(dir, 'config.toml'); + writeFileSync(configPath, ''); + vi.stubEnv('HOME', dir); + vi.stubEnv('USERPROFILE', dir); + const client = createClient(join(dir, 'history.db')); + runMigrations(client.db); + const store = createRunHistoryStore(client.db, { + uuid: randomUUID, + now: Date.now, + workflow: { + slug: workflow.workflow.id, + name: 'Probe', + definitionJson: JSON.stringify(workflow), + }, + }); + const engine = await buildEngine({ + host: createCliHost(store, { runLeases: createRunLeasePort(store) }), + }); + const handle = engine.start({ workflow, inputs: {}, eventConsumption: 'iterated' }); + const iterator = handle.events[Symbol.asyncIterator](); + for (;;) { + const next = await iterator.next(); + if (next.done) throw new Error('pause missing'); + if (next.value.type === 'run:paused') break; + } + expect((await handle.depart()).kind).toBe('detached'); + expect((await iterator.next()).done).toBe(true); + await iterator.return?.(); + const events = store.loadRunEventLog(handle.runId).events; + const paused = events.find((event) => event.type === 'human_gate:paused'); + if (paused === undefined) throw new Error('expected real paused event'); + const sequenceNumber = Math.max(...events.map((event) => event.sequenceNumber)) + 1; + const conflict = RunEventSchema.parse({ + ...paused, + sequenceNumber, + nodeId: 'different-valid-node', + }); + client.sqlite + .prepare( + 'INSERT INTO run_events(id,run_id,seq,event_type,node_id,payload_json,ts) VALUES(?,?,?,?,?,?,?)', + ) + .run( + randomUUID(), + handle.runId, + sequenceNumber, + conflict.type, + 'different-valid-node', + JSON.stringify(conflict), + Date.now(), + ); + // Every row passes the actual strict parser/projection before the relational fold refuses reuse. + expect(store.loadRunEventLogForReplay(handle.runId)).toHaveLength(events.length + 1); + const foldFailure = await createHistoryCheckpointer(store) + .load(handle.runId) + .then( + () => undefined, + (cause: unknown) => cause, + ); + expect(foldFailure).toBeInstanceOf(RunSuspensionCorruptionError); + expect(foldFailure).toMatchObject({ code: 'gate_identity_conflict' }); + const f = invocationFixture(); + const c = captureIo(); + const close = vi.fn(() => { + if (client.sqlite.open) client.sqlite.close(); + }); + const open = (_home: string, capture?: (close: () => void) => void) => { + capture?.(close); + return { db: client.db, close }; + }; + const global = { cwd: dir, configPath, json: true, color: false, verbosity: 'normal' as const }; + if (route === 'dispatch') + vi.spyOn(databaseOpen, 'openLocalDb').mockImplementation((_home, _notice, capture) => + open(_home, capture), + ); + let code: number | undefined; + try { + const runId = handle.runId; + try { + code = + route === 'dispatch' + ? await executeCommand( + surface === 'gate' ? 'gate' : 'budget.resume', + { + positionals: [runId], + options: surface === 'gate' ? { approve: true } : { abort: true }, + }, + { io: c.io, global, invocation: f.invocation }, + ) + : surface === 'gate' + ? await gateCommand( + { runId, approve: true }, + { + io: c.io, + global, + invocation: f.invocation, + openDb: open, + subscribeSignals: () => () => undefined, + }, + ) + : await budgetCommand( + { runId, abort: true }, + { + io: c.io, + global, + invocation: f.invocation, + openDb: open, + subscribeSignals: () => () => undefined, + }, + ); + } catch (cause) { + code = toUserFacing(cause).exitCode; + } + expect(code, 'TYPED_GATE_FOLD_CORRUPTION_REMAINS_OPERATIONAL').toBe(1); + expect(f.registry.observations.exitCode()).toBe(1); + expect(f.registry.termination).toBeUndefined(); + expect(close).toHaveBeenCalledOnce(); + expect(client.sqlite.open).toBe(false); + expect(f.registry.activeOwners).toBe(0); + } finally { + vi.restoreAllMocks(); + if (client.sqlite.open) client.sqlite.close(); + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/commands/gate-checkpoint-native.test.ts b/apps/cli/src/commands/gate-checkpoint-native.test.ts new file mode 100644 index 00000000..7a67be5a --- /dev/null +++ b/apps/cli/src/commands/gate-checkpoint-native.test.ts @@ -0,0 +1,178 @@ +import { randomUUID } from 'node:crypto'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, expect, vi } from 'vitest'; +import { parseWorkflow } from '@relavium/core'; +import { + createClient, + runMigrations, + createRunHistoryStore, + createRunLeasePort, +} from '@relavium/db'; +import { buildEngine } from '../engine/build-engine.js'; +import { createCliHost } from '../engine/host.js'; +import { gateCommand, budgetCommand } from './gate.js'; +import { executeCommand } from './dispatch.js'; +import * as databaseOpen from '../db/open.js'; +import { CliError, toUserFacing } from '../process/errors.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; + +const workflow = parseWorkflow( + JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'probe-checkpoint', + nodes: [ + { id: 'input', type: 'input' }, + { id: 'gate', type: 'human_gate', gate_type: 'approval' }, + { id: 'output', type: 'output' }, + ], + edges: [ + { from: 'input', to: 'gate' }, + { from: 'gate', to: 'output' }, + ], + }, + }), +); +describe('actual SQLite checkpoint failure ownership', () => { + it.each([ + ['gate', 'direct', 'closed'], + ['budget', 'direct', 'closed'], + ['gate', 'dispatch', 'closed'], + ['budget', 'dispatch', 'closed'], + ['gate', 'direct', 'corrupt'], + ['gate', 'dispatch', 'corrupt'], + ['gate', 'direct', 'missing'], + ['gate', 'dispatch', 'missing'], + ] as const)('%s %s %s', async (surface, route, fault) => { + const dir = mkdtempSync(join(tmpdir(), 'relavium-r10-checkpoint-')); + const configPath = join(dir, 'config.toml'); + writeFileSync(configPath, ''); + vi.stubEnv('HOME', dir); + vi.stubEnv('USERPROFILE', dir); + const client = createClient(join(dir, 'history.db')); + runMigrations(client.db); + const store = createRunHistoryStore(client.db, { + uuid: randomUUID, + now: Date.now, + workflow: { + slug: workflow.workflow.id, + name: 'Probe', + definitionJson: JSON.stringify(workflow), + }, + }); + const engine = await buildEngine({ + host: createCliHost(store, { runLeases: createRunLeasePort(store) }), + }); + const handle = engine.start({ workflow, inputs: {}, eventConsumption: 'iterated' }); + const iterator = handle.events[Symbol.asyncIterator](); + for (;;) { + const next = await iterator.next(); + if (next.done) throw new Error('pause missing'); + if (next.value.type === 'run:paused') break; + } + expect((await handle.depart()).kind).toBe('detached'); + expect((await iterator.next()).done).toBe(true); + await iterator.return?.(); + if (fault === 'corrupt') + client.sqlite + .prepare( + "UPDATE run_events SET payload_json='not-json' WHERE run_id=? AND event_type='run:started'", + ) + .run(handle.runId); + let nativeFailure: unknown; + let entered = 0; + if (fault === 'closed') { + const original = client.sqlite.transaction.bind(client.sqlite); + vi.spyOn(client.sqlite, 'transaction').mockImplementation((fn) => { + entered++; + client.sqlite.close(); + try { + return original(fn); + } catch (cause) { + nativeFailure = cause; + throw cause; + } + }); + } + const f = invocationFixture(); + const c = captureIo(); + const close = vi.fn(() => { + if (client.sqlite.open) client.sqlite.close(); + }); + const open = (_home: string, capture?: (close: () => void) => void) => { + capture?.(close); + return { db: client.db, close }; + }; + const global = { cwd: dir, configPath, json: true, color: false, verbosity: 'normal' as const }; + if (route === 'dispatch') + vi.spyOn(databaseOpen, 'openLocalDb').mockImplementation((_home, _notice, capture) => + open(_home, capture), + ); + let thrown: unknown; + let code: number | undefined; + try { + const runId = fault === 'missing' ? '00000000-0000-4000-8000-000000000000' : handle.runId; + try { + code = + route === 'dispatch' + ? await executeCommand( + surface === 'gate' ? 'gate' : 'budget.resume', + { + positionals: [runId], + options: surface === 'gate' ? { approve: true } : { abort: true }, + }, + { io: c.io, global, invocation: f.invocation }, + ) + : surface === 'gate' + ? await gateCommand( + { runId, approve: true }, + { + io: c.io, + global, + invocation: f.invocation, + openDb: open, + subscribeSignals: () => () => undefined, + }, + ) + : await budgetCommand( + { runId, abort: true }, + { + io: c.io, + global, + invocation: f.invocation, + openDb: open, + subscribeSignals: () => () => undefined, + }, + ); + } catch (cause) { + thrown = cause; + code = toUserFacing(cause).exitCode; + } + + if (fault === 'closed') { + nativeFailure = thrown instanceof CliError ? thrown.cause : thrown; + expect(entered).toBe(1); + expect(nativeFailure).toBeInstanceOf(TypeError); + expect(nativeFailure).toMatchObject({ message: 'The database connection is not open' }); + expect(toUserFacing(nativeFailure).exitCode).toBe(9); + expect(code, 'ACTUAL_NATIVE_CLOSED_CHECKPOINT_MUST_RETAIN_INFRASTRUCTURE').toBe(9); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.termination).toBeDefined(); + } else { + expect(code).toBe(fault === 'corrupt' ? 1 : 2); + expect(f.registry.termination).toBeUndefined(); + } + expect(f.registry.activeOwners).toBe(0); + expect(close).toHaveBeenCalledOnce(); + expect(client.sqlite.open).toBe(false); + } finally { + vi.restoreAllMocks(); + if (client.sqlite.open) client.sqlite.close(); + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index edb4686c..1c9f5631 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -24,8 +24,6 @@ import { createEffectJournalStore, createRunHistoryStore, createRunLeasePort, - isCorruptRunEventError, - isUnreadableRunEventLogError, loadRunSnapshot, type Db, } from '@relavium/db'; @@ -353,12 +351,7 @@ async function resumeGateOwned( }, }); const checkpointer = createHistoryCheckpointer(store); - let checkpoint: CheckpointState | undefined; - try { - checkpoint = await checkpointer.load(args.runId); - } catch (err) { - throwGateLoadFault(err, args.runId); - } + const checkpoint = await checkpointer.load(args.runId); lifetime.checkpoint(); if (checkpoint === undefined) { // A run row with a snapshot but no reconstructable checkpoint (no run:started in the log) — corrupt/partial. @@ -890,35 +883,3 @@ async function resolveSecretInputs( for (const key of Object.keys(supplied)) merged[key] = supplied[key]; return merged; } - -/** - * Map a checkpoint-read fault to the surface error it deserves, and never return. Extracted from - * `gateCommand` so its three branches do not count against that function's cognitive-complexity budget — - * they belong together and nowhere else. - */ -function throwGateLoadFault(err: unknown, runId: string): never { - // ADR-0075's refusal passes through UNTOUCHED. This catch pre-dates it and would otherwise fold a - // "your Relavium is too old" into the generic branch below: the user would get no count, no `seq` - // values, no upgrade remedy, no "your history is still readable" — and exit 2 (`invalid_invocation`), - // which is semantically wrong, since the invocation was valid. `toUserFacing` already renders this - // error properly; re-wrapping it here made that mapper unreachable. - if (isUnreadableRunEventLogError(err)) { - throw err; - } - // A damaged row passes through typed too, for the SAME reason and to fix an asymmetry the reasoning - // above exposed: `relavium logs` on a corrupt log exits 1 (its typed error reaches `toUserFacing`), - // while `gate` on the IDENTICAL log exited 2 because this catch re-wrapped it as `invalid_invocation`. - // The invocation was valid in both. `toUserFacing`'s corrupt branch already composes the better - // sentence — the run, the `seq`, the `event_type`, and what is still listable — so re-wrapping made - // `gate` both less diagnosable and inconsistent with every other single-run surface. - if (isCorruptRunEventError(err)) { - throw err; - } - // Anything else from the fold: an unreadable file, a closed handle, a driver fault. Still a clean - // exit-2 invocation fault, matching the snapshot/inputs handling, never a raw escaping error. - throw new CliError( - 'invalid_invocation', - `the persisted event log for run ${runId} could not be read`, - { cause: err }, - ); -} diff --git a/apps/cli/src/home/drive-home.test.ts b/apps/cli/src/home/drive-home.test.ts index 10c10241..848e3cd3 100644 --- a/apps/cli/src/home/drive-home.test.ts +++ b/apps/cli/src/home/drive-home.test.ts @@ -5,7 +5,7 @@ import { holdNativeToolProcess, witnessNativeToolProcess, } from '../commands/native-forwarding.test-support.js'; -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Readable } from 'node:stream'; @@ -220,6 +220,85 @@ describe('driveHome (2.5.B / ADR-0054)', () => { return { deps, unmount, writeControl, signalHandlers, exitHandlers }; } + it.each([false, true])( + 'observes actual passive Home view faults before bus recovery (fault=%s)', + async (faulted) => { + const f = invocationFixture(); + let captured: RootAppProps | undefined; + let repliesAtClose = 0; + let observedSessionId: string | undefined; + const sessions = createSessionStore(client.db); + closeSpy.mockImplementation(() => { + const sessionId = observedSessionId; + if (sessionId !== undefined) + repliesAtClose = + sessions.loadFull(sessionId)?.messages.filter((message) => message.role === 'assistant') + .length ?? 0; + client.sqlite.close(); + }); + const { deps, unmount } = makeDeps( + (props) => { + captured = props; + }, + { + invocation: f.invocation, + providers: scriptedResolver([textTurn('first'), textTurn('second')]), + }, + ); + const running = driveHome(deps); + const completion = running.then( + (value) => ({ value }), + (cause: unknown) => ({ cause }), + ); + let hits = 0; + try { + await vi.waitFor(() => expect(captured).toBeDefined()); + if (captured === undefined) throw new Error('Home did not mount'); + const props = captured; + for (const character of 'hello') props.controller.handleKey(character, {}); + props.controller.handleKey('', ENTER); + await vi.waitFor(() => { + const id = props.controller.getSnapshot().session?.sessionId; + expect( + id === undefined + ? [] + : sessions.loadFull(id)?.messages.filter((message) => message.role === 'assistant'), + ).toHaveLength(1); + }); + const session = props.controller.getSnapshot().session; + if (session === undefined) throw new Error('Home did not create a session'); + observedSessionId = session.sessionId; + const apply = session.store.apply.bind(session.store); + vi.spyOn(session.store, 'apply').mockImplementation((event) => { + if (faulted && event.type === 'session:turn_started') { + hits++; + throw new TypeError('PRIVATE_PASSIVE_HOME_FAULT'); + } + apply(event); + }); + await session.processLine('second input'); + expect(f.registry.termination !== undefined).toBe(faulted); + if (!faulted) { + props.controller.handleKey('c', CTRL_C); + await flush(); + props.controller.handleKey('c', CTRL_C); + } + expect(await running).toBe(faulted ? 9 : 0); + expect(hits).toBe(faulted ? 1 : 0); + expect(repliesAtClose).toBe(faulted ? 1 : 2); + expect(closeSpy).toHaveBeenCalledOnce(); + expect(client.sqlite.open).toBe(false); + expect(f.registry.activeOwners).toBe(0); + } finally { + unmount(); + await completion; + vi.restoreAllMocks(); + if (client.sqlite.open) client.sqlite.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); + it('an init fault after the db is open (a throwing render/mount) still closes the db once', async () => { // The cleanup scope opens right after `opened`, and the inner finally guarantees the close even though the // ink mount throws — so a faulty render can never leak the db handle. (Bracketed-paste enable is ink 7's diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index 92730b89..639cdc8d 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -1,3 +1,4 @@ +import { ownSessionSubscriber } from '../chat/session-presentation.js'; import { SessionControlWrites } from '../chat/session-control-writes.js'; import { SessionHomeLifetime } from './session-home-lifetime.js'; import type { SessionHostOwner } from '../chat/session-host-owner.js'; @@ -495,8 +496,13 @@ async function driveHomeBody(deps: HomeDeps, ownership?: SessionHomeLifetime): P }, editor: { env: deps.io.env, - spawnEditor: createNodeSpawnEditor(nativeProcesses), - createTempDocument: nodeCreateTempDocument, + spawnEditor: createNodeSpawnEditor(nativeProcesses, ownership?.surface.work.failure), + createTempDocument: (contents) => + nodeCreateTempDocument( + contents, + ownership?.surface.work.failure, + ownership?.surface.work.cleanupUncertain, + ), onDisposeFailed: (path, error) => { if (ownership !== undefined) { ownership.surface.work.cleanupUncertain(error); @@ -692,7 +698,9 @@ async function driveHomeBody(deps: HomeDeps, ownership?: SessionHomeLifetime): P ); // Subscribe the view store BEFORE opening the session so the synchronous session:started is observed. const subscribeView = (): (() => void) => - built.handle.subscribe((event) => store.apply(event)); + built.handle.subscribe( + ownSessionSubscriber(built.hostOwner?.work, (event) => store.apply(event)), + ); const startFrame = (): (() => void) => { frame = setInterval(() => store.tick(), FRAME_MS); frame.unref(); diff --git a/apps/cli/src/process/errors.test.ts b/apps/cli/src/process/errors.test.ts index 54542799..48ee3394 100644 --- a/apps/cli/src/process/errors.test.ts +++ b/apps/cli/src/process/errors.test.ts @@ -1,3 +1,4 @@ +import { RunSuspensionCorruptionError } from '@relavium/shared'; import { AgentParseError, EngineStateError, @@ -99,6 +100,28 @@ describe('CliError', () => { }); describe('toUserFacing', () => { + it.each([ + 'gate_identity_conflict', + 'gate_state_conflict', + 'legacy_gate_join_missing', + 'legacy_gate_join_ambiguous', + ] as const)( + 'keeps typed %s history corruption operational without reflecting mutated detail', + (code) => { + const failure = new RunSuspensionCorruptionError(code); + failure.message = 'PRIVATE_CORRUPT_HISTORY'; + expect(toUserFacing(failure)).toEqual({ + code: 'internal', + message: + 'The stored gate history is inconsistent. Inspect its history before resuming or repeating the run.', + exitCode: 1, + }); + expect(toUserFacing(Object.assign(new Error('PRIVATE_LOOKALIKE'), { code })).exitCode).toBe( + 9, + ); + }, + ); + it.each(['message_invalid', 'metadata_invalid'] as const)( 'maps a %s session boundary refusal to a fixed, value-free invocation diagnostic', (code) => { diff --git a/apps/cli/src/process/errors.ts b/apps/cli/src/process/errors.ts index 1d670acf..1ea86bb6 100644 --- a/apps/cli/src/process/errors.ts +++ b/apps/cli/src/process/errors.ts @@ -11,6 +11,7 @@ import { ProviderHeaderPolicyError, SessionMessageBoundaryError, } from '@relavium/db'; +import { RunSuspensionCorruptionError } from '@relavium/shared'; import { EXIT_CODES } from './exit-codes.js'; import { @@ -65,6 +66,14 @@ function projectUserFacing(value: unknown): UserFacingError { exitCode: EXIT_CODES.invalidInvocation, }; } + if (value instanceof RunSuspensionCorruptionError) { + return { + code: 'internal', + message: + 'The stored gate history is inconsistent. Inspect its history before resuming or repeating the run.', + exitCode: EXIT_CODES.workflowFailed, + }; + } // History permits compatible errors from another bundle. Preflight own data directly rather than // calling structural guards that read arbitrary code/message accessors. The typed store messages are // echo-safe by their contract and retain their existing operational-1 recovery advice. diff --git a/apps/cli/src/process/owned-storage.ts b/apps/cli/src/process/owned-storage.ts index 3042e5a3..327b6311 100644 --- a/apps/cli/src/process/owned-storage.ts +++ b/apps/cli/src/process/owned-storage.ts @@ -1,4 +1,5 @@ import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; +import { mkdtemp, writeFile } from 'node:fs/promises'; import { CliError, toCliError } from './errors.js'; @@ -34,6 +35,24 @@ export function writeFileForStorage(path: string, content: string, force: boolea } } +/** Concrete native acquisition for the editor's private transcript directory. */ +export async function mkdtempForStorage(prefix: string): Promise { + try { + return await mkdtemp(prefix); + } catch (error) { + throwOwnedFilesystemFailure(error); + } +} + +/** Concrete native transcript write; mode and encoding are fixed at this boundary. */ +export async function writePrivateFileForStorage(path: string, content: string): Promise { + try { + await writeFile(path, content, { encoding: 'utf8', mode: 0o600 }); + } catch (error) { + throwOwnedFilesystemFailure(error); + } +} + // Only these actual native operations grant errno origin. No exported general errno classifier, // arbitrary callback, message or cause can promote a foreign error to storage authority. function throwOwnedFilesystemFailure(error: unknown): never { diff --git a/apps/cli/src/render/editor-native-lifetime.test.ts b/apps/cli/src/render/editor-native-lifetime.test.ts index c47ba8a5..b4dc987f 100644 --- a/apps/cli/src/render/editor-native-lifetime.test.ts +++ b/apps/cli/src/render/editor-native-lifetime.test.ts @@ -1,8 +1,9 @@ +import { invocationFixture } from '../process/invocation.test-support.js'; +import { SessionWorkOwner } from '../chat/session-work-owner.js'; import { spawn, type ChildProcess } from 'node:child_process'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createCliInvocation } from '../process/invocation.js'; import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; import { createNodeSpawnEditor } from './editor.js'; @@ -14,66 +15,134 @@ afterEach(() => { }); describe('foreground editor actual native lifetime', () => { - it('requests cancellation on a real native error and retains ownership after the public rejection', async () => { - const invocation = createCliInvocation(); - const nativeProcesses = nativeProcessesForInvocation(invocation); - if (nativeProcesses === undefined) throw new Error('explicit invocation missing'); - let child: ChildProcess | undefined; - let closed = false; - let acknowledgeReady = (): void => undefined; - let acknowledgeClosed = (): void => undefined; - const ready = new Promise((resolve) => { - acknowledgeReady = resolve; - }); - const whenClosed = new Promise((resolve) => { - acknowledgeClosed = resolve; - }); - vi.mocked(spawn).mockImplementationOnce((command, args, options) => { - // Keep the exact production binary/argv/shell/detached choices. Capture a pipe only to acknowledge - // the inert child's entry instead of opening a real terminal editor in the test runner. - const created = native.spawn(command, args, { - ...options, - stdio: ['ignore', 'pipe', 'ignore'], + it.each(['ENOENT', 'EACCES', 'unknown'])( + 'observes actual native %s before presentation and retains child ownership after public rejection', + async (code) => { + const { invocation } = invocationFixture(); + const owner = new SessionWorkOwner(invocation, 'chat', { + cancel: () => {}, + cleanup: () => Promise.resolve(), }); - child = created; - created.stdout?.once('data', () => acknowledgeReady()); - created.once('close', () => { - closed = true; - acknowledgeClosed(); + const nativeProcesses = nativeProcessesForInvocation(invocation); + if (nativeProcesses === undefined) throw new Error('explicit invocation missing'); + let child: ChildProcess | undefined; + let closed = false; + let acknowledgeReady = (): void => undefined; + let acknowledgeClosed = (): void => undefined; + const ready = new Promise((resolve) => { + acknowledgeReady = resolve; }); - return created; - }); - const getChild = (): ChildProcess => { - if (child === undefined) throw new Error('fixture native entry missing'); - return child; - }; - const result = createNodeSpawnEditor(nativeProcesses)( - process.execPath, - ['-e', 'process.stdout.write("ready");setInterval(()=>{},1000)'], - 'synthetic-unused-file', - ).catch((cause: unknown) => cause); - let watchdogFired = false; - const watchdog = setTimeout(() => { - watchdogFired = true; - getChild().kill('SIGKILL'); - }, 5000); - try { - await ready; - expect(invocation.registry.activeOwners).toBe(1); - const fault = new Error('synthetic owned native fault'); - getChild().emit('error', fault); - expect(await result).toBe(fault); - expect(invocation.registry.activeOwners, 'EDITOR_ERROR_NOT_NATIVE_ACK').toBe(1); - await whenClosed; - const retirement = await invocation.registry.terminate({ kind: 'normal' }); - expect(retirement).toEqual({ kind: 'settled', unacknowledgedOwners: 0 }); - expect(invocation.registry.activeOwners).toBe(0); - expect(watchdogFired, 'EDITOR_NATIVE_ERROR_CANCEL').toBe(false); - } finally { - clearTimeout(watchdog); - if (!closed) getChild().kill('SIGKILL'); - await whenClosed; - await result; - } - }, 10000); + const whenClosed = new Promise((resolve) => { + acknowledgeClosed = resolve; + }); + vi.mocked(spawn).mockImplementationOnce((command, args, options) => { + // Keep the exact production binary/argv/shell/detached choices. Capture a pipe only to acknowledge + // the inert child's entry instead of opening a real terminal editor in the test runner. + const created = native.spawn(command, args, { + ...options, + stdio: ['ignore', 'pipe', 'ignore'], + }); + child = created; + created.stdout?.once('data', () => acknowledgeReady()); + created.once('close', () => { + closed = true; + acknowledgeClosed(); + }); + return created; + }); + const getChild = (): ChildProcess => { + if (child === undefined) throw new Error('fixture native entry missing'); + return child; + }; + const result = createNodeSpawnEditor(nativeProcesses, owner.failure)( + process.execPath, + ['-e', 'process.stdout.write("ready");setInterval(()=>{},1000)'], + 'synthetic-unused-file', + ).catch((cause: unknown) => cause); + let watchdogFired = false; + const watchdog = setTimeout(() => { + watchdogFired = true; + getChild().kill('SIGKILL'); + }, 5000); + try { + await ready; + expect(invocation.registry.activeOwners).toBe(2); + const fault = Object.assign(new Error('synthetic owned native fault'), { code }); + getChild().emit('error', fault); + expect(await result).toBe(fault); + expect( + invocation.registry.activeOwners, + 'EDITOR_ERROR_NOT_NATIVE_ACK', + ).toBeGreaterThanOrEqual(1); + expect(invocation.registry.observations.hasInfrastructureFailure()).toBe( + code === 'unknown', + ); + expect(owner.firstFailure?.cause).toBe(code === 'unknown' ? fault : undefined); + await whenClosed; + const retirement = await invocation.registry.terminate({ kind: 'normal' }); + expect(retirement).toEqual({ kind: 'settled', unacknowledgedOwners: 0 }); + expect(invocation.registry.activeOwners).toBe(0); + expect(watchdogFired, 'EDITOR_NATIVE_ERROR_CANCEL').toBe(false); + } finally { + clearTimeout(watchdog); + if (!closed) getChild().kill('SIGKILL'); + await whenClosed; + await result; + await owner.close(); + } + }, + 10000, + ); }); + +it('a stop between reservation and actual spawn is not promoted to an unexpected native fault', async () => { + const f = invocationFixture(); + const actual = nativeProcessesForInvocation(f.invocation); + if (actual === undefined) throw new Error('missing native controller'); + const owner = new SessionWorkOwner(f.invocation, 'chat', { + cancel: () => {}, + cleanup: () => Promise.resolve(), + }); + const result = createNodeSpawnEditor( + { + reserve: async (kind) => { + const native = await actual.reserve(kind); + f.invocation.finiteExit.signal(15); + return native; + }, + observeMcp: actual.observeMcp, + }, + owner.failure, + )('must-not-spawn', [], 'unused').catch((cause: unknown) => cause); + await result; + await owner.close(); + await f.registry.termination; + expect(spawn).not.toHaveBeenCalled(); + expect(owner.firstFailure).toBeUndefined(); + expect(f.registry.observations.hasInfrastructureFailure()).toBe(false); + expect(f.registry.activeOwners).toBe(0); +}); + +it.each(['ENOENT', 'EACCES', 'unknown'])( + 'synchronous actual spawn %s keeps its native origin disposition', + async (code) => { + const f = invocationFixture(); + const owner = new SessionWorkOwner(f.invocation, 'chat', { + cancel: () => {}, + cleanup: () => Promise.resolve(), + }); + const cause = Object.assign(new TypeError('PRIVATE_SPAWN_FAULT'), { code }); + vi.mocked(spawn).mockImplementationOnce(() => { + throw cause; + }); + const observed = await createNodeSpawnEditor( + nativeProcessesForInvocation(f.invocation), + owner.failure, + )('must-not-spawn', [], 'unused').catch((failure: unknown) => failure); + expect(observed).toBe(cause); + expect(f.registry.observations.hasInfrastructureFailure()).toBe(code === 'unknown'); + await owner.close(); + await f.registry.termination; + expect(f.registry.activeOwners).toBe(0); + }, +); diff --git a/apps/cli/src/render/editor.test.ts b/apps/cli/src/render/editor.test.ts index 6cce529f..0c468648 100644 --- a/apps/cli/src/render/editor.test.ts +++ b/apps/cli/src/render/editor.test.ts @@ -1,3 +1,6 @@ +import { invocationFixture } from '../process/invocation.test-support.js'; +import { SessionWorkOwner } from '../chat/session-work-owner.js'; +import { toUserFacing } from '../process/errors.js'; import { existsSync, readdirSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname } from 'node:path'; @@ -12,17 +15,24 @@ import { * (an ENOSPC/EIO cannot be provoked portably). Everything else in `node:fs/promises` stays real. */ const fsFault = vi.hoisted(() => ({ writeFileError: undefined as Error | undefined, + mkdtempError: undefined as Error | undefined, + rmWait: undefined as Promise | undefined, rmError: undefined as Error | undefined, })); vi.mock('node:fs/promises', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + mkdtemp: async (...args: Parameters) => { + if (fsFault.mkdtempError !== undefined) throw fsFault.mkdtempError; + return actual.mkdtemp(...args); + }, writeFile: async (...args: Parameters) => { if (fsFault.writeFileError !== undefined) throw fsFault.writeFileError; return actual.writeFile(...args); }, rm: async (...args: Parameters) => { + await fsFault.rmWait; if (fsFault.rmError !== undefined) throw fsFault.rmError; return actual.rm(...args); }, @@ -459,3 +469,136 @@ describe('openInEditor — the cleanup block cannot break the never-throws contr expect(reported).toHaveLength(1); }); }); + +describe('owned native transcript preparation failure observation', () => { + for (const operation of ['directory', 'write'] as const) { + it.each(['EACCES', 'EPERM', 'ENOSPC', 'EROFS'])( + `${operation} keeps actual native %s operational`, + async (code) => { + const cause = Object.assign(new Error('PRIVATE_NATIVE_TEXT'), { code }); + const observed: unknown[] = []; + if (operation === 'directory') fsFault.mkdtempError = cause; + else fsFault.writeFileError = cause; + const before = pendingTempDirCount(); + try { + const outcome = await openInEditor( + { + env: { EDITOR: 'must-not-spawn' }, + spawnEditor: () => Promise.reject(new Error('unexpected spawn')), + createTempDocument: (contents) => + nodeCreateTempDocument(contents, (failure) => { + observed.push(failure); + }), + }, + 'PRIVATE_TRANSCRIPT', + ); + expect(outcome.kind).toBe('failed'); + expect(observed).toHaveLength(1); + expect(toUserFacing(observed[0])).toMatchObject({ + code: 'storage_unavailable', + exitCode: 1, + }); + expect(JSON.stringify(toUserFacing(observed[0]))).not.toContain('PRIVATE_'); + expect(pendingTempDirCount()).toBe(before); + } finally { + fsFault.mkdtempError = undefined; + fsFault.writeFileError = undefined; + } + }, + ); + } + + it('observes a native write fault before held partial removal without inventing cleanup acknowledgement', async () => { + const f = invocationFixture(); + const cleanup = vi.fn(() => Promise.resolve()); + const owner = new SessionWorkOwner(f.invocation, 'chat', { cancel: () => {}, cleanup }); + const before = pendingTempDirCount(); + let release: () => void = () => {}; + fsFault.rmWait = new Promise((resolve) => { + release = resolve; + }); + const cause = new TypeError('PRIVATE_NATIVE_WRITE_FAULT'); + fsFault.writeFileError = cause; + let settled = false; + const editor = owner.enter(() => + openInEditor( + { + env: { EDITOR: 'must-not-spawn' }, + spawnEditor: () => Promise.reject(new Error('unexpected spawn')), + createTempDocument: (contents) => nodeCreateTempDocument(contents, owner.failure), + }, + 'PRIVATE_TRANSCRIPT', + ), + ); + const observed = editor.then((outcome) => { + settled = true; + return outcome; + }); + let closing: Promise | undefined; + try { + await vi.waitFor(() => expect(f.registry.termination).toBeDefined()); + expect(owner.firstFailure?.cause).toBe(cause); + expect(f.registry.observations.exitCode()).toBe(9); + expect(pendingTempDirCount()).toBe(before + 1); + closing = owner.close(); + await Promise.resolve(); + expect(settled).toBe(false); + expect(cleanup).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + } finally { + fsFault.writeFileError = undefined; + fsFault.rmWait = undefined; + release(); + await observed; + await (closing ?? owner.close()); + } + expect(pendingTempDirCount()).toBe(before); + expect(cleanup).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + }); + + it('a throwing observer preserves the original rejection and still reclaims the partial transcript', async () => { + const cause = new TypeError('original native fault'); + fsFault.writeFileError = cause; + const before = pendingTempDirCount(); + try { + await expect( + nodeCreateTempDocument('PRIVATE_TRANSCRIPT', () => { + throw new Error('observer fault'); + }), + ).rejects.toBe(cause); + expect(pendingTempDirCount()).toBe(before); + } finally { + fsFault.writeFileError = undefined; + } + }); +}); + +it('a failed partial removal reports cleanup uncertainty, preserves the primary write failure and retains the exit net', async () => { + const primary = new TypeError('primary write fault'); + const secondary = new Error('partial removal fault'); + const observed: unknown[] = []; + const before = pendingTempDirCount(); + fsFault.writeFileError = primary; + fsFault.rmError = secondary; + try { + await expect( + nodeCreateTempDocument( + 'PRIVATE_TRANSCRIPT', + (failure) => { + observed.push(failure); + }, + (failure) => { + observed.push(failure); + }, + ), + ).rejects.toBe(primary); + expect(observed).toEqual([primary, secondary]); + expect(pendingTempDirCount()).toBe(before + 1); + } finally { + fsFault.writeFileError = undefined; + fsFault.rmError = undefined; + disposePendingTempDirs(); + } + expect(pendingTempDirCount()).toBe(0); +}); diff --git a/apps/cli/src/render/editor.ts b/apps/cli/src/render/editor.ts index 07ab4d61..0eaa6165 100644 --- a/apps/cli/src/render/editor.ts +++ b/apps/cli/src/render/editor.ts @@ -1,6 +1,6 @@ import { spawn } from 'node:child_process'; import { rmSync } from 'node:fs'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -12,6 +12,7 @@ import { WindowsTeardownUnavailableError, WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE, } from '../process/windows-teardown.js'; +import { mkdtempForStorage, writePrivateFileForStorage } from '../process/owned-storage.js'; /** * The `$EDITOR` half of the ADR-0068 §e copy-and-search escape hatches (2.6.F Step 5d): write the transcript to a @@ -235,16 +236,30 @@ export function disposePendingTempDirs(): void { export function pendingTempDirCount(): number { return pendingTempDirs.size; } -export const nodeCreateTempDocument = async (contents: string): Promise => { - const dir = await mkdtemp(join(tmpdir(), 'relavium-transcript-')); - const path = join(dir, 'transcript.md'); +export const nodeCreateTempDocument = async ( + contents: string, + onFailure?: (cause: unknown) => void, + onCleanupFailure?: (cause: unknown) => void, +): Promise => { + let acquired: string | undefined; + try { + acquired = await mkdtempForStorage(join(tmpdir(), 'relavium-transcript-')); + } catch (cause) { + observeEditorFailure(onFailure, cause); + throw cause; + } + const dir = acquired; + let path: string; // Register + arm the exit net BEFORE the write: the write flushes (part of) the conversation, so a write that fails // has still put transcript bytes in the directory, and the exit net must be able to reclaim it (Step-6h review). - pendingTempDirs.add(dir); - armTempDirExitNet(); try { - await writeFile(path, contents, { encoding: 'utf8', mode: 0o600 }); + path = join(dir, 'transcript.md'); + pendingTempDirs.add(dir); + armTempDirExitNet(); + await writePrivateFileForStorage(path, contents); } catch (error) { + // Observe before partial removal can hold. This is not a cleanup or terminal release ACK. + observeEditorFailure(onFailure, error); // Reclaim the partial transcript. If THAT fails (a locked dir), leave it registered so the exit net retries — and // never let the removal's error MASK the write error the caller classifies as `{ kind: 'failed' }` (Step-5d-3 Opus // review; the masking + never-registered gap is the Step-6h Sonnet finding). Async `rm` here (not `rmSync`) so this @@ -252,7 +267,8 @@ export const nodeCreateTempDocument = async (contents: string): Promise void, ): (command: string, args: readonly string[], file: string) => Promise { return async (command, args, file) => { const native = await nativeProcesses.reserve('pid'); @@ -286,12 +303,22 @@ export function createNodeSpawnEditor( let child: ReturnType; try { native.assertAdmitting(); + } catch (cause) { + // Rejected admission after ordinary stop is not an unexpected native spawn failure. + native.abandon(); + reject( + cause instanceof Error ? cause : new Error('The editor was not admitted.', { cause }), + ); + return; + } + try { child = spawn(command, [...args, file], { stdio: 'inherit', // the editor owns the terminal; suspendFullScreen already handed it over shell: false, // SECURITY: metacharacters remain inert argv, never a command windowsHide: false, }); } catch (cause) { + observeEditorStartFailure(onFailure, cause); native.abandon(); reject( cause instanceof Error ? cause : new Error('The editor could not be started.', { cause }), @@ -301,6 +328,7 @@ export function createNodeSpawnEditor( native.bindChild(child); // The public start error is unchanged, but the registered lifetime remains until native close. child.once('error', (cause) => { + observeEditorStartFailure(onFailure, cause); void native.stop(); reject(cause); }); @@ -311,5 +339,35 @@ export function createNodeSpawnEditor( }; } +/** Fault observation cannot replace the original failure or skip partial transcript cleanup. */ +function observeEditorFailure( + onFailure: ((cause: unknown) => void) | undefined, + cause: unknown, +): void { + try { + onFailure?.(cause); + } catch { + // The compatibility editor outcome still owns presentation; its original native cause is preserved. + } +} + +/** Only the concrete spawn throw/error event establishes an ordinary unavailable editor. */ +function observeEditorStartFailure( + onFailure: ((cause: unknown) => void) | undefined, + cause: unknown, +): void { + if (cause instanceof WindowsTeardownUnavailableError) return; + let code: unknown; + try { + if (typeof cause === 'object' && cause !== null) { + const descriptor = Object.getOwnPropertyDescriptor(cause, 'code'); + if (descriptor !== undefined && 'value' in descriptor) code = descriptor.value; + } + } catch { + // A hostile descriptor cannot establish an ordinary native start refusal. + } + if (code !== 'ENOENT' && code !== 'EACCES') observeEditorFailure(onFailure, cause); +} + /** Compatibility host with local cleanup; product constructors bind their original invocation explicitly. */ export const nodeSpawnEditor = createNodeSpawnEditor(); diff --git a/apps/cli/src/render/tui/chat-ink.tsx b/apps/cli/src/render/tui/chat-ink.tsx index 8312f944..7b227581 100644 --- a/apps/cli/src/render/tui/chat-ink.tsx +++ b/apps/cli/src/render/tui/chat-ink.tsx @@ -1,3 +1,4 @@ +import { ownSessionSubscriber } from '../../chat/session-presentation.js'; import { Box, Static, Text, render, useApp, useInput, usePaste, useWindowSize } from 'ink'; import { createElement, @@ -1738,7 +1739,9 @@ export function driveInk(ctx: ChatDriveContext): Promise { writeOut: (text) => ctx.io.writeOut(text), }); // Mirror the live stream into the view store the component projects. - const unsubscribe = ctx.handle.subscribe((event) => ctx.store.apply(event)); + const unsubscribe = ctx.handle.subscribe( + ownSessionSubscriber(ctx.workOwner, (event) => ctx.store.apply(event)), + ); const frame = setInterval(() => ctx.store.tick(), FRAME_MS); frame.unref(); From b9bc7118cef1ed5da7af7956bb615e0fda2fdf5f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 08:27:02 +0300 Subject: [PATCH 129/222] docs(docs): record tenth review and original-owner corrections Record all 293 paths and 1600 cumulative hunks as NONCLEAN, the four confirmed findings, independent native fixture triage and exact author gates. Preserve failed Windows and source-scoped platform attribution; keep Step 8d open for new cumulative review, separate security and final-source CI. Refs: ADR-0110, ADR-0103, Wave 3 Step 8d Co-Authored-By: Codex --- AGENTS.md | 22 +++-- CLAUDE.md | 22 +++-- docs/reference/cli/commands.md | 31 +++++- docs/roadmap/current.md | 10 ++ .../phase-2.5.5-hardening-and-remediation.md | 94 +++++++++++++++++++ 5 files changed, 157 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 700c7ab9..8884fbe7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,16 +107,18 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first nine complete cumulative rounds are -NONCLEAN. R9 independently verifies all 293 paths / 1,588 cumulative hunks and -reproduces two High caught-fault gaps: one-shot passive rendering and bare-Home -`/models` default writing. The corrections observe the original owner before UI/bus -recovery, preserve actual producer/native/accounting joins and keep known refusals -inline. The signal summary is reconciled with existing forced workflow termination. -All four same-artifact consumers and the Windows advisory pass at reviewed -`40086e71`; those results precede the R9 runtime corrections and are not final-source -acceptance. The earlier `09740575` Windows timeout remains failed with exact cause -unclassified. Original five-second bounds and semantic assertions are preserved. +but not independently accepted. Its first ten complete cumulative rounds are +NONCLEAN. R10 independently verifies all 293 paths / 1,600 cumulative hunks at +`4c0fa5b7` and confirms four original-owner failure gaps: passive session consumers, +export timestamp recovery, native editor preparation and checkpoint classification. +Corrections preserve known inline/typed refusals and actual producer/native/accounting +joins. Separate actual-SQLite triage proves filesystem fixture raw-work/read cleanup +gaps; original five-second bounds and semantic assertions are preserved. All four +same-artifact consumers pass at that reviewed source, but its Windows advisory +filesystem timeout remains failed with exact initiating phase/cause unclassified. +The older `09740575` Windows timeout also retains its failed, unclassified status. +Author correction gates pass (341 CLI files / 5,475 passes / ten established skips); +final-source independent review/security/CI/platform acceptance remains pending. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. diff --git a/CLAUDE.md b/CLAUDE.md index adbb3561..46aca843 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,16 +196,18 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first nine complete cumulative rounds are -NONCLEAN. R9 independently verifies all 293 paths / 1,588 cumulative hunks and -reproduces two High caught-fault gaps: one-shot passive rendering and bare-Home -`/models` default writing. The corrections observe the original owner before UI/bus -recovery, preserve actual producer/native/accounting joins and keep known refusals -inline. The signal summary is reconciled with existing forced workflow termination. -All four same-artifact consumers and the Windows advisory pass at reviewed -`40086e71`; those results precede the R9 runtime corrections and are not final-source -acceptance. The earlier `09740575` Windows timeout remains failed with exact cause -unclassified. Original five-second bounds and semantic assertions are preserved. +but not independently accepted. Its first ten complete cumulative rounds are +NONCLEAN. R10 independently verifies all 293 paths / 1,600 cumulative hunks at +`4c0fa5b7` and confirms four original-owner failure gaps: passive session consumers, +export timestamp recovery, native editor preparation and checkpoint classification. +Corrections preserve known inline/typed refusals and actual producer/native/accounting +joins. Separate actual-SQLite triage proves filesystem fixture raw-work/read cleanup +gaps; original five-second bounds and semantic assertions are preserved. All four +same-artifact consumers pass at that reviewed source, but its Windows advisory +filesystem timeout remains failed with exact initiating phase/cause unclassified. +The older `09740575` Windows timeout also retains its failed, unclassified status. +Author correction gates pass (341 CLI files / 5,475 passes / ten established skips); +final-source independent review/security/CI/platform acceptance remains pending. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 5aa154da..4f8d9624 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -783,7 +783,7 @@ The runtime classifier queries the read-only [`@relavium/db` storage-origin fact](../shared-core/database-schema.md#node-client-failure-provenance). A public `DbOpenError('open_failed', …)`, a copied error, an errno-shaped object or a convincing cause does not establish storage origin. The CLI's concrete -directory-creation, permission-hardening and export-write operations classify +directory-creation, permission-hardening, export-write and native editor temporary-directory/private-transcript-write operations classify `EACCES`, `EPERM`, `ENOSPC` and `EROFS` at those owned native calls. Shared local opening, run/gate opening and history helpers preserve these dispositions; centralizing all openers remains Step 18. @@ -794,7 +794,7 @@ unknown throws project fixed `cli_internal` and exit 9. Runtime projection uses own data descriptors; reflection/accessor faults fall back to the same fixed infrastructure diagnostic without incoming message, cause or coercion. Exit classification comes from the CLI policy, not a mutable error exit field. -Typed history corruption/unreadability and known run/gate departure failures +Typed history corruption/unreadability, including the shared checkpoint suspension-corruption family, and known run/gate departure failures retain operational 1; URI/API validation, authoring and known maintenance refusals retain their existing invocation disposition. @@ -1183,3 +1183,30 @@ G15 is refuted as a current shipping defect. Preserve human durable-event replay and actual JSON stored sequence/gaps. The engine/store/log regression must prove live fragments are emitted live but absent from durable history; no token coalescer or new persistence is authorized by that historical finding. + + +#### Step 8d passive consumers and editor preparation + +Surface-owned session view/printer/JSON callbacks observe an unexpected failure +through their original invocation before passive bus isolation can consume it. +The callback seals before cancellation reentry; the core bus keeps its listener +isolation. Headless business writes use the captured output origin, while entered +terminal restoration retains its independent original capabilities. Actual producer, +accounting, native close and output delivery remain independently owed. + +The native editor temp helper observes unexpected creation/write failures before +partial removal can hold. Its existing classified `EditorOutcome` compatibility +contract remains: missing editor and known operational refusals stay inline; +unexpected native faults select infrastructure 9 through the original owner. +Actual spawn ENOENT/EACCES remain inline only at concrete spawn origins. Rejected +admission after ordinary stop is separate from a native spawn fault. Partial-removal +failure reports cleanup uncertainty while retaining the exit net and original +primary failure; observation does not acknowledge cleanup. Ownerless compatibility +callers retain the existing outcome contract. + +A completed export's auxiliary provenance-row write remains best effort. Timestamp +construction is outside that recovery scope, so an unexpected clock/format fault +cannot become successful provenance recovery or cause a second scaffold write. +Checkpoint loading likewise preserves original causes: typed inconsistent history +remains operational 1 with inspection advice; an unexpected driver/wiring failure +selects infrastructure 9 before original-owner cleanup. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 862e7c31..d1086616 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1218,6 +1218,16 @@ All four package consumers and the Windows advisory pass at the reviewed source, which precedes these runtime corrections. The old unexplained Windows failure is not retrospectively passed. Two NEW clean complete cumulative rounds, a NEW clean security sitting and fresh final-source CI/platform evidence remain required. +The [tenth cumulative review and original-owner corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-tenth-cumulative-review-and-original-owner-corrections--2026-10-17) +records complete NONCLEAN coverage of 293 paths / 1,600 independently verified hunks +at `4c0fa5b7`. Four confirmed gaps affect passive Home/chat consumers, export timestamps, +native editor preparation and checkpoint error classification. Corrections preserve +known refusals and real cleanup joins. Separate fixture triage proves raw-work and +manual SQLite-read cleanup gaps without claiming the initiating remote Windows cause. +All four same-artifact consumers pass at that reviewed source; its Windows advisory +remains failed. Correction author gates pass (341 CLI files / 5,475 passes / ten +established skips); two NEW clean cumulative rounds, NEW separate security and fresh +final-source CI/platform evidence remain pending. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index ac82efc7..fe15b6b5 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -7106,3 +7106,97 @@ records; seal SHA-256: `4ba3e9799c4f2382256425fdc2c83b1fb76c0befae899c5ca5662028d884aced`. Source pins are checked unchanged after the gates. This does not replace the required new independent reviews, separate security or final-source CI/platforms. + + +### Wave 3 Step 8d tenth cumulative review and original-owner corrections — 2026-10-17 + +**NONCLEAN; implementation corrections in progress.** Source `4c0fa5b7` has a +NEW complete cumulative review from base `d9625ff0`: all **293 physical paths / +1,600 full U0 hunks** independently rehashed and joined without overlap or omission. +Session/terminal: 73/509, NONCLEAN; workflow/management: 134/825, NONCLEAN; +native/tools/docs: 86/266, scoped CLEAN. The interrupted session sitting was +completed independently from retained captures plus fresh source review/execution; +no interrupted predecessor completion or earlier clean credit is inherited. +Root independently joined every physical file, full patch and hunk identity. +Aggregate SHA-256: +`e11fe25dbf8011952ebb5ebae4bb35cba82b4fe8a7dcbf1246cbfc5776b17a22`. +Detailed reports/ledgers remain local and Git-ignored. + +| Confirmed finding | Correction and preservation | +| --- | --- | +| ST-R10-1 — passive Home/plain/JSON/Ink session consumers hide failures behind bus isolation | Observe through original owner before local recovery; seal before reentrant cancellation; classify captured business output and retain independent restoration capabilities | +| ST-R10-2 — export provenance catch also encloses native timestamp failure | Compute timestamp outside best-effort row write; preserve already-written scaffold and existing row-write warning policy | +| ST-R10-3 — native `/edit` setup failure becomes an inline editor outcome before owner observation | Observe concrete native creation/write before partial cleanup; preserve known errno/editor refusal and never-throws outcome compatibility; report partial-cleanup uncertainty | +| R10-WM-01 — gate/budget checkpoint native failure becomes invocation 2 | Preserve original cause; keep existing typed suspension corruption operational 1 with fixed history-inspection advice; unexpected native failure remains infrastructure 9 | + +Fresh reviewer verification records 35 assigned session files / **699 passes**, +68 assigned workflow files / **1,287 passes and one established host skip**, and +20 native source files / **304 passes**, with **101 Node tests** separately. +Counts retain their own cohorts; overlap is not added into a unique whole-project +claim. Session probes freshly reproduce all three findings, seven cases across +four physical files with zero runner-unhandled errors. Workflow native checkpoint +controls have four precise expected9 failures / four passing known-history/missing-run +controls; valid-row fold controls separately characterize the old noncanonical +mapping. Root independently reproduces Home/plain/JSON/Ink, export, checkpoint and +native editor observations with owned zero-unhandled reporter receipts. These are +injected offline/native-boundary scenarios, not live provider or naturally observed +OS corruption claims. + +**Separately attributed source platforms.** At `4c0fa5b7`, push run `39002571093` +passes pack, required package aggregation and all four same-artifact consumers. +Root and native reviewer independently inspect five ZIPs, 70 public tar members +and four bound receipts. Tarball: **445,764 bytes**, SHA-256 +`6ff96978bf39cba43a817fa1f739ad5897355ccd2af6d3f800a0285c49968cb5`; +physical audit SHA-256: +`8d35a9f645dc3058da71baf8db56619e3aaa87631a860951c36038fcb0585130`. +Its Windows advisory fails the benign-alias filesystem case at its unchanged +5,000-ms bound (reported 5,418 ms; 322 passes / eighteen Windows skips). The remote +initiating phase/cause remains **unknown**. Broader required CI retains its actual +separate status; these platform passes precede the R10 corrections. + +Separate isolated macOS/native-SQLite triage proves that runner timeout does not +join pending raw work, mutable fixture identity can affect late reads, and an actual +read fault bypasses an unguarded close. Eight unchanged original cases plus three +controls pass; two intentionally held original-bound timeout controls remain RED; +two real missing-table read controls pass. All fresh owned launchers/children join, +all source/dependency pins remain fixed, and no watchdog fires. This does not explain +or waive the remote Windows failure. The correction retains immutable case identity, +bridges runner abort to the original invocation, joins raw work before HOME/root +removal, and protects manual native reads/writes with `finally`. No timeout widening, +new skip, speculative warmup or serialization is introduced. + +Author correction gates, permanent old-runtime causal checks, two NEW complete +cumulative clean rounds after the last material fix, a NEW separate clean security +sitting and fresh final-source full CI plus all four same-artifact consumers remain +required. R1–R10 retain NONCLEAN verdicts. No Step 8d, whole Step 8, later-step or +whole-wave acceptance is inferred. No new ADR, user decision, provider capture, +credential, billing, production recipient or release upload is pending. + + +**R10 correction author gates.** Workspace lint/typecheck/test passes all **23 +tasks** (twenty cached); the fresh CLI cohort records **341 files / 5,475 passes / +ten established skips**. Build/format passes all seven tasks (five cached). +Seam and engine-dependency fences, tools typecheck, **720-file** isolation/ownership, +compiled **25-output / 408-input / 17-external** closure, **177** bootstrap controls +and compiled CLI smoke pass. No dependency-manifest change or core/LLM/DB/MCP/shared runtime +source delta is introduced. All 490 relative file targets in the five changed Markdown +homes resolve; mandatory managed Turbo headings and fenced examples are preserved. + +Permanent controls execute twelve identical test files against an immutable +`4c0fa5b7` Git archive and the corrected source. Old runtime: **29 precise semantic +assertion failures / 228 passes**; corrected source: **261 passes**, including four +additional helper tests. Both owned runner receipts report zero unhandled errors; +actual execution sessions join. No tracked runtime is mutated for these controls. +The original no-collection dependency-link attempt receives no passing credit. +A final Prettier-only comment indentation change in one test is independently +verified to preserve the complete TypeScript executable-token sequence; the original +execution pins and explicit format-equivalence receipt remain local. Causal record +SHA-256: `baf2ada2043e876a911e73d5e19f253617c1194687004511d1eb2c5a6ec72e16`. + +Material source/test/canonical-contract seal SHA-256: `bea2dbf25e58cf75ffa7f4421d1926f6b9f51fe5c7df4093e974c080cd23011f`. +Append-only status records are excluded from that seal to avoid a circular hash. +Initial test expectation/type/lint/format failures were corrected and retain their +failed attribution; only the successful final commands above receive credit. +These author gates do not replace two NEW complete cumulative clean rounds, the +NEW separate clean security sitting or fresh final-source full CI and all four +same-artifact consumers. Step 8d and whole Step 8 remain open. From d8e6b4d58c938b4274dd9cf3fbadd9e577e5a479 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 08:58:33 +0300 Subject: [PATCH 130/222] fix(cli): retain owners through advisory recovery Bind session notifications to their creation owner before governor and bus recovery. Preserve successful passive reporting, known refusals and original terminal restore capabilities. Classify only missing MCP consent wiring as fixed infrastructure 9. Add actual command/Home/native-owner regression pairs. Refresh the two coupled causal workload pins and remove migration maintenance from the fixture snapshot read while retaining its native close, original assertions and five-second bounds. Refs: ADR-0110, ADR-0103, Wave 3 Step 8d Co-Authored-By: Codex --- apps/cli/src/chat/session-host-owner.test.ts | 37 ++++ apps/cli/src/chat/session-host.ts | 39 +++- .../cli/src/chat/session-presentation.test.ts | 64 ++++++- apps/cli/src/chat/session-presentation.ts | 12 +- .../commands/chat-advisory-failure.test.ts | 161 ++++++++++++++++ .../commands/fs-workspace-admission.test.ts | 85 +++++++-- .../src/engine/mcp-consent-lifetime.test.ts | 92 +++++++++ apps/cli/src/engine/mcp-servers.test.ts | 6 +- apps/cli/src/engine/mcp-servers.ts | 8 +- apps/cli/src/home/drive-home-advisory.test.ts | 177 ++++++++++++++++++ docs/reference/cli/commands.md | 10 + tools/fs-policy-controls/mutations.mjs | 2 +- tools/secrets-controls/README.md | 2 +- tools/secrets-controls/mutations.mjs | 2 +- 14 files changed, 657 insertions(+), 40 deletions(-) create mode 100644 apps/cli/src/commands/chat-advisory-failure.test.ts create mode 100644 apps/cli/src/engine/mcp-consent-lifetime.test.ts create mode 100644 apps/cli/src/home/drive-home-advisory.test.ts diff --git a/apps/cli/src/chat/session-host-owner.test.ts b/apps/cli/src/chat/session-host-owner.test.ts index 7f31fa94..6a677881 100644 --- a/apps/cli/src/chat/session-host-owner.test.ts +++ b/apps/cli/src/chat/session-host-owner.test.ts @@ -90,6 +90,43 @@ async function fixture( } describe('actual session builder, persister and host cleanup ownership', () => { + it.each([false, true])( + 'owns reporter failure %s without fatalizing a successfully reported passive fault', + async (faulted) => { + const cause = new TypeError('PRIVATE_REPORTER_FAILURE'); + const notes: string[] = []; + const f = await fixture((opts) => ({ + ...opts, + onListenerError: (note) => { + if (faulted) throw cause; + notes.push(note); + }, + })); + const remove = f.built.handle.subscribe((event) => { + if (event.type === 'session:turn_started') throw new Error('synthetic passive listener'); + }); + try { + f.persister.beginUserTurn('synthetic prompt'); + await f.owner.work.enter(() => f.built.session.sendMessage('synthetic prompt')); + expect(f.owner.work.firstFailure?.cause, 'REPORTER_ORIGINAL_CAUSE').toBe( + faulted ? cause : undefined, + ); + expect(f.invocation.registry.observations.exitCode()).toBe(faulted ? 9 : 0); + expect( + f.store.loadMessages(f.built.sessionId).filter((message) => message.role === 'assistant'), + ).toHaveLength(faulted ? 0 : 1); + if (!faulted) + expect(notes).toContainEqual(expect.stringContaining('synthetic passive listener')); + } finally { + remove(); + await f.owner.work.close(); + f.persister.close(); + f.client.sqlite.close(); + } + expect(f.invocation.registry.activeOwners).toBe(0); + }, + ); + it('joins the complete real session and records its final terminal before releasing the host', async () => { const f = await fixture(); try { diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index cc009b99..63726bb2 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -76,6 +76,7 @@ import { sanitizeUntrustedInline } from '../render/sanitize.js'; import { hostDeadlineTimer, hostSleep } from '../process/sleep.js'; import type { SessionWorkOwner } from './session-work-owner.js'; import type { SessionHostOwner } from './session-host-owner.js'; +import { ownSessionNotification } from './session-presentation.js'; const SESSION_DURABILITY_FAILURE = 'this session could not be saved, so it will not send anything further — the transcript and cost on disk are behind what you see'; @@ -173,7 +174,8 @@ export interface BuildChatSessionOptions { /** * Sink for an `on_exceed: 'warn'` pre-egress budget warning. A session has no `budget:warning` event in * its namespace, so the surface (the REPL) is the warning channel — the command wires this to surface a - * one-line notice. Absent ⇒ a no-op (the warn stays non-blocking either way). + * one-line notice. Absent ⇒ a no-op. The warning stays advisory; an owned output/view failure is a separate + * invocation failure and is observed before the governor recovers from this callback. */ readonly onBudgetWarning?: (warning: ChatBudgetWarning) => void; /** @@ -196,8 +198,9 @@ export interface BuildChatSessionOptions { * on screen and the in-memory transcript is intact). * * Absent ⇒ **not** a no-op, unlike the sinks above. A dropped durable write must never be silent - * (`docs/standards/error-handling.md`), so the runtime still surfaces it out-of-band, where the process-level - * `unhandledRejection` net in `index.ts` reports it and keeps the process alive. + * (`docs/standards/error-handling.md`), so the runtime still surfaces it out-of-band to the process-level + * failure boundary. Owned notification faults are observed before advisory recovery; successfully reported + * passive failures retain their existing recoverable policy. */ readonly onListenerError?: (note: string) => void; /** @@ -381,8 +384,26 @@ function buildSessionRuntime( attachDurabilityProbe: (probe: () => Error | undefined) => void; } { let durabilityProbe: () => Error | undefined = () => undefined; + // Bind once to this actual host, including callbacks captured before fresh/resume/rebuild publication. + // Only a callback's own failure is observed: reporting an existing passive error is still supported. + const onBudgetWarning = + opts.onBudgetWarning === undefined + ? undefined + : ownSessionNotification(opts.workOwner, opts.onBudgetWarning); + const onUnpriced = + opts.onUnpriced === undefined + ? undefined + : ownSessionNotification(opts.workOwner, opts.onUnpriced); + const onEffortWithheld = + opts.onEffortWithheld === undefined + ? undefined + : ownSessionNotification(opts.workOwner, opts.onEffortWithheld); + const onListenerError = + opts.onListenerError === undefined + ? undefined + : ownSessionNotification(opts.workOwner, opts.onListenerError); // #228: a passive subscriber that throws — the chat persister failing a durable `history.db` write — is - // isolated by `deliver()` either way, so the turn itself is never broken. What was missing is where the + // isolated by `deliver()` either way, so successful surface reporting preserves the turn. What was missing is where the // failure GOES: with no sink the bus re-throws it out-of-band, and Node kills the process on the resulting // unhandled rejection, asynchronously, after the reply was already on screen. Wiring the sink turns that into // a reported, survivable event. When the surface supplied no `onListenerError` the sink deliberately re-throws @@ -390,7 +411,7 @@ function buildSessionRuntime( const bus = new RunEventBus({ now: () => new Date(opts.now()).toISOString(), onListenerError: (error, event) => { - const report = opts.onListenerError; + const report = onListenerError; if (report === undefined) { // Rethrowing from the sink is not a shrug — `RunEventBus.#reportListenerError` catches a throwing sink // and routes it to `#surfaceOutOfBand`, i.e. exactly the no-sink path, which is the fallback we want. @@ -459,10 +480,10 @@ function buildSessionRuntime( }; const governor = buildGovernorWiring( opts.chat, - opts.onBudgetWarning, + onBudgetWarning, opts.resolvePrice, providers.endpointKind, - opts.onUnpriced, + onUnpriced, opts.maxTokensEstimate, ); // The session event sink (1.W): a draft → bus → stamped sequenceNumber/timestamp. Hoisted so a SURFACE @@ -652,11 +673,11 @@ function buildSessionRuntime( withheldByCap: reasoningWithheldByCapFor, // …and when the gate withholds, SAY SO. The engine cannot print; it hands back the verdict (which carries the // tiers the model would take) and the surface turns it into the one sentence every path uses. - ...(opts.onEffortWithheld === undefined + ...(onEffortWithheld === undefined ? {} : { onEffortWithheld: (result: EffortGateResult, model: string) => { - opts.onEffortWithheld?.(effortWithheldNote(result, model)); + onEffortWithheld(effortWithheldNote(result, model)); }, }), registry, diff --git a/apps/cli/src/chat/session-presentation.test.ts b/apps/cli/src/chat/session-presentation.test.ts index a81e53ec..f522ba15 100644 --- a/apps/cli/src/chat/session-presentation.test.ts +++ b/apps/cli/src/chat/session-presentation.test.ts @@ -4,7 +4,11 @@ import type { SessionStreamHandleEvent } from '@relavium/core'; import { CliError } from '../process/errors.js'; import { invocationFixture } from '../process/invocation.test-support.js'; import { captureIo } from '../test-support.js'; -import { ownSessionOutput, ownSessionSubscriber } from './session-presentation.js'; +import { + ownSessionNotification, + ownSessionOutput, + ownSessionSubscriber, +} from './session-presentation.js'; import { SessionWorkOwner } from './session-work-owner.js'; const event: SessionStreamHandleEvent = { @@ -18,6 +22,64 @@ const event: SessionStreamHandleEvent = { }; describe('owned passive session presentation', () => { + it('observes the notification failure before advisory recovery and reentrant cancellation', async () => { + const f = invocationFixture(); + let notify: (value: string) => void = () => {}; + const owner = new SessionWorkOwner(f.invocation, 'chat', { + cancel: () => notify('cancel notification'), + cleanup: () => Promise.resolve(), + }); + const cause = new TypeError('PRIVATE_NOTIFICATION_FAILURE'); + const consume = vi.fn(() => { + throw cause; + }); + notify = ownSessionNotification(owner, consume); + try { + expect(() => notify('warning')).not.toThrow(); + notify('late warning'); + expect(consume).toHaveBeenCalledOnce(); + expect(owner.firstFailure?.cause).toBe(cause); + expect(f.registry.observations.exitCode()).toBe(9); + } finally { + await owner.close(); + } + expect(f.registry.activeOwners).toBe(0); + }); + + it('reports an existing passive error successfully without classifying that argument as a fatal fault', async () => { + const f = invocationFixture(); + const owner = new SessionWorkOwner(f.invocation, 'chat', { + cancel: () => {}, + cleanup: () => Promise.resolve(), + }); + const notes: unknown[] = []; + const report = ownSessionNotification(owner, (error: unknown) => { + notes.push(error); + }); + const reported = new TypeError('existing passive error'); + try { + report(reported); + expect(notes).toEqual([reported]); + expect(owner.firstFailure).toBeUndefined(); + expect(f.registry.termination).toBeUndefined(); + owner.stop(); + report(new Error('late callback must not enter')); + expect(notes).toEqual([reported]); + } finally { + await owner.close(); + } + expect(f.registry.activeOwners).toBe(0); + }); + + it('preserves ownerless notification identity and the original thrown cause', () => { + const cause = new Error('ownerless advisory'); + const consume = () => { + throw cause; + }; + expect(ownSessionNotification(undefined, consume)).toBe(consume); + expect(() => ownSessionNotification(undefined, consume)('warning')).toThrow(cause); + }); + it('seals before failure cancellation reenters the passive callback', async () => { const f = invocationFixture(); let subscriber: (event: SessionStreamHandleEvent) => void = () => {}; diff --git a/apps/cli/src/chat/session-presentation.ts b/apps/cli/src/chat/session-presentation.ts index 241e7992..6d802262 100644 --- a/apps/cli/src/chat/session-presentation.ts +++ b/apps/cli/src/chat/session-presentation.ts @@ -9,13 +9,21 @@ export function ownSessionSubscriber( owner: SessionWorkOwner | undefined, consume: (event: SessionStreamHandleEvent) => void, ): (event: SessionStreamHandleEvent) => void { + return ownSessionNotification(owner, consume); +} + +/** Observe a notification's own failure before an advisory producer can recover from it. */ +export function ownSessionNotification( + owner: SessionWorkOwner | undefined, + consume: (value: Value) => void, +): (value: Value) => void { if (owner === undefined) return consume; let stopped = false; - return (event) => { + return (value) => { if (stopped || owner.signal.aborted) return; try { owner.assertAdmitting(); - consume(event); + consume(value); } catch (cause) { stopped = true; owner.failure(cause); diff --git a/apps/cli/src/commands/chat-advisory-failure.test.ts b/apps/cli/src/commands/chat-advisory-failure.test.ts new file mode 100644 index 00000000..c42e2425 --- /dev/null +++ b/apps/cli/src/commands/chat-advisory-failure.test.ts @@ -0,0 +1,161 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { createClient, createSessionStore, runMigrations } from '@relavium/db'; +import { AgentSchema } from '@relavium/shared'; +import { expect, it, vi } from 'vitest'; + +import { buildResumedChatSession } from '../chat/session-host.js'; +import type { SessionWorkOwner } from '../chat/session-work-owner.js'; +import { scriptedResolver, textTurn } from '../chat/test-support.js'; +import { CliError, toUserFacing } from '../process/errors.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import { chatResumeCommand, driveJson, drivePlain } from './chat.js'; + +const cases = (['plain', 'json'] as const).flatMap((mode) => + (['budget', 'unpriced', 'effort'] as const).map((notice) => ({ mode, notice })), +); + +it.each(cases)( + 'owned $mode $notice notification failure versus healthy command', + async ({ mode, notice }) => { + for (const faulted of [false, true]) { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-advisory-owner-'))); + const client = createClient(':memory:'); + runMigrations(client.db); + const store = createSessionStore(client.db); + const f = invocationFixture(); + const captured = captureIo(); + const cause = new CliError('output_unavailable', ''); + const providers = scriptedResolver([textTurn('ADVISORY_REPLY')]); + const provider = providers.resolveProvider('anthropic'); + if (provider === undefined) throw new Error('Expected offline provider.'); + const stream = vi.spyOn(provider, 'stream'); + let owner: SessionWorkOwner | undefined; + let faultHits = 0; + let closed = 0; + let durableReplies = 0; + let id = 0; + const pattern = + notice === 'budget' + ? 'budget warning' + : notice === 'unpriced' + ? 'has no price' + : 'needs a larger max_tokens'; + const agent = AgentSchema.parse({ + id: 'advisory', + provider: 'anthropic', + model: + notice === 'unpriced' + ? 'synthetic-uncatalogued' + : notice === 'effort' + ? 'claude-haiku-4-5' + : 'claude-sonnet-4-6', + ...(notice === 'effort' ? { reasoning_effort: 'high', max_tokens: 10 } : {}), + system_prompt: 'offline', + }); + const iso = '2026-10-17T00:00:00.000Z'; + store.createSession({ + id: 'advisory-session', + agentSlug: agent.id, + agentSnapshot: agent, + context: { workingDir: dir, fsScopeTier: 'sandboxed' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: iso, + updatedAt: iso, + }); + mkdirSync(join(dir, '.relavium')); + writeFileSync( + join(dir, '.relavium', 'project.toml'), + '[chat]\nmax_cost_microcents = 1\non_exceed = "warn"\n', + ); + try { + const result = await chatResumeCommand( + { sessionId: 'advisory-session' }, + { + invocation: f.invocation, + io: { + ...captured.io, + stdin: Readable.from(['hello\n/exit\n']), + writeErr: (text) => { + if (faulted && text.includes(pattern)) { + faultHits++; + throw cause; + } + captured.io.writeErr(text); + }, + }, + global: { + cwd: dir, + json: mode === 'json', + color: false, + configPath: join(dir, 'absent-global.toml'), + verbosity: 'normal', + }, + providers, + now: () => Date.parse(iso), + uuid: () => `advisory-message-${++id}`, + buildResumedSession: (options) => { + owner = options.workOwner; + return buildResumedChatSession(options); + }, + openSessionStore: () => ({ + db: client.db, + store, + close: () => { + durableReplies = + store.loadFull('advisory-session')?.messages.filter((m) => m.role === 'assistant') + .length ?? 0; + closed++; + client.sqlite.close(); + }, + }), + lifecycle: { + onProcessExit: () => () => {}, + onInterrupt: () => () => {}, + onTerminationSignal: () => () => {}, + setRawMode: () => {}, + exit: () => {}, + }, + jobControlLifecycle: { + supported: false, + onSuspend: () => () => {}, + onContinue: () => () => {}, + suspendSelf: () => {}, + }, + drive: mode === 'json' ? driveJson : drivePlain, + }, + ).catch((failure: unknown) => { + expect(failure).toBe(cause); + return toUserFacing(failure).exitCode; + }); + expect(owner).toBeDefined(); + expect(result, 'ADVISORY_ORIGINAL_OWNER_OUTCOME').toBe(faulted ? 9 : 4); + expect(owner?.firstFailure?.cause, 'ADVISORY_ORIGINAL_CAUSE').toBe( + faulted ? cause : undefined, + ); + expect(faultHits).toBe(faulted ? 1 : 0); + expect(stream).toHaveBeenCalledTimes(faulted ? 0 : 1); + expect(durableReplies).toBe(faulted ? 0 : 1); + expect(closed).toBe(1); + expect(client.sqlite.open).toBe(false); + expect(f.registry.termination !== undefined).toBe(faulted); + expect(f.registry.activeOwners).toBe(0); + if (!faulted) { + expect(captured.err()).toContain(pattern); + expect(captured.out()).toContain('ADVISORY_REPLY'); + } + } finally { + stream.mockRestore(); + if (client.sqlite.open) client.sqlite.close(); + rmSync(dir, { recursive: true, force: true }); + } + } + }, +); diff --git a/apps/cli/src/commands/fs-workspace-admission.test.ts b/apps/cli/src/commands/fs-workspace-admission.test.ts index 1ec86cec..1652fd4a 100644 --- a/apps/cli/src/commands/fs-workspace-admission.test.ts +++ b/apps/cli/src/commands/fs-workspace-admission.test.ts @@ -1,4 +1,3 @@ -import * as sessionOpen from '../history/session-open.js'; import { invocationFixture } from '../process/invocation.test-support.js'; import type { CliInvocation } from '../process/invocation.js'; import { @@ -14,7 +13,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createClient } from '@relavium/db'; +import { createClient, createSessionStore } from '@relavium/db'; import { buildChatSession, buildResumedChatSession } from '../chat/session-host.js'; import { scriptedResolver } from '../chat/test-support.js'; @@ -27,13 +26,38 @@ import { captureIo } from '../test-support.js'; import { chatCommand, chatResumeCommand, type ChatDriver } from './chat.js'; const effects = vi.hoisted(() => ({ history: 0, keys: 0 })); +type WorkspacePhase = + | 'native-open-enter' + | 'native-open-return' + | 'seed-enter' + | 'seed-return' + | 'read-enter' + | 'read-closed' + | 'alias-retarget-enter' + | 'alias-retarget-return' + | 'resume-enter' + | 'resume-return' + | 'runner-abort'; +interface PhaseEntry { + readonly phase: WorkspacePhase; + readonly elapsedMs: number; +} +const phaseTrace = vi.hoisted((): { active: ((phase: WorkspacePhase) => void) | undefined } => ({ + active: undefined, +})); vi.mock('../db/open.js', async (original) => { const native = await original(); return { ...native, openLocalDb: (...args: Parameters) => { + const trace = phaseTrace.active; effects.history += 1; - return native.openLocalDb(...args); + trace?.('native-open-enter'); + try { + return native.openLocalDb(...args); + } finally { + trace?.('native-open-return'); + } }, }; }); @@ -54,6 +78,8 @@ interface WorkspaceCase { readonly pending: Set>; readonly removeAbort: () => void; readonly assertCurrent: () => void; + readonly trace: (phase: WorkspacePhase) => void; + readonly abortSnapshot: () => readonly PhaseEntry[] | undefined; } let currentCase: WorkspaceCase; beforeEach((context) => { @@ -65,7 +91,16 @@ beforeEach((context) => { symlinkSync(workspace, sensitiveAlias, 'junction'); writeFileSync(join(workspace, 'id_ed25519'), 'synthetic key sentinel'); const { invocation } = invocationFixture(); + const started = performance.now(); + const phases: PhaseEntry[] = []; + let aborted: readonly PhaseEntry[] | undefined; + const trace = (phase: WorkspacePhase): void => { + if (phases.length < 32) phases.push({ phase, elapsedMs: performance.now() - started }); + }; + phaseTrace.active = trace; const onAbort = (): void => { + trace('runner-abort'); + aborted ??= phases.map((entry) => ({ ...entry })); invocation.finiteExit.signal(15); }; context.signal.addEventListener('abort', onAbort, { once: true }); @@ -77,6 +112,8 @@ beforeEach((context) => { invocation, signal: context.signal, pending: new Set(), + trace, + abortSnapshot: () => aborted, removeAbort: () => context.signal.removeEventListener('abort', onAbort), assertCurrent: () => { context.signal.throwIfAborted(); @@ -89,11 +126,17 @@ beforeEach((context) => { }); afterEach(async () => { const fixture = currentCase; + const aborted = fixture.abortSnapshot(); + if (aborted !== undefined) { + // Fixed labels/numbers only. A timeout snapshot is diagnostic, never a native-close receipt. + console.error(JSON.stringify({ kind: 'workspace-case-abort-phases', phases: aborted })); + } // Runner timeout is cancellation, not raw settlement. Preserve this case's HOME/root until the actual join. await Promise.allSettled([...fixture.pending]); if (fixture.invocation.registry.termination !== undefined) await fixture.invocation.registry.termination; fixture.removeAbort(); + phaseTrace.active = undefined; expect(fixture.invocation.registry.activeOwners).toBe(0); vi.restoreAllMocks(); vi.unstubAllEnvs(); @@ -108,13 +151,19 @@ function ownCase(body: (fixture: WorkspaceCase) => Promise): Promise fixture.pending.add(raw); return raw; } -function readSession(fixture: WorkspaceCase) { +function readSession( + fixture: WorkspaceCase, + open: (path: string) => ReturnType = createClient, +) { fixture.assertCurrent(); - const opened = sessionOpen.openSessionStore(fixture.home); + fixture.trace('read-enter'); + // Observe the existing real database without repeating writable migration/checkpoint maintenance. + const client = open(join(fixture.home, '.relavium', 'history.db')); try { - return opened.store.loadFull('workspace-floor-session'); + return createSessionStore(client.db).loadFull('workspace-floor-session'); } finally { - opened.close(); + client.sqlite.close(); + fixture.trace('read-closed'); } } const global = (cwd: string): GlobalOptions => ({ @@ -131,6 +180,7 @@ const exitDriver: ChatDriver = async (ctx) => { }; async function seed(fixture: WorkspaceCase, cwd: string): Promise { fixture.assertCurrent(); + fixture.trace('seed-enter'); await chatCommand( { agent: undefined }, { @@ -142,6 +192,7 @@ async function seed(fixture: WorkspaceCase, cwd: string): Promise { drive: exitDriver, }, ); + fixture.trace('seed-return'); fixture.assertCurrent(); } @@ -226,8 +277,11 @@ describe('secret workspace admission before canonical persistence', () => { await seed(fixture, alias); const before = readSession(fixture); expect(before?.session.context.workingDir).toBe(workspace); + fixture.trace('alias-retarget-enter'); rmSync(alias); symlinkSync(home, alias, 'junction'); + fixture.trace('alias-retarget-return'); + fixture.trace('resume-enter'); expect( await chatResumeCommand( { sessionId: 'workspace-floor-session' }, @@ -240,6 +294,7 @@ describe('secret workspace admission before canonical persistence', () => { }, ), ).toBe(4); + fixture.trace('resume-return'); }); }); @@ -388,24 +443,22 @@ it('runner-abort ownership joins a held resumed builder before fixture removal a it('a real SQLite read failure still closes the manually acquired session store', () => ownCase(async (fixture) => { await seed(fixture, fixture.workspace); - const original = sessionOpen.openSessionStore; let native: ReturnType['sqlite'] | undefined; - const spy = vi.spyOn(sessionOpen, 'openSessionStore').mockImplementation((home) => { - const opened = original(home); - native = opened.db.$client; + const open = (path: string) => { + const client = createClient(path); + native = client.sqlite; try { native.prepare('DROP TABLE agent_sessions').run(); } catch (cause) { - opened.close(); + native.close(); throw cause; } - return opened; - }); + return client; + }; try { - expect(() => readSession(fixture)).toThrow(/no such table/); + expect(() => readSession(fixture, open)).toThrow(/no such table/); expect(native?.open).toBe(false); } finally { - spy.mockRestore(); if (native?.open === true) native.close(); } })); diff --git a/apps/cli/src/engine/mcp-consent-lifetime.test.ts b/apps/cli/src/engine/mcp-consent-lifetime.test.ts new file mode 100644 index 00000000..c61010da --- /dev/null +++ b/apps/cli/src/engine/mcp-consent-lifetime.test.ts @@ -0,0 +1,92 @@ +import { parseWorkflow } from '@relavium/core'; +import { expect, it, vi } from 'vitest'; + +import { SessionSurfaceLifetime } from '../chat/session-surface-lifetime.js'; +import { WorkflowLifetime } from '../commands/workflow-lifetime.js'; +import { CliError, toUserFacing } from '../process/errors.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import type { ResolvedStdioSpawn } from './mcp-consent.js'; +import { connectAgentMcp, connectWorkflowMcp, type StdioConsentGate } from './mcp-servers.js'; + +const cases = (['workflow', 'agent'] as const).flatMap((surface) => + (['missing', 'refused', 'cancelled', 'empty'] as const).map((scenario) => ({ + surface, + scenario, + })), +); + +it.each(cases)( + 'original $surface owner classifies $scenario MCP consent before native start', + async ({ surface, scenario }) => { + const f = invocationFixture(); + const start = vi.fn(() => { + throw new Error('Unexpected native MCP entry.'); + }); + const refs = [{ id: 'private-server-id', transport: 'stdio' as const, command: 'node' }]; + const agent = { + id: 'consent', + provider: 'openai' as const, + model: 'gpt-4o', + system_prompt: 'offline', + ...(scenario === 'empty' ? {} : { mcp_servers: refs }), + }; + const workflow = parseWorkflow( + JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'consent', + agents: [agent], + nodes: [ + { id: 'input', type: 'input' }, + { id: 'agent', type: 'agent', agent_ref: 'consent' }, + { id: 'output', type: 'output' }, + ], + edges: [ + { from: 'input', to: 'agent' }, + { from: 'agent', to: 'output' }, + ], + }, + }), + ); + const cancelled = new AbortController(); + if (scenario === 'cancelled') cancelled.abort(); + const gate: StdioConsentGate = () => { + if (scenario === 'refused') + return Promise.reject(new CliError('invalid_invocation', 'Consent was refused.')); + return Promise.resolve(new Map()); + }; + const options = { + cwd: process.cwd(), + startMcpClient: start, + ...(scenario === 'missing' || scenario === 'empty' ? {} : { consentGate: gate }), + ...(scenario === 'cancelled' ? { connectSignal: cancelled.signal } : {}), + }; + const lifetime = + surface === 'workflow' + ? new WorkflowLifetime(f.invocation, captureIo().io, () => () => {}) + : new SessionSurfaceLifetime(f.invocation, 'agent', () => () => {}); + let failed: { readonly cause: unknown } | undefined; + const result = await lifetime + .execute(async () => { + if (surface === 'workflow') await connectWorkflowMcp(workflow, options); + else await connectAgentMcp(scenario === 'empty' ? undefined : refs, options); + return 0; + }) + .catch((cause: unknown) => { + failed = { cause }; + return toUserFacing(cause).exitCode; + }); + const expected = + scenario === 'missing' ? 9 : scenario === 'refused' ? 2 : scenario === 'cancelled' ? 1 : 0; + expect(result, 'MISSING_CONSENT_ORIGINAL_OWNER_OUTCOME').toBe(expected); + expect(f.registry.observations.exitCode()).toBe(expected); + expect(f.registry.termination !== undefined).toBe(scenario === 'missing'); + expect(start).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(0); + if (scenario === 'missing') { + expect(toUserFacing(failed?.cause)).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + expect(toUserFacing(failed?.cause).message).not.toContain('private-server-id'); + } + }, +); diff --git a/apps/cli/src/engine/mcp-servers.test.ts b/apps/cli/src/engine/mcp-servers.test.ts index 1738a6b5..9c1e08b5 100644 --- a/apps/cli/src/engine/mcp-servers.test.ts +++ b/apps/cli/src/engine/mcp-servers.test.ts @@ -848,7 +848,7 @@ describe('the consent gate is REQUIRED, not optional (ADR-0084 §1)', () => { return Promise.resolve(fakeClient()); }, }), - ).rejects.toThrow(/without a consent gate/); + ).rejects.toMatchObject({ code: 'cli_internal', exitCode: 9 }); expect(started).toBe(0); }); @@ -874,7 +874,7 @@ describe('connectWorkflowMcp (run path)', () => { (toolIdsByServer: ReadonlyMap) => (): Promise => Promise.resolve(fakeClient({ toolIdsByServer })); - it('refuses a stdio declaration when no gate was wired, naming the server (ADR-0084 §1)', async () => { + it('refuses missing stdio consent wiring as fixed infrastructure failure (ADR-0084 §1)', async () => { const def = wf( ` - { id: scanner, model: claude-sonnet-4-6, provider: anthropic, system_prompt: go, mcp_servers: [{ id: fs, transport: stdio, command: node }] }\n`, ); @@ -887,7 +887,7 @@ describe('connectWorkflowMcp (run path)', () => { return fakeStart(new Map())(); }, }), - ).rejects.toThrow(/without a consent gate.*fs|fs.*without a consent gate/s); + ).rejects.toMatchObject({ code: 'cli_internal', exitCode: 9 }); expect(started).toBe(0); }); diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index 8eef7cee..28422537 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -692,12 +692,8 @@ function requireGateForStdio( gate: StdioConsentGate | undefined, ): void { if (gate !== undefined) return; - const stdio = refs.filter((ref) => ref.transport === 'stdio').map((ref) => ref.id); - if (stdio.length === 0) return; - throw new CliError( - 'internal', - `refusing to start local MCP program(s) (${stdio.join(', ')}): this surface reached the MCP host without a consent gate. This is a wiring defect, not a configuration one.`, - ); + if (!refs.some((ref) => ref.transport === 'stdio')) return; + throw new CliError('cli_internal', ''); } /** diff --git a/apps/cli/src/home/drive-home-advisory.test.ts b/apps/cli/src/home/drive-home-advisory.test.ts new file mode 100644 index 00000000..61492ee2 --- /dev/null +++ b/apps/cli/src/home/drive-home-advisory.test.ts @@ -0,0 +1,177 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createClient, createSessionStore, runMigrations } from '@relavium/db'; +import { expect, it, vi } from 'vitest'; + +import { buildChatSession } from '../chat/session-host.js'; +import type { SessionWorkOwner } from '../chat/session-work-owner.js'; +import { scriptedResolver, textTurn } from '../chat/test-support.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import type { RootAppProps } from '../render/tui/home-app.js'; +import { captureIo } from '../test-support.js'; +import { driveHome } from './drive-home.js'; + +vi.mock('../secrets/os-keychain.js', () => ({ + createOsKeychainStore: () => ({ get: () => null, set: () => undefined, delete: () => false }), +})); +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +it.each(['notice', 'store-listener'] as const)( + 'actual Home host owns an advisory %s failure before governor recovery', + async (faultKind) => { + for (const faulted of [false, true]) { + const f = invocationFixture(); + const client = createClient(':memory:'); + runMigrations(client.db); + const sessions = createSessionStore(client.db); + const cwd = mkdtempSync(join(tmpdir(), 'relavium-home-advisory-')); + mkdirSync(join(cwd, '.relavium')); + writeFileSync( + join(cwd, '.relavium', 'project.toml'), + '[chat]\nmax_cost_microcents = 1\non_exceed = "warn"\n', + ); + let props: RootAppProps | undefined; + let owner: SessionWorkOwner | undefined; + let finishRender = () => {}; + let closed = 0; + let durableReplies = 0; + let sessionId: string | undefined; + let id = 0; + const exited = new Promise((resolve) => { + finishRender = resolve; + }); + const providers = scriptedResolver([textTurn('first'), textTurn('second')]); + const provider = providers.resolveProvider('anthropic'); + if (provider === undefined) throw new Error('Expected offline provider.'); + const stream = vi.spyOn(provider, 'stream'); + const running = driveHome({ + invocation: f.invocation, + io: { ...captureIo().io, stdoutIsTty: true, stdinIsTty: true }, + global: { + cwd, + json: false, + color: false, + configPath: join(cwd, 'absent-global.toml'), + verbosity: 'normal', + }, + providers, + now: () => Date.parse('2026-10-17T00:00:00.000Z'), + uuid: () => `home-advisory-${++id}`, + buildSession: (options) => { + owner = options.workOwner; + return buildChatSession(options); + }, + openSessionStore: () => ({ + store: sessions, + db: client.db, + close: () => { + durableReplies = + sessionId === undefined + ? 0 + : (sessions.loadFull(sessionId)?.messages.filter((m) => m.role === 'assistant') + .length ?? 0); + closed++; + client.sqlite.close(); + }, + }), + // Real controller/session/store; this captured renderer does not claim installed Ink coverage. + render: (value) => { + props = value; + value.onRendererReady?.(async (publish) => { + await publish?.(); + }); + return { + unmount: finishRender, + waitUntilExit: () => exited, + waitUntilRenderFlush: () => Promise.resolve(), + }; + }, + getSize: () => ({ cols: 120, rows: 40 }), + subscribeResize: () => () => {}, + subscribeSignals: () => () => {}, + subscribeProcessExit: () => () => {}, + jobControlLifecycle: { + supported: false, + onSuspend: () => () => {}, + onContinue: () => () => {}, + suspendSelf: () => {}, + }, + onboardingTerminalLifecycle: { + onInput: () => () => {}, + isRawMode: () => false, + setRawMode: () => {}, + }, + }); + try { + if (props === undefined) throw new Error('Expected Home controller.'); + const controller = props.controller; + for (const char of 'hello') controller.handleKey(char, {}); + controller.handleKey('', { return: true }); + await flush(); + await flush(); + const session = controller.getSnapshot().session; + if (session === undefined) throw new Error('Expected active Home session.'); + sessionId = session.sessionId; + const cause = new TypeError('PRIVATE_ADVISORY_VIEW_FAILURE'); + let hits = 0; + const notice = session.store.notice.bind(session.store); + const spy = vi.spyOn(session.store, 'notice').mockImplementation((text) => { + if (faulted && faultKind === 'notice' && text.includes('budget warning')) { + hits++; + throw cause; + } + notice(text); + }); + const remove = session.store.subscribe(() => { + const last = session.store.getSnapshot().state.transcript.at(-1)?.text; + if ( + faulted && + faultKind === 'store-listener' && + hits === 0 && + last?.includes('budget warning') + ) { + hits++; + throw cause; + } + }); + try { + await session.processLine('second input'); + await flush(); + expect(hits).toBe(faulted ? 1 : 0); + expect(owner?.firstFailure?.cause, 'HOME_ADVISORY_ORIGINAL_CAUSE').toBe( + faulted ? cause : undefined, + ); + expect(f.registry.termination !== undefined).toBe(faulted); + if (!faulted) { + expect( + session.store + .getSnapshot() + .state.transcript.some((entry) => entry.text?.includes('budget warning')), + ).toBe(true); + controller.handleKey('c', { ctrl: true }); + await flush(); + await flush(); + controller.handleKey('c', { ctrl: true }); + } + } finally { + remove(); + spy.mockRestore(); + } + expect(await running, 'HOME_ADVISORY_ORIGINAL_OWNER_OUTCOME').toBe(faulted ? 9 : 0); + expect(stream).toHaveBeenCalledTimes(faulted ? 1 : 2); + expect(durableReplies).toBe(faulted ? 1 : 2); + expect(closed).toBe(1); + expect(client.sqlite.open).toBe(false); + expect(f.registry.activeOwners).toBe(0); + } finally { + finishRender(); + await running; + stream.mockRestore(); + vi.restoreAllMocks(); + if (client.sqlite.open) client.sqlite.close(); + rmSync(cwd, { recursive: true, force: true }); + } + } + }, +); diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 4f8d9624..a589cfb9 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1210,3 +1210,13 @@ cannot become successful provenance recovery or cause a second scaffold write. Checkpoint loading likewise preserves original causes: typed inconsistent history remains operational 1 with inspection advice; an unexpected driver/wiring failure selects infrastructure 9 before original-owner cleanup. + +Budget, unpriced-model, withheld-effort and passive-error notification callbacks +are bound at shared session-runtime creation to that actual host's owner. A failure +in notification output or view publication is observed before advisory recovery; +it stops further business admission without disabling captured terminal restoration. +Successful passive-error reporting keeps its existing recoverable policy: the +reported error argument alone does not become an infrastructure fault. Ownerless +embeddings keep their callback compatibility. Missing local-MCP consent wiring +uses the existing fixed infrastructure-9 classification before any native start; +ordinary consent refusal and known cancellation retain their distinct outcomes. diff --git a/tools/fs-policy-controls/mutations.mjs b/tools/fs-policy-controls/mutations.mjs index a6caaa7a..9672151a 100644 --- a/tools/fs-policy-controls/mutations.mjs +++ b/tools/fs-policy-controls/mutations.mjs @@ -253,7 +253,7 @@ for (const mutation of mutations) { : file === floorCases[0] ? 14 : file === workspaceCases - ? 8 + ? 10 : file === cooperativeCases ? 6 : file === windowsWinnerCases diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index 4e76b2cb..b7cf7d1d 100644 --- a/tools/secrets-controls/README.md +++ b/tools/secrets-controls/README.md @@ -109,7 +109,7 @@ transliteration or uniqueness of generated IDs. Two independent CLI notice controls remove the successful standalone command and live `/export` notice sites. Each requires named human and JSON witnesses in -the complete 15-case command-export or 133-case chat file. The live control also +the complete 16-case command-export or 133-case chat file. The live control also requires its actual interactive transcript/Ink-frame witness. A third control restores raw stderr delivery only for the live notice: human/JSON positive cases still pass, while the interactive witness must fail with its named assertion marker. diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index 13d696ac..512563b2 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -503,7 +503,7 @@ export const mutations = [ ' deps.io.writeErr(SESSION_EXPORT_REVIEW_NOTICE);\n', '', ['apps/cli/src/commands/chat-export.test.ts'], - 15, + 16, "reports fixed continuity and review notice on stderr after successful 'human' export", 'W2_EXPORT_COMMAND_NOTICE', [ From 518082b1d407fd8c23b0384e9ec150c05630b832 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 08:59:39 +0300 Subject: [PATCH 131/222] docs(docs): record eleventh review and advisory-owner correction Join all 302 paths and 1650 cumulative hunks, the four confirmed review findings, permanent old-runtime controls and final author gates. Preserve failed CI and unclassified Windows evidence separately from passing exact-package consumers. Keep complete causal republication, two new clean cumulative reviews, separate security and fresh final-source acceptance open before Step 8d can close. Refs: ADR-0110, ADR-0103, Wave 3 Step 8d Co-Authored-By: Codex --- AGENTS.md | 26 ++--- CLAUDE.md | 26 ++--- docs/roadmap/current.md | 12 +++ .../phase-2.5.5-hardening-and-remediation.md | 97 +++++++++++++++++++ 4 files changed, 137 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8884fbe7..69dcbe88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,18 +107,20 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first ten complete cumulative rounds are -NONCLEAN. R10 independently verifies all 293 paths / 1,600 cumulative hunks at -`4c0fa5b7` and confirms four original-owner failure gaps: passive session consumers, -export timestamp recovery, native editor preparation and checkpoint classification. -Corrections preserve known inline/typed refusals and actual producer/native/accounting -joins. Separate actual-SQLite triage proves filesystem fixture raw-work/read cleanup -gaps; original five-second bounds and semantic assertions are preserved. All four -same-artifact consumers pass at that reviewed source, but its Windows advisory -filesystem timeout remains failed with exact initiating phase/cause unclassified. -The older `09740575` Windows timeout also retains its failed, unclassified status. -Author correction gates pass (341 CLI files / 5,475 passes / ten established skips); -final-source independent review/security/CI/platform acceptance remains pending. +but not independently accepted. Its first eleven complete cumulative rounds are +NONCLEAN. R11 independently verifies all 302 paths / 1,650 cumulative hunks at +`b9bc7118`: advisory notification failures bypass their original owner, missing MCP +consent wiring has a noncanonical exit, and two causal workload declarations are stale. +Corrections at `d8e6b4d5` preserve successful passive reporting, classified refusals, +original restoration capabilities and actual producer/native/accounting joins. +The fixture snapshot no longer repeats migration/checkpoint maintenance; actual default +fresh/resume openers, original assertions and five-second bounds remain. Fixed-label, +bounded abort diagnostics do not certify cleanup or explain old platform failures. +All four same-artifact consumers pass at the reviewed source, but required CI and its +Windows advisory fail separately. The initiating Windows phase/cause remains unknown, +including earlier `4c0fa5b7` and `09740575` failures. Author correction gates pass +(344 CLI files / 5,496 passes / ten established skips); complete causal republication, +new independent review/security and final-source CI/platform acceptance remain pending. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. diff --git a/CLAUDE.md b/CLAUDE.md index 46aca843..b3f4194e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,18 +196,20 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first ten complete cumulative rounds are -NONCLEAN. R10 independently verifies all 293 paths / 1,600 cumulative hunks at -`4c0fa5b7` and confirms four original-owner failure gaps: passive session consumers, -export timestamp recovery, native editor preparation and checkpoint classification. -Corrections preserve known inline/typed refusals and actual producer/native/accounting -joins. Separate actual-SQLite triage proves filesystem fixture raw-work/read cleanup -gaps; original five-second bounds and semantic assertions are preserved. All four -same-artifact consumers pass at that reviewed source, but its Windows advisory -filesystem timeout remains failed with exact initiating phase/cause unclassified. -The older `09740575` Windows timeout also retains its failed, unclassified status. -Author correction gates pass (341 CLI files / 5,475 passes / ten established skips); -final-source independent review/security/CI/platform acceptance remains pending. +but not independently accepted. Its first eleven complete cumulative rounds are +NONCLEAN. R11 independently verifies all 302 paths / 1,650 cumulative hunks at +`b9bc7118`: advisory notification failures bypass their original owner, missing MCP +consent wiring has a noncanonical exit, and two causal workload declarations are stale. +Corrections at `d8e6b4d5` preserve successful passive reporting, classified refusals, +original restoration capabilities and actual producer/native/accounting joins. +The fixture snapshot no longer repeats migration/checkpoint maintenance; actual default +fresh/resume openers, original assertions and five-second bounds remain. Fixed-label, +bounded abort diagnostics do not certify cleanup or explain old platform failures. +All four same-artifact consumers pass at the reviewed source, but required CI and its +Windows advisory fail separately. The initiating Windows phase/cause remains unknown, +including earlier `4c0fa5b7` and `09740575` failures. Author correction gates pass +(344 CLI files / 5,496 passes / ten established skips); complete causal republication, +new independent review/security and final-source CI/platform acceptance remain pending. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index d1086616..5e723586 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1228,6 +1228,18 @@ All four same-artifact consumers pass at that reviewed source; its Windows advis remains failed. Correction author gates pass (341 CLI files / 5,475 passes / ten established skips); two NEW clean cumulative rounds, NEW separate security and fresh final-source CI/platform evidence remain pending. +The [eleventh cumulative review and advisory-owner corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-eleventh-cumulative-review-and-advisory-owner-corrections--2026-10-17) +records full NONCLEAN review of 302 paths / 1,650 independently verified hunks at +`b9bc7118`. Two original-owner/classification gaps and two stale required causal +workload declarations are corrected at `d8e6b4d5`. Healthy warnings, successful +passive reporting, known refusals and actual cleanup joins remain intact. The test's +manual native snapshot avoids redundant migration maintenance while preserving +real default fresh/resume entry and original five-second bounds. Four installed +consumers pass at the reviewed source; required CI and Windows advisory fail +separately, with the latter's initiating phase still unknown. Author gates pass +(344 CLI files / 5,496 passes / ten established skips). Complete causal republication, +two NEW clean cumulative rounds, separate security and final-source CI/platforms +remain required. No user decision is pending. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index fe15b6b5..7cafb627 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -7200,3 +7200,100 @@ failed attribution; only the successful final commands above receive credit. These author gates do not replace two NEW complete cumulative clean rounds, the NEW separate clean security sitting or fresh final-source full CI and all four same-artifact consumers. Step 8d and whole Step 8 remain open. + + +### Wave 3 Step 8d eleventh cumulative review and advisory-owner corrections — 2026-10-17 + +**NONCLEAN; corrections committed at `d8e6b4d5`.** Three NEW independent sittings +review frozen `b9bc7118` cumulatively from `d9625ff0`: all **302 physical paths / +1,650 U0 hunks** are independently rehashed and joined without overlap or omission. +Session/terminal: 79/532; workflow/management: 136/834; native/tools/docs: 87/284. +All three verdicts are NONCLEAN. Root reads all reports, corroborates the runtime +findings, and independently verifies every physical/full-diff/hunk identity. +Aggregate SHA-256: +`9eb66529d81ceef58303f6971517b54742bd1794ebbb752322b1739f3dc2e8a3`. +Detailed reports and physical ledgers remain local and Git-ignored. + +| Confirmed finding | Correction and preservation | +| --- | --- | +| ST-R11-1 — budget/unpriced notifications consume output/view faults before original-owner observation | Bind the synchronous notification family at shared runtime creation; observe its own original cause before governor/bus recovery and seal before cancellation reentry. Preserve healthy warnings, successful passive-error reporting, ownerless compatibility and original restoration capabilities | +| R11-WM-01 — missing local MCP consent wiring remains operational 1 despite the accepted infrastructure-9 inventory | Use existing fixed `cli_internal` at this exact pre-start guard. Preserve known consent/cancellation outcomes and actual workflow health priority; do not reinterpret `internal` globally | +| WNT-R11-1 — filesystem workspace declaration expects eight cases after two permanent controls were added | Refresh only the complete workload pin to ten; preserve named semantic assertions, exact inventory, runtime-error refusal and original bounds | +| WNT-R11-2 — command-export declaration and README still expect fifteen cases | Refresh to sixteen, preserving the new timestamp regression, full-file witness and restoration requirements | + +Fresh assigned reviewer execution: session **40 files / 744 passes**, workflow +**70 files / 1,301 passes / one established platform skip**, native **19 files / +201 passes**, with a separate **16-pass** command-export file and **101 Node tests**. +Every reported cohort has zero runner-unhandled errors and actual joined execution; +overlapping checks are not added into a unique project total. The 61-file / 1,726-case +union is collection evidence for 151 declarations, not execution of that mutation +family. Only two workload count mismatches were found. + +Actual owned chat-resume plain/JSON and Home controls reproduce hidden notification +faults with real SQLite and scripted offline providers. Effort, manual compaction +acknowledgement and installed Ink error controls retain their distinct successful +owner-observation evidence. The missing-consent guard refuses before spawn on both +actual lifetime routes, but originally selects the wrong classification. Shipping +callers currently supply consent; no CLI route to unconsented spawning is claimed. + +**Permanent causal evidence.** Three identical new regression files execute against +the corrected source and an immutable `b9bc7118` Git archive. All **1,736 original +tracked blobs** in that archive are independently reverified; only the three new +test files are explicitly overlaid. Old runtime: **eight precise assertion failures / +eight passes**. Corrected runtime: **sixteen passes**, zero skips. Both actual sessions +join and owned reporter receipts record zero unhandled errors. The healthy pairs, +original cause, provider entry counts, durable replies, native DB close and zero +remaining owners are checked. Parent source aliases and shared installed dependencies +are explicit; no Windows, provider-call or operator-TTY evidence is inferred. +Causal record SHA-256: +`e55cf4054a155db9dd844d13f7854687742ac06ec49f9f0984a7a0c0dbb9ed40`. +Additional actual session-host reporter tests preserve successful passive reporting +while observing a failure in the reporter itself; helper tests pin cancellation +reentry, stopped entry and ownerless compatibility. + +**Separately attributed platform evidence.** Reviewed-source push run `39006313132` +attempt 1 completes with **failure**. Both required causal families reject the stale +counts, and the main required job refuses their failed dependency. Coverage, wire, +usage, minimum-installable-Node and peer checks pass in their own scopes. Pack, +all four installed consumers and required package aggregation pass. Root and native +reviewer independently inspect five ZIPs, 70 public members and four bound receipts. +Tarball: **446,001 bytes**, SHA-256 +`98e6b32548904d59588a14e28b478c8bc62d6c21761ef3e001e24275679a8e77`; +physical audit SHA-256: +`5af82c23fb67d39877e96705cdd73d9a794a479180c64c730a02c4d8f420f58c`. +The current-runtime legs report Node 24.21.0; the minimum consumer is exactly 22.0.0. +Package passes do not make full CI green. + +The separate Windows advisory fails the benign-alias fixture at **5,108 ms** against +the unchanged **5,000-ms** bound (324 passes / eighteen established skips). Its +initiating phase/cause remains **unknown**, as do the earlier attributed failures. +Read-only source inspection and separately joined original/proposed native probes +prove that a manual fixture snapshot redundantly runs migration/checkpoint maintenance. +Both complete ten-case files pass locally with zero unhandled errors and all native +connections closed; benign-case connections change from four main/four lock to four +main/three lock. The private snapshot helper now uses the existing real SQLite/store +read with `finally` close. Default fresh/resume openers and all semantic assertions +remain intact. The actual missing-table control still proves native close on read +failure. Bounded fixed-label/elapsed-number abort diagnostics record no paths, values +or raw causes and grant no cleanup acknowledgement. This removes demonstrated +unnecessary test work; it does not claim to explain or repair the remote timeout. +No warmup, serialization, skip or timeout increase is introduced. + +**Correction author gates.** Final workspace lint/typecheck/test passes all **23 +tasks** (twenty cached), with fresh CLI **344 files / 5,496 passes / ten established +skips**. Build/format passes seven tasks (five cached). Seam and engine-dependency +fences, tools typecheck, **723-file** isolation/ownership, compiled **25-output / +408-input / 17-external** closure, **177** bootstrap controls and compiled CLI smoke +pass. Initial test-only type/lint failures and a misspelled helper-command attempt +receive no passing credit; successful final commands are separately retained. +The fourteen material source/test/tool/contract files remain unchanged through these +gates. Material seal SHA-256: +`c6f121b7b035b5d166afc6d2428c724ab55ce19552945802ddbe13ab1fd195dd`. +Append-only status records are excluded to avoid a circular hash. + +Complete filesystem/secrets causal republication, two NEW clean complete cumulative +rounds after the last material fix, a NEW separate clean security sitting and fresh +final-source full CI plus all four same-artifact consumers remain required. R1–R11 +retain NONCLEAN verdicts. Step 8d, whole Step 8 and later increments remain open. +No new ADR, maintainer decision, key, billing, provider capture, production recipient +or release upload is pending. Implementation continues automatically. From 3d93724466c46199eb7798f2e19e529c1eff232a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 09:28:08 +0300 Subject: [PATCH 132/222] fix(cli): preserve cooperative terminals and advisory ownership Publish the real cooperative session terminal before sealing business output, without reopening callbacks stopped by fatal coordination. Retain actual provider, accounting and persistence joins after terminal publication. Bind workflow effort, pricing and legacy-media notifications to their original invocation before passive recovery can consume their own output fault. Preserve actual 8 > 7 > 5 > 6 health and independent terminal restoration. Keep the clear/reseat native fixture root until original work retires and avoid redundant observer migration maintenance under the unchanged bound. Permanent JSON, workflow and held-producer controls pin both healthy and faulted behavior; old runtime yields 24 precise failures and six positives. Refs: ADR-0110, ADR-0103, Wave 3 Step 8d Co-Authored-By: Codex --- apps/cli/src/chat/session-host-owner.test.ts | 194 +++++++++-------- apps/cli/src/chat/session-host-owner.ts | 11 + apps/cli/src/chat/session-presentation.ts | 14 +- .../commands/chat-json-terminal-owner.test.ts | 204 ++++++++++++++++++ apps/cli/src/commands/chat.ts | 2 +- apps/cli/src/commands/fs-invocation.test.ts | 179 ++++++++++++--- apps/cli/src/commands/gate.ts | 27 ++- apps/cli/src/commands/run.ts | 26 +-- .../commands/workflow-advisory-owner.test.ts | 179 +++++++++++++++ apps/cli/src/commands/workflow-lifetime.ts | 12 ++ .../src/process/owned-notification.test.ts | 60 ++++++ apps/cli/src/process/owned-notification.ts | 24 +++ 12 files changed, 787 insertions(+), 145 deletions(-) create mode 100644 apps/cli/src/commands/chat-json-terminal-owner.test.ts create mode 100644 apps/cli/src/commands/workflow-advisory-owner.test.ts create mode 100644 apps/cli/src/process/owned-notification.test.ts create mode 100644 apps/cli/src/process/owned-notification.ts diff --git a/apps/cli/src/chat/session-host-owner.test.ts b/apps/cli/src/chat/session-host-owner.test.ts index 6a677881..fc507131 100644 --- a/apps/cli/src/chat/session-host-owner.test.ts +++ b/apps/cli/src/chat/session-host-owner.test.ts @@ -147,50 +147,66 @@ describe('actual session builder, persister and host cleanup ownership', () => { } }); - it('retains actual provider work after cancellation instead of treating the session terminal as quiescence', async () => { - const started = gate(); - const producer = gate(); - const provider = { - ...scriptedProvider([]), - stream: async function* () { - started.release(); - await producer.promise; - yield* textTurn('late synthetic reply'); - }, - }; - const f = await fixture((opts) => ({ - ...opts, - providers: { resolveProvider: () => provider, keyFor: () => 'synthetic-test-key' }, - })); - let turn: Promise | undefined; - try { - f.persister.beginUserTurn('synthetic prompt'); - turn = f.owner.work.enter(() => f.built.session.sendMessage('synthetic prompt')); - await started.promise; - let retired = false; - const closing = f.owner.work.close(); - void closing.then(() => { - retired = true; + it.each([false, true])( + 'retains actual provider work after cancellation (cooperative %s), independently of its terminal', + async (cooperative) => { + const started = gate(); + const producer = gate(); + const provider = { + ...scriptedProvider([]), + stream: async function* () { + started.release(); + await producer.promise; + yield* textTurn('late synthetic reply'); + }, + }; + const f = await fixture((opts) => ({ + ...opts, + providers: { resolveProvider: () => provider, keyFor: () => 'synthetic-test-key' }, + })); + let turn: Promise | undefined; + const terminalAdmission: boolean[] = []; + const unsubscribe = f.built.handle.subscribe((event) => { + if (event.type === 'session:cancelled') + terminalAdmission.push(!f.owner.work.signal.aborted); }); - await reactions(); - expect(f.store.loadSession(f.built.sessionId)?.status).toBe('ended'); - expect(retired, 'SESSION_NATIVE_ROOT_OUTLIVES_CANCEL_TERMINAL').toBe(false); - expect(f.invocation.registry.activeOwners).toBe(1); - producer.release(); - await turn; - await reactions(); - expect(f.owner.work.firstFailure).toBeUndefined(); - await closing; - await reactions(); - expect(f.invocation.registry.activeOwners).toBe(0); - expect(f.store.loadMessages(f.built.sessionId)).toHaveLength(0); - } finally { - producer.release(); - await turn; - f.persister.close(); - f.client.sqlite.close(); - } - }); + try { + f.persister.beginUserTurn('synthetic prompt'); + turn = f.owner.work.enter(() => f.built.session.sendMessage('synthetic prompt')); + await started.promise; + let retired = false; + if (cooperative) f.owner.cancel(); + const closing = f.owner.work.close(); + expect(terminalAdmission, 'SESSION_COOPERATIVE_TERMINAL_BEFORE_SEAL').toEqual([ + cooperative, + ]); + expect(() => + f.owner.work.enter(() => f.built.session.sendMessage('must not enter')), + ).toThrow(); + void closing.then(() => { + retired = true; + }); + await reactions(); + expect(f.store.loadSession(f.built.sessionId)?.status).toBe('ended'); + expect(retired, 'SESSION_NATIVE_ROOT_OUTLIVES_CANCEL_TERMINAL').toBe(false); + expect(f.invocation.registry.activeOwners).toBe(1); + producer.release(); + await turn; + await reactions(); + expect(f.owner.work.firstFailure).toBeUndefined(); + await closing; + await reactions(); + expect(f.invocation.registry.activeOwners).toBe(0); + expect(f.store.loadMessages(f.built.sessionId)).toHaveLength(0); + } finally { + unsubscribe(); + producer.release(); + await turn; + f.persister.close(); + f.client.sqlite.close(); + } + }, + ); it('retains a real terminal persistence refusal through actual cleanup and repeated barriers', async () => { const f = await fixture(); @@ -217,50 +233,54 @@ describe('actual session builder, persister and host cleanup ownership', () => { } }); - it('starts the common fatal budget for a refused terminal write before a held provider tail joins', async () => { - const started = gate(); - const producer = gate(); - const provider = { - ...scriptedProvider([]), - stream: async function* () { - started.release(); - await producer.promise; - yield* textTurn('late synthetic reply'); - }, - }; - const f = await fixture((opts) => ({ - ...opts, - providers: { resolveProvider: () => provider, keyFor: () => 'synthetic-test-key' }, - })); - let turn: Promise | undefined; - try { - f.client.sqlite.exec( - "CREATE TRIGGER refuse_held_terminal BEFORE UPDATE ON agent_sessions WHEN NEW.status = 'ended' BEGIN SELECT RAISE(ABORT, 'synthetic_held_terminal_refusal'); END", - ); - f.persister.beginUserTurn('synthetic prompt'); - turn = f.owner.work.enter(() => f.built.session.sendMessage('synthetic prompt')); - await started.promise; - void f.owner.work.close(); - await reactions(); - expect(f.persister.durabilityFailure).toBeDefined(); - expect( - f.invocation.registry.budget.deadline(), - 'SESSION_TERMINAL_FAILURE_BEFORE_HELD_PROVIDER_JOIN', - ).toBe(3000); - expect(f.invocation.registry.observations.exitCode()).toBe(9); - expect(f.invocation.registry.activeOwners).toBe(1); - producer.release(); - await turn; - await reactions(); - expect(f.invocation.registry.activeOwners).toBe(1); - } finally { - producer.release(); - await turn; - // Actual test-owned work has settled; this administrative close grants no product ACK. - f.persister.close(); - f.client.sqlite.close(); - } - }); + it.each([false, true])( + 'starts the fatal budget on terminal refusal before held producer joins (cooperative %s)', + async (cooperative) => { + const started = gate(); + const producer = gate(); + const provider = { + ...scriptedProvider([]), + stream: async function* () { + started.release(); + await producer.promise; + yield* textTurn('late synthetic reply'); + }, + }; + const f = await fixture((opts) => ({ + ...opts, + providers: { resolveProvider: () => provider, keyFor: () => 'synthetic-test-key' }, + })); + let turn: Promise | undefined; + try { + f.client.sqlite.exec( + "CREATE TRIGGER refuse_held_terminal BEFORE UPDATE ON agent_sessions WHEN NEW.status = 'ended' BEGIN SELECT RAISE(ABORT, 'synthetic_held_terminal_refusal'); END", + ); + f.persister.beginUserTurn('synthetic prompt'); + turn = f.owner.work.enter(() => f.built.session.sendMessage('synthetic prompt')); + await started.promise; + if (cooperative) f.owner.cancel(); + void f.owner.work.close(); + await reactions(); + expect(f.persister.durabilityFailure).toBeDefined(); + expect( + f.invocation.registry.budget.deadline(), + 'SESSION_TERMINAL_FAILURE_BEFORE_HELD_PROVIDER_JOIN', + ).toBe(3000); + expect(f.invocation.registry.observations.exitCode()).toBe(9); + expect(f.invocation.registry.activeOwners).toBe(1); + producer.release(); + await turn; + await reactions(); + expect(f.invocation.registry.activeOwners).toBe(1); + } finally { + producer.release(); + await turn; + // Actual test-owned work has settled; this administrative close grants no product ACK. + f.persister.close(); + f.client.sqlite.close(); + } + }, + ); it('preserves a committed native charge when its post-ACK publication fails', async () => { let acknowledged = false; diff --git a/apps/cli/src/chat/session-host-owner.ts b/apps/cli/src/chat/session-host-owner.ts index 7b9b0e12..47cda55f 100644 --- a/apps/cli/src/chat/session-host-owner.ts +++ b/apps/cli/src/chat/session-host-owner.ts @@ -49,6 +49,17 @@ export class SessionHostOwner { return persister; } + /** Cooperative end publishes its real terminal before sealing output; fatal stop stays sealed. */ + cancel(): void { + try { + this.#cancel(); + } catch (cause) { + this.work.cleanupUncertain(cause); + } finally { + this.work.stop(); + } + } + #cancel(): void { const built = this.#built; if (built === undefined || this.#cancelled === built) return; diff --git a/apps/cli/src/chat/session-presentation.ts b/apps/cli/src/chat/session-presentation.ts index 6d802262..3d90d047 100644 --- a/apps/cli/src/chat/session-presentation.ts +++ b/apps/cli/src/chat/session-presentation.ts @@ -1,6 +1,7 @@ import type { SessionStreamHandleEvent } from '@relavium/core'; import { CliError } from '../process/errors.js'; +import { ownNotification } from '../process/owned-notification.js'; import type { CliIo } from '../process/io.js'; import type { SessionWorkOwner } from './session-work-owner.js'; @@ -17,18 +18,7 @@ export function ownSessionNotification( owner: SessionWorkOwner | undefined, consume: (value: Value) => void, ): (value: Value) => void { - if (owner === undefined) return consume; - let stopped = false; - return (value) => { - if (stopped || owner.signal.aborted) return; - try { - owner.assertAdmitting(); - consume(value); - } catch (cause) { - stopped = true; - owner.failure(cause); - } - }; + return ownNotification(owner, consume); } /** Business output only. Entered terminal restoration retains the original captured capabilities. */ diff --git a/apps/cli/src/commands/chat-json-terminal-owner.test.ts b/apps/cli/src/commands/chat-json-terminal-owner.test.ts new file mode 100644 index 00000000..040ea854 --- /dev/null +++ b/apps/cli/src/commands/chat-json-terminal-owner.test.ts @@ -0,0 +1,204 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough, Readable } from 'node:stream'; +import { createClient, createSessionStore, runMigrations } from '@relavium/db'; +import { AgentSchema, RunOrSessionEventSchema } from '@relavium/shared'; +import { expect, it, vi } from 'vitest'; + +import { buildChatSession, buildResumedChatSession } from '../chat/session-host.js'; +import type { SessionWorkOwner } from '../chat/session-work-owner.js'; +import { scriptedResolver, textTurn } from '../chat/test-support.js'; +import { CliError, toUserFacing } from '../process/errors.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import { chatCommand, chatResumeCommand, driveJson } from './chat.js'; + +const cases = (['fresh', 'resumed'] as const).flatMap((entry) => [ + ...(['eof', 'exit', 'cancel'] as const).flatMap((ending) => + (['owned', 'ownerless', 'terminal-fault'] as const).map((mode) => ({ entry, ending, mode })), + ), + ...(['owned', 'terminal-fault'] as const).map((mode) => ({ + entry, + ending: 'sigint' as const, + mode, + })), +]); + +it.each(cases)( + '$entry $ending $mode keeps real terminal custody', + async ({ entry, ending, mode }) => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-json-terminal-'))); + const home = join(dir, 'home'); + mkdirSync(home); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); + const client = createClient(':memory:'); + runMigrations(client.db); + const store = createSessionStore(client.db); + const f = invocationFixture(); + const captured = captureIo(); + const providers = scriptedResolver([textTurn('TERMINAL_PRECEDING_REPLY')]); + const provider = providers.resolveProvider('anthropic'); + if (provider === undefined) throw new Error('Expected offline provider.'); + const stream = vi.spyOn(provider, 'stream'); + const cause = new CliError('output_unavailable', ''); + const faulted = mode === 'terminal-fault'; + const input = + ending === 'sigint' + ? new PassThrough() + : Readable.from([ending === 'eof' ? 'hello\n' : `hello\n/${ending}\n`]); + if (input instanceof PassThrough) input.write('hello\n'); + let interrupt: (() => void) | undefined; + let owner: SessionWorkOwner | undefined; + let sessionId = 'resumed-terminal'; + let ordinal = 0; + let closes = 0; + let terminalWrites = 0; + let durableReplies = 0; + let durableStatus: string | undefined; + const iso = '2026-10-17T00:00:00.000Z'; + const agent = AgentSchema.parse({ + id: 'terminal', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + system_prompt: 'offline', + }); + store.createSession({ + id: sessionId, + agentSlug: agent.id, + agentSnapshot: agent, + context: { workingDir: dir, fsScopeTier: 'sandboxed' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: iso, + updatedAt: iso, + }); + try { + const deps = { + ...(mode === 'ownerless' ? {} : { invocation: f.invocation }), + io: { + ...captured.io, + stdin: input, + writeOut: (text: string) => { + const event = RunOrSessionEventSchema.parse(JSON.parse(text)); + if (event.type === 'session:cancelled') { + terminalWrites++; + if (faulted) throw cause; + } + captured.io.writeOut(text); + }, + }, + global: { + cwd: dir, + json: true, + color: false, + configPath: join(dir, 'absent-global.toml'), + verbosity: 'normal' as const, + }, + providers, + now: () => Date.parse(iso), + uuid: () => `terminal-${++ordinal}`, + buildSession: async (options: Parameters[0]) => { + owner = options.workOwner; + const built = await buildChatSession(options); + sessionId = built.sessionId; + return built; + }, + buildResumedSession: (options: Parameters[0]) => { + owner = options.workOwner; + return buildResumedChatSession(options); + }, + openSessionStore: () => ({ + db: client.db, + store, + close: () => { + const row = store.loadFull(sessionId); + durableReplies = + row?.messages.filter((message) => message.role === 'assistant').length ?? 0; + durableStatus = row?.session.status; + closes++; + client.sqlite.close(); + }, + }), + lifecycle: { + onProcessExit: () => () => {}, + onInterrupt: (listener: () => void) => { + interrupt = listener; + return () => { + interrupt = undefined; + }; + }, + onTerminationSignal: () => () => {}, + setRawMode: () => {}, + exit: () => {}, + }, + jobControlLifecycle: { + supported: false, + onSuspend: () => () => {}, + onContinue: () => () => {}, + suspendSelf: () => {}, + }, + drive: (context: Parameters[0]) => + driveJson({ + ...context, + processLine: async (line, display) => { + await context.processLine(line, display); + if (ending === 'sigint') { + expect(interrupt).toBeDefined(); + interrupt?.(); + } + }, + }), + }; + const result = await ( + entry === 'fresh' + ? chatCommand({ agent: undefined }, deps) + : chatResumeCommand({ sessionId }, deps) + ).catch((failure: unknown) => { + expect(failure).toBe(cause); + return toUserFacing(failure).exitCode; + }); + expect(result, 'JSON_TERMINAL_ORIGINAL_OUTCOME').toBe(faulted ? 9 : 4); + expect(terminalWrites, 'JSON_TERMINAL_ATTEMPTED_EXACTLY_ONCE').toBe(1); + const events = captured + .out() + .trim() + .split('\n') + .map((line) => RunOrSessionEventSchema.parse(JSON.parse(line))); + expect( + events.filter((event) => event.type === 'session:cancelled'), + 'JSON_TERMINAL_REAL_DELIVERY', + ).toHaveLength(faulted ? 0 : 1); + if (!faulted) expect(events.at(-1)?.type).toBe('session:cancelled'); + expect(events.every((event) => 'sessionId' in event && event.sessionId === sessionId)).toBe( + true, + ); + expect(stream).toHaveBeenCalledOnce(); + expect(durableReplies).toBe(1); + expect(durableStatus).toBe('ended'); + expect(closes).toBe(1); + expect(client.sqlite.open).toBe(false); + expect(owner !== undefined).toBe(mode !== 'ownerless'); + if (faulted) { + const failure = owner?.firstFailure?.cause; + expect(failure).toBeInstanceOf(CliError); + if (!(failure instanceof CliError)) + throw new Error('Expected original output classification.'); + expect(failure.code).toBe('output_unavailable'); + expect(failure.cause).toBe(cause); + } else expect(owner?.firstFailure).toBeUndefined(); + expect(f.registry.termination !== undefined).toBe(faulted); + expect(f.registry.activeOwners).toBe(0); + } finally { + input.destroy(); + stream.mockRestore(); + if (client.sqlite.open) client.sqlite.close(); + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); + } + }, +); diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index f51f520c..c984cf97 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -1387,7 +1387,7 @@ export function createChatLineHandler( if (!cancelled) { cancelled = true; if (built.hostOwner === undefined) built.session.cancel(); - else built.hostOwner.work.stop(); // Observe an owned terminal-write refusal before producer joins. + else built.hostOwner.cancel(); // Publish the real cooperative terminal before stopping output admission. } }; // Whether an interactive prompt (the ink UI / Home controller) can answer an approval — the ink view is diff --git a/apps/cli/src/commands/fs-invocation.test.ts b/apps/cli/src/commands/fs-invocation.test.ts index 056f56b8..71d7cb7d 100644 --- a/apps/cli/src/commands/fs-invocation.test.ts +++ b/apps/cli/src/commands/fs-invocation.test.ts @@ -14,7 +14,8 @@ import { join } from 'node:path'; import { Readable } from 'node:stream'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createClient } from '@relavium/db'; +import { createClient, createSessionStore } from '@relavium/db'; +import type { CliInvocation } from '../process/invocation.js'; import { buildChatSession, @@ -34,13 +35,53 @@ import { fsPolicyWorkspace } from '../engine/tool-host/fs-policy.js'; import { createNodeFsCapability } from '../engine/tool-host/fs.js'; const effects = vi.hoisted(() => ({ history: 0, keys: 0, builders: 0 })); +type ClearPhase = + | 'command-enter' + | 'command-settled' + | 'native-open-enter' + | 'native-open-return' + | 'fresh-build-enter' + | 'fresh-build-return' + | 'resumed-build-enter' + | 'resumed-build-return' + | 'clear-enter' + | 'clear-return' + | 'reseat-enter' + | 'reseat-return' + | 'exit-enter' + | 'exit-return' + | 'observer-enter' + | 'observer-closed' + | 'runner-abort'; +interface ClearPhaseEntry { + readonly phase: ClearPhase; + readonly elapsedMs: number; +} +const clearTrace = vi.hoisted((): { active: ((phase: ClearPhase) => void) | undefined } => ({ + active: undefined, +})); +let clearOperation: + | { + readonly raw: Promise; + readonly rawSettled: () => boolean; + readonly invocation: CliInvocation; + readonly removeAbort: () => void; + readonly abortSnapshot: () => readonly ClearPhaseEntry[] | undefined; + } + | undefined; vi.mock('../db/open.js', async (original) => { const native = await original(); return { ...native, openLocalDb: (...args: Parameters) => { effects.history += 1; - return native.openLocalDb(...args); + const trace = clearTrace.active; + trace?.('native-open-enter'); + try { + return native.openLocalDb(...args); + } finally { + trace?.('native-open-return'); + } }, }; }); @@ -75,6 +116,11 @@ let workspace: string; let outside: string; const argv = (...tokens: string[]) => ['node', 'relavium', ...tokens]; beforeEach(() => { + if ( + clearOperation !== undefined && + (!clearOperation.rawSettled() || clearOperation.invocation.registry.activeOwners !== 0) + ) + throw new Error('Previous native filesystem fixture is not retired.'); base = realpathSync.native(mkdtempSync(join(tmpdir(), 'relavium-fs-invocation-'))); home = join(base, 'home'); workspace = join(base, 'workspace'); @@ -83,10 +129,30 @@ beforeEach(() => { vi.stubEnv('HOME', home); vi.stubEnv('USERPROFILE', home); Object.assign(effects, { history: 0, keys: 0, builders: 0 }); + clearOperation = undefined; }); -afterEach(() => { +afterEach(async () => { + const root = base; + const operation = clearOperation; + if (operation !== undefined) { + const phases = operation.abortSnapshot(); + if (phases !== undefined) + console.error(JSON.stringify({ kind: 'clear-reseat-case-abort-phases', phases })); + // Runner cancellation is not settlement. Keep this root/HOME until the original operation and owners join. + await Promise.allSettled([operation.raw]); + if (operation.invocation.registry.termination !== undefined) + await operation.invocation.registry.termination; + expect(operation.invocation.registry.activeOwners).toBe(0); + operation.removeAbort(); + } + // A timed-out hook may resume after the runner attempted another case. Never restore that case's environment. + if (operation !== undefined && clearOperation !== operation) { + rmSync(root, { recursive: true, force: true }); + return; + } + clearTrace.active = undefined; vi.unstubAllEnvs(); - rmSync(base, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); }); function global(cwd = workspace): GlobalOptions { return { cwd, json: false, color: false, configPath: undefined, verbosity: 'normal' }; @@ -270,54 +336,119 @@ it('resumes current config at the canonical original workspace with fresh grants expect((await fs.readFile('../outside/allowed.txt', {})).content).toBe('allowed'); }); -it('keeps one owned policy through clear and model reseating without persisting the grants', async () => { +it('keeps one owned policy through clear and model reseating without persisting the grants', async (context) => { config(workspace, '[chat]\nfs_scope="project"\n'); + const f = invocationFixture(); + const started = performance.now(); + const phases: ClearPhaseEntry[] = []; + let aborted: readonly ClearPhaseEntry[] | undefined; + const trace = (phase: ClearPhase): void => { + if (phases.length < 32) phases.push({ phase, elapsedMs: performance.now() - started }); + }; + clearTrace.active = trace; + const onAbort = (): void => { + trace('runner-abort'); + aborted ??= phases.map((entry) => ({ ...entry })); + f.invocation.finiteExit.signal(15); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); const policies: BuildChatSessionOptions['fsPolicy'][] = []; let round = 0; const drive: ChatDriver = async (ctx) => { ctx.startSession(); round += 1; if (round === 1) { + trace('clear-enter'); await ctx.processLine('/clear'); + trace('clear-return'); return { kind: ctx.stopReason() }; } if (round === 2) { + trace('reseat-enter'); ctx.onReseat?.({ modelId: 'gpt-4o-mini', provider: 'openai' }); + trace('reseat-return'); return { kind: ctx.stopReason() }; } + trace('exit-enter'); await ctx.processLine('/exit'); + trace('exit-return'); return { kind: 'exit' }; }; - await chatCommand( - { agent: undefined }, - { - io: { ...captureIo().io, stdoutIsTty: true }, - writeControl: () => undefined, - global: { ...global(), noAltScreen: true, allowFsRoots: [outside] }, - providers: scriptedResolver([]), - drive, - buildSession: async (options) => { - policies.push(options.fsPolicy); - return buildChatSession(options); - }, - buildResumedSession: async (options) => { - policies.push(options.fsPolicy); - return buildResumedChatSession(options); + trace('command-enter'); + const raw = Promise.resolve().then(() => { + context.signal.throwIfAborted(); + return chatCommand( + { agent: undefined }, + { + invocation: f.invocation, + lifecycle: { + onProcessExit: () => () => {}, + onInterrupt: () => () => {}, + onTerminationSignal: () => () => {}, + setRawMode: () => {}, + exit: () => {}, + }, + io: { ...captureIo().io, stdoutIsTty: true }, + writeControl: () => undefined, + global: { ...global(), noAltScreen: true, allowFsRoots: [outside] }, + providers: scriptedResolver([]), + drive, + buildSession: async (options) => { + policies.push(options.fsPolicy); + trace('fresh-build-enter'); + try { + return await buildChatSession(options); + } finally { + trace('fresh-build-return'); + } + }, + buildResumedSession: async (options) => { + policies.push(options.fsPolicy); + trace('resumed-build-enter'); + try { + return await buildResumedChatSession(options); + } finally { + trace('resumed-build-return'); + } + }, }, + ); + }); + let rawSettled = false; + void raw.then( + () => { + rawSettled = true; + }, + () => { + rawSettled = true; }, ); + clearOperation = { + raw, + rawSettled: () => rawSettled, + invocation: f.invocation, + removeAbort: () => context.signal.removeEventListener('abort', onAbort), + abortSnapshot: () => aborted, + }; + await raw; + trace('command-settled'); + context.signal.throwIfAborted(); expect(policies).toHaveLength(3); expect(policies[0]).toBeDefined(); expect(policies.every((policy) => policy === policies[0])).toBe(true); - const opened = openSessionStore(home); + trace('observer-enter'); + // Inspect the existing real DB without repeating writable migrations and privacy maintenance. + const client = createClient(join(home, '.relavium', 'history.db')); try { - for (const row of opened.store.listSessions()) { - const loaded = opened.store.loadFull(row.id); + const store = createSessionStore(client.db); + for (const row of store.listSessions()) { + const loaded = store.loadFull(row.id); expect(loaded?.session.context).not.toHaveProperty('fsPolicy'); expect(loaded?.session.context).not.toHaveProperty('allowFsRoots'); } } finally { - opened.close(); + client.sqlite.close(); + trace('observer-closed'); } if (policies[0] !== undefined) expect(fsPolicyWorkspace(policies[0])).toBe(workspace); }); diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index 1c9f5631..877274d6 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -31,6 +31,7 @@ import { MaskedSecretSchema, WorkflowSnapshotSchema, type GateDecision, + type MediaBilledModality, type RunStatus, } from '@relavium/shared'; @@ -418,8 +419,12 @@ async function resumeGateOwned( // resolveSaveToRoot), else the resumer's cwd — so a same-machine resume writes under the original dir, while // a resume on a different machine / after that dir was deleted falls back gracefully instead of failing. const saveToRoot = resolveSaveToRoot(snapshot.projectRoot, deps.global.cwd); - const wiring = buildMediaEngineWiring(opened.db, homeDir, saveToRoot, config, (m) => - deps.io.writeErr(`${m}\n`), + const wiring = buildMediaEngineWiring( + opened.db, + homeDir, + saveToRoot, + config, + lifetime.notification((m: string) => deps.io.writeErr(`${m}\n`)), ); // D15 catalog load-check on the resume path too (the SAME helper `run` uses) — re-validate the snapshot's // authored `output_modalities` against the CURRENT catalog, so a model that lost a capability between the @@ -498,22 +503,26 @@ async function resumeGateOwned( // ADR-0071 §6: the far side of a gate re-runs agent nodes, so an authored tier the bound model rejects is // withheld here too — and this surface has no other safety net (no picker, no footer, no client-side check). // stderr, never stdout (`--json`). - onEffortWithheld: onceEffortNotice((note) => deps.io.writeErr(`warning: ${note}\n`)), + onEffortWithheld: onceEffortNotice( + lifetime.notification((note: string) => deps.io.writeErr(`warning: ${note}\n`)), + ), // ADR-0071 §K7: a resumed agent node on an unpriced model degrades to `allow`, so `budget.max_cost_microcents` // did not apply to it — say so on stderr (never stdout, `--json`). `budget.strict_cost_cap` blocks instead. - onUnpriced: (model, capMicrocents, modalities) => - deps.io.writeErr( - `warning: ${unpricedModelNote(model, capMicrocents, 'budget.strict_cost_cap', modalities)}\n`, - ), + onUnpriced: lifetime.notification( + (model: string, capMicrocents: number, modalities?: readonly MediaBilledModality[]) => + deps.io.writeErr( + `warning: ${unpricedModelNote(model, capMicrocents, 'budget.strict_cost_cap', modalities)}\n`, + ), + ), // ADR-0074 §3: new egress is HELD while a resumed media job submitted by an older Relavium settles — its // cost basis was not recorded, so the cap cannot be trusted until the job reports its real charge. Say so, // or a resume looks like an unexplained stall. stderr, never stdout (`--json` stays a pure event stream). // stderr, never stdout, so `--json` stays a pure event stream. The SENTENCE is // {@link legacyMediaJobHoldNotice} so it can be pinned directly — this sink reached the terminal for the // first time only when the engine wiring was fixed, and nothing had ever rendered it. - onLegacyMediaJobHold: (nodeIds) => { + onLegacyMediaJobHold: lifetime.notification((nodeIds: readonly string[]) => { deps.io.writeErr(legacyMediaJobHoldNotice(nodeIds)); - }, + }), // 2.5.A (ADR-0055): wire the SAME read+write fs + process ToolHost the `relavium run` path wires, jailed // to the ORIGINAL run's project root (`saveToRoot` — the original `runs.project_root` when it still exists // on this machine, else the resumer's cwd, exactly like the `save_to` root) at the resolved `fs_scope`. So a diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 1525a591..bc0772b5 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -368,19 +368,17 @@ async function runOwned( // run succeeds, the knob does nothing, and the bill arrives at the provider's default tier. // `onceEffortNotice`: the gate is consulted on EVERY agent-node execution, so an agent inside a `loop` would // otherwise print the same warning on every iteration. A withheld tier is a standing condition, not an event. - const onEffortWithheld = onceEffortNotice((note: string): void => - deps.io.writeErr(`warning: ${note}\n`), + const onEffortWithheld = onceEffortNotice( + lifetime.notification((note: string): void => deps.io.writeErr(`warning: ${note}\n`)), ); // ADR-0071 §K7: a turn ran on a model we could not price, so `budget.max_cost_microcents` did not apply. The // governor already dedups per model. STDERR, never stdout (`--json`). `budget.strict_cost_cap` blocks instead. - const onUnpriced = ( - model: string, - capMicrocents: number, - modalities?: readonly MediaBilledModality[], - ): void => - deps.io.writeErr( - `warning: ${unpricedModelNote(model, capMicrocents, 'budget.strict_cost_cap', modalities)}\n`, - ); + const onUnpriced = lifetime.notification( + (model: string, capMicrocents: number, modalities?: readonly MediaBilledModality[]): void => + deps.io.writeErr( + `warning: ${unpricedModelNote(model, capMicrocents, 'budget.strict_cost_cap', modalities)}\n`, + ), + ); let engineOptions: BuildEngineOptions = { providers, toolEnv, @@ -393,8 +391,12 @@ async function runOwned( // Bound to a `const` so the closures below keep the narrowing: `opened` is a `let` assigned inside // this same try, and TypeScript cannot prove it is still set by the time a callback runs. const history = opened; - const wiring = buildMediaEngineWiring(history.db, homeDir, deps.global.cwd, config, (m) => - deps.io.writeErr(`${m}\n`), + const wiring = buildMediaEngineWiring( + history.db, + homeDir, + deps.global.cwd, + config, + lifetime.notification((m: string) => deps.io.writeErr(`${m}\n`)), ); mediaCasRoot = wiring.media.casRoot; // hoisted for the run-end host media GC below // D15 load-check (ADR-0044 §2 / ADR-0045 §1): an incapable / malformed-generative authored `output_modalities` diff --git a/apps/cli/src/commands/workflow-advisory-owner.test.ts b/apps/cli/src/commands/workflow-advisory-owner.test.ts new file mode 100644 index 00000000..12baf0b3 --- /dev/null +++ b/apps/cli/src/commands/workflow-advisory-owner.test.ts @@ -0,0 +1,179 @@ +import { randomUUID } from 'node:crypto'; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseWorkflow, type WorkflowDefinition } from '@relavium/core'; +import { + createClient, + createRunHistoryStore, + createRunLeasePort, + runEvents, + runMigrations, +} from '@relavium/db'; +import { expect, it, vi } from 'vitest'; + +import { scriptedResolver, textTurn } from '../chat/test-support.js'; +import { buildEngine } from '../engine/build-engine.js'; +import { createCliHost } from '../engine/host.js'; +import { CliError, toUserFacing } from '../process/errors.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import { gateCommand } from './gate.js'; +import { runCommand } from './run.js'; + +const cases = (['run', 'gate'] as const).flatMap((surface) => + (['unpriced', 'effort'] as const).flatMap((notice) => + [false, true].map((json) => ({ surface, notice, json })), + ), +); + +it.each(cases)( + '$surface $notice json=$json owns advisory failure before provider entry', + async ({ surface, notice, json }) => { + for (const faulted of [false, true]) { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-workflow-notice-'))); + const client = createClient(':memory:'); + runMigrations(client.db); + const f = invocationFixture(); + const captured = captureIo(); + const cause = new CliError('output_unavailable', ''); + const providers = scriptedResolver([textTurn('WORKFLOW_NOTICE_REPLY')]); + const provider = providers.resolveProvider('anthropic'); + if (provider === undefined) throw new Error('Expected offline provider.'); + const stream = vi.spyOn(provider, 'stream'); + const source = JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'notice-owner', + budget: { max_cost_microcents: 10000000, on_exceed: 'warn' }, + agents: [ + { + id: 'a', + provider: 'anthropic', + model: notice === 'unpriced' ? 'synthetic-uncatalogued' : 'claude-haiku-4-5', + system_prompt: 'offline', + ...(notice === 'effort' ? { reasoning_effort: 'high' } : {}), + }, + ], + nodes: [ + ...(surface === 'gate' + ? [{ id: 'approval', type: 'human_gate', gate_type: 'approval' }] + : []), + { id: 'work', type: 'agent', agent_ref: 'a', prompt_template: 'hello', max_tokens: 10 }, + ], + edges: surface === 'gate' ? [{ from: 'approval', to: 'work' }] : [], + }, + }); + const path = join(dir, 'workflow.relavium.yaml'); + writeFileSync(path, source); + let closes = 0; + let warnings = 0; + let durableTypes: string[] = []; + const storeFor = (def: WorkflowDefinition) => + createRunHistoryStore(client.db, { + uuid: randomUUID, + now: Date.now, + projectRoot: dir, + workflow: { + slug: def.workflow.id, + name: def.workflow.id, + definitionJson: JSON.stringify(def), + }, + }); + const close = () => { + durableTypes = client.db + .select({ type: runEvents.eventType }) + .from(runEvents) + .all() + .map((row) => row.type); + closes++; + client.sqlite.close(); + }; + const deps = { + invocation: f.invocation, + subscribeSignals: () => () => {}, + providers, + io: { + ...captured.io, + writeErr: (text: string) => { + if (text.startsWith('warning:')) { + warnings++; + if (faulted) throw cause; + } + captured.io.writeErr(text); + }, + }, + global: { + cwd: dir, + json, + color: false, + configPath: join(dir, 'absent.toml'), + verbosity: 'normal' as const, + }, + }; + try { + let result: number; + if (surface === 'run') { + result = await runCommand( + { workflow: path, input: [], allowMcpStdio: [] }, + { + ...deps, + openRunStore: (def, _home, _cwd, captureClose) => { + captureClose?.(close); + return { + db: client.db, + store: storeFor(def), + close, + terminalOutboxPath: join(dir, 'terminal-outbox.ndjson'), + }; + }, + }, + ).catch((failure: unknown) => toUserFacing(failure).exitCode); + } else { + const def = parseWorkflow(source, { source: 'advisory-fixture' }); + const store = storeFor(def); + const engine = await buildEngine({ + providers, + host: createCliHost(store, { runLeases: createRunLeasePort(store) }), + }); + const handle = engine.start({ workflow: def, inputs: {} }); + for await (const event of handle.events) if (event.type === 'run:paused') break; + expect(stream).not.toHaveBeenCalled(); + result = await gateCommand( + { runId: handle.runId, approve: true }, + { + ...deps, + openDb: (_home, captureClose) => { + captureClose?.(close); + return { db: client.db, close }; + }, + }, + ).catch((failure: unknown) => toUserFacing(failure).exitCode); + } + expect(result, 'WORKFLOW_ADVISORY_ORIGINAL_OWNER').toBe(faulted ? 9 : 0); + expect(warnings).toBe(1); + expect(stream, 'WORKFLOW_ADVISORY_REFUSES_PAID_ENTRY').toHaveBeenCalledTimes( + faulted ? 0 : 1, + ); + expect(f.registry.observations.exitCode()).toBe(faulted ? 9 : 0); + expect(f.registry.termination !== undefined).toBe(faulted); + expect(f.registry.activeOwners).toBe(0); + expect(closes).toBe(1); + expect(client.sqlite.open).toBe(false); + expect( + durableTypes.filter((type) => + ['run:completed', 'run:cancelled', 'run:failed'].includes(type), + ), + ).toEqual([faulted ? 'run:cancelled' : 'run:completed']); + if (!faulted) + expect(captured.err()).toContain( + notice === 'unpriced' ? 'has no price' : 'needs a larger max_tokens', + ); + } finally { + stream.mockRestore(); + if (client.sqlite.open) client.sqlite.close(); + rmSync(dir, { recursive: true, force: true }); + } + } + }, +); diff --git a/apps/cli/src/commands/workflow-lifetime.ts b/apps/cli/src/commands/workflow-lifetime.ts index 9e2e5883..e061a7ca 100644 --- a/apps/cli/src/commands/workflow-lifetime.ts +++ b/apps/cli/src/commands/workflow-lifetime.ts @@ -10,6 +10,7 @@ import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import { defaultSubscribeSignals } from '../process/signals.js'; import { subscribeWithSignalCustody } from '../process/signal-custody.js'; import { WorkflowTerminal } from './workflow-terminal.js'; +import { ownNotification } from '../process/owned-notification.js'; /** Single command owner: registration/signals precede its first owned asynchronous preparation. */ export class WorkflowLifetime { @@ -126,6 +127,17 @@ export class WorkflowLifetime { this.#safety = safety; this.#hostSafe ||= hostSafe; } + /** Observe advisory sink/formatter faults before engine recovery, using this original owner. */ + notification(consume: (...args: Args) => void): (...args: Args) => void { + return ownNotification( + { + signal: this.signal, + assertAdmitting: () => this.checkpoint(), + failure: (cause) => this.failure(cause), + }, + consume, + ); + } failure(cause: unknown): void { if (this.isStoppedFailure(cause)) return; const classified = toUserFacing(cause); diff --git a/apps/cli/src/process/owned-notification.test.ts b/apps/cli/src/process/owned-notification.test.ts new file mode 100644 index 00000000..c20c9781 --- /dev/null +++ b/apps/cli/src/process/owned-notification.test.ts @@ -0,0 +1,60 @@ +import { expect, it, vi } from 'vitest'; + +import { SessionWorkOwner } from '../chat/session-work-owner.js'; +import { WorkflowLifetime } from '../commands/workflow-lifetime.js'; +import { captureIo } from '../test-support.js'; +import { CliError } from './cli-error.js'; +import { invocationFixture } from './invocation.test-support.js'; +import { ownNotification } from './owned-notification.js'; + +it('keeps exact arguments and seals before the actual owner reenters during failure cancellation', async () => { + const f = invocationFixture(); + let notify: (model: string, amount: number, modalities?: readonly string[]) => void = () => {}; + const owner = new SessionWorkOwner(f.invocation, 'chat', { + cancel: () => notify('must not enter after original sink failed', 1), + cleanup: () => Promise.resolve(), + }); + const modalities = Object.freeze(['audio']); + const cause = new TypeError('PRIVATE_NOTIFICATION_FORMATTER_FAULT'); + const consume = vi.fn((model: string, amount: number, supplied?: readonly string[]) => { + expect(model).toBe('synthetic'); + expect(amount).toBe(0); + expect(supplied).toBe(modalities); + throw cause; + }); + notify = ownNotification(owner, consume); + try { + expect(() => notify('synthetic', 0, modalities)).not.toThrow(); + notify('must not enter late', 2); + expect(consume).toHaveBeenCalledOnce(); + expect(owner.firstFailure?.cause).toBe(cause); + expect(f.registry.observations.exitCode()).toBe(9); + } finally { + await owner.close(); + } + expect(f.registry.activeOwners).toBe(0); +}); + +it.each([8, 7, 5, 6] as const)( + 'observed workflow health %s survives a passively recovered notification failure', + async (health) => { + const f = invocationFixture(); + const lifetime = new WorkflowLifetime(f.invocation, captureIo().io, () => () => {}); + const cause = new CliError('output_unavailable', ''); + const consume = vi.fn(() => { + throw cause; + }); + const notify = lifetime.notification(consume); + const result = await lifetime.execute(() => { + lifetime.publish(health, true); + notify(); + notify(); + return Promise.resolve(0); + }); + expect(result).toBe(health); + expect(consume).toHaveBeenCalledOnce(); + expect(f.registry.observations.exitCode()).toBe(health); + expect(f.registry.termination).toBeDefined(); + expect(f.registry.activeOwners).toBe(0); + }, +); diff --git a/apps/cli/src/process/owned-notification.ts b/apps/cli/src/process/owned-notification.ts new file mode 100644 index 00000000..e9a05d49 --- /dev/null +++ b/apps/cli/src/process/owned-notification.ts @@ -0,0 +1,24 @@ +/** A passive producer may recover; the original invocation must first observe its sink failure. */ +interface NotificationOwner { + readonly signal: AbortSignal; + readonly assertAdmitting: () => void; + readonly failure: (cause: unknown) => void; +} + +export function ownNotification( + owner: NotificationOwner | undefined, + consume: (...args: Args) => void, +): (...args: Args) => void { + if (owner === undefined) return consume; + let stopped = false; + return (...args) => { + if (stopped || owner.signal.aborted) return; + try { + owner.assertAdmitting(); + consume(...args); + } catch (cause) { + stopped = true; + owner.failure(cause); + } + }; +} From c15aa3148b887b94bf9731ff4dc0027bbe71aac9 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 09:30:33 +0300 Subject: [PATCH 133/222] docs(docs): record twelfth review and cooperative terminal correction Join complete cumulative R12 coverage and its two confirmed runtime gaps to the implementation, permanent old/fixed controls and actual correction gates. Keep historical causal/package evidence, failed Windows timing and unstarted later steps separate from new acceptance obligations. Document cooperative terminal delivery and original-owner passive notifications in the canonical CLI contract. Step 8d remains open for two new clean rounds, separate security and final-source CI/platform checks. Refs: ADR-0110, ADR-0103, Wave 3 Step 8d Co-Authored-By: Codex --- AGENTS.md | 29 ++--- CLAUDE.md | 29 ++--- docs/reference/cli/commands.md | 23 ++++ docs/roadmap/current.md | 15 +++ .../phase-2.5.5-hardening-and-remediation.md | 117 ++++++++++++++++++ 5 files changed, 185 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 69dcbe88..1ecfefcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,20 +107,21 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first eleven complete cumulative rounds are -NONCLEAN. R11 independently verifies all 302 paths / 1,650 cumulative hunks at -`b9bc7118`: advisory notification failures bypass their original owner, missing MCP -consent wiring has a noncanonical exit, and two causal workload declarations are stale. -Corrections at `d8e6b4d5` preserve successful passive reporting, classified refusals, -original restoration capabilities and actual producer/native/accounting joins. -The fixture snapshot no longer repeats migration/checkpoint maintenance; actual default -fresh/resume openers, original assertions and five-second bounds remain. Fixed-label, -bounded abort diagnostics do not certify cleanup or explain old platform failures. -All four same-artifact consumers pass at the reviewed source, but required CI and its -Windows advisory fail separately. The initiating Windows phase/cause remains unknown, -including earlier `4c0fa5b7` and `09740575` failures. Author correction gates pass -(344 CLI files / 5,496 passes / ten established skips); complete causal republication, -new independent review/security and final-source CI/platform acceptance remain pending. +but not independently accepted. Its first twelve complete cumulative rounds are +NONCLEAN. R12 independently verifies all 306 paths / 1,672 cumulative hunks at +`518082b1`: cooperative JSON finalization seals output before its real terminal, +and workflow advisory faults bypass their original invocation. Corrections at +`3d937244` publish cooperative terminals before sealing, observe workflow +notifications before passive recovery and retain original fatal/output boundaries, +actual workflow-health priority and producer/native/accounting joins. The clear/reseat +fixture keeps the original operation/root through actual retirement and removes only +redundant observer maintenance, preserving default product openers and five-second bounds. +The old source's full filesystem (110 controls) and secrets (41) families pass with +independent physical/source/runtime/evaluation audits. Its four same-artifact +consumers pass; its different clear/reseat Windows advisory failure remains failed, +with initiating phase/cause unclassified. Author correction gates pass +(347 CLI files / 5,533 passes / ten established skips). New independent reviews, +a separate security sitting and final-source CI/platform acceptance remain pending. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. diff --git a/CLAUDE.md b/CLAUDE.md index b3f4194e..ba34e32d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,20 +196,21 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first eleven complete cumulative rounds are -NONCLEAN. R11 independently verifies all 302 paths / 1,650 cumulative hunks at -`b9bc7118`: advisory notification failures bypass their original owner, missing MCP -consent wiring has a noncanonical exit, and two causal workload declarations are stale. -Corrections at `d8e6b4d5` preserve successful passive reporting, classified refusals, -original restoration capabilities and actual producer/native/accounting joins. -The fixture snapshot no longer repeats migration/checkpoint maintenance; actual default -fresh/resume openers, original assertions and five-second bounds remain. Fixed-label, -bounded abort diagnostics do not certify cleanup or explain old platform failures. -All four same-artifact consumers pass at the reviewed source, but required CI and its -Windows advisory fail separately. The initiating Windows phase/cause remains unknown, -including earlier `4c0fa5b7` and `09740575` failures. Author correction gates pass -(344 CLI files / 5,496 passes / ten established skips); complete causal republication, -new independent review/security and final-source CI/platform acceptance remain pending. +but not independently accepted. Its first twelve complete cumulative rounds are +NONCLEAN. R12 independently verifies all 306 paths / 1,672 cumulative hunks at +`518082b1`: cooperative JSON finalization seals output before its real terminal, +and workflow advisory faults bypass their original invocation. Corrections at +`3d937244` publish cooperative terminals before sealing, observe workflow +notifications before passive recovery and retain original fatal/output boundaries, +actual workflow-health priority and producer/native/accounting joins. The clear/reseat +fixture keeps the original operation/root through actual retirement and removes only +redundant observer maintenance, preserving default product openers and five-second bounds. +The old source's full filesystem (110 controls) and secrets (41) families pass with +independent physical/source/runtime/evaluation audits. Its four same-artifact +consumers pass; its different clear/reseat Windows advisory failure remains failed, +with initiating phase/cause unclassified. Author correction gates pass +(347 CLI files / 5,533 passes / ten established skips). New independent reviews, +a separate security sitting and final-source CI/platform acceptance remain pending. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index a589cfb9..26555eb4 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1220,3 +1220,26 @@ reported error argument alone does not become an infrastructure fault. Ownerless embeddings keep their callback compatibility. Missing local-MCP consent wiring uses the existing fixed infrastructure-9 classification before any native start; ordinary consent refusal and known cancellation retain their distinct outcomes. + + +### Cooperative session finalization and passive notifications + +Normal owned JSON chat finalization (EOF, `/exit`, `/cancel` and first cooperative +SIGINT) publishes the real synchronous `session:cancelled` while its original +subscriber/output capability is still admitted, then seals further work in all +cases. It observes the original persister refusal before waiting for producer tails. +This terminal is not a provider/accounting/native cleanup acknowledgement: those +actual joins still precede writer and database release. Fatal registry stopping +seals admission before cancellation; cooperative finalization does not reopen an +already-stopped callback or permit postfatal business output. A terminal-output +failure retains the existing `output_unavailable` disposition and its private cause. + +Workflow effort, unpriced-model/modality and legacy-media-hold notifications bind +to the original command lifetime before entering passive core recovery. The +notification's own sink or formatter failure is observed before that recovery, +seals reentrant delivery, and selects existing infrastructure disposition `9` +unless actual workflow health `8 > 7 > 5 > 6` takes priority. Healthy warnings and +successful reporting of an existing passive error retain their established +behaviour. This does not turn the reported argument into a new fatal fault or +replace captured terminal-restoration capabilities. Actual producer, receipt, +input and native cleanup joins remain required. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 5e723586..4cc4d789 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1240,6 +1240,21 @@ separately, with the latter's initiating phase still unknown. Author gates pass (344 CLI files / 5,496 passes / ten established skips). Complete causal republication, two NEW clean cumulative rounds, separate security and final-source CI/platforms remain required. No user decision is pending. +The [twelfth cumulative review and cooperative-terminal correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-twelfth-cumulative-review-and-cooperative-terminal-correction--2026-10-17) +records complete NONCLEAN coverage of 306 paths / 1,672 independently rehashed +hunks at `518082b1`. Two High gaps in normal JSON finalization and workflow advisory +ownership are corrected at `3d937244`, with 24 precise old-runtime failures and six +healthy controls. Cooperative terminal delivery, existing fatal admission, actual +workflow health and cleanup joins remain separate. The clear/reseat fixture retains +actual raw-work/root custody and avoids redundant observer maintenance under its +original five-second bound. Both complete causal families pass at the reviewed old +source (110 filesystem / 41 secrets controls), with independent retained-evidence +audits; its four package consumers pass. Its different Windows clear/reseat failure +remains failed and unexplained. Final author lint/typecheck/test gates pass +(347 CLI files / 5,533 passing tests / ten established skips). Two NEW clean cumulative +rounds, NEW separate security and fresh final-source CI/platforms remain required. +Steps 1–7 and 8a–8c are accepted; 8d remains open and Steps 9–19 have not started. +No user decision is pending. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 7cafb627..ed9c86f4 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -7297,3 +7297,120 @@ final-source full CI plus all four same-artifact consumers remain required. R1 retain NONCLEAN verdicts. Step 8d, whole Step 8 and later increments remain open. No new ADR, maintainer decision, key, billing, provider capture, production recipient or release upload is pending. Implementation continues automatically. + + +### Wave 3 Step 8d twelfth cumulative review and cooperative-terminal correction — 2026-10-17 + +**R12 is complete and NONCLEAN; Step 8d and whole Step 8 remain open.** Three NEW +independent reviewers cover `d9625ff0a641197c6ed45968df99094a25db88d6` → +`518082b1d407fd8c23b0384e9ec150c05630b832`: 81/138/87 disjoint physical paths and +543/839/290 complete cumulative hunks, respectively. The root reads all three +reports and independently recomputes physical, full-diff and every hunk identity: +**306 paths / 1,672 hunks**, no gap or overlap. Inventory SHA-256 is +`7d30d6427380b5adbb8f6ffb577d34da3c08ea89e954917c74c074f93025e151`; +aggregate SHA-256 is `37cf2c88e988355ff33c18afed0f2a7e622ce3bfd859f40f0829c5de61f13953`. +All detailed reports remain local and Git-ignored. + +| Group | New whole-file execution | Verdict / confirmed finding | +|---|---|---| +| Session / terminal | 42 TypeScript files / 757 passes / no skips or unhandled errors | NONCLEAN, High `ST-R12-1`: healthy owned JSON EOF, exit and cancel omit their sole real terminal | +| Workflow / management | 72 TypeScript files / 1,397 passes / one established Windows skip / no unhandled errors | NONCLEAN, High `R12-WM-01`: workflow pricing/effort callback faults become success or ordinary failure instead of original-owner infrastructure failure | +| Native / tooling / docs | 19 TypeScript files / 201 passes, eight Node suites / 101 passes, no skips or unhandled errors | Scoped CLEAN; no new finding; all 151 declaration anchors and collected workloads reconcile | + +All reviewer runners actually join. Source aliases, existing-dist native child +imports, synthetic OS ports, intentionally held legacy test promises and absence +of a complete independent opening dependency inventory retain their explicit +report qualifications. Passing ordinary suites do not disprove the two precise +semantic reproductions or establish a separate security sitting. + +**Confirmed corrections, `3d937244`.** Cooperative session cancellation publishes +the actual synchronous terminal before sealing output, observes the original +persister refusal, and stops the same work owner in all cases. Fatal registry stop +still seals first; stopped subscribers are never reopened. Held provider/accounting +work remains owed after the terminal. Workflow notification binding uses the same +passive-notification boundary as sessions, before core advisory recovery. Actual +workflow health and the original captured terminal-restoration capabilities remain +independent; the fixed [CLI contract](../../reference/cli/commands.md#cooperative-session-finalization-and-passive-notifications) +is canonical. No new ADR, code category, dependency or user decision is introduced. + +Permanent source integration covers 22 JSON cases across fresh/resume, EOF, exit, +cancel, first cooperative SIGINT, ownerless compatibility and terminal-output +refusal; eight workflow cases pair healthy/faulted pricing and effort diagnostics +on fresh runs and actual SQLite gate resumes in both output modes. Five shared +notification cases preserve original argument identity, reentry sealing and actual +8/7/5/6 priority. Existing actual host/persister suites now also exercise cooperative +cancellation with a held producer and a refused terminal write. The old source is +an immutable archive (SHA-256 +`e3995764a3e8f11c8054fc46df2022c00049e514523bbe5b39c4dc10da83e427`), with all +1,739 tracked blobs independently verified. The identical two new command suites +produce **24 precise assertion failures / six healthy positives / zero skips or +unhandled errors** against it. Later helper-only tests are not falsely claimed as +runnable against an absent old export. Fixed-candidate controls pass 65 cases in +five complete files; the existing eight-file sibling cohort passes 238 cases. +Regression-control receipt SHA-256 is +`db6a6e1691a1d6a8280f048e23c3852c6d44b2d5d9ccc93c7a13dd346c1461f9`. + +**Native fixture and Windows qualification.** The old source's Windows advisory +job `117325099391` in run `39008265384` fails a different case: +`fs-invocation.test.ts` clear/reseat, **7,617 ms / original 5,000-ms bound**. +All ten workspace-admission cases, including the old benign-alias case, pass. +Neither the initiating phase nor raw-pending state is established: installed +Vitest can reject while the body is pending or after overlong synchronous work +settles. No historical Windows failure is retrospectively passed or explained. + +Separate original and observer-only whole-file controls both pass 19 cases. +Instrumented controls retain real native calls: three main clients / three +migration passes / six close returns become three / two / five, with zero returned +main clients still open. A real missing-table read still propagates and closes +its exact native connection. This removes only redundant observer maintenance; +default product fresh/resume openers, all assertions and original time bounds +remain. The final clear/reseat fixture passes the original invocation into the +command, retains its raw operation and abort custody, refuses a later case while +prior work/owners remain live, and keeps its root/environment through actual +retirement. Its 32-entry fixed-label trace diagnoses progress without certifying +cleanup. A separate deliberately held local builder triggers the unchanged +five-second timeout: the root/HOME remain present with two owners before release, +then disappear only after the actual raw join and zero owners. That probe remains +an expected timeout, not a passing liveness test or remote-cause explanation. + +**Completed historical causal republication at `518082b1`.** Both full families +actually join before any correction is applied. Root and an independent follow-up +auditor verify exact frozen source/declaration membership, named mutation +witnesses, source/evaluation/runtime/stdio bindings and complete physical trees: + +| Family | Controls / phases | Physical files / bytes | Runner unhandled errors | +|---|---|---|---| +| Filesystem | 110 / 330 | 1,987 / 43,687,739 | 0 | +| Secrets | 41 / 123 | 745 / 27,883,106 | 0 | + +All exports satisfy their unchanged 2,000-file / 64-MiB physical limits. Original +source pins join frozen Git blobs; dependency metadata does not retain dependency +bodies, deleted execution graphs are not recreated, and a missing opening physical +dependency inventory does not acquire an equality claim. Independent follow-up +receipt SHA-256 is +`f333751144d8dd2c153a3a094a2bbbf4dba7ee2593ad23a3595f554a7ccfb076`; +its separate seal is `41cad778a257b99d4f9ea7928516f43c0139ed94a2acbb547f5a9245bdc93bb5`. +Earlier sealed R12 reports are unchanged. These controls do not certify the later +correction source. + +The same historical run's four installed consumers and aggregate pass. Root and +independent audit inspect five ZIPs, all 70 public tar members and four receipt +origins for tarball SHA-256 +`e9c2feb13b887835e8a685c5ea2c6e6bd80605513669d52abcb4bae746df2381` +(**446,105 bytes**): current Linux/macOS/Windows Node 24.21.0 and minimum Linux +Node 22.0.0, each with 177 bootstrap controls and eight installed cases. This is +physical inspection of published execution, not another execution or full-CI +acceptance. Its Windows advisory retains the separate failed status above. + +**Correction author gates.** Final `pnpm turbo run lint typecheck test` passes all +23 tasks (20 cached), including fresh **347 CLI files / 5,533 passes / ten established +skips**. Fences, engine-dependency and tooling type checks, 726-file isolation and +build pass; compiled closure remains 25 outputs / 409 inputs / 17 externals. +Untouched compiled bootstrap passes 177 controls on local Node 22.23.1/Darwin; +compiled workflow/MCP/budget smoke passes. The 13-file correction source seal SHA-256 is +`af282cfd6f2585737e4565cc0a842b810e38f6b1f777799c914737c9af19e4e8`. +These are author checks, not independent acceptance. R1–R12 retain NONCLEAN +verdicts. Two NEW complete cumulative CLEAN rounds after the last material fix, +a NEW separate clean security sitting and fresh full CI plus all four same-artifact +package consumers remain required. Steps 9–19 have not started; no credential, +provider capture, billing, production archive recipient or user decision is pending. From afb3b9326737c310d055c3d631b291b7956fb3f4 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 10:11:06 +0300 Subject: [PATCH 134/222] fix(cli): retain checkpoint notice delivery ownership Deliver deferred-checkpoint notices through the original captured IO and retain the real write independently of the successfully opened database. Observe synchronous and asynchronous refusal before passive recovery, while preserving ownerless compatibility and actual workflow health. Forward the explicit context through default command/session factories. Add real SQLite, management-command and held-delivery regressions; the identical tests fail on the pre-correction source. Refs: ADR-0110, ADR-0103, Wave 3 Step 8d Co-Authored-By: Codex --- apps/cli/src/commands/agent-run.ts | 6 +- apps/cli/src/commands/chat-export.ts | 7 +- apps/cli/src/commands/chat-list.ts | 7 +- apps/cli/src/commands/chat.ts | 23 +- apps/cli/src/commands/dispatch.ts | 19 +- .../commands/fs-workspace-admission.test.ts | 4 +- apps/cli/src/commands/gate-list.ts | 7 +- apps/cli/src/commands/gate.ts | 12 +- apps/cli/src/commands/list.ts | 7 +- apps/cli/src/commands/logs.ts | 7 +- apps/cli/src/commands/run.ts | 8 +- apps/cli/src/commands/status.ts | 7 +- .../commands/surface-db-acquisition.test.ts | 10 +- .../src/db/checkpoint-notice-lifetime.test.ts | 161 +++++++++ apps/cli/src/db/checkpoint-notice.test.ts | 307 ++++++++++++++++++ apps/cli/src/db/checkpoint-notice.ts | 47 +++ apps/cli/src/db/open.ts | 12 +- .../src/engine/file-catalog-cutover.test.ts | 4 + apps/cli/src/engine/pricing-overlay.ts | 4 +- apps/cli/src/history/open.ts | 4 +- apps/cli/src/history/reader.ts | 7 +- apps/cli/src/history/session-open.ts | 4 +- apps/cli/src/home/drive-home.tsx | 12 +- 23 files changed, 651 insertions(+), 35 deletions(-) create mode 100644 apps/cli/src/db/checkpoint-notice-lifetime.test.ts create mode 100644 apps/cli/src/db/checkpoint-notice.test.ts create mode 100644 apps/cli/src/db/checkpoint-notice.ts diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index 637600c3..486a0d21 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -232,7 +232,11 @@ async function agentRunOwned( // owns this database from acquisition through actual host cleanup, including setup failures before a turn. lifetime.work.assertAdmitting(); const journalStore = acquireNativeResource( - (captureClose) => openSessionStore(homeDir, captureClose), + (captureClose) => + openSessionStore(homeDir, captureClose, { + io: deps.io, + invocation: deps.invocation, + }), { assertAdmitting: lifetime.work.assertAdmitting, captureClose: captureJournalClose, diff --git a/apps/cli/src/commands/chat-export.ts b/apps/cli/src/commands/chat-export.ts index dba10750..2e7da3c8 100644 --- a/apps/cli/src/commands/chat-export.ts +++ b/apps/cli/src/commands/chat-export.ts @@ -1,3 +1,4 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { acquireCommandResource, type CommandLifetime } from '../process/command-lifetime.js'; import { closeQuietly } from '../process/close-quietly.js'; import { SessionExportedEventSchema } from '@relavium/shared'; @@ -35,6 +36,7 @@ export interface ChatExportCommandDeps { readonly openSessionStore?: ( homeDir: string, captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ) => OpenedSessionStore; /** Wall-clock (ms) for the `exported` row timestamp + the event timestamp (injectable for tests). */ readonly now?: () => number; @@ -50,7 +52,10 @@ export function chatExportCommand( configPath: deps.global.configPath, }); const opened = acquireCommandResource(deps.commandLifetime, (captureClose) => - (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose), + (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose, { + io: deps.io, + invocation: deps.commandLifetime?.invocation, + }), ); try { const result = exportSession({ diff --git a/apps/cli/src/commands/chat-list.ts b/apps/cli/src/commands/chat-list.ts index 81602415..6b4d93e5 100644 --- a/apps/cli/src/commands/chat-list.ts +++ b/apps/cli/src/commands/chat-list.ts @@ -1,3 +1,4 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { acquireCommandResource, type CommandLifetime } from '../process/command-lifetime.js'; import { closeQuietly } from '../process/close-quietly.js'; import type { AgentSessionRecord } from '@relavium/shared'; @@ -18,6 +19,7 @@ export interface ChatListCommandDeps { readonly openSessionStore?: ( homeDir: string, captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ) => OpenedSessionStore; } @@ -35,7 +37,10 @@ export function chatListCommand(deps: ChatListCommandDeps): ExitCode { configPath: deps.global.configPath, }); const opened = acquireCommandResource(deps.commandLifetime, (captureClose) => - (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose), + (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose, { + io: deps.io, + invocation: deps.commandLifetime?.invocation, + }), ); try { if (deps.global.json) { diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index c984cf97..64bb2a7b 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -1,3 +1,4 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { ownSessionOutput, ownSessionSubscriber } from '../chat/session-presentation.js'; import { SessionChatTerminal } from '../chat/session-chat-terminal.js'; import type { SessionInkMount } from '../chat/session-ink-mount.js'; @@ -375,6 +376,7 @@ export interface ChatCommandDeps { readonly openSessionStore?: ( homeDir: string, captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ) => OpenedSessionStore; /** The MCP named-secret resolver (2.R Step 4) — production injects the keychain-backed one (specs.ts); default env-only. */ readonly mcpSecretResolver?: McpSecretResolver; @@ -417,6 +419,7 @@ export interface ChatResumeCommandDeps { readonly openSessionStore?: ( homeDir: string, captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ) => OpenedSessionStore; /** The MCP named-secret resolver (2.R Step 4) — production injects the keychain-backed one; default env-only. */ readonly mcpSecretResolver?: McpSecretResolver; @@ -683,9 +686,15 @@ async function chatCommandBody( try { opened = ownership === undefined - ? (deps.openSessionStore ?? openSessionStore)(homeDir) + ? (deps.openSessionStore ?? openSessionStore)(homeDir, undefined, { + io: deps.io, + invocation: deps.invocation, + }) : ownership.acquireStore((captureClose) => - (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose), + (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose, { + io: deps.io, + invocation: deps.invocation, + }), ); } catch (err) { if (host !== undefined) { @@ -876,9 +885,15 @@ async function chatResumeCommandBody( const homeDir = resolveHomeDir({}); const opened = ownership === undefined - ? (deps.openSessionStore ?? openSessionStore)(homeDir) + ? (deps.openSessionStore ?? openSessionStore)(homeDir, undefined, { + io: deps.io, + invocation: deps.invocation, + }) : ownership.acquireStore((captureClose) => - (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose), + (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose, { + io: deps.io, + invocation: deps.invocation, + }), ); const prepare = async (host?: SessionHostOwner) => { let config: LoadedConfig['config']; diff --git a/apps/cli/src/commands/dispatch.ts b/apps/cli/src/commands/dispatch.ts index 0a6b3fc1..1ca84c7b 100644 --- a/apps/cli/src/commands/dispatch.ts +++ b/apps/cli/src/commands/dispatch.ts @@ -1,3 +1,4 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { randomUUID } from 'node:crypto'; import { @@ -385,7 +386,8 @@ function storeAwareResolver( }); return withSynchronousResource( ctx.invocation, - (captureClose) => openLocalDb(homeDir, undefined, captureClose), + (captureClose) => + openLocalDb(homeDir, undefined, captureClose, { io: ctx.io, invocation: ctx.invocation }), ({ db }) => fromDb(db), ); } @@ -520,7 +522,11 @@ export function createProviderSlugResolver( * touching the real `history.db` or loading the native keychain. */ export interface ModelsDbPorts { - readonly openDb: (homeDir: string, captureClose?: (close: () => void) => void) => OpenedDb; + readonly openDb: ( + homeDir: string, + captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, + ) => OpenedDb; /** Build the key resolver over the models db — the `providerStore` (from the SAME db) makes it store-aware so a * custom `base_url` lists models over the SSRF-validated hop (2.5.G S9, ADR-0065 §4). A test stub may ignore it. */ readonly makeResolver: ( @@ -541,7 +547,8 @@ export interface ModelsDbPorts { } const PRODUCTION_MODELS_PORTS: ModelsDbPorts = { - openDb: (homeDir, captureClose) => openLocalDb(homeDir, undefined, captureClose), + openDb: (homeDir, captureClose, noticeContext) => + openLocalDb(homeDir, undefined, captureClose, noticeContext), makeResolver: (io, providerStore) => createProviderResolver(io.env, createOsKeychainStore(), { providerStore }), refreshCatalog: (homeDir, persist, lifecycle) => @@ -574,7 +581,7 @@ async function withModelsDepsOwned( configPath: ctx.global.configPath, }); const { db, close } = acquireCommandResource(ctx.commandLifetime, (captureClose) => - ports.openDb(homeDir, captureClose), + ports.openDb(homeDir, captureClose, { io: ctx.io, invocation: ctx.invocation }), ); try { const storeDeps = { uuid: () => randomUUID(), now: () => Date.now() }; @@ -680,7 +687,7 @@ const executeModelsPricing: CommandExecutor = (input, ctx) => { configPath: ctx.global.configPath, }); const { db, close } = acquireCommandResource(ctx.commandLifetime, (captureClose) => - openLocalDb(homeDir, undefined, captureClose), + openLocalDb(homeDir, undefined, captureClose, { io: ctx.io, invocation: ctx.invocation }), ); try { const storeDeps = { uuid: () => randomUUID(), now: () => Date.now() }; @@ -716,7 +723,7 @@ async function withProviderDepsOwned( configPath: ctx.global.configPath, }); const { db, close } = acquireCommandResource(ctx.commandLifetime, (captureClose) => - openLocalDb(homeDir, undefined, captureClose), + openLocalDb(homeDir, undefined, captureClose, { io: ctx.io, invocation: ctx.invocation }), ); try { const keychain = createOsKeychainStore(); // one native accessor, shared by the store-ref writes + the resolver diff --git a/apps/cli/src/commands/fs-workspace-admission.test.ts b/apps/cli/src/commands/fs-workspace-admission.test.ts index 1652fd4a..4bca4c3e 100644 --- a/apps/cli/src/commands/fs-workspace-admission.test.ts +++ b/apps/cli/src/commands/fs-workspace-admission.test.ts @@ -401,8 +401,8 @@ it('runner-abort ownership joins a held resumed builder before fixture removal a global: global(fixture.home), providers: scriptedResolver([]), drive, - openSessionStore: (home, captureClose) => { - const opened = openSessionStore(home, captureClose); + openSessionStore: (home, captureClose, noticeContext) => { + const opened = openSessionStore(home, captureClose, noticeContext); native = opened.db.$client; return { ...opened, diff --git a/apps/cli/src/commands/gate-list.ts b/apps/cli/src/commands/gate-list.ts index ca66d605..d703e0e0 100644 --- a/apps/cli/src/commands/gate-list.ts +++ b/apps/cli/src/commands/gate-list.ts @@ -1,3 +1,4 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { acquireCommandResource, type CommandLifetime } from '../process/command-lifetime.js'; import type { Db, RunHistoryReader } from '@relavium/db'; @@ -31,6 +32,7 @@ export interface GateListCommandDeps { readonly openDb?: ( homeDir: string, captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ) => { db: Db; close: () => void }; } @@ -52,7 +54,10 @@ export function gateListCommand(args: GateListCommandArgs, deps: GateListCommand configPath: deps.global.configPath, }); const { reader, close } = acquireCommandResource(deps.commandLifetime, (captureClose) => - openHistoryReader(homeDir, deps.openDb, captureClose), + openHistoryReader(homeDir, deps.openDb, captureClose, { + io: deps.io, + invocation: deps.commandLifetime?.invocation, + }), ); try { // Per-run isolation (`readPerRunOrDegrade`) for the no-arg ALL-RUNS form: one damaged run used to abort the diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index 877274d6..2c30d845 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -1,3 +1,4 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { acquireNativeResource } from '../process/acquire-native-resource.js'; import type { CliInvocation } from '../process/invocation.js'; import type { defaultSubscribeSignals } from '../process/signals.js'; @@ -156,6 +157,7 @@ export interface GateCommandDeps { readonly openDb?: ( homeDir: string, captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ) => { db: Db; close: () => void }; readonly selectRenderer?: ( io: CliIo, @@ -319,8 +321,14 @@ async function resumeGateOwned( const opened = acquireNativeResource( (captureClose) => deps.openDb === undefined - ? openLocalDb(homeDir, undefined, captureClose) - : deps.openDb(homeDir, captureClose), + ? openLocalDb(homeDir, undefined, captureClose, { + io: deps.io, + invocation: lifetime.invocation, + }) + : deps.openDb(homeDir, captureClose, { + io: deps.io, + invocation: lifetime.invocation, + }), { assertAdmitting: () => lifetime.checkpoint(), captureClose: (close) => { diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts index 9238b869..0a306755 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -1,3 +1,4 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { acquireCommandResource, type CommandLifetime } from '../process/command-lifetime.js'; import type { Db, RunRecord } from '@relavium/db'; import { byCodeUnit } from '@relavium/shared'; @@ -24,6 +25,7 @@ export interface ListCommandDeps { readonly openDb?: ( homeDir: string, captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ) => { db: Db; close: () => void }; /** Injectable catalog scanner — tests pass a fixture catalog without touching the filesystem. */ readonly discoverCatalog?: typeof discoverCatalog; @@ -70,7 +72,10 @@ export function listCommand(args: ListCommandArgs, deps: ListCommandDeps): ExitC const lastBySlug = new Map(); if (kind === 'workflows' && entries.some((entry) => entry.valid)) { const { reader, close } = acquireCommandResource(deps.commandLifetime, (captureClose) => - openHistoryReader(homeDir, deps.openDb, captureClose), + openHistoryReader(homeDir, deps.openDb, captureClose, { + io: deps.io, + invocation: deps.commandLifetime?.invocation, + }), ); try { for (const summary of reader.loadLatestRunPerWorkflow()) { diff --git a/apps/cli/src/commands/logs.ts b/apps/cli/src/commands/logs.ts index 027b1555..5a14804f 100644 --- a/apps/cli/src/commands/logs.ts +++ b/apps/cli/src/commands/logs.ts @@ -1,3 +1,4 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { acquireCommandResource, type CommandLifetime } from '../process/command-lifetime.js'; import type { Db } from '@relavium/db'; import type { RunEvent } from '@relavium/shared'; @@ -25,6 +26,7 @@ export interface LogsCommandDeps { readonly openDb?: ( homeDir: string, captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ) => { db: Db; close: () => void }; } @@ -40,7 +42,10 @@ export function logsCommand(args: LogsCommandArgs, deps: LogsCommandDeps): ExitC configPath: deps.global.configPath, }); const { reader, close } = acquireCommandResource(deps.commandLifetime, (captureClose) => - openHistoryReader(homeDir, deps.openDb, captureClose), + openHistoryReader(homeDir, deps.openDb, captureClose, { + io: deps.io, + invocation: deps.commandLifetime?.invocation, + }), ); try { const run = reader.loadRun(args.runId); diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index bc0772b5..2118ce75 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -1,3 +1,4 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { acquireNativeResource } from '../process/acquire-native-resource.js'; import type { CliInvocation } from '../process/invocation.js'; import type { defaultSubscribeSignals } from '../process/signals.js'; @@ -127,6 +128,7 @@ export interface RunCommandDeps { homeDir: string, projectRoot: string, captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ) => OpenedHistory; /** * Injectable renderer selector (TUI / json / plain). Defaults to the real {@link selectRenderer}; tests @@ -323,7 +325,11 @@ async function runOwned( const openRunStore = deps.openRunStore; if (openRunStore !== undefined) { opened = acquireNativeResource( - (captureClose) => openRunStore(runWorkflow, homeDir, deps.global.cwd, captureClose), + (captureClose) => + openRunStore(runWorkflow, homeDir, deps.global.cwd, captureClose, { + io: deps.io, + invocation: lifetime.invocation, + }), { assertAdmitting: () => lifetime.checkpoint(), captureClose: (close) => { diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index cf06cc4b..8f15b7f0 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -1,3 +1,4 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { acquireCommandResource, type CommandLifetime } from '../process/command-lifetime.js'; import type { RunEvent } from '@relavium/shared'; @@ -29,6 +30,7 @@ export interface StatusCommandDeps { readonly openDb?: ( homeDir: string, captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ) => { db: Db; close: () => void }; /** Injected in tests; production reads `~/.relavium/terminal-outbox.ndjson` through the file outbox. */ readonly readTerminalOutbox?: (homeDir: string) => Promise; @@ -87,7 +89,10 @@ export async function statusCommand(deps: StatusCommandDeps): Promise configPath: deps.global.configPath, }); const { reader, close } = acquireCommandResource(deps.commandLifetime, (captureClose) => - openHistoryReader(homeDir, deps.openDb, captureClose), + openHistoryReader(homeDir, deps.openDb, captureClose, { + io: deps.io, + invocation: deps.commandLifetime?.invocation, + }), ); // Runs whose TERMINAL is held in the outbox (ADR-0078 §4/§5) — read, never drained. // diff --git a/apps/cli/src/commands/surface-db-acquisition.test.ts b/apps/cli/src/commands/surface-db-acquisition.test.ts index 4ed0d62f..ea803e40 100644 --- a/apps/cli/src/commands/surface-db-acquisition.test.ts +++ b/apps/cli/src/commands/surface-db-acquisition.test.ts @@ -116,10 +116,12 @@ workflow: const session = await import('../history/session-open.js'); assertCurrent(); const nativeOpen = session.openSessionStore; - vi.spyOn(session, 'openSessionStore').mockImplementation((home, captureClose) => { - faultEnabled = true; - return nativeOpen(home, captureClose); - }); + vi.spyOn(session, 'openSessionStore').mockImplementation( + (home, captureClose, noticeContext) => { + faultEnabled = true; + return nativeOpen(home, captureClose, noticeContext); + }, + ); const openSessionStore = session.openSessionStore; const f = invocationFixture(); const captured = captureIo(); diff --git a/apps/cli/src/db/checkpoint-notice-lifetime.test.ts b/apps/cli/src/db/checkpoint-notice-lifetime.test.ts new file mode 100644 index 00000000..b86c4b36 --- /dev/null +++ b/apps/cli/src/db/checkpoint-notice-lifetime.test.ts @@ -0,0 +1,161 @@ +import { WorkflowLifetime } from '../commands/workflow-lifetime.js'; +import { captureIo } from '../test-support.js'; +import { expect, it } from 'vitest'; +import { deliverCheckpointNotice } from './checkpoint-notice.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +const tick = () => new Promise((r) => setImmediate(r)); +const hold = () => { + let release = () => {}; + const promise = new Promise((r) => { + release = r; + }); + return { promise, release }; +}; +it('seals original admission before direct synchronous sink refusal returns', async () => { + const f = invocationFixture(); + const cause = new Error('synthetic sync notice sink'); + const raw = deliverCheckpointNotice( + () => { + throw cause; + }, + 'fixed notice', + f.invocation, + ); + expect(f.registry.termination).toBeDefined(); + expect(() => f.registry.assertAdmitting()).toThrow(); + await Promise.resolve(raw).catch(() => undefined); + await f.registry.termination; + expect(f.registry.observations.infrastructureCode()).toBe('output_unavailable'); + expect(f.registry.activeOwners).toBe(0); +}); +it('normal termination waits actual held delivery and retires once after ACK', async () => { + const f = invocationFixture(); + const h = hold(); + let writes = 0; + const raw = deliverCheckpointNotice( + () => { + writes++; + return h.promise; + }, + 'fixed notice', + f.invocation, + ); + const done = f.registry.terminate({ kind: 'normal' }); + let settled = false; + void done.then(() => { + settled = true; + }); + await tick(); + expect(writes).toBe(1); + expect(settled).toBe(false); + expect(f.registry.activeOwners).toBe(1); + h.release(); + await raw; + expect(await done).toEqual({ kind: 'settled', unacknowledgedOwners: 0 }); + expect(f.registry.activeOwners).toBe(0); +}); +it('reentrant normal stop cannot retire a raw write still on the stack', async () => { + const f = invocationFixture(); + const h = hold(); + const raw = deliverCheckpointNotice( + () => { + void f.registry.terminate({ kind: 'normal' }); + return h.promise; + }, + 'fixed notice', + f.invocation, + ); + await tick(); + expect(f.registry.activeOwners).toBe(1); + h.release(); + await raw; + await f.registry.termination; + expect(f.registry.activeOwners).toBe(0); +}); +it('fatal deadline is no delivery/native ACK and later raw fulfillment cannot fabricate timely retirement', async () => { + const f = invocationFixture(); + const h = hold(); + const raw = deliverCheckpointNotice(() => h.promise, 'fixed notice', f.invocation); + f.invocation.finiteExit.fail('cli_internal'); + f.advance(3000); + expect(await f.invocation.finiteExit.exited).toBe(9); + expect(f.registry.activeOwners).toBe(1); + h.release(); + await raw; + await tick(); + expect(f.registry.activeOwners).toBe(1); +}); +it('asynchronous sink rejection is observed at its actual settlement with no lost raw join', async () => { + const f = invocationFixture(); + let reject: (cause: Error) => void = () => {}; + const p = new Promise((_r, no) => { + reject = no; + }); + const raw = deliverCheckpointNotice(() => p, 'fixed notice', f.invocation); + expect(f.registry.termination).toBeUndefined(); + reject(new Error('synthetic async sink')); + await Promise.resolve(raw).catch(() => undefined); + await f.registry.termination; + expect(f.registry.observations.infrastructureCode()).toBe('output_unavailable'); + expect(f.registry.activeOwners).toBe(0); +}); +it('ownerless compatibility preserves exact Promise and synchronous throwable', async () => { + const h = hold(); + expect(deliverCheckpointNotice(() => h.promise, 'fixed notice', undefined)).toBe(h.promise); + h.release(); + await h.promise; + const cause = new Error('synthetic ownerless failure'); + let observed: unknown; + try { + void deliverCheckpointNotice( + () => { + throw cause; + }, + 'fixed notice', + undefined, + ); + } catch (error) { + observed = error; + } + expect(observed).toBe(cause); +}); + +it.each([8, 7, 5, 6] as const)( + 'published workflow health %s outranks checkpoint output failure', + async (health) => { + const f = invocationFixture(); + const lifetime = new WorkflowLifetime(f.invocation, captureIo().io, () => () => {}); + const result = await lifetime.execute(() => { + lifetime.publish(health, true); + const raw = deliverCheckpointNotice( + () => { + throw new Error('synthetic notice refusal'); + }, + 'fixed notice', + f.invocation, + ); + void Promise.resolve(raw).catch(() => undefined); + expect(f.registry.termination).toBeDefined(); + return Promise.resolve(0); + }); + expect(result).toBe(health); + expect(f.registry.observations.exitCode()).toBe(health); + expect(f.registry.activeOwners).toBe(0); + }, +); +it('already stopped invocation creates no writer or retained notice owner', async () => { + const f = invocationFixture(); + await f.registry.terminate({ kind: 'normal' }); + let writes = 0; + expect(() => + deliverCheckpointNotice( + () => { + writes++; + }, + 'fixed notice', + f.invocation, + ), + ).toThrow(); + expect(writes).toBe(0); + expect(f.registry.activeOwners).toBe(0); +}); diff --git a/apps/cli/src/db/checkpoint-notice.test.ts b/apps/cli/src/db/checkpoint-notice.test.ts new file mode 100644 index 00000000..dca9668c --- /dev/null +++ b/apps/cli/src/db/checkpoint-notice.test.ts @@ -0,0 +1,307 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import * as processOutput from '../process/io.js'; +import { openLocalDb } from './open.js'; +import { CHECKPOINT_DEFERRED } from './privacy-notice.js'; +import { openHistoryStore } from '../history/open.js'; +import { openHistoryReader } from '../history/reader.js'; +import { openSessionStore } from '../history/session-open.js'; +import { loadUserPricingOverlay } from '../engine/pricing-overlay.js'; +import { parseWorkflow } from '@relavium/core'; +import { withCommandLifetime } from '../process/command-lifetime.js'; +import { chatListCommand } from '../commands/chat-list.js'; +import * as databaseOpen from './open.js'; + +vi.mock('@relavium/db', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runMigrations: (...args: Parameters) => { + actual.runMigrations(...args); + // Real native creation/migrations; force only the physical-checkpoint disposition. + return 'deferred'; + }, + }; +}); + +afterEach(() => vi.restoreAllMocks()); + +describe('deferred-checkpoint notice ownership', () => { + it.each(['healthy', 'throw', 'reject', 'held'] as const)( + 'actual management command retains its default SQLite notice: %s', + async (mode) => { + const home = mkdtempSync(join(tmpdir(), 'relavium-checkpoint-command-')); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); + const configPath = join(home, 'config.toml'); + writeFileSync(configPath, ''); + const f = invocationFixture(); + const captured = captureIo(); + let enter = (): void => undefined; + const entered = new Promise((resolve) => { + enter = resolve; + }); + let release = (): void => undefined; + const held = new Promise((resolve) => { + release = resolve; + }); + const write = vi.fn((text: string): Promise => { + enter(); + if (mode === 'throw') throw new Error('SYNTHETIC_PRIVATE_COMMAND_NOTICE'); + if (mode === 'reject') return Promise.reject(new Error('SYNTHETIC_PRIVATE_COMMAND_NOTICE')); + return mode === 'held' ? held : captured.io.writeErrAcknowledged(text); + }); + const nativeOpen = databaseOpen.openLocalDb; + let opened: ReturnType | undefined; + vi.spyOn(databaseOpen, 'openLocalDb').mockImplementation((...args) => { + opened = nativeOpen(...args); + return opened; + }); + const borrowed = vi.spyOn(processOutput, 'processIo').mockImplementation(() => { + throw new Error('SYNTHETIC_FORBIDDEN_AMBIENT_COMMAND_NOTICE'); + }); + let settled = false; + const action = withCommandLifetime( + { + invocation: f.invocation, + io: { ...captured.io, writeErrAcknowledged: write }, + global: { cwd: home, configPath, json: true, color: false, verbosity: 'normal' }, + }, + (owned) => Promise.resolve(chatListCommand(owned)), + ).then( + (value) => { + settled = true; + return { kind: 'value' as const, value }; + }, + (cause: unknown) => { + settled = true; + return { kind: 'failure' as const, cause }; + }, + ); + try { + const first = await Promise.race([ + entered.then(() => 'notice'), + action.then(() => 'command'), + ]); + expect(first, 'ACTUAL_COMMAND_ENTERS_ITS_CAPTURED_CHECKPOINT_NOTICE').toBe('notice'); + expect(write).toHaveBeenCalledExactlyOnceWith(`${CHECKPOINT_DEFERRED}\n`); + expect(borrowed).not.toHaveBeenCalled(); + if (mode === 'held') { + expect(settled, 'ACTUAL_COMMAND_RETAINS_PENDING_NOTICE_RECEIPT').toBe(false); + expect(opened?.db.$client.open).toBe(true); + expect(f.registry.activeOwners).toBeGreaterThan(0); + } + release(); + const outcome = await action; + if (mode === 'throw' || mode === 'reject') + expect(outcome).toMatchObject({ + kind: 'failure', + cause: { code: 'output_unavailable', exitCode: 9 }, + }); + else expect(outcome).toEqual({ kind: 'value', value: 0 }); + expect(opened?.db.$client.open).toBe(false); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.exitCode()).toBe( + mode === 'throw' || mode === 'reject' ? 9 : 0, + ); + expect(captured.out()).toBe(''); + expect(captured.err()).not.toContain('SYNTHETIC_PRIVATE_COMMAND_NOTICE'); + } finally { + release(); + await action; + await f.registry.terminate({ kind: 'normal' }); + vi.unstubAllEnvs(); + rmSync(home, { recursive: true, force: true }); + } + }, + ); + + it.each( + [false, true].flatMap((owned) => + (['healthy', 'throw', 'reject', 'held'] as const).map((mode) => ({ owned, mode })), + ), + )('captured writer: owned=$owned mode=$mode', async ({ owned, mode }) => { + const home = mkdtempSync(join(tmpdir(), 'relavium-checkpoint-notice-')); + const f = invocationFixture(); + const captured = captureIo(); + const cause = new Error('SYNTHETIC_PRIVATE_CHECKPOINT_NOTICE'); + let release = (): void => undefined; + const held = new Promise((resolve) => { + release = resolve; + }); + const write = vi.fn((text: string): Promise => { + expect(text).toBe(`${CHECKPOINT_DEFERRED}\n`); + if (mode === 'throw') throw cause; + if (mode === 'reject') return Promise.reject(cause); + if (mode === 'held') return held; + return captured.io.writeErrAcknowledged(text); + }); + const io = { ...captured.io, writeErrAcknowledged: write }; + const borrowed = vi.spyOn(processOutput, 'processIo'); + let opened: ReturnType | undefined; + let closed = false; + try { + opened = openLocalDb(home, undefined, undefined, { + io, + invocation: owned ? f.invocation : undefined, + }); + expect(opened.db.$client.open, 'NOTICE_FAILURE_DOES_NOT_CLOSE_CALLER_DATABASE').toBe(true); + if (owned && mode === 'throw') + expect( + f.registry.termination, + 'SYNCHRONOUS_NOTICE_FAILURE_SEALS_BEFORE_OPEN_RETURNS', + ).toBeDefined(); + expect(write, 'CAPTURED_NOTICE_SINK_RECEIVES_THE_ACTUAL_DELIVERY').toHaveBeenCalledTimes(1); + expect(borrowed, 'CAPTURED_NOTICE_NEVER_BORROWS_PROCESS_STDERR').not.toHaveBeenCalled(); + opened.close(); + closed = true; + expect(opened.db.$client.open).toBe(false); + if (mode === 'held') { + expect(f.registry.activeOwners, 'HELD_NOTICE_REMAINS_OWNED_AFTER_DATABASE_CLOSE').toBe( + owned ? 1 : 0, + ); + let joined = false; + const termination = f.registry.terminate({ kind: 'normal' }).then(() => { + joined = true; + }); + await Promise.resolve(); + if (owned) expect(joined).toBe(false); + release(); + await termination; + } else { + await vi.waitFor(() => expect(f.registry.activeOwners).toBe(0)); + } + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.exitCode(), 'ORIGINAL_NOTICE_FAILURE_IS_NOT_SUCCESS').toBe( + owned && (mode === 'throw' || mode === 'reject') ? 9 : 0, + ); + expect(f.registry.observations.infrastructureCode()).toBe( + owned && (mode === 'throw' || mode === 'reject') ? 'output_unavailable' : undefined, + ); + expect(captured.out()).toBe(''); + expect(captured.err()).toBe(mode === 'healthy' ? `${CHECKPOINT_DEFERRED}\n` : ''); + } finally { + release(); + if (!closed) opened?.close(); + await f.registry.terminate({ kind: 'normal' }); + rmSync(home, { recursive: true, force: true }); + } + }); + + it.each(['throw', 'reject'] as const)( + 'an explicit notice callback is still invocation-owned: %s', + async (mode) => { + const home = mkdtempSync(join(tmpdir(), 'relavium-explicit-checkpoint-notice-')); + const f = invocationFixture(); + const cause = new Error('SYNTHETIC_PRIVATE_EXPLICIT_NOTICE'); + const captured = captureIo(); + const notice = vi.fn(() => { + if (mode === 'throw') throw cause; + return Promise.reject(cause); + }); + let opened: ReturnType | undefined; + try { + opened = openLocalDb(home, notice, undefined, { + io: captured.io, + invocation: f.invocation, + }); + expect(opened.db.$client.open).toBe(true); + expect(notice).toHaveBeenCalledExactlyOnceWith(`${CHECKPOINT_DEFERRED}\n`); + await f.registry.terminate({ kind: 'normal' }); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.observations.infrastructureCode()).toBe('output_unavailable'); + expect(f.registry.activeOwners).toBe(0); + expect(captured.err()).toBe(''); + } finally { + opened?.close(); + await f.registry.terminate({ kind: 'normal' }); + rmSync(home, { recursive: true, force: true }); + } + }, + ); + + it.each( + (['run-history', 'session', 'reader', 'transient-pricing'] as const).flatMap((surface) => + [false, true].map((fault) => ({ surface, fault })), + ), + )( + 'actual default $surface factory forwards captured notice authority: fault=$fault', + async ({ surface, fault }) => { + const home = mkdtempSync(join(tmpdir(), 'relavium-checkpoint-factory-')); + const f = invocationFixture(); + const captured = captureIo(); + const write = vi.fn((text: string): Promise => { + if (fault) throw new Error('SYNTHETIC_PRIVATE_FACTORY_NOTICE'); + return captured.io.writeErrAcknowledged(text); + }); + const io = { ...captured.io, writeErrAcknowledged: write }; + const context = { io, invocation: f.invocation }; + const borrowed = vi.spyOn(processOutput, 'processIo').mockImplementation(() => { + throw new Error('SYNTHETIC_FORBIDDEN_AMBIENT_NOTICE'); + }); + let close = (): void => undefined; + let primary: { readonly cause: unknown } | undefined; + try { + try { + switch (surface) { + case 'run-history': { + const workflow = parseWorkflow(`schema_version: '1.0' +workflow: + id: synthetic + nodes: + - { id: start, type: input } + - { id: end, type: output } + edges: + - { from: start, to: end } +`); + const opened = openHistoryStore(workflow, home, home, undefined, context); + expect(opened.db.$client.open).toBe(true); + close = opened.close; + break; + } + case 'session': { + const opened = openSessionStore(home, undefined, context); + expect(opened.db.$client.open).toBe(true); + close = opened.close; + break; + } + case 'reader': { + const opened = openHistoryReader(home, undefined, undefined, context); + close = opened.close; + break; + } + case 'transient-pricing': { + const overlay = loadUserPricingOverlay(home, io, f.invocation); + if (!fault) expect(overlay).toBeDefined(); + break; + } + } + } catch (cause) { + primary = { cause }; + } finally { + close(); + } + expect( + write, + 'DEFAULT_STORAGE_FACTORY_USES_CAPTURED_NOTICE_SINK', + ).toHaveBeenCalledExactlyOnceWith(`${CHECKPOINT_DEFERRED}\n`); + expect(borrowed).not.toHaveBeenCalled(); + if (!fault || surface !== 'transient-pricing') expect(primary).toBeUndefined(); + else expect(primary, 'FAILED_NOTICE_REFUSES_LATER_TRANSIENT_ENTRY').toBeDefined(); + await f.registry.terminate({ kind: 'normal' }); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.exitCode()).toBe(fault ? 9 : 0); + expect(captured.out()).toBe(''); + expect(captured.err()).toBe(fault ? '' : `${CHECKPOINT_DEFERRED}\n`); + } finally { + await f.registry.terminate({ kind: 'normal' }); + rmSync(home, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/apps/cli/src/db/checkpoint-notice.ts b/apps/cli/src/db/checkpoint-notice.ts new file mode 100644 index 00000000..f4f1e786 --- /dev/null +++ b/apps/cli/src/db/checkpoint-notice.ts @@ -0,0 +1,47 @@ +import { CliError } from '../process/cli-error.js'; +import type { CliInvocation } from '../process/invocation.js'; +import { InvocationWorkOwner } from '../process/invocation-work-owner.js'; +import type { CliIo } from '../process/io.js'; + +/** Captured invocation and delivery sink; opening a database never borrows a later process sink. */ +export interface LocalDbNoticeContext { + readonly io: Pick; + readonly invocation: CliInvocation | undefined; +} + +/** + * A privacy notice is best effort for the opened database, but its actual delivery still belongs to + * the original invocation. A database close, rejection or stop request cannot acknowledge the writer. + */ +export function deliverCheckpointNotice( + write: (text: string) => void | Promise, + text: string, + invocation: CliInvocation | undefined, +): void | Promise { + if (invocation === undefined) return write(text); + const owner = new InvocationWorkOwner( + invocation, + { kind: 'command' }, + { cancel: () => undefined, cleanup: () => Promise.resolve() }, + ); + let raw: Promise; + try { + raw = owner.enter(async () => { + try { + await write(text); + } catch (cause) { + const failure = new CliError('output_unavailable', '', { cause }); + // A synchronous writer fault must seal admission before the opener returns to its caller. + owner.failure(failure); + throw failure; + } + }); + } catch (cause) { + void owner.close().catch(owner.cleanupUncertain); + throw cause; + } + // Both arms wait for the actual write. The failed arm is already observed by owner.enter before + // openLocalDb's best-effort catch; this cleanup chain never treats rejection as an unknown join. + void raw.then(owner.close, owner.close).catch(owner.cleanupUncertain); + return raw; +} diff --git a/apps/cli/src/db/open.ts b/apps/cli/src/db/open.ts index 791616e8..1b70bc5c 100644 --- a/apps/cli/src/db/open.ts +++ b/apps/cli/src/db/open.ts @@ -11,6 +11,7 @@ import { import { processIo } from '../process/io.js'; import { CHECKPOINT_DEFERRED } from './privacy-notice.js'; import { CliError } from '../process/cli-error.js'; +import { deliverCheckpointNotice, type LocalDbNoticeContext } from './checkpoint-notice.js'; /** An opened local database plus the close capability for all of its actual native connections. */ export interface OpenedDb { @@ -26,8 +27,9 @@ export interface OpenedDb { */ export function openLocalDb( homeDir: string, - notice: (text: string) => void | Promise = (text) => processIo().writeErrAcknowledged(text), + notice?: (text: string) => void | Promise, captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ): OpenedDb { const dir = globalConfigDir(homeDir); mkdirForStorage(dir); @@ -105,7 +107,13 @@ export function openLocalDb( // Logical suppression succeeded; only physical erasure awaits a reader. Diagnostic failure // cannot reject this successful open, close its caller-owned connection or repeat maintenance. try { - const delivery = notice(`${CHECKPOINT_DEFERRED}\n`); + const write = + notice ?? ((text: string) => (noticeContext?.io ?? processIo()).writeErrAcknowledged(text)); + const delivery = deliverCheckpointNotice( + write, + `${CHECKPOINT_DEFERRED}\n`, + noticeContext?.invocation, + ); if (delivery !== undefined) void delivery.catch(() => undefined); } catch { // Best-effort diagnostic only; no stored content or raw driver error is surfaced. diff --git a/apps/cli/src/engine/file-catalog-cutover.test.ts b/apps/cli/src/engine/file-catalog-cutover.test.ts index c6198013..cb8bf6a1 100644 --- a/apps/cli/src/engine/file-catalog-cutover.test.ts +++ b/apps/cli/src/engine/file-catalog-cutover.test.ts @@ -163,6 +163,10 @@ describe('file-to-DB catalog cutover (ADR-0071/0072/0111)', () => { writeErr: (text) => { errors.push(text); }, + writeErrAcknowledged: (text) => { + errors.push(text); + return Promise.resolve(); + }, }); expect(overlay).toBeDefined(); expect(errors).toEqual([]); diff --git a/apps/cli/src/engine/pricing-overlay.ts b/apps/cli/src/engine/pricing-overlay.ts index e98e2eef..869e3b06 100644 --- a/apps/cli/src/engine/pricing-overlay.ts +++ b/apps/cli/src/engine/pricing-overlay.ts @@ -98,7 +98,7 @@ function readPricingRows(db: Db): PricingOverlay { */ export function loadUserPricingOverlay( homeDir: string, - io: Pick, + io: Pick, invocation?: CliInvocation, ): PricingOverlay | undefined { return withSynchronousResource( @@ -106,7 +106,7 @@ export function loadUserPricingOverlay( (captureClose) => { let opened: { db: Db; close: () => void }; try { - opened = openLocalDb(homeDir, undefined, captureClose); + opened = openLocalDb(homeDir, undefined, captureClose, { io, invocation }); } catch (cause) { // An optional cache cannot launder partial construction or a failed native close into success. if (toUserFacing(cause).code !== 'storage_unavailable') throw cause; diff --git a/apps/cli/src/history/open.ts b/apps/cli/src/history/open.ts index d938b7e2..8ef53f39 100644 --- a/apps/cli/src/history/open.ts +++ b/apps/cli/src/history/open.ts @@ -1,3 +1,4 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { randomUUID } from 'node:crypto'; import { join } from 'node:path'; @@ -49,8 +50,9 @@ export function openHistoryStore( homeDir: string, projectRoot: string, captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ): OpenedHistory { - const { db, close } = openLocalDb(homeDir, undefined, captureClose); + const { db, close } = openLocalDb(homeDir, undefined, captureClose, noticeContext); const store = createRunHistoryStore(db, { uuid: () => randomUUID(), now: () => Date.now(), diff --git a/apps/cli/src/history/reader.ts b/apps/cli/src/history/reader.ts index 9e1b03c3..741e309f 100644 --- a/apps/cli/src/history/reader.ts +++ b/apps/cli/src/history/reader.ts @@ -1,3 +1,4 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { createRunHistoryReader, type Db, type RunHistoryReader } from '@relavium/db'; import { openLocalDb } from '../db/open.js'; @@ -20,8 +21,10 @@ export function openHistoryReader( openDb?: ( homeDir: string, captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ) => { db: Db; close: () => void }, captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ): OpenedReader { let opened: { db: Db; close: () => void }; let nativeClose: (() => void) | undefined; @@ -31,7 +34,9 @@ export function openHistoryReader( }; try { opened = - openDb === undefined ? openLocalDb(homeDir, undefined, capture) : openDb(homeDir, capture); + openDb === undefined + ? openLocalDb(homeDir, undefined, capture, noticeContext) + : openDb(homeDir, capture, noticeContext); } catch (err) { throw storageBoundaryError(err); } diff --git a/apps/cli/src/history/session-open.ts b/apps/cli/src/history/session-open.ts index 0d5a8619..5583eeff 100644 --- a/apps/cli/src/history/session-open.ts +++ b/apps/cli/src/history/session-open.ts @@ -1,3 +1,4 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { createSessionStore, type Db, type SessionStore } from '@relavium/db'; import { openLocalDb } from '../db/open.js'; @@ -25,10 +26,11 @@ export interface OpenedSessionStore { export function openSessionStore( homeDir: string, captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ): OpenedSessionStore { let opened: { db: Db; close: () => void }; try { - opened = openLocalDb(homeDir, undefined, captureClose); + opened = openLocalDb(homeDir, undefined, captureClose, noticeContext); } catch (err) { throw storageBoundaryError(err); } diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index 639cdc8d..bef3f3e6 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -1,3 +1,4 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { ownSessionSubscriber } from '../chat/session-presentation.js'; import { SessionControlWrites } from '../chat/session-control-writes.js'; import { SessionHomeLifetime } from './session-home-lifetime.js'; @@ -129,6 +130,7 @@ export interface HomeDeps { readonly openSessionStore?: ( homeDir: string, captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ) => OpenedSessionStore; readonly mcpSecretResolver?: McpSecretResolver; /** The `/doctor` probes (2.5.C S5) — production assembles the real probes; a test injects a fake. */ @@ -241,9 +243,15 @@ async function driveHomeBody(deps: HomeDeps, ownership?: SessionHomeLifetime): P deps.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env, keychain); const opened = ownership === undefined - ? (deps.openSessionStore ?? openSessionStore)(homeDir) + ? (deps.openSessionStore ?? openSessionStore)(homeDir, undefined, { + io: deps.io, + invocation: deps.invocation, + }) : ownership.acquireStore((captureClose) => - (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose), + (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose, { + io: deps.io, + invocation: deps.invocation, + }), ); // The provider resolver + `/doctor` probes are built INSIDE the try below — once `opened.db` + the `providerStore` // exist — so the resolver is STORE-AWARE (a custom `base_url` rebinds to its validated endpoint, 2.5.G S9); a test From dcc23cfbf3aa497b5100e8353fdd58a9b92fbe9d Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 10:12:01 +0300 Subject: [PATCH 135/222] docs(docs): record checkpoint notice correction and review status Record complete thirteenth-round coverage with explicit supplementary reviewer attribution, the verified original-owner finding and regression evidence. Update the canonical notification contract and retain honest source-specific CI, package and author-check qualifications. Step 8d remains open for two new complete clean cumulative rounds, separate security review and final-source CI/platform acceptance. Refs: ADR-0110, ADR-0103, Wave 3 Step 8d Co-Authored-By: Codex --- AGENTS.md | 26 +++---- CLAUDE.md | 26 +++---- docs/reference/cli/commands.md | 12 +++ docs/roadmap/current.md | 11 +++ .../phase-2.5.5-hardening-and-remediation.md | 78 +++++++++++++++++++ 5 files changed, 123 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1ecfefcd..70c7af57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,21 +107,17 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first twelve complete cumulative rounds are -NONCLEAN. R12 independently verifies all 306 paths / 1,672 cumulative hunks at -`518082b1`: cooperative JSON finalization seals output before its real terminal, -and workflow advisory faults bypass their original invocation. Corrections at -`3d937244` publish cooperative terminals before sealing, observe workflow -notifications before passive recovery and retain original fatal/output boundaries, -actual workflow-health priority and producer/native/accounting joins. The clear/reseat -fixture keeps the original operation/root through actual retirement and removes only -redundant observer maintenance, preserving default product openers and five-second bounds. -The old source's full filesystem (110 controls) and secrets (41) families pass with -independent physical/source/runtime/evaluation audits. Its four same-artifact -consumers pass; its different clear/reseat Windows advisory failure remains failed, -with initiating phase/cause unclassified. Author correction gates pass -(347 CLI files / 5,533 passes / ten established skips). New independent reviews, -a separate security sitting and final-source CI/platform acceptance remain pending. +but not independently accepted. Its first thirteen complete cumulative rounds are +NONCLEAN. R13 independently verifies all 310 paths / 1,706 cumulative hunks at +`c15aa314`, with explicitly attributed supplementary reading. Its High checkpoint-notice +finding is corrected at `afb3b932`: the original captured IO observes failed writes, +and the actual pending delivery remains owned independently of database closure. +Real SQLite/default-factory/management-command regressions fail on the old source; +fixed author gates pass (349 CLI files / 5,566 passes / ten established skips). +All four same-artifact consumers and the Windows advisory pass at the older reviewed +source; these results do not certify the correction. Earlier failed Windows runs +retain their original outcomes and unknown initiating causes. New independent +reviews, a separate security sitting and final-source CI/platform acceptance remain pending. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. diff --git a/CLAUDE.md b/CLAUDE.md index ba34e32d..1e1e31a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,21 +196,17 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first twelve complete cumulative rounds are -NONCLEAN. R12 independently verifies all 306 paths / 1,672 cumulative hunks at -`518082b1`: cooperative JSON finalization seals output before its real terminal, -and workflow advisory faults bypass their original invocation. Corrections at -`3d937244` publish cooperative terminals before sealing, observe workflow -notifications before passive recovery and retain original fatal/output boundaries, -actual workflow-health priority and producer/native/accounting joins. The clear/reseat -fixture keeps the original operation/root through actual retirement and removes only -redundant observer maintenance, preserving default product openers and five-second bounds. -The old source's full filesystem (110 controls) and secrets (41) families pass with -independent physical/source/runtime/evaluation audits. Its four same-artifact -consumers pass; its different clear/reseat Windows advisory failure remains failed, -with initiating phase/cause unclassified. Author correction gates pass -(347 CLI files / 5,533 passes / ten established skips). New independent reviews, -a separate security sitting and final-source CI/platform acceptance remain pending. +but not independently accepted. Its first thirteen complete cumulative rounds are +NONCLEAN. R13 independently verifies all 310 paths / 1,706 cumulative hunks at +`c15aa314`, with explicitly attributed supplementary reading. Its High checkpoint-notice +finding is corrected at `afb3b932`: the original captured IO observes failed writes, +and the actual pending delivery remains owned independently of database closure. +Real SQLite/default-factory/management-command regressions fail on the old source; +fixed author gates pass (349 CLI files / 5,566 passes / ten established skips). +All four same-artifact consumers and the Windows advisory pass at the older reviewed +source; these results do not certify the correction. Earlier failed Windows runs +retain their original outcomes and unknown initiating causes. New independent +reviews, a separate security sitting and final-source CI/platform acceptance remain pending. Two NEW complete cumulative clean rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 26555eb4..22867813 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1243,3 +1243,15 @@ successful reporting of an existing passive error retain their established behaviour. This does not turn the reported argument into a new fatal fault or replace captured terminal-restoration capabilities. Actual producer, receipt, input and native cleanup joins remain required. + +Deferred-checkpoint privacy notices use the original invocation's captured stderr +and retain the actual delivery operation independently of the opened database. +The successful open still transfers its connection to the caller; notice failure +neither closes that connection nor repeats maintenance. A synchronous write fault +seals invocation admission before the opener returns, and an asynchronous fault +is observed when its delivery rejects. Both retain `output_unavailable` and exit +`9`, subject to the same actual workflow-health priority. Database closure, stop +requests and deadline expiry never acknowledge a pending notice delivery. +Ownerless library calls retain their existing best-effort behaviour. A custom +opener delegating to the default factories forwards the explicit notice context, +alongside its existing native-close handoff. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 4cc4d789..75c6eb1f 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1255,6 +1255,17 @@ remains failed and unexplained. Final author lint/typecheck/test gates pass rounds, NEW separate security and fresh final-source CI/platforms remain required. Steps 1–7 and 8a–8c are accepted; 8d remains open and Steps 9–19 have not started. No user decision is pending. +The [thirteenth cumulative review and checkpoint-notice correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-thirteenth-cumulative-review-and-checkpoint-notice-correction--2026-10-17) +records complete NONCLEAN coverage of 310 paths / 1,706 independently rehashed +hunks at `c15aa314`. The confirmed default-notice fault/custody gap is corrected +at `afb3b932`: explicit captured IO, immediate original-owner observation and +actual pending-delivery ownership preserve successful DB acquisition without +inventing a write acknowledgement. Identical regressions fail on the old source; +fixed author checks pass (349 CLI files / 5,566 passes / ten established skips). +The older source's four installed consumers and Windows advisory pass; its full +CI was still running when recorded. These do not certify the correction. R1–R13 +remain NONCLEAN; two NEW complete cumulative clean rounds, separate security and +fresh final-source CI/platforms are required. No user decision is pending. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index ed9c86f4..8b1addef 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -7414,3 +7414,81 @@ verdicts. Two NEW complete cumulative CLEAN rounds after the last material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. Steps 9–19 have not started; no credential, provider capture, billing, production archive recipient or user decision is pending. + +### Wave 3 Step 8d thirteenth cumulative review and checkpoint-notice correction — 2026-10-17 + +**R13 is complete and NONCLEAN; Step 8d and whole Step 8 remain open.** The +cumulative range is `d9625ff0a641197c6ed45968df99094a25db88d6` → +`c15aa3148b887b94bf9731ff4dc0027bbe71aac9`: **310 paths / 1,706 hunks**, with no +gap or overlap. Session/terminal covers 82/544; workflow/management 139/870; +native/tooling/docs 89/292. The workflow group explicitly attributes five complete +test bodies / 83 hunks to a fourth, named supplementary reviewer; the primary +personally reads the other 134 bodies / 787 hunks. This is one complete sitting, +not an extra clean round or borrowed execution. Root reads all four reports and +recomputes every physical/full-diff/hunk identity. Inventory SHA-256 is +`3b32cb00c4e0727f3a17fe35f0878045d33e92641e5437073c887146f9b59460`; +aggregate SHA-256 is `c0fc759b768fbd2920b3631e5ed5834df516e24e02043b6290a11b6879844166`. +Opening/closing freeze is identical across 1,743 tracked blobs, 21,165 physical +dependency files / 1,215 lexical symlinks and 466 existing dist files. Mutable +caches are excluded; symlinks are not followed. Detailed records remain local and +Git-ignored. + +Fresh whole-file executions pass: session/terminal 43 files / 781 tests; +workflow/management 73 / 1,405 with one established Windows-only skip; +native/tooling 20 TypeScript files / 206 tests plus eight Node suites / 101 tests. +All actual launchers join, with zero reported unhandled errors. Source aliases, +existing-dist native children and synthetic IO/clock/Windows ports retain their +explicit report limits. These executions do not establish a separate security +sitting or final-source CI. + +**Confirmed High `WM-R13-01`, corrected at `afb3b932`.** Default deferred-checkpoint +notices reacquire process IO, suppress acknowledged-write failure and lose pending +delivery custody. Independent reviewer and root probes each use real SQLite, +actual command/native coordination and synthetic native write outcomes: one +healthy positive / three precise assertion failures / zero unhandled errors. +The correction forwards the original captured IO and invocation through default +workflow/session/management factories. It observes synchronous failure before the +opener returns, observes asynchronous rejection on settlement and owns the actual +pending write independently of the successfully transferred DB connection. Native +close, a stop request or deadline never becomes a delivery acknowledgement. +Known workflow-health priority and ownerless best-effort compatibility remain; +the [CLI contract](../../reference/cli/commands.md#cooperative-session-finalization-and-passive-notifications) +is canonical. This is the approved Step 8d boundary, not Step 18 centralization or +a new ADR, dependency, public package seam or decision. + +Permanent controls include real default factories and an actual management +command, held delivery after DB close, synchronous/asynchronous failures, +reentry, finite termination and existing workflow-health priority. The identical +22 regression cases produce precise AssertionErrors on an immutable `c15aa314` +archive while ten unchanged opener controls pass. The fixed candidate passes +632 tests in 22 whole files; actual child waits and zero run-end unhandled errors +are recorded. Native SQLite creation/migrations are real; only checkpoint +`deferred` disposition is forced. These are not observations of kernel EPIPE or +actual OS process exit. Candidate parent aliases do not claim to rewrite every +native child's existing-dist imports. Validation receipt SHA-256 is +`fffcd6398f9dbd8b3ca0edb64a3214fa9743c5b78e7541fc1ec38353505f0767`. + +**Author gates on the applied correction:** `pnpm turbo run lint typecheck test` +passes all 23 tasks (20 cached), with fresh **349 CLI files / 5,566 passing tests / +ten established skips**. Build passes six tasks (five cached); format, fences, +engine dependencies, tooling typecheck and 728-file isolation pass. Compiled +closure is 25 outputs / 410 inputs / 17 externals. Untouched compiled bootstrap +passes 177 controls on local Node 22.23.1/Darwin, and compiled workflow/MCP/budget +smoke passes. These are author checks, not independent acceptance. + +At the older reviewed source `c15aa314`, all four installed consumers and Windows +advisory pass in run `39010171558`; full CI is still in progress when recorded. +Root and an independent reviewer inspect five ZIPs, all 70 public tar members and +four receipt origins for tarball SHA-256 +`23c8efff4dcd3155445e6827f6a9181b0402e29603168492731741c19f5cf7fc` +(446,233 bytes). Current Linux/macOS/Windows use Node 24.21.0; minimum Linux uses +22.0.0, each with 177 bootstrap controls and eight installed cases. This is +physical inspection of published execution, not another execution or acceptance +of the later correction. Earlier failed/cancelled CI and Windows jobs retain their +separate source attribution; no unknown initiating cause is invented. + +R1–R13 remain NONCLEAN. Two NEW complete cumulative CLEAN rounds after the last +material correction, a NEW separate clean security sitting and fresh full CI plus +all four same-artifact consumers remain required. Steps 9–19 have not started; +no credential, billing, provider capture, production recipient or user decision is +pending. From d6d2fbdd4f18248095499424fae4af26841f3ce3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 10:48:16 +0300 Subject: [PATCH 136/222] fix(cli): retain original effect-disclosure delivery ownership Observe disclosure, render and warning sink faults before advisory recovery, and join actual delivery through the original session or workflow owner. Preserve failed-disclosure evidence, safe committed cleanup and ownerless compatibility. Add actual chat, Home, run and gate regression controls. Keep workspace fixture admission closed until its captured predecessor work and owners retire; share teardown and protect successor global state. Refs: ADR-0110, ADR-0103; Wave 3 Step 8d R14 Co-Authored-By: Codex --- apps/cli/src/commands/agent-run.ts | 2 +- .../chat-effect-disclosure-owner.test.ts | 190 +++++++++++ apps/cli/src/commands/chat.ts | 2 + .../commands/fs-workspace-admission.test.ts | 161 +++++++-- apps/cli/src/commands/gate.ts | 2 +- apps/cli/src/commands/run.ts | 2 +- .../workflow-effect-retention-owner.test.ts | 190 +++++++++++ apps/cli/src/commands/workflow-lifetime.ts | 11 + apps/cli/src/engine/effect-retention.ts | 76 ++++- apps/cli/src/home/drive-home.tsx | 1 + .../home/home-effect-disclosure-owner.test.ts | 310 ++++++++++++++++++ 11 files changed, 908 insertions(+), 39 deletions(-) create mode 100644 apps/cli/src/commands/chat-effect-disclosure-owner.test.ts create mode 100644 apps/cli/src/commands/workflow-effect-retention-owner.test.ts create mode 100644 apps/cli/src/home/home-effect-disclosure-owner.test.ts diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index 486a0d21..38be940c 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -375,7 +375,7 @@ async function agentRunOwned( lifetime.invocation.registry.budget.remainingMs() !== 0 && ownedEffectSessionId !== undefined ) - sweepOneShotSessionEffects(deps.io, journalStore.db, ownedEffectSessionId); + sweepOneShotSessionEffects(deps.io, journalStore.db, ownedEffectSessionId, lifetime.work); for (const receipt of receipts.values()) usageStore.retire(receipt); receipts.clear(); } diff --git a/apps/cli/src/commands/chat-effect-disclosure-owner.test.ts b/apps/cli/src/commands/chat-effect-disclosure-owner.test.ts new file mode 100644 index 00000000..6de035b6 --- /dev/null +++ b/apps/cli/src/commands/chat-effect-disclosure-owner.test.ts @@ -0,0 +1,190 @@ +import { mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + createClient, + createSessionStore, + createEffectJournalStore, + runMigrations, +} from '@relavium/db'; +import { AgentSchema } from '@relavium/shared'; +import { expect, it } from 'vitest'; +import { chatResumeCommand } from './chat.js'; +import { scriptedResolver } from '../chat/test-support.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import { CliError, toUserFacing } from '../process/errors.js'; + +const cases = [ + { owned: true, mode: 'healthy' }, + { owned: true, mode: 'throw' }, + { owned: true, mode: 'reject' }, + { owned: true, mode: 'held' }, + { owned: false, mode: 'healthy' }, + { owned: false, mode: 'throw' }, + { owned: false, mode: 'reject' }, +] as const; +it.each(cases)( + 'actual resumed disclosure retains original output: owned=$owned mode=$mode', + async ({ owned, mode }) => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-disclosure-owner-'))); + const client = createClient(':memory:'); + runMigrations(client.db); + const store = createSessionStore(client.db); + const f = invocationFixture(); + const captured = captureIo(); + const agent = AgentSchema.parse({ + id: 'notice-review', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + system_prompt: 'offline', + }); + const stamp = '2026-10-17T00:00:00.000Z'; + store.createSession({ + id: 'notice-session', + agentSlug: agent.id, + agentSnapshot: agent, + context: { workingDir: dir, fsScopeTier: 'sandboxed' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: stamp, + updatedAt: stamp, + }); + const turn = store.reserveEffectTurnKey('notice-session'); + const journal = createEffectJournalStore(client.db, { + uuid: () => 'notice-effect', + now: () => 0, + }); + const identity = { scope: `session:notice-session:${turn}`, slot: 0, toolId: 'run_command' }; + journal.prepare( + identity, + { kind: 'session', sessionId: 'notice-session', turn }, + { providerAttempt: 1, toolCallId: `session-tool:${turn}:0` }, + 3, + 'digest', + ); + journal.settle(identity, 'committed'); + let writes = 0; + let closes = 0; + let settle = false; + let heldStarted = false; + let enter = () => {}; + const entered = new Promise((resolve) => { + enter = resolve; + }); + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + let retained: unknown; + const action = chatResumeCommand( + { sessionId: 'notice-session' }, + { + ...(owned ? { invocation: f.invocation } : {}), + io: { + ...captured.io, + writeErrAcknowledged: () => { + writes++; + enter(); + if (mode === 'healthy') return Promise.resolve(); + if (mode === 'held' || writes > 2) { + heldStarted = true; + return held; + } + const cause = new CliError('output_unavailable', '', { + cause: new Error('SYNTHETIC_PRIVATE_DISCLOSURE_SINK'), + }); + if (mode === 'throw') throw cause; + return Promise.reject(cause); + }, + }, + global: { + cwd: dir, + json: false, + color: false, + configPath: join(dir, 'absent.toml'), + verbosity: 'normal', + }, + providers: scriptedResolver([]), + now: () => 0, + uuid: () => 'notice-message', + openSessionStore: () => ({ + db: client.db, + store, + close: () => { + closes++; + retained = client.sqlite.prepare('SELECT COUNT(*) AS count FROM run_effects').get(); + client.sqlite.close(); + }, + }), + lifecycle: { + onProcessExit: () => () => {}, + onInterrupt: () => () => {}, + onTerminationSignal: () => () => {}, + setRawMode: () => {}, + exit: () => {}, + }, + drive: async (ctx) => { + ctx.startSession(); + await ctx.onActivated?.(() => !ctx.shouldStop()); + await ctx.processLine('/exit'); + return { kind: 'exit' }; + }, + }, + ).then( + (value) => { + settle = true; + return { kind: 'value' as const, value }; + }, + (cause: unknown) => { + settle = true; + return { kind: 'failure' as const, cause }; + }, + ); + try { + expect(await Promise.race([entered.then(() => 'notice'), action.then(() => 'command')])).toBe( + 'notice', + ); + if (mode === 'held') { + expect(settle, 'ACTUAL_DISCLOSURE_WRITE_MUST_JOIN_BEFORE_COMMAND_COMPLETION').toBe(false); + expect(closes).toBe(0); + expect(f.registry.activeOwners).toBeGreaterThan(0); + release(); + } + const outcome = await action; + if (owned && (mode === 'throw' || mode === 'reject')) { + expect( + f.registry.observations.infrastructureCode(), + 'DISCLOSURE_FAILURE_REACHES_ORIGINAL_INVOCATION', + ).toBe('output_unavailable'); + expect(f.registry.termination).toBeDefined(); + expect(writes, 'STOP_DOES_NOT_START_AN_UNOWNED_WARNING_TAIL').toBe(1); + expect(heldStarted).toBe(false); + // Command adapters classify an escaping stopped marker through the same fixed exit boundary. + expect( + outcome.kind === 'failure' ? toUserFacing(outcome.cause).exitCode : outcome.value, + ).toBe(9); + } else { + expect(outcome).toEqual({ kind: 'value', value: 4 }); + expect(f.registry.observations.infrastructureCode()).toBeUndefined(); + expect(writes).toBe(mode === 'throw' || mode === 'reject' ? 3 : 1); + expect(heldStarted).toBe(mode !== 'healthy'); + } + expect(closes).toBe(1); + expect(f.registry.activeOwners).toBe(0); + expect(retained, 'NO_FAILED_DISCLOSURE_DELETES_ITS_AUDIT_ROW').toEqual({ + count: mode === 'throw' || mode === 'reject' ? 1 : 0, + }); + expect(captured.out() + captured.err()).not.toContain('SYNTHETIC_PRIVATE_DISCLOSURE_SINK'); + } finally { + release(); + await action; + await f.registry.terminate({ kind: 'normal' }); + if (client.sqlite.open) client.sqlite.close(); + rmSync(dir, { recursive: true, force: true }); + } + }, +); diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 64bb2a7b..12865b47 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -1129,6 +1129,7 @@ async function chatResumeCommandBody( onActivated: (isActive, flushNotice) => reconcileResumedSessionEffects({ io: deps.io, + ...(built.workOwner === undefined ? {} : { owner: built.workOwner }), db: opened.db, sessionId: built.sessionId, sanitize: sanitizeInline, @@ -2279,6 +2280,7 @@ async function buildReseatWiring( onActivated: (isActive, flushNotice) => reconcileResumedSessionEffects({ io: deps.io, + ...(resumed.workOwner === undefined ? {} : { owner: resumed.workOwner }), db: deps.opened.db, sessionId: resumed.sessionId, sanitize: sanitizeInline, diff --git a/apps/cli/src/commands/fs-workspace-admission.test.ts b/apps/cli/src/commands/fs-workspace-admission.test.ts index 4bca4c3e..6dbb010c 100644 --- a/apps/cli/src/commands/fs-workspace-admission.test.ts +++ b/apps/cli/src/commands/fs-workspace-admission.test.ts @@ -1,5 +1,6 @@ import { invocationFixture } from '../process/invocation.test-support.js'; import type { CliInvocation } from '../process/invocation.js'; +import { InvocationWorkOwner } from '../process/invocation-work-owner.js'; import { existsSync, mkdirSync, @@ -80,9 +81,14 @@ interface WorkspaceCase { readonly assertCurrent: () => void; readonly trace: (phase: WorkspacePhase) => void; readonly abortSnapshot: () => readonly PhaseEntry[] | undefined; + sealed: boolean; + retired: boolean; + closing: Promise | undefined; } -let currentCase: WorkspaceCase; -beforeEach((context) => { +let currentCase: WorkspaceCase | undefined; +function beginWorkspaceCase(context: { readonly signal: AbortSignal }): WorkspaceCase { + if (currentCase !== undefined && !currentCase.retired) + throw new Error('Previous native workspace fixture is not retired.'); const base = realpathSync.native(mkdtempSync(join(tmpdir(), 'relavium-workspace-floor-'))); const home = join(base, 'home'); const workspace = join(base, 'neutral-vault'); @@ -104,7 +110,7 @@ beforeEach((context) => { invocation.finiteExit.signal(15); }; context.signal.addEventListener('abort', onAbort, { once: true }); - currentCase = { + const fixture: WorkspaceCase = { base, home, workspace, @@ -112,38 +118,59 @@ beforeEach((context) => { invocation, signal: context.signal, pending: new Set(), + sealed: false, + retired: false, + closing: undefined, trace, abortSnapshot: () => aborted, removeAbort: () => context.signal.removeEventListener('abort', onAbort), assertCurrent: () => { context.signal.throwIfAborted(); + if (fixture.sealed || currentCase !== fixture) + throw new Error('Native workspace fixture no longer admits work.'); invocation.registry.assertAdmitting(); }, }; + currentCase = fixture; vi.stubEnv('HOME', home); vi.stubEnv('USERPROFILE', home); Object.assign(effects, { history: 0, keys: 0 }); + return fixture; +} +beforeEach((context) => { + beginWorkspaceCase(context); }); +function retireWorkspaceCase(fixture: WorkspaceCase): Promise { + fixture.sealed = true; + fixture.closing ??= (async () => { + const aborted = fixture.abortSnapshot(); + if (aborted !== undefined) { + // Fixed labels/numbers only. A timeout snapshot is diagnostic, never a native-close receipt. + console.error(JSON.stringify({ kind: 'workspace-case-abort-phases', phases: aborted })); + } + // Runner timeout is cancellation, not raw settlement. Keep HOME/root and refuse the next case until this joins. + await Promise.allSettled([...fixture.pending]); + if (fixture.invocation.registry.termination !== undefined) + await fixture.invocation.registry.termination; + fixture.removeAbort(); + expect(fixture.invocation.registry.activeOwners).toBe(0); + // A late hook owns only its captured fixture, never a successor's environment, spies or trace. + if (currentCase === fixture) { + phaseTrace.active = undefined; + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + } + rmSync(fixture.base, { recursive: true, force: true }); + fixture.retired = true; + })(); + return fixture.closing; +} afterEach(async () => { - const fixture = currentCase; - const aborted = fixture.abortSnapshot(); - if (aborted !== undefined) { - // Fixed labels/numbers only. A timeout snapshot is diagnostic, never a native-close receipt. - console.error(JSON.stringify({ kind: 'workspace-case-abort-phases', phases: aborted })); - } - // Runner timeout is cancellation, not raw settlement. Preserve this case's HOME/root until the actual join. - await Promise.allSettled([...fixture.pending]); - if (fixture.invocation.registry.termination !== undefined) - await fixture.invocation.registry.termination; - fixture.removeAbort(); - phaseTrace.active = undefined; - expect(fixture.invocation.registry.activeOwners).toBe(0); - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - rmSync(fixture.base, { recursive: true, force: true }); + if (currentCase !== undefined) await retireWorkspaceCase(currentCase); }); function ownCase(body: (fixture: WorkspaceCase) => Promise): Promise { const fixture = currentCase; + if (fixture === undefined) throw new Error('Native workspace fixture was not created.'); const raw = Promise.resolve().then(() => { fixture.assertCurrent(); return body(fixture); @@ -462,3 +489,99 @@ it('a real SQLite read failure still closes the manually acquired session store' if (native?.open === true) native.close(); } })); + +it('fixture retirement seals late work and refuses successor setup until raw work and owners join', async () => { + const fixture = currentCase; + if (fixture === undefined) throw new Error('Missing native workspace fixture.'); + let releaseRaw = (): void => {}; + const raw = new Promise((resolve) => { + releaseRaw = resolve; + }); + fixture.pending.add(raw); + let releaseOwner = (): void => {}; + const cleanup = new Promise((resolve) => { + releaseOwner = resolve; + }); + let ownerCleanupEntered = false; + new InvocationWorkOwner( + fixture.invocation, + { kind: 'command' }, + { + cancel: () => undefined, + cleanup: () => { + ownerCleanupEntered = true; + return cleanup; + }, + }, + ); + fixture.invocation.finiteExit.signal(15); + const ownTrace = phaseTrace.active; + const effectsBefore = { ...effects }; + let unexpectedSuccessor: WorkspaceCase | undefined; + const attemptSuccessor = (): void => { + unexpectedSuccessor = beginWorkspaceCase({ signal: new AbortController().signal }); + }; + const closing = retireWorkspaceCase(fixture); + try { + expect(attemptSuccessor).toThrow('Previous native workspace fixture is not retired.'); + expect(currentCase).toBe(fixture); + expect(process.env['HOME']).toBe(fixture.home); + expect(process.env['USERPROFILE']).toBe(fixture.home); + expect(phaseTrace.active).toBe(ownTrace); + expect(effects).toEqual(effectsBefore); + expect(existsSync(fixture.base)).toBe(true); + const lateEntry = vi.fn(() => Promise.resolve()); + await expect(ownCase(lateEntry)).rejects.toThrow( + 'Native workspace fixture no longer admits work.', + ); + expect(lateEntry).not.toHaveBeenCalled(); + releaseRaw(); + await new Promise((resolve) => setImmediate(resolve)); + expect(fixture.retired).toBe(false); + expect(ownerCleanupEntered).toBe(true); + expect(fixture.invocation.registry.activeOwners).toBe(1); + expect(attemptSuccessor).toThrow('Previous native workspace fixture is not retired.'); + expect(existsSync(fixture.base)).toBe(true); + } finally { + releaseRaw(); + releaseOwner(); + await closing; + if (unexpectedSuccessor !== undefined) await retireWorkspaceCase(unexpectedSuccessor); + } + expect(fixture.retired).toBe(true); + expect(existsSync(fixture.base)).toBe(false); + const next = beginWorkspaceCase({ signal: new AbortController().signal }); + expect(next).not.toBe(fixture); + expect(process.env['HOME']).toBe(next.home); +}); + +it('repeated teardown shares the actual join and cannot restore a later fixture environment', async () => { + const fixture = currentCase; + if (fixture === undefined) throw new Error('Missing native workspace fixture.'); + let release = (): void => {}; + const raw = new Promise((resolve) => { + release = resolve; + }); + fixture.pending.add(raw); + const first = retireWorkspaceCase(fixture); + const second = retireWorkspaceCase(fixture); + try { + expect(second).toBe(first); + expect(fixture.retired).toBe(false); + expect(existsSync(fixture.base)).toBe(true); + } finally { + release(); + await Promise.all([first, second]); + } + const next = beginWorkspaceCase({ signal: new AbortController().signal }); + const ownTrace = phaseTrace.active; + const witness = { value: () => 1 }; + vi.spyOn(witness, 'value').mockReturnValue(2); + expect(retireWorkspaceCase(fixture)).toBe(first); + await first; + expect(process.env['HOME']).toBe(next.home); + expect(process.env['USERPROFILE']).toBe(next.home); + expect(phaseTrace.active).toBe(ownTrace); + expect(witness.value()).toBe(2); + expect(existsSync(next.base)).toBe(true); +}); diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index 2c30d845..a190daf4 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -633,7 +633,7 @@ async function resumeGateOwned( // are untouched by construction: they are the record an operator needs, and an exit-7 run's rows must // survive precisely because its run is over. if (isTerminalOutcome(outcome)) { - sweepCommittedEffects(deps.io, opened.db, args.runId); + sweepCommittedEffects(deps.io, opened.db, args.runId, lifetime.effectNoticeOwner); } await sweepMediaAtTerminal({ sweep: deps.sweepMedia ?? defaultSweepMedia, diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 2118ce75..05483c42 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -526,7 +526,7 @@ async function runOwned( // survive precisely because its run is over. // `opened` is undefined on the in-memory path (`--no-history`), which has no journal to sweep. if (isTerminalOutcome(outcome) && opened !== undefined) { - sweepCommittedEffects(deps.io, opened.db, handle.runId); + sweepCommittedEffects(deps.io, opened.db, handle.runId, lifetime.effectNoticeOwner); } await sweepMediaAtTerminal({ sweep: deps.sweepMedia ?? defaultSweepMedia, diff --git a/apps/cli/src/commands/workflow-effect-retention-owner.test.ts b/apps/cli/src/commands/workflow-effect-retention-owner.test.ts new file mode 100644 index 00000000..e9b5ce4a --- /dev/null +++ b/apps/cli/src/commands/workflow-effect-retention-owner.test.ts @@ -0,0 +1,190 @@ +import { randomUUID } from 'node:crypto'; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseWorkflow, type WorkflowDefinition } from '@relavium/core'; +import { + createClient, + createRunHistoryStore, + createRunLeasePort, + runMigrations, +} from '@relavium/db'; +import { expect, it, vi } from 'vitest'; + +import { scriptedResolver } from '../chat/test-support.js'; +import { buildEngine } from '../engine/build-engine.js'; +import { createCliHost } from '../engine/host.js'; +import { toUserFacing } from '../process/errors.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import { gateCommand } from './gate.js'; +import { runCommand } from './run.js'; + +const cases = (['run', 'gate'] as const).flatMap((surface) => + (['healthy', 'throw', 'reject', 'held'] as const).map((mode) => ({ surface, mode })), +); + +it.each(cases)('$surface owns effect-retention warning mode=$mode', async ({ surface, mode }) => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-retention-notice-'))); + const client = createClient(':memory:'); + runMigrations(client.db); + const f = invocationFixture(); + const captured = captureIo(); + const source = JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'retention-notice', + nodes: [ + { id: 'start', type: 'input' }, + ...(surface === 'gate' + ? [{ id: 'approval', type: 'human_gate', gate_type: 'approval' }] + : []), + { id: 'out', type: 'output' }, + ], + edges: + surface === 'gate' + ? [ + { from: 'start', to: 'approval' }, + { from: 'approval', to: 'out' }, + ] + : [{ from: 'start', to: 'out' }], + }, + }); + const path = join(dir, 'workflow.relavium.yaml'); + writeFileSync(path, source); + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + let enter = () => {}; + const entered = new Promise((resolve) => { + enter = resolve; + }); + let closed = () => {}; + const closeEntered = new Promise((resolve) => { + closed = resolve; + }); + let writes = 0; + let closes = 0; + let settled = false; + const prepare = client.sqlite.prepare.bind(client.sqlite); + const prepareSpy = vi.spyOn(client.sqlite, 'prepare').mockImplementation((sql) => { + if (/^\s*delete\s+from\s+"run_effects"/iu.test(sql)) + throw new Error('SYNTHETIC_PRIVATE_RETENTION_DRIVER'); + return prepare(sql); + }); + const storeFor = (def: WorkflowDefinition) => + createRunHistoryStore(client.db, { + uuid: randomUUID, + now: Date.now, + projectRoot: dir, + workflow: { + slug: def.workflow.id, + name: def.workflow.id, + definitionJson: JSON.stringify(def), + }, + }); + const close = () => { + closes++; + client.sqlite.close(); + closed(); + }; + const deps = { + invocation: f.invocation, + subscribeSignals: () => () => {}, + providers: scriptedResolver([]), + io: { + ...captured.io, + writeErrAcknowledged: (text: string) => { + expect(text).toBe('warning: effect-journal retention could not be completed.\n'); + writes++; + enter(); + if (mode === 'held') return held; + if (mode === 'throw') throw new Error('SYNTHETIC_PRIVATE_RETENTION_SINK'); + if (mode === 'reject') return Promise.reject(new Error('SYNTHETIC_PRIVATE_RETENTION_SINK')); + return captured.io.writeErrAcknowledged(text); + }, + }, + global: { + cwd: dir, + json: false, + color: false, + configPath: join(dir, 'absent.toml'), + verbosity: 'normal' as const, + }, + sweepMedia: () => Promise.resolve(undefined), + }; + const execute = async () => { + if (surface === 'run') + return runCommand( + { workflow: path, input: [], allowMcpStdio: [] }, + { + ...deps, + openRunStore: (def, _home, _cwd, captureClose) => { + captureClose?.(close); + return { + db: client.db, + store: storeFor(def), + close, + terminalOutboxPath: join(dir, 'outbox.ndjson'), + }; + }, + }, + ); + const def = parseWorkflow(source, { source: 'retention-notice-fixture' }); + const store = storeFor(def); + const engine = await buildEngine({ + providers: deps.providers, + host: createCliHost(store, { runLeases: createRunLeasePort(store) }), + }); + const handle = engine.start({ workflow: def, inputs: {} }); + for await (const event of handle.events) if (event.type === 'run:paused') break; + return gateCommand( + { runId: handle.runId, approve: true }, + { + ...deps, + openDb: (_home, captureClose) => { + captureClose?.(close); + return { db: client.db, close }; + }, + }, + ); + }; + const action = execute() + .catch((cause: unknown) => toUserFacing(cause).exitCode) + .then((value) => { + settled = true; + return value; + }); + try { + expect(await Promise.race([entered.then(() => 'notice'), action.then(() => 'command')])).toBe( + 'notice', + ); + if (mode === 'held') { + await closeEntered; + // The real command cleanup has closed SQLite; only the entered output delivery remains. + await new Promise((resolve) => setImmediate(resolve)); + expect(settled, 'RETENTION_WARNING_DELIVERY_MUST_JOIN_COMMAND_COMPLETION').toBe(false); + expect(f.registry.activeOwners).toBeGreaterThan(0); + release(); + } + expect(await action, 'RETENTION_OUTPUT_FAILURE_REACHES_ORIGINAL_WORKFLOW').toBe( + mode === 'throw' || mode === 'reject' ? 9 : 0, + ); + expect(f.registry.observations.infrastructureCode()).toBe( + mode === 'throw' || mode === 'reject' ? 'output_unavailable' : undefined, + ); + expect(writes).toBe(1); + expect(closes).toBe(1); + expect(f.registry.activeOwners).toBe(0); + expect(captured.out() + captured.err()).not.toContain('SYNTHETIC_PRIVATE'); + if (mode === 'healthy') expect(captured.err()).toContain('effect-journal retention'); + } finally { + release(); + await action; + await f.registry.terminate({ kind: 'normal' }); + prepareSpy.mockRestore(); + if (client.sqlite.open) client.sqlite.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/apps/cli/src/commands/workflow-lifetime.ts b/apps/cli/src/commands/workflow-lifetime.ts index e061a7ca..7cadd8f3 100644 --- a/apps/cli/src/commands/workflow-lifetime.ts +++ b/apps/cli/src/commands/workflow-lifetime.ts @@ -11,11 +11,13 @@ import { defaultSubscribeSignals } from '../process/signals.js'; import { subscribeWithSignalCustody } from '../process/signal-custody.js'; import { WorkflowTerminal } from './workflow-terminal.js'; import { ownNotification } from '../process/owned-notification.js'; +import type { EffectNoticeOwner } from '../engine/effect-retention.js'; /** Single command owner: registration/signals precede its first owned asynchronous preparation. */ export class WorkflowLifetime { readonly invocation: CliInvocation; readonly terminal: WorkflowTerminal; + readonly effectNoticeOwner: EffectNoticeOwner; readonly #cancel = new AbortController(); readonly #stoppedMarker = new Error('Workflow command stopped.'); readonly #retired: Promise; @@ -57,6 +59,15 @@ export class WorkflowLifetime { () => this.checkpoint(), (cause) => this.failure(cause), ); + this.effectNoticeOwner = Object.freeze({ + signal: this.signal, + assertAdmitting: () => this.checkpoint(), + failure: (cause: unknown) => this.failure(cause), + work: Object.freeze({ + retainWork: (factory: () => Promise): Promise => + this.terminal.runViewOperation(factory, false), + }), + }); this.#removeSignals = subscribeWithSignalCustody( this.invocation.signalCustody, subscribeSignals, diff --git a/apps/cli/src/engine/effect-retention.ts b/apps/cli/src/engine/effect-retention.ts index bedd7474..45aea052 100644 --- a/apps/cli/src/engine/effect-retention.ts +++ b/apps/cli/src/engine/effect-retention.ts @@ -9,8 +9,34 @@ import { } from '@relavium/db'; import type { CliIo } from '../process/io.js'; +import type { InvocationWorkOwner } from '../process/invocation-work-owner.js'; +import { CliError } from '../process/cli-error.js'; import { CHECKPOINT_DEFERRED } from '../db/privacy-notice.js'; +/** The original caller retains actual delivery; this port grants no receipt or native-close authority. */ +export type EffectNoticeOwner = Pick< + InvocationWorkOwner, + 'signal' | 'assertAdmitting' | 'failure' | 'work' +>; + +function deliver( + owner: EffectNoticeOwner | undefined, + operation: () => void | Promise, +): void | Promise { + if (owner === undefined) return operation(); + owner.assertAdmitting(); + return owner.work.retainWork(async () => { + try { + await operation(); + } catch (cause) { + const failure = new CliError('output_unavailable', '', { cause }); + // Observe synchronous throws before returning, and rejections before advisory recovery. + owner.failure(failure); + throw failure; + } + }); +} + const RETENTION_FAILED = 'warning: effect-journal retention could not be completed.'; const DISCLOSURE_FAILED = 'warning: session effect disclosure could not be completed; audit evidence was retained.'; @@ -18,22 +44,27 @@ const IDENTITY_HISTORY_INVALID = 'warning: session effect identity history is invalid; audit evidence was retained and effect-turn allocation remains blocked.'; /** Driver diagnostics, ids and database contents are never interpolated into a warning. */ -async function writeWarning(io: CliIo, text: string): Promise { +async function writeWarning(io: CliIo, text: string, owner?: EffectNoticeOwner): Promise { // The async boundary also converts a synchronous host-port throw into a rejected promise. - await io.writeErrAcknowledged(`${text}\n`); + await deliver(owner, () => io.writeErrAcknowledged(`${text}\n`)); } -function warn(io: CliIo, text: string): void { - // A failed output sink must not change the outcome or trigger another cleanup attempt. - void writeWarning(io, text).catch(() => undefined); +function warn(io: CliIo, text: string, owner?: EffectNoticeOwner): void { + // Never repeat retention to repair a diagnostic. The original owner observes and joins its output. + void writeWarning(io, text, owner).catch(() => undefined); } /** Delete only committed rows of a RUN the caller knows can no longer be resumed. */ -export function sweepCommittedEffects(io: CliIo, db: Db, runId: string): void { +export function sweepCommittedEffects( + io: CliIo, + db: Db, + runId: string, + owner?: EffectNoticeOwner, +): void { try { createEffectJournalStore(db, { uuid: randomUUID, now: Date.now }).sweepCommittedForRun(runId); } catch { - warn(io, RETENTION_FAILED); + warn(io, RETENTION_FAILED, owner); } } @@ -42,6 +73,7 @@ export type NoticeFlush = (publish?: () => void | Promise) => Promise string; @@ -57,7 +89,8 @@ export interface ResumedSessionEffectOptions { export function reconcileResumedSessionEffects( options: ResumedSessionEffectOptions, ): void | Promise { - const active = options.isActive ?? (() => true); + const active = (): boolean => + options.owner?.signal.aborted !== true && (options.isActive?.() ?? true); if (!active()) return; const store = createEffectJournalStore(options.db, { uuid: randomUUID, now: Date.now }); let snapshot: SessionEffectDisclosureSnapshot; @@ -67,10 +100,11 @@ export function reconcileResumedSessionEffects( const warning = (text: string): void | Promise => { if (!active()) return; try { - const warningDelivery = options.deliverNotice(text); - if (warningDelivery !== undefined) return warningDelivery.catch(() => warn(options.io, text)); + const warningDelivery = deliver(options.owner, () => options.deliverNotice(text)); + if (warningDelivery !== undefined) + return warningDelivery.catch(() => warn(options.io, text, options.owner)); } catch { - warn(options.io, text); + warn(options.io, text, options.owner); } }; try { @@ -95,7 +129,7 @@ export function reconcileResumedSessionEffects( }; const publish = (): void | Promise => { if (!active() || notice === undefined) return; - const result = options.deliverNotice(notice); + const result = deliver(options.owner, () => options.deliverNotice(notice)); published = true; if (result !== undefined) delivered = result.then( @@ -113,8 +147,11 @@ export function reconcileResumedSessionEffects( : delivered.then((success) => (success ? sweep() : warning(DISCLOSURE_FAILED))); }; try { - if (options.flushNotice !== undefined) - return options.flushNotice(publish).then(acknowledged, () => warning(DISCLOSURE_FAILED)); + const flush = options.flushNotice; + if (flush !== undefined) + return Promise.resolve(deliver(options.owner, () => flush(publish))).then(acknowledged, () => + warning(DISCLOSURE_FAILED), + ); void publish(); return acknowledged(); } catch { @@ -123,14 +160,19 @@ export function reconcileResumedSessionEffects( } /** Never resumable; the caller proves ownership by a successful one-shot turn-key reservation. */ -export function sweepOneShotSessionEffects(io: CliIo, db: Db, sessionId: string): void { +export function sweepOneShotSessionEffects( + io: CliIo, + db: Db, + sessionId: string, + owner?: EffectNoticeOwner, +): void { try { const store = createEffectJournalStore(db, { uuid: randomUUID, now: Date.now }); const snapshot = store.readSessionDisclosureSnapshot(sessionId); const swept = store.sweepCommittedForSession(sessionId, snapshot.committed); - if (swept.checkpoint === 'deferred') warn(io, CHECKPOINT_DEFERRED); + if (swept.checkpoint === 'deferred') warn(io, CHECKPOINT_DEFERRED, owner); } catch { - warn(io, 'warning: one-shot effect retention or WAL erasure could not be completed.'); + warn(io, 'warning: one-shot effect retention or WAL erasure could not be completed.', owner); } } diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index bef3f3e6..9810bc17 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -782,6 +782,7 @@ async function driveHomeBody(deps: HomeDeps, ownership?: SessionHomeLifetime): P const activate = () => reconcileResumedSessionEffects({ io: deps.io, + ...(built.workOwner === undefined ? {} : { owner: built.workOwner }), db: opened.db, sessionId: built.sessionId, sanitize: sanitizeInline, diff --git a/apps/cli/src/home/home-effect-disclosure-owner.test.ts b/apps/cli/src/home/home-effect-disclosure-owner.test.ts new file mode 100644 index 00000000..c3bddae9 --- /dev/null +++ b/apps/cli/src/home/home-effect-disclosure-owner.test.ts @@ -0,0 +1,310 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + createClient, + createEffectJournalStore, + createSessionStore, + runMigrations, +} from '@relavium/db'; +import { expect, it, vi } from 'vitest'; + +import { buildChatSession, buildResumedChatSession } from '../chat/session-host.js'; +import type { SessionWorkOwner } from '../chat/session-work-owner.js'; +import { scriptedResolver, textTurn } from '../chat/test-support.js'; +import { isCliError } from '../process/cli-error.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import type { RootAppProps } from '../render/tui/home-app.js'; +import { captureIo } from '../test-support.js'; +import { driveHome } from './drive-home.js'; + +vi.mock('../secrets/os-keychain.js', () => ({ + createOsKeychainStore: () => ({ get: () => null, set: () => undefined, delete: () => false }), +})); + +const flush = () => new Promise((resolve) => setImmediate(resolve)); +const DISCLOSURE_FAILED = + 'warning: session effect disclosure could not be completed; audit evidence was retained.'; +const PRIVATE_RESULT = 'PRIVATE_EFFECT_RESULT /private/result-path'; +const PRIVATE_DATABASE = 'PRIVATE_DISCLOSURE_DB_FAULT /private/database-path'; + +type Scenario = + | 'healthy' + | 'sync-notice-fault' + | 'async-notice-fault' + | 'async-flush-fault' + | 'held-flush' + | 'read-refusal' + | 'unpublished-flush'; + +it.each([ + 'healthy', + 'sync-notice-fault', + 'async-notice-fault', + 'async-flush-fault', + 'held-flush', + 'read-refusal', + 'unpublished-flush', +])('actual Home reseat owns effect disclosure: %s', async (scenario) => { + const f = invocationFixture(); + const client = createClient(':memory:'); + runMigrations(client.db); + const sessions = createSessionStore(client.db); + const cwd = mkdtempSync(join(tmpdir(), 'relavium-home-effect-owner-')); + const captured = captureIo(); + const cause = new TypeError('PRIVATE_DISCLOSURE_PRODUCER_FAULT /private/output-path'); + const faulted = scenario.endsWith('-fault'); + let props: RootAppProps | undefined; + let originalOwner: SessionWorkOwner | undefined; + let resumedOwner: SessionWorkOwner | undefined; + let finishRender = () => {}; + let releaseDelivery = () => {}; + const exited = new Promise((resolve) => { + finishRender = resolve; + }); + const delivery = new Promise((resolve) => { + releaseDelivery = resolve; + }); + let closed = 0; + let id = 0; + let armed = false; + let deliveryStarted = 0; + let deliverySettled = false; + let sinkCalls = 0; + let refusedReads = 0; + let resumedRetired = false; + let settled = false; + let retainedAtClose: unknown; + const attemptedNotices: string[] = []; + const running = driveHome({ + invocation: f.invocation, + io: { ...captured.io, stdoutIsTty: true, stdinIsTty: true }, + global: { + cwd, + configPath: join(cwd, 'absent-global.toml'), + json: false, + color: false, + verbosity: 'normal', + }, + providers: scriptedResolver([textTurn('first'), textTurn('second')]), + uuid: () => `home-effect-owner-${++id}`, + buildSession: (options) => { + originalOwner = options.workOwner; + return buildChatSession(options); + }, + buildResumedSession: async (options) => { + resumedOwner = options.workOwner; + const built = await buildResumedChatSession(options); + // Fault only the disclosure projection AFTER the real resume loader succeeds. Other + // SQLite queries and actual host construction stay live; this refusal is recoverable. + if (scenario === 'read-refusal') { + const prepare = client.sqlite.prepare.bind(client.sqlite); + vi.spyOn(client.sqlite, 'prepare').mockImplementation((query) => { + if ( + query.startsWith('select') && + query.includes('run_effects') && + query.includes('attempt_json') + ) { + refusedReads++; + throw new Error(PRIVATE_DATABASE); + } + return prepare(query); + }); + } + return built; + }, + openSessionStore: () => ({ + db: client.db, + store: sessions, + close: () => { + retainedAtClose = client.sqlite.prepare('SELECT state FROM run_effects').all(); + closed++; + client.sqlite.close(); + }, + }), + // Actual controller, hosts, stores and SQLite; this acknowledged renderer seam is not a real TTY. + render: (value) => { + props = value; + value.onRendererReady?.(async (publish) => { + if (armed) { + armed = false; + const session = value.controller.getSnapshot().session; + if (session === undefined) throw new Error('Expected reseated Home session.'); + const notice = session.store.notice.bind(session.store); + // eslint-disable-next-line @typescript-eslint/no-misused-promises -- Inject a runtime async sink fault through Home's original callback. + vi.spyOn(session.store, 'notice').mockImplementation((text) => { + attemptedNotices.push(text); + if (text.includes('external effect(s)')) { + sinkCalls++; + if (scenario === 'sync-notice-fault') throw cause; + if (scenario === 'async-notice-fault') return Promise.reject(cause); + } + return notice(text); + }); + if (scenario === 'unpublished-flush') return; + await publish?.(); + if (scenario === 'async-flush-fault') throw cause; + if (scenario === 'held-flush') { + deliveryStarted++; + await delivery; + deliverySettled = true; + } + return; + } + await publish?.(); + }); + return { + unmount: finishRender, + waitUntilExit: () => exited, + waitUntilRenderFlush: () => Promise.resolve(), + }; + }, + getSize: () => ({ cols: 120, rows: 40 }), + subscribeResize: () => () => {}, + subscribeSignals: () => () => {}, + subscribeProcessExit: () => () => {}, + jobControlLifecycle: { + supported: false, + onSuspend: () => () => {}, + onContinue: () => () => {}, + suspendSelf: () => {}, + }, + onboardingTerminalLifecycle: { + onInput: () => () => {}, + isRawMode: () => false, + setRawMode: () => {}, + }, + }).then((code) => { + settled = true; + return code; + }); + try { + if (props === undefined) throw new Error('Expected Home mount.'); + const controller = props.controller; + const type = (text: string): void => { + for (const char of text) controller.handleKey(char, {}); + }; + type('first'); + controller.handleKey('', { return: true }); + await vi.waitFor(() => expect(controller.getSnapshot().session).toBeDefined()); + await vi.waitFor(() => expect(controller.getSnapshot().submitBusy).toBe(false)); + const original = controller.getSnapshot().session; + if (original === undefined) throw new Error('Expected original Home session.'); + // The first reply must really be durable before /models loads and reseats that session. + await vi.waitFor(() => + expect( + sessions + .loadFull(original.sessionId) + ?.messages.filter((message) => message.role === 'assistant'), + ).toHaveLength(1), + ); + const turn = sessions.reserveEffectTurnKey(original.sessionId); + const journal = createEffectJournalStore(client.db, { + uuid: () => 'home-effect-owner-effect', + now: Date.now, + }); + const identity = { + scope: `session:${encodeURIComponent(original.sessionId)}:${String(turn)}`, + slot: 0, + toolId: 'run_command', + }; + journal.prepare( + identity, + { kind: 'session', sessionId: original.sessionId, turn }, + { providerAttempt: 1, toolCallId: 'home-effect-owner-call' }, + 3, + 'digest', + ); + journal.settle(identity, 'committed', PRIVATE_RESULT); + armed = true; + controller.handleKey('/', {}); + type('models'); + controller.handleKey('', { return: true }); + await vi.waitFor(() => expect(controller.getSnapshot().modelPicker).toBeDefined()); + type('claude-opus-4-8'); + controller.handleKey('', { return: true }); + if (controller.getSnapshot().modelPicker?.phase === 'effort') + controller.handleKey('', { return: true }); + await vi.waitFor(() => expect(resumedOwner).toBeDefined()); + expect(originalOwner).toBeDefined(); + expect(resumedOwner).not.toBe(originalOwner); + if (resumedOwner === undefined) throw new Error('Expected original resumed owner.'); + const retired = resumedOwner.retired.then(() => { + resumedRetired = true; + }); + if (faulted) { + await vi.waitFor(() => expect(resumedOwner?.firstFailure).toBeDefined()); + const failure = resumedOwner?.firstFailure?.cause; + expect(isCliError(failure), 'original session owns disclosure output failure').toBe(true); + if (!isCliError(failure)) throw new Error('Expected classified output failure.'); + expect(failure.code).toBe('output_unavailable'); + expect(failure.cause, 'retain the exact producer cause before advisory recovery').toBe(cause); + expect(f.registry.termination).toBeDefined(); + expect(sinkCalls).toBe(1); + } else if (scenario === 'held-flush') { + await vi.waitFor(() => expect(deliveryStarted).toBe(1)); + expect(client.sqlite.prepare('SELECT state FROM run_effects').all()).toEqual([ + { state: 'committed' }, + ]); + expect(controller.getSnapshot().submitBusy).toBe(true); + finishRender(); + await flush(); + await flush(); + expect(settled, 'Home result must join the actual pending acknowledgement').toBe(false); + expect(deliverySettled).toBe(false); + expect(closed, 'pending output cannot release the shared database').toBe(0); + expect(client.sqlite.open).toBe(true); + expect(f.registry.activeOwners).toBeGreaterThan(0); + expect(resumedRetired, 'the actual resumed host still owns its pending delivery').toBe(false); + expect(resumedOwner?.firstFailure).toBeUndefined(); + expect(f.registry.termination).toBeUndefined(); + releaseDelivery(); + } else { + await vi.waitFor(() => expect(controller.getSnapshot().session).not.toBe(original)); + await vi.waitFor(() => expect(controller.getSnapshot().submitBusy).toBe(false)); + const resumed = controller.getSnapshot().session; + if (resumed === undefined) throw new Error('Expected active reseated Home session.'); + expect(resumed.sessionId).toBe(original.sessionId); + const text = resumed.store + .getSnapshot() + .state.transcript.map((entry) => entry.text ?? '') + .join('\n'); + attemptedNotices.push(text); + if (scenario === 'read-refusal' || scenario === 'unpublished-flush') { + expect(text).toContain(DISCLOSURE_FAILED); + if (scenario === 'read-refusal') expect(refusedReads).toBeGreaterThan(0); + else expect(sinkCalls).toBe(0); + expect(client.sqlite.prepare('SELECT state FROM run_effects').all()).toEqual([ + { state: 'committed' }, + ]); + } else { + expect(text).toContain('external effect(s) from earlier turns need attention'); + expect(client.sqlite.prepare('SELECT state FROM run_effects').all()).toEqual([]); + expect(sinkCalls).toBe(1); + } + expect(resumedOwner?.firstFailure).toBeUndefined(); + expect(f.registry.termination).toBeUndefined(); + } + finishRender(); + expect(await running).toBe(faulted ? 9 : 0); + await retired; + expect(resumedRetired).toBe(true); + if (scenario === 'held-flush') expect(deliverySettled).toBe(true); + expect(retainedAtClose).toEqual(scenario === 'healthy' ? [] : [{ state: 'committed' }]); + expect(closed).toBe(1); + expect(client.sqlite.open).toBe(false); + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); + const publicText = `${captured.out()}${captured.err()}${attemptedNotices.join('\n')}`; + for (const secret of [PRIVATE_RESULT, PRIVATE_DATABASE, cause.message, '/private/']) + expect(publicText).not.toContain(secret); + } finally { + releaseDelivery(); + finishRender(); + await running; + vi.restoreAllMocks(); + if (client.sqlite.open) client.sqlite.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}); From e218ba0e9b38f5bb7af71487ec3de969c60e84b0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 10:48:20 +0300 Subject: [PATCH 137/222] fix(repo): keep installed worker failure text private Project fixed failure labels rather than arbitrary assertion headers and nested error text. Keep original primary evidence in the private report, with bounded public native identity, code, signal and close facts intact. Add the actual child-stderr assertion and wrapper chain regression. Refs: ADR-0110, ADR-0111; Wave 3 Step 8d R14 Co-Authored-By: Codex --- tools/cli-package/README.md | 6 +- tools/cli-package/consumer-lifetime.test.mjs | 2 +- .../cli-package/smoke/failure-diagnostic.mjs | 31 +++--- .../smoke/failure-diagnostic.test.mjs | 94 +++++++++++++++++-- 4 files changed, 104 insertions(+), 29 deletions(-) diff --git a/tools/cli-package/README.md b/tools/cli-package/README.md index f66bafaa..d798c906 100644 --- a/tools/cli-package/README.md +++ b/tools/cli-package/README.md @@ -54,7 +54,11 @@ publication, and a secondary cleanup/diagnostic failure preserves the primary. A failed worker emits one bounded synthetic-test diagnostic on stderr after its cleanup and private report write. The projection identifies failed canonical cases and selected native-close facts without copying report paths, environment, argv, -transcripts or raw child output. It remains failure evidence: the parent still joins +transcripts or raw child output. Failure-text fields use one fixed private-detail +label, including assertion headers and nested reason/cleanup fields; arbitrary +first-line text is never public. Canonical case IDs and bounded native codes, +signals, identities and close observations remain available. The original raw +primary stays in the existing private report. It remains failure evidence: the parent still joins the worker and removes its installation, and no success receipt is published. An unavailable diagnostic sink does not replace the original failure. diff --git a/tools/cli-package/consumer-lifetime.test.mjs b/tools/cli-package/consumer-lifetime.test.mjs index 420e9b4a..df66448c 100644 --- a/tools/cli-package/consumer-lifetime.test.mjs +++ b/tools/cli-package/consumer-lifetime.test.mjs @@ -163,7 +163,7 @@ for (const diagnosticSinkFailure of [false, true]) assert.equal(diagnostic.parentPid, process.pid); assert.equal(diagnostic.cases.length, 1); assert.equal(diagnostic.cases[0].id, 'actual-native-process-owner'); - assert.match(diagnostic.cases[0].error, /synthetic helper-close failure/); + assert.equal(diagnostic.cases[0].error, 'Failure detail retained in private report.'); assert.equal(diagnostic.cases[0].native[0].observed[0].code, 1); assert.equal(diagnostic.cases[0].native[0].cleanup.cliActualCloseObserved, true); } diff --git a/tools/cli-package/smoke/failure-diagnostic.mjs b/tools/cli-package/smoke/failure-diagnostic.mjs index 383367a6..bc594d40 100644 --- a/tools/cli-package/smoke/failure-diagnostic.mjs +++ b/tools/cli-package/smoke/failure-diagnostic.mjs @@ -66,19 +66,13 @@ const code = (value) => const signal = (value) => (value === null || signals.has(value) ? value : undefined); const identity = (value, pattern) => typeof value === 'string' && value.length <= 128 && pattern.test(value) ? value : 'unavailable'; -function errorText(value, maximum = 256) { - if (typeof value !== 'string') return undefined; - // Only the first diagnostic line is useful here; stacks, assertion diffs and child output stay private. - const first = value.slice(0, 1024).split(/[\r\n]/u, 1)[0]; - if (/[a-z]:[\\/]|file:\/\/|(?:^|[\s("'=])(?:\.{1,2}[\\/]|[\\/])/iu.test(first)) { - const errno = - /^Error:\s*(EACCES|EPERM|ENOENT|EBUSY|EIO|EEXIST|EINVAL|ENOTDIR|EISDIR|ENAMETOOLONG|ETIMEDOUT|ENOSPC|EROFS)\b/u.exec( - first, - )?.[1]; - return `${errno === undefined ? 'Failure' : errno}: path detail omitted.`; - } - return first.replace(/[^\x20-\x7e]/gu, '?').slice(0, maximum); +// Assertion messages may contain raw child stderr, argv or other fixture text. Only +// the presence of a private failure is public; structured native facts use the +// separate closed projections below. Text shape is never evidence of safe origin. +function privateFailure(value) { + return typeof value === 'string' ? 'Failure detail retained in private report.' : undefined; } + function observation(raw) { const value = object(raw); if (!operations.has(value.operation)) return undefined; @@ -91,7 +85,7 @@ function observation(raw) { detached: bool(value.detached), shell: bool(value.shell), synthetic: bool(value.synthetic), - error: errorText(value.reason, 128), + error: privateFailure(value.reason), ...(value.operation === 'native-signal-observed' ? { owned: bool(value.owned), @@ -151,7 +145,7 @@ function nativeMode(raw) { watchdogFired: bool(object(cleanup.sentinel).watchdogFired), }, failures: list(cleanup.failures, 3) - .map((value) => errorText(value, 128)) + .map((value) => privateFailure(value)) .filter(Boolean), }, }; @@ -172,9 +166,9 @@ export function formatInstalledWorkerFailure(raw) { : 'unavailable', workerPid: pid(report.pid), parentPid: pid(report.parentPid), - error: errorText(report.error), - cleanupError: errorText(report.cleanupError), - immutabilityError: errorText(report.immutabilityError), + error: privateFailure(report.error), + cleanupError: privateFailure(report.cleanupError), + immutabilityError: privateFailure(report.immutabilityError), compiledUnchanged: bool(report.compiledUnchanged), ownedTempRemoved: bool(report.ownedTempRemoved), cases: REQUIRED_SMOKE_CASES.flatMap((id) => { @@ -184,7 +178,7 @@ export function formatInstalledWorkerFailure(raw) { { id, status: item === undefined ? 'missing' : 'failed', - error: errorText(item?.error), + error: privateFailure(item?.error), ...(['actual-native-process-owner', 'actual-native-bootstrap-lifecycle'].includes(id) ? { native: list(object(item?.evidence).outcomes, 6) @@ -201,7 +195,6 @@ export function formatInstalledWorkerFailure(raw) { if (Buffer.byteLength(line) > MAX_WORKER_FAILURE_BYTES) { diagnostic.truncated = true; for (const item of diagnostic.cases) { - item.error = errorText(item.error, 96); if ((item.native?.length ?? 0) > 2) { item.native = [item.native[0], item.native.at(-1)]; item.nativeModesTruncated = true; diff --git a/tools/cli-package/smoke/failure-diagnostic.test.mjs b/tools/cli-package/smoke/failure-diagnostic.test.mjs index 8c0d48b4..26f0054e 100644 --- a/tools/cli-package/smoke/failure-diagnostic.test.mjs +++ b/tools/cli-package/smoke/failure-diagnostic.test.mjs @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import { Buffer } from 'node:buffer'; import { test } from 'node:test'; import { REQUIRED_SMOKE_CASES } from './cases.mjs'; +import { assertExit } from './process.mjs'; +import { InstalledNativeControlError } from './native-process.mjs'; import { emitInstalledWorkerFailure, formatInstalledWorkerFailure, @@ -69,10 +71,7 @@ test('failure identifies the exact artifact/runtime/canonical case and partial n diagnostic.cases.map((item) => item.id), ['actual-native-process-owner'], ); - assert.equal( - diagnostic.cases[0].error, - 'AssertionError [ERR_ASSERTION]: synthetic helper close failed', - ); + assert.equal(diagnostic.cases[0].error, 'Failure detail retained in private report.'); assert.equal(diagnostic.cases[0].native[0].observed[0].code, 1); assert.equal(diagnostic.cases[0].native[0].cleanup.sentinel.actualCloseObserved, true); }); @@ -127,8 +126,8 @@ test('strict projection excludes paths, env, argv, transcripts, unknown IDs and assert.equal(line.includes(excluded), false); const diagnostic = JSON.parse(line); assert.equal(diagnostic.cases.length, 1); - assert.match(diagnostic.error, /path/); - assert.equal(diagnostic.cases[0].error, 'EACCES: path detail omitted.'); + assert.equal(diagnostic.error, 'Failure detail retained in private report.'); + assert.equal(diagnostic.cases[0].error, 'Failure detail retained in private report.'); assert.equal(diagnostic.cases[0].native[0].observed.length, 1); }); @@ -144,8 +143,8 @@ test('worker-level cleanup and immutability failures remain visible when all cas assert.deepEqual(diagnostic.cases, []); assert.equal(diagnostic.compiledUnchanged, false); assert.equal(diagnostic.ownedTempRemoved, false); - assert.match(diagnostic.cleanupError, /synthetic cleanup refusal/); - assert.match(diagnostic.immutabilityError, /synthetic compiled drift/); + assert.equal(diagnostic.cleanupError, 'Failure detail retained in private report.'); + assert.equal(diagnostic.immutabilityError, 'Failure detail retained in private report.'); }); test('oversized Unicode/errors/native observations produce one valid line within eight KiB', () => { @@ -219,3 +218,82 @@ test('stderr refusal is secondary and never invokes another sink or changes the assert.equal(attempted, 1); assert.equal(failed.success, false); }); + +// Use the actual assertion and wrapper: the installed control supplies child +// stderr as its assertion message, which Node places in the first stack line. +test('actual child-stderr assertion chain stays private through worker failure projection', () => { + const canary = 'PRIVATE_ASSERTION_CHILD_STDERR_SENTINEL'; + let primary; + try { + assertExit({ signal: null, code: 134, stderr: canary }, 9); + } catch (cause) { + primary = cause; + } + assert.ok(primary instanceof assert.AssertionError); + const native = new InstalledNativeControlError(primary, { outcomes: [] }); + assert.ok(native.primaryFailure.includes(canary), 'Private primary evidence must be preserved'); + const failed = report(); + const item = failed.cases.find((value) => value.id === 'actual-native-bootstrap-lifecycle'); + item.passed = false; + // The actual worker retains this exact primaryFailure branch in its private report. + item.error = native.primaryFailure; + item.evidence = native.evidence; + const line = formatInstalledWorkerFailure(failed); + assert.equal(line.includes(canary), false, 'Public diagnostic copied raw child stderr'); + const diagnostic = JSON.parse(line); + assert.equal(diagnostic.cases[0].id, 'actual-native-bootstrap-lifecycle'); + assert.equal(diagnostic.cases[0].error, 'Failure detail retained in private report.'); + assert.equal(item.error, native.primaryFailure, 'Projection must not mutate the private report'); +}); + +test('every root, case, native reason and cleanup failure keeps arbitrary first-line text private', () => { + for (const canary of [ + 'PRIVATE_PLAIN_TEXT_FROM_CHILD', + 'Error: child credential token=PRIVATE_CREDENTIAL_VALUE', + 'AssertionError [ERR_ASSERTION]: child argv --secret PRIVATE_ARGUMENT_VALUE', + 'EACCES PRIVATE_NATIVE_DETAIL_WITHOUT_A_PATH', + ]) { + const failed = report(); + failed.error = canary; + failed.cleanupError = canary; + failed.immutabilityError = canary; + const item = failed.cases.find((value) => value.id === 'actual-native-process-owner'); + item.passed = false; + item.error = canary; + item.evidence = { + outcomes: [ + { + mode: 'injected-error', + passed: false, + observed: [ + { + operation: 'native-signal-observed', + pid: 80, + owned: true, + target: -40, + signal: 'SIGKILL', + outcome: 'threw', + nativeCode: 'EPERM', + errno: -1, + milliseconds: 2, + reason: canary, + }, + ], + cleanup: { cliActualCloseObserved: true, failures: [canary] }, + }, + ], + }; + const line = formatInstalledWorkerFailure(failed); + assert.equal(line.includes(canary), false, 'Arbitrary failure text entered a public field'); + const diagnostic = JSON.parse(line); + for (const key of ['error', 'cleanupError', 'immutabilityError']) + assert.equal(diagnostic[key], 'Failure detail retained in private report.'); + const mode = diagnostic.cases[0].native[0]; + assert.equal(mode.observed[0].error, 'Failure detail retained in private report.'); + assert.equal(mode.cleanup.failures[0], 'Failure detail retained in private report.'); + assert.equal(mode.observed[0].nativeCode, 'EPERM'); + assert.equal(mode.observed[0].errno, -1); + assert.equal(mode.observed[0].target, -40); + assert.equal(mode.cleanup.cliActualCloseObserved, true); + } +}); From eb5d61af098c27284fa57fd69206c7045ab08a44 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 10:49:43 +0300 Subject: [PATCH 138/222] docs(docs): record disclosure correction and remaining wave gates Record complete R14 cumulative coverage, confirmed disclosure, fixture and installed-test projection findings, their fixes and actual author evidence. Keep 8d/whole-step acceptance open and distinguish older remote platform success from the corrected source. Update the canonical delivery contract. Refs: ADR-0110, ADR-0111, ADR-0103; Wave 3 Step 8d R14 Co-Authored-By: Codex --- AGENTS.md | 25 +++--- CLAUDE.md | 25 +++--- docs/reference/cli/commands.md | 11 +++ docs/roadmap/current.md | 12 +++ .../phase-2.5.5-hardening-and-remediation.md | 87 +++++++++++++++++++ 5 files changed, 136 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 70c7af57..4283ccd5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,18 +107,19 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first thirteen complete cumulative rounds are -NONCLEAN. R13 independently verifies all 310 paths / 1,706 cumulative hunks at -`c15aa314`, with explicitly attributed supplementary reading. Its High checkpoint-notice -finding is corrected at `afb3b932`: the original captured IO observes failed writes, -and the actual pending delivery remains owned independently of database closure. -Real SQLite/default-factory/management-command regressions fail on the old source; -fixed author gates pass (349 CLI files / 5,566 passes / ten established skips). -All four same-artifact consumers and the Windows advisory pass at the older reviewed -source; these results do not certify the correction. Earlier failed Windows runs -retain their original outcomes and unknown initiating causes. New independent -reviews, a separate security sitting and final-source CI/platform acceptance remain pending. -Two NEW complete cumulative clean rounds after the last material fix, a NEW +but not independently accepted. Its first fourteen complete cumulative rounds are +NONCLEAN. R14 independently verifies all 314 paths / 1,715 cumulative hunks at +`dcc23cfb`, with explicitly attributed supplementary reading. Two High disclosure +findings and the workspace-fixture isolation finding are corrected at `d6d2fbdd`; +the installed-test diagnostic projection is corrected at `e218ba0e`. Actual +chat/Home/run/gate regressions preserve evidence and original pending-delivery +ownership; safe committed cleanup and known operational outcomes remain intact. +Author gates pass (352 CLI files / 5,590 passes / ten established skips and 549 +acceptance-tool tests), with a separately joined post-build CLI run and zero +unhandled errors. All four same-artifact consumers and Windows advisory pass at +the older reviewed source; full CI was pending there when recorded. Those results +do not certify the correction. Earlier failed Windows runs retain their outcomes +and unknown initiating causes. Two NEW complete cumulative clean rounds, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. Step 8 proceeds as recorded serial increments 8a–8d; native diff --git a/CLAUDE.md b/CLAUDE.md index 1e1e31a7..5d2322c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,18 +196,19 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first thirteen complete cumulative rounds are -NONCLEAN. R13 independently verifies all 310 paths / 1,706 cumulative hunks at -`c15aa314`, with explicitly attributed supplementary reading. Its High checkpoint-notice -finding is corrected at `afb3b932`: the original captured IO observes failed writes, -and the actual pending delivery remains owned independently of database closure. -Real SQLite/default-factory/management-command regressions fail on the old source; -fixed author gates pass (349 CLI files / 5,566 passes / ten established skips). -All four same-artifact consumers and the Windows advisory pass at the older reviewed -source; these results do not certify the correction. Earlier failed Windows runs -retain their original outcomes and unknown initiating causes. New independent -reviews, a separate security sitting and final-source CI/platform acceptance remain pending. -Two NEW complete cumulative clean rounds after the last material fix, a NEW +but not independently accepted. Its first fourteen complete cumulative rounds are +NONCLEAN. R14 independently verifies all 314 paths / 1,715 cumulative hunks at +`dcc23cfb`, with explicitly attributed supplementary reading. Two High disclosure +findings and the workspace-fixture isolation finding are corrected at `d6d2fbdd`; +the installed-test diagnostic projection is corrected at `e218ba0e`. Actual +chat/Home/run/gate regressions preserve evidence and original pending-delivery +ownership; safe committed cleanup and known operational outcomes remain intact. +Author gates pass (352 CLI files / 5,590 passes / ten established skips and 549 +acceptance-tool tests), with a separately joined post-build CLI run and zero +unhandled errors. All four same-artifact consumers and Windows advisory pass at +the older reviewed source; full CI was pending there when recorded. Those results +do not certify the correction. Earlier failed Windows runs retain their outcomes +and unknown initiating causes. Two NEW complete cumulative clean rounds, a NEW separate clean security sitting and fresh full CI plus all four same-artifact package consumers remain required. Step 8 proceeds as recorded serial increments 8a–8d; native diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 22867813..551dde10 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1255,3 +1255,14 @@ requests and deadline expiry never acknowledge a pending notice delivery. Ownerless library calls retain their existing best-effort behaviour. A custom opener delegating to the default factories forwards the explicit notice context, alongside its existing native-close handoff. + +Resumed chat and Home effect disclosures bind publication, render acknowledgement +and fallback stderr delivery to the original session owner before advisory +recovery. Failed publication retains the audit rows and selects +`output_unavailable`; an entered delivery remains owned until its actual +settlement. Run/gate and one-shot retention warnings use their original command +or session owner in the same way. A maintenance refusal with successful warning +delivery remains best effort, and stopped one-shot cleanup still sweeps only its +committed rows. Neither database closure nor an outer recovered helper promise +acknowledges a pending notification. Ownerless library calls retain their existing +best-effort behaviour. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 75c6eb1f..8f568b39 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1266,6 +1266,18 @@ The older source's four installed consumers and Windows advisory pass; its full CI was still running when recorded. These do not certify the correction. R1–R13 remain NONCLEAN; two NEW complete cumulative clean rounds, separate security and fresh final-source CI/platforms are required. No user decision is pending. +The [fourteenth cumulative review and disclosure-retention corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-fourteenth-cumulative-review-and-disclosure-retention-corrections--2026-10-17) +record complete NONCLEAN coverage of 314 paths / 1,715 independently rehashed +hunks at `dcc23cfb`. Two High disclosure instances share a common ownership gap; +`d6d2fbdd` fixes it across chat/Home/run/gate and fixes workspace fixture isolation. +`e218ba0e` keeps arbitrary installed-test error text in its private report. Actual +old-source/removal controls break the corresponding assertions; author gates pass +(352 CLI files / 5,590 passes / ten established skips; 549 acceptance-tool tests). +A separate joined post-build CLI run reports zero unhandled errors. At the older +reviewed source, all four installed consumers and Windows advisory pass; full CI +was pending at the filesystem causal job when recorded. R1–R14 remain NONCLEAN; +two NEW complete clean cumulative rounds, separate security and fresh final-source +CI/platforms remain required. Steps 9–19 have not started. No user decision is pending. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 8b1addef..92120fb1 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -7492,3 +7492,90 @@ material correction, a NEW separate clean security sitting and fresh full CI plu all four same-artifact consumers remain required. Steps 9–19 have not started; no credential, billing, provider capture, production recipient or user decision is pending. + +### Wave 3 Step 8d fourteenth cumulative review and disclosure-retention corrections — 2026-10-17 + +**R14 is complete and NONCLEAN; Step 8d and whole Step 8 remain open.** The +cumulative range is `d9625ff0a641197c6ed45968df99094a25db88d6` → +`dcc23cfbf3aa497b5100e8353fdd58a9b92fbe9d`: **314 paths / 1,715 hunks** in six +disjoint groups. Seven named reviewers read their assigned complete current +production/test/support bodies and every cumulative/deleted hunk. The native +87-path group explicitly combines its 55-path primary and 32-path supplement; +neither borrows personal reading or execution, and the supplement is not an extra +round. Large normative/register files retain their changed-section/context +qualification. Root reads all seven reports and independently recomputes every +physical/full-diff/hunk identity. Inventory SHA-256 is +`492d5c7cd7f640e74b96c008d249e44036d6ab2d99fe7d8e7ceb7cfc42b20fc2`; +aggregate SHA-256 is `2f374a8c11700bfa4e8c9509e9b5a8ed51b5b24ca10b2981be51090162f48352`. +Opening/closing freezes are identical across 1,746 tracked blobs, 21,165 physical +dependency files, 1,215 lexical symlinks and 466 existing dist files. Detailed +records remain local and Git-ignored. + +**Two High surface findings, corrected at `d6d2fbdd`.** `R14-CHAT-1` and +`R14-HOME-1` independently reproduce the same disclosure-recovery gap: raw notice +failure is recovered, and fallback stderr delivery can remain pending after the +session/database and invocation owners retire. The shared retention boundary now +observes actual publication/render/sink failure before advisory recovery and +retains entered delivery through the original session or workflow owner. Failed +disclosure keeps audit evidence; successful maintenance warnings remain best +effort. Safe committed one-shot cleanup remains permitted after stop. Ownerless +library compatibility and actual workflow-health priority remain unchanged. +Root additionally reproduces this warning gap through actual `run` and `gate` +commands; both shipping callers now retain the same original delivery ownership. +The [CLI contract](../../reference/cli/commands.md#cooperative-session-finalization-and-passive-notifications) +owns the behaviour. This needs no new ADR, dependency, public seam or decision. + +Identical actual chat/run/gate regressions on an immutable `dcc23cfb` archive yield +**eight precise assertion failures / seven positive controls**; the corrected +source passes all fifteen. Real Home/reseat/SQLite controls add seven cases; +substituting only the exact frozen retention helper into that candidate graph +breaks its three output-fault assertions and preserves four controls. That is a +helper-removal control, not original-whole-source execution. Held successful +acknowledgements, no-publish renderers, recoverable disclosure-read refusal and +ownerless compatibility remain separately exercised. Providers, renderer ports, +exit clocks and sink faults are synthetic; SQLite and command/host execution are +real. Existing-dist native children and source-alias parents retain their limits. + +**Two Medium findings are corrected.** `R14-MS-1` is fixed in the same CLI commit: +workspace fixture setup refuses an unretired predecessor before global mutation; +teardown shares its actual join, seals entry and restores only its captured +current fixture. Two new permanent controls pass, and the complete file passes +12/12. Real-runner original/corrected schedules retain the original five-second +test / ten-second hook bounds. Deliberate predecessor timeout/refusal remains +failed evidence; only the corrected later recovery passes. No historical Windows +initiating cause is inferred. `W3-8D-R14-TD-01` is corrected at `e218ba0e`: installed +worker diagnostics project a fixed private-detail label for all arbitrary error +text, retaining useful closed native/case facts and the unchanged raw private +report. The actual child-stderr → assertion → wrapper → projection regression and +nested-field controls fail on frozen code; all 31 focused fixed tests pass. The +five baseline failures include three changed label expectations, not five claimed +independent causal mechanisms. This is synthetic test-output custody, not an +observed production credential leak. + +**Applied-source author gates pass.** `pnpm turbo run lint typecheck test` passes +23 tasks (20 cached); fresh CLI execution passes **352 files / 5,590 tests / ten +established skips**. A separate post-build complete CLI run confirms the same +counts and zero machine-reported unhandled errors, with the actual child joined. +All 549 acceptance-tool tests pass. Build passes six tasks (five cached); format, +fences, engine dependencies, tooling lint/typecheck and 731-file isolation pass. +Compiled closure remains 25 outputs / 410 inputs / 17 externals. Compiled bootstrap +passes 177 controls on local Darwin / Node 22.23.1, and workflow/MCP/budget CLI smoke +passes. Initial test-only typing/lint/format failures were corrected before these +final gates; no assertion or timeout was weakened. Author validation SHA-256 is +`d29ab254051d3c6fed7b1ee2de44c3858e6c0b3e9baefb51323896703d4f0e7c`. + +At reviewed `dcc23cfb`, run `39012759605` has successful pack, all four required +same-artifact consumers, their aggregate and Windows advisory. Root and an +independent reviewer physically inspect five ZIPs, all 70 tar members and four +receipts: tarball SHA-256 `e7e72e016d90be0a4767aa05974e38963f5bbd277203a2b0926430982d223c2a`, +446,189 bytes, 177 bootstrap controls and eight installed cases per consumer. +Full CI is still in progress at the filesystem causal job when recorded. These +old-source results do not accept the later correction. The superseded `c15aa314` +run was cancelled before full CI finished; its existing platform successes and +all earlier failed Windows outcomes retain their original attribution. + +R1–R14 remain NONCLEAN. Two NEW complete cumulative CLEAN rounds after the last +material fix, a NEW separate clean security sitting and fresh full CI plus all +four same-artifact consumers remain required. Steps 9–19 have not started. No +credential, provider capture, billing, production archive recipient or user +decision is pending; implementation continues automatically on `development`. From 151abaf5dcefa9602023ae8c27300d8822e3fa57 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 11:31:09 +0300 Subject: [PATCH 139/222] fix(cli): retain original home doctor publication and work Observe controller subscriber and doctor formatter faults through the actual Home invocation before advisory recovery can consume them. Retain the full entered doctor report until actual settlement before database release, while known check refusals and ownerless embeddings keep their existing outcomes. Pin healthy, private-fault, held-report, late-rejection and compatibility paths with actual Home and SQLite controls. Refs: ADR-0110, ADR-0103, Wave 3 Step 8d Co-Authored-By: Codex --- apps/cli/src/home/drive-home.tsx | 1 + .../src/home/home-doctor-ownership.test.ts | 212 ++++++++++++++++++ apps/cli/src/render/tui/home-controller.ts | 58 +++-- 3 files changed, 249 insertions(+), 22 deletions(-) create mode 100644 apps/cli/src/home/home-doctor-ownership.test.ts diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index 9810bc17..bdbeac4d 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -1295,6 +1295,7 @@ async function driveHomeBody(deps: HomeDeps, ownership?: SessionHomeLifetime): P resolve(EXIT_CODES.success); }; controller = createHomeController({ + ...(ownership === undefined ? {} : { workOwner: ownership.surface.work }), startChat, reseatChat, homeStore, diff --git a/apps/cli/src/home/home-doctor-ownership.test.ts b/apps/cli/src/home/home-doctor-ownership.test.ts new file mode 100644 index 00000000..3e7b2f61 --- /dev/null +++ b/apps/cli/src/home/home-doctor-ownership.test.ts @@ -0,0 +1,212 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createClient, createSessionStore, runMigrations } from '@relavium/db'; +import { expect, it, vi } from 'vitest'; + +import * as doctor from '../chat/doctor.js'; +import { scriptedResolver } from '../chat/test-support.js'; +import { ConfigError } from '../config/errors.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import type { InvocationWorkOwner } from '../process/invocation-work-owner.js'; +import type { RootAppProps } from '../render/tui/home-app.js'; +import { captureIo } from '../test-support.js'; +import { driveHome } from './drive-home.js'; +import { SessionHomeLifetime } from './session-home-lifetime.js'; + +vi.mock('../secrets/os-keychain.js', () => ({ + createOsKeychainStore: () => ({ get: () => null, set: () => undefined, delete: () => false }), +})); + +const cases = [ + 'healthy', + 'checking-fault', + 'publication-fault', + 'formatter-fault', + 'known-refusal', + 'held-report', + 'late-rejection', + 'ownerless-publication-fault', +] as const; + +it.each(cases)('actual Home doctor retains original publication and work: %s', async (mode) => { + const client = createClient(':memory:'); + runMigrations(client.db); + const sessions = createSessionStore(client.db); + const f = invocationFixture(); + const cwd = mkdtempSync(join(tmpdir(), 'relavium-home-doctor-owner-')); + const captured = captureIo(); + const cause = new TypeError('PRIVATE_HOME_DOCTOR_LOCAL_FAULT /private/doctor-detail'); + const owned = mode !== 'ownerless-publication-fault'; + let owner: InvocationWorkOwner | undefined; + // eslint-disable-next-line @typescript-eslint/unbound-method -- Preserve the original method and explicitly restore its actual receiver with call() below. + const execute = SessionHomeLifetime.prototype.execute; + vi.spyOn(SessionHomeLifetime.prototype, 'execute').mockImplementation(function ( + this: SessionHomeLifetime, + body, + ) { + owner = this.surface.work; + return execute.call(this, body); + }); + let releaseReport: (report: doctor.DoctorReport) => void = () => {}; + let rejectReport: (reason: unknown) => void = () => {}; + const held = new Promise((resolve, reject) => { + releaseReport = resolve; + rejectReport = reject; + }); + let reportEntered = () => {}; + const entered = new Promise((resolve) => { + reportEntered = resolve; + }); + const report: doctor.DoctorReport = { checks: [] }; + if (mode === 'held-report' || mode === 'late-rejection') + vi.spyOn(doctor, 'runDoctorChecks').mockImplementation(() => { + reportEntered(); + return held; + }); + if (mode === 'formatter-fault') + vi.spyOn(doctor, 'formatDoctorReport').mockImplementation(() => { + throw cause; + }); + let props: RootAppProps | undefined; + let unmount = () => {}; + const exited = new Promise((resolve) => { + unmount = resolve; + }); + let closes = 0; + let settled = false; + let hits = 0; + const running = driveHome({ + ...(owned ? { invocation: f.invocation } : {}), + io: { ...captured.io, stdoutIsTty: true, stdinIsTty: true }, + global: { + cwd, + configPath: join(cwd, 'absent.toml'), + json: false, + color: false, + verbosity: 'normal', + }, + providers: scriptedResolver([]), + doctorProbes: { + keychain: () => {}, + config: () => { + if (mode === 'known-refusal') throw new ConfigError('synthetic.toml', 'invalid shape'); + }, + toolHost: {}, + }, + openSessionStore: () => ({ + db: client.db, + store: sessions, + close: () => { + closes++; + client.sqlite.close(); + }, + }), + render: (value) => { + props = value; + value.onRendererReady?.(async (publish) => { + await publish?.(); + }); + return { + unmount, + waitUntilExit: () => exited, + waitUntilRenderFlush: () => Promise.resolve(), + }; + }, + getSize: () => ({ cols: 120, rows: 40 }), + subscribeResize: () => () => {}, + subscribeSignals: () => () => {}, + subscribeProcessExit: () => () => {}, + jobControlLifecycle: { + supported: false, + onSuspend: () => () => {}, + onContinue: () => () => {}, + suspendSelf: () => {}, + }, + onboardingTerminalLifecycle: { + onInput: () => () => {}, + isRawMode: () => false, + setRawMode: () => {}, + }, + }).then((code) => { + settled = true; + return code; + }); + let remove = () => {}; + try { + if (props === undefined) throw new Error('Expected actual Home mount.'); + const controller = props.controller; + remove = controller.subscribe(() => { + const text = controller.getSnapshot().notice; + const match = + mode === 'checking-fault' + ? text === 'doctor: checking…' + : text?.includes('all checks passed'); + if (match && hits === 0) { + hits++; + if (['checking-fault', 'publication-fault', 'ownerless-publication-fault'].includes(mode)) + throw cause; + } + }); + controller.handleKey('/', {}); + for (const char of 'doctor') controller.handleKey(char, {}); + controller.handleKey('', { return: true }); + if (mode === 'held-report' || mode === 'late-rejection') { + expect(await Promise.race([entered.then(() => 'report'), running.then(() => 'exit')])).toBe( + 'report', + ); + controller.handleKey('c', { ctrl: true }); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled, 'DOCTOR_RAW_REPORT_MUST_JOIN_ORIGINAL_HOME').toBe(false); + expect(closes).toBe(0); + expect(client.sqlite.open).toBe(true); + expect(f.registry.activeOwners).toBeGreaterThan(0); + if (mode === 'late-rejection') rejectReport(cause); + else releaseReport(report); + } else if (mode === 'formatter-fault') { + await vi.waitFor(() => + expect( + owner?.firstFailure !== undefined || + controller.getSnapshot().notice === 'doctor: check failed', + ).toBe(true), + ); + remove(); + controller.handleKey('c', { ctrl: true }); + } else if (mode === 'known-refusal') { + await vi.waitFor(() => expect(controller.getSnapshot().notice).toContain('check(s) failed')); + expect(controller.getSnapshot().notice).toContain('invalid shape'); + remove(); + controller.handleKey('c', { ctrl: true }); + } else { + await vi.waitFor(() => expect(hits).toBe(1)); + remove(); + controller.handleKey('c', { ctrl: true }); + } + const faulted = [ + 'checking-fault', + 'publication-fault', + 'formatter-fault', + 'late-rejection', + ].includes(mode); + expect(await running, 'DOCTOR_PUBLICATION_FAILURE_REACHES_ORIGINAL_HOME').toBe(faulted ? 9 : 0); + if (faulted) { + expect(owner?.firstFailure?.cause).toBe(cause); + expect(f.registry.observations.infrastructureCode()).toBe('cli_internal'); + } else { + expect(owner?.firstFailure).toBeUndefined(); + expect(f.registry.observations.infrastructureCode()).toBeUndefined(); + } + expect(closes).toBe(1); + expect(client.sqlite.open).toBe(false); + expect(f.registry.activeOwners).toBe(0); + expect(captured.out() + captured.err()).not.toContain('PRIVATE_HOME_DOCTOR'); + } finally { + remove(); + releaseReport(report); + unmount(); + await running; + vi.restoreAllMocks(); + if (client.sqlite.open) client.sqlite.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/apps/cli/src/render/tui/home-controller.ts b/apps/cli/src/render/tui/home-controller.ts index 875f2bee..c4790e5b 100644 --- a/apps/cli/src/render/tui/home-controller.ts +++ b/apps/cli/src/render/tui/home-controller.ts @@ -21,6 +21,7 @@ import { nextMode, type ChatMode } from '../../chat/chat-mode.js'; import { clearedNotice, modelSwitchNotice } from '../../chat/repl-info.js'; import { formatDoctorReport, runDoctorChecks, type DoctorProbes } from '../../chat/doctor.js'; import type { HomeSnapshot, HomeStore } from '../../home/home-store.js'; +import type { InvocationWorkOwner } from '../../process/invocation-work-owner.js'; import { applyEditorAction, editorFromText, @@ -236,6 +237,8 @@ export interface HomeControllerDeps { carriedTranscript: readonly TranscriptEntry[], ) => Promise; readonly homeStore: HomeStore; + /** Original Home invocation; observe publication before local recovery and retain entered doctor work. */ + readonly workOwner?: Pick; /** The Home exited cleanly (Ctrl-C / EOF in Home mode) → `driveHome` resolves with exit 0. */ readonly onExit: () => void; /** An unexpected error escaping a chat turn (a re-thrown turn-core bug) — `driveHome` tears down + propagates. */ @@ -366,7 +369,13 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { }); const notify = (): void => { - for (const listener of listeners) listener(); + try { + for (const listener of listeners) listener(); + } catch (cause) { + // Palette and other advisory callers may recover; the original publication fault comes first. + deps.workOwner?.failure(cause); + throw cause; + } }; /** Whether a chat turn is streaming — paste content (like every other key) is gated mid-turn. */ const chatRunning = (): boolean => @@ -1042,27 +1051,32 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { editTranscript: () => undefined, copyTranscript: () => undefined, - runDoctor: async (deep) => { - if (exiting) return; - const runId = (doctorRunId += 1); // a new run; a prompt edit/submit or a later run bumps this, invalidating us - set({ notice: 'doctor: checking…' }); - let text: string; - try { - text = formatDoctorReport(await runDoctorChecks(deep, deps.doctorProbes)); - } catch { - text = 'doctor: check failed'; - } - // Land ONLY if nothing moved on during the await: still THIS run (the prompt wasn't edited/submitted), still - // a bare idle Home (no chat started, mode still 'home'), and the palette isn't open (it cleared the notice). - if ( - runId === doctorRunId && - !exiting && - state.mode === 'home' && - state.session === undefined && - state.palette === undefined - ) { - set({ notice: text }); - } + runDoctor: (deep) => { + if (exiting) return Promise.resolve(); + const operation = async (): Promise => { + const runId = (doctorRunId += 1); // a new run; a prompt edit/submit or a later run bumps this, invalidating us + set({ notice: 'doctor: checking…' }); + let text: string; + try { + text = formatDoctorReport(await runDoctorChecks(deep, deps.doctorProbes)); + } catch (cause) { + deps.workOwner?.failure(cause); + text = 'doctor: check failed'; + } + // Land ONLY if nothing moved on during the await: still THIS run (the prompt wasn't edited/submitted), still + // a bare idle Home (no chat started, mode still 'home'), and the palette isn't open (it cleared the notice). + if ( + runId === doctorRunId && + !exiting && + state.mode === 'home' && + state.session === undefined && + state.palette === undefined + ) { + set({ notice: text }); + } + }; + // Returning/handling this promise is separate from retaining the actual original operation. + return deps.workOwner === undefined ? operation() : deps.workOwner.enter(operation); }, }; From 1ed15ab88fe69e33a1f6c3e5c39ae6d3e4d5305a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 11:31:13 +0300 Subject: [PATCH 140/222] fix(repo): align filesystem control workload with fixture cases The two new fixture-isolation regressions increase the workspace-admission whole-file workload from ten to twelve. Update only that exact declaration; retain the guard that correctly refused the stale count in CI and preserve the fixed, removal and restoration semantic assertions. Correct the inherited serializer workload count in the secrets README from 24 to the existing 25 cases; this changes no runtime declaration. Refs: ADR-0105, ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- tools/fs-policy-controls/mutations.mjs | 2 +- tools/secrets-controls/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/fs-policy-controls/mutations.mjs b/tools/fs-policy-controls/mutations.mjs index 9672151a..7210b8d4 100644 --- a/tools/fs-policy-controls/mutations.mjs +++ b/tools/fs-policy-controls/mutations.mjs @@ -253,7 +253,7 @@ for (const mutation of mutations) { : file === floorCases[0] ? 14 : file === workspaceCases - ? 10 + ? 12 : file === cooperativeCases ? 6 : file === windowsWinnerCases diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index b7cf7d1d..0c8c06e2 100644 --- a/tools/secrets-controls/README.md +++ b/tools/secrets-controls/README.md @@ -102,7 +102,7 @@ parse/export mechanisms: direct unsupported `secrets.*` context refusal, title N normalization, session-identity fallback for an unusable title, and bounded legal encoding for an odd external session id. The context removal must fail an unread direct-reference parser case; the complete analysis file has 22 cases. Each export-ID -removal must fail its own serializer assertion in the complete 24-case file. Separate +removal must fail its own serializer assertion in the complete 25-case file. Separate positive tests retain legitimate secret-input context propagation, faithful metadata, and deterministic parse/serialize behavior. These controls do not certify universal transliteration or uniqueness of generated IDs. From 71a91042c040da026a746cb0d468120000ce6dee Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 11:32:48 +0300 Subject: [PATCH 141/222] docs(docs): record doctor correction and remaining acceptance gates Record all 318 paths and 1,737 cumulative hunks in the nonclean fifteenth review, the two verified fixes, applied-source author gates and explicit predecessor CI failure. Keep Step 8d and later increments open until two new complete clean rounds, separate security and fresh CI/platform evidence. Document original Home report and notification ownership in the canonical CLI contract. Detailed reports remain local and Git-ignored. Refs: ADR-0110, ADR-0103, Wave 3 Step 8d Co-Authored-By: Codex --- AGENTS.md | 30 +++---- CLAUDE.md | 30 +++---- docs/reference/cli/commands.md | 9 +++ docs/roadmap/current.md | 12 +++ .../phase-2.5.5-hardening-and-remediation.md | 81 +++++++++++++++++++ 5 files changed, 132 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4283ccd5..6de731cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,21 +107,21 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first fourteen complete cumulative rounds are -NONCLEAN. R14 independently verifies all 314 paths / 1,715 cumulative hunks at -`dcc23cfb`, with explicitly attributed supplementary reading. Two High disclosure -findings and the workspace-fixture isolation finding are corrected at `d6d2fbdd`; -the installed-test diagnostic projection is corrected at `e218ba0e`. Actual -chat/Home/run/gate regressions preserve evidence and original pending-delivery -ownership; safe committed cleanup and known operational outcomes remain intact. -Author gates pass (352 CLI files / 5,590 passes / ten established skips and 549 -acceptance-tool tests), with a separately joined post-build CLI run and zero -unhandled errors. All four same-artifact consumers and Windows advisory pass at -the older reviewed source; full CI was pending there when recorded. Those results -do not certify the correction. Earlier failed Windows runs retain their outcomes -and unknown initiating causes. Two NEW complete cumulative clean rounds, a NEW -separate clean security sitting and fresh full CI plus all four same-artifact -package consumers remain required. +but not independently accepted. Its first fifteen complete cumulative rounds are +NONCLEAN. R15 independently verifies all 318 paths / 1,737 cumulative hunks at +`eb5d61af`. Its Home doctor publication High is corrected at `151abaf5`: original +notification faults are observed before advisory recovery and complete report work +remains owned through actual settlement. The required-CI workload blocker is +corrected at `1ed15ab8`, retaining the exact 12-case count and all causal assertions. +Author gates pass (353 CLI files / 5,598 passes / ten established skips and 549 +acceptance-tool tests), including a separately joined post-build CLI run with zero +unhandled errors. The affected control passes fixed/restored 12/12 and breaks seven +assertions on removal. All four same-artifact consumers and Windows advisory pass +at reviewed `eb5d61af`; its full CI fails the stale workload and downstream dependency +gate, so those results do not certify the correction. Earlier failed Windows runs +retain their outcomes and unknown initiating causes. Two NEW complete cumulative +clean rounds, a NEW separate clean security sitting and fresh full CI plus all four +same-artifact package consumers remain required. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index 5d2322c7..91fa47eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,21 +196,21 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first fourteen complete cumulative rounds are -NONCLEAN. R14 independently verifies all 314 paths / 1,715 cumulative hunks at -`dcc23cfb`, with explicitly attributed supplementary reading. Two High disclosure -findings and the workspace-fixture isolation finding are corrected at `d6d2fbdd`; -the installed-test diagnostic projection is corrected at `e218ba0e`. Actual -chat/Home/run/gate regressions preserve evidence and original pending-delivery -ownership; safe committed cleanup and known operational outcomes remain intact. -Author gates pass (352 CLI files / 5,590 passes / ten established skips and 549 -acceptance-tool tests), with a separately joined post-build CLI run and zero -unhandled errors. All four same-artifact consumers and Windows advisory pass at -the older reviewed source; full CI was pending there when recorded. Those results -do not certify the correction. Earlier failed Windows runs retain their outcomes -and unknown initiating causes. Two NEW complete cumulative clean rounds, a NEW -separate clean security sitting and fresh full CI plus all four same-artifact -package consumers remain required. +but not independently accepted. Its first fifteen complete cumulative rounds are +NONCLEAN. R15 independently verifies all 318 paths / 1,737 cumulative hunks at +`eb5d61af`. Its Home doctor publication High is corrected at `151abaf5`: original +notification faults are observed before advisory recovery and complete report work +remains owned through actual settlement. The required-CI workload blocker is +corrected at `1ed15ab8`, retaining the exact 12-case count and all causal assertions. +Author gates pass (353 CLI files / 5,598 passes / ten established skips and 549 +acceptance-tool tests), including a separately joined post-build CLI run with zero +unhandled errors. The affected control passes fixed/restored 12/12 and breaks seven +assertions on removal. All four same-artifact consumers and Windows advisory pass +at reviewed `eb5d61af`; its full CI fails the stale workload and downstream dependency +gate, so those results do not certify the correction. Earlier failed Windows runs +retain their outcomes and unknown initiating causes. Two NEW complete cumulative +clean rounds, a NEW separate clean security sitting and fresh full CI plus all four +same-artifact package consumers remain required. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 551dde10..3a549d91 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1266,3 +1266,12 @@ delivery remains best effort, and stopped one-shot cleanup still sweeps only its committed rows. Neither database closure nor an outer recovered helper promise acknowledges a pending notification. Ownerless library calls retain their existing best-effort behaviour. + +Home controller notifications observe their own unexpected listener failures through +the original Home invocation before palette or other advisory recovery. Bare-Home +`/doctor` retains its complete entered report operation through formatting and final +publication; exiting Home does not acknowledge a still-pending report. Unexpected +formatter, report or listener faults select infrastructure `9` with private original +causes, while known diagnostic-check refusals remain an inline report. An obsolete +report still cannot overwrite a newer prompt or session. Ownerless controller +embeddings retain their existing compatibility behaviour. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 8f568b39..376fe1c4 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1278,6 +1278,18 @@ reviewed source, all four installed consumers and Windows advisory pass; full CI was pending at the filesystem causal job when recorded. R1–R14 remain NONCLEAN; two NEW complete clean cumulative rounds, separate security and fresh final-source CI/platforms remain required. Steps 9–19 have not started. No user decision is pending. +The [fifteenth cumulative review and Home doctor correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-fifteenth-cumulative-review-and-home-doctor-correction--2026-10-17) +records complete NONCLEAN coverage of 318 paths / 1,737 independently rehashed +hunks at `eb5d61af`. `151abaf5` fixes the High Home doctor publication/work gap; +`1ed15ab8` fixes the required-CI workload blocker without weakening the 12-case +coordinator. Fixed/restored runs pass 12/12 and removal breaks seven assertions. +Author gates pass (353 CLI files / 5,598 passes / ten established skips; 549 +acceptance-tool tests), with a joined post-build CLI run and explicit zero unhandled +errors. The reviewed source's four installed consumers and Windows advisory pass, +but its full CI fails the stale workload and downstream dependency gate. Those +results do not accept the correction. R1–R15 remain NONCLEAN; two NEW complete +cumulative clean rounds, separate security and fresh final-source CI/platforms +remain required. Steps 9–19 have not started. No user decision is pending. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 92120fb1..9619c689 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -7579,3 +7579,84 @@ material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact consumers remain required. Steps 9–19 have not started. No credential, provider capture, billing, production archive recipient or user decision is pending; implementation continues automatically on `development`. + +### Wave 3 Step 8d fifteenth cumulative review and Home doctor correction — 2026-10-17 + +**R15 is complete and NONCLEAN; Step 8d and whole Step 8 remain open.** Seven NEW +reviewers cover the complete cumulative `d9625ff0` → `eb5d61af` range: **318 paths / +1,737 hunks** across seven disjoint groups. Root reads every original report and +independently rehashes every physical body, full diff and hunk. Large normative, +register and generated files retain explicit changed-section/context qualifications. +Inventory SHA-256 is `26c345c208da564889ed53a5b03ab5fab06ff61236986080d3c13449e7fb49bf`; +aggregate SHA-256 is `6b61ce20970adb1967e10ee9ab502c1100680178796fa17ef383b4f601b031ad`. +Opening/closing freezes are identical across 1,749 tracked blobs, 21,165 physical +dependency files, 1,215 lexical symlinks and 466 existing dist files. Detailed +reports, the root reading receipt and all failed/corrected harness attempts remain +local and Git-ignored. Recognizing an explicitly qualified ledger field alias did +not rewrite any sealed report or relax personal reading requirements. + +**High `R15-HOME-01` is corrected at `151abaf5`.** Bare Home's final `/doctor` +notification could throw into palette recovery, leave input admitted and exit 0. +The original Home invocation now observes common controller-notification failures +before that recovery and owns the complete entered report through formatting and +publication. Unexpected report/formatter/listener faults retain private original +causes and select infrastructure 9. Known check refusals stay inline; stale reports +cannot overwrite newer interaction, and ownerless compatibility remains intact. +The [canonical CLI contract](../../reference/cli/commands.md#cooperative-session-finalization-and-passive-notifications) +owns the behaviour. No new ADR, runtime dependency or public provider seam is needed. + +Eight new permanent actual Home/SQLite cases pass. The identical new suite on an +immutable archive of all 1,749 reviewed blobs yields **five precise assertion +failures / three positive controls**: checking/final publication and formatting +faults wrongly exit 0, while held/late-rejected report work retires too early. +All eight pass on the correction. A separate limited author-side reviewer runs +six fresh signal/fatal reentry, stale-report and common-notification controls, +all passing with an explicit zero-unhandled receipt and actual child join. That +check is not a complete cumulative round or security sitting. Renderer, probe, +held-report and fault ports remain synthetic around real Home/ownership/SQLite; +no live provider, operator TTY or packaged-platform execution is inferred. + +**Blocker `W3-8D-R15-TD-01` is corrected at `1ed15ab8`.** Two fixture-isolation +tests made the complete workspace workload 12, but its declaration still expected +10. Required CI correctly refused that drift. Only the exact declaration changes; +whole-file identity, named witnesses, error receipts, original bounds and byte-exact +restoration stay enforced. The applied-source selected control passes fixed 12/12, +breaks seven assertions on removal, and passes restored 12/12, with actual joined +children and zero runner-reported unhandled errors in all three phases. This +selected diagnostic run retains `complete: false`; it is not the entire family. +The immutable physical source manifest records precommit author bytes and is kept +separate from the reviewed predecessor's Git label. Independently collected 61 +files / 1,728 cases identify only this one mismatch across all 151 filesystem and +secrets declarations. An inherited secrets README count is also corrected from +24 to the existing 25 serializer cases; it predates the cumulative base and is not +a new R15 finding or runtime change. + +**Applied-source author gates pass.** `pnpm turbo run lint typecheck test` passes +23 tasks (20 cached): **353 CLI files / 5,598 passing tests / ten established skips**. +A separate post-build complete CLI run has the same counts, an explicit zero +unhandled-errors receipt and an actually joined launcher. All **549 acceptance-tool +tests** pass. Build passes six tasks (five cached); tools lint/typecheck, formatting, +fences, engine dependencies and 732-file isolation pass. Compiled closure stays +25 outputs / 410 inputs / 17 externals. Actual local Darwin / Node 22.23.1 compiled +bootstrap passes 177 controls; workflow/MCP/budget CLI smoke passes. No production +assertion or timeout is weakened. Initial isolated lint and reviewer-harness +mistakes retain failed evidence and are not counted as product failures or passes. + +At reviewed `eb5d61af`, CI `39015097996` completes failure: the filesystem job +rejects 12 versus 10, and the required job rejects that failed dependency before +its test phases. This is one defect with a downstream gate consequence, not a +second observed CLI-test failure. The other thirteen jobs succeed. Root and an +independent reviewer physically inspect five ZIPs, all 70 public members and four +receipts for one 446,375-byte tarball, SHA-256 +`54069bb0ddfe4dfe72bde0f711627cc915647fce970d748bb8b94c7f24d7ec8e`. +Current Linux/macOS/Windows and exact Node 22.0.0 consumers each record 177 bootstrap +controls and eight installed cases. These predecessor successes do not accept the +correction. The earlier `dcc23cfb` CI is cancelled by the superseding push; its +completed platform results retain that source, as do every earlier failed Windows +result and unknown initiating cause. + +R1–R15 remain NONCLEAN. Two NEW complete cumulative CLEAN rounds after the last +material fix, a NEW separate clean security sitting and fresh full CI plus all +four same-artifact consumers remain required. Steps 9–19 have not started. No +credential, provider capture, billing, production archive recipient or user +decision is pending; implementation continues automatically on `development`. From ea996d6c694fcf55ae87ae72b96d8aa0f1ee1e0e Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 12:06:38 +0300 Subject: [PATCH 142/222] fix(cli): retain cancelled compaction notification work Bind the complete disclosure callback to the original session owner before core cancellation can release its wait. Preserve late delivery failures and join publication before host and shared database retirement. Ten actual chat/SQLite regressions cover fresh and resumed sessions, held native acknowledgements, late failure and a new turn after compaction abort. The identical tests expose six ownership failures on the reviewed predecessor. Refs: ADR-0110, Wave 3 Step 8d, R16-C1 Co-Authored-By: Codex --- apps/cli/src/chat/session-host.ts | 14 +- .../chat-compaction-disclosure-owner.test.ts | 244 ++++++++++++++++++ docs/reference/cli/commands.md | 12 + 3 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 apps/cli/src/commands/chat-compaction-disclosure-owner.test.ts diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index 63726bb2..8797e395 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -402,6 +402,18 @@ function buildSessionRuntime( opts.onListenerError === undefined ? undefined : ownSessionNotification(opts.workOwner, opts.onListenerError); + // The core may stop waiting on disclosure when compaction is cancelled. The original host still + // owns the entire entered publication, including a late native delivery rejection, before retirement. + const compactionNotice = opts.onCompactionStart; + const workOwner = opts.workOwner; + const onCompactionStart = + compactionNotice === undefined || workOwner === undefined + ? compactionNotice + : (info: Parameters>[0]) => + workOwner.enter(() => { + const acknowledgement = compactionNotice(info); + return acknowledgement === undefined ? Promise.resolve() : acknowledgement; + }); // #228: a passive subscriber that throws — the chat persister failing a durable `history.db` write — is // isolated by `deliver()` either way, so successful surface reporting preserves the turn. What was missing is where the // failure GOES: with no sink the bus re-throws it out-of-band, and Node kills the process on the resulting @@ -718,7 +730,7 @@ function buildSessionRuntime( ...(opts.afterTurnCompaction === undefined ? {} : { afterTurnCompaction: opts.afterTurnCompaction }), - ...(opts.onCompactionStart === undefined ? {} : { onCompactionStart: opts.onCompactionStart }), + ...(onCompactionStart === undefined ? {} : { onCompactionStart }), ...(opts.chat.autoCompact === undefined ? {} : { autoCompact: opts.chat.autoCompact }), ...(opts.chat.compactThreshold === undefined ? {} diff --git a/apps/cli/src/commands/chat-compaction-disclosure-owner.test.ts b/apps/cli/src/commands/chat-compaction-disclosure-owner.test.ts new file mode 100644 index 00000000..8a934890 --- /dev/null +++ b/apps/cli/src/commands/chat-compaction-disclosure-owner.test.ts @@ -0,0 +1,244 @@ +import { mkdtempSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createClient, createSessionStore, runMigrations } from '@relavium/db'; +import { AgentSchema } from '@relavium/shared'; +import { expect, it, vi } from 'vitest'; +import { chatCommand, chatResumeCommand, type ChatCommandDeps } from './chat.js'; +import { buildChatSession, buildResumedChatSession } from '../chat/session-host.js'; +import type { SessionWorkOwner } from '../chat/session-work-owner.js'; +import { scriptedResolver, textTurn } from '../chat/test-support.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { acknowledgeWrite } from '../process/io.js'; +import { captureIo, OwnedTtyOutput } from '../test-support.js'; + +/** Actual Writable acknowledgement, with only its native callback schedule controlled. */ +class HeldOutput extends OwnedTtyOutput { + #complete: ((error?: Error) => void) | undefined; + #released = false; + + override _write( + _chunk: unknown, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + if (this.#released) { + callback(); + return; + } + this.#complete = (error) => { + this.#complete = undefined; + this.#released = true; + callback(error); + }; + } + + release(error?: Error): void { + this.#complete?.(error); + } +} + +const nativeTurn = () => new Promise((resolve) => setImmediate(resolve)); + +const outcomes = [ + { cancel: false, failedDelivery: false }, + { cancel: false, failedDelivery: true }, + { cancel: true, failedDelivery: false }, + { cancel: true, failedDelivery: true }, +] as const; +const cases = (['fresh', 'resume'] as const).flatMap((surface) => [ + ...outcomes.map((outcome) => ({ surface, ...outcome, continueAfterCancel: false })), + { surface, cancel: true, failedDelivery: false, continueAfterCancel: true }, +]); + +it.each(cases)( + '$surface chat retains compaction delivery: cancel=$cancel failedDelivery=$failedDelivery continue=$continueAfterCancel', + async ({ surface, cancel, failedDelivery, continueAfterCancel }) => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-compaction-owner-'))); + const client = createClient(':memory:'); + runMigrations(client.db); + const store = createSessionStore(client.db); + if (surface === 'resume') { + const stamp = '2026-10-17T00:00:00.000Z'; + const agent = AgentSchema.parse({ + id: 'compaction-owner', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + system_prompt: 'Offline acknowledgement fixture.', + }); + store.createSession({ + id: 'compaction-history', + agentSlug: agent.id, + agentSnapshot: agent, + context: { workingDir: dir, fsScopeTier: 'sandboxed' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: stamp, + updatedAt: stamp, + }); + for (let seq = 0; seq < 4; seq++) { + store.appendMessage({ + id: `history-${seq}`, + sessionId: 'compaction-history', + sequenceNumber: seq, + role: seq % 2 === 0 ? 'user' : 'assistant', + content: [{ type: 'text', text: `persisted message ${seq}` }], + timestamp: stamp, + }); + } + } + const output = new HeldOutput(); + const f = invocationFixture(); + const { io } = captureIo(); + const priorCalls = surface === 'fresh' ? 2 : 0; + const scripts = + surface === 'fresh' + ? [ + textTurn('first persisted answer'), + textTurn('second persisted answer'), + textTurn(continueAfterCancel ? 'third admitted answer' : 'summary'), + ] + : [textTurn(continueAfterCancel ? 'third admitted answer' : 'summary')]; + const providers = scriptedResolver(scripts); + const provider = providers.resolveProvider('anthropic'); + if (provider === undefined) throw new Error('The scripted provider must exist.'); + const stream = vi.spyOn(provider, 'stream'); + let enterDelivery = () => {}; + const deliveryEntered = new Promise((resolve) => { + enterDelivery = resolve; + }); + let leaveDriver = () => {}; + const driverLeft = new Promise((resolve) => { + leaveDriver = resolve; + }); + let owner: SessionWorkOwner | undefined; + let closes = 0; + let finished = false; + let escaped: unknown; + let id = 0; + const dependencies: ChatCommandDeps = { + invocation: f.invocation, + io: { + ...io, + writeErrAcknowledged: (text) => { + const acknowledgement = acknowledgeWrite(output, text); + enterDelivery(); + return acknowledgement; + }, + }, + global: { + cwd: dir, + configPath: join(dir, 'absent.toml'), + json: false, + color: false, + verbosity: 'normal', + }, + providers, + buildSession: (options) => { + owner = options.workOwner; + return buildChatSession(options); + }, + buildResumedSession: (options) => { + owner = options.workOwner; + return buildResumedChatSession(options); + }, + now: () => 0, + uuid: () => `compaction-owner-${++id}`, + openSessionStore: () => ({ + store, + db: client.db, + close: () => { + closes++; + client.sqlite.close(); + }, + }), + lifecycle: { + onProcessExit: () => () => {}, + onInterrupt: () => () => {}, + onTerminationSignal: () => () => {}, + setRawMode: () => {}, + exit: () => {}, + }, + drive: async (context) => { + context.startSession(); + await context.onActivated?.(() => !context.shouldStop()); + if (surface === 'fresh') { + await context.processLine('one'); + await context.processLine('two'); + } + const compaction = context.processLine('/compact'); + await deliveryEntered; + if (cancel) context.onAbort?.(); + await compaction; + if (continueAfterCancel) await context.processLine('three'); + if (!context.shouldStop()) await context.processLine('/exit'); + leaveDriver(); + return { kind: 'exit' }; + }, + }; + const run = + surface === 'fresh' + ? chatCommand({ agent: undefined }, dependencies) + : chatResumeCommand({ sessionId: 'compaction-history' }, dependencies); + const action = run.then( + (code) => { + finished = true; + return code; + }, + (cause: unknown) => { + escaped = cause; + finished = true; + return f.registry.observations.exitCode(); + }, + ); + try { + await deliveryEntered; + // Cancellation must release the interactive compaction wait even while delivery stays owed. + if (cancel) await driverLeft; + await nativeTurn(); + expect(finished, 'COMPACTION_NATIVE_DELIVERY_REMAINS_OWNED').toBe(false); + expect(closes).toBe(0); + expect(client.sqlite.open).toBe(true); + expect(f.registry.activeOwners).toBeGreaterThan(0); + expect(output.listenerCount('error')).toBe(1); + expect(output.listenerCount('close')).toBe(1); + expect(stream).toHaveBeenCalledTimes(priorCalls + (continueAfterCancel ? 1 : 0)); + if (continueAfterCancel) { + expect( + client.sqlite + .prepare( + 'SELECT count(*) AS count FROM session_messages WHERE role = ? AND content_parts LIKE ?', + ) + .get('assistant', '%third admitted answer%'), + ).toEqual({ count: 1 }); + } + + output.release(failedDelivery ? new Error('SYNTHETIC_PRIVATE_NATIVE_WRITE') : undefined); + expect(await action).toBe(failedDelivery ? 9 : 4); + await nativeTurn(); + expect(closes).toBe(1); + expect(client.sqlite.open).toBe(false); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.exitCode()).toBe(failedDelivery ? 9 : 4); + expect(owner?.firstFailure?.cause).toEqual( + failedDelivery ? expect.objectContaining({ code: 'output_unavailable' }) : undefined, + ); + if (escaped !== undefined) expect(owner?.isStoppedFailure(escaped)).toBe(true); + if (!failedDelivery) expect(escaped).toBeUndefined(); + expect(output.listenerCount('error')).toBe(0); + expect(output.listenerCount('close')).toBe(0); + expect(stream).toHaveBeenCalledTimes( + priorCalls + (continueAfterCancel || (!cancel && !failedDelivery) ? 1 : 0), + ); + } finally { + output.release(); + await action; + output.destroy(); + if (client.sqlite.open) client.sqlite.close(); + rmSync(dir, { recursive: true, force: true }); + } + }, +); diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 3a549d91..7e086372 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1275,3 +1275,15 @@ formatter, report or listener faults select infrastructure `9` with private orig causes, while known diagnostic-check refusals remain an inline report. An obsolete report still cannot overwrite a newer prompt or session. Ownerless controller embeddings retain their existing compatibility behaviour. + +Compaction disclosures bind their complete returned publication or render-flush +operation to the original host before core starts waiting for acknowledgement. +Cancelling compaction may release its interactive wait and unused admission; the +entered delivery still precedes host retirement and shared database closure. A +late publication or flush fault reaches that original owner even after the +compaction result is consumed. Unexpected faults select infrastructure `9`, and +native sink refusal retains `output_unavailable`. An operation +abort permits a subsequent turn but cannot restart the cancelled summary request. +Full owner stop refuses notices that have not entered. Fresh, resumed, cleared, +reseated and Home hosts share this boundary; absent callbacks and ownerless +embeddings retain their existing behaviour, and one-shot execution gains no notice. From 2891e8fdb37e69e9cdb70b2ae185b705e0615ce8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 12:09:19 +0300 Subject: [PATCH 143/222] docs(docs): record compaction correction and remaining review gates Record complete NONCLEAN R16 coverage, the independently reproduced delivery race, permanent causal regressions and actual source-specific author gates. Keep Step 8d and later steps open pending fresh independent acceptance. Refs: ADR-0110, Wave 3 Step 8d, R16-C1 Co-Authored-By: Codex --- AGENTS.md | 28 +++--- CLAUDE.md | 28 +++--- docs/roadmap/current.md | 10 +++ .../phase-2.5.5-hardening-and-remediation.md | 87 +++++++++++++++++++ 4 files changed, 123 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6de731cd..ebe94bda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,21 +107,19 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first fifteen complete cumulative rounds are -NONCLEAN. R15 independently verifies all 318 paths / 1,737 cumulative hunks at -`eb5d61af`. Its Home doctor publication High is corrected at `151abaf5`: original -notification faults are observed before advisory recovery and complete report work -remains owned through actual settlement. The required-CI workload blocker is -corrected at `1ed15ab8`, retaining the exact 12-case count and all causal assertions. -Author gates pass (353 CLI files / 5,598 passes / ten established skips and 549 -acceptance-tool tests), including a separately joined post-build CLI run with zero -unhandled errors. The affected control passes fixed/restored 12/12 and breaks seven -assertions on removal. All four same-artifact consumers and Windows advisory pass -at reviewed `eb5d61af`; its full CI fails the stale workload and downstream dependency -gate, so those results do not certify the correction. Earlier failed Windows runs -retain their outcomes and unknown initiating causes. Two NEW complete cumulative -clean rounds, a NEW separate clean security sitting and fresh full CI plus all four -same-artifact package consumers remain required. +but not independently accepted. Its first sixteen complete cumulative rounds are +NONCLEAN. R16 independently verifies all 319 paths / 1,744 cumulative hunks at +`71a91042` and confirms one High cancelled-compaction delivery gap. `ea996d6c` +binds the complete publication/flush callback to the original host before core's +cancellation race, preserving late failure and actual delivery before retirement. +Ten permanent fresh/resumed chat/SQLite cases pass, including a new turn after +operation abort; the identical suite exposes six precise predecessor failures +with four positive controls. Author workspace and separate joined post-build CLI +gates pass (354 files / 5,608 passes / ten established skips, zero unhandled errors). +Original-source package/CI results retain their own source and do not accept this +correction. Earlier failed Windows runs retain their outcomes and unknown initiating +causes. Two NEW complete cumulative clean rounds, a NEW separate clean security +sitting and fresh full CI plus all four same-artifact package consumers remain required. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index 91fa47eb..01a9670a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,21 +196,19 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first fifteen complete cumulative rounds are -NONCLEAN. R15 independently verifies all 318 paths / 1,737 cumulative hunks at -`eb5d61af`. Its Home doctor publication High is corrected at `151abaf5`: original -notification faults are observed before advisory recovery and complete report work -remains owned through actual settlement. The required-CI workload blocker is -corrected at `1ed15ab8`, retaining the exact 12-case count and all causal assertions. -Author gates pass (353 CLI files / 5,598 passes / ten established skips and 549 -acceptance-tool tests), including a separately joined post-build CLI run with zero -unhandled errors. The affected control passes fixed/restored 12/12 and breaks seven -assertions on removal. All four same-artifact consumers and Windows advisory pass -at reviewed `eb5d61af`; its full CI fails the stale workload and downstream dependency -gate, so those results do not certify the correction. Earlier failed Windows runs -retain their outcomes and unknown initiating causes. Two NEW complete cumulative -clean rounds, a NEW separate clean security sitting and fresh full CI plus all four -same-artifact package consumers remain required. +but not independently accepted. Its first sixteen complete cumulative rounds are +NONCLEAN. R16 independently verifies all 319 paths / 1,744 cumulative hunks at +`71a91042` and confirms one High cancelled-compaction delivery gap. `ea996d6c` +binds the complete publication/flush callback to the original host before core's +cancellation race, preserving late failure and actual delivery before retirement. +Ten permanent fresh/resumed chat/SQLite cases pass, including a new turn after +operation abort; the identical suite exposes six precise predecessor failures +with four positive controls. Author workspace and separate joined post-build CLI +gates pass (354 files / 5,608 passes / ten established skips, zero unhandled errors). +Original-source package/CI results retain their own source and do not accept this +correction. Earlier failed Windows runs retain their outcomes and unknown initiating +causes. Two NEW complete cumulative clean rounds, a NEW separate clean security +sitting and fresh full CI plus all four same-artifact package consumers remain required. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 376fe1c4..3de30f28 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1290,6 +1290,16 @@ but its full CI fails the stale workload and downstream dependency gate. Those results do not accept the correction. R1–R15 remain NONCLEAN; two NEW complete cumulative clean rounds, separate security and fresh final-source CI/platforms remain required. Steps 9–19 have not started. No user decision is pending. +The [sixteenth cumulative review and compaction-delivery correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-sixteenth-cumulative-review-and-compaction-delivery-correction--2026-10-17) +records complete NONCLEAN coverage of 319 paths / 1,744 rehashed hunks at `71a91042`. +`ea996d6c` retains the original compaction publication/flush callback through actual +settlement after core cancellation. Ten actual fresh/resumed chat/SQLite controls +pass, including a subsequent turn after abort; predecessor code breaks six precise +ownership assertions with four positive controls. Applied workspace and separately +joined post-build CLI checks pass (354 files / 5,608 passes / ten established skips, +zero unhandled errors). R1–R16 remain NONCLEAN. Two NEW complete cumulative clean +rounds, NEW separate security and fresh final-source CI/platforms remain required. +Steps 9–19 have not started; no user decision is pending. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 9619c689..02b5d6f4 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -7660,3 +7660,90 @@ material fix, a NEW separate clean security sitting and fresh full CI plus all four same-artifact consumers remain required. Steps 9–19 have not started. No credential, provider capture, billing, production archive recipient or user decision is pending; implementation continues automatically on `development`. + + +### Wave 3 Step 8d sixteenth cumulative review and compaction-delivery correction — 2026-10-17 + +**R16 is complete and NONCLEAN; Step 8d and whole Step 8 remain open.** Seven NEW +reviewers personally cover the complete cumulative `d9625ff0` → `71a91042` range: +**319 paths / 1,744 hunks** in seven disjoint groups. Root reads every original +report and independently verifies every physical body, complete U0 diff and hunk. +Large normative/register/generated files retain their explicit changed-section +and surrounding-context qualifications. Inventory SHA-256 is +`5551022806d84ab5d4e08909ccbb64fbaa27949a0ed24bf4a6f48e0c1416a9a9`; +aggregate SHA-256 is +`95fd0fdc8fa34e96ff12657967adccbea894448fd33304b14027a8a8715e31c0`. +Opening and closing freezes match across 1,750 tracked blobs, 21,165 physical +dependency files, 1,215 lexical symlinks and 466 existing dist files. All seven +original reports, identities, actual runner joins, failed/corrected harness +attempts and the root reading receipt remain local and Git-ignored. This complete +round supplies no clean-round, security or whole-step acceptance. + +**High `R16-C1` is corrected at `ea996d6c`.** Core intentionally races compaction's +interactive disclosure wait against operation cancellation. The shared CLI session +runtime previously forwarded the surface callback without retaining its original +publication/flush operation. Cancelling manual compaction could therefore return +exit 4, close the shared store and retire every owner while an acknowledged native +write remained pending; a later failed callback still left exit 4. Root independently +reproduces two precise cancellation failures and two positive controls through +actual chat, session construction, SQLite and production `acknowledgeWrite`, with +zero explicit runner-reported unhandled errors and an actual exit-1 launcher join. +The output callback schedule, provider, driver and lifecycle ports are controlled; +this is neither an operator-TTY nor naturally occurring OS-error measurement. + +The shared runtime now captures the callback and original host once, reserves its +root before callback entry, and retains the exact returned Promise, normalizing +only a void result. Core's prompt cancellation and no-post-cancel-summary admission +remain unchanged. The entered publication or visible-render flush stays owed until +actual settlement, and its late failure reaches the original owner before passive +recovery. A full host stop refuses notices that never entered; operation abort +still permits another turn. Fresh/resumed/clear/reseat and Home share this boundary. +Absent callbacks and ownerless embeddings retain compatibility; one-shot execution +gains no sink or enabled after-turn compaction. The +[canonical CLI contract](../../reference/cli/commands.md#cooperative-session-finalization-and-passive-notifications) +owns the behaviour. No new ADR, dependency, error code or provider seam is needed. + +Ten new permanent actual fresh/resumed chat cases cover cancellation × native +acknowledgement success/failure, plus a subsequent admitted and durably persisted +turn while the cancelled disclosure is held. They assert actual SQLite closure, +original owner retention, late output disposition and no resumed old-summary +request. All ten pass on the correction; the identical suite with only the shared +runtime restored to immutable reviewed bytes yields **six precise ownership +assertion failures / four positive controls**. Four complete focused files pass +**99 tests**, with explicit zero-unhandled receipts and actual joined launchers. +The archive contains all 1,750 independently Git-verified predecessor blobs; +separate plain source copies introduce no branch/worktree/index or shared-source +mutation. Initial author harness resolution, escaped-error expectation and fixture +SQL mistakes remain retained and are neither product findings nor passing evidence. + +**Applied author gates pass.** Workspace lint/typecheck/test passes 23 tasks +(20 cached), including **354 CLI files / 5,608 passing tests / ten established +skips**. Build passes six tasks (five cached). A separate post-build complete CLI +run has the same counts, an explicit zero-unhandled receipt and actual launcher +exit 0. Tools typecheck, formatting, fences, engine dependency and DB-sync checks +pass. All 549 acceptance-tool tests pass. Test isolation collects 733 files with +its existing ownership probes intact. +Compiled closure verifies 25 outputs / 410 inputs / 17 externals; the untouched +local Darwin / Node 22.23.1 bootstrap passes 177 controls and compiled workflow, +MCP signal-cleanup and budget CLI smoke passes. Original bounds and assertions +remain intact. Author checks do not replace independent review or current required +platform acceptance. + +At the original reviewed `71a91042`, current Linux/macOS/Windows and exact Node +22.0.0 consumers pass. Root and an independent reviewer separately inspect five +ZIPs and all 70 public members/receipts for the same 446,408-byte tarball, SHA-256 +`cf80bd3aa00b42996bde53b56d9a59aae515fec48116b573d3d2cbbd68b22740`. +Each consumer records 177 bootstrap controls and eight installed cases. The Node +22.13 workspace floor, Windows advisory, wire, usage, secrets and coverage jobs +also pass; when R16 closes, the filesystem causal family and final required job +have not completed. Root independently inspects the 41-control / 123-phase secrets +archive, its 745 files / 26,909,694 bytes, source/runtime/stdio/evaluation receipts +and explicit zero-error results. These predecessor passes do not accept the new +correction or imply an unobserved final CI result. Every failed, superseded or +cancelled run retains its own source and initiating-cause qualification. + +R1–R16 remain NONCLEAN. Two NEW complete cumulative CLEAN rounds after the last +material fix, a NEW separate clean security sitting and fresh full CI plus all four +same-artifact consumers remain required. Steps 9–19 have not started. No credential, +provider capture, billing, production archive recipient or user decision is pending; +implementation continues automatically on `development`. From 1dec7a8d6b2ea12fa9c6212133abe4d7ec056858 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 12:48:53 +0300 Subject: [PATCH 144/222] fix(cli): observe onboarding raw-mode failures Observe release and reclaim faults on the original Home invocation before reversible job-control recovery consumes them. Retain existing classified refusals, ownerless behaviour and actual prompt/terminal cleanup joins. Add nine actual Home/SQLite controls for healthy, unknown, held, classified and ownerless paths. Fixed/removal/restored runs pin original fault ownership. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- apps/cli/src/home/drive-home.tsx | 6 +- .../home/home-onboarding-raw-owner.test.ts | 226 ++++++++++++++++++ docs/reference/cli/commands.md | 9 + 3 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 apps/cli/src/home/home-onboarding-raw-owner.test.ts diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index bdbeac4d..94121fde 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -340,7 +340,8 @@ async function driveHomeBody(deps: HomeDeps, ownership?: SessionHomeLifetime): P try { onboardingTerminal.setRawMode(true); onboardingRawReleased = false; - } catch { + } catch (cause) { + ownership?.surface.work.failure(cause); // A future recovery opportunity gets another exact retry. } } @@ -377,7 +378,8 @@ async function driveHomeBody(deps: HomeDeps, ownership?: SessionHomeLifetime): P onboardingCursorReleased = true; void writeControl(SHOW_CURSOR); return true; - } catch { + } catch (cause) { + ownership?.surface.work.failure(cause); // We did not stop the process, so put any partial release back immediately rather than leaving Clack half-owned. reclaimOnboardingTerminal(); return false; diff --git a/apps/cli/src/home/home-onboarding-raw-owner.test.ts b/apps/cli/src/home/home-onboarding-raw-owner.test.ts new file mode 100644 index 00000000..86f921c5 --- /dev/null +++ b/apps/cli/src/home/home-onboarding-raw-owner.test.ts @@ -0,0 +1,226 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createClient, createSessionStore, runMigrations } from '@relavium/db'; +import { expect, it, vi } from 'vitest'; + +import { ConfigError } from '../config/errors.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import type { InvocationWorkOwner } from '../process/invocation-work-owner.js'; +import type { RootAppProps } from '../render/tui/home-app.js'; +import { captureIo, OwnedTtyInput, OwnedTtyOutput } from '../test-support.js'; +import { driveHome } from './drive-home.js'; +import { SessionHomeLifetime } from './session-home-lifetime.js'; + +vi.mock('../secrets/os-keychain.js', () => ({ + createOsKeychainStore: () => ({ get: () => null, set: () => undefined, delete: () => false }), +})); + +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const scenarios = [ + 'healthy', + 'owned-release', + 'owned-reclaim', + 'held-release', + 'held-reclaim', + 'known-release', + 'known-reclaim', + 'ownerless-release', + 'ownerless-reclaim', +] as const; + +it.each(scenarios)('Home retains the original onboarding raw-mode fault: %s', async (scenario) => { + const client = createClient(':memory:'); + runMigrations(client.db); + const f = invocationFixture(); + const cwd = mkdtempSync(join(tmpdir(), 'relavium-home-raw-owner-')); + const captured = captureIo(); + const input = new OwnedTtyInput(); + const output = new OwnedTtyOutput(); + const owned = !scenario.startsWith('ownerless-'); + const unexpected = scenario.startsWith('owned-') || scenario.startsWith('held-'); + const cause = scenario.startsWith('known-') + ? new ConfigError('synthetic.toml', 'invalid shape') + : new TypeError('PRIVATE_HOME_RAW_FAULT /private/terminal-detail'); + let originalOwner: InvocationWorkOwner | undefined; + // eslint-disable-next-line @typescript-eslint/unbound-method -- Invoke the captured method with its original instance below. + const execute = SessionHomeLifetime.prototype.execute; + vi.spyOn(SessionHomeLifetime.prototype, 'execute').mockImplementation(function ( + this: SessionHomeLifetime, + body, + ) { + originalOwner = this.surface.work; + return execute.call(this, body); + }); + let armed = false; + let hits = 0; + const rawMode = input.setRawMode.bind(input); + vi.spyOn(input, 'setRawMode').mockImplementation((enabled) => { + if ( + armed && + hits === 0 && + ((scenario.endsWith('-release') && !enabled) || (scenario.endsWith('-reclaim') && enabled)) + ) { + hits++; + throw cause; + } + return rawMode(enabled); + }); + let answer: (value: symbol) => void = () => {}; + const pendingPrompt = new Promise((resolve) => { + answer = resolve; + }); + let entered = false; + let suspend = () => {}; + let props: RootAppProps | undefined; + let unmount = () => {}; + const exited = new Promise((resolve) => { + unmount = resolve; + }); + const selfStop = vi.fn(); + let closes = 0; + let settled = false; + const running = driveHome({ + ...(owned ? { invocation: f.invocation } : {}), + io: { + ...captured.io, + stdoutIsTty: true, + stdinIsTty: true, + stdin: input, + nativeStdin: input, + nativeStdout: output, + nativeStderr: output, + }, + global: { + cwd, + configPath: join(cwd, 'absent.toml'), + json: false, + color: false, + verbosity: 'normal', + }, + providers: { + resolveProvider: () => undefined, + keyFor: () => { + throw new Error('No synthetic key.'); + }, + }, + openSessionStore: () => ({ + db: client.db, + store: createSessionStore(client.db), + close: () => { + closes++; + client.sqlite.close(); + }, + }), + onboardingPrompter: { + intro: () => {}, + outro: () => {}, + note: () => {}, + select: () => { + rawMode(true); + entered = true; + return pendingPrompt; + }, + password: () => Promise.resolve(Symbol('unused')), + isCancel: (value): value is symbol => typeof value === 'symbol', + }, + // Owned cases exercise Home's default captured native terminal adapter. The + // compatibility case binds an explicit port instead of touching the user's TTY. + ...(owned + ? {} + : { + onboardingTerminalLifecycle: { + onInput: () => () => {}, + isRawMode: () => input.isRaw, + setRawMode: (enabled: boolean) => { + input.setRawMode(enabled); + }, + }, + }), + jobControlLifecycle: { + supported: true, + onSuspend: (callback) => { + suspend = callback; + return () => {}; + }, + onContinue: () => () => {}, + suspendSelf: selfStop, + }, + subscribeSignals: () => () => {}, + subscribeProcessExit: () => () => {}, + getSize: () => ({ cols: 100, rows: 30 }), + subscribeResize: () => () => {}, + render: (value) => { + props = value; + return { + unmount: () => { + rawMode(false); // Model the renderer's actual final raw-input release. + unmount(); + }, + waitUntilExit: () => exited, + waitUntilRenderFlush: () => Promise.resolve(), + }; + }, + }).then((code) => { + settled = true; + return code; + }); + + try { + await vi.waitFor(() => expect(entered).toBe(true)); + armed = true; + suspend(); + await tick(); + await tick(); + expect(hits).toBe(scenario === 'healthy' ? 0 : 1); + expect( + f.registry.observations.hasInfrastructureFailure(), + 'HOME_UNKNOWN_NATIVE_RAW_FAILURE_REACHES_ORIGINAL_OWNER', + ).toBe(unexpected); + if (unexpected) { + expect(originalOwner?.firstFailure?.cause).toBe(cause); + expect(f.registry.termination).toBeDefined(); + expect(originalOwner?.signal.aborted).toBe(true); + expect(props).toBeUndefined(); + } else { + expect(originalOwner?.firstFailure).toBeUndefined(); + expect(f.registry.termination).toBeUndefined(); + } + if (scenario.startsWith('held-')) { + // A failed reversible operation is no prompt-release ACK. The original + // native promise must still settle before Home closes its actual SQLite handle. + expect(settled).toBe(false); + expect(closes).toBe(0); + expect(client.sqlite.open).toBe(true); + expect(f.registry.activeOwners).toBeGreaterThan(0); + } + expect(selfStop).toHaveBeenCalledTimes(scenario.endsWith('-release') ? 0 : 1); + + armed = false; + rawMode(false); // The scripted prompt explicitly completes its native release. + answer(Symbol('cancel')); + await tick(); + await tick(); + props?.controller.handleKey('c', { ctrl: true }); + expect(await running).toBe(unexpected ? 9 : 0); + expect(input.isRaw).toBe(false); + expect(closes).toBe(1); + expect(client.sqlite.open).toBe(false); + expect(f.registry.activeOwners).toBe(0); + expect(captured.out() + captured.err()).not.toContain('PRIVATE_HOME_RAW_FAULT'); + } finally { + armed = false; + rawMode(false); + answer(Symbol('cancel')); + await tick(); + props?.controller.handleKey('c', { ctrl: true }); + unmount(); + await running; + vi.restoreAllMocks(); + input.destroy(); + output.destroy(); + if (client.sqlite.open) client.sqlite.close(); + rmSync(cwd, { recursive: true, force: true }); + } +}); diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 7e086372..92ebd799 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1287,3 +1287,12 @@ abort permits a subsequent turn but cannot restart the cancelled summary request Full owner stop refuses notices that have not entered. Fresh, resumed, cleared, reseated and Home hosts share this boundary; absent callbacks and ownerless embeddings retain their existing behaviour, and one-shot execution gains no notice. + +Home onboarding observes an unexpected raw-input release or reclaim failure through +the original invocation before reversible job-control recovery consumes it. The +original private cause selects infrastructure `9` and stops further business entry; +a classified operational refusal retains its existing inline behaviour. Failed +reversible mode changes supply no final terminal-release acknowledgement. The +original prompt and full cursor delivery must still settle before terminal and +shared database release. Ownerless embeddings retain their existing best-effort +recovery, and the finite process-exit restoration net grants no cleanup ACK. From 1f3cce50421dd7aeacafccfb363c7605c1400669 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 12:50:44 +0300 Subject: [PATCH 145/222] docs(docs): record onboarding correction and open review gates Record complete NONCLEAN R17 coverage, the corrected original-owner fault boundary, causal controls and attributed author/remote validation limits. Keep Step 8d and later work explicitly open pending fresh acceptance gates. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- AGENTS.md | 26 ++++---- CLAUDE.md | 26 ++++---- docs/roadmap/current.md | 10 ++++ .../phase-2.5.5-hardening-and-remediation.md | 60 +++++++++++++++++++ 4 files changed, 96 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ebe94bda..688008ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,19 +107,19 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first sixteen complete cumulative rounds are -NONCLEAN. R16 independently verifies all 319 paths / 1,744 cumulative hunks at -`71a91042` and confirms one High cancelled-compaction delivery gap. `ea996d6c` -binds the complete publication/flush callback to the original host before core's -cancellation race, preserving late failure and actual delivery before retirement. -Ten permanent fresh/resumed chat/SQLite cases pass, including a new turn after -operation abort; the identical suite exposes six precise predecessor failures -with four positive controls. Author workspace and separate joined post-build CLI -gates pass (354 files / 5,608 passes / ten established skips, zero unhandled errors). -Original-source package/CI results retain their own source and do not accept this -correction. Earlier failed Windows runs retain their outcomes and unknown initiating -causes. Two NEW complete cumulative clean rounds, a NEW separate clean security -sitting and fresh full CI plus all four same-artifact package consumers remain required. +but not independently accepted. Its first seventeen complete cumulative rounds are +NONCLEAN. R17 independently verifies all 320 paths / 1,746 cumulative hunks at +`2891e8fd` and confirms one High Home onboarding raw-mode fault-observation gap. +`1dec7a8d` observes release/reclaim faults on the original invocation before +reversible recovery, retaining classified refusals and actual prompt cleanup. +Nine permanent actual Home/SQLite controls pass; removing the observations yields +four precise failures and five positive controls. Author workspace and separate +joined post-build CLI gates pass (355 files / 5,617 passes / ten established skips, +zero unhandled errors); all 549 acceptance-tool tests pass. Predecessor package/CI +results retain their own source and do not accept this correction. Earlier failed +Windows runs retain their outcomes and unknown initiating causes. Two NEW complete +cumulative clean rounds, a NEW separate clean security sitting and fresh full CI +plus all four same-artifact package consumers remain required. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index 01a9670a..9536e360 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,19 +196,19 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first sixteen complete cumulative rounds are -NONCLEAN. R16 independently verifies all 319 paths / 1,744 cumulative hunks at -`71a91042` and confirms one High cancelled-compaction delivery gap. `ea996d6c` -binds the complete publication/flush callback to the original host before core's -cancellation race, preserving late failure and actual delivery before retirement. -Ten permanent fresh/resumed chat/SQLite cases pass, including a new turn after -operation abort; the identical suite exposes six precise predecessor failures -with four positive controls. Author workspace and separate joined post-build CLI -gates pass (354 files / 5,608 passes / ten established skips, zero unhandled errors). -Original-source package/CI results retain their own source and do not accept this -correction. Earlier failed Windows runs retain their outcomes and unknown initiating -causes. Two NEW complete cumulative clean rounds, a NEW separate clean security -sitting and fresh full CI plus all four same-artifact package consumers remain required. +but not independently accepted. Its first seventeen complete cumulative rounds are +NONCLEAN. R17 independently verifies all 320 paths / 1,746 cumulative hunks at +`2891e8fd` and confirms one High Home onboarding raw-mode fault-observation gap. +`1dec7a8d` observes release/reclaim faults on the original invocation before +reversible recovery, retaining classified refusals and actual prompt cleanup. +Nine permanent actual Home/SQLite controls pass; removing the observations yields +four precise failures and five positive controls. Author workspace and separate +joined post-build CLI gates pass (355 files / 5,617 passes / ten established skips, +zero unhandled errors); all 549 acceptance-tool tests pass. Predecessor package/CI +results retain their own source and do not accept this correction. Earlier failed +Windows runs retain their outcomes and unknown initiating causes. Two NEW complete +cumulative clean rounds, a NEW separate clean security sitting and fresh full CI +plus all four same-artifact package consumers remain required. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 3de30f28..7b9499a7 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1300,6 +1300,16 @@ joined post-build CLI checks pass (354 files / 5,608 passes / ten established sk zero unhandled errors). R1–R16 remain NONCLEAN. Two NEW complete cumulative clean rounds, NEW separate security and fresh final-source CI/platforms remain required. Steps 9–19 have not started; no user decision is pending. +The [seventeenth cumulative review and onboarding correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-seventeenth-cumulative-review-and-onboarding-correction--2026-10-17) +records complete NONCLEAN coverage of 320 paths / 1,746 rehashed hunks at `2891e8fd`. +`1dec7a8d` observes original Home raw-mode release/reclaim faults before reversible +recovery consumes them. Nine actual Home/SQLite controls pass; removal produces +four precise failures with five positive controls. Applied workspace, build, +separate joined post-build CLI (355 files / 5,617 passes / ten established skips, +zero unhandled errors), 549 acceptance-tool tests and compiled smokes pass. +R1–R17 remain NONCLEAN. Two NEW complete cumulative clean rounds, NEW separate +security and fresh final-source CI/platforms remain required. Steps 9–19 have not +started; no user decision is pending. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 02b5d6f4..6d2d8b1c 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -7747,3 +7747,63 @@ material fix, a NEW separate clean security sitting and fresh full CI plus all f same-artifact consumers remain required. Steps 9–19 have not started. No credential, provider capture, billing, production archive recipient or user decision is pending; implementation continues automatically on `development`. + +### Wave 3 Step 8d seventeenth cumulative review and onboarding correction — 2026-10-17 + +**R17 is complete and NONCLEAN; Step 8d and whole Step 8 remain open.** Seven NEW +independent slices cover cumulative `d9625ff0` → `2891e8fd`: **320 paths / 1,746 +hunks**. Root reads all original reports and independently rechecks every physical +body, complete U0 diff and hunk identity. Large normative/generated files retain +their explicit current-context qualifications. Inventory SHA-256 is +`bfd6948823aa421950325313ef3a2107c96affb8346307abcca0ab15580c5ce2`; +aggregate SHA-256 is +`30d64beb43d63817da0c3771388e9ec2cfb41865b0de618aa17975447968b7ae`. +Opening and closing freezes match across 1,751 tracked files, 21,165 captured +physical dependency files, 1,215 lexical links and 466 existing dist files. +Detailed reports, runtime receipts, rejected synthetic suspicions and root's +original-report reading receipt remain local and Git-ignored. + +**High `R17-HOME-H1` is corrected at `1dec7a8d`.** Home onboarding could consume an +opaque native raw-mode release/reclaim error inside reversible recovery, then end +successfully without infrastructure observation. Root independently reproduces +both failures and a healthy control through actual Home, SQLite and the default +captured terminal adapter. The correction observes the original cause at the two +live recovery boundaries before preserving their existing retry/refusal behaviour. +Unexpected faults select existing infrastructure 9 and stop new business entry; +classified refusals and ownerless compatibility remain intact. Failed reversible +operations grant no cleanup ACK: the original prompt and full cursor delivery +still precede actual terminal/database retirement. The +[canonical CLI contract](../../reference/cli/commands.md#cooperative-session-finalization-and-passive-notifications) +owns this behaviour. No new ADR, dependency, error code or provider seam is needed. + +Nine permanent controls cover healthy, release/reclaim, held-prompt, classified +refusal and ownerless paths. The exact fixed suite passes; removing only the +observations causes **four precise original-owner assertion failures / five +positive controls**. Fixed and restored six-file runs each pass **147 tests**; +all three phases have explicit zero-unhandled receipts and actual launcher joins. +Native faults, prompt completion and renderer release use controlled ports; this +is not an operator-TTY or naturally occurring OS-error measurement. Initial author +harness API/renderer mistakes and an unused-observer candidate remain retained +without acceptance credit. Final causal receipt SHA-256 is +`c2740a48cea959cfb43db6c28368b496e49be6f9606887c04ab2577935c8aff0`. + +Applied author workspace lint/typecheck/test passes 23 tasks (20 cached), including +**355 CLI files / 5,617 passes / ten established skips**. Build passes six tasks +(five cached). A separate post-build full CLI run has the same counts, explicit +zero unhandled errors and an actual exit-0 launcher join. All **549 acceptance-tool +tests**, tools typecheck, formatting, fences, engine dependency and DB-sync checks +pass. Isolation collects 734 files with ownership probes intact. Compiled closure +verifies 25 outputs / 410 inputs / 17 externals; actual Darwin / Node 22.23.1 +bootstrap passes 177 controls, and workflow/MCP/budget CLI smoke passes. Markdown +remains governed by the documentation standard, outside Prettier's configured scope. + +At predecessor `2891e8fd`, all four same-tarball consumers and all causal families +pass in [CI run 39020071201](https://github.com/HodeTech/Relavium/actions/runs/39020071201); +its final required job is still running when recorded. Root and a NEW reviewer +separately inspect five physical ZIPs and the exact 446,459-byte tarball and its +receipts. These results do not execute the correction. Earlier cancelled/failed +runs keep their own source and initiating-cause qualifications. Two NEW complete +cumulative CLEAN rounds after the last material fix, a NEW separate clean security +sitting and fresh final-source full CI plus all four installed consumers remain +required. Steps 9–19 have not started; no credential, provider capture, billing, +production recipient or user decision is pending. From f23e0da770e156a481fb2fcc3b6fe376b45f3bea Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 13:27:10 +0300 Subject: [PATCH 146/222] fix(cli): observe workflow self-stop failures before recovery Forward caught native self-stop failures to the original workflow invocation before listener recovery. Keep classified refusals and ownerless recovery compatible, and retain actual terminal and primary cleanup acknowledgements. Add actual engine/driver healthy, classified and unexpected controls plus helper observation/reentry and ownerless recovery regressions. Both hook and driver-link removals break the original-owner test; restored suites pass. Refs: ADR-0110, Wave 3 Step 8d, R18-HOME-1 Co-Authored-By: Codex --- apps/cli/src/commands/drive.ts | 13 +- .../workflow-job-control-failure.test.ts | 115 ++++++++++++++++++ apps/cli/src/render/run-job-control.test.ts | 47 +++++++ apps/cli/src/render/run-job-control.ts | 5 +- docs/reference/cli/commands.md | 7 ++ 5 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 apps/cli/src/commands/workflow-job-control-failure.test.ts diff --git a/apps/cli/src/commands/drive.ts b/apps/cli/src/commands/drive.ts index 0d092514..ecf88d8a 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -19,7 +19,7 @@ import type { import type { GatePrompter } from '../gate/prompter.js'; import { budgetIdentifier, budgetPromptContext, type BudgetPromptContext } from '../gate/budget.js'; import { sanitizeUntrustedInline, stringifyJsonLine } from '../render/sanitize.js'; -import { CliError } from '../process/errors.js'; +import { CliError, toUserFacing } from '../process/errors.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import { detectOutputMode, isCiEnv } from '../process/output-mode.js'; import type { CliIo } from '../process/io.js'; @@ -330,7 +330,16 @@ export async function driveRun(deps: DriveRunDeps): Promise - wireRunJobControl({ write: (text) => io.writeOut(text), adopt }), + wireRunJobControl({ + write: (text) => io.writeOut(text), + adopt, + onFailure: (cause) => { + // A recoverable self-stop refusal is not the run's eventual outcome. + // Unexpected native faults must reach its original owner before reattachment. + if (toUserFacing(cause).exitCode === EXIT_CODES.infrastructureFailed) + lifetime.failure(cause); + }, + }), ); if (deps.makeGatePrompter !== undefined) gatePrompter = deps.makeGatePrompter(); renderer = terminal.createRenderer(makeRenderer); diff --git a/apps/cli/src/commands/workflow-job-control-failure.test.ts b/apps/cli/src/commands/workflow-job-control-failure.test.ts new file mode 100644 index 00000000..2a1ec8c0 --- /dev/null +++ b/apps/cli/src/commands/workflow-job-control-failure.test.ts @@ -0,0 +1,115 @@ +import { WorkflowEngine, createInMemoryHost, parseWorkflow } from '@relavium/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { CliError } from '../process/cli-error.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { defaultJobControlLifecycle } from '../render/suspend.js'; +import { captureIo } from '../test-support.js'; +import { driveRun, outcomeToExitCode } from './drive.js'; +import { WorkflowLifetime } from './workflow-lifetime.js'; + +describe('original workflow owner observes caught native self-stop faults', () => { + it.each(['healthy', 'known', 'unexpected'] as const)('%s self-stop', async (mode) => { + const f = invocationFixture(); + const captured = captureIo(); + const io = { ...captured.io, stdoutIsTty: true, stdinIsTty: true }; + const lifetime = new WorkflowLifetime(f.invocation, io, () => () => undefined); + const observed = vi.spyOn(lifetime, 'failure'); + const cause = + mode === 'known' + ? new CliError('invalid_invocation', '') + : new TypeError('PRIVATE_WORKFLOW_SELF_STOP_FAULT'); + const engine = new WorkflowEngine({ + host: createInMemoryHost(), + executor: { + execute: () => Promise.resolve({ kind: 'completed', output: 'answer' }), + }, + }); + const handle = engine.start({ + workflow: parseWorkflow( + JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'job-control-owner', + nodes: [{ id: 'work', type: 'input' }], + edges: [], + }, + }), + ), + }); + let suspend = (): void => { + throw new Error('The driver must register job control before constructing its renderer.'); + }; + let registrations = 0; + let suspendRemovals = 0; + let continueRemovals = 0; + let paints = 0; + const supported = Object.getOwnPropertyDescriptor(defaultJobControlLifecycle, 'supported'); + if (supported === undefined) throw new Error('Missing native lifecycle descriptor.'); + // Exercise the same POSIX lifecycle on every test platform without installing real signals. + Object.defineProperty(defaultJobControlLifecycle, 'supported', { ...supported, value: true }); + const onSuspend = vi + .spyOn(defaultJobControlLifecycle, 'onSuspend') + .mockImplementation((callback) => { + registrations += 1; + suspend = callback; + return () => { + suspendRemovals += 1; + }; + }); + const onContinue = vi + .spyOn(defaultJobControlLifecycle, 'onContinue') + .mockImplementation(() => () => { + continueRemovals += 1; + }); + const selfStop = vi.spyOn(defaultJobControlLifecycle, 'suspendSelf').mockImplementation(() => { + if (mode !== 'healthy') throw cause; + }); + try { + const result = await lifetime.execute(async () => + outcomeToExitCode( + await driveRun({ + engine, + handle, + lifetime, + io, + makeRenderer: () => { + suspend(); + return { + onEvent: () => { + paints += 1; + }, + releaseInput: () => Promise.resolve(), + }; + }, + }), + ), + ); + expect(result, 'WORKFLOW_CAUGHT_SELF_STOP_ORIGINAL_OWNER').toBe( + mode === 'unexpected' ? 9 : 0, + ); + expect(f.registry.observations.exitCode()).toBe(mode === 'unexpected' ? 9 : 0); + expect(f.registry.termination !== undefined).toBe(mode === 'unexpected'); + if (mode === 'unexpected') { + expect(observed).toHaveBeenCalledExactlyOnceWith(cause); + expect(paints, 'No business rendering after original-owner failure').toBe(0); + expect(registrations, 'Stopped recovery cannot reattach a new listener').toBe(1); + } else { + expect(observed).not.toHaveBeenCalled(); + expect(paints).toBeGreaterThan(0); + expect(registrations).toBe(mode === 'known' ? 2 : 1); + } + expect(selfStop).toHaveBeenCalledTimes(1); + expect(suspendRemovals).toBe(registrations); + expect(continueRemovals).toBe(1); + expect(f.registry.activeOwners, 'Only actual native and primary ACKs retire owners').toBe(0); + expect(captured.out() + captured.err()).not.toContain('PRIVATE_WORKFLOW_SELF_STOP_FAULT'); + } finally { + onSuspend.mockRestore(); + onContinue.mockRestore(); + selfStop.mockRestore(); + observed.mockRestore(); + Object.defineProperty(defaultJobControlLifecycle, 'supported', supported); + } + }); +}); diff --git a/apps/cli/src/render/run-job-control.test.ts b/apps/cli/src/render/run-job-control.test.ts index be7c9e9f..c9fc2d79 100644 --- a/apps/cli/src/render/run-job-control.test.ts +++ b/apps/cli/src/render/run-job-control.test.ts @@ -71,6 +71,53 @@ describe('wireRunJobControl (G0, the run/gate path)', () => { } }); + it('observes the original failed self-stop before recovery can reattach', () => { + const h = harness(); + const cause = new TypeError('private self-stop fault'); + h.suspendSelf.mockImplementation(() => { + throw cause; + }); + const observed: unknown[] = []; + const jc = wireRunJobControl({ + write: h.write, + lifecycle: h.lifecycle, + onFailure: (original) => { + observed.push(original); + expect(h.attaches()).toBe(1); + jc.dispose(); + }, + }); + h.fireSuspend(); + expect(observed).toEqual([cause]); + expect(h.attaches()).toBe(1); + h.fireSuspend(); + h.fireContinue(); + expect(h.suspendSelf).toHaveBeenCalledTimes(1); + expect(h.out()).toBe(SHOW_CURSOR); + expect(h.removeSuspend).toHaveBeenCalledTimes(1); + expect(h.removeContinue).toHaveBeenCalledTimes(1); + }); + + it('preserves ownerless reversible recovery after a self-stop refusal', () => { + const h = harness(); + h.suspendSelf.mockImplementationOnce(() => { + throw new Error('self-stop refused'); + }); + const jc = wireRunJobControl({ write: h.write, lifecycle: h.lifecycle }); + try { + h.fireSuspend(); + expect(h.attaches()).toBe(2); + h.fireSuspend(); + expect(h.suspendSelf).toHaveBeenCalledTimes(2); + h.fireContinue(); + expect(h.out()).toBe(SHOW_CURSOR + SHOW_CURSOR + HIDE_CURSOR); + } finally { + jc.dispose(); + } + expect(h.removeSuspend).toHaveBeenCalledTimes(3); + expect(h.removeContinue).toHaveBeenCalledTimes(1); + }); + it('DETACHES before re-raising, or the self-sent SIGTSTP re-enters this handler forever', () => { // `suspendSelf` sends SIGTSTP to our own pid, and a signal with a handler installed runs the handler // instead of taking the default stop action. Raising while still attached therefore re-enters this diff --git a/apps/cli/src/render/run-job-control.ts b/apps/cli/src/render/run-job-control.ts index ec189c4a..5270bf95 100644 --- a/apps/cli/src/render/run-job-control.ts +++ b/apps/cli/src/render/run-job-control.ts @@ -28,6 +28,8 @@ export interface RunJobControl { export interface WireRunJobControlOptions { readonly adopt?: (control: RunJobControl) => void; + /** Observe the original self-stop failure before reversible listener recovery consumes it. */ + readonly onFailure?: (cause: unknown) => void; /** Where the cursor codes go — the same seam the renderers write through, so tests capture them. */ readonly write: (text: string) => void; /** Injectable signal registration (`defaultJobControlLifecycle` in production; a fake in tests). */ @@ -108,7 +110,8 @@ export function wireRunJobControl(opts: WireRunJobControlOptions): RunJobControl if (disposed) return; try { lifecycle.suspendSelf(); - } catch { + } catch (cause) { + opts.onFailure?.(cause); attach(); } }; diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 92ebd799..a9d0ded2 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1054,6 +1054,13 @@ after a failed mount is not a release ACK. A remount already entered before stop must settle and release its final native state. JSON/CI/plain output acquires no TTY job-control or cursor custody merely because stdout has a TTY flag. +Run/gate self-stop failures reach the original workflow owner before reversible +listener recovery consumes them. An unexpected fault selects infrastructure 9 +and seals business rendering and reattachment; an already classified self-stop +refusal retains its recoverable outcome. Ownerless bindings keep their existing +recovery behaviour. A failed self-signal supplies no native-release or cursor +delivery acknowledgement. + MCP manager close failures can arrive through its callback even when `close()` fulfills. They retain the MCP/workflow obligation and keep DB close behind actual cleanup. An opaque database-close throw also supplies no release ACK; already From df6e663145835362e0fcfed9ebeda72a04c9efad Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 13:27:16 +0300 Subject: [PATCH 147/222] fix(repo): budget the complete required CI workload Give the main required job a finite 25-minute outer watchdog after an actual 15-minute cancellation interrupted budget replay behind a successful cold serial workspace prefix. Pin the new main budget in the existing regression. Preserve the separate floor watchdog, serialization, native execution evidence, every test/readiness/replay bound and all required success-only dependencies. Refs: ADR-0110, Wave 3 Step 8d, TD18-CI1 Co-Authored-By: Codex --- .github/workflows/ci.yml | 5 ++++- tools/ci/workspace-scheduling.test.mjs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a12c6b0b..a48c85a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,10 @@ jobs: if: ${{ always() }} name: lint · typecheck · test runs-on: ubuntu-latest - timeout-minutes: 15 # a hung step fails fast instead of inheriting the 360-min default + # A cold lint/typecheck plus serialized workspace run takes about 12.5 minutes, before + # build, tool checks and the replay smoke's six sequential, individually bounded 45s workers. + # Keep a finite outer watchdog without truncating those unchanged native/test bounds. + timeout-minutes: 25 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: diff --git a/tools/ci/workspace-scheduling.test.mjs b/tools/ci/workspace-scheduling.test.mjs index 737b5990..ef0518fc 100644 --- a/tools/ci/workspace-scheduling.test.mjs +++ b/tools/ci/workspace-scheduling.test.mjs @@ -77,7 +77,7 @@ test('required and local CI share all workspace tasks and uncached per-invocatio assert.equal(workspace.run, 'pnpm ci:workspaces'); assert.equal(workspace.if, undefined); assert.equal(workspace['continue-on-error'], undefined); - assert.equal(ci['timeout-minutes'], 15); + assert.equal(ci['timeout-minutes'], 25); const variable = 'RELAVIUM_DB_NATIVE_DIAGNOSTICS'; assert.deepEqual(turbo.config.tasks['@relavium/db#test'].env, [variable]); assert.deepEqual(turbo.config.tasks['@relavium/db#test'].dependsOn, [ From 261bb82d8e8c056c9267c0177e8f70380da1b583 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 13:29:27 +0300 Subject: [PATCH 148/222] docs(docs): record self-stop and CI corrections with open gates Record complete NONCLEAN R18 coverage, both independently confirmed defects, corrected original-owner handling and measured outer CI budget. Keep causal, author, reviewed-source platform and historical failure evidence attributed. Leave Step 8d and Steps 9-19 open pending new clean cumulative rounds, separate security and corrected-source full CI plus all same-artifact consumers. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- AGENTS.md | 25 +++++---- CLAUDE.md | 25 +++++---- docs/roadmap/current.md | 11 ++++ .../phase-2.5.5-hardening-and-remediation.md | 52 +++++++++++++++++++ 4 files changed, 87 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 688008ca..4cae1259 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,19 +107,18 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first seventeen complete cumulative rounds are -NONCLEAN. R17 independently verifies all 320 paths / 1,746 cumulative hunks at -`2891e8fd` and confirms one High Home onboarding raw-mode fault-observation gap. -`1dec7a8d` observes release/reclaim faults on the original invocation before -reversible recovery, retaining classified refusals and actual prompt cleanup. -Nine permanent actual Home/SQLite controls pass; removing the observations yields -four precise failures and five positive controls. Author workspace and separate -joined post-build CLI gates pass (355 files / 5,617 passes / ten established skips, -zero unhandled errors); all 549 acceptance-tool tests pass. Predecessor package/CI -results retain their own source and do not accept this correction. Earlier failed -Windows runs retain their outcomes and unknown initiating causes. Two NEW complete -cumulative clean rounds, a NEW separate clean security sitting and fresh full CI -plus all four same-artifact package consumers remain required. +but not independently accepted. Its first eighteen complete cumulative rounds are +NONCLEAN. R18 verifies all 321 paths / 1,749 cumulative hunks at `1f3cce50` +and confirms two High gaps: caught workflow self-stop faults and the required +CI job's outer time budget. `f23e0da7` observes the original self-stop failure +before reversible recovery; `df6e6631` sizes only the main CI watchdog at 25 minutes. +Classified refusals, ownerless recovery, actual cleanup ACKs and all inner test, +native and replay bounds remain intact. Author workspace/build and separately +joined post-build CLI gates pass (356 files / 5,622 passes / ten established skips, +zero unhandled errors), along with all 549 acceptance-tool tests and compiled smokes. +Predecessor platform/CI results retain their source and do not accept these fixes. +Two NEW complete cumulative clean rounds, a NEW separate clean security sitting +and fresh full CI plus all four same-artifact consumers remain required. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index 9536e360..7bacc95b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,19 +196,18 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first seventeen complete cumulative rounds are -NONCLEAN. R17 independently verifies all 320 paths / 1,746 cumulative hunks at -`2891e8fd` and confirms one High Home onboarding raw-mode fault-observation gap. -`1dec7a8d` observes release/reclaim faults on the original invocation before -reversible recovery, retaining classified refusals and actual prompt cleanup. -Nine permanent actual Home/SQLite controls pass; removing the observations yields -four precise failures and five positive controls. Author workspace and separate -joined post-build CLI gates pass (355 files / 5,617 passes / ten established skips, -zero unhandled errors); all 549 acceptance-tool tests pass. Predecessor package/CI -results retain their own source and do not accept this correction. Earlier failed -Windows runs retain their outcomes and unknown initiating causes. Two NEW complete -cumulative clean rounds, a NEW separate clean security sitting and fresh full CI -plus all four same-artifact package consumers remain required. +but not independently accepted. Its first eighteen complete cumulative rounds are +NONCLEAN. R18 verifies all 321 paths / 1,749 cumulative hunks at `1f3cce50` +and confirms two High gaps: caught workflow self-stop faults and the required +CI job's outer time budget. `f23e0da7` observes the original self-stop failure +before reversible recovery; `df6e6631` sizes only the main CI watchdog at 25 minutes. +Classified refusals, ownerless recovery, actual cleanup ACKs and all inner test, +native and replay bounds remain intact. Author workspace/build and separately +joined post-build CLI gates pass (356 files / 5,622 passes / ten established skips, +zero unhandled errors), along with all 549 acceptance-tool tests and compiled smokes. +Predecessor platform/CI results retain their source and do not accept these fixes. +Two NEW complete cumulative clean rounds, a NEW separate clean security sitting +and fresh full CI plus all four same-artifact consumers remain required. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 7b9499a7..f8b801e2 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1310,6 +1310,17 @@ zero unhandled errors), 549 acceptance-tool tests and compiled smokes pass. R1–R17 remain NONCLEAN. Two NEW complete cumulative clean rounds, NEW separate security and fresh final-source CI/platforms remain required. Steps 9–19 have not started; no user decision is pending. +The [eighteenth cumulative review and self-stop/CI corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-eighteenth-cumulative-review-and-self-stopci-corrections--2026-10-17) +records complete NONCLEAN coverage of 321 paths / 1,749 rehashed hunks at `1f3cce50`. +`f23e0da7` observes caught workflow self-stop faults on the original invocation; +`df6e6631` fixes the positively confirmed main-CI watchdog expiry while retaining +all inner bounds and required gates. Two independent mechanism removals break +the new ownership regressions; fixed/restored runs pass all 75 cases. Applied +workspace/build and joined post-build CLI checks pass (356 files / 5,622 passes / +ten established skips, zero unhandled errors), together with 549 acceptance-tool +tests and compiled smokes. R1–R18 remain NONCLEAN. Two NEW complete cumulative +clean rounds, NEW separate security and fresh corrected-source CI/platforms remain +required. Steps 9–19 have not started; no user decision is pending. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 6d2d8b1c..194003f2 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -7807,3 +7807,55 @@ cumulative CLEAN rounds after the last material fix, a NEW separate clean securi sitting and fresh final-source full CI plus all four installed consumers remain required. Steps 9–19 have not started; no credential, provider capture, billing, production recipient or user decision is pending. + +### Wave 3 Step 8d eighteenth cumulative review and self-stop/CI corrections — 2026-10-17 + +**R18 is complete and NONCLEAN; Step 8d and whole Step 8 remain open.** Seven NEW +independent slices cover cumulative `d9625ff0` → `1f3cce50`: **321 paths / 1,749 +hunks**. Root reads every original report and substantive file assessment, then +independently rechecks physical bodies, complete diffs and ordered hunk identities. +Opening/closing freezes match all 1,752 tracked files, 21,165 physical dependency +files, 1,215 lexical links and 466 dist files. Aggregate SHA-256 is +`3f839514f8a8690a1cc91ad06731d3f9be7c1adf9d61d024f6e624424ab3c3eb`. +Original detailed reports, qualified normative-context reads, a corrected historical +test attribution and root validation notes remain local and Git-ignored. + +**High `R18-HOME-1` is corrected at `f23e0da7`.** The run/gate self-stop catch +could consume an unexpected native fault, reattach and return success. Actual +engine/driver/original-lifetime controls independently reproduce that gap. The +production caller now forwards unexpected failure to its original owner before +recovery; classified refusals and ownerless behaviour stay recoverable. Actual +native/primary/cursor ACKs still govern retirement. The +[canonical workflow/terminal contract](../../reference/cli/commands.md#workflow-and-terminal-ownership) +owns the behaviour. Three actual driver controls and two helper controls are +permanent. Seven-file fixed/restored runs pass 75 cases; removing the observation +breaks two precise assertions and removing only the driver link breaks one, with +all remaining controls passing. Every phase has explicit zero-unhandled and actual +OS-join receipts. These controlled native faults are not operator-TTY measurements. + +**High `TD18-CI1` is corrected at `df6e6631`.** GitHub positively annotates the +predecessor [required job](https://github.com/HodeTech/Relavium/actions/runs/39020071201/job/117365513223) +as exceeding its 15-minute limit: its successful serial prefix leaves only 24s +when replay begins, despite six sequential workers retaining individual 45s bounds. +The main outer watchdog is now 25 minutes, pinned by the existing scheduling +regression. The separate floor watchdog, serialization, native execution evidence, +all inner test/readiness/replay bounds and success-only dependencies are unchanged. +This later evidence resolves that job's initiating cause; its earlier still-running +checkpoint remains historical. No failed test or complete CI success is inferred. + +Applied author workspace lint/typecheck/test and build pass. The CLI records +**356 files / 5,622 passes / ten established skips**; a separate post-build full +CLI run has the same counts, explicit zero unhandled errors and actual Node exit 0. +All **549 acceptance-tool tests**, tools/static/format/DB-sync/fence checks pass; +isolation collects 735 files. Compiled closure verifies 25 outputs / 410 inputs / +17 externals, Darwin/Node 22.23.1 bootstrap passes 177 controls and CLI smoke passes. +Cached tasks retain their qualification; Markdown remains outside Prettier's scope. + +At reviewed `1f3cce50`, all four same-artifact consumers, coverage and Windows +advisory pass in [CI run 39022803277](https://github.com/HodeTech/Relavium/actions/runs/39022803277); +full CI remains in progress when recorded. Root independently inspects five ZIPs, +70 public files and the 446,477-byte tarball. Those results do not execute or accept +the corrections. Two NEW complete cumulative CLEAN rounds after the last material +fix, NEW separate clean security and fresh corrected-source full CI plus all four +installed consumers remain required. Steps 9–19 have not started. No credential, +provider capture, billing, production recipient or user decision is pending. From 6cc8bc5386b5c64380af547cc9ddb7bda67a9d8b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 14:16:47 +0300 Subject: [PATCH 149/222] fix(cli): seal delayed chat publication after session stop Recheck the original input seal before delayed shell refusal notices and mention listings can publish during native terminal restoration. Preserve live inline warnings, local busy cleanup and the actual native close join. Four installed-Ink controls cover live/stopped shell and listing results. Independent guard removals each break their precise rendered-output test; fixed/restored five-file runs pass all 27 cases. Workspace lint, typecheck, tests, build and a joined post-build CLI suite pass. Independent cumulative reviews, separate security and corrected-source CI remain open. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- .../render/tui/chat-completion-stop.test.ts | 208 ++++++++++++++++++ apps/cli/src/render/tui/chat-ink.tsx | 12 +- docs/reference/cli/commands.md | 6 +- 3 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 apps/cli/src/render/tui/chat-completion-stop.test.ts diff --git a/apps/cli/src/render/tui/chat-completion-stop.test.ts b/apps/cli/src/render/tui/chat-completion-stop.test.ts new file mode 100644 index 00000000..e0aed1a0 --- /dev/null +++ b/apps/cli/src/render/tui/chat-completion-stop.test.ts @@ -0,0 +1,208 @@ +import { createSessionHandle, RunEventBus } from '@relavium/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { SessionWorkOwner } from '../../chat/session-work-owner.js'; +import { CliError } from '../../process/cli-error.js'; +import { invocationFixture } from '../../process/invocation.test-support.js'; +import { OwnedTtyInput, OwnedTtyOutput } from '../../test-support.js'; +import { createSuspendPort } from '../suspend.js'; +import { driveInk } from './chat-ink.js'; +import { createChatStore } from './chat-store.js'; +import type { MentionCandidate } from './mention.js'; +import { INLINE_TRANSCRIPT_BOUND } from './session-view-model.js'; + +describe('installed chat asynchronous publication during native suspension', () => { + it.each([ + { kind: 'shell', stopped: false }, + { kind: 'shell', stopped: true }, + { kind: 'mention', stopped: false }, + { kind: 'mention', stopped: true }, + ])('$kind preserves live reporting and respects stop=$stopped', async ({ kind, stopped }) => { + const f = invocationFixture(); + const owner = new SessionWorkOwner(f.invocation, 'chat', { + cancel: () => undefined, + cleanup: () => Promise.resolve(), + }); + const input = new OwnedTtyInput(); + const output = new OwnedTtyOutput(); + const stderr = new OwnedTtyOutput(); + const store = createChatStore(false, undefined, INLINE_TRANSCRIPT_BOUND); + const port = createSuspendPort(); + let release = (): void => undefined; + const body = new Promise((resolve) => { + release = resolve; + }); + let refuse: (cause: unknown) => void = () => undefined; + const raw = new Promise((_resolve, reject) => { + refuse = reject; + }); + void raw.catch(() => undefined); // Cleanup may refuse a fixture before its shell entry is reached. + const runner = vi.fn(() => owner.enter(() => raw)); + let finishList: (candidates: readonly MentionCandidate[]) => void = () => undefined; + const listing = new Promise((resolve) => { + finishList = resolve; + }); + const list = vi.fn(() => listing); + const read = vi.fn(() => Promise.resolve({ content: 'Unused fixture', sizeBytes: 14 })); + const candidate: MentionCandidate = { + name: 'arriving-candidate.ts', + path: 'arriving-candidate.ts', + type: 'file', + }; + const bus = new RunEventBus({ now: () => new Date(0).toISOString() }); + const handle = createSessionHandle( + bus, + 'owned-completion-publication', + () => undefined, + 16, + 'subscribe-only', + ); + const primary = new Error('PRIVATE_ORIGINAL_COMPLETION_STOP'); + const refusal = new CliError('invalid_invocation', 'PRIVATE_CLASSIFIED_SHELL_REFUSAL'); + const warning = '! shell command failed unexpectedly'; + const marker = kind === 'shell' ? warning : candidate.name; + let activated = false; + let requestedExit = false; + const driving = driveInk({ + workOwner: owner, + handle, + store, + suspendPort: port, + subscribeInterrupt: () => () => undefined, + startSession: () => undefined, + onActivated: () => { + activated = true; + }, + processLine: () => { + requestedExit = true; + return Promise.resolve(); + }, + shouldStop: () => requestedExit, + stopReason: () => 'exit', + runShellCommand: runner, + mentionReader: { list, read }, + io: { + env: {}, + stdoutIsTty: true, + stdinIsTty: true, + stdin: input, + nativeStdin: input, + nativeStdout: output, + nativeStderr: stderr, + writeOut: (text) => { + output.write(text); + }, + writeErr: (text) => { + stderr.write(text); + }, + writeOutAcknowledged: (text) => + new Promise((resolve, reject) => { + output.write(text, (cause) => + cause === null || cause === undefined ? resolve() : reject(cause), + ); + }), + writeErrAcknowledged: (text) => + new Promise((resolve, reject) => { + stderr.write(text, (cause) => + cause === null || cause === undefined ? resolve() : reject(cause), + ); + }), + }, + global: { + configPath: undefined, + cwd: process.cwd(), + json: false, + color: false, + verbosity: 'normal', + noAltScreen: true, + }, + }); + // Retain both outcomes immediately; cleanup must join the actual driver even if an assertion fails. + const settled = driving.then( + () => undefined, + () => undefined, + ); + let suspended: Promise | undefined; + try { + await vi.waitFor(() => expect(activated).toBe(true)); + if (kind === 'shell') { + input.push('!synthetic'); + await new Promise((resolve) => setImmediate(resolve)); + input.push('\r'); + await vi.waitFor(() => expect(runner).toHaveBeenCalledOnce()); + expect(list).not.toHaveBeenCalled(); + } else { + input.push('@'); + await vi.waitFor(() => expect(list).toHaveBeenCalledExactlyOnceWith('')); + expect(runner).not.toHaveBeenCalled(); + } + const suspend = port.current(); + if (suspend === undefined) throw new Error('Installed chat suspension was not attached.'); + suspended = suspend(() => body); + await vi.waitFor(() => expect(input.isRaw).toBe(false)); + if (stopped) { + owner.failure(primary); + expect(f.registry.observations.exitCode()).toBe(9); + expect(owner.firstFailure?.cause).toBe(primary); + expect(port.current()).toBeUndefined(); + } + const frameIndex = output.frames.length; + if (kind === 'shell') { + refuse(refusal); + await raw.catch(() => undefined); + } else { + finishList([candidate]); + await listing; + } + await new Promise((resolve) => setImmediate(resolve)); + expect(f.registry.activeOwners).toBeGreaterThan(0); + expect(output.frames.slice(frameIndex).join('')).not.toContain(marker); + release(); + await suspended; // Actual installed Ink endSuspend redraws before the final native unmount. + if (stopped) { + await driving; + expect( + output.frames.slice(frameIndex).join(''), + kind === 'shell' ? 'SESSION_STOPPED_SHELL_RENDER' : 'SESSION_STOPPED_MENTION_RENDER', + ).not.toContain(marker); + if (kind === 'shell') + expect(store.getSnapshot().state.warnings, 'SESSION_STOPPED_SHELL_NOTICE').not.toContain( + warning, + ); + expect(f.registry.observations.exitCode()).toBe(9); + expect(owner.firstFailure?.cause).toBe(primary); + } else { + await vi.waitFor(() => expect(output.frames.slice(frameIndex).join('')).toContain(marker)); + if (kind === 'shell') expect(store.getSnapshot().state.warnings).toContain(warning); + expect(f.registry.observations.exitCode()).toBe(0); + expect(owner.firstFailure).toBeUndefined(); + if (kind === 'shell') { + input.push('/exit'); + await new Promise((resolve) => setImmediate(resolve)); + input.push('\r'); + } else { + f.invocation.finiteExit.cooperativeCancel(4); + } + await driving; + } + expect(input.isRaw).toBe(false); + expect(input.listenerCount('readable')).toBe(0); + expect(port.current()).toBeUndefined(); + expect(read).not.toHaveBeenCalled(); + expect(output.frames.join('') + stderr.frames.join('')).not.toContain('PRIVATE_'); + } finally { + refuse(refusal); + finishList([]); + release(); + await suspended; + f.invocation.finiteExit.cooperativeCancel(4); + await settled; + await owner.close(); + input.destroy(); + output.destroy(); + stderr.destroy(); + } + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/cli/src/render/tui/chat-ink.tsx b/apps/cli/src/render/tui/chat-ink.tsx index 7b227581..14095585 100644 --- a/apps/cli/src/render/tui/chat-ink.tsx +++ b/apps/cli/src/render/tui/chat-ink.tsx @@ -781,11 +781,13 @@ export function ChatApp(props: Readonly): ReactElement { if (reader === undefined) return; void reader.list(dir).then( (candidates) => { + if (!acceptsInput()) return; const open = mentionRef.current; if (open !== undefined && open.dir === dir) applyMention({ ...open, candidates, loading: false }); }, () => { + if (!acceptsInput()) return; const open = mentionRef.current; if (open !== undefined && open.dir === dir) applyMention({ ...open, candidates: [], loading: false }); @@ -873,7 +875,8 @@ export function ChatApp(props: Readonly): ReactElement { }, () => { applyShellBusy(false); - if (submitGenRef.current === gen) props.store.note('! shell command failed unexpectedly'); + if (acceptsInput() && submitGenRef.current === gen) + props.store.note('! shell command failed unexpectedly'); }, ); }; @@ -1698,7 +1701,8 @@ export function finalizeInkExit( * back to the conversation it had just ended (whole-phase Opus review). The MCP-skipped diagnostic already took * this route (chat.ts); the intro did not. * - * `driveInk` mounts real ink and cannot be unit-tested, so the decision lives here, where it can be. + * The placement decision is directly testable here; driver integration tests also mount installed Ink + * over captured native streams. */ export function emitIntro( intro: string | undefined, @@ -1918,8 +1922,8 @@ export function driveInk(ctx: ChatDriveContext): Promise { onSubmit: ctx.processLine, shouldStop: ctx.shouldStop, onExit: () => resolveExit(), - // An unexpected turn-core throw rejects `exited` → the finally tears down + the rejection propagates out - // of the command (mapped to exit 1), matching the plain driver where the throw escapes the for-await loop. + // An unexpected turn-core throw reaches the original owner before native teardown, matching + // the plain driver's escaping cause. Classified outcomes retain their existing surface policy. onError: (err) => rejectExit(err), // ADR-0057 mode/abort wiring — the REPL loop always supplies these. onModeChange defaults to a no-op so a // driver wired without it degrades to a fixed mode; onAbort is passed through AS-IS (optional) so the diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index a9d0ded2..cd7036b4 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -958,7 +958,11 @@ These are **Step 8d implementation claims, not acceptance**. Complete cumulative reviews, security and current required platform acceptance remain open. Home post-chat snapshot failures reach the actual lifetime; ordinary slash notices are suppressed after owned stop, including delayed doctor/compaction results and -export fault catches. The +export fault catches. Standalone shell rejection notices and directory-completion +results also recheck the live input seal before publication; an already-entered +native suspension still drains completely before unmount. Live classified refusals +retain their inline warning, and clearing local busy state does not authorize a +new notice. The [session substrate checkpoint](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-session-substrate-checkpoint--2026-10-16) and [chat/Home checkpoint](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-chat-and-home-integration-checkpoint--2026-10-17) record their exact scopes. Whole Step 8 remains open. From 4eee7c68aba06728f7769964a18363a26a59be07 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 14:17:41 +0300 Subject: [PATCH 150/222] docs(docs): record late-publication correction and open review gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record complete R19 coverage, both confirmed publication defects and the applied native-driver regressions without inferring independent acceptance. Keep predecessor CI/platform evidence tied to its original source. Two new complete clean reviews, separate security and corrected-source CI remain required; Steps 9–19 have not started. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- AGENTS.md | 27 +++++---- CLAUDE.md | 27 +++++---- docs/roadmap/current.md | 12 ++++ .../phase-2.5.5-hardening-and-remediation.md | 60 +++++++++++++++++++ 4 files changed, 102 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4cae1259..095b538a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,18 +107,21 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first eighteen complete cumulative rounds are -NONCLEAN. R18 verifies all 321 paths / 1,749 cumulative hunks at `1f3cce50` -and confirms two High gaps: caught workflow self-stop faults and the required -CI job's outer time budget. `f23e0da7` observes the original self-stop failure -before reversible recovery; `df6e6631` sizes only the main CI watchdog at 25 minutes. -Classified refusals, ownerless recovery, actual cleanup ACKs and all inner test, -native and replay bounds remain intact. Author workspace/build and separately -joined post-build CLI gates pass (356 files / 5,622 passes / ten established skips, -zero unhandled errors), along with all 549 acceptance-tool tests and compiled smokes. -Predecessor platform/CI results retain their source and do not accept these fixes. -Two NEW complete cumulative clean rounds, a NEW separate clean security sitting -and fresh full CI plus all four same-artifact consumers remain required. +but not independently accepted. Its first nineteen complete cumulative rounds are +NONCLEAN. R19 verifies all 324 paths / 1,754 cumulative hunks at `261bb82d` +and confirms two Medium late-publication gaps: shell refusal notices and directory +completion results after owned stop. `6cc8bc53` rechecks the live input seal before +these results publish; live warnings, local cleanup and actual native closure stay +intact. Four permanent installed-Ink cases cover both live and stopped paths; +removing either guard breaks its precise rendered-output assertion. Fixed/restored +five-file runs pass 27 cases with zero unhandled errors and actual process joins. +Author workspace/build and joined post-build CLI gates pass (357 files / 5,626 +passes / ten established skips, zero unhandled errors), together with static and +compiled smokes. Predecessor source `261bb82d` has four successful same-tarball +consumers; its full CI remains in progress when recorded and does not accept the +correction. Two NEW complete cumulative clean rounds, a NEW separate clean +security sitting and fresh corrected-source full CI plus all four same-artifact +consumers remain required. Steps 9–19 have not started; no user decision is pending. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index 7bacc95b..af5b77b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,18 +196,21 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first eighteen complete cumulative rounds are -NONCLEAN. R18 verifies all 321 paths / 1,749 cumulative hunks at `1f3cce50` -and confirms two High gaps: caught workflow self-stop faults and the required -CI job's outer time budget. `f23e0da7` observes the original self-stop failure -before reversible recovery; `df6e6631` sizes only the main CI watchdog at 25 minutes. -Classified refusals, ownerless recovery, actual cleanup ACKs and all inner test, -native and replay bounds remain intact. Author workspace/build and separately -joined post-build CLI gates pass (356 files / 5,622 passes / ten established skips, -zero unhandled errors), along with all 549 acceptance-tool tests and compiled smokes. -Predecessor platform/CI results retain their source and do not accept these fixes. -Two NEW complete cumulative clean rounds, a NEW separate clean security sitting -and fresh full CI plus all four same-artifact consumers remain required. +but not independently accepted. Its first nineteen complete cumulative rounds are +NONCLEAN. R19 verifies all 324 paths / 1,754 cumulative hunks at `261bb82d` +and confirms two Medium late-publication gaps: shell refusal notices and directory +completion results after owned stop. `6cc8bc53` rechecks the live input seal before +these results publish; live warnings, local cleanup and actual native closure stay +intact. Four permanent installed-Ink cases cover both live and stopped paths; +removing either guard breaks its precise rendered-output assertion. Fixed/restored +five-file runs pass 27 cases with zero unhandled errors and actual process joins. +Author workspace/build and joined post-build CLI gates pass (357 files / 5,626 +passes / ten established skips, zero unhandled errors), together with static and +compiled smokes. Predecessor source `261bb82d` has four successful same-tarball +consumers; its full CI remains in progress when recorded and does not accept the +correction. Two NEW complete cumulative clean rounds, a NEW separate clean +security sitting and fresh corrected-source full CI plus all four same-artifact +consumers remain required. Steps 9–19 have not started; no user decision is pending. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index f8b801e2..7ad6b29b 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1321,6 +1321,18 @@ ten established skips, zero unhandled errors), together with 549 acceptance-tool tests and compiled smokes. R1–R18 remain NONCLEAN. Two NEW complete cumulative clean rounds, NEW separate security and fresh corrected-source CI/platforms remain required. Steps 9–19 have not started; no user decision is pending. +The [nineteenth cumulative review and late-publication correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-nineteenth-cumulative-review-and-late-publication-correction--2026-10-17) +records complete NONCLEAN coverage of 324 paths / 1,754 rehashed hunks at `261bb82d`. +Two Medium gaps let shell refusal notices and directory completion results publish +after owned stop. `6cc8bc53` seals both results while retaining live warnings and +actual native cleanup. Four installed-Ink controls and two independent guard +removals pin the behaviour; fixed/restored five-file runs pass all 27 cases with +zero unhandled errors and actual process joins. Applied workspace/build and joined +post-build CLI gates pass (357 files / 5,626 passes / ten established skips, zero +unhandled errors), with static and compiled smokes. R1–R19 remain NONCLEAN; two +NEW complete cumulative clean rounds, NEW separate security and corrected-source +full CI/platforms remain required. Steps 9–19 have not started; no user decision +is pending. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 194003f2..a0c3db4e 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -7859,3 +7859,63 @@ the corrections. Two NEW complete cumulative CLEAN rounds after the last materia fix, NEW separate clean security and fresh corrected-source full CI plus all four installed consumers remain required. Steps 9–19 have not started. No credential, provider capture, billing, production recipient or user decision is pending. + +### Wave 3 Step 8d nineteenth cumulative review and late-publication correction — 2026-10-17 + +**R19 is complete and NONCLEAN; Step 8d and whole Step 8 remain open.** Seven NEW +independent slices cover cumulative `d9625ff0` → `261bb82d`: **324 paths / 1,754 +hunks**. The base is Step 7's installed-package acceptance, so these cumulative +reads include all Step 8 increments. Root reads every original report and all +substantive file assessments, then independently rechecks physical bodies, complete +diffs and ordered hunk identities. Opening/closing freezes match 1,753 tracked +files, 21,165 physical dependency files, 1,215 lexical links and 466 dist files. +Aggregate SHA-256 is +`a6082615586c27ef9171f6fed464cf28ced93ca47a92e52b6de7a2a92f0b4bca`. +The root closure receipt is +`ceceb5448aa58de4d798a2c7a6cd94f4a20f620604ee5ee263042508fa271a2f`. +Original reports, a byte-identical bridge-ledger filename alias, qualified document +reads and runtime receipts remain local and Git-ignored. + +**Medium `R19-HOME-M1` and supplemental `R19-HOME-M2` are corrected at +`6cc8bc53`.** An already-entered shell rejection could append a warning after +owned stop. A neighbouring mention-list fulfilment could install new file +suggestions. Both become visible when installed Ink completes an outstanding +native suspension, before final unmount. Independent original-lifetime/driver +reproductions preserve the original exit 9 and actual cleanup joins; these are +post-stop business-publication defects, not evidence of a credential, filesystem +authority, accounting or native-ACK bypass. Existing assigned chat-path conclusions +retain their exact scope and do not certify the neighbouring ChatApp callbacks. +The [canonical session/terminal contract](../../reference/cli/commands.md#session-ownership-implementation-checkpoint) +owns the corrected publication rule. + +Both callbacks now recheck the original live input seal before publishing. +The rejection-side mention callback follows the same seal. Live classified shell +warnings remain inline; local busy cleanup and actual native restoration still +complete. Four permanent installed-Ink cases exercise shell/listing results in +live and stopped sessions. Fixed and byte-restored five-file runs pass **27 cases**. +Removing only the shell guard or only the successful-listing guard produces its +precise rendered-output failure, with **26 positive controls** still passing. +Every phase has explicit zero-unhandled evidence, actual Node `wait` and outer +execution joins. Synthetic native streams do not claim an operator-TTY test. +No assertion or readiness/native bound is widened. No new ADR, API or dependency +is introduced. + +Applied author workspace lint/typecheck/test and build pass; cached dependency +tasks retain that qualification. A separately joined post-build full CLI records +**357 files / 5,626 passes / ten established skips**, explicit zero unhandled errors +and actual Node exit 0. The initial direct run overlaps dependency builds and is +supplementary rather than final post-build evidence. Format, isolation (736 files), +seam and engine-dependency gates pass. Compiled closure verifies 25 outputs / 410 +inputs / 17 externals; local bootstrap and CLI smoke pass. Markdown remains outside +Prettier's scope. These are author checks, not independent acceptance. + +At predecessor `261bb82d`, all four same-artifact consumers, coverage and Windows +advisory pass in [CI run 39025094399](https://github.com/HodeTech/Relavium/actions/runs/39025094399); +full CI remains in progress when recorded. Root independently inspects five ZIPs, +70 public files and the 446,514-byte tarball, SHA-256 +`55da92eec03851114d9d5aa945d84b8f2316d74bbe5d5a3a274b991bbb81a68f`. +Those results do not execute or accept this correction. R1–R19 retain NONCLEAN +verdicts. Two NEW complete cumulative CLEAN rounds after the last material fix, +NEW separate clean security and fresh corrected-source full CI plus all four +installed consumers remain required. Steps 9–19 have not started. No credential, +provider capture, billing, production recipient or user decision is pending. From 50d7309f3adff6283656d185ce8801bd7c340e82 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 14:58:42 +0300 Subject: [PATCH 151/222] fix(cli): seal Home results and expired maintenance entry Seal deferred Home file, shell, mention and model-refresh publication after permanent stop while preserving live notices and actual entered native cleanup. Reuse the original invocation budget before optional workflow effect retention and media GC; keep entered sweeps, receipt health and mandatory resource joins owed. Actual installed Home, controller and run/gate/budget controls cover live and stopped or expired paths. Two Home removals and five budget-entry/forwarding removals fail precise assertions; every restoration passes. Workspace lint/typecheck/test/build, static gates and a separately OS-joined post-build CLI suite pass (360 files, 5649 passes, 10 established skips, zero unhandled errors). Independent cumulative review, security and corrected-source CI remain open. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- apps/cli/src/commands/gate.ts | 5 +- apps/cli/src/commands/run.ts | 9 +- .../workflow-maintenance-budget.test.ts | 360 ++++++++++++++++++ apps/cli/src/engine/media-gc.test.ts | 46 +++ apps/cli/src/engine/media-gc.ts | 7 +- .../cli/src/home/home-completion-stop.test.ts | 239 ++++++++++++ .../render/tui/home-completion-stop.test.ts | 250 ++++++++++++ apps/cli/src/render/tui/home-controller.ts | 35 +- docs/reference/cli/commands.md | 10 +- 9 files changed, 944 insertions(+), 17 deletions(-) create mode 100644 apps/cli/src/commands/workflow-maintenance-budget.test.ts create mode 100644 apps/cli/src/home/home-completion-stop.test.ts create mode 100644 apps/cli/src/render/tui/home-completion-stop.test.ts diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index a190daf4..7abae049 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -625,6 +625,8 @@ async function resumeGateOwned( return EXIT_CODES.success; } + // Both retention passes are optional: do not start new maintenance after the original deadline. + // Already-entered work and mandatory engine/input/MCP/database joins retain their own barriers. // Host media GC (2.S/D-GC, ADR-0042 §4) — only when the gate-resumed run reaches a TERMINAL event, exactly as // `run` does (the SAME helper). A re-pause (a second gate / budget pause) is NOT terminal, so the still-paused // run's media survives for the next resume; a GC failure is swallowed (never a correctness break). @@ -632,7 +634,7 @@ async function resumeGateOwned( // returns a closed handle for one — so its COMMITTED rows have no reader left and go. Unresolved rows // are untouched by construction: they are the record an operator needs, and an exit-7 run's rows must // survive precisely because its run is over. - if (isTerminalOutcome(outcome)) { + if (isTerminalOutcome(outcome) && lifetime.invocation.registry.budget.remainingMs() !== 0) { sweepCommittedEffects(deps.io, opened.db, args.runId, lifetime.effectNoticeOwner); } await sweepMediaAtTerminal({ @@ -642,6 +644,7 @@ async function resumeGateOwned( casRoot: wiring.media.casRoot, currentRunId: args.runId, graceMs: config.mediaGcGraceMs, + budget: lifetime.invocation.registry.budget, }); // Same rule as (ADR-0078 §5) — the resumed leg's terminal is durable, or the run says so. // **Read off the HANDLE, never from a `subscribe()` here.** The resume gate settles `run:failed` INSIDE diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 05483c42..6884e50c 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -516,6 +516,8 @@ async function runOwned( false, ); + // Both retention passes are optional: do not start new maintenance after the original deadline. + // Already-entered work and mandatory engine/input/MCP/database joins retain their own barriers. // Host media GC (2.S/D-GC, ADR-0042 §4) — best-effort, keyed on a TERMINAL outcome: the clean-terminal reclaim // retry + the grace-window byte reclaim + the CAS-orphan sweep, over the same durable `history.db`. Skipped on // a `paused` outcome (resumable — its media must survive) and the in-memory path (no CAS). See @@ -525,7 +527,11 @@ async function runOwned( // are untouched by construction: they are the record an operator needs, and an exit-7 run's rows must // survive precisely because its run is over. // `opened` is undefined on the in-memory path (`--no-history`), which has no journal to sweep. - if (isTerminalOutcome(outcome) && opened !== undefined) { + if ( + isTerminalOutcome(outcome) && + opened !== undefined && + lifetime.invocation.registry.budget.remainingMs() !== 0 + ) { sweepCommittedEffects(deps.io, opened.db, handle.runId, lifetime.effectNoticeOwner); } await sweepMediaAtTerminal({ @@ -535,6 +541,7 @@ async function runOwned( casRoot: mediaCasRoot, currentRunId: handle.runId, graceMs: config.mediaGcGraceMs, + budget: lifetime.invocation.registry.budget, }); // The handle's disposition OUTRANKS the outcome (ADR-0078 §5): a delivered `run:completed` whose diff --git a/apps/cli/src/commands/workflow-maintenance-budget.test.ts b/apps/cli/src/commands/workflow-maintenance-budget.test.ts new file mode 100644 index 00000000..50ed8f0b --- /dev/null +++ b/apps/cli/src/commands/workflow-maintenance-budget.test.ts @@ -0,0 +1,360 @@ +import { randomUUID } from 'node:crypto'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { parseWorkflow, type RunHandle } from '@relavium/core'; +import { + createClient, + createEffectJournalStore, + createModelCatalogStore, + createProviderStore, + createRunHistoryStore, + createRunLeasePort, + FilesystemMediaStore, + runMigrations, +} from '@relavium/db'; +import type { LlmProvider, ModelPricing } from '@relavium/llm'; +import { expect, it, vi } from 'vitest'; + +import { buildEngine, type BuildEngineOptions } from '../engine/build-engine.js'; +import { createCliHost } from '../engine/host.js'; +import { sweepHostMediaBestEffort } from '../engine/media-gc.js'; +import { createInvocationFiniteExit } from '../process/invocation-finite-exit.js'; +import { InvocationRegistry } from '../process/invocation-registry.js'; +import { captureIo, CHAT_TEXT_CAPABILITY_FLAGS } from '../test-support.js'; +import { budgetCommand, gateCommand } from './gate.js'; +import { runCommand } from './run.js'; + +let home = ''; +vi.mock('../config/load.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadResolvedConfig: (options: Parameters[0]) => + actual.loadResolvedConfig({ ...options, home }), + }; +}); + +const MODEL = 'offline-maintenance-budget'; +const PRICE: ModelPricing = { + provider: 'openai', + nativeId: MODEL, + displayName: MODEL, + contextWindowTokens: 100000, + maxOutputTokens: 1000, + inputPerMtokMicrocents: 1000000, + outputPerMtokMicrocents: 1000000, + cachedInputPerMtokMicrocents: 1000000, +}; +const provider: LlmProvider = { + id: 'openai', + customEndpoint: true, + supports: CHAT_TEXT_CAPABILITY_FLAGS, + generate: () => Promise.reject(new Error('unexpected maintenance provider invocation')), + stream: () => { + throw new Error('budget rejection must not invoke provider'); + }, +}; +const providers = { + resolveProvider: () => provider, + keyFor: () => { + throw new Error('no maintenance fixture needs a credential'); + }, + endpointKind: () => 'custom' as const, +}; + +/** Actual command/engine/SQLite/default CAS; only clock, exit, signal subscription and view are synthetic. + * At expiry native terminal restoration remains owed: DB close and raw sweeps are joined separately, + * and this test never calls a returning fake process.exit an acknowledgement of command retirement. */ +it.each(['run', 'gate', 'budget'] as const)( + '%s maintenance uses the original live/expired budget', + async (surface) => { + for (const expired of [false, true]) { + const root = mkdtempSync(join(tmpdir(), 'relavium-terminal-maintenance-')); + let acquired: ReturnType | undefined; + try { + home = root; + const client = createClient(':memory:'); + acquired = client; + runMigrations(client.db); + let now = 0; + const timers = new Set<() => void>(); + const clock = { + now: () => now, + schedule: (_ms: number, fire: () => void) => { + timers.add(fire); + return () => { + timers.delete(fire); + }; + }, + }; + const registry = new InvocationRegistry(clock); + const exit = vi.fn(); + const finiteExit = createInvocationFiniteExit(registry, clock, { + exit, + setExitCode: vi.fn(), + }); + const invocation = { registry, finiteExit, seedFileCatalogOnce: () => Promise.resolve() }; + let onSignal: (signo: number) => void = () => undefined; + let closeCompleted: () => void = () => undefined; + const closed = new Promise((resolve) => { + closeCompleted = resolve; + }); + const committedCounts: number[] = []; + const ambiguousCounts: number[] = []; + const journal = createEffectJournalStore(client.db, { uuid: randomUUID, now: Date.now }); + let correlation: + | { kind: 'run'; runId: string; nodeId: string; attempt: number } + | undefined; + let terminalHealthReached = false; + let finalized = false; + let settled = false; + const entries: Array<{ remaining: number | undefined; exits: number }> = []; + const rawSweeps: Promise[] = []; + // eslint-disable-next-line @typescript-eslint/unbound-method -- Captured native implementation is invoked only with its actual receiver via call below. + const nativeSweep = FilesystemMediaStore.prototype.sweepTemporaries; + const sweepSpy = vi + .spyOn(FilesystemMediaStore.prototype, 'sweepTemporaries') + .mockImplementation(function (this: FilesystemMediaStore, options) { + const pending = nativeSweep.call(this, options); + rawSweeps.push(pending); + return pending; + }); + const configPath = join(root, 'config.toml'); + writeFileSync(configPath, ''); + const yaml = + surface === 'budget' + ? `schema_version: '1.0' +workflow: + id: maintenance + budget: {max_cost_microcents: 1, on_exceed: pause_for_approval, strict_cost_cap: true} + agents: + - {id: worker, model: ${MODEL}, provider: openai, system_prompt: synthetic} + nodes: + - {id: worker, type: agent, agent_ref: worker, prompt_template: synthetic, max_tokens: 64} + - {id: out, type: output} + edges: [{from: worker, to: out}] +` + : `schema_version: '1.0' +workflow: + id: maintenance + nodes: + - {id: input, type: input} +${surface === 'gate' ? ' - {id: approval, type: human_gate, gate_type: approval}\n' : ''} - {id: out, type: output} + edges: +${surface === 'gate' ? ' - {from: input, to: approval}\n - {from: approval, to: out}' : ' - {from: input, to: out}'} +`; + const workflow = parseWorkflow(yaml); + const store = createRunHistoryStore(client.db, { + uuid: randomUUID, + now: Date.now, + projectRoot: root, + workflow: { + slug: 'maintenance', + name: 'maintenance', + definitionJson: JSON.stringify(workflow), + }, + }); + const prices = new Map([[MODEL, PRICE]]); + if (surface === 'budget') { + const providerId = createProviderStore(client.db, { + uuid: randomUUID, + now: Date.now, + }).upsert({ + name: 'openai', + displayName: 'Offline', + baseUrl: 'https://offline.example.invalid/v1', + }).id; + createModelCatalogStore(client.db, { uuid: randomUUID, now: Date.now }).upsert({ + providerId, + modelId: MODEL, + displayName: MODEL, + source: 'user', + contextWindowTokens: PRICE.contextWindowTokens, + maxOutputTokens: PRICE.maxOutputTokens, + inputCostPerMtokMicrocents: PRICE.inputPerMtokMicrocents, + outputCostPerMtokMicrocents: PRICE.outputPerMtokMicrocents, + cachedInputCostPerMtokMicrocents: PRICE.cachedInputPerMtokMicrocents, + }); + } + let runId = ''; + if (surface !== 'run') { + const initial = await buildEngine({ + host: createCliHost(store, { runLeases: createRunLeasePort(store) }), + providers, + resolvePrice: prices, + }); + const paused = initial.start({ workflow, inputs: {} }); + runId = paused.runId; + let sawPause = false; + for await (const event of paused.events) { + if (event.type === 'run:paused') { + sawPause = true; + // Departure needs the intact primary; break returns its iterator afterwards. + expect((await paused.depart()).kind).not.toBe('continue'); + break; + } + } + expect(sawPause).toBe(true); + } + const observe = (handle: RunHandle): RunHandle => { + runId = handle.runId; + const read = handle.durability.bind(handle); + let reads = 0; + vi.spyOn(handle, 'durability').mockImplementation(() => { + if (++reads === 2) { + // Final command health sample, after actual primary/departure and the subscribed signal. + expect(finalized).toBe(true); + expect(registry.budget.deadline()).toBe(3000); + now = expired ? 3000 : 2999; + terminalHealthReached = true; + correlation = { kind: 'run', runId, nodeId: 'maintenance-fixture', attempt: 1 }; + for (const [slot, state] of [ + [0, 'committed'], + [1, 'ambiguous'], + ] as const) { + const identity = { + scope: `run:${encodeURIComponent(runId)}:maintenance-fixture`, + slot, + toolId: 'fixture', + }; + journal.prepare( + identity, + correlation, + { providerAttempt: 1, toolCallId: 'fixture' }, + 3, + 'fixture', + ); + journal.settle(identity, state); + } + } + return read(); + }); + return handle; + }; + const build = async (options?: BuildEngineOptions) => { + const engine = await buildEngine(options); + const start = engine.start.bind(engine); + vi.spyOn(engine, 'start').mockImplementation((params) => observe(start(params))); + const resume = engine.resumeFromCheckpoint.bind(engine); + vi.spyOn(engine, 'resumeFromCheckpoint').mockImplementation(async (...params) => + observe(await resume(...params)), + ); + return engine; + }; + const close = () => { + if (correlation !== undefined) { + const records = journal.recordsFor(correlation); + committedCounts.push(records.filter((r) => r.state === 'committed').length); + ambiguousCounts.push(records.filter((r) => r.state === 'ambiguous').length); + } + client.sqlite.close(); + closeCompleted(); + }; + const deps = { + invocation, + io: captureIo().io, + global: { + cwd: root, + configPath, + json: false, + color: false, + verbosity: 'normal' as const, + }, + subscribeSignals: (consume: (signo: number) => void) => { + onSignal = consume; + return () => undefined; + }, + buildEngine: build, + selectRenderer: () => ({ + onEvent: () => undefined, + finalize: () => { + finalized = true; + onSignal(15); + }, + }), + sweepMedia: (args: Parameters[0]) => { + entries.push({ + remaining: registry.budget.remainingMs(), + exits: exit.mock.calls.length, + }); + return sweepHostMediaBestEffort(args); + }, + }; + const path = join(root, 'maintenance.relavium.yaml'); + writeFileSync(path, yaml); + const action = ( + surface === 'run' + ? runCommand( + { workflow: path, input: [], allowMcpStdio: [] }, + { + ...deps, + openRunStore: () => ({ + db: client.db, + store, + terminalOutboxPath: join(root, 'outbox'), + close, + }), + }, + ) + : surface === 'gate' + ? gateCommand( + { runId, approve: true }, + { ...deps, openDb: () => ({ db: client.db, close }) }, + ) + : budgetCommand( + { runId, abort: true }, + { ...deps, openDb: () => ({ db: client.db, close }) }, + ) + ).then( + (code) => { + settled = true; + return code; + }, + (cause: unknown) => { + settled = true; + throw cause; + }, + ); + // Observe rejection immediately even if a fixture assertion below fails first. + void action.catch(() => undefined); + try { + await closed; + expect(terminalHealthReached, 'ACTUAL_TERMINAL_HEALTH_PRECEDES_MAINTENANCE').toBe(true); + expect(entries, 'WORKFLOW_TERMINAL_MEDIA_ENTRY_BUDGET').toEqual( + expired ? [] : [{ remaining: 1, exits: 0 }], + ); + expect(rawSweeps, 'ACTUAL_NATIVE_MEDIA_ENTRY_BUDGET').toHaveLength(expired ? 0 : 1); + expect(committedCounts, 'WORKFLOW_TERMINAL_EFFECT_ENTRY_BUDGET').toEqual([ + expired ? 1 : 0, + ]); + expect(ambiguousCounts).toEqual([1]); + expect(client.sqlite.open).toBe(false); + if (expired) { + expect(settled, 'EXPIRED_TERMINAL_RESTORATION_IS_NOT_AN_ACK').toBe(false); + expect(registry.activeOwners).toBeGreaterThan(0); + } else { + expect(await action).toBe(surface === 'budget' ? 1 : 0); + expect(await finiteExit.exited).toBe(143); + expect(registry.activeOwners).toBe(0); + } + expect(registry.budget.deadline()).toBe(3000); + } finally { + await Promise.allSettled(rawSweeps); + for (const fire of [...timers]) fire(); + await new Promise((resolve) => setImmediate(resolve)); + if (client.sqlite.open) client.sqlite.close(); + sweepSpy.mockRestore(); + } + } finally { + try { + if (acquired?.sqlite.open) acquired.sqlite.close(); + } finally { + vi.restoreAllMocks(); + rmSync(root, { recursive: true, force: true }); + } + } + } + }, +); diff --git a/apps/cli/src/engine/media-gc.test.ts b/apps/cli/src/engine/media-gc.test.ts index 49d41a05..a20d0bb6 100644 --- a/apps/cli/src/engine/media-gc.test.ts +++ b/apps/cli/src/engine/media-gc.test.ts @@ -411,6 +411,52 @@ describe('sweepMediaAtTerminal (the shared run/gate run-end GC guard)', () => { expect('graceMs' in (calls[0] ?? {})).toBe(false); // conditional-spread omitted the key, never graceMs: undefined }); + it.each([undefined, 1, 0])('respects the original remaining budget %s', async (remaining) => { + const { sweep, calls } = spySweep(); + const budget = { deadline: () => 3000, remainingMs: () => remaining }; + await sweepMediaAtTerminal({ + sweep, + isTerminal: true, + db: client.db, + casRoot: '/cas', + currentRunId: 'r1', + graceMs: undefined, + budget, + }); + expect(calls, 'TERMINAL_MEDIA_MAINTENANCE_ENTRY_BUDGET').toHaveLength(remaining === 0 ? 0 : 1); + }); + + it('still joins a sweep entered before expiry without renewing the budget', async () => { + let remaining = 1; + let release: () => void = () => undefined; + const raw = new Promise((resolve) => { + release = () => resolve(undefined); + }); + const sweep = vi.fn(() => raw); + let settled = false; + const pending = sweepMediaAtTerminal({ + sweep, + isTerminal: true, + db: client.db, + casRoot: '/cas', + currentRunId: 'r1', + graceMs: undefined, + budget: { deadline: () => 3000, remainingMs: () => remaining }, + }).then(() => { + settled = true; + }); + try { + expect(sweep).toHaveBeenCalledOnce(); + remaining = 0; + await Promise.resolve(); + expect(settled, 'ENTERED_MEDIA_SWEEP_REMAINS_OWED').toBe(false); + } finally { + release(); + await pending; + } + expect(settled).toBe(true); + }); + it('SWALLOWS a throwing sweep — the run-end GC must NEVER fail the run', async () => { const { sweep } = spySweep(() => Promise.reject(new Error('gc boom'))); await expect( diff --git a/apps/cli/src/engine/media-gc.ts b/apps/cli/src/engine/media-gc.ts index ff2a0985..80a861d7 100644 --- a/apps/cli/src/engine/media-gc.ts +++ b/apps/cli/src/engine/media-gc.ts @@ -7,6 +7,8 @@ import { } from '@relavium/db'; import type { RunStatus } from '@relavium/shared'; +import type { RemainingBudget } from '../process/invocation-registry.js'; + /** The terminal `runs.status` set — a run here will never be resumed, so its lingering `run`-refs are reclaimable. * Typed as `Set` (not `Set`) so a misspelled status is a compile error and `.has(status)` * narrows against the closed run-status union. */ @@ -171,7 +173,8 @@ export async function sweepHostMediaBestEffort(args: { * (`casRoot`/`db` present — the in-memory unit/harness path has neither). A non-terminal (`paused`) outcome is * skipped so the resumable run's media survives. The whole call is best-effort — any throw (incl. an injected * `sweep` stub's) is swallowed so the GC can NEVER fail the run. Extracted here so neither command carries the - * branch/try in its own body (one home for the guard). + * branch/try in its own body (one home for the guard). An expired original invocation budget skips + * NEW maintenance; a sweep already entered is still joined and never receives a renewed deadline. */ export async function sweepMediaAtTerminal(args: { readonly sweep: typeof sweepHostMediaBestEffort; @@ -180,6 +183,7 @@ export async function sweepMediaAtTerminal(args: { readonly casRoot: string | undefined; readonly currentRunId: string; readonly graceMs: number | undefined; + readonly budget?: RemainingBudget; }): Promise { // `db === undefined` is run.ts's optional-store path (gate always passes a live `opened.db`); `casRoot` / // `db` are wired together, so the in-memory unit/harness path (neither present) is also skipped here. @@ -187,6 +191,7 @@ export async function sweepMediaAtTerminal(args: { return; } try { + if (args.budget?.remainingMs() === 0) return; await args.sweep({ db: args.db, casRoot: args.casRoot, diff --git a/apps/cli/src/home/home-completion-stop.test.ts b/apps/cli/src/home/home-completion-stop.test.ts new file mode 100644 index 00000000..40cfcae7 --- /dev/null +++ b/apps/cli/src/home/home-completion-stop.test.ts @@ -0,0 +1,239 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createElement } from 'react'; +import { render } from 'ink'; +import { createClient, createSessionStore, runMigrations } from '@relavium/db'; +import { describe, expect, it, vi } from 'vitest'; + +import { buildChatSession } from '../chat/session-host.js'; +import { scriptedResolver, textTurn } from '../chat/test-support.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { CliError } from '../process/cli-error.js'; +import { OwnedTtyInput, OwnedTtyOutput, captureIo } from '../test-support.js'; +import { RootApp, type RootAppProps } from '../render/tui/home-app.js'; +import * as mention from '../render/tui/mention.js'; +import { driveHome } from './drive-home.js'; + +vi.mock('../secrets/os-keychain.js', () => ({ + createOsKeychainStore: () => ({ + get: () => null, + set: () => undefined, + delete: () => false, + }), +})); + +const tick = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +describe('installed Home asynchronous publication during native suspension', () => { + it.each([ + { kind: 'mention', stopped: false }, + { kind: 'mention', stopped: true }, + { kind: 'shell', stopped: false }, + { kind: 'shell', stopped: true }, + { kind: 'picker', stopped: false }, + { kind: 'picker', stopped: true }, + ])('$kind preserves live reporting and respects stop=$stopped', async ({ kind, stopped }) => { + const f = invocationFixture(); + const client = createClient(':memory:'); + runMigrations(client.db); + const sessions = createSessionStore(client.db); + const cwd = mkdtempSync(join(tmpdir(), 'relavium-home-publication-')); + const input = new OwnedTtyInput(); + const output = new OwnedTtyOutput(); + const stderr = new OwnedTtyOutput(); + const captured = captureIo(); + let props: RootAppProps | undefined; + let release = (): void => undefined; + const held = new Promise((resolve) => { + release = resolve; + }); + let finishList: (value: readonly mention.MentionCandidate[]) => void = () => undefined; + const listing = new Promise((resolve) => { + finishList = resolve; + }); + let rejectShell: (cause: unknown) => void = () => undefined; + const shell = new Promise((_resolve, reject) => { + rejectShell = reject; + }); + void shell.catch(() => undefined); // Cleanup may refuse a fixture before its shell entry is reached. + let rejectModels: (cause: unknown) => void = () => undefined; + const modelListing = new Promise((_resolve, reject) => { + rejectModels = reject; + }); + void modelListing.catch(() => undefined); + const listModels = vi.fn(() => modelListing); + const resolver = scriptedResolver([textTurn('ready')]); + const providers = { + ...resolver, + resolveProvider: (id: Parameters[0]) => { + const provider = resolver.resolveProvider(id); + return provider === undefined ? undefined : { ...provider, listModels }; + }, + }; + const list = vi.fn(() => listing); + const read = vi.fn(() => Promise.resolve({ content: 'Unused fixture', sizeBytes: 14 })); + vi.spyOn(mention, 'createMentionReader').mockReturnValue({ list, read }); + const shellRunner = vi.fn(() => shell); + const marker = + kind === 'mention' + ? 'arriving-home-candidate.ts' + : kind === 'picker' + ? "couldn't refresh anthropic" + : '! shell command failed unexpectedly'; + const refusal = new CliError('invalid_invocation', 'PRIVATE_CLASSIFIED_HOME_REFUSAL'); + const running = driveHome({ + invocation: f.invocation, + io: { + ...captured.io, + stdoutIsTty: true, + stdinIsTty: true, + stdin: input, + nativeStdin: input, + nativeStdout: output, + nativeStderr: stderr, + writeOutAcknowledged: (text) => + new Promise((resolve, reject) => { + output.write(text, (cause) => + cause === null || cause === undefined ? resolve() : reject(cause), + ); + }), + writeErrAcknowledged: (text) => + new Promise((resolve, reject) => { + stderr.write(text, (cause) => + cause === null || cause === undefined ? resolve() : reject(cause), + ); + }), + }, + global: { + cwd, + configPath: join(cwd, 'absent.toml'), + json: false, + color: false, + verbosity: 'normal', + noAltScreen: true, + }, + providers, + buildSession: async (options) => { + const built = await buildChatSession(options); + vi.spyOn(built.session, 'runUserCommand').mockImplementation(shellRunner); + return built; + }, + openSessionStore: () => ({ + db: client.db, + store: sessions, + close: () => client.sqlite.close(), + }), + doctorProbes: { keychain: () => undefined, config: () => undefined, toolHost: {} }, + render: (value) => { + props = value; + return render(createElement(RootApp, value), { + stdin: input, + stdout: output, + stderr, + interactive: true, + alternateScreen: false, + exitOnCtrlC: false, + patchConsole: false, + }); + }, + getSize: () => ({ cols: 120, rows: 40 }), + subscribeResize: () => () => undefined, + subscribeSignals: () => () => undefined, + subscribeProcessExit: () => () => undefined, + jobControlLifecycle: { + supported: false, + onSuspend: () => () => undefined, + onContinue: () => () => undefined, + suspendSelf: () => undefined, + }, + }); + const settled = running.then( + () => undefined, + () => undefined, + ); + let suspended: Promise | undefined; + try { + if (props === undefined) throw new Error('Installed Home mount absent.'); + const controller = props.controller; + controller.handlePaste('hello'); + controller.handleKey('', { return: true }); + await vi.waitFor(() => { + expect(controller.getSnapshot().session).toBeDefined(); + expect(controller.getSnapshot().submitBusy).toBe(false); + }); + if (kind === 'mention') { + controller.handleKey('@', {}); + await vi.waitFor(() => expect(list).toHaveBeenCalledOnce()); + expect(shellRunner).not.toHaveBeenCalled(); + } else if (kind === 'picker') { + controller.handlePaste('/models'); + controller.handleKey('', { return: true }); + await vi.waitFor(() => { + expect(listModels).toHaveBeenCalledOnce(); + expect(controller.getSnapshot().modelPicker?.loading).toBe(true); + }); + expect(controller.getSnapshot().modelPicker?.banner).toBeUndefined(); + expect(shellRunner).not.toHaveBeenCalled(); + expect(list).not.toHaveBeenCalled(); + } else { + controller.handlePaste('!synthetic'); + controller.handleKey('', { return: true }); + await vi.waitFor(() => expect(shellRunner).toHaveBeenCalledOnce()); + expect(list).not.toHaveBeenCalled(); + } + const port = props.suspendPort; + await vi.waitFor(() => expect(port?.current()).toBeDefined()); + const suspend = port?.current(); + if (suspend === undefined) throw new Error('Installed Home suspension absent.'); + suspended = suspend(() => held); + await vi.waitFor(() => expect(input.isRaw).toBe(false)); + if (stopped) { + f.invocation.finiteExit.fail('cli_internal'); + expect(props.inputEnabled?.()).toBe(false); + expect(port?.current()).toBeUndefined(); + } else expect(props.inputEnabled?.()).toBe(true); + const from = output.frames.length; + if (kind === 'mention') finishList([{ name: marker, path: marker, type: 'file' }]); + else if (kind === 'picker') rejectModels(refusal); + else rejectShell(refusal); + await tick(); + await tick(); + expect(f.registry.activeOwners).toBeGreaterThan(0); + expect(output.frames.slice(from).join('')).not.toContain(marker); + release(); + await suspended; // Actual installed endSuspend redraws before final native unmount. + if (stopped) { + expect(await running).toBe(9); + expect(output.frames.slice(from).join(''), 'HOME_STOPPED_COMPLETION_RENDER').not.toContain( + marker, + ); + } else { + await vi.waitFor(() => expect(output.frames.slice(from).join('')).toContain(marker)); + f.invocation.finiteExit.cooperativeCancel(4); + expect(await running).toBe(0); // Healthy Home consumes the session-end outcome. + } + expect(f.registry.activeOwners).toBe(0); + expect(input.isRaw).toBe(false); + expect(input.listenerCount('readable')).toBe(0); + expect(port?.current()).toBeUndefined(); + expect(read).not.toHaveBeenCalled(); + expect(f.exit).not.toHaveBeenCalled(); + expect(output.frames.join('') + stderr.frames.join('')).not.toContain('PRIVATE_'); + } finally { + finishList([]); + rejectShell(refusal); + rejectModels(refusal); + release(); + await suspended; + f.invocation.finiteExit.cooperativeCancel(4); + await settled; + vi.restoreAllMocks(); + if (client.sqlite.open) client.sqlite.close(); + input.destroy(); + output.destroy(); + stderr.destroy(); + rmSync(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/render/tui/home-completion-stop.test.ts b/apps/cli/src/render/tui/home-completion-stop.test.ts new file mode 100644 index 00000000..7a7ed243 --- /dev/null +++ b/apps/cli/src/render/tui/home-completion-stop.test.ts @@ -0,0 +1,250 @@ +import type { UserCommandOutcome } from '@relavium/core'; +import type { ModelCatalogEntry } from '@relavium/llm'; +import { describe, expect, it, vi } from 'vitest'; + +import type { RefreshReport } from '../../engine/model-refresh.js'; +import type { HomeSnapshot } from '../../home/home-store.js'; +import { createChatStore } from './chat-store.js'; +import { + createHomeController, + type HomeChatSession, + type HomeModelsPort, +} from './home-controller.js'; +import type { MentionReader } from './mention.js'; +import { INLINE_TRANSCRIPT_BOUND } from './session-view-model.js'; + +const EMPTY_HOME: HomeSnapshot = { + attention: { gates: [], failedRuns: [] }, + recentSessions: [], + recentRuns: [], + recentAgents: [], + unreadableRunIds: [], + isEmpty: true, +}; +const tick = (): Promise => new Promise((resolve) => setImmediate(resolve)); +function deferred() { + let resolve: (value: T) => void = () => undefined; + let reject: (cause: unknown) => void = () => undefined; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +function fixture( + options: { + mentionReader?: MentionReader; + runShellCommand?: HomeChatSession['runShellCommand']; + processLine?: HomeChatSession['processLine']; + shouldStop?: HomeChatSession['shouldStop']; + models?: HomeModelsPort; + } = {}, +) { + const store = createChatStore(false, undefined, INLINE_TRANSCRIPT_BOUND); + const session: HomeChatSession = { + sessionId: 'completion-owner', + store, + processLine: options.processLine ?? (() => Promise.resolve()), + shouldStop: options.shouldStop ?? (() => false), + stopReason: () => 'clear', + teardown: vi.fn(() => Promise.resolve()), + ...(options.mentionReader === undefined ? {} : { mentionReader: options.mentionReader }), + ...(options.runShellCommand === undefined ? {} : { runShellCommand: options.runShellCommand }), + }; + const fresh: HomeChatSession = { + ...session, + sessionId: 'fresh-completion-owner', + store: createChatStore(false, undefined, INLINE_TRANSCRIPT_BOUND), + shouldStop: () => false, + processLine: () => Promise.resolve(), + teardown: vi.fn(() => Promise.resolve()), + }; + let builds = 0; + const startChat = vi.fn(() => Promise.resolve(builds++ === 0 ? session : fresh)); + const onError = vi.fn(); + const controller = createHomeController({ + startChat, + homeStore: { read: () => EMPTY_HOME }, + onExit: vi.fn(), + onError, + doctorProbes: { keychain: () => undefined, config: () => undefined, toolHost: {} }, + ...(options.models === undefined ? {} : { models: options.models }), + }); + const start = async (): Promise => { + controller.handlePaste('hello'); + controller.handleKey('', { return: true }); + await vi.waitFor(() => { + expect(controller.getSnapshot().session).toBe(session); + expect(controller.getSnapshot().submitBusy).toBe(false); + }); + }; + return { controller, store, session, fresh, startChat, onError, start }; +} + +describe('Home deferred results respect permanent controller stop', () => { + it.each([ + { refused: false, stopped: false }, + { refused: false, stopped: true }, + { refused: true, stopped: false }, + { refused: true, stopped: true }, + ])('file read refused=$refused stopped=$stopped', async ({ refused, stopped }) => { + const raw = deferred<{ content: string; sizeBytes: number }>(); + const read = vi.fn(() => raw.promise); + const f = fixture({ + mentionReader: { + list: () => Promise.resolve([{ name: 'late-read.ts', path: 'late-read.ts', type: 'file' }]), + read, + }, + }); + try { + await f.start(); + f.controller.handleKey('@', {}); + await vi.waitFor(() => expect(f.controller.getSnapshot().mention?.loading).toBe(false)); + f.controller.handleKey('', { return: true }); + expect(read).toHaveBeenCalledOnce(); + if (stopped) await f.controller.teardownActive(); + if (refused) raw.reject(new Error('PRIVATE_FILE_READ_FAILURE')); + else raw.resolve({ content: 'late read text', sizeBytes: 14 }); + await tick(); + if (stopped) { + expect(f.controller.getSnapshot().attachments, 'HOME_STOPPED_FILE_ATTACHMENT').toEqual([]); + expect(f.controller.getSnapshot().input.text).toBe(''); + expect(f.store.getSnapshot().state.warnings, 'HOME_STOPPED_FILE_WARNING').toEqual([]); + f.controller.handlePaste('new work'); + f.controller.handleKey('', { return: true }); + expect(f.controller.getSnapshot().input.text, 'HOME_STOPPED_INPUT').toBe(''); + expect(f.startChat).toHaveBeenCalledOnce(); + } else if (refused) { + expect(f.store.getSnapshot().state.warnings).toContain( + '@ mention could not read that file (refused, binary, or too large)', + ); + } else { + expect(f.controller.getSnapshot().attachments).toEqual([ + { kind: 'file', path: 'late-read.ts', content: 'late read text', sizeBytes: 14 }, + ]); + expect(f.controller.getSnapshot().input.text).toContain('late-read.ts'); + } + expect(JSON.stringify(f.store.getSnapshot())).not.toContain('PRIVATE_'); + expect(f.onError).not.toHaveBeenCalled(); + } finally { + raw.resolve({ content: '', sizeBytes: 0 }); + await f.controller.teardownActive(); + } + }); + + it.each([false, true])( + 'shell success after stopped=%s keeps cleanup separate from publication', + async (stopped) => { + const raw = deferred(); + const runner = vi.fn(() => raw.promise); + const f = fixture({ runShellCommand: runner }); + try { + await f.start(); + f.controller.handlePaste('!synthetic'); + f.controller.handleKey('', { return: true }); + expect(runner).toHaveBeenCalledOnce(); + expect(f.controller.getSnapshot().shellBusy).toBe(true); + if (stopped) await f.controller.teardownActive(); + raw.resolve({ kind: 'ran', exitCode: 0, stdout: 'late command text', stderr: '' }); + await tick(); + expect(f.controller.getSnapshot().shellBusy).toBe(false); + if (stopped) { + expect(f.controller.getSnapshot().attachments, 'HOME_STOPPED_SHELL_ATTACHMENT').toEqual( + [], + ); + expect(f.store.getSnapshot().state.transcript, 'HOME_STOPPED_SHELL_PREVIEW').toEqual([]); + } else { + expect(f.controller.getSnapshot().attachments).toHaveLength(1); + expect( + f.store + .getSnapshot() + .state.transcript.some((entry) => entry.text.includes('late command text')), + ).toBe(true); + } + expect(f.onError).not.toHaveBeenCalled(); + } finally { + raw.resolve({ kind: 'cancelled' }); + await f.controller.teardownActive(); + } + }, + ); + + it.each([false, true])( + 'model refresh after stopped=%s cannot start a new catalog read', + async (stopped) => { + const raw = deferred(); + const entry = (modelId: string): ModelCatalogEntry => ({ + modelId, + provider: 'anthropic', + displayName: modelId, + pricingSource: 'catalog', + priceKnown: true, + available: true, + deprecated: false, + }); + let refreshed = false; + const load = vi.fn(() => ({ + entries: [entry(refreshed ? 'new-result' : 'initial-cache')], + refreshedAt: refreshed ? 2 : 1, + })); + const f = fixture({ + models: { + load, + refreshIfStale: () => raw.promise, + refresh: () => Promise.resolve({ providers: [] }), + currentDefault: () => undefined, + currentEffort: () => undefined, + writeDefault: vi.fn(), + }, + }); + try { + f.controller.handleKey('/', {}); + for (const ch of 'models') f.controller.handleKey(ch, {}); + f.controller.handleKey('', { return: true }); + expect(load).toHaveBeenCalledOnce(); + expect(f.controller.getSnapshot().modelPicker?.loading).toBe(true); + if (stopped) await f.controller.teardownActive(); + refreshed = true; + raw.resolve({ providers: [] }); + await tick(); + expect(load, 'HOME_STOPPED_CATALOG_READ').toHaveBeenCalledTimes(stopped ? 1 : 2); + expect( + f.controller.getSnapshot().modelPicker?.entries.map((value) => value.modelId), + ).toEqual([stopped ? 'initial-cache' : 'new-result']); + expect(f.onError).not.toHaveBeenCalled(); + } finally { + raw.resolve(undefined); + await f.controller.teardownActive(); + } + }, + ); + + it.each([false, true])( + 'a clear turn settling after stopped=%s cannot rebuild a session', + async (stopped) => { + const raw = deferred(); + let clearing = false; + const f = fixture({ + processLine: () => (clearing ? raw.promise : Promise.resolve()), + shouldStop: () => clearing, + }); + try { + await f.start(); + clearing = true; + f.controller.handlePaste('/clear'); + f.controller.handleKey('', { return: true }); + expect(f.controller.getSnapshot().submitBusy).toBe(true); + if (stopped) await f.controller.teardownActive(); + raw.resolve(undefined); + await tick(); + expect(f.startChat, 'HOME_STOPPED_CLEAR_REBUILD').toHaveBeenCalledTimes(stopped ? 1 : 2); + if (!stopped) expect(f.controller.getSnapshot().session).toBe(f.fresh); + expect(f.controller.getSnapshot().submitBusy).toBe(false); + expect(f.onError).not.toHaveBeenCalled(); + } finally { + raw.resolve(undefined); + await f.controller.teardownActive(); + } + }, + ); +}); diff --git a/apps/cli/src/render/tui/home-controller.ts b/apps/cli/src/render/tui/home-controller.ts index c4790e5b..aaa98d76 100644 --- a/apps/cli/src/render/tui/home-controller.ts +++ b/apps/cli/src/render/tui/home-controller.ts @@ -84,7 +84,8 @@ import { FORCE_TEARDOWN_MS } from './tui-constants.js'; /** * The Home session state machine, extracted from the ink view as a plain external store (2.5.B / ADR-0054) so the * lifecycle — submit → build → chat → end, the build/turn error paths, the exit/close-race guards, and bracketed - * paste — is unit-testable WITHOUT mounting ink (the repo does not render-test ink). `RootApp` is a thin view that + * paste — is unit-testable without mounting Ink. Installed-RootApp integration tests also use captured native + * streams. `RootApp` is a thin view that * `useSyncExternalStore`s this controller and forwards the single `useInput` to {@link HomeController.handleKey}; * `driveHome` owns the process lifetime and calls {@link HomeController.teardownActive} from its signal handler so * an external SIGINT/SIGTERM reaps a live chat's MCP child + frame loop before the process exits. @@ -298,7 +299,9 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { // process; Up/Down recall, Ctrl+R reverse-searches. Not persisted (a chat-resume starts fresh). let history: InputHistory = EMPTY_HISTORY; let cancelFired = false; - let exiting = false; // set on the clean-exit / error / signal paths — guards deferred reads of a closed db + let exiting = false; // permanent stop seals deferred business results as well as database reads + const isCurrentChat = (active: HomeChatSession): boolean => + !exiting && state.session === active && state.mode === 'chat'; let tearingDown: HomeChatSession | undefined; let activeTeardown: Promise | undefined; // the in-flight teardown of `tearingDown`, so a signal can await it let buildInFlight: Promise | undefined; // a `loading`-state build, so a signal can reap it @@ -458,7 +461,7 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { // about-to-be-cleared session. On success the old session is torn down (bounded, mirroring endChat) and the fresh // one is swapped in, its transcript opening with the clearedNotice naming the prior (resumable) conversation. const clearChat = (old: HomeChatSession): void => { - if (tearingDown === old) return; // already ending this session (a double-/clear race) — mirror endChat + if (exiting || tearingDown === old) return; // permanent stop or a double-/clear race const oldId = old.sessionId; set({ submitBusy: true }); // re-gate input for the whole swap (the old chat is still live underneath) const build = deps.startChat(); @@ -529,6 +532,7 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { // session is torn down (bounded) and the reseated session — the SAME sessionId, resumed under the switched model // with the carried transcript + cost/turns — is swapped in, its transcript opening with the modelSwitchNotice. const reseatChat = (old: HomeChatSession, target: ReseatTarget): void => { + if (exiting) return; const reseat = deps.reseatChat; if (reseat === undefined) return; // acceptModel only calls this when a builder IS wired (defensive) if (tearingDown === old) return; // already ending this session (mirror clearChat) @@ -607,11 +611,12 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { // Gate input for the WHOLE submit — streaming AND any after-turn auto-compaction (ADR-0062), which runs // inside processLine AFTER session:turn_completed flipped the view idle. Without this a message typed then // would reach sendMessage → SessionStateError → crash (the same hazard `shellBusy` fixes for `!`-shell). + if (!isCurrentChat(active)) return; set({ submitBusy: true }); - if (exiting || state.session !== active || state.mode !== 'chat') return; void active.processLine(line, display).then( () => { if (state.session === active) set({ submitBusy: false }); + if (!isCurrentChat(active)) return; if (active.shouldStop()) { // `/clear` (ADR-0062 §7) swaps in a FRESH session, staying in chat; `/exit`/`/cancel` end to the bare Home. if (active.stopReason() === 'clear') clearChat(active); @@ -635,6 +640,7 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { }; const submit = (): void => { + if (exiting) return; const trimmed = state.input.text.trim(); if (trimmed.length === 0) { set({ input: emptyEditor() }); // an empty prompt stays on the Home (no chat) @@ -693,7 +699,7 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { // refresh can never silently wipe a "not available"/"could not save" message the user just triggered. const applyRefreshResult = (epoch: number, report: RefreshReport | undefined): void => { const open = state.modelPicker; - if (epoch !== pickerEpoch || open === undefined || deps.models === undefined) return; // stale/closed — drop it + if (exiting || epoch !== pickerEpoch || open === undefined || deps.models === undefined) return; // stale/closed — drop it const failed = report?.providers.filter((p) => p.status === 'failed').map((p) => p.provider) ?? []; let view: ReturnType; @@ -716,6 +722,7 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { }); }; const runPickerRefresh = (refresh: () => Promise): void => { + if (exiting) return; const epoch = pickerEpoch; // capture THIS picker generation so a reopened picker never adopts this result const open = state.modelPicker; if (open === undefined) return; @@ -732,6 +739,7 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { ); }; const openModelPicker = (): void => { + if (exiting) return; const port = deps.models; // capture the narrowed port so the refresh closure needs no non-null assertion if (port === undefined) { set({ notice: '/models is unavailable here.' }); // defensive — production always wires the port @@ -1127,9 +1135,9 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { // (mirrors acceptMention's session-identity guard). const applyIfCurrent = (candidates: readonly MentionCandidate[]): void => { const open = state.mention; - if (open === undefined) return; // no submode open (narrows `open` for the spread below) - // Drop a stale resolve: a since-descended submode, or (after /exit → new chat) a different session / mode. - if (open.dir !== dir || state.session !== active || state.mode !== 'chat') return; + if (open === undefined || !isCurrentChat(active)) return; + // A matching directory is insufficient after permanent stop or a chat swap. + if (open.dir !== dir) return; set({ mention: { ...open, candidates, loading: false } }); }; void reader.list(dir).then( @@ -1149,8 +1157,7 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { if (reader === undefined) return; const epoch = submitEpoch; // capture: a submit since accept ⇒ the buffer moved on (drop the inject) // Stale if the chat swapped/ended (session/mode) OR the compose buffer was submitted since accept. - const stale = (): boolean => - state.session !== active || state.mode !== 'chat' || submitEpoch !== epoch; + const stale = (): boolean => !isCurrentChat(active) || submitEpoch !== epoch; void reader.read(path).then( ({ content, sizeBytes }) => { if (stale()) return; // the chat ended or the buffer was submitted mid-read — drop it @@ -1190,7 +1197,7 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { mode: ChatMode, epoch: number, ): void => { - if (state.session !== active || state.mode !== 'chat' || submitEpoch !== epoch) return; + if (!isCurrentChat(active) || submitEpoch !== epoch) return; if (outcome.kind === 'ran') { // SHOW the output read-only (a transcript notice) + queue it as a pending COMMAND attachment that rides the // next message (the FULL output is expanded into the UNTRUSTED frame at submit; the preview is bounded). @@ -1221,6 +1228,7 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { ); }; const runShell = (active: HomeChatSession, parsed: ShellCommand): void => { + if (!isCurrentChat(active)) return; const runner = active.runShellCommand; if (runner === undefined) return; const epoch = submitEpoch; @@ -1239,7 +1247,7 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { }, () => { clearBusy(); - if (state.session === active && state.mode === 'chat' && submitEpoch === epoch) { + if (isCurrentChat(active) && submitEpoch === epoch) { active.store.note('! shell command failed unexpectedly'); } }, @@ -1579,9 +1587,10 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { // Bracketed paste is handled natively by ink 7's `usePaste` → `handlePaste` (a channel separate from // `useInput`), so a pasted multi-line block never arrives here char-by-char and a pasted approval token can // never reach the approval reducer. Every real keystroke is dispatched by the open-overlay + mode router. - dispatchKey(input, key); + if (!exiting) dispatchKey(input, key); }, handlePaste(text) { + if (exiting) return; // ink 7 delivers a whole bracketed paste as ONE native event; CRLF/CR → LF (matching a typed newline). // Dropped (not char-inserted) unless the main prompt is the active editable target — `pasteEditable` mirrors // the old bracketed-paste gate and additionally refuses a pending approval (the fail-closed floor, ADR-0057) diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index cd7036b4..dd45cb6c 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -962,7 +962,9 @@ export fault catches. Standalone shell rejection notices and directory-completio results also recheck the live input seal before publication; an already-entered native suspension still drains completely before unmount. Live classified refusals retain their inline warning, and clearing local busy state does not authorize a -new notice. The +new notice. Home applies its permanent exit seal to deferred directory/file results, +shell results and model refresh banners before changing business state. Local busy +cleanup and already-entered producer/native joins still complete after that seal. The [session substrate checkpoint](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-session-substrate-checkpoint--2026-10-16) and [chat/Home checkpoint](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-chat-and-home-integration-checkpoint--2026-10-17) record their exact scopes. Whole Step 8 remains open. @@ -1017,6 +1019,12 @@ review, security and platform gates remain open in the ### Workflow and terminal ownership +Optional terminal effect retention and media GC check the original invocation +remaining budget immediately before new maintenance. At zero they leave rows and +bytes for a later eligible pass. An already-entered media sweep remains joined; +this skips no engine, receipt, input, MCP or native database-close acknowledgement +and never resets the absolute termination deadline. + **Step 8c independently accepted on `development`.** The [acceptance register](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8c-acceptance--2026-10-16) records exact reviewed-source, causal, CI and platform evidence. From c491836a189e59ad7943b7a1ad080bdef5db96d6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 14:59:51 +0300 Subject: [PATCH 152/222] docs(docs): record Home and maintenance review corrections Record complete R20 cumulative coverage, both root-confirmed Medium findings, exact corrected runtime and author/causal evidence. Keep8d and whole8 open pending two new clean complete rounds, new security and corrected-source CI/platforms. Preserve predecessor execution limits and the local Git-ignored review records. Refs: ADR-0110, Wave 3 Step 8d Co-Authored-By: Codex --- AGENTS.md | 31 ++++----- CLAUDE.md | 31 ++++----- docs/roadmap/current.md | 13 ++++ .../phase-2.5.5-hardening-and-remediation.md | 67 +++++++++++++++++++ 4 files changed, 112 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 095b538a..658846ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,21 +107,22 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first nineteen complete cumulative rounds are -NONCLEAN. R19 verifies all 324 paths / 1,754 cumulative hunks at `261bb82d` -and confirms two Medium late-publication gaps: shell refusal notices and directory -completion results after owned stop. `6cc8bc53` rechecks the live input seal before -these results publish; live warnings, local cleanup and actual native closure stay -intact. Four permanent installed-Ink cases cover both live and stopped paths; -removing either guard breaks its precise rendered-output assertion. Fixed/restored -five-file runs pass 27 cases with zero unhandled errors and actual process joins. -Author workspace/build and joined post-build CLI gates pass (357 files / 5,626 -passes / ten established skips, zero unhandled errors), together with static and -compiled smokes. Predecessor source `261bb82d` has four successful same-tarball -consumers; its full CI remains in progress when recorded and does not accept the -correction. Two NEW complete cumulative clean rounds, a NEW separate clean -security sitting and fresh corrected-source full CI plus all four same-artifact -consumers remain required. Steps 9–19 have not started; no user decision is pending. +but not independently accepted. Its first twenty complete cumulative rounds are +NONCLEAN. R20 verifies all 325 paths / 1,759 cumulative hunks at `4eee7c68` +and confirms two Medium gaps: deferred Home business results after permanent stop +and new optional workflow maintenance at an expired original budget. `50d7309f` +seals Home results and gates run/gate/budget effect retention and media GC on the +same absolute budget, preserving live behaviour and all actual entered/native joins. +Six installed-Home cases, ten controller cases and actual run/gate/budget controls +pin the correction. Two Home guard removals and five independent budget guard or +forwarding removals fail precise assertions; every restoration passes. Applied +workspace/build and joined post-build CLI gates pass (360 files / 5,649 passes / +ten established skips, zero unhandled errors), together with static and compiled +smokes. Predecessor `4eee7c68` has four successful same-artifact consumers; its +full CI is still in progress when recorded and does not accept this correction. +Two NEW complete cumulative clean rounds, a NEW separate clean security sitting +and fresh corrected-source full CI plus all four same-artifact consumers remain +required. Steps 9–19 have not started; no user decision is pending. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index af5b77b8..e1d1f2da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,21 +196,22 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first nineteen complete cumulative rounds are -NONCLEAN. R19 verifies all 324 paths / 1,754 cumulative hunks at `261bb82d` -and confirms two Medium late-publication gaps: shell refusal notices and directory -completion results after owned stop. `6cc8bc53` rechecks the live input seal before -these results publish; live warnings, local cleanup and actual native closure stay -intact. Four permanent installed-Ink cases cover both live and stopped paths; -removing either guard breaks its precise rendered-output assertion. Fixed/restored -five-file runs pass 27 cases with zero unhandled errors and actual process joins. -Author workspace/build and joined post-build CLI gates pass (357 files / 5,626 -passes / ten established skips, zero unhandled errors), together with static and -compiled smokes. Predecessor source `261bb82d` has four successful same-tarball -consumers; its full CI remains in progress when recorded and does not accept the -correction. Two NEW complete cumulative clean rounds, a NEW separate clean -security sitting and fresh corrected-source full CI plus all four same-artifact -consumers remain required. Steps 9–19 have not started; no user decision is pending. +but not independently accepted. Its first twenty complete cumulative rounds are +NONCLEAN. R20 verifies all 325 paths / 1,759 cumulative hunks at `4eee7c68` +and confirms two Medium gaps: deferred Home business results after permanent stop +and new optional workflow maintenance at an expired original budget. `50d7309f` +seals Home results and gates run/gate/budget effect retention and media GC on the +same absolute budget, preserving live behaviour and all actual entered/native joins. +Six installed-Home cases, ten controller cases and actual run/gate/budget controls +pin the correction. Two Home guard removals and five independent budget guard or +forwarding removals fail precise assertions; every restoration passes. Applied +workspace/build and joined post-build CLI gates pass (360 files / 5,649 passes / +ten established skips, zero unhandled errors), together with static and compiled +smokes. Predecessor `4eee7c68` has four successful same-artifact consumers; its +full CI is still in progress when recorded and does not accept this correction. +Two NEW complete cumulative clean rounds, a NEW separate clean security sitting +and fresh corrected-source full CI plus all four same-artifact consumers remain +required. Steps 9–19 have not started; no user decision is pending. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 7ad6b29b..05d8df47 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1333,6 +1333,19 @@ unhandled errors), with static and compiled smokes. R1–R19 remain NONCLEAN; tw NEW complete cumulative clean rounds, NEW separate security and corrected-source full CI/platforms remain required. Steps 9–19 have not started; no user decision is pending. +The [twentieth cumulative review and stopped-result/maintenance corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-twentieth-cumulative-review-and-stopped-resultmaintenance-corrections--2026-10-17) +records complete NONCLEAN coverage of 325 paths / 1,759 rehashed hunks at `4eee7c68`. +Two Medium findings are corrected at `50d7309f`: Home drops deferred business +results after permanent stop, and optional workflow effect retention/media GC +refuse fresh entry at the original zero remaining budget. Actual native and +already-entered work joins stay owed. Six installed-Home, ten controller and +run/gate/budget live/expired controls pass; two Home and five budget mechanism +removals fail their precise assertions and every restoration passes. Author +workspace/build and joined post-build CLI gates pass (360 files / 5,649 passes / +ten established skips, zero unhandled errors), with static/compiled smokes. +R1–R20 remain NONCLEAN; two NEW complete cumulative clean rounds, NEW separate +security and fresh corrected-source full CI/platforms remain required. Steps 9–19 +have not started; no user decision is pending. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index a0c3db4e..1806c3e9 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -7919,3 +7919,70 @@ verdicts. Two NEW complete cumulative CLEAN rounds after the last material fix, NEW separate clean security and fresh corrected-source full CI plus all four installed consumers remain required. Steps 9–19 have not started. No credential, provider capture, billing, production recipient or user decision is pending. + + +### Wave 3 Step 8d twentieth cumulative review and stopped-result/maintenance corrections — 2026-10-17 + +**R20 is complete and NONCLEAN; Step 8d and whole Step 8 remain open.** Seven NEW +independent slices cover cumulative `d9625ff0` → `4eee7c68`: **325 paths / 1,759 +hunks**. The base is Step 7 installed-package acceptance, not the Step 8c boundary. +Root reads all original reports and every substantive assessment, then verifies +physical/Git bodies, complete diffs and ordered hunks. Opening/closing freezes +match all 1,754 tracked files, 21,165 dependency files, 1,215 lexical links and +466 dist files. Aggregate SHA-256 is +`07261862e1830f0bf60fa31e5282536109d660691e2f5409971cdf825c52ee5a`; +root closure is `5740cb79de2f3253cec556bd68aaa943ad2c8dee576189e74002b84cd3b3fd01`. +Original reviews, a byte-identical workflow-ledger filename alias, append-only +caller-path metadata correction, qualified document reads and runtime receipts +remain local and Git-ignored. Index blob/mode membership is unchanged; a Git +stat-cache byte hash is not a staged-content identity. + +**Medium `R20-HOME-1` is corrected at `50d7309f`.** Deferred mention/file/shell +results and model-refresh banners could mutate Home business state after its +permanent stop, while entered native suspension still kept the view mounted. +The monotonic exit seal now joins session identity/epoch checks before those +publications. Live classified notices, local busy cleanup and actual producer, +suspension and native restoration joins remain intact. No new credential, +provider, database-authority or accounting bypass is claimed. Existing clear/end +latches already prevented a demonstrated new host; clear/rebuild controls preserve +that behaviour. The [canonical session contract](../../reference/cli/commands.md#session-ownership-implementation-checkpoint) +owns publication. Six actual installed-Home/RootApp cases and ten controller cases +cover live/stopped results. Two separate Home guard removals produce five and two +precise failures; fixed/restored 24-file runs pass **424 cases** with no unhandled +errors and actual Node/outer execution joins. Synthetic streams are not operator TTYs. + +**Medium `WFA-R20-01` is corrected in the same commit.** Actual subscribed-signal, +engine, SQLite and default CAS probes show new media GC can enter at remaining +budget zero before queued physical exit. Root independently reproduces it; no data +loss or delayed physical deadline is inferred. The same original budget now guards +new effect retention and shared media-GC entry for run, gate and budget resume. +Already-entered sweeps remain joined; no deadline is renewed and no engine, +receipt, input, MCP, database or terminal acknowledgement is skipped. The +[canonical workflow contract](../../reference/cli/commands.md#workflow-and-terminal-ownership) +owns this rule. Permanent actual run/gate/budget live/expired journeys preserve +unresolved rows and distinguish DB/raw-sweep closure from still-owed expired +terminal restoration. Five independent guard/forwarding removals cause four, +one, two, one and two precise failures; each restored two-file run passes all +**28 cases**, with explicit zero-unhandled and actual Node/outer joins. Initial +author fixture/anchor corrections are retained honestly; no product assertion, +readiness bound or native/test watchdog is weakened. No new dependency or public +engine/provider seam is introduced. + +Applied author workspace lint/typecheck/test and build pass; cached dependency +tasks retain that qualification. A separate post-build complete CLI run passes +**360 files / 5,649 cases / ten established skips**, explicit zero unhandled errors, +actual Node exit 0 and outer execution exit 0. Format, isolation (739 files), seam +and engine-dependency checks pass. Compiled closure verifies 25 outputs / 410 inputs / +17 externals; local bootstrap passes 177 controls and compiled CLI smoke passes. +These are author checks, not independent acceptance; Markdown is outside Prettier. + +At reviewed predecessor `4eee7c68`, all four same-artifact consumers, coverage and +Windows advisory pass in [CI run 39028149301](https://github.com/HodeTech/Relavium/actions/runs/39028149301). +Full CI remains in progress when recorded. Root independently inspects five ZIPs, +70 public files and the 446,626-byte tarball, SHA-256 +`31ff784e2b81aef980187bd3609905f66eeef9cc7ac6ff01ad56b5a0eb7122ab`. +Those results do not execute or accept the correction. R1–R20 retain NONCLEAN +verdicts. Two NEW complete cumulative CLEAN rounds after the last material fix, +NEW separate clean security and fresh corrected-source full CI plus all four +same-artifact consumers remain required. Steps 9–19 have not started. No user, +credential, provider capture, billing or production-recipient decision is pending. From e7fe93c52bee7c190b937be5386011bf7a724748 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 16:33:22 +0300 Subject: [PATCH 153/222] fix(llm): retain discovery through actual producer settlement Transfer existing invocation-local ownership through bounded model lists, SDK pagination and controlled fetch descendants. Forward the same owner from management, Home/chat refresh and key validation. Preserve the public timeout and keep database retirement behind actual entered work. Add 29 permanent SDK/SQLite and boundary regressions. Nine independent removals fail their semantic witnesses and every restoration passes with zero unhandled errors. Workspace, build, isolation and compiled gates pass. Record R21 clean/R22 nonclean scope without accepting Step 8d or inheriting predecessor security, CI or platform results for this correction. Refs: ADR-0064, ADR-0103, ADR-0110 Co-Authored-By: Codex --- AGENTS.md | 30 +- CLAUDE.md | 30 +- apps/cli/src/commands/dispatch.ts | 6 +- .../commands/model-discovery-lifetime.test.ts | 283 ++++++++++++++++++ apps/cli/src/engine/model-catalog-port.ts | 1 + apps/cli/src/engine/model-refresh.ts | 37 ++- apps/cli/src/engine/providers.ts | 19 +- docs/decisions/0064-live-model-catalog.md | 13 + ...d-discovery-have-explicit-compatibility.md | 15 + docs/reference/cli/commands.md | 9 + .../shared-core/llm-provider-seam.md | 34 ++- docs/roadmap/current.md | 10 + .../phase-2.5.5-hardening-and-remediation.md | 78 +++++ packages/llm/src/adapters/anthropic.ts | 30 +- .../bounded-list-models-lifetime.test.ts | 215 +++++++++++++ packages/llm/src/adapters/gemini.ts | 64 ++-- .../src/adapters/list-models-lifetime.test.ts | 208 +++++++++++++ packages/llm/src/adapters/openai.ts | 14 +- packages/llm/src/adapters/shared.ts | 101 ++++--- packages/llm/src/types.ts | 6 +- 20 files changed, 1086 insertions(+), 117 deletions(-) create mode 100644 apps/cli/src/commands/model-discovery-lifetime.test.ts create mode 100644 packages/llm/src/adapters/bounded-list-models-lifetime.test.ts create mode 100644 packages/llm/src/adapters/list-models-lifetime.test.ts diff --git a/AGENTS.md b/AGENTS.md index 658846ed..13203c3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,22 +107,20 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first twenty complete cumulative rounds are -NONCLEAN. R20 verifies all 325 paths / 1,759 cumulative hunks at `4eee7c68` -and confirms two Medium gaps: deferred Home business results after permanent stop -and new optional workflow maintenance at an expired original budget. `50d7309f` -seals Home results and gates run/gate/budget effect retention and media GC on the -same absolute budget, preserving live behaviour and all actual entered/native joins. -Six installed-Home cases, ten controller cases and actual run/gate/budget controls -pin the correction. Two Home guard removals and five independent budget guard or -forwarding removals fail precise assertions; every restoration passes. Applied -workspace/build and joined post-build CLI gates pass (360 files / 5,649 passes / -ten established skips, zero unhandled errors), together with static and compiled -smokes. Predecessor `4eee7c68` has four successful same-artifact consumers; its -full CI is still in progress when recorded and does not accept this correction. -Two NEW complete cumulative clean rounds, a NEW separate clean security sitting -and fresh corrected-source full CI plus all four same-artifact consumers remain -required. Steps 9–19 have not started; no user decision is pending. +but not independently accepted. R1–R20 remain NONCLEAN. R21 is complete CLEAN at +`c491836a` (330 paths / 1,789 cumulative hunks); R22 covers the same complete +source and confirms one Medium gap: bounded model discovery can release its +original owner before an abort-ignoring SDK collector settles. Root independently +reproduces management/SQLite early release and a healthy completed-transport +control. The correction forwards existing invocation-local work through model +refresh, all three discovery dialects and key validation, preserving the public +timeout and actual entered/native joins. No new decision or approval is needed. +R21 and the separate clean security sitting, successful full CI and all four +physically inspected same-artifact consumers remain attributed to `c491836a`; +they do not accept the later correction. Two NEW complete cumulative clean rounds, +a NEW separate clean security sitting and fresh corrected-source full CI plus all +four same-artifact consumers remain required. Steps 9–19 have not started; no +user decision is pending. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index e1d1f2da..1d3cb3c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,22 +196,20 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. Its first twenty complete cumulative rounds are -NONCLEAN. R20 verifies all 325 paths / 1,759 cumulative hunks at `4eee7c68` -and confirms two Medium gaps: deferred Home business results after permanent stop -and new optional workflow maintenance at an expired original budget. `50d7309f` -seals Home results and gates run/gate/budget effect retention and media GC on the -same absolute budget, preserving live behaviour and all actual entered/native joins. -Six installed-Home cases, ten controller cases and actual run/gate/budget controls -pin the correction. Two Home guard removals and five independent budget guard or -forwarding removals fail precise assertions; every restoration passes. Applied -workspace/build and joined post-build CLI gates pass (360 files / 5,649 passes / -ten established skips, zero unhandled errors), together with static and compiled -smokes. Predecessor `4eee7c68` has four successful same-artifact consumers; its -full CI is still in progress when recorded and does not accept this correction. -Two NEW complete cumulative clean rounds, a NEW separate clean security sitting -and fresh corrected-source full CI plus all four same-artifact consumers remain -required. Steps 9–19 have not started; no user decision is pending. +but not independently accepted. R1–R20 remain NONCLEAN. R21 is complete CLEAN at +`c491836a` (330 paths / 1,789 cumulative hunks); R22 covers the same complete +source and confirms one Medium gap: bounded model discovery can release its +original owner before an abort-ignoring SDK collector settles. Root independently +reproduces management/SQLite early release and a healthy completed-transport +control. The correction forwards existing invocation-local work through model +refresh, all three discovery dialects and key validation, preserving the public +timeout and actual entered/native joins. No new decision or approval is needed. +R21 and the separate clean security sitting, successful full CI and all four +physically inspected same-artifact consumers remain attributed to `c491836a`; +they do not accept the later correction. Two NEW complete cumulative clean rounds, +a NEW separate clean security sitting and fresh corrected-source full CI plus all +four same-artifact consumers remain required. Steps 9–19 have not started; no +user decision is pending. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/apps/cli/src/commands/dispatch.ts b/apps/cli/src/commands/dispatch.ts index 1ca84c7b..4c8c3ca7 100644 --- a/apps/cli/src/commands/dispatch.ts +++ b/apps/cli/src/commands/dispatch.ts @@ -608,7 +608,11 @@ async function withModelsDepsOwned( const service = createModelRefreshService({ ...(owner === undefined ? {} - : { assertAdmitting: owner.assertAdmitting, onHostFailure: owner.failure }), + : { + assertAdmitting: owner.assertAdmitting, + onHostFailure: owner.failure, + invocationOptions: owner.work, + }), resolveProvider: resolver.resolveProvider, keyFor: resolver.keyFor, providerStore, diff --git a/apps/cli/src/commands/model-discovery-lifetime.test.ts b/apps/cli/src/commands/model-discovery-lifetime.test.ts new file mode 100644 index 00000000..fc5d5279 --- /dev/null +++ b/apps/cli/src/commands/model-discovery-lifetime.test.ts @@ -0,0 +1,283 @@ +import { + createClient, + runMigrations, + createModelCatalogStore, + createProviderStore, +} from '@relavium/db'; +import { describe, expect, it, vi } from 'vitest'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { SessionWorkOwner } from '../chat/session-work-owner.js'; +import { SessionHomeLifetime } from '../home/session-home-lifetime.js'; +import { captureIo } from '../test-support.js'; +import { + createProviderResolver, + validateProviderKey, + KNOWN_PROVIDERS, +} from '../engine/providers.js'; +import { createModelRefreshService } from '../engine/model-refresh.js'; +import type { FetchLike } from '../engine/validated-fetch.js'; +import { createModelCatalogPort } from '../engine/model-catalog-port.js'; +import { withModelsDeps } from './dispatch.js'; + +function deferred() { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} +const reactions = () => new Promise((resolve) => setImmediate(resolve)); +const listed = () => + new Response(JSON.stringify({ object: 'list', data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +function providers(fetch: FetchLike) { + return createProviderResolver({ RELAVIUM_OPENAI_API_KEY: 'synthetic-discovery-key' }, undefined, { + providerStore: { + list: () => [ + { + id: 'synthetic-custom-openai', + name: 'openai', + displayName: 'Synthetic discovery', + baseUrl: 'https://review.example/v1', + defaultHeaders: {}, + isActive: true, + createdAt: '2026-10-17T00:00:00.000Z', + updatedAt: '2026-10-17T00:00:00.000Z', + }, + ], + }, + validatedFetch: fetch, + }); +} +const global = { + cwd: process.cwd(), + json: true, + color: false, + configPath: undefined, + verbosity: 'normal', +} as const; + +describe('actual SDK discovery original-owner integration', () => { + it('observes an opaque host transfer refusal before per-provider normalization without SDK entry', async () => { + const client = createClient(':memory:'); + runMigrations(client.db); + let reflections = 0; + const cause = new Proxy(new Error('private original-owner refusal'), { + get() { + reflections += 1; + throw new Error('reflection'); + }, + getPrototypeOf() { + reflections += 1; + throw new Error('reflection'); + }, + }); + const fetch = vi.fn(() => Promise.resolve(listed())); + const resolver = providers(fetch); + const observed = vi.fn(); + const storeDeps = { now: () => 0, uuid: () => 'synthetic-discovery' }; + const service = createModelRefreshService({ + invocationOptions: { + retainWork: () => { + throw cause; + }, + }, + onHostFailure: observed, + resolveProvider: resolver.resolveProvider, + keyFor: resolver.keyFor, + providerStore: createProviderStore(client.db, storeDeps), + catalogStore: createModelCatalogStore(client.db, storeDeps), + knownProviderIds: ['openai'], + knownProviders: KNOWN_PROVIDERS, + now: () => 0, + }); + try { + expect(await service.refresh()).toEqual({ + providers: [{ provider: 'openai', status: 'failed', error: 'refresh failed' }], + }); + expect(observed).toHaveBeenCalledOnce(); + expect(Object.is(observed.mock.calls[0]?.[0], cause)).toBe(true); + expect(reflections).toBe(0); + expect(fetch).not.toHaveBeenCalled(); + expect(client.sqlite.prepare('SELECT COUNT(*) AS count FROM llm_providers').get()).toEqual({ + count: 0, + }); + } finally { + client.sqlite.close(); + } + }); + + it.each(['timeout', 'success'] as const)( + 'management keeps SQLite until actual SDK transport settles after %s', + async (end) => { + vi.useFakeTimers(); + const f = invocationFixture(); + const client = createClient(':memory:'); + runMigrations(client.db); + const response = deferred(); + const entered = deferred(); + const captured = captureIo(); + const resolver = providers(() => { + entered.resolve(); + return response.promise; + }); + const close = vi.fn(() => client.sqlite.close()); + let settled = false; + const command = withModelsDeps( + { io: captured.io, global, invocation: f.invocation }, + { refresh: true, axis: 'providers' }, + { + openDb: () => ({ db: client.db, close }), + makeResolver: () => resolver, + refreshCatalog: () => Promise.reject(new Error('unused catalog axis')), + }, + ).then((value) => { + settled = true; + return value; + }); + try { + await entered.promise; + if (end === 'timeout') { + await vi.advanceTimersByTimeAsync(15_000); + expect(captured.out()).toContain('model list timed out after 15000ms'); + expect(settled, 'MANAGEMENT_BOUNDED_REPORT_DOES_NOT_RETIRE_RAW_SDK').toBe(false); + expect(client.sqlite.open).toBe(true); + expect(close).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + } + response.resolve(listed()); + expect(await command).toBe(0); + expect(close).toHaveBeenCalledOnce(); + expect(client.sqlite.open).toBe(false); + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); + } finally { + response.resolve(listed()); + await command; + if (client.sqlite.open) client.sqlite.close(); + vi.useRealTimers(); + } + }, + ); + + it.each(['refresh', 'refreshIfStale'] as const)( + 'shared Home/chat %s retains timed-out discovery before the shared DB closes', + async (operation) => { + vi.useFakeTimers(); + const f = invocationFixture(); + const client = createClient(':memory:'); + runMigrations(client.db); + const home = new SessionHomeLifetime(f.invocation, () => () => undefined); + const close = vi.fn(() => client.sqlite.close()); + home.adoptStore({ close }); + const response = deferred(); + const entered = deferred(); + const port = createModelCatalogPort({ + db: client.db, + providers: providers(() => { + entered.resolve(); + return response.promise; + }), + now: () => 0, + uuid: () => 'synthetic-catalog', + workOwner: home.surface.work, + }); + let reported = false; + let settled = false; + const drive = home + .execute(async () => { + const result = await port[operation](); + expect(result?.providers.find((row) => row.provider === 'openai')?.status).toBe('failed'); + reported = true; + return 0; + }) + .then((value) => { + settled = true; + return value; + }); + try { + await entered.promise; + await vi.advanceTimersByTimeAsync(15_000); + expect(reported, 'PICKER_DISCOVERY_REPORT_REMAINS_BOUNDED').toBe(true); + expect(settled, 'PICKER_DISCOVERY_TAIL_PRECEDES_DATABASE_CLOSE').toBe(false); + expect(client.sqlite.open).toBe(true); + expect(close).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + response.resolve(listed()); + expect(await drive).toBe(0); + expect(close).toHaveBeenCalledOnce(); + expect(client.sqlite.open).toBe(false); + expect(f.registry.activeOwners).toBe(0); + } finally { + response.resolve(listed()); + await drive; + if (client.sqlite.open) client.sqlite.close(); + vi.useRealTimers(); + } + }, + ); + + it('key validation retains transitive native close after its actual SDK response succeeds', async () => { + const f = invocationFixture(); + const cleanup = vi.fn(() => Promise.resolve()); + const owner = new SessionWorkOwner(f.invocation, 'home', { cancel: () => undefined, cleanup }); + const nativeClose = deferred(); + let transferred = false; + const resolver = providers((_input, _init, work) => { + if (work?.retainWork !== undefined) { + transferred = true; + void work.retainWork(() => nativeClose.promise); + } + return Promise.resolve( + new Response( + JSON.stringify({ + id: 'synthetic-validation', + object: 'chat.completion', + created: 0, + model: 'gpt-4o-mini', + choices: [ + { index: 0, finish_reason: 'stop', message: { role: 'assistant', content: 'ok' } }, + ], + usage: { + prompt_tokens: 1, + completion_tokens: 1, + total_tokens: 2, + prompt_tokens_details: { cached_tokens: 0 }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + }); + const provider = resolver.resolveProvider('openai'); + if (provider === undefined) throw new Error('missing synthetic provider'); + let closing: Promise | undefined; + try { + expect( + await validateProviderKey(provider, 'synthetic-discovery-key', 'gpt-4o-mini', 10_000, { + signal: owner.signal, + work: owner.work, + assertAdmitting: owner.assertAdmitting, + }), + ).toMatchObject({ ok: true }); + let retired = false; + closing = owner.close().then(() => { + retired = true; + }); + await reactions(); + expect(transferred, 'KEY_PROBE_FORWARDS_INVOCATION_DESCENDANTS').toBe(true); + expect(retired, 'KEY_PROBE_SUCCESS_DOES_NOT_ACK_NATIVE_CLOSE').toBe(false); + expect(cleanup).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); + nativeClose.resolve(); + await closing; + expect(cleanup).toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(0); + } finally { + nativeClose.resolve(); + await closing; + } + }); +}); diff --git a/apps/cli/src/engine/model-catalog-port.ts b/apps/cli/src/engine/model-catalog-port.ts index bf687209..3637bb0a 100644 --- a/apps/cli/src/engine/model-catalog-port.ts +++ b/apps/cli/src/engine/model-catalog-port.ts @@ -50,6 +50,7 @@ export function createModelCatalogPort(params: { : { assertAdmitting: params.workOwner.assertAdmitting, onHostFailure: params.workOwner.failure, + invocationOptions: params.workOwner.work, }), }); // Memoized: a keychain read is a SYNC native N-API call (and can pop an OS ACL prompt on first access), so it must diff --git a/apps/cli/src/engine/model-refresh.ts b/apps/cli/src/engine/model-refresh.ts index cb1e5317..c7dd51cd 100644 --- a/apps/cli/src/engine/model-refresh.ts +++ b/apps/cli/src/engine/model-refresh.ts @@ -1,6 +1,12 @@ import type { ModelCatalogLiveModel, ReplaceProviderModelsResult } from '@relavium/db'; import { byCodeUnit } from '@relavium/shared'; -import type { AbortSignalLike, LlmProvider, ModelListing, ProviderId } from '@relavium/llm'; +import type { + AbortSignalLike, + LlmInvocationOptions, + LlmProvider, + ModelListing, + ProviderId, +} from '@relavium/llm'; import { keyHint } from './providers.js'; @@ -89,6 +95,8 @@ export interface RefreshCatalogStore { } export interface ModelRefreshDeps { + /** Original invocation's transitive SDK/transport work; never part of catalog data. */ + readonly invocationOptions?: LlmInvocationOptions; /** Original long-lived session admission; recheck after discovery before any shared-database write. */ readonly assertAdmitting?: () => void; /** Observe local clock/key/store faults before sibling discovery can hold the refresh result. */ @@ -219,7 +227,32 @@ export function createModelRefreshService(deps: ModelRefreshDeps): ModelRefreshS return { provider: id, status: 'skipped-unsupported' }; } // The key reaches ONLY this call; the seam bounds + aborts + redacts it. - const listings = await adapter.listModels(key, signal); + let transferFailure: { readonly cause: unknown } | undefined; + const invocationOptions = deps.invocationOptions; + const ownedOptions: LlmInvocationOptions | undefined = + invocationOptions === undefined + ? undefined + : { + retainWork: (factory) => { + try { + return invocationOptions.retainWork(factory); + } catch (cause) { + transferFailure = { cause }; + deps.onHostFailure?.(cause); + throw cause; + } + }, + }; + let listings: ModelListing[]; + try { + listings = await adapter.listModels(key, signal, ownedOptions); + } catch (cause) { + // A host-entry refusal is not a vendor diagnostic. Its original owner + // observes it above; do not inspect or render the opaque throwable. + if (transferFailure !== undefined && Object.is(cause, transferFailure.cause)) + return { provider: id, status: 'failed', error: 'refresh failed' }; + throw cause; + } deps.assertAdmitting?.(); // Ensure the FK-target `llm_providers` row exists BEFORE writing model rows (FK ordering). The metadata is diff --git a/apps/cli/src/engine/providers.ts b/apps/cli/src/engine/providers.ts index 9773baee..ce7b3ce5 100644 --- a/apps/cli/src/engine/providers.ts +++ b/apps/cli/src/engine/providers.ts @@ -8,6 +8,7 @@ import { type EndpointKind, LlmProviderError, type LlmProvider, + type LlmRequest, type ProviderId, } from '@relavium/llm'; import type { Agent } from '@relavium/shared'; @@ -250,15 +251,15 @@ export async function validateProviderKey( const probeFactory = async (): Promise => { lifecycle?.assertAdmitting(); try { - await provider.generate( - { - model, - messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }], - maxTokens: 1, - signal: controller.signal, - }, - key, - ); + const request: LlmRequest = { + model, + messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }], + maxTokens: 1, + signal: controller.signal, + }; + await (lifecycle === undefined + ? provider.generate(request, key) + : provider.generate(request, key, lifecycle.work)); return { ok: true, detail: `key works (${model})`, reason: 'ok' }; } catch (err) { const raw = err instanceof Error ? err.message : String(err); diff --git a/docs/decisions/0064-live-model-catalog.md b/docs/decisions/0064-live-model-catalog.md index 179cca90..c335b767 100644 --- a/docs/decisions/0064-live-model-catalog.md +++ b/docs/decisions/0064-live-model-catalog.md @@ -363,3 +363,16 @@ Usage pending and disposition writes use the existing bounded whole-transaction See [ADR-0104](0104-unknown-provider-usage-is-durable-cost-uncertainty.md) and the [canonical usage contract](../reference/shared-core/llm-provider-seam.md#6-usage). + +### Wave 3 discovery ownership clarification — 2026-10-17 + +The public discovery timeout in §3 remains bounded, abortable and secret-free. +It is not acknowledgement that an abort-ignoring SDK/transport has actually +settled. Wave 3's original-owner correction forwards the existing invocation-local +work authority through discovery and key-validation callers; actual retirement +can remain pending after a bounded report is available. This implements +[ADR-0110](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md)'s +already accepted actual-join rule without changing discovery, pricing, retry or +authorization policy. The additive signature, dialect boundaries and opaque +Gemini/native-close limitation belong in the +[canonical discovery contract](../reference/shared-core/llm-provider-seam.md#model-discovery--the-listmodels-capability-adr-0064). diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index 2c3906a0..14ff2c8a 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -301,3 +301,18 @@ an earlier successful request or later closure. The installed control distinguis that narrowly evidenced failure outcome from ordinary turn success while retaining all actual-lifetime and cleanup obligations. Earlier failed runs stay failed; fresh independent correctness/security/platform acceptance remains open. + +## Discovery ownership correction — 2026-10-17 + +The complete cumulative Step 8d R22 review confirmed that a bounded model-list +result could retire its original command owner before an abort-ignoring SDK +collector settled. A supported offline fetch, the actual installed SDK and +SQLite reproduce that gap; no late database write or real remote transport +failure is inferred. Forwarding the existing invocation-local work authority +through discovery and key-validation callers implements this decision's +actual-join rule. It neither replaces ADR-0064's public timeout nor introduces +another termination budget. Concrete signatures and transport limits remain in +the [canonical discovery contract](../reference/shared-core/llm-provider-seam.md#model-discovery--the-listmodels-capability-adr-0064). +Step 8d and whole Step 8 remain open until the corrected source has two new +complete cumulative clean reviews, a new separate security sitting and fresh +full CI plus all four same-artifact package consumers. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index dd45cb6c..f543d9cf 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -874,6 +874,15 @@ otherwise alternate-screen restoration can erase it. Restoration control bytes u the existing terminal control sink, not diagnostic stdout. Keep host/DB close behind actual joins; fatal process termination does not grant safe store close. +Model discovery preserves the distinction between its bounded public report and +actual SDK/transport completion. The management refresh and shared Home/chat +catalog port transfer their original work authority through the +[provider seam](../shared-core/llm-provider-seam.md#model-discovery--the-listmodels-capability-adr-0064), +as does the key-validation probe. A completed or timed-out report cannot release +the database or signal custody while an admitted descendant remains pending. +Normal cleanup may still wait after reporting a timeout; no new deadline or +safe-close acknowledgement is invented. + The fatal deadline is three seconds from the first infrastructure fault, including join, native helper/reap, restore and flush. Every callback/helper receives only the remaining common monotonic budget; no serial per-owner renewal. An already earlier diff --git a/docs/reference/shared-core/llm-provider-seam.md b/docs/reference/shared-core/llm-provider-seam.md index b4f4f133..2ac78921 100644 --- a/docs/reference/shared-core/llm-provider-seam.md +++ b/docs/reference/shared-core/llm-provider-seam.md @@ -266,7 +266,7 @@ interface LlmProvider { // Relavium ModelListing (no vendor models.list() type crosses). OPTIONAL (a provider without a list endpoint // omits it → host degrades to static-only). Bounded + abortable + secret-free; one bad row is dropped, a // breaking endpoint change throws a classified, key-redacted LlmProviderError. See "Model discovery" below. - listModels?(key: string, signal?: AbortSignalLike): Promise; + listModels?(key: string, signal?: AbortSignalLike, options?: LlmInvocationOptions): Promise; } // The per-modality capability matrix (ADR-0031 decision #3). Input composability is unconstrained @@ -1225,7 +1225,7 @@ acceptance remain required; implementation of this contract alone does not close ### Model discovery — the `listModels?` capability ([ADR-0064](../../decisions/0064-live-model-catalog.md)) -`listModels?(key, signal?): Promise` is an **optional, capability-varying** method (the same +`listModels?(key, signal?, options?): Promise` is an **optional, capability-varying** method (the same pattern as `generateMedia?` / `contextLimit?`): a provider without a live list endpoint omits it and the host degrades to the generated catalog snapshot ([catalog/snapshot.ts](../../../packages/llm/src/catalog/snapshot.ts)) for that provider. It returns **live discovery** — which model ids a given `key` can actually reach (tier/allowlist-gated). The live @@ -1262,6 +1262,36 @@ that yields no `id` is **dropped** at the mapper boundary, never throwing (one m model, not the whole provider); a breaking endpoint/shape change throws, which the host's per-provider refresh isolation catches. A per-provider filter keeps only **chat-capable text models**. +**Original-invocation lifetime.** The optional third argument reuses +`LlmInvocationOptions.retainWork`; existing two-argument implementations remain +assignable. It is trusted process-local authority, never request/catalog data, +and discovery invokes neither paid-invocation nor native-repair callbacks. +The shared bounded helper transfers an aggregate before SDK construction and +retains its exact collection promise. The existing public timeout stays bounded; +success, timeout or cancellation retires future SDK entry without acknowledging +already-entered work. Pagination reads and iterator cleanup retain their own +actual completion. + +OpenAI-compatible (including DeepSeek and custom endpoints) and Anthropic clients +bind the same aggregate into their fetch closures. An explicitly transferred +transport/native-close child can outlive the SDK response and still holds the +original owner. Gemini forwards the same authority into its transport; its +default SDK list operation and pager are retained. The installed Gemini SDK has +no supported per-client fetch hook for this API: these observable SDK operations +do not certify opaque native undici socket-close acknowledgement or descendants +an arbitrary foreign implementation fails to transfer. No global fetch override +is installed by the product. + +Management and the shared Home/chat catalog port pass their original owner to +every explicit/TTL refresh, separately retaining mapping and cache publication. +The key-validation probe likewise forwards its existing work authority to +`generate`. A bounded report may therefore be available while normal process +retirement still waits for actual work. Original signal/fatal deadlines are not +renewed, expiry grants no database-close permission, and post-stop discovery +cannot publish fresh catalog rows. Calls without an owner retain their existing +bounded API. Ignoring the optional argument supplies no hidden-work completion +guarantee for a foreign implementation. + Per-provider list-models endpoint contracts: | Provider (`kind`) | Endpoint | Shape | Mapping + filter | diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 05d8df47..9064cd87 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1346,6 +1346,16 @@ ten established skips, zero unhandled errors), with static/compiled smokes. R1–R20 remain NONCLEAN; two NEW complete cumulative clean rounds, NEW separate security and fresh corrected-source full CI/platforms remain required. Steps 9–19 have not started; no user decision is pending. +The [R21–R22 discovery ownership checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-r21r22-discovery-ownership-correction--2026-10-17) +records complete coverage of 330 paths / 1,789 cumulative hunks at `c491836a`. +R21 is CLEAN; R22 confirms one Medium discovery-ownership gap, independently +reproduced with actual installed SDK/SQLite and a held synthetic transport. +The correction uses existing invocation-local work, preserving bounded reports, +actual cleanup joins and all existing deadlines. The separate clean security +sitting, full CI and all four physically inspected package consumers belong to +`c491836a`; none accepts the later correction. Two NEW complete cumulative clean +rounds, NEW separate security and corrected-source full CI/platforms remain +required. Steps 9–19 have not started; no user decision is pending. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 1806c3e9..dd061062 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -7986,3 +7986,81 @@ verdicts. Two NEW complete cumulative CLEAN rounds after the last material fix, NEW separate clean security and fresh corrected-source full CI plus all four same-artifact consumers remain required. Steps 9–19 have not started. No user, credential, provider capture, billing or production-recipient decision is pending. + + +### Wave 3 Step 8d R21–R22 discovery ownership correction — 2026-10-17 + +**Step 8d and whole Step 8 remain OPEN.** The reviewed source is `c491836a`, +from cumulative Step 7 base `d9625ff0`; R21 and R22 each cover all **330 paths / +1,789 ordered cumulative hunks** across seven disjoint semantic slices. Root +reads all original reports and every per-path substantive assessment, rechecks +physical/Git/full-diff/hunk identities and closes each full source/dependency/dist +freeze. Model/effort records are parent-dispatch attribution, not independent +backend attestation. R21 is CLEAN. Its six explicit workflow test-description +errata are independently compared against all six full current test bodies; the +original overbroad descriptions supply no evidence. R22 is NONCLEAN: one Medium +**MS22-1**, bounded model-discovery result versus actual SDK/transport retirement. + +The management command's actual resolver, custom OpenAI adapter, installed SDK +and native SQLite reproduce the gap at the unchanged public model-list timeout. +With only the supported offline fetch held across abort, the command returns 0, +closes SQLite and retires all owners while the transport remains unsettled. Root +independently executes the probe-derived negative and an independently authored +normal completed-transport positive, with explicit empty runtime-error channel, +actual Node OS wait and outer tool join. This proves early owner release; it does +not prove a late SQLite write, actual remote abort failure, socket leak, paid call +or native Windows failure. Home/chat applicability follows the shared catalog +port and is covered by the permanent correction controls. + +The correction uses the existing invocation-local work mechanism rather than a +new decision: optional existing ownership authority reaches live discovery, all +three dialects, management and shared Home/chat refreshes. The key probe forwards +its existing authority to generation. SDK collection, pagination and controlled +fetch descendants retain actual completion separately from the bounded report. +Future entry retires at the same boundary; no provider retry, paid authorization, +engine health, deadline or database-close acknowledgement is invented. Existing +two-argument and ownerless calls remain compatible. The observable Gemini SDK +list/pager lifetime does not certify opaque native undici close or untransferred +foreign descendants; the [provider seam](../../reference/shared-core/llm-provider-seam.md#model-discovery--the-listmodels-capability-adr-0064) +owns that concrete limit. + +Twenty-nine new permanent cases exercise actual installed SDKs and SQLite, +normal success, unchanged public timeout, original-owner retirement, pre-stopped +entry, late rejection, an opaque transfer refusal, explicit transport descendants, +management, both shared picker refresh modes and key validation. Nine independent +mechanism removals fail named semantic assertions and every restoration passes, +with explicit zero unhandled errors and actual Node/outer joins. Collector/parent +removals run their complete seven-case unit file; the seven SDK/client/host +removals run all three complete files / 29 cases. Source restoration is byte-exact +and the product tree is unchanged by those isolated controls. Initial broad +collector removal was rejected for three real SDK unhandled errors; a later +negative was rejected because its test sampled only one microtask. The permanent +assertion now observes a complete clock turn. Neither contaminated nor ineffective +negative execution is acceptance evidence; no assertion or deadline is weakened. + +At predecessor `c491836a`, a separate clean security sitting and successful full +[CI run 39030793953](https://github.com/HodeTech/Relavium/actions/runs/39030793953) +retain their exact source scopes. All fifteen jobs pass, including all four +same-tarball consumers and the Windows advisory. Root physically checks the five +package ZIPs and **70 public files**, including the **446,702-byte** tarball with +SHA-256 `28c8fd16c9a72b08bcf78af2482f92ee0b57c9bcd1f1919ab3fa3bb547dd2df5`. +Root also independently validates current-source retained filesystem (**110 / +330 phases**) and secrets (**41 / 123 phases**) archives against exact declaration +sets, runtime-error, source-manifest, evaluation, stdio and physical retention +checks. This inspects published evidence; it does not execute a new causal matrix. +These results do not accept the later material correction. Two NEW complete +cumulative clean rounds, NEW separate clean security and fresh corrected-source +full CI plus all four same-artifact consumers remain required. Steps 9–19 have +not started; no user decision, credential, billing or provider capture is pending. + +Applied author lint/typecheck passes all **17 tasks** (six cache hits); the +serialized workspace test pipeline passes all **11 tasks** (seven cache hits). +The complete CLI suite reports **361 files / 5,655 passes / ten established +skips**. Build and format pass seven tasks (five cache hits). Test isolation +collects 742 files, seam and engine-dependency checks pass, compiled closure +verifies 25 outputs / 410 inputs / 17 externals, bootstrap passes 177 controls +and compiled CLI smoke passes. Every actual subprocess and the outer orchestrator +is joined with exit 0. Existing cache, synthetic/native and platform qualifications +remain; these author gates are not the two required independent reviews or new +whole-CI/platform acceptance. All 151 current filesystem/secrets source anchors +remain unique; that static check is not a new causal-family execution. diff --git a/packages/llm/src/adapters/anthropic.ts b/packages/llm/src/adapters/anthropic.ts index 10f51995..aec5b8b8 100644 --- a/packages/llm/src/adapters/anthropic.ts +++ b/packages/llm/src/adapters/anthropic.ts @@ -27,6 +27,7 @@ import type { CapabilityFlags, LlmError, LlmErrorKind, + LlmInvocationOptions, LlmMessage, LlmProvider, LlmRequest, @@ -49,6 +50,7 @@ import { toModelListing, readRetryAfter, } from './shared.js'; +import { ProviderInvocationWork, type ProviderFetch } from './invocation-work.js'; /** * The reference adapter over `@anthropic-ai/sdk` (1.C) — the seam fence's first real consumer and @@ -623,7 +625,7 @@ function buildRequestOptions(req: LlmRequest): { signal?: AbortSignal } { */ export interface AnthropicAdapterDeps { /** Inject a `fetch` (the replayer/recorder) in place of the network. */ - readonly fetch?: (input: string | URL | Request, init?: RequestInit) => Promise; + readonly fetch?: ProviderFetch; /** * Override the SDK's own retry count. **Defaults to `0`** — the vendor SDK's built-in retry is deliberately * OFF in production, because `FallbackChain` owns the retry/fallback policy (ADR-0011: "the runner — not the @@ -871,12 +873,17 @@ async function* streamChunks( * abort-aware and ceiling-bounded; until that lands, no retry is the safer of the two failure modes. */ export function createAnthropicAdapter(deps: AnthropicAdapterDeps = {}): LlmProvider { - const createClient = (key: string, maxRetries = deps.maxRetries ?? 0): Anthropic => - new Anthropic({ + const createClient = ( + key: string, + maxRetries = deps.maxRetries ?? 0, + work?: ProviderInvocationWork, + ): Anthropic => { + const sdkFetch = work?.bindFetch(deps.fetch ?? globalThis.fetch) ?? deps.fetch; + return new Anthropic({ apiKey: key, // Keep the official factory route independent of ANTHROPIC_BASE_URL. baseURL: 'https://api.anthropic.com', - ...(deps.fetch === undefined ? {} : { fetch: deps.fetch }), + ...(sdkFetch === undefined ? {} : { fetch: sdkFetch }), // ALWAYS passed, never conditionally: an absent option means the SDK's own default (2), which is // exactly the pre-emption #276 is about. Explicit beats implicit. Floored, because a negative value // makes the SDK's retry loop unbounded (`retriesRemaining - 1` stays truthy at -1). @@ -885,6 +892,7 @@ export function createAnthropicAdapter(deps: AnthropicAdapterDeps = {}): LlmProv // documented default (the runner owns retry policy — ADR-0011). maxRetries: Number.isFinite(maxRetries) ? Math.max(0, Math.trunc(maxRetries)) : 0, }); + }; return { id: PROVIDER, @@ -955,19 +963,25 @@ export function createAnthropicAdapter(deps: AnthropicAdapterDeps = {}): LlmProv * mapped INSIDE the adapter to a canonical `ModelListing` (no vendor type escapes), and a per-row parse * failure drops only that row. Bounded + abortable + secret-free via `boundedListModels`. */ - async listModels(key: string, signal?: AbortSignalLike): Promise { + async listModels( + key: string, + signal?: AbortSignalLike, + options?: LlmInvocationOptions, + ): Promise { return boundedListModels({ provider: PROVIDER, key, signal, + options, classify: anthropicErrorToLlmError, - collect: async (innerSignal) => { - const client = createClient(key); + collect: async (innerSignal, work) => { + const client = createClient(key, undefined, work); const listings: ModelListing[] = []; const seen = new Set(); let rawCount = 0; let droppedForShape = 0; - for await (const info of client.models.list(undefined, { signal: innerSignal })) { + const source = client.models.list(undefined, { signal: innerSignal }); + for await (const info of work?.ownIterator(source) ?? source) { rawCount += 1; if (!isRecord(info)) { droppedForShape += 1; // a non-object row (e.g. a null in `data`) — drop it, never dereference diff --git a/packages/llm/src/adapters/bounded-list-models-lifetime.test.ts b/packages/llm/src/adapters/bounded-list-models-lifetime.test.ts new file mode 100644 index 00000000..a8efedb2 --- /dev/null +++ b/packages/llm/src/adapters/bounded-list-models-lifetime.test.ts @@ -0,0 +1,215 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { LlmInvocationOptions } from '../types.js'; +import { makeLlmError } from '../llm-error.js'; +import { boundedListModels } from './shared.js'; +import type { AbortSignalLike } from '@relavium/shared'; +import { LlmProviderError } from '../llm-error.js'; + +function owner() { + const raw: Promise[] = []; + const options: LlmInvocationOptions = { + retainWork: (factory) => { + const pending = factory(); + raw.push(pending); + return pending; + }, + }; + return { raw, options }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('bounded model-list collector ownership', () => { + it('cleans an attempted host listener and empty aggregate after partial setup refuses', async () => { + const parent = owner(); + const cause = new Error('private listener setup'); + let attached = false; + const remove = vi.fn(() => { + attached = false; + }); + const signal: AbortSignalLike = { + aborted: false, + addEventListener: () => { + attached = true; + throw cause; + }, + removeEventListener: remove, + }; + const collect = vi.fn(() => Promise.resolve([])); + await expect( + boundedListModels({ + provider: 'openai', + key: 'synthetic', + signal, + options: parent.options, + collect, + classify: () => makeLlmError({ provider: 'openai', kind: 'unknown', message: 'fixed' }), + }), + ).rejects.toBe(cause); + expect(remove).toHaveBeenCalledOnce(); + expect(attached).toBe(false); + expect(collect).not.toHaveBeenCalled(); + await Promise.all(parent.raw); + }); + + it.each([false, true])( + 'attempts retirement while caller cleanup refuses (primary=%s)', + async (primary) => { + const parent = owner(); + const cause = new Error('private caller cleanup'); + const stop = new AbortController(); + const removal = vi.spyOn(stop.signal, 'removeEventListener').mockImplementation(() => { + throw cause; + }); + try { + const outcome = await boundedListModels({ + provider: 'openai', + key: 'synthetic', + signal: stop.signal, + options: parent.options, + collect: () => + primary + ? Promise.reject( + new LlmProviderError( + makeLlmError({ + provider: 'openai', + kind: 'auth', + message: 'fixed provider refusal', + }), + ), + ) + : Promise.resolve([]), + classify: () => makeLlmError({ provider: 'openai', kind: 'unknown', message: 'fixed' }), + }).catch((error: unknown) => error); + expect(outcome).toMatchObject({ llmError: { kind: primary ? 'auth' : 'unknown' } }); + if (outcome instanceof LlmProviderError) { + expect(outcome.llmError.cause).toBeUndefined(); + expect(outcome.message).not.toContain('private caller cleanup'); + } + expect(removal).toHaveBeenCalledOnce(); + await Promise.all(parent.raw); + } finally { + removal.mockRestore(); + } + }, + ); + + it('joins empty synchronous collector refusal without leaving its timer or original owner pending', async () => { + vi.useFakeTimers(); + const parent = owner(); + await expect( + boundedListModels({ + provider: 'openai', + key: 'synthetic', + signal: undefined, + options: parent.options, + collect: () => { + throw new Error('private synchronous SDK entry'); + }, + classify: () => + makeLlmError({ provider: 'openai', kind: 'auth', message: 'fixed entry refusal' }), + }), + ).rejects.toMatchObject({ llmError: { kind: 'auth' } }); + await Promise.all(parent.raw); + expect(vi.getTimerCount()).toBe(0); + }); + + it('transfers original ownership before a successful collector enters', async () => { + const parent = owner(); + const collect = vi.fn(() => Promise.resolve([])); + const result = boundedListModels({ + provider: 'openai', + key: 'synthetic', + signal: undefined, + options: parent.options, + collect, + classify: () => makeLlmError({ provider: 'openai', kind: 'unknown', message: 'fixed' }), + }); + try { + expect(parent.raw.length, 'BOUNDED_DISCOVERY_OWNER_TRANSFER').toBe(1); + expect(await result).toEqual([]); + expect(collect).toHaveBeenCalledOnce(); + } finally { + await result; + await Promise.all(parent.raw); + } + }); + it('retains a collector independently of a shorter public result and joins late rejection', async () => { + vi.useFakeTimers(); + const parent = owner(); + let reject: (cause: unknown) => void = () => undefined; + const raw = new Promise((_yes, no) => { + reject = no; + }); + const classified = vi.fn(() => + makeLlmError({ provider: 'openai', kind: 'unknown', message: 'fixed' }), + ); + const outcome = boundedListModels({ + provider: 'openai', + key: 'synthetic', + signal: undefined, + options: parent.options, + classify: classified, + collect: () => raw, + }).catch((cause: unknown) => cause); + try { + await vi.advanceTimersByTimeAsync(15_000); + expect(await outcome).toMatchObject({ llmError: { kind: 'timeout' } }); + let complete = false; + void Promise.all(parent.raw).then(() => { + complete = true; + }); + await vi.advanceTimersByTimeAsync(0); + expect(complete, 'DISCOVERY_RETAINS_EXACT_COLLECT').toBe(false); + reject(new Error('late synthetic rejection')); + await Promise.all(parent.raw); + expect(complete).toBe(true); + expect(classified).not.toHaveBeenCalled(); + } finally { + reject(new Error('fixture release')); + await outcome; + await Promise.all(parent.raw); + } + }); + + it('refuses an opaque original-owner transfer before SDK construction or classification', async () => { + let reflected = 0; + const cause = new Proxy(new Error('opaque host refusal'), { + get() { + reflected += 1; + throw new Error('reflection'); + }, + getPrototypeOf() { + reflected += 1; + throw new Error('reflection'); + }, + }); + const collect = vi.fn(() => Promise.resolve([])); + const classify = vi.fn(() => + makeLlmError({ provider: 'openai', kind: 'unknown', message: 'fixed' }), + ); + let actual: unknown; + try { + await boundedListModels({ + provider: 'openai', + key: 'synthetic', + signal: undefined, + options: { + retainWork: () => { + throw cause; + }, + }, + collect, + classify, + }); + } catch (error) { + actual = error; + } + expect(Object.is(actual, cause)).toBe(true); + expect(reflected).toBe(0); + expect(collect).not.toHaveBeenCalled(); + expect(classify).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/llm/src/adapters/gemini.ts b/packages/llm/src/adapters/gemini.ts index 58a04832..0469b8ea 100644 --- a/packages/llm/src/adapters/gemini.ts +++ b/packages/llm/src/adapters/gemini.ts @@ -42,6 +42,7 @@ import { UnsupportedCapabilityError, UnsupportedRequestDataError } from '../erro import type { CapabilityFlags, LlmError, + LlmInvocationOptions, LlmMessage, LlmProvider, LlmRequest, @@ -55,6 +56,7 @@ import type { ToolDef, UsageEvidence, } from '../types.js'; +import { ProviderInvocationWork } from './invocation-work.js'; import { CONTEXT_SEAM_DEFAULTS, @@ -260,7 +262,11 @@ export interface GeminiTransport { * the vendor-type-free {@link GeminiModelInfo}; the conformance harness injects a replay. `signal` aborts * the in-flight list request (a client-only cancel — see the SDK's `abortSignal` note). */ - listModels(key: string, signal?: AbortSignalLike): Promise; + listModels( + key: string, + signal?: AbortSignalLike, + options?: LlmInvocationOptions, + ): Promise; /** * Imagen generative image endpoint (1.AH A2). The default wraps `ai.models.generateImages`; the * conformance harness injects a replay. Required so a transport honestly declares the generative @@ -797,24 +803,35 @@ const sdkTransport: GeminiTransport = { const client = createSdkClient(key); return await client.models.generateContentStream(request); }, - async listModels(key: string, signal?: AbortSignalLike): Promise { - const client = createSdkClient(key); - // models.list() returns an auto-paginating Pager; `for await` follows nextPageToken. Each row is - // normalized to the vendor-type-free GeminiModelInfo here so no @google/genai Model type crosses the seam. - const pager = await client.models.list({ - config: isAbortSignal(signal) ? { abortSignal: signal } : {}, - }); - const rows: GeminiModelInfo[] = []; - for await (const model of pager) { - rows.push({ - name: model.name, - displayName: model.displayName, - inputTokenLimit: model.inputTokenLimit, - outputTokenLimit: model.outputTokenLimit, - supportedActions: model.supportedActions, - }); + async listModels( + key: string, + signal?: AbortSignalLike, + options?: LlmInvocationOptions, + ): Promise { + const work = options === undefined ? undefined : new ProviderInvocationWork(options, signal); + try { + const client = createSdkClient(key); + // models.list() returns an auto-paginating Pager; `for await` follows nextPageToken. Each row is + // normalized to the vendor-type-free GeminiModelInfo here so no @google/genai Model type crosses the seam. + const invoke = () => + client.models.list({ + config: isAbortSignal(signal) ? { abortSignal: signal } : {}, + }); + const pager = await (work === undefined ? invoke() : work.retainWork(invoke)); + const rows: GeminiModelInfo[] = []; + for await (const model of work?.ownIterator(pager) ?? pager) { + rows.push({ + name: model.name, + displayName: model.displayName, + inputTokenLimit: model.inputTokenLimit, + outputTokenLimit: model.outputTokenLimit, + supportedActions: model.supportedActions, + }); + } + return rows; + } finally { + work?.retire(); } - return rows; }, async generateImages(request: GeminiImageRequest, key: string): Promise { const client = createSdkClient(key); @@ -1370,14 +1387,19 @@ export function createGeminiAdapter(deps: GeminiAdapterDeps = {}): LlmProvider { * `ModelListing` (`supportedActions` must include `generateContent`, `models/` prefix stripped); a * per-row parse failure drops only that row. Bounded + abortable + secret-free via `boundedListModels`. */ - async listModels(key: string, signal?: AbortSignalLike): Promise { + async listModels( + key: string, + signal?: AbortSignalLike, + options?: LlmInvocationOptions, + ): Promise { return boundedListModels({ provider: PROVIDER, key, signal, + options, classify: geminiErrorToLlmError, - collect: async (innerSignal) => { - const rows = await transport.listModels(key, innerSignal); + collect: async (innerSignal, work) => { + const rows = await transport.listModels(key, innerSignal, work); const listings: ModelListing[] = []; const seen = new Set(); let rawCount = 0; diff --git a/packages/llm/src/adapters/list-models-lifetime.test.ts b/packages/llm/src/adapters/list-models-lifetime.test.ts new file mode 100644 index 00000000..83605891 --- /dev/null +++ b/packages/llm/src/adapters/list-models-lifetime.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { LlmInvocationOptions, LlmProvider } from '../types.js'; +import { LlmProviderError } from '../llm-error.js'; +import { createAnthropicAdapter } from './anthropic.js'; +import { createGeminiAdapter, type GeminiTransport } from './gemini.js'; +import { createOpenAiAdapter } from './openai.js'; +import type { ProviderFetch } from './invocation-work.js'; + +function deferred() { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} +function owner() { + const raw: Promise[] = []; + const options: LlmInvocationOptions = { + retainWork: (factory) => { + const pending = factory(); + raw.push(pending); + return pending; + }, + }; + return { raw, options }; +} +function list(provider: LlmProvider, options: LlmInvocationOptions, signal?: AbortSignal) { + if (provider.listModels === undefined) throw new Error('missing discovery capability'); + return provider.listModels('synthetic-discovery-key', signal, options); +} +const dialects = [ + { + name: 'openai', + create: (fetch: ProviderFetch) => createOpenAiAdapter({ fetch }), + body: { object: 'list', data: [] }, + }, + { + name: 'deepseek', + create: (fetch: ProviderFetch) => createOpenAiAdapter({ fetch, providerId: 'deepseek' }), + body: { object: 'list', data: [] }, + }, + { + name: 'anthropic', + create: (fetch: ProviderFetch) => createAnthropicAdapter({ fetch }), + body: { data: [], has_more: false }, + }, + { + name: 'gemini', + create: (fetch: ProviderFetch) => { + vi.stubGlobal('fetch', fetch); + return createGeminiAdapter(); + }, + body: { models: [] }, + }, +]; +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe('discovery result versus original invocation retirement', () => { + it('transfers explicit Gemini transport descendants independently of the returned list', async () => { + const parent = owner(); + const nativeClose = deferred(); + let transferred = false; + const unused = () => Promise.reject(new Error('unused transport operation')); + const transport: GeminiTransport = { + generate: unused, + stream: unused, + generateImages: unused, + generateVideos: unused, + pollVideo: unused, + listModels: (_key, _signal, options) => { + if (options !== undefined) { + transferred = true; + void options.retainWork(() => nativeClose.promise); + } + return Promise.resolve([]); + }, + }; + try { + expect(await list(createGeminiAdapter({ transport }), parent.options)).toEqual([]); + let retired = false; + void Promise.all(parent.raw).then(() => { + retired = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(transferred, 'GEMINI_DISCOVERY_FORWARDS_TRANSPORT_WORK').toBe(true); + expect(retired).toBe(false); + nativeClose.resolve(); + await Promise.all(parent.raw); + expect(retired).toBe(true); + } finally { + nativeClose.resolve(); + await Promise.all(parent.raw); + } + }); + + it.each(dialects)( + '$name preserves ordinary owned discovery and actual retirement', + async ({ create, body }) => { + const parent = owner(); + let registeredBeforeFetch = false; + const provider = create(() => { + registeredBeforeFetch = parent.raw.length === 1; + return Promise.resolve(jsonResponse(body)); + }); + expect(await list(provider, parent.options)).toEqual([]); + expect(registeredBeforeFetch).toBe(true); + await Promise.all(parent.raw); + expect(parent.raw).toHaveLength(1); + }, + ); + + it.each(dialects)( + '$name retains the real installed SDK collect past the unchanged public timeout', + async ({ create, body }) => { + vi.useFakeTimers(); + const parent = owner(); + const response = deferred(); + const entered = deferred(); + const provider = create(() => { + entered.resolve(); + return response.promise; + }); + const outcome = list(provider, parent.options).then( + (value) => ({ value }), + (cause: unknown) => ({ cause }), + ); + let retired = false; + try { + await entered.promise; + void Promise.all(parent.raw).then(() => { + retired = true; + }); + await vi.advanceTimersByTimeAsync(15_000); + const result = await outcome; + expect(result).toMatchObject({ cause: { llmError: { kind: 'timeout' } } }); + if ('cause' in result && result.cause instanceof LlmProviderError) { + expect(result.cause.llmError.cause).toBeUndefined(); + expect(result.cause.message).not.toContain('synthetic-discovery-key'); + } + expect(parent.raw.length, 'DISCOVERY_REGISTERED_BEFORE_SDK_ENTRY').toBe(1); + expect(retired, 'DISCOVERY_TIMEOUT_DOES_NOT_RETIRE_RAW_COLLECT').toBe(false); + response.resolve(jsonResponse(body)); + await Promise.all(parent.raw); + expect(retired).toBe(true); + } finally { + response.resolve(jsonResponse(body)); + await outcome; + await Promise.all(parent.raw); + } + }, + ); + + it.each(dialects.filter(({ name }) => name !== 'gemini'))( + '$name retains native descendants after successful SDK collection', + async ({ create, body }) => { + const parent = owner(); + const nativeClose = deferred(); + let transferred = false; + const provider = create((_input, _init, work) => { + if (work !== undefined) { + transferred = true; + void work.retainWork(() => nativeClose.promise); + } + return Promise.resolve(jsonResponse(body)); + }); + let retired = false; + try { + expect(await list(provider, parent.options)).toEqual([]); + void Promise.all(parent.raw).then(() => { + retired = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(transferred, 'DISCOVERY_CLIENT_FORWARDS_TRANSITIVE_WORK').toBe(true); + expect(retired, 'DISCOVERY_SUCCESS_IS_NOT_NATIVE_CLOSE_ACK').toBe(false); + nativeClose.resolve(); + await Promise.all(parent.raw); + expect(retired).toBe(true); + } finally { + nativeClose.resolve(); + await Promise.all(parent.raw); + } + }, + ); + + it.each(dialects)( + '$name refuses SDK entry for an already-stopped owned request', + async ({ create }) => { + const parent = owner(); + const fetch = vi.fn(() => Promise.resolve(jsonResponse({}))); + const stop = new AbortController(); + stop.abort(); + await expect(list(create(fetch), parent.options, stop.signal)).rejects.toBeInstanceOf( + LlmProviderError, + ); + expect(fetch).not.toHaveBeenCalled(); + await Promise.all(parent.raw); + }, + ); +}); diff --git a/packages/llm/src/adapters/openai.ts b/packages/llm/src/adapters/openai.ts index 0535cb9b..678d31a5 100644 --- a/packages/llm/src/adapters/openai.ts +++ b/packages/llm/src/adapters/openai.ts @@ -1606,20 +1606,26 @@ export function createOpenAiAdapter(deps: OpenAiAdapterDeps = {}): LlmProvider { * priced ids for cost-eligibility). The provider id (`openai` | `deepseek`) selects the priced-id union set. Bounded + * abortable + secret-free via `boundedListModels`; a per-row parse failure drops only that row. */ - async listModels(key: string, signal?: AbortSignalLike): Promise { + async listModels( + key: string, + signal?: AbortSignalLike, + options?: LlmInvocationOptions, + ): Promise { return boundedListModels({ provider: providerId, key, signal, + options, classify: (err) => openaiErrorToLlmError(err, providerId), - collect: async (innerSignal) => { - const client = createClient(key); + collect: async (innerSignal, work) => { + const client = createClient(key, undefined, work); const priced = pricedModelIdsFor(providerId); const listings: ModelListing[] = []; const seen = new Set(); let rawCount = 0; let droppedForShape = 0; - for await (const model of client.models.list({ signal: innerSignal })) { + const source = client.models.list({ signal: innerSignal }); + for await (const model of work?.ownIterator(source) ?? source) { rawCount += 1; if (!isRecord(model)) { droppedForShape += 1; // a non-object row — drop it, never dereference diff --git a/packages/llm/src/adapters/shared.ts b/packages/llm/src/adapters/shared.ts index 9ffd557a..1de9c59f 100644 --- a/packages/llm/src/adapters/shared.ts +++ b/packages/llm/src/adapters/shared.ts @@ -11,10 +11,12 @@ import type { CapabilityFlags, EstimateTokensInput, LlmError, + LlmInvocationOptions, LlmRequest, ModelListing, ProviderId, } from '../types.js'; +import { captureInvocationOptions, ProviderInvocationWork } from './invocation-work.js'; /** * Shared helpers for the provider adapters — the platform-coupled zone (`src/adapters/*`) that may @@ -277,59 +279,84 @@ export async function boundedListModels(params: { readonly provider: ProviderId; readonly key: string; readonly signal: AbortSignalLike | undefined; + /** Existing invocation-local authority; the bounded result is not its completion ACK. */ + readonly options?: LlmInvocationOptions | undefined; /** The adapter's SDK-error classifier (e.g. `anthropicErrorToLlmError`). */ readonly classify: (err: unknown) => LlmError; /** Fetch + map the rows, threading the internal (timeout/abort-linked) signal to the SDK. */ - readonly collect: (signal: AbortSignal) => Promise; + readonly collect: (signal: AbortSignal, work?: ProviderInvocationWork) => Promise; readonly timeoutMs?: number; }): Promise { const { provider, key, signal, classify, collect, timeoutMs = LIST_MODELS_TIMEOUT_MS } = params; const controller = new AbortController(); + // Transfer before listener setup, SDK construction or collection. A refused + // host transfer remains outside provider-error normalization. + const options = captureInvocationOptions(params.options); + const work = + options === undefined ? undefined : new ProviderInvocationWork(options, controller.signal); const onAbort = (): void => controller.abort(); - if (signal !== undefined) { - if (signal.aborted) { - controller.abort(); - } else { - signal.addEventListener('abort', onAbort); - } - } let timer: ReturnType | undefined; + let listenerAttempted = false; let timedOut = false; - const timeout = new Promise((_resolve, reject) => { - timer = setTimeout(() => { - timedOut = true; - controller.abort(); - reject(new Error('listModels timed out')); - }, timeoutMs); - }); - const collecting = collect(controller.signal); - // Attach a no-op handler so a post-timeout rejection (the SDK aborting after the timeout won the race) - // is not an unhandled rejection; the race still observes the rejection through its own handler. - collecting.catch(() => undefined); + let result: ModelListing[]; + let cleanupFailed = false; try { - return await Promise.race([collecting, timeout]); - } catch (err) { - // Resolve the base error (timeout wins; a pre-classified `LlmProviderError` passes through with its own - // `kind`), then re-wrap through makeLlmError so scrubSecrets runs again AND redactKey strips the resolved - // key; never pass `cause` (it could carry the key or the raw vendor payload — ADR-0064 §3). - const base = resolveListModelsError({ provider, err, timedOut, timeoutMs, classify }); + if (signal !== undefined) { + // A supported host signal can throw after attaching. Cleanup still owns + // the exact attempted listener, independently of collection entry. + listenerAttempted = true; + signal.addEventListener('abort', onAbort); + if (signal.aborted) controller.abort(); + } + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + timedOut = true; + controller.abort(); + reject(new Error('listModels timed out')); + }, timeoutMs); + }); + try { + const invoke = () => collect(work?.signal ?? controller.signal, work); + const collecting = work === undefined ? invoke() : work.retainWork(invoke); + // The public race observes a late rejection; the separate work scope + // retains the exact collect and its admitted transport descendants. + result = await Promise.race([collecting, timeout]); + } catch (err) { + const base = resolveListModelsError({ provider, err, timedOut, timeoutMs, classify }); + throw new LlmProviderError( + makeLlmError({ + provider, + kind: base.kind, + message: redactKey(base.message, key), + ...(base.code !== undefined ? { code: redactKey(base.code, key) } : {}), + ...(base.status !== undefined ? { status: base.status } : {}), + }), + ); + } + } finally { + if (timer !== undefined) clearTimeout(timer); + try { + if (listenerAttempted) signal?.removeEventListener('abort', onAbort); + } catch { + cleanupFailed = true; + } + try { + work?.retire(); + } catch { + cleanupFailed = true; + } + } + // An established throw skips this success-only path and remains primary. + // Cleanup-only refusal is fixed, non-retryable and cause-free. + if (cleanupFailed) throw new LlmProviderError( makeLlmError({ provider, - kind: base.kind, - message: redactKey(base.message, key), - ...(base.code !== undefined ? { code: redactKey(base.code, key) } : {}), - ...(base.status !== undefined ? { status: base.status } : {}), + kind: 'unknown', + message: 'model list cleanup could not be completed', }), ); - } finally { - if (timer !== undefined) { - clearTimeout(timer); - } - if (signal !== undefined) { - signal.removeEventListener('abort', onAbort); - } - } + return result; } /** diff --git a/packages/llm/src/types.ts b/packages/llm/src/types.ts index 535544b2..f139b041 100644 --- a/packages/llm/src/types.ts +++ b/packages/llm/src/types.ts @@ -621,5 +621,9 @@ export interface LlmProvider { * that one row (never throws for it — ADR-0064 §8); a breaking endpoint/shape change throws a classified, * key-redacted `LlmProviderError` the host's per-provider refresh isolation catches (ADR-0064 §5). */ - listModels?(key: string, signal?: AbortSignalLike): Promise; + listModels?( + key: string, + signal?: AbortSignalLike, + options?: LlmInvocationOptions, + ): Promise; } From cef520d4fe1aa13e827a3847c0f811b014020335 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 17:33:28 +0300 Subject: [PATCH 154/222] fix(cli): join entered media maintenance siblings before release Retain actual grace/orphan deletion and CAS shard work after a sibling fails. Preserve best-effort workflow outcomes, original failures and existing termination budgets while preventing early database and invocation-owner release. The atomic CLI and db correction adds ten permanent cases and three independent causal removals. Update canonical ownership and review status without claiming Step 8d acceptance. Refs: ADR-0103, ADR-0110 Co-Authored-By: Codex --- AGENTS.md | 28 +- CLAUDE.md | 28 +- .../workflow-maintenance-siblings.test.ts | 247 ++++++++++++++++++ apps/cli/src/engine/media-gc.test.ts | 44 ++++ apps/cli/src/engine/media-gc.ts | 28 +- docs/reference/cli/commands.md | 10 +- docs/roadmap/current.md | 11 +- .../phase-2.5.5-hardening-and-remediation.md | 79 ++++++ packages/db/src/media-store.ts | 11 +- 9 files changed, 451 insertions(+), 35 deletions(-) create mode 100644 apps/cli/src/commands/workflow-maintenance-siblings.test.ts diff --git a/AGENTS.md b/AGENTS.md index 13203c3d..2215b952 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,20 +107,20 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. R1–R20 remain NONCLEAN. R21 is complete CLEAN at -`c491836a` (330 paths / 1,789 cumulative hunks); R22 covers the same complete -source and confirms one Medium gap: bounded model discovery can release its -original owner before an abort-ignoring SDK collector settles. Root independently -reproduces management/SQLite early release and a healthy completed-transport -control. The correction forwards existing invocation-local work through model -refresh, all three discovery dialects and key validation, preserving the public -timeout and actual entered/native joins. No new decision or approval is needed. -R21 and the separate clean security sitting, successful full CI and all four -physically inspected same-artifact consumers remain attributed to `c491836a`; -they do not accept the later correction. Two NEW complete cumulative clean rounds, -a NEW separate clean security sitting and fresh corrected-source full CI plus all -four same-artifact consumers remain required. Steps 9–19 have not started; no -user decision is pending. +but not independently accepted. R21 is complete CLEAN at `c491836a`; R22 and +R23 are NONCLEAN. R23 covers all 340 paths / 1,834 cumulative hunks at `e7fe93c5` +and confirms two Medium joined-maintenance gaps: a failed media deletion or CAS +shard can release the command/database owner while an entered sibling remains +pending. Root independently reproduces both with real engine/SQLite and qualified +synthetic native-port failures. Corrections join every entered sibling before +propagating failure, preserving best-effort outcomes and existing budgets. Ten +new permanent cases and three independent fixed/removal/restored mechanisms +verify the correction; this is author evidence, not independent acceptance. +Earlier clean review, security, CI and same-artifact consumers retain their exact +reviewed-source attribution and do not accept later material fixes. Two NEW +complete cumulative clean rounds, a NEW separate clean security sitting and fresh +corrected-source full CI plus all four same-artifact consumers remain required. +Steps 9–19 have not started; no new decision or approval is needed. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index 1d3cb3c9..4cf85ec9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,20 +196,20 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. R1–R20 remain NONCLEAN. R21 is complete CLEAN at -`c491836a` (330 paths / 1,789 cumulative hunks); R22 covers the same complete -source and confirms one Medium gap: bounded model discovery can release its -original owner before an abort-ignoring SDK collector settles. Root independently -reproduces management/SQLite early release and a healthy completed-transport -control. The correction forwards existing invocation-local work through model -refresh, all three discovery dialects and key validation, preserving the public -timeout and actual entered/native joins. No new decision or approval is needed. -R21 and the separate clean security sitting, successful full CI and all four -physically inspected same-artifact consumers remain attributed to `c491836a`; -they do not accept the later correction. Two NEW complete cumulative clean rounds, -a NEW separate clean security sitting and fresh corrected-source full CI plus all -four same-artifact consumers remain required. Steps 9–19 have not started; no -user decision is pending. +but not independently accepted. R21 is complete CLEAN at `c491836a`; R22 and +R23 are NONCLEAN. R23 covers all 340 paths / 1,834 cumulative hunks at `e7fe93c5` +and confirms two Medium joined-maintenance gaps: a failed media deletion or CAS +shard can release the command/database owner while an entered sibling remains +pending. Root independently reproduces both with real engine/SQLite and qualified +synthetic native-port failures. Corrections join every entered sibling before +propagating failure, preserving best-effort outcomes and existing budgets. Ten +new permanent cases and three independent fixed/removal/restored mechanisms +verify the correction; this is author evidence, not independent acceptance. +Earlier clean review, security, CI and same-artifact consumers retain their exact +reviewed-source attribution and do not accept later material fixes. Two NEW +complete cumulative clean rounds, a NEW separate clean security sitting and fresh +corrected-source full CI plus all four same-artifact consumers remain required. +Steps 9–19 have not started; no new decision or approval is needed. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/apps/cli/src/commands/workflow-maintenance-siblings.test.ts b/apps/cli/src/commands/workflow-maintenance-siblings.test.ts new file mode 100644 index 00000000..28a00bfa --- /dev/null +++ b/apps/cli/src/commands/workflow-maintenance-siblings.test.ts @@ -0,0 +1,247 @@ +import { randomUUID } from 'node:crypto'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + createClient, + createMediaReferenceStore, + createRunHistoryStore, + FilesystemMediaStore, + runMigrations, +} from '@relavium/db'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; + +import { sweepHostMediaBestEffort } from '../engine/media-gc.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import { runCommand } from './run.js'; + +const reads = vi.hoisted(() => ({ afterRead: vi.fn<(path: string) => Promise>() })); +vi.mock('node:fs/promises', async (original) => { + const native = await original(); + return { + ...native, + readdir: async (...args: Parameters) => { + const raw = native.readdir(...args); + // The fixture holds the adapter continuation after starting the real native read. + void raw.catch(() => undefined); + await reads.afterRead(String(args[0])); + return raw; + }, + }; +}); + +beforeEach(() => { + reads.afterRead.mockResolvedValue(undefined); +}); +afterEach(() => { + vi.restoreAllMocks(); + reads.afterRead.mockReset(); +}); + +function deferred() { + let resolve: () => void = () => undefined; + const promise = new Promise((yes) => { + resolve = yes; + }); + return { promise, resolve }; +} + +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const handle = (digit: string) => 'media://sha256-' + digit.repeat(64); + +function fixture() { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-maintenance-siblings-'))); + const client = createClient(); + runMigrations(client.db); + const configPath = join(root, 'config.toml'); + const workflow = join(root, 'maintenance.relavium.yaml'); + writeFileSync(configPath, ''); + writeFileSync( + workflow, + JSON.stringify({ + schema_version: '1.0', + workflow: { + id: 'maintenance-siblings', + nodes: [ + { id: 'input', type: 'input' }, + { id: 'out', type: 'output' }, + ], + edges: [{ from: 'input', to: 'out' }], + }, + }), + ); + const owned = invocationFixture(); + const casRoot = join(root, 'cas'); + const state = { settled: false, closes: 0 }; + return { + client, + owned, + casRoot, + state, + start() { + const action = runCommand( + { workflow, input: [], allowMcpStdio: [] }, + { + invocation: owned.invocation, + subscribeSignals: () => () => undefined, + io: captureIo().io, + global: { cwd: root, configPath, json: true, color: false, verbosity: 'normal' }, + providers: { + resolveProvider: () => { + throw new Error('No provider is needed.'); + }, + keyFor: () => { + throw new Error('No key is needed.'); + }, + }, + openRunStore: (definition, _home, _cwd, captureClose) => { + const close = () => { + state.closes += 1; + client.sqlite.close(); + }; + captureClose?.(close); + return { + db: client.db, + close, + terminalOutboxPath: join(root, 'outbox'), + store: createRunHistoryStore(client.db, { + uuid: randomUUID, + now: Date.now, + workflow: { + slug: definition.workflow.id, + name: definition.workflow.id, + definitionJson: JSON.stringify(definition), + }, + }), + }; + }, + sweepMedia: (args) => + sweepHostMediaBestEffort({ + ...args, + casRoot, + graceMs: 0, + orphanMinAgeMs: 0, + now: () => 10_000, + }), + }, + ).then((code) => { + state.settled = true; + return code; + }); + void action.catch(() => undefined); + return action; + }, + dispose() { + if (client.sqlite.open) client.sqlite.close(); + rmSync(root, { recursive: true, force: true }); + }, + }; +} + +const deletionCases = (['grace', 'orphan'] as const).flatMap((batch) => + (['healthy', 'rejected', 'synchronous'] as const).map((outcome) => ({ batch, outcome })), +); +it.each(deletionCases)( + '$batch GC joins entered deletions through a $outcome sibling', + async ({ batch, outcome }) => { + const f = fixture(); + const entered = deferred(); + const held = deferred(); + const handles = [handle('a'), handle('b')]; + if (batch === 'grace') { + const refs = createMediaReferenceStore(f.client.db, () => 0); + for (const value of handles) + refs.recordObject({ + handle: value, + mimeType: 'audio/L16', + modality: 'audio', + byteLength: 2, + }); + } + const listing = vi + .spyOn(FilesystemMediaStore.prototype, 'listHandles') + .mockResolvedValue( + batch === 'orphan' ? handles.map((value) => ({ handle: value, mtimeMs: 0 })) : [], + ); + let calls = 0; + let completed = 0; + const deletion = vi.spyOn(FilesystemMediaStore.prototype, 'delete').mockImplementation(() => { + calls += 1; + if (calls === 1) { + entered.resolve(); + return held.promise.then(() => { + completed += 1; + }); + } + if (outcome === 'synchronous') throw new Error('Synthetic deletion entry refusal.'); + return outcome === 'rejected' + ? Promise.reject(new Error('Synthetic deletion refusal.')) + : Promise.resolve(); + }); + const action = f.start(); + try { + await entered.promise; + await tick(); + expect(calls).toBe(2); + expect(completed).toBe(0); + expect(f.state.settled, 'GC_DELETE_SIBLINGS_BEFORE_WORKFLOW_RELEASE').toBe(false); + expect(f.state.closes).toBe(0); + expect(f.client.sqlite.open).toBe(true); + expect(f.owned.registry.activeOwners).toBeGreaterThan(0); + } finally { + held.resolve(); + try { + await action; + } finally { + deletion.mockRestore(); + listing.mockRestore(); + f.dispose(); + } + } + expect(completed).toBe(1); + expect(await action).toBe(0); + expect(f.state.closes).toBe(1); + expect(f.owned.registry.activeOwners).toBe(0); + }, +); + +it.each([false, true])( + 'default GC joins shard continuation when a sibling rejects=%s', + async (fail) => { + const f = fixture(); + const entered = deferred(); + const held = deferred(); + mkdirSync(join(f.casRoot, 'aa'), { recursive: true }); + mkdirSync(join(f.casRoot, 'bb'), { recursive: true }); + writeFileSync(join(f.casRoot, 'aa', '0'.repeat(62)), 'inert young orphan'); + reads.afterRead.mockImplementation(async (path) => { + if (path === join(f.casRoot, 'aa')) { + entered.resolve(); + await held.promise; + } + if (path === join(f.casRoot, 'bb') && fail) + throw Object.assign(new Error('Synthetic shard refusal.'), { code: 'EIO' }); + }); + const action = f.start(); + try { + await entered.promise; + await tick(); + expect(f.state.settled, 'GC_SHARD_SIBLINGS_BEFORE_WORKFLOW_RELEASE').toBe(false); + expect(f.state.closes).toBe(0); + expect(f.client.sqlite.open).toBe(true); + expect(f.owned.registry.activeOwners).toBeGreaterThan(0); + } finally { + held.resolve(); + try { + await action; + } finally { + f.dispose(); + } + } + expect(await action).toBe(0); + expect(f.state.closes).toBe(1); + expect(f.owned.registry.activeOwners).toBe(0); + }, +); diff --git a/apps/cli/src/engine/media-gc.test.ts b/apps/cli/src/engine/media-gc.test.ts index a20d0bb6..6215a4d2 100644 --- a/apps/cli/src/engine/media-gc.test.ts +++ b/apps/cli/src/engine/media-gc.test.ts @@ -87,6 +87,50 @@ function baseDeps(over: Partial): MediaGcDeps { } describe('runHostMediaGc (2.S/D-GC, ADR-0042 §4)', () => { + it.each(['grace', 'orphan'] as const)( + '%s synchronous entry refusal joins prior work and does not start later deletes', + async (batch) => { + let release: () => void = () => undefined; + const raw = new Promise((resolve) => { + release = resolve; + }); + const cause = new Error('Synthetic synchronous entry refusal.'); + const handles = [H('a'), H('b'), H('c')]; + const cas = fakeCas( + batch === 'orphan' ? handles.map((handle) => ({ handle, mtimeMs: 0 })) : [], + ); + const deletion = vi.fn((handle: string) => { + if (handle === handles[0]) return raw; + throw cause; + }); + let settled = false; + const result = runHostMediaGc( + baseDeps({ + casStore: { ...cas.store, delete: deletion }, + references: fakeRefs({ expired: batch === 'grace' ? handles : [] }).refs, + }), + ).then( + () => { + settled = true; + return undefined; + }, + (failure: unknown) => { + settled = true; + return failure; + }, + ); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(deletion.mock.calls.map(([handle]) => handle)).toEqual(handles.slice(0, 2)); + expect(settled, 'GC_SYNC_REFUSAL_RETAINS_ENTERED_SIBLING').toBe(false); + } finally { + release(); + await result; + } + expect(await result).toBe(cause); + }, + ); + it('reclaim-retry: sweeps ONLY reclaimable (settled), non-current runs with lingering run-refs', async () => { const { refs, removed } = fakeRefs({ runRefRunIds: ['settled-a', 'active-b', 'current'] }); const report = await runHostMediaGc( diff --git a/apps/cli/src/engine/media-gc.ts b/apps/cli/src/engine/media-gc.ts index 80a861d7..e69642ae 100644 --- a/apps/cli/src/engine/media-gc.ts +++ b/apps/cli/src/engine/media-gc.ts @@ -64,6 +64,30 @@ export interface MediaGcReport { readonly orphanSweepRan: boolean; } +/** A failed sibling is a result, not acknowledgement of every already-entered delete. */ +async function deleteJoined( + store: MediaGcDeps['casStore'], + handles: readonly string[], +): Promise { + const pending: Promise[] = []; + let failure: { readonly cause: unknown } | undefined; + for (const handle of handles) { + try { + pending.push( + store.delete(handle).catch((cause: unknown) => { + failure ??= { cause }; + }), + ); + } catch (cause) { + // Preserve synchronous entry refusal without abandoning previously entered siblings. + failure ??= { cause }; + break; + } + } + await Promise.all(pending); + if (failure !== undefined) throw failure.cause; +} + /** * The host media garbage collection (2.S/D-GC, ADR-0042 §4) — a best-effort, run-end ("keyed on the terminal run * event") pass the CLI owns (the engine signals the terminal; the host runs the mechanism). Three ordered steps: @@ -104,7 +128,7 @@ export async function runHostMediaGc(deps: MediaGcDeps): Promise // today; if `graceMs` is ever shortened materially, gate each delete on a re-verify SELECT (skip a handle // whose `deleted_at` is NULL again). const expired = deps.references.reclaimExpired(deps.graceMs); - await Promise.all(expired.map((handle) => deps.casStore.delete(handle))); + await deleteJoined(deps.casStore, expired); // 3. CAS-orphan sweep — skip entirely while another run could be mid-write; age-guard each candidate so a // fresh row-less blob (a concurrent run's, not yet recordObject'd) is never deleted. @@ -118,7 +142,7 @@ export async function runHostMediaGc(deps: MediaGcDeps): Promise const orphans = (await deps.casStore.listHandles()) .filter(({ handle, mtimeMs }) => !known.has(handle) && mtimeMs <= settledBefore) .map(({ handle }) => handle); - await Promise.all(orphans.map((handle) => deps.casStore.delete(handle))); + await deleteJoined(deps.casStore, orphans); orphansDeleted = orphans.length; } diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index f543d9cf..26f8422e 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1030,9 +1030,13 @@ review, security and platform gates remain open in the Optional terminal effect retention and media GC check the original invocation remaining budget immediately before new maintenance. At zero they leave rows and -bytes for a later eligible pass. An already-entered media sweep remains joined; -this skips no engine, receipt, input, MCP or native database-close acknowledgement -and never resets the absolute termination deadline. +bytes for a later eligible pass. An already-entered media sweep remains joined, +including every entered grace/orphan deletion and CAS-shard enumeration branch +when a sibling rejects. A synchronous deletion-entry refusal stops new entries +but still joins previously entered deletions. Only then may the original failure +reach the best-effort caller; an aggregate rejection alone never acknowledges +sibling completion. This skips no engine, receipt, input, MCP or native +database-close acknowledgement and never resets the absolute termination deadline. **Step 8c independently accepted on `development`.** The [acceptance register](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8c-acceptance--2026-10-16) diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 9064cd87..4bbfc350 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1356,7 +1356,16 @@ sitting, full CI and all four physically inspected package consumers belong to `c491836a`; none accepts the later correction. Two NEW complete cumulative clean rounds, NEW separate security and corrected-source full CI/platforms remain required. Steps 9–19 have not started; no user decision is pending. -Step 8d proceeds automatically. The +The [R23 joined-maintenance checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-r23-joined-maintenance-correction--2026-10-17) +records complete NONCLEAN review of 340 paths / 1,834 cumulative hunks at +`e7fe93c5`. Two confirmed Medium gaps release workflow/database ownership before +entered sibling media deletions or shard enumeration settle. Root independently +reproduces both; the correction preserves existing best-effort results and budgets +while joining entered siblings through failure. Ten new permanent cases and three +independent causal removals cover the correction. This author evidence does not +replace two NEW complete cumulative clean rounds, NEW separate security or fresh +corrected-source full CI and all four same-artifact consumers. Steps 9–19 have not +started; no user decision is pending. Step 8d proceeds automatically. The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index dd061062..075e3bf3 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8064,3 +8064,82 @@ is joined with exit 0. Existing cache, synthetic/native and platform qualificati remain; these author gates are not the two required independent reviews or new whole-CI/platform acceptance. All 151 current filesystem/secrets source anchors remain unique; that static check is not a new causal-family execution. + +### Wave 3 Step 8d R23 joined-maintenance correction — 2026-10-17 + +**R23 is complete and NONCLEAN; Step 8d and whole Step 8 remain OPEN.** Eight +NEW independent reviewers cover all **340 paths / 1,834 ordered cumulative +hunks**, from Step 7 installed-package acceptance `d9625ff0` to `e7fe93c5`. +Root reads all eight original reports and every substantive path assessment, +rechecks full diff/hunk/current-body identities and verifies unchanged opening +and closing source/dependency/dist freezes. Explicit test-description and support +path clarifications qualify the original reports; they do not silently rewrite +them or confer unsupported runtime coverage. Model/effort identifies parent +dispatch, not independently attested backend execution. + +Two Medium findings are independently reproduced before implementation: + +- **R23-WA-01:** a rejected grace/orphan deletion, or synchronous deletion-entry + refusal after a prior entry, lets the best-effort sweep return while a sibling + deletion is pending. The actual workflow command closes SQLite and retires its + original owners before that sibling settles. Healthy held-sibling controls keep + the command/database/owners alive until release. +- **R23-NB-1:** a rejected CAS shard lets `listHandles` and the same default + terminal sweep settle while another entered shard continuation remains held. + The real command/engine/SQLite path reproduces early release; the healthy + counterpart retains ownership. + +The parallel helpers predate Step 7. These findings contradict Step 8's new +joined-maintenance guarantee and actual owner integration; they do not claim the +old parallel algorithm or run exit code originated in this wave. Deletion ports +are controlled promises/throws. The shard fixture starts a real native read and +holds its adapter continuation; the sibling EIO is synthetic. No late database +access, data loss, real pending OS unlink, measured filesystem latency, remote +provider failure or native Windows failure is inferred. + +The correction observes and joins every entered deletion and launched shard +branch before propagating its original failure. Synchronous deletion-entry +refusal starts no later deletion and still joins earlier entries. Existing +best-effort outcomes, admission, timeout, absolute termination budget and native +close acknowledgement retain their meanings. The [CLI ownership contract](../../reference/cli/commands.md#workflow-and-terminal-ownership) +owns the concrete requirement; no new decision or approval is needed. + +Ten NEW permanent cases include six grace/orphan healthy/rejection/synchronous +command controls, two actual-shard command controls, and two unit controls that +also pin original-cause identity and no later entry after a synchronous refusal. +Author causal validation runs both complete files / 35 cases in seven phases: +fixed, three independent mechanism removals and each restoration. All fixed and +restored phases pass; removals fail seven named semantic assertions across +**245 case executions**. Every phase records an empty explicit runtime-error +channel, completed Vitest close and actual Node OS wait; the outer orchestrator +also exits 0. Product source/index remain unchanged by isolated controls and +restoration is byte-exact. An earlier candidate setup failed before collecting +tests because it misread the alias manifest shape; it supplies no acceptance +evidence. These are author controls, not a fresh independent review or platform +matrix. Detailed reports and runtime records remain local and Git-ignored. + +Earlier clean R21/security/CI evidence belongs to `c491836a`; R23 belongs to +`e7fe93c5`. At the latter source, all fifteen jobs in +[CI run 39037060129](https://github.com/HodeTech/Relavium/actions/runs/39037060129) +complete successfully. All four physically inspected same-tarball consumers pass: +current Linux/macOS/Windows and minimum Node 22.0.0. The five +downloaded ZIPs agree on the 70-file public package and **446,599-byte** tarball, +SHA-256 `887559ac65bd6d23e40719a1a6e7261cb8db1efc3bf2249e77c2e06295f34dc4`. +These results precede this material maintenance correction and do not accept it. +Two NEW complete cumulative clean rounds, NEW separate clean security and fresh +corrected-source full CI plus all four actual same-artifact consumers remain +required. Steps 9–19 have not started; no user decision, credential, billing or +provider capture is pending. + +#### R23 maintenance-correction author gates + +Lint/typecheck passes all 17 tasks (12 cache hits); serialized workspace tests +pass all 11 tasks (nine cache hits). The changed DB and CLI suites execute: +DB reports **64 files / 1,062 passes**, CLI **362 files / 5,665 passes / ten +established skips**. Cached sibling results retain their cache attribution. +Build/format passes seven tasks (five cache hits), test isolation collects 743 +files, seam and engine-dependency checks pass, finite compiled closure verifies +25 outputs / 410 inputs / 17 externals, bootstrap passes 177 controls and compiled +CLI smoke passes. Every author subprocess and the outer orchestrator is joined +with exit 0. These checks are author validation, not the two NEW independent +reviews, NEW security sitting or fresh full-CI/platform acceptance still required. diff --git a/packages/db/src/media-store.ts b/packages/db/src/media-store.ts index 5ff42219..0a6d7655 100644 --- a/packages/db/src/media-store.ts +++ b/packages/db/src/media-store.ts @@ -374,6 +374,7 @@ export class FilesystemMediaStore implements MediaStore { throw this.#paths.error(error); }); this.#paths.root(false); + let failure: { readonly cause: unknown } | undefined; const perShard = await Promise.all( shards .filter( @@ -396,8 +397,16 @@ export class FilesystemMediaStore implements MediaStore { if (info !== undefined) found.push({ handle, mtimeMs: info.mtimeMs }); } return found; - }), + }) + .map((work) => + work.catch((cause: unknown) => { + // Propagate the original typed failure only after every launched shard has settled. + failure ??= { cause }; + return []; + }), + ), ); + if (failure !== undefined) throw failure.cause; return perShard.flat(); } } From c83c8575b0baaa9bf20765b03dffb00623ed96c6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 18:21:03 +0300 Subject: [PATCH 155/222] test(cli): isolate builder admission from disk chat startup Use a real in-memory SQLite SessionStore create/load roundtrip for the direct fresh/resumed builder guard test. Preserve its five-second bound, all refusal and no-service assertions, captured abort ownership and the surrounding disk-backed chat lifecycle cases. Record the complete R24 review and Windows advisory qualification in the roadmap and agent guides. Author CI-mode and guard-removal controls do not replace fresh independent reviews, security or platform acceptance. Refs: ADR-0105, ADR-0110 Co-Authored-By: Codex --- AGENTS.md | 29 ++++---- CLAUDE.md | 29 ++++---- .../commands/fs-workspace-admission.test.ts | 50 ++++++++++++- docs/roadmap/current.md | 15 +++- .../phase-2.5.5-hardening-and-remediation.md | 73 +++++++++++++++++++ 5 files changed, 162 insertions(+), 34 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2215b952..9a2893fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,20 +107,21 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. R21 is complete CLEAN at `c491836a`; R22 and -R23 are NONCLEAN. R23 covers all 340 paths / 1,834 cumulative hunks at `e7fe93c5` -and confirms two Medium joined-maintenance gaps: a failed media deletion or CAS -shard can release the command/database owner while an entered sibling remains -pending. Root independently reproduces both with real engine/SQLite and qualified -synthetic native-port failures. Corrections join every entered sibling before -propagating failure, preserving best-effort outcomes and existing budgets. Ten -new permanent cases and three independent fixed/removal/restored mechanisms -verify the correction; this is author evidence, not independent acceptance. -Earlier clean review, security, CI and same-artifact consumers retain their exact -reviewed-source attribution and do not accept later material fixes. Two NEW -complete cumulative clean rounds, a NEW separate clean security sitting and fresh -corrected-source full CI plus all four same-artifact consumers remain required. -Steps 9–19 have not started; no new decision or approval is needed. +but not independently accepted. R21 is complete CLEAN at `c491836a`; R22–R24 +are NONCLEAN. R24 covers all 342 paths / 1,842 cumulative hunks at `cef520d4`. +Its one Medium finding is test preparation: the direct-builder guard spends its +unchanged five-second case bound on unrelated disk-backed chat seeding. The actual +Windows source-native advisory fails; required CI is a separate job. The correction +uses a real in-memory SQLite SessionStore roundtrip only for that case, preserving +all original guard/no-service assertions, abort ownership and surrounding native +disk lifecycle cases. Author CI-mode focus passes 228 cases; independent fresh +and resume guard removals fail the exact case and byte-exact restorations pass. +This is author evidence, not independent acceptance. Earlier clean review, +security, CI and same-artifact consumers retain their exact reviewed-source +attribution. Two NEW complete cumulative clean rounds, a NEW separate clean +security sitting and fresh corrected-source full CI plus all four same-artifact +consumers remain required. Steps 9–19 have not started; no new decision or +approval is needed. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/CLAUDE.md b/CLAUDE.md index 4cf85ec9..e5516d97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,20 +196,21 @@ is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. R21 is complete CLEAN at `c491836a`; R22 and -R23 are NONCLEAN. R23 covers all 340 paths / 1,834 cumulative hunks at `e7fe93c5` -and confirms two Medium joined-maintenance gaps: a failed media deletion or CAS -shard can release the command/database owner while an entered sibling remains -pending. Root independently reproduces both with real engine/SQLite and qualified -synthetic native-port failures. Corrections join every entered sibling before -propagating failure, preserving best-effort outcomes and existing budgets. Ten -new permanent cases and three independent fixed/removal/restored mechanisms -verify the correction; this is author evidence, not independent acceptance. -Earlier clean review, security, CI and same-artifact consumers retain their exact -reviewed-source attribution and do not accept later material fixes. Two NEW -complete cumulative clean rounds, a NEW separate clean security sitting and fresh -corrected-source full CI plus all four same-artifact consumers remain required. -Steps 9–19 have not started; no new decision or approval is needed. +but not independently accepted. R21 is complete CLEAN at `c491836a`; R22–R24 +are NONCLEAN. R24 covers all 342 paths / 1,842 cumulative hunks at `cef520d4`. +Its one Medium finding is test preparation: the direct-builder guard spends its +unchanged five-second case bound on unrelated disk-backed chat seeding. The actual +Windows source-native advisory fails; required CI is a separate job. The correction +uses a real in-memory SQLite SessionStore roundtrip only for that case, preserving +all original guard/no-service assertions, abort ownership and surrounding native +disk lifecycle cases. Author CI-mode focus passes 228 cases; independent fresh +and resume guard removals fail the exact case and byte-exact restorations pass. +This is author evidence, not independent acceptance. Earlier clean review, +security, CI and same-artifact consumers retain their exact reviewed-source +attribution. Two NEW complete cumulative clean rounds, a NEW separate clean +security sitting and fresh corrected-source full CI plus all four same-artifact +consumers remain required. Steps 9–19 have not started; no new decision or +approval is needed. Step 8 proceeds as recorded serial increments 8a–8d; native fatal integration remains open until all actual owners are connected. Later implementation and whole-wave acceptance diff --git a/apps/cli/src/commands/fs-workspace-admission.test.ts b/apps/cli/src/commands/fs-workspace-admission.test.ts index 6dbb010c..d1aa9973 100644 --- a/apps/cli/src/commands/fs-workspace-admission.test.ts +++ b/apps/cli/src/commands/fs-workspace-admission.test.ts @@ -14,7 +14,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { createClient, createSessionStore } from '@relavium/db'; +import { createClient, createSessionStore, runMigrations } from '@relavium/db'; +import { AgentSchema } from '@relavium/shared'; import { buildChatSession, buildResumedChatSession } from '../chat/session-host.js'; import { scriptedResolver } from '../chat/test-support.js'; @@ -223,6 +224,49 @@ async function seed(fixture: WorkspaceCase, cwd: string): Promise { fixture.assertCurrent(); } +function loadBuilderSession(fixture: WorkspaceCase) { + fixture.assertCurrent(); + // Direct constructor admission needs a validated stored record, not a disk-backed chat lifecycle. + // The surrounding command/resume cases retain their real on-disk startup and shutdown. + const client = createClient(':memory:'); + try { + runMigrations(client.db); + const store = createSessionStore(client.db); + const agent = AgentSchema.parse({ + id: 'workspace-floor-agent', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + system_prompt: 'Synthetic fixture', + tools: [], + }); + const iso = '2026-10-17T00:00:00.000Z'; + store.createSession({ + id: 'workspace-floor-session', + agentSlug: agent.id, + agentSnapshot: agent, + context: { workingDir: fixture.workspace, fsScopeTier: 'project' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: iso, + updatedAt: iso, + }); + const loaded = store.loadFull('workspace-floor-session'); + if (loaded === undefined) throw new Error('synthetic builder record missing'); + expect(loaded.session.context).toEqual({ + workingDir: fixture.workspace, + fsScopeTier: 'project', + }); + expect(loaded.session.agentSnapshot).toEqual(agent); + expect(loaded.messages).toEqual([]); + return loaded; + } finally { + client.sqlite.close(); + } +} + describe('secret workspace admission before canonical persistence', () => { it.each([[], ['chat'], ['agent', 'run', 'unused-agent.yaml']].map((surface) => ({ surface })))( 'refuses real argv surface %j before history/key/provider/MCP composition', @@ -366,9 +410,7 @@ describe('secret workspace admission before canonical persistence', () => { it('fresh and resumed builders refuse owned lexical provenance even with canonical cwd', async () => { return ownCase(async (fixture) => { const { workspace, sensitiveAlias } = fixture; - await seed(fixture, workspace); - const loaded = readSession(fixture); - if (loaded === undefined) throw new Error('synthetic seed missing'); + const loaded = loadBuilderSession(fixture); const chat = loadResolvedConfig({ cwd: workspace }).config.chat; const policy = createFsPathPolicy({ workspaceDir: sensitiveAlias }); const providers = scriptedResolver([]); diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 4bbfc350..084587e3 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1365,8 +1365,19 @@ while joining entered siblings through failure. Ten new permanent cases and thre independent causal removals cover the correction. This author evidence does not replace two NEW complete cumulative clean rounds, NEW separate security or fresh corrected-source full CI and all four same-artifact consumers. Steps 9–19 have not -started; no user decision is pending. Step 8d proceeds automatically. The -[final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) +started; no user decision is pending. Step 8d proceeds automatically. +The [R24 direct-builder fixture correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-r24-builder-fixture-correction--2026-10-17) +records complete NONCLEAN review of 342 paths / 1,842 cumulative hunks at +`cef520d4`, with one Medium test-preparation finding. The native Windows advisory +exceeds the original five-second case bound during unrelated disk-backed chat +seeding; required CI is separately attributed. Only the direct-builder fixture now +uses a validated real in-memory SQLite SessionStore roundtrip. All guard/no-service +assertions, fixture abort ownership, five-second bounds and surrounding on-disk +lifecycle cases remain. CI-mode focus passes 228 cases; two independent entry-guard +removals fail the exact case and every restoration passes. Two NEW complete +cumulative clean rounds, NEW separate security and fresh corrected-source full +CI/all four same-artifact consumers remain required. No user decision is pending. +The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. No provider capture, credential, billing or production upload is needed for this implementation. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 075e3bf3..441c2b57 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8143,3 +8143,76 @@ files, seam and engine-dependency checks pass, finite compiled closure verifies CLI smoke passes. Every author subprocess and the outer orchestrator is joined with exit 0. These checks are author validation, not the two NEW independent reviews, NEW security sitting or fresh full-CI/platform acceptance still required. + +### Wave 3 Step 8d R24 builder-fixture correction — 2026-10-17 + +**R24 is complete and NONCLEAN; Step 8d and whole Step 8 remain OPEN.** Eight +NEW independent reviewers cover all **342 paths / 1,842 ordered cumulative +hunks** from Step 7 installed-package acceptance `d9625ff0` to `cef520d4`. +Root reads every original report and all 342 substantive path assessments, +rechecks physical/Git/full-diff/ordered-hunk identities and independently matches +opening/closing tracked, installed-dependency and existing-dist freezes. Explicit +root qualifications narrow an overbroad Home test description and distinguish the +Windows advisory from required CI; originals remain unchanged. Exact-scope fresh +runtime records retain their empty error channels, actual Node/outer joins and +source/native/synthetic/platform limits. Invalid broad-scope attempts receive no +acceptance credit. Model/effort identifies parent dispatch, not backend attestation. + +**MS-R24-1 (Medium)** concerns the direct fresh/resume builder-admission test, +not a demonstrated product lifetime defect. In native Windows +[job 117421092884](https://github.com/HodeTech/Relavium/actions/runs/39041270052/job/117421092884), +full chat seeding takes **7,574.7133 ms** before the existing **5,000 ms** case +bound aborts. The captured native-open interval does not establish its underlying +latency cause or completion of individual builder assertions. That source-native +Windows job is advisory and is not a dependency of required `ci`; the original +review's broader required-CI wording is explicitly qualified. + +The correction replaces only this direct-constructor case's disk-backed chat +startup with an actual **in-memory SQLite SessionStore create/load roundtrip**. +A schema-valid frozen agent, canonical working directory and empty transcript +reach the same real fresh and resumed builders. The record's context, agent and +messages are asserted, and the exact SQLite connection closes in `finally`. +Both original `invalid_invocation` checks and every no-provider/key/UUID/MCP +assertion remain, along with the original case name, five-second deadline, +captured abort/join ownership and every surrounding real disk-backed command, +persistence and resumed-host lifecycle test. There is no new ADR or deferral. + +Author focus under **CI=true** passes four complete files / **228 cases**, with +explicit `onFinished` errors=[], completed Vitest close, actual Node OS wait and +outer exec join. Separate isolated controls run the complete 12-case workspace +file through five phases: fixed, fresh-entry guard removal/restoration and +resumed-entry guard removal/restoration. Each removal fails the exact original +builder-refusal case with its semantic assertion; fixed/restored phases pass. +All **60 case executions** preserve original bounds and exact case membership, +empty runtime-error channels and actual joins. Restoration is byte-exact and the +controls leave developer source/index unchanged. An initial wrapper escaping +error fails before collecting tests and supplies no acceptance credit. These are +author controls, not native Windows or independent review acceptance. + +At `cef520d4`, all four required same-tarball package consumers pass and their +five downloaded ZIPs are independently inspected against CI origin, native tar +headers, public/installed files and exact shared digest: **446,752 bytes / 70 +public files**, SHA-256 +`070e437c2c7ea02bd3ce72a8bef1f77cf58c3809af77edefc10ecd82e71309a1`. +Downloaded filesystem/secrets evidence independently verifies **110 / 41 controls** +and **330 / 123 phases**, their exact declaration sets, retained source/runtime/ +stdio/evaluation records and physical bounds. This is retained-evidence inspection, +not a new mutation execution. Those package/control results precede this fixture +correction; the Windows advisory remains failed and required CI retains its own +attributed outcome. None confers later-source acceptance. + +Two NEW complete cumulative clean rounds after the final material correction, a +NEW separate clean security sitting and fresh full CI plus all four actual +same-artifact consumers remain required. Steps 9–19 have not started. No user +approval, provider capture, credential, billing or production release operation +is pending. Detailed review and runtime records remain local and Git-ignored. + +#### R24 builder-fixture author gates + +Lint/typecheck passes all 17 tasks (15 cache hits); serialized workspace tests +pass all 11 tasks (ten cache hits). The changed CLI suite executes **362 files / +5,665 passes / ten established skips**; unchanged siblings retain cache attribution. +Build/format, test isolation, SDK seam, engine-dependency and finite bundle closure +pass, along with all 177 compiled bootstrap controls and compiled CLI smoke. +Every author subprocess and the outer orchestrator joins with exit 0. The later +independent review/security/full-CI/platform gates above remain required. From 00fb72d2633b3a99897ae2af6ea55c85c8514704 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 20:19:03 +0300 Subject: [PATCH 156/222] docs(docs): record step 8d acceptance and handoff boundary Close Step 8d and whole Step 8 at reviewed source c83c8575 after two new complete cumulative clean rounds, separate security and successful full CI plus all four same-artifact consumers. Reconcile living status and preserve historical failures, runtime limits and source attribution. Record the remaining 9-19 boundary for another agent without starting later implementation or opening a PR. Detailed handoff stays ignored. This commit changes documentation only; source CI belongs to c83c8575. Refs: ADR-0110, Wave 3 Step 8 Co-Authored-By: Codex --- AGENTS.md | 35 ++++---- CLAUDE.md | 35 ++++---- ...d-discovery-have-explicit-compatibility.md | 11 +++ docs/reference/cli/commands.md | 45 ++++++----- docs/reference/cli/home.md | 6 +- docs/reference/cli/packaging.md | 10 +-- docs/reference/shared-core/mcp-integration.md | 9 ++- docs/roadmap/current.md | 25 ++++-- .../phase-2.5.5-hardening-and-remediation.md | 80 +++++++++++++++++++ docs/standards/security-review.md | 29 +++++++ 10 files changed, 205 insertions(+), 80 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9a2893fc..96efcbf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,27 +105,20 @@ platform consumers remain attributed to `ab50be11`, with independently verified source/dependency equivalence. Step 8c workflow/preparation/terminal integration is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four -same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. -Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. R21 is complete CLEAN at `c491836a`; R22–R24 -are NONCLEAN. R24 covers all 342 paths / 1,842 cumulative hunks at `cef520d4`. -Its one Medium finding is test preparation: the direct-builder guard spends its -unchanged five-second case bound on unrelated disk-backed chat seeding. The actual -Windows source-native advisory fails; required CI is a separate job. The correction -uses a real in-memory SQLite SessionStore roundtrip only for that case, preserving -all original guard/no-service assertions, abort ownership and surrounding native -disk lifecycle cases. Author CI-mode focus passes 228 cases; independent fresh -and resume guard removals fail the exact case and byte-exact restorations pass. -This is author evidence, not independent acceptance. Earlier clean review, -security, CI and same-artifact consumers retain their exact reviewed-source -attribution. Two NEW complete cumulative clean rounds, a NEW separate clean -security sitting and fresh corrected-source full CI plus all four same-artifact -consumers remain required. Steps 9–19 have not started; no new decision or -approval is needed. -Step 8 proceeds as recorded serial increments 8a–8d; native -fatal integration remains open until all actual owners are connected. -Later implementation and whole-wave acceptance -remain staged in the canonical roadmap. +same-tarball platform consumers pass at that reviewed source. +**Step 8d and whole Step 8 are independently accepted on `development` at +`c83c8575` — 2026-10-17.** Two NEW complete cumulative CLEAN rounds (R25/R26) +each cover all 342 paths / 1,842 ordered hunks after the final fixture correction. +A NEW separate clean security sitting and all fifteen full-CI jobs pass at that +reviewed source, as do all four actual same-tarball platform consumers. Original +failed reviewer-preparation records and source/native/platform limits remain +explicit; source CI does not certify later documentation commits. The +[acceptance register](docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) +joins exact evidence and historical corrections. Steps 1–8 are accepted; +Steps 9–19 have NOT STARTED. The maintainer requested a timestamped private +handoff for another agent; this session starts no Step 9 work or PR. No provider, +credential, billing or new ADR approval is pending. Later implementation and +whole-wave acceptance remain staged in the canonical roadmap. See [docs/roadmap/current.md](docs/roadmap/current.md) for live status. diff --git a/CLAUDE.md b/CLAUDE.md index e5516d97..6f263cee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -194,27 +194,20 @@ platform consumers remain attributed to `ab50be11`, with independently verified source/dependency equivalence. Step 8c workflow/preparation/terminal integration is independently accepted at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI. All four -same-tarball platform consumers pass at that reviewed source. Whole Step 8 remains open. -Step 8d session/terminal integration and product fatal activation are implemented -but not independently accepted. R21 is complete CLEAN at `c491836a`; R22–R24 -are NONCLEAN. R24 covers all 342 paths / 1,842 cumulative hunks at `cef520d4`. -Its one Medium finding is test preparation: the direct-builder guard spends its -unchanged five-second case bound on unrelated disk-backed chat seeding. The actual -Windows source-native advisory fails; required CI is a separate job. The correction -uses a real in-memory SQLite SessionStore roundtrip only for that case, preserving -all original guard/no-service assertions, abort ownership and surrounding native -disk lifecycle cases. Author CI-mode focus passes 228 cases; independent fresh -and resume guard removals fail the exact case and byte-exact restorations pass. -This is author evidence, not independent acceptance. Earlier clean review, -security, CI and same-artifact consumers retain their exact reviewed-source -attribution. Two NEW complete cumulative clean rounds, a NEW separate clean -security sitting and fresh corrected-source full CI plus all four same-artifact -consumers remain required. Steps 9–19 have not started; no new decision or -approval is needed. -Step 8 proceeds as recorded serial increments 8a–8d; native -fatal integration remains open until all actual owners are connected. -Later implementation and whole-wave acceptance -remain staged in the canonical roadmap. +same-tarball platform consumers pass at that reviewed source. +**Step 8d and whole Step 8 are independently accepted on `development` at +`c83c8575` — 2026-10-17.** Two NEW complete cumulative CLEAN rounds (R25/R26) +each cover all 342 paths / 1,842 ordered hunks after the final fixture correction. +A NEW separate clean security sitting and all fifteen full-CI jobs pass at that +reviewed source, as do all four actual same-tarball platform consumers. Original +failed reviewer-preparation records and source/native/platform limits remain +explicit; source CI does not certify later documentation commits. The +[acceptance register](docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) +joins exact evidence and historical corrections. Steps 1–8 are accepted; +Steps 9–19 have NOT STARTED. The maintainer requested a timestamped private +handoff for another agent; this session starts no Step 9 work or PR. No provider, +credential, billing or new ADR approval is pending. Later implementation and +whole-wave acceptance remain staged in the canonical roadmap. For live status, per-PR history, milestone dates, and open obligations, see the canonical home [docs/roadmap/current.md](docs/roadmap/current.md); diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index 14ff2c8a..b8418195 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -316,3 +316,14 @@ the [canonical discovery contract](../reference/shared-core/llm-provider-seam.md Step 8d and whole Step 8 remain open until the corrected source has two new complete cumulative clean reviews, a new separate security sitting and fresh full CI plus all four same-artifact package consumers. + +## Step 8 implementation landing — 2026-10-17 + +[Step 8d and whole Step 8 are independently accepted on `development`](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) +at `c83c8575`, after two NEW complete cumulative CLEAN rounds following the final +fixture correction, a NEW separate CLEAN security sitting and successful full CI +plus all four actual same-tarball consumers. Earlier dated implementation and +failed-review checkpoints retain their historical scopes. This lands native +owner/session/terminal/fatal integration; it does not claim Step 10's unshipped +CLI compatibility work or whole-Wave-3 acceptance. No new decision or deferral +is added. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 26f8422e..d62886b1 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -607,9 +607,10 @@ also retain their [owned signal dispositions](#invocation-owners-and-fatal-order | `8` | Required money receipts were not durably acknowledged. **Do not repeat paid work**: inspect `relavium logs ` and the ledger before recovery. This is separate from terminal-outbox uncertainty | | `9` | CLI infrastructure failed, including output delivery. Inspect existing work before repeating paid/effectful operations; see the [classification and remedy contract](#failure-classification-and-remedies) | -Wave 3 Step 8a implements runtime origin classification and fixed boundary projections. -Full fatal coordination, actual workflow-health precedence on escaping faults and -all-surface integration remain staged in Steps 8b–8d below. +Wave 3 Step 8 implements runtime origin classification, fixed boundary projections, +fatal coordination and actual workflow-health precedence on escaping faults. +[Step 8d and whole Step 8 are independently accepted on `development`](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17); +the surface and ownership contracts below describe that implementation. > Final required-money uncertainty takes priority (`8`), then effect attention (`7`), then the actual terminal/ownership disposition (`5`/`6`) and ordinary outcome. A combined `8` warning also directs the operator to inspect the effect target and resolve its row. Final health never replaces the actual terminal, its error code or durable history; it selects the command's disposition after joining admitted work. > The fixed final-health stderr diagnostic has `type: diagnostic`, `code`, `message`, `terminalDurability` (`durable`, `uncertain` or `none`) and `effectNeedsAttention`. It reports one highest-priority warning; a combined money/effect case includes both remedies. These fields are nondurable and never added to stdout RunEvents. @@ -662,7 +663,7 @@ Numeric cost totals are known realized sums. Human totals qualify unverified his ## Proposed Wave 3 CLI compatibility -**Approved design, partially implemented on `development` — 2026-10-16.** +**Approved design, partially implemented on `development` — 2026-10-17.** [ADR-0110](../../decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md) was approved by the maintainer. Step 6 implements the pure flag/error/observation and ownership substrate, descriptor-derived current Commander grammar and the @@ -671,9 +672,10 @@ production JSON help, aliases, pricing spellings, output categories and consent remain Step 10. Step 7's lazy entry and installed package are independently accepted. Step 8a implements minimal storage-origin adapters and defensive runtime projections, and adds an inert native invocation foundation. Product fatal/native/terminal -activation is implemented in Step 8d on `development`; its complete cumulative -review, separate security sitting and current platform acceptance remain open. Earlier sections describe current -behaviour unless stated here. +activation is [independently accepted in Step 8d and whole Step 8](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) +on `development`. The remaining Step 10 behaviour described in this proposed +section is still unshipped. Earlier sections describe current behaviour unless +stated here. ### Diagnostic policy @@ -832,7 +834,8 @@ numeric value alone ([Node exit codes](https://nodejs.org/docs/latest-v22.x/api/ ### Invocation owners and fatal ordering -**Step 8d activation implemented; independent acceptance pending.** The registry +**Step 8d activation independently accepted on `development`.** The [acceptance register](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) +records exact-source reviews, security and platform evidence. The registry exposes read-only termination and remaining-budget observations so an explicitly installed native adapter can react to autonomous owner failure. The adapter observes both real output streams and uses the same absolute @@ -911,7 +914,7 @@ This attempt-all cancellation rule grants no retirement or safe-close ACK. ### Session ownership implementation checkpoint -**Partial Step 8d implementation on `development`; not independently accepted.** +**Step 8d independently accepted on `development`.** See the [acceptance register](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17). Standalone `agent run` now reserves its surface and host owners before asynchronous input, consent or session construction. The original invocation owns captured stdin cancellation, late builder adoption, producer/accounting joins, MCP cleanup @@ -963,8 +966,8 @@ checks admission before publication; unexpected local clock/key/publication faul are observed before sibling discovery joins. Optional cache reads keep their existing recoverable UI policy. -These are **Step 8d implementation claims, not acceptance**. Complete cumulative -reviews, security and current required platform acceptance remain open. Home +These contracts are covered by [Step 8d acceptance](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17), including complete +cumulative reviews, separate security and current required platform evidence. Home post-chat snapshot failures reach the actual lifetime; ordinary slash notices are suppressed after owned stop, including delayed doctor/compaction results and export fault catches. Standalone shell rejection notices and directory-completion @@ -976,11 +979,11 @@ shell results and model refresh banners before changing business state. Local bu cleanup and already-entered producer/native joins still complete after that seal. The [session substrate checkpoint](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-session-substrate-checkpoint--2026-10-16) and [chat/Home checkpoint](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-chat-and-home-integration-checkpoint--2026-10-17) -record their exact scopes. Whole Step 8 remains open. +record their historical implementation scopes; the final acceptance above closes whole Step 8. ### Management ownership implementation checkpoint -**Partial Step 8d integration on `development`; not independently accepted.** +**Step 8d independently accepted on `development`.** See the [acceptance register](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17). Management dispatch and the separate media-maintenance route reserve a command owner before entering work. Provider verification and both model-refresh axes retain actual operations beyond early report/failure results. Their original @@ -1022,9 +1025,9 @@ The standalone create wizard binds installed Clack prompts to captured streams and checks admission between answers and before writing. Its already-entered terminal restoration uses the original captured control capability, separately from guarded business output. No deadline certifies input, terminal or database -release. Bootstrap/caught-fatal activation is implemented; complete Step 8d -review, security and platform gates remain open in the -[implementation register](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-management-ownership-checkpoint--2026-10-17). +release. Bootstrap/caught-fatal activation and management ownership are covered +by the final [Step 8d acceptance](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17); earlier implementation checkpoints +retain their historical scopes. ### Workflow and terminal ownership @@ -1099,8 +1102,9 @@ The native bootstrap takes the original observer, deadline and physical-exit latch before runtime entry, invalidating any queued fallback completion. Actual terminal restoration and the sole fixed final diagnostic require native ACK within the unchanged budget. A background-only selected exit 9 does not become -a fatal shutdown merely because a completed surface returns it. Whole Step 8 -remains open until Step 8d cumulative review, security and platform acceptance. +a fatal shutdown merely because a completed surface returns it. Whole Step 8 is +[independently accepted](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) with its cumulative review, security and +actual-platform qualifications. ### Native crash teardown scope @@ -1119,8 +1123,9 @@ active native capabilities. There is no completed PID/group history or independe product MCP reaper collection. Workflow routing follows the ownership section above; Step 8d implements the corresponding session/management lifetimes and activates the product coordinator. Source fixtures retain their separately -attributed explicit foundation tests; they do not certify product activation. Workflow/session/terminal retirement and -safe DB close remain their separate integration obligations. +attributed explicit foundation tests; those fixtures alone do not certify product +activation. Workflow/session/terminal retirement and safe DB close have separate +acknowledgement obligations, covered by the accepted 8c/8d integration above. | Creation owner / platform | Permitted finite teardown | |---|---| diff --git a/docs/reference/cli/home.md b/docs/reference/cli/home.md index 59a395c8..049f2227 100644 --- a/docs/reference/cli/home.md +++ b/docs/reference/cli/home.md @@ -1,6 +1,6 @@ # `relavium` — Bare-invocation interactive Home -> Last updated: 2026-10-16 +> Last updated: 2026-10-17 - **Status**: Reference — the Home surface is delivered by **2.5.B** (the bare-invocation Home, the read-only management strip over `history.db`, the single-ink-tree mode machine, one SIGINT/SIGTERM lifecycle, and bracketed paste); the in-app slash palette / `/help` / `/doctor` + the context-aware footer landed in **2.5.C**; and the reseat-less mode keymap (`Shift+Tab` / `/mode`) + the fail-closed per-tool approval + the `Esc` mid-turn abort landed in **2.5.E** ([ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md)) — all **extend** this same canonical home. The Home-side **`/models` picker** landed in **2.5.G**: in the bare Home an accepted pick writes the global `[preferences].default_model` (+ the effort tier) for the *next* session ([ADR-0063](../../decisions/0063-cli-config-write-contract.md) §1, [ADR-0064](../../decisions/0064-live-model-catalog.md)); in the in-Home **chat** the same picker **reseats** the live session instead ([chat-session.md](chat-session.md#model-reseat-models)). - **Surface**: CLI (a bare `relavium` with no subcommand, on a TTY) @@ -31,8 +31,8 @@ retain exit `1`, and an unexpected escaping throw uses the fixed `cli_internal` projection and exit `9`, without its incoming message, cause or stack in that projection. The legacy broad `internal` code is not remapped wholesale. Step 8d implements caught Home/session fault integration and the complete fatal coordinator; -its independent acceptance remains pending. This does not turn a classified failed -chat turn into a fatal CLI failure. +it is [independently accepted on `development`](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17). This does not turn a +classified failed chat turn into a fatal CLI failure. ## First-run onboarding wizard (2.5.G S8) diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index 40c724d3..cb4b7571 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -1,6 +1,6 @@ # CLI Packaging Contract -- **Status**: Compiled graph and installed package independently accepted on `development`; Step 8d activation implemented, acceptance and release gates staged +- **Status**: Compiled graph, installed package and Step 8 native activation independently accepted on `development`; Step 9 map/custody and release gates remain staged - **Date**: 2026-10-15 - **Related**: [ADR-0111](../../decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md), [commands.md](commands.md), [release runbook](../../runbooks/release-a-surface.md) @@ -85,8 +85,8 @@ fatal/background/output hooks and native close-delivery ordering through the untouched entry. Interactive restoration and deadline nonrenewal retain separate source controls. The exact owned Node 22.0.0 experimental JSON-module warning is retained separately from product diagnostics; unknown runtime output still fails -the diagnostic grammar and ordering controls. Step 8d's cumulative -review/security/platform acceptance remains open. +the diagnostic grammar and ordering controls. [Step 8d and whole Step 8 acceptance](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) +records cumulative reviews, separate security and actual same-artifact platforms. The emitted graph checker parses actual JavaScript and compares every loading edge with the build inventory. It rejects missing/extra/unreachable/escaping chunks, @@ -136,8 +136,8 @@ and independent acceptance are recorded separately in the Wave 3 register. This intermediate receipt does not claim the full map-bearing package-test mode below. Original-TypeScript symbolication, private staging and release custody -remain Step 9; native fatal-owner integration remains Step 8. No intermediate -local result closes those obligations or certifies a release. +remain Step 9. Native fatal-owner integration is covered by [Step 8 acceptance](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17). +No intermediate local result closes Step 9 or certifies a release. ### Candidate finalization — Step 9 diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index 261ac6a0..69fd4b03 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -203,8 +203,9 @@ acceptance is recorded in [current.md](../../roadmap/current.md). ### Failed-start cleanup lifecycle -**Wave 3 Step 8d partial implementation on `development`; independent acceptance -is pending.** `startMcpClient` accepts an optional fourth `McpStartupLifecycle` +**Wave 3 Step 8d independently accepted on `development`.** The [acceptance register](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) +records cumulative reviews, security and platform evidence. `startMcpClient` +accepts an optional fourth `McpStartupLifecycle` argument, separately from its existing third work-transfer argument. Its `onCloseError(serverId, cause)` observes each failed-start connection close refusal immediately, including while a different close is held. Its read-only @@ -234,8 +235,8 @@ The CLI forwards this lifecycle through the actual default manager. Custom starters that delegate to `startMcpClient` must forward the fourth argument to retain the same failed-start observations. No vendor handle, PID authority, receipt success or new engine error code is carried by this channel. Final -session/terminal integration and global activation remain Step 8d in the -[execution plan](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments). +session/terminal integration and global activation are covered by [Step 8d +acceptance](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17). ### Discovery and result ingress bounds diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 084587e3..ea2eb3a4 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1,7 +1,7 @@ # Current state > Status: Living -> Last updated: 2026-10-15 +> Last updated: 2026-10-17 - **Related**: [README.md](README.md), [phases/phase-2.5-cli-consolidation.md](phases/phase-2.5-cli-consolidation.md), [phases/phase-2.5.5-hardening-and-remediation.md](phases/phase-2.5.5-hardening-and-remediation.md), [phases/phase-2-cli.md](phases/phase-2-cli.md), [deferred-tasks.md](deferred-tasks.md), [../project-structure.md](../project-structure.md), [../tech-stack.md](../tech-stack.md) @@ -1142,8 +1142,13 @@ four required same-tarball consumers pass at `ab50be11`; independent equivalence checks preserve that execution attribution. [Step 8c workflow/terminal integration is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8c-acceptance--2026-10-16) at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean security sitting and successful full CI plus all four same-tarball platform -consumers. Step 8d session/terminal integration and product fatal activation are -implemented but their acceptance remains open; whole Step 8 and Steps 9–19 remain open. Its +consumers. [Step 8d and whole Step 8 are independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) +at `c83c8575` after two NEW complete cumulative CLEAN rounds, a NEW separate +clean security sitting and successful full CI plus all four actual same-tarball +consumers. Steps 1–8 are accepted on `development`; Steps 9–19 have NOT STARTED. +The maintainer requested a private handoff for another agent; no Step 9 work or PR +is started in this session. The checkpoints below are historical observations; +their then-open gates are resolved only by the final acceptance linked above. The earlier [2026-10-17 chat/Home implementation checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-chat-and-home-integration-checkpoint--2026-10-17) records partial session/native-terminal integration and the remaining activation, review/security/platform gates without claiming acceptance. The subsequent @@ -1151,9 +1156,9 @@ review/security/platform gates without claiming acceptance. The subsequent records remaining command, prompt, doctor and transient-resource integration; [native activation checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-native-activation-implementation-checkpoint--2026-10-17) records product bootstrap activation at `a2acba8e` and the eighth required -installed case at `2928d8ef`. Implementation is present; two NEW complete -cumulative clean reviews, a separate clean security sitting and current full -CI/platform acceptance remain open. The +installed case at `2928d8ef`. At that checkpoint, two NEW complete cumulative +clean reviews, separate security and current full CI/platform acceptance were +still required. Subsequent historical rounds retain their exact source scopes. The [first cumulative round and correction register](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-first-cumulative-review-and-corrections--2026-10-17) records complete NONCLEAN coverage of all 255 paths and the verified corrections; no clean-round acceptance is inferred. The [second cumulative round and native-input corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-second-cumulative-review-and-native-input-corrections--2026-10-17) @@ -1377,6 +1382,14 @@ lifecycle cases remain. CI-mode focus passes 228 cases; two independent entry-gu removals fail the exact case and every restoration passes. Two NEW complete cumulative clean rounds, NEW separate security and fresh corrected-source full CI/all four same-artifact consumers remain required. No user decision is pending. + +**Latest status — 2026-10-17:** the [final Step 8d / whole Step 8 acceptance](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) +closes those gates at `c83c8575`: R25/R26 each cover all 342 paths / 1,842 hunks +and are CLEAN; separate security is CLEAN; full CI completes 15/15 jobs and all +four actual same-tarball consumers succeed. Earlier failed/partial records remain +historical and are not relabelled. Steps 9–19 remain unstarted for the next agent; +no new decision, credential, billing or release operation is needed to begin Step 9. + The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) remain proposal evidence, not implementation review. The selected encrypted archive method persists; production recipient/recovery belongs to Wave 4b release setup. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 441c2b57..9b084023 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8216,3 +8216,83 @@ Build/format, test isolation, SDK seam, engine-dependency and finite bundle clos pass, along with all 177 compiled bootstrap controls and compiled CLI smoke. Every author subprocess and the outer orchestrator joins with exit 0. The later independent review/security/full-CI/platform gates above remain required. + +### Wave 3 Step 8d and whole Step 8 acceptance — 2026-10-17 + +**Step 8d and whole Step 8 are independently ACCEPTED on `development` at +`c83c8575b0baaa9bf20765b03dffb00623ed96c6`.** This closes session, Home, +one-shot, pending/replaced host, terminal and product fatal/output/background +integration under [ADR-0110](../../decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md). +The original remaining budget never supplies a native-close, receipt, terminal +delivery or database-safe-close acknowledgement. Classified turn failures and +existing recovery/signal meanings remain separate from infrastructure 9. + +After the R24 fixture correction, **R25 and R26 are two NEW complete cumulative +CLEAN correctness rounds**, each covering all **342 disjoint paths / 1,842 ordered +hunks** from accepted Step 7 `d9625ff0` through `c83c8575`. Each round uses eight +fresh independent reviewers. Root personally reads all eight original reports +and every path assessment, independently verifies physical/Git/full-diff/ordered +hunk identities and raw runtime/error/join records, then closes the whole round. +Opening and closing freezes match all **1,761 tracked files, 21,165 installed +dependency files, 1,215 lexical dependency symlinks and 466 existing dist files**; +the index's membership/blob/mode equals HEAD. Root closure receipt SHA-256: +R25 `d423c086ca2e9adbd594cec57a879b923c498c43330c0669d8eda5277222b273`; +R26 `dac98a610c3c32811dd87dc97c6e7657b6fd853cf59d1b673ef8ee3259b6646b`. + +The complete source reviews retain their runtime qualifications. In particular, +R25's tooling arithmetic wrapper and R26's Home collection wrapper actually exit +nonzero; their failed harness receipts stay failed. Root independently checks the +complete passing product-case records and explicit empty error channels, records +the collection/arithmetic limitations and does not invent missing callback +history or rerun a successful suite merely to repair metadata. Original reports +remain unchanged; explicit supplements narrow imprecise fixture descriptions. +Parent source aliases do not certify native-child source/dist equivalence. +Existing-dist checks, synthetic seams, local platform skips and actual OS/outer +joins keep their individual attribution. Overlapping review suites are reported +separately. + +A **NEW separate CLEAN security sitting** reviews the cumulative ownership, +provenance and fixed-diagnostic boundaries. Its full report and every semantic +scope/exclusion assessment are independently read and verified by root. Fresh +execution covers **75 complete files / 1,105 passes / one actual-Windows-only +local skip**, with explicit runtime errors=[], completed Vitest close and actual +Node/outer joins. This is a focused security audit, not full-body correctness +certification of all 342 paths. The [security sitting](../../standards/security-review.md#sitting-wave-3-sessionterminal-ownership-and-fatal-activation-2026-10-17) +records its scope and limits. + +At the same reviewed source, [full CI run 39044664485](https://github.com/HodeTech/Relavium/actions/runs/39044664485) +completes **15/15 jobs successfully**. Main CI records lint/typecheck **17/17** +tasks (five cache hits), serialized workspace tests **11/11** (five cache hits) +and build **6/6** (six cache hits). Reported workspace totals are **743 files / +15,635 passes / 22 established skips**; cached results retain their cache +attribution. Formatting, isolation, seam/dependency/bundle fences, 177 bootstrap +controls, compiled CLI/overflow smokes and replay compatibility pass. Replay +records **16 strict refusals / two positive controls / zero fetches**. Required +engine coverage also passes; core's later required-floor promotion remains +Step 19. The separate source-native Windows advisory succeeds, including the +12-case direct-builder file within its unchanged five-second case bound; it +remains advisory rather than a dependency of required `ci`. + +All four actual installed consumers succeed against one independently inspected +tarball: current Linux/macOS/Windows on **Node 24.21.0** and minimum Linux on +**Node 22.0.0**. The archive is **446,750 bytes / 70 public files**, SHA-256 +`a64f4ff2cbd444d6bc37e6b0938c42e0a6ebca0ddcd7905799c718346764558f`. +The five downloaded producer/consumer ZIPs are checked against actual CI origin, +native tar headers/modes, public and installed inventories, source and digest. +Downloaded current-source filesystem/secrets controls independently verify +**110 / 41 controls and 330 / 123 phases**, exact declaration sets, retained +source/runtime/stdio/evaluation/restoration and physical bounds, with zero +unhandled errors. Archive inspection is not a new local control execution. + +No operator TTY, live provider, keychain, paid call, production release, opaque +native undici dispatcher closure or universal descendant containment is inferred. +Actual native closure is not receipt durability. Earlier NONCLEAN rounds and +failed/old-source CI observations remain historical; later documentation commits +are not claimed executed by source CI. Detailed reports and physical records +remain local and Git-ignored. No new ADR, decision, dependency or deferral is added. + +**Steps 1–8 are accepted on `development`; Steps 9–19 have NOT STARTED.** The +maintainer requested closure and a timestamped private handoff for another agent; +this session starts no Step 9 work and opens no PR. Next is approved Step 9's +release map/custody substrate in the [nineteen-increment plan](#proposed-implementation-increments). +Whole Wave 3 and whole Phase 2.5.5/2.6 remain open. diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index e858c764..763c13c4 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -702,6 +702,35 @@ finding or new deferral remains in 8c. Session/terminal integration, global nati activation and final acknowledged diagnosis remain Step 8d; whole Step 8 and whole-wave acceptance are still open. +### Sitting: Wave 3 session/terminal ownership and fatal activation (2026-10-17) + +A NEW separate sitting is **CLEAN** at +`c83c8575b0baaa9bf20765b03dffb00623ed96c6`, independent of the two NEW +complete cumulative correctness rounds. Its inventory spans Step 7 `d9625ff0` +through the current **342 paths / 1,842 hunks**, with full bodies for 71 critical +ownership/tooling paths and explicit changed-caller/test/document scope and +exclusions elsewhere. This focused sitting is not a full-body correctness +review of all 342 paths. Root reads the complete original report and every scope +assessment/exclusion and independently verifies source and artifact pins. +Report SHA-256: `caa33566005c53a9036f589224584d38a0284cf7d9d2c4628434fc014261d325`. + +Fresh execution covers **75 complete files / 1,105 passes / one actual-Windows-only +local skip**, with explicit runtime errors=[], completed Vitest close and actual +Node/outer joins. The audit covers captured input/output and secret custody, +fixed fault projection, original-owner native PID/PGID authority, partial +acquisition and transferred work, MCP consent/discovery, session/Home/terminal +restoration and receipt-versus-cleanup acknowledgement boundaries. No confirmed +finding or new deferral remains. [Final Step 8 acceptance](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) separately +joins successful full CI and all four actual same-tarball consumers at this source. + +Parent aliases do not prove native-child source/dist equivalence. Existing-dist +smokes, simulated Windows protocols and synthetic output/prompt edges retain +their qualifications. No operator TTY, real provider/keychain, paid operation, +opaque native undici close, universal descendant containment, unchanged legacy +verbose-stack secrecy or complete vulnerability-database audit is certified. +Actual native close is not a receipt ACK; expiry never grants DB-safe-close +permission. Steps 9–19 and whole-Wave-3 acceptance remain open. + ## Sandbox and tool policy (`run_command`, node tools, secret inputs) This is the **binding home** for the tool-policy rules; the rationale is From 4762f21f7f8a23fca08a08f9b6be47f04b9143c1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 17 Oct 2026 22:03:56 +0300 Subject: [PATCH 157/222] docs(docs): record the scoped wave 3 publication checkpoint Reflect the maintainer's request to publish accepted Steps 1-8 before the remaining Wave 3 work. Preserve the Step 8 runtime source and CI attribution, and retain all later whole-wave acceptance obligations. Keep the next agent aware that new development commits extend an open PR. No Step 9 implementation or runtime change is included. Refs: ADR-0110, ADR-0111, Wave 3 Steps 1-8 Co-Authored-By: Codex --- AGENTS.md | 10 ++++++---- CLAUDE.md | 10 ++++++---- docs/roadmap/current.md | 6 ++++-- .../phase-2.5.5-hardening-and-remediation.md | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 96efcbf0..8d7dc760 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,10 +115,12 @@ failed reviewer-preparation records and source/native/platform limits remain explicit; source CI does not certify later documentation commits. The [acceptance register](docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) joins exact evidence and historical corrections. Steps 1–8 are accepted; -Steps 9–19 have NOT STARTED. The maintainer requested a timestamped private -handoff for another agent; this session starts no Step 9 work or PR. No provider, -credential, billing or new ADR approval is pending. Later implementation and -whole-wave acceptance remain staged in the canonical roadmap. +Steps 9–19 have NOT STARTED. After the private handoff, the maintainer authorized +an initial PR for completed Steps 1–8. This is a scoped publication checkpoint; +whole-wave acceptance remains Step 19. Later work stays with the next agent. +Check that PR's status before committing Step 9 on `development`, because new +commits extend its review scope. No provider, credential, billing or new ADR +approval is pending for the approved implementation plan. See [docs/roadmap/current.md](docs/roadmap/current.md) for live status. diff --git a/CLAUDE.md b/CLAUDE.md index 6f263cee..8c828f2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,10 +204,12 @@ failed reviewer-preparation records and source/native/platform limits remain explicit; source CI does not certify later documentation commits. The [acceptance register](docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) joins exact evidence and historical corrections. Steps 1–8 are accepted; -Steps 9–19 have NOT STARTED. The maintainer requested a timestamped private -handoff for another agent; this session starts no Step 9 work or PR. No provider, -credential, billing or new ADR approval is pending. Later implementation and -whole-wave acceptance remain staged in the canonical roadmap. +Steps 9–19 have NOT STARTED. After the private handoff, the maintainer authorized +an initial PR for completed Steps 1–8. This is a scoped publication checkpoint; +whole-wave acceptance remains Step 19. Later work stays with the next agent. +Check that PR's status before committing Step 9 on `development`, because new +commits extend its review scope. No provider, credential, billing or new ADR +approval is pending for the approved implementation plan. For live status, per-PR history, milestone dates, and open obligations, see the canonical home [docs/roadmap/current.md](docs/roadmap/current.md); diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index ea2eb3a4..843d04fe 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1146,8 +1146,10 @@ consumers. [Step 8d and whole Step 8 are independently accepted](phases/phase-2. at `c83c8575` after two NEW complete cumulative CLEAN rounds, a NEW separate clean security sitting and successful full CI plus all four actual same-tarball consumers. Steps 1–8 are accepted on `development`; Steps 9–19 have NOT STARTED. -The maintainer requested a private handoff for another agent; no Step 9 work or PR -is started in this session. The checkpoints below are historical observations; +Following the private handoff, the maintainer authorized an initial PR for completed +Steps 1–8. Steps 9–19 and whole-wave acceptance remain separate. Check that PR's +status before later commits on `development`, which would extend its review scope. +The checkpoints below are historical observations; their then-open gates are resolved only by the final acceptance linked above. The earlier [2026-10-17 chat/Home implementation checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-chat-and-home-integration-checkpoint--2026-10-17) records partial session/native-terminal integration and the remaining activation, diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 9b084023..aaa0c171 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8296,3 +8296,21 @@ maintainer requested closure and a timestamped private handoff for another agent this session starts no Step 9 work and opens no PR. Next is approved Step 9's release map/custody substrate in the [nineteen-increment plan](#proposed-implementation-increments). Whole Wave 3 and whole Phase 2.5.5/2.6 remain open. + +### Wave 3 Steps 1–8 scoped publication — 2026-10-17 + +After Step 8d acceptance and the private handoff, the maintainer explicitly +requested a PR for the completed work so far. This authorizes an initial +`development` → `main` PR containing accepted **Steps 1–8**; it does not close +Wave 3 or start Steps 9–19. It refines the original Step 19 publication timing +at the maintainer's request without changing any implementation acceptance gate. +The existing Step 8d closure above remains a historical pre-publication checkpoint. + +The reviewed runtime source remains `c83c8575`; `00fb72d2` and this publication +checkpoint change documentation only. Existing source CI and scoped reviews keep +their exact attribution. New push/PR checks must be reported at their actual head; +no prior result is presented as their execution. Detailed handoff/review records +remain local and Git-ignored. Another agent should inspect the PR/merge state +before Step 9: commits on the same `development` head extend the open PR's scope. +Whole-wave reviews, core coverage promotion and the remaining Step 19 obligations +are still required after later increments. From 0eae1b8b5f2be52577f6b8eff99da92ce9611140 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 00:28:38 +0300 Subject: [PATCH 158/222] fix(repo): bound diagnostic stack scans to individual lines Restrict acceptance-tool stack indentation to spaces and tabs, preventing quadratic rescanning across newline-only output. Inspect the actual source assertions in permanent regressions, retain private-marker and file-URL rejection, and make the existing bundle positive assertion explicit. Use equivalent membership checks for exact paths and repeated warnings. Validation: 551 acceptance-tool tests; both independent regex removals fail their intended regression and byte-exact restorations pass; lint, format, workspace lint/typecheck/tests and build pass. No case bound or control inventory is reduced. Refs: ADR-0110, ADR-0111; PR #95 Sonar review Co-Authored-By: Codex --- tools/bundle-closure/closure.test.mjs | 2 +- tools/cli-bootstrap/check.mjs | 4 +- tools/cli-bootstrap/witness.test.mjs | 48 +++++++++++++++++++++- tools/cli-package/smoke/native-process.mjs | 2 +- tools/cli-package/smoke/runtime-stderr.mjs | 2 +- 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/tools/bundle-closure/closure.test.mjs b/tools/bundle-closure/closure.test.mjs index 9c96179e..404bb0a4 100644 --- a/tools/bundle-closure/closure.test.mjs +++ b/tools/bundle-closure/closure.test.mjs @@ -123,7 +123,7 @@ test('literal/comment/regex/template import-looking text and hashbang text are d 'const data = ["require(\\"nope\\")", "#!", /import("nope")/, `import("nope")`];\n' + '/* import("nope"); require("nope"); */ console.log(data);', ); - checkBundleClosure(value); + assert.doesNotThrow(() => checkBundleClosure(value)); }); test('removing an emitted lazy chunk refuses even with complete build metadata', () => { diff --git a/tools/cli-bootstrap/check.mjs b/tools/cli-bootstrap/check.mjs index fa02e7c5..45ae164d 100644 --- a/tools/cli-bootstrap/check.mjs +++ b/tools/cli-bootstrap/check.mjs @@ -114,7 +114,7 @@ export async function runBootstrap(packageRoot = join(repository, 'apps/cli')) { 'fs', 'real execution did not reach filesystem admission before services', ); - assert.ok(firstDenied.paths.some((path) => path === fixture.workspace)); + assert.ok(firstDenied.paths.includes(fixture.workspace)); records.push({ name: 'real-execution-fs-before-service', exitCode: admission.code, @@ -151,7 +151,7 @@ export async function runBootstrap(packageRoot = join(repository, 'apps/cli')) { assert.match(result.stderr, /Inspect existing work/, name); } assert.ok(!result.stderr.includes(importFailureCanary), name); - assert.doesNotMatch(result.stderr, /\n\s+at |file:\/\/|Error: synthetic/, name); + assert.doesNotMatch(result.stderr, /\n[\t ]+at |file:\/\/|Error: synthetic/, name); assert.equal( result.events.filter((event) => event.denied && !event.runtimeFailure).length, 0, diff --git a/tools/cli-bootstrap/witness.test.mjs b/tools/cli-bootstrap/witness.test.mjs index f3724e25..5487cf7c 100644 --- a/tools/cli-bootstrap/witness.test.mjs +++ b/tools/cli-bootstrap/witness.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -8,6 +8,52 @@ import { withFixture, toEntryUrl } from './child.mjs'; import { assertWitness, importFailureCanary, servicePackages } from './policy.mjs'; import { spawnBounded } from './process.mjs'; import { BOOTSTRAP_CONTROL_COUNT } from './inventory.mjs'; +import ts from 'typescript'; + +// Inspect the actual assertion operands: a copied test regex could pass after either +// acceptance runner regressed. Parsing never imports or executes the native harness. +for (const [path, markers] of [ + ['./check.mjs', ['file://', 'Error: synthetic']], + ['../cli-package/smoke/native-process.mjs', ['file://', 'PRIVATE_INSTALLED_']], +]) { + test(`${path}: actual failure-output guard stays within each stack line`, () => { + const source = ts.createSourceFile( + path, + readFileSync(new URL(path, import.meta.url), 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.JS, + ); + const literals = []; + const visit = (node) => { + if ( + ts.isCallExpression(node) && + node.expression.getText(source) === 'assert.doesNotMatch' && + node.arguments[0]?.getText(source) === 'result.stderr' + ) { + assert.ok(ts.isRegularExpressionLiteral(node.arguments[1])); + literals.push(node.arguments[1].text); + } + ts.forEachChild(node, visit); + }; + visit(source); + assert.equal(literals.length, 1, 'the real stderr assertion must remain present'); + const literal = literals[0]; + const end = literal.lastIndexOf('/'); + const guard = new RegExp(literal.slice(1, end), literal.slice(end + 1)); + for (const frame of ['\n at fn (synthetic:1:1)', '\n\tat fn', '\r\n \tat fn']) + assert.match(`diagnostic${frame}\ntrailer`, guard); + for (const marker of markers) assert.match(`prefix ${marker} suffix`, guard); + for (const text of [ + '{"code":"cli_internal","message":"fixed diagnostic"}\n', + '\n\nat unindented text', + '\n \n\tat-without-space', + '\n'.repeat(16_384) + 'ordinary diagnostic', + '\n' + ' '.repeat(16_384) + 'ordinary diagnostic', + ]) + assert.doesNotMatch(text, guard); + }); +} test('package binding expects the complete human/JSON native-failure cohort', () => { assert.equal(BOOTSTRAP_CONTROL_COUNT, 177); diff --git a/tools/cli-package/smoke/native-process.mjs b/tools/cli-package/smoke/native-process.mjs index 7f4c57fb..d10cb1fb 100644 --- a/tools/cli-package/smoke/native-process.mjs +++ b/tools/cli-package/smoke/native-process.mjs @@ -534,7 +534,7 @@ export async function assertInstalledNativeProcesses(context, { bootstrap = fals 'Background-only observation changed a successful turn', ); } - assert.doesNotMatch(result.stderr, /PRIVATE_INSTALLED_|\n\s+at |file:\/\//); + assert.doesNotMatch(result.stderr, /PRIVATE_INSTALLED_|\n[\t ]+at |file:\/\//); } assert.equal(watchdogFired, false, 'External fixture watchdog supplied native cleanup'); assert.equal( diff --git a/tools/cli-package/smoke/runtime-stderr.mjs b/tools/cli-package/smoke/runtime-stderr.mjs index d1e623c3..ee7b3c69 100644 --- a/tools/cli-package/smoke/runtime-stderr.mjs +++ b/tools/cli-package/smoke/runtime-stderr.mjs @@ -8,7 +8,7 @@ export function projectRuntimeStderr(stderr, { runtime, pid }) { pid <= 0 || first < 0 || (first > 0 && stderr[first - 1] !== '\n') || - stderr.indexOf(warning, first + warning.length) !== -1 + stderr.includes(warning, first + warning.length) ) return { diagnostics: stderr, runtimeWarnings: [] }; return { From 887ff47eff6092d035df9442658f0eb24625f9e9 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 00:28:38 +0300 Subject: [PATCH 159/222] refactor(cli): clarify terminal ownership and gate test setup Keep the captured Ink input baseline reference readonly. Preserve the original synchronous-factory failure observation and release ordering while returning its exact opaque cause through Promise.reject. Move gate DB hooks before cases within their existing describe scope. Validation: 94 focused CLI tests, targeted lint/typecheck, complete workspace lint/typecheck/tests and build pass. Ownership acknowledgements and all existing native deadlines remain unchanged. Refs: ADR-0110; PR #95 Sonar review Co-Authored-By: Codex --- apps/cli/src/chat/session-ink-input.ts | 2 +- apps/cli/src/commands/gate.test.ts | 24 +++++++++++----------- apps/cli/src/commands/workflow-terminal.ts | 6 ++---- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/chat/session-ink-input.ts b/apps/cli/src/chat/session-ink-input.ts index 00e134ea..dc891c36 100644 --- a/apps/cli/src/chat/session-ink-input.ts +++ b/apps/cli/src/chat/session-ink-input.ts @@ -13,7 +13,7 @@ export class SessionInkInput { readonly #acknowledge: (text: string) => Promise; readonly #failure: (cause: unknown) => void; readonly #listeners = new Set(); - #baseline = new Map(); + readonly #baseline = new Map(); readonly #remove: NodeJS.ReadStream['removeListener']; readonly #readable: () => readonly unknown[]; #entered = false; diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index 86b67fc7..7636a663 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -230,6 +230,18 @@ function globalOptions(): GlobalOptions { } describe('gateCommand', () => { + let client: DbClient; + let db: Db; + + beforeEach(() => { + client = createClient(':memory:'); + runMigrations(client.db); + db = client.db; + }); + afterEach(() => { + if (client.sqlite.open) client.sqlite.close(); + }); + it('classifies actual history corruption as storage 1 before any resume', async () => { mkdirSync(join(home, '.relavium')); writeFileSync(join(home, '.relavium', 'history.db'), 'PRIVATE_CORRUPT_HISTORY'); @@ -277,18 +289,6 @@ describe('gateCommand', () => { expect(settled).toBe(false); }); - let client: DbClient; - let db: Db; - - beforeEach(() => { - client = createClient(':memory:'); - runMigrations(client.db); - db = client.db; - }); - afterEach(() => { - if (client.sqlite.open) client.sqlite.close(); - }); - /** The injected deps for a resume over the SHARED in-memory db (no-op close — the db spans the whole test). */ function deps(io: ReturnType['io']): GateCommandDeps { return { diff --git a/apps/cli/src/commands/workflow-terminal.ts b/apps/cli/src/commands/workflow-terminal.ts index 97c93d52..9471b3f2 100644 --- a/apps/cli/src/commands/workflow-terminal.ts +++ b/apps/cli/src/commands/workflow-terminal.ts @@ -86,10 +86,8 @@ export class WorkflowTerminal { this.#prompts.delete(settled); settle(); this.#fail(cause); - return new Promise((_resolve, reject) => { - // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Preserve the opaque constructor failure without forging terminal release. - reject(cause); - }); + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Preserve the opaque constructor failure without forging terminal release. + return Promise.reject(cause); } return raw.then((value) => { this.#prompts.delete(settled); From 05061d55235df91a58813e37317bbbbc78f70dc2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 00:38:01 +0300 Subject: [PATCH 160/222] docs(docs): record reviewed pr 95 sonar corrections Join the two-site regex correction and equivalent CLI/tooling clarifications to two new complete independent cumulative review rounds, exact author validation and the local installed tarball. Record verified reasoned Sonar dispositions while keeping valid maintenance suggestions and later Wave 3 work open. New publication-head CI remains separately owed. Refs: ADR-0110, ADR-0111; PR #95 Co-Authored-By: Codex --- .../phase-2.5.5-hardening-and-remediation.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index aaa0c171..ab54b0a5 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8314,3 +8314,44 @@ remain local and Git-ignored. Another agent should inspect the PR/merge state before Step 9: commits on the same `development` head extend the open PR's scope. Whole-wave reviews, core coverage promotion and the remaining Step 19 obligations are still required after later increments. + +### PR #95 Sonar correction — 2026-10-18 + +The maintainer authorized the necessary Sonar corrections and issue dispositions +for the scoped Steps 1–8 PR. The complete initial inventory contains **214 open +records across 80 source files**. Independent source/caller review identifies one +tooling performance defect at two sites: stack-line detection could rescan a +newline-only suffix quadratically. `0eae1b8b` restricts indentation to spaces/tabs +within one line, preserving stack positives and the existing private-text/file-URL +checks. Permanent tests inspect the actual assertion operands in both runners; +independent removal of either fix fails its regression and byte-exact restoration +passes. No native deadline, case inventory or output ceiling is relaxed. + +`887ff47e` adds small equivalent CLI clarifications: a readonly captured baseline, +exact opaque-cause rejection without a redundant Promise constructor, and gate +hooks ordered before cases within their existing suite. Together, the correction +has **eight paths / eleven ordered hunks** against publication head `4762f21f`. +Two NEW complete cumulative CLEAN rounds, each with two independent reviewers, +cover that exact corrective source. These reviews do not replace the pending +maintainer whole-PR review or extend the earlier Step 8 acceptance scope. + +Author gates pass at the corrected source: workspace lint/typecheck **17/17** +(15 cache hits), serialized workspace tasks **11/11** (10 cache hits), build +**6/6** (five cache hits), and **551 acceptance-tool tests** without skips. +The fresh CLI workspace run passes **362 files / 5,665 tests**, with ten established +skips; other workspace results retain their cache attribution. The actual local +macOS/Node 22.23.1 installed consumer passes **177 bootstrap controls and eight +smoke cases** using tarball SHA-256 +`2dd80f46204a60e17d4abb72c5442425e29d09c73e60642cad40ccc254125d4e`. +This is one local consumer, not fresh Linux/Windows/minimum-runtime certification. + +All **52 false-positive** and **86 intentional-design** records receive reasoned +online dispositions, verified by exact issue key and retained comment. This +preserves synchronous-entry catches, typed Error ancestry, deterministic machine +ordering and actual lifetime acknowledgements. The local Git fallback retains its +explicit trusted-host PATH boundary. Valid remaining maintainability suggestions +stay visible; rules, quality-gate thresholds and approved deferrals are unchanged. +Fresh push/PR CI and Sonar analysis must be evaluated at the new published head; +the earlier green publication runs are not attributed to these changed bytes. +Detailed triage, correction reviews and receipts remain local and Git-ignored. +Steps 9–19 have not started; Wave 3 and whole-PR acceptance remain open. From 9d5bcc8c62fec32c497597b9f7fd9f79f0d3ee9f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 01:36:41 +0300 Subject: [PATCH 161/222] fix(core): preserve full versioned effect argument fingerprints Keep display truncation out of the collision guard and retain credential redaction before hashing. The DB adapter and actual SQLite regression are an atomic repair; all legacy unversioned digests refuse instead of replaying unproven results. Refs: ADR-0080, ADR-0098 Co-Authored-By: Codex --- .../engine/tool-alias-journal-digest.test.ts | 120 +++++++++++++++++- .../tools/registry-alias-projection.test.ts | 56 ++++++++ packages/core/src/tools/registry.ts | 42 +++--- packages/db/src/effect-journal-store.test.ts | 12 +- packages/db/src/effect-journal-store.ts | 4 +- 5 files changed, 210 insertions(+), 24 deletions(-) diff --git a/apps/cli/src/engine/tool-alias-journal-digest.test.ts b/apps/cli/src/engine/tool-alias-journal-digest.test.ts index 095db6de..88f90110 100644 --- a/apps/cli/src/engine/tool-alias-journal-digest.test.ts +++ b/apps/cli/src/engine/tool-alias-journal-digest.test.ts @@ -25,7 +25,7 @@ const EXPECTED_CANONICAL = { }; function sha256(text: string): string { - return createHash('sha256').update(text).digest('hex'); + return `v2:${createHash('sha256').update(text).digest('hex')}`; } function registryFixture(effects: EffectDispatchPort) { @@ -110,7 +110,7 @@ describe.each(METADATA)( ); expect(rows).toHaveLength(2); expect(rows.map((row) => row.state)).toEqual(['committed', 'committed']); - for (const row of rows) expect(row.args_digest).toMatch(/^[0-9a-f]{64}$/); + for (const row of rows) expect(row.args_digest).toMatch(/^v2:[0-9a-f]{64}$/); expect(rows[0]?.args_digest, 'PHYSICAL_ALIAS_DIGEST_DISTINCT').not.toBe( rows[1]?.args_digest, ); @@ -258,3 +258,119 @@ describe('W3S5 #94 physical journal deliberate secret-projection equality', () = } }); }); + +describe('PR #95 full redacted journal digest', () => { + it('PHYSICAL_LONG_ARGS hashes the non-secret tail, preserves replay and refuses changed same-slot args', async () => { + const client = createClient(':memory:'); + try { + runMigrations(client.db); + let ids = 0; + const store = createEffectJournalStore(client.db, { + uuid: () => `long-${++ids}`, + now: () => 6, + }); + const effects = recordingPort( + createEffectJournalPort( + store, + { kind: 'run', runId: 'r-long', nodeId: 'n', attempt: 1 }, + { providerAttempt: 1, toolCallId: 'long-call' }, + ), + ); + const h = registryFixture(effects); + const context = { ...h.context, secretArgKeys: new Set(['confidentialNote']) }; + const prefix = 'x'.repeat(20 * 1024); + const payload = (tail: string, credential: string) => ({ + text: `${prefix}${tail} Bearer ${credential}`, + password: credential, + confidentialNote: credential, + }); + const firstPayload = payload('first', 'SYNTHETIC_LONG_SECRET_ONE'); + const otherPayload = payload('other', 'SYNTHETIC_LONG_SECRET_TWO'); + expect(firstPayload.text.length).toBe(otherPayload.text.length); + const first = await h.registry.dispatch(call(firstPayload), context); + const replay = await h.registry.dispatch( + call(payload('first', 'SYNTHETIC_LONG_SECRET_TWO')), + context, + ); + expect(replay.output).toEqual(first.output); + expect(h.received).toHaveLength(1); + await expect( + h.registry.dispatch(call(otherPayload), context), + 'PHYSICAL_LONG_ARGS_CONFLICT', + ).rejects.toMatchObject({ name: 'ToolEffectConflictError', retryable: false }); + expect(h.received).toHaveLength(1); + await h.registry.dispatch(call(otherPayload), { ...context, effectSlot: 1 }); + expect(h.received).toEqual([firstPayload, otherPayload]); + + const rows = PhysicalRowsSchema.parse( + client.sqlite + .prepare('SELECT slot, state, args_digest FROM run_effects ORDER BY slot') + .all(), + ); + // Independent canonical bytes, including the entire non-secret tail, not the display preview. + const expected = (tail: string) => + sha256(`{"payload":{"password":"[redacted]","text":"${prefix}${tail} Bearer [redacted]"}}`); + expect(rows, 'PHYSICAL_LONG_ARGS_DIGEST').toEqual([ + { slot: 0, state: 'committed', args_digest: expected('first') }, + { slot: 1, state: 'committed', args_digest: expected('other') }, + ]); + expect(rows[0]?.args_digest).not.toBe(rows[1]?.args_digest); + for (const args of effects.prepare.mock.calls) { + const projection = JSON.stringify(args[3]); + expect(projection).not.toContain('SYNTHETIC_LONG_SECRET'); + expect(projection).not.toContain('confidentialNote'); + expect(projection).toContain('Bearer [redacted]'); + } + expect(JSON.stringify(first.events.call.toolInput).length).toBeLessThan(17 * 1024); + expect( + JSON.stringify(client.sqlite.prepare('SELECT * FROM run_effects').all()), + ).not.toContain('SYNTHETIC_LONG_SECRET'); + } finally { + client.sqlite.close(); + } + }); + + it.each(['original', 'literal-preview'] as const)( + 'PHYSICAL_LEGACY_LONG_ARGS refuses the %s against an old truncated digest', + async (kind) => { + const client = createClient(':memory:'); + try { + runMigrations(client.db); + const store = createEffectJournalStore(client.db, { + uuid: () => 'legacy-long', + now: () => 6, + }); + const effects = createEffectJournalPort( + store, + { kind: 'run', runId: 'r-legacy-long', nodeId: 'n', attempt: 1 }, + { providerAttempt: 1, toolCallId: 'legacy-call' }, + ); + const text = `${'x'.repeat(20 * 1024)}first`; + const preview = `${text.slice(0, 16 * 1024)}…[${String(text.length)} chars total]`; + // Seed actual pre-correction bytes, never through the current digest-producing port. + const legacyDigest = createHash('sha256') + .update(JSON.stringify({ payload: { text: preview } })) + .digest('hex'); + store.prepare( + { scope: 'run:r-legacy-long:n', slot: 0, toolId: 'alias_publish' }, + { kind: 'run', runId: 'r-legacy-long', nodeId: 'n', attempt: 1 }, + { providerAttempt: 1, toolCallId: 'legacy-call' }, + 3, + legacyDigest, + ); + await effects.settle(0, 'alias_publish', 'committed', { receipt: 'legacy result' }); + const h = registryFixture(effects); + await expect( + h.registry.dispatch(call({ text: kind === 'original' ? text : preview }), h.context), + 'PHYSICAL_LEGACY_LONG_ARGS_REFUSAL', + ).rejects.toMatchObject({ name: 'ToolEffectConflictError', retryable: false }); + expect(h.received).toEqual([]); + expect( + store.recordsFor({ kind: 'run', runId: 'r-legacy-long', nodeId: 'n', attempt: 1 }), + ).toHaveLength(1); + } finally { + client.sqlite.close(); + } + }, + ); +}); diff --git a/packages/core/src/tools/registry-alias-projection.test.ts b/packages/core/src/tools/registry-alias-projection.test.ts index cf0cabc4..cfeb9267 100644 --- a/packages/core/src/tools/registry-alias-projection.test.ts +++ b/packages/core/src/tools/registry-alias-projection.test.ts @@ -79,6 +79,62 @@ const METADATA = [ { name: 'nonempty', keys: new Set(['confidentialNote']), omitDeclared: true }, ]; +describe('PR #95 journal identity retains long redacted arguments', () => { + it('LONG_ARGS_CONFLICT replays identical args but refuses a changed same-length tail at the same slot', async () => { + const h = harness(); + const prefix = 'x'.repeat(20 * 1024); + const firstPayload = { text: `${prefix}first` }; + const changedPayload = { text: `${prefix}other` }; + expect(firstPayload.text.length).toBe(changedPayload.text.length); + const first = await h.registry.dispatch(call(firstPayload), h.context); + const replay = await h.registry.dispatch(call(firstPayload), h.context); + expect(replay.output).toEqual(first.output); + expect(h.received).toEqual([firstPayload]); + await expect( + h.registry.dispatch(call(changedPayload), h.context), + 'LONG_ARGS_CONFLICT', + ).rejects.toMatchObject({ name: 'ToolEffectConflictError', retryable: false }); + expect(h.received).toEqual([firstPayload]); + await h.registry.dispatch(call(changedPayload), { ...h.context, effectSlot: 1 }); + expect(h.received).toEqual([firstPayload, changedPayload]); + expect(vi.mocked(h.effects.prepare).mock.calls[0]?.[3], 'LONG_ARGS_FULL_PREPARE').toEqual({ + payload: firstPayload, + }); + const display = JSON.stringify(first.events.call.toolInput); + expect(display.length).toBeLessThan(17 * 1024); + expect(display).not.toContain('first'); + }); + + it('LONG_ARGS_REDACTION removes declared and shaped secrets beyond the display boundary before hashing', async () => { + const h = harness(); + const prefix = 'x'.repeat(20 * 1024); + const payload = { + text: `${prefix} Bearer SYNTHETIC_LONG_BEARER_CANARY`, + confidentialNote: 'SYNTHETIC_DECLARED_LONG_CANARY', + nested: { password: 'SYNTHETIC_PASSWORD_LONG_CANARY' }, + }; + const outcome = await h.registry.dispatch(call(payload), { + ...h.context, + secretArgKeys: new Set(['confidentialNote']), + }); + const projected = vi.mocked(h.effects.prepare).mock.calls[0]?.[3]; + expect(projected, 'LONG_ARGS_REDACTED_FULL_VALUE').toEqual({ + payload: { text: `${prefix} Bearer [redacted]`, nested: { password: '[redacted]' } }, + }); + for (const projection of [projected, outcome.events.call.toolInput]) { + const text = JSON.stringify(projection); + for (const canary of [ + 'SYNTHETIC_LONG_BEARER_CANARY', + 'SYNTHETIC_DECLARED_LONG_CANARY', + 'SYNTHETIC_PASSWORD_LONG_CANARY', + ]) + expect(text, 'LONG_ARGS_NO_SECRET_ORACLE').not.toContain(canary); + } + expect(h.received[0]).toBe(payload); + expect(payload.text).toContain('SYNTHETIC_LONG_BEARER_CANARY'); + }); +}); + describe.each(METADATA)( 'W3S5 #94 actual registry joins with $name external metadata', ({ keys, omitDeclared }) => { diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index c88e1854..3fddc0b6 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -343,7 +343,7 @@ async function prepareEffect( ctx.effectSlot, def.id, tier, - sanitizeInput(def, effective, ctx.secretArgKeys), + sanitizeInput(def, effective, ctx.secretArgKeys, 'journal'), ] as const; return await (ctx.effectAttempt === undefined ? ctx.effects.prepare(...args) @@ -615,7 +615,10 @@ async function dispatch( mediaAttachments: markUntrusted(mediaAttachments), truncated: bounded.truncated, events: { - call: { toolId: def.id, toolInput: sanitizeInput(def, effective, ctx.secretArgKeys) }, + call: { + toolId: def.id, + toolInput: sanitizeInput(def, effective, ctx.secretArgKeys, 'display'), + }, result: { toolId: def.id, success: true, outputSummary: bounded.summary }, }, }; @@ -1056,6 +1059,7 @@ function sanitizeInput( def: ToolDef, effective: Record, secretArgKeys: ReadonlySet | undefined, + purpose: 'display' | 'journal', ): Readonly> { const out: Record = { ...effective }; for (const key of def.configOnlyParams ?? []) { @@ -1078,8 +1082,8 @@ function sanitizeInput( // `secretArgKeys` deletion above only covers KNOWN top-level key names, so a model-set credential in an // arbitrary place (an `http_request` `Authorization` header value, a token in the body or url query) would // otherwise pass through — `redactSecretShapedValue` scrubs it by shape, keeping the object keys (header - // names) intact. Symmetric to the `outputSummary` scrub on the result side — display-only, the dispatch - // already ran on the real args. + // names) intact. This projection also protects the journal's digest from credential equality oracles. + // Neither projection changes the real args passed to the host. // // **Walked ONCE over the whole object, not per key.** A per-key loop handed the walker each VALUE with // its key already stripped, so the key-name rule — the one that catches an opaque credential with no @@ -1087,7 +1091,10 @@ function sanitizeInput( // A tool's own TOP-LEVEL secretish parameter, which is exactly what an MCP server declares freely, went // through untouched into both the durable digest and the event stream. A review recovered `api_key` // verbatim from a real dispatch. - const walked = redactSecretShapedValue(redactInlineMedia(boundArgsForScan(out))); + // A display preview may omit a tail; the journal's collision guard may not. Both still exclude + // declared secrets and credential/media-shaped values BEFORE the host computes its digest. + const projection = purpose === 'display' ? boundArgsForDisplay(out) : out; + const walked = redactSecretShapedValue(redactInlineMedia(projection)); // The walk preserves plain-object shape, so this is the same record with scrubbed members; the guard is // a boundary check rather than a cast, and the fallback can only be reached if the walk ever stops // returning an object for an object input. @@ -1095,24 +1102,19 @@ function sanitizeInput( } /** - * The ceiling on how much of a single argument string the scrub will look at. - * - * The result side already truncates before scrubbing (`makeSummary`); the args side did not, so every - * effectful dispatch ran seven full-string passes over an UNCAPPED value — plausibly megabytes, via a - * `write_file` `content` sourced from a prior large `read_file`, or an `http_request` `body`. Each pass is - * linear (bounded quantifiers, no nesting — measured, not assumed), so this is avoidable synchronous CPU on - * the dispatch hot path rather than a ReDoS. The cap is generous: a credential is short, and the tail of a - * megabyte payload carries no diagnostic value the head does not. + * The display-only string preview ceiling. The journal scans the full redacted argument set, bounded by + * tool admission, because an omitted non-secret tail may distinguish one effect from another. */ const ARG_SCAN_MAX_CHARS = 16 * 1024; /** - * Truncate over-long STRING leaves before the scrub walks them, so the redaction cost is bounded per - * argument. The marker is explicit and carries the true length — a silently shortened value in a durable - * digest would be indistinguishable from a genuinely shorter one, and telling two calls apart is the - * digest's whole job. + * Truncate display STRING leaves with an explicit true-length marker. This is never a journal input: + * equal-length values can differ after the retained prefix and must not replay each other's result. */ -function boundArgsForScan(value: unknown, seen: WeakSet = new WeakSet()): unknown { +function boundArgsForDisplay( + value: unknown, + seen: WeakSet = new WeakSet(), +): unknown { if (typeof value === 'string') { return value.length <= ARG_SCAN_MAX_CHARS ? value @@ -1122,11 +1124,11 @@ function boundArgsForScan(value: unknown, seen: WeakSet = new WeakSet boundArgsForScan(item, seen)); + if (Array.isArray(value)) return value.map((item) => boundArgsForDisplay(item, seen)); const proto: unknown = Object.getPrototypeOf(value); if (proto !== Object.prototype && proto !== null) return value; const out: Record = {}; - for (const [key, item] of Object.entries(value)) out[key] = boundArgsForScan(item, seen); + for (const [key, item] of Object.entries(value)) out[key] = boundArgsForDisplay(item, seen); return out; } finally { seen.delete(value); diff --git a/packages/db/src/effect-journal-store.test.ts b/packages/db/src/effect-journal-store.test.ts index 039ef29e..17ed710d 100644 --- a/packages/db/src/effect-journal-store.test.ts +++ b/packages/db/src/effect-journal-store.test.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { effectScope, @@ -341,6 +341,16 @@ describe('the effect journal store', () => { const port = (): ReturnType => createEffectJournalPort(store, RUN, ATTEMPT); + it('refuses even unchanged small legacy args whose unversioned digest cannot prove the original projection', async () => { + const legacy = createHash('sha256').update('{"body":"x","path":"/tmp/a"}').digest('hex'); + store.prepare(ID, RUN, ATTEMPT, 3, legacy); + store.settle(ID, 'committed', { receipt: 'legacy' }); + await expect( + port().prepare(ID.slot, ID.toolId, 3, { path: '/tmp/a', body: 'x' }), + ).rejects.toSatisfy(isEffectConflictError); + expect(store.recordsFor(RUN)).toHaveLength(1); + }); + it('a realistic redacted argument shape digests, and key ORDER cannot change it', async () => { // `canonicalJson` moved to `@relavium/shared` and grew refusals it never had. This is the behaviour // that must NOT have changed: a JSON-derived tool-argument shape — which is all a redacted arg ever diff --git a/packages/db/src/effect-journal-store.ts b/packages/db/src/effect-journal-store.ts index accbb84f..0809ff94 100644 --- a/packages/db/src/effect-journal-store.ts +++ b/packages/db/src/effect-journal-store.ts @@ -641,5 +641,7 @@ function coerceEffectTier(value: number): EffectTier { * by one package's module-private helper is not a contract a second implementation can be held to. */ function digestOf(redactedArgs: unknown): string { - return createHash('sha256').update(canonicalJson(redactedArgs)).digest('hex'); + // Legacy unversioned digests may describe a lossy display preview. Even a current literal + // preview can match one, so every current projection uses a disjoint persisted version domain. + return `v2:${createHash('sha256').update(canonicalJson(redactedArgs)).digest('hex')}`; } From 1a5cc24ca35005ce1006268e90b3bad95e497ada Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 01:36:41 +0300 Subject: [PATCH 162/222] fix(llm): scrub credential authorities and classify discovery aborts Cover finite URL and header credential diagnostics without losing benign prose. Classify caller discovery cancellation consistently while preserving timeout precedence. Refs: ADR-0011, ADR-0064, ADR-0110 Co-Authored-By: Codex --- .../bounded-list-models-lifetime.test.ts | 59 +++++++++ .../src/adapters/list-models-lifetime.test.ts | 37 +++++- packages/llm/src/adapters/shared.ts | 25 +++- packages/llm/src/scrub-secrets.test.ts | 112 ++++++++++++++++++ packages/llm/src/scrub-secrets.ts | 37 +++++- 5 files changed, 257 insertions(+), 13 deletions(-) diff --git a/packages/llm/src/adapters/bounded-list-models-lifetime.test.ts b/packages/llm/src/adapters/bounded-list-models-lifetime.test.ts index a8efedb2..3f6171e0 100644 --- a/packages/llm/src/adapters/bounded-list-models-lifetime.test.ts +++ b/packages/llm/src/adapters/bounded-list-models-lifetime.test.ts @@ -22,6 +22,65 @@ afterEach(() => { }); describe('bounded model-list collector ownership', () => { + it.each([false, true])( + 'keeps caller cancellation primary through SDK and cleanup refusal (already stopped=%s)', + async (alreadyStopped) => { + const stop = new AbortController(); + if (alreadyStopped) stop.abort(); + const classify = vi.fn(() => + makeLlmError({ provider: 'anthropic', kind: 'unknown', message: 'SDK abort differs' }), + ); + const removal = vi.spyOn(stop.signal, 'removeEventListener').mockImplementation(() => { + throw new Error('private listener cleanup refusal'); + }); + const outcome = boundedListModels({ + provider: 'anthropic', + key: 'synthetic', + signal: stop.signal, + classify, + collect: (signal) => + new Promise((_resolve, reject) => { + const refuse = () => reject(new Error('private SDK abort')); + if (signal.aborted) refuse(); + else signal.addEventListener('abort', refuse, { once: true }); + }), + }); + if (!alreadyStopped) stop.abort(); + await expect(outcome).rejects.toMatchObject({ + message: 'model list cancelled', + llmError: { kind: 'cancelled', retryable: false }, + }); + expect(classify).not.toHaveBeenCalled(); + expect(removal).toHaveBeenCalledOnce(); + }, + ); + + it('keeps the hard timeout primary when abort also reaches the caller signal', async () => { + vi.useFakeTimers(); + const stop = new AbortController(); + const outcome = boundedListModels({ + provider: 'gemini', + key: 'synthetic', + signal: stop.signal, + classify: () => makeLlmError({ provider: 'gemini', kind: 'unknown', message: 'unused' }), + collect: (signal) => + new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + stop.abort(); + reject(new Error('private SDK abort')); + }, + { once: true }, + ); + }), + }).catch((cause: unknown) => cause); + await vi.advanceTimersByTimeAsync(15_000); + expect(await outcome).toMatchObject({ llmError: { kind: 'timeout', retryable: true } }); + expect(stop.signal.aborted).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + it('cleans an attempted host listener and empty aggregate after partial setup refuses', async () => { const parent = owner(); const cause = new Error('private listener setup'); diff --git a/packages/llm/src/adapters/list-models-lifetime.test.ts b/packages/llm/src/adapters/list-models-lifetime.test.ts index 83605891..f21a41cc 100644 --- a/packages/llm/src/adapters/list-models-lifetime.test.ts +++ b/packages/llm/src/adapters/list-models-lifetime.test.ts @@ -191,6 +191,35 @@ describe('discovery result versus original invocation retirement', () => { }, ); + it.each(dialects)( + '$name classifies an in-flight caller abort consistently through the installed SDK', + async ({ create }) => { + const parent = owner(); + const entered = deferred(); + const stop = new AbortController(); + const fetch: ProviderFetch = (_input, init) => + new Promise((_resolve, reject) => { + entered.resolve(); + const refuse = () => reject(new DOMException('synthetic request aborted', 'AbortError')); + if (init?.signal?.aborted === true) refuse(); + else init?.signal?.addEventListener('abort', refuse, { once: true }); + }); + const outcome = list(create(fetch), parent.options, stop.signal).catch( + (cause: unknown) => cause, + ); + await entered.promise; + stop.abort(); + const cause = await outcome; + expect(cause).toBeInstanceOf(LlmProviderError); + expect(cause).toMatchObject({ llmError: { kind: 'cancelled', retryable: false } }); + if (cause instanceof LlmProviderError) { + expect(cause.llmError.cause).toBeUndefined(); + expect(cause.message).not.toContain('synthetic-discovery-key'); + } + await Promise.all(parent.raw); + }, + ); + it.each(dialects)( '$name refuses SDK entry for an already-stopped owned request', async ({ create }) => { @@ -198,9 +227,11 @@ describe('discovery result versus original invocation retirement', () => { const fetch = vi.fn(() => Promise.resolve(jsonResponse({}))); const stop = new AbortController(); stop.abort(); - await expect(list(create(fetch), parent.options, stop.signal)).rejects.toBeInstanceOf( - LlmProviderError, - ); + const outcome = list(create(fetch), parent.options, stop.signal); + await expect(outcome).rejects.toBeInstanceOf(LlmProviderError); + await expect(outcome).rejects.toMatchObject({ + llmError: { kind: 'cancelled', retryable: false }, + }); expect(fetch).not.toHaveBeenCalled(); await Promise.all(parent.raw); }, diff --git a/packages/llm/src/adapters/shared.ts b/packages/llm/src/adapters/shared.ts index 1de9c59f..07a6db63 100644 --- a/packages/llm/src/adapters/shared.ts +++ b/packages/llm/src/adapters/shared.ts @@ -242,7 +242,7 @@ export function assertListModelsShape( /** * Resolve the base {@link LlmError} for a failed {@link boundedListModels} race, BEFORE the final redact + - * `cause`-strip re-wrap. A timeout wins first; then a pre-classified error (the §8 drift throw, or any + * `cause`-strip re-wrap. A timeout wins first, then caller cancellation; a pre-classified error (the §8 drift throw, or any * adapter-side `LlmProviderError`) passes THROUGH with its own `kind` — never re-run through `classify` * (which would flatten a `bad_request` drift throw to `unknown`); otherwise the adapter classifier runs. */ @@ -250,10 +250,11 @@ function resolveListModelsError(params: { readonly provider: ProviderId; readonly err: unknown; readonly timedOut: boolean; + readonly cancelled: boolean; readonly timeoutMs: number; readonly classify: (err: unknown) => LlmError; }): LlmError { - const { provider, err, timedOut, timeoutMs, classify } = params; + const { provider, err, timedOut, cancelled, timeoutMs, classify } = params; if (timedOut) { return makeLlmError({ provider, @@ -261,6 +262,9 @@ function resolveListModelsError(params: { message: `model list timed out after ${String(timeoutMs)}ms`, }); } + if (cancelled) { + return makeLlmError({ provider, kind: 'cancelled', message: 'model list cancelled' }); + } if (err instanceof LlmProviderError) { return err.llmError; } @@ -294,7 +298,11 @@ export async function boundedListModels(params: { const options = captureInvocationOptions(params.options); const work = options === undefined ? undefined : new ProviderInvocationWork(options, controller.signal); - const onAbort = (): void => controller.abort(); + let cancelled = false; + const onAbort = (): void => { + cancelled = true; + controller.abort(); + }; let timer: ReturnType | undefined; let listenerAttempted = false; let timedOut = false; @@ -306,7 +314,7 @@ export async function boundedListModels(params: { // the exact attempted listener, independently of collection entry. listenerAttempted = true; signal.addEventListener('abort', onAbort); - if (signal.aborted) controller.abort(); + if (signal.aborted) onAbort(); } const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { @@ -322,7 +330,14 @@ export async function boundedListModels(params: { // retains the exact collect and its admitted transport descendants. result = await Promise.race([collecting, timeout]); } catch (err) { - const base = resolveListModelsError({ provider, err, timedOut, timeoutMs, classify }); + const base = resolveListModelsError({ + provider, + err, + timedOut, + cancelled, + timeoutMs, + classify, + }); throw new LlmProviderError( makeLlmError({ provider, diff --git a/packages/llm/src/scrub-secrets.test.ts b/packages/llm/src/scrub-secrets.test.ts index 190c42e1..80699ece 100644 --- a/packages/llm/src/scrub-secrets.test.ts +++ b/packages/llm/src/scrub-secrets.test.ts @@ -62,3 +62,115 @@ describe('pure scrub-secrets leaf compatibility', () => { expect(error.code).toBe(expected); }); }); + +describe('finite diagnostic credential backstop regressions (PR #95 M2)', () => { + const protectedDiagnostics = [ + { + input: 'connect https://alice:opaque@password@provider.invalid/v1 failed', + expected: 'connect https://[REDACTED]@provider.invalid/v1 failed', + }, + { + input: 'connect https://alice:opaque:password@@provider.invalid/v1 failed', + expected: 'connect https://[REDACTED]@provider.invalid/v1 failed', + }, + ...['http', 'https', 'ws', 'wss', 'ftp', 'ftps'].flatMap((scheme) => [ + { + input: `connect ${scheme}://alice:opaque-password@provider.invalid/v1 failed`, + expected: `connect ${scheme}://[REDACTED]@provider.invalid/v1 failed`, + }, + { + input: `connect ${scheme.toUpperCase()}://opaque-user@provider.invalid/v1 failed`, + expected: `connect ${scheme.toUpperCase()}://[REDACTED]@provider.invalid/v1 failed`, + }, + ]), + ...['api-key', 'x-api-key', 'x-goog-api-key'].flatMap((name) => [ + { + input: `header ${name}: opaque-header-Z29 rejected`, + expected: `header ${name}: [REDACTED] rejected`, + }, + { + input: `header ${name.toUpperCase()}: opaque-header-Z29 rejected`, + expected: `header ${name.toUpperCase()}: [REDACTED] rejected`, + }, + { + input: `{"${name}":"opaque-header-Z29","ordinary":"kept"}`, + expected: `{"${name}":"[REDACTED]","ordinary":"kept"}`, + }, + ]), + ...['\n', '\t'].flatMap((separator) => [ + { + input: `Bear${separator}er opaque-header-Z29 rejected`, + expected: 'Bearer [REDACTED] rejected', + }, + { input: `Bas${separator}ic dXNlcjpwYXNz rejected`, expected: 'Basic [REDACTED] rejected' }, + { + input: `wss://ali${separator}ce:opaque@password@provider.invalid/v1`, + expected: '[REDACTED]', + }, + { + input: `https://alice:opaque@pass${separator}word@provider.invalid/v1`, + expected: '[REDACTED]', + }, + ]), + { + input: 'header x-api-\u0001key: opaque-header-Z29 rejected', + expected: '[REDACTED]', + }, + { + input: '?token=opaque; w\u0001ss://alice:opaque-password@provider.invalid/v1', + expected: '[REDACTED]', + }, + ]; + + it.each(protectedDiagnostics)('protects the finite diagnostic $input', ({ input, expected }) => { + expect(scrubSecrets(input)).toBe(expected); + const error = makeLlmError({ provider: 'openai', kind: 'auth', message: input, code: input }); + expect(error.message).toBe(expected); + expect(error.code).toBe(expected); + }); + + it('protects each internal LF/TAB split position in the finite Bearer/Basic labels', () => { + for (const label of ['Bearer', 'Basic']) { + for (const separator of ['\n', '\t', '\n\t']) { + for (let position = 1; position < label.length; position += 1) { + const input = `${label.slice(0, position)}${separator}${label.slice(position)} opaque-header-Z29`; + expect(scrubSecrets(input)).toBe(`${label} [REDACTED]`); + } + } + } + }); + + it('retains independent prefixed-key evidence before a new header can erase its prefix', () => { + const input = `x-api-key: ${key.slice(0, 12)}\n${key.slice(12)}`; + expect(scrubSecrets(input)).toBe('[REDACTED]'); + const error = makeLlmError({ provider: 'openai', kind: 'auth', message: input, code: input }); + expect(error.message).toBe('[REDACTED]'); + expect(error.code).toBe('[REDACTED]'); + }); + + it.each([ + 'connect wss://provider.invalid/v1 failed', + 'connect https://provider.invalid/user@public failed', + 'connect https://provider.invalid/?contact=alice@example.invalid failed', + 'connect https://provider.invalid/#contact=alice@example.invalid failed', + 'api-key-hint: ordinary', + 'prefix-api-key: ordinary', + 'x-api-key-count: ordinary', + 'x-api-key=ordinary', + '{"api-key-hint":"ordinary"}', + 'Authorization: Bearer [REDACTED]\nrequest failed retry later', + 'Authorization: Basic [REDACTED]\nrequest failed retry later', + 'x-api-key: [REDACTED]\nrequest failed retry later', + ])('preserves benign or already masked diagnostics %s', (input) => { + expect(scrubSecrets(input)).toBe(input); + }); + + it.each(['Bearer opaque-header-Z29', 'Basic dXNlcjpwYXNz', 'x-api-key: opaque-header-Z29'])( + 'preserves separate following diagnostic prose after %s', + (credential) => { + const input = `${credential}\nrequest failed retry later`; + const expected = `${credential.split(' ')[0]} [REDACTED]\nrequest failed retry later`; + expect(scrubSecrets(input)).toBe(expected); + }, + ); +}); diff --git a/packages/llm/src/scrub-secrets.ts b/packages/llm/src/scrub-secrets.ts index 50f4bccc..19d9e27b 100644 --- a/packages/llm/src/scrub-secrets.ts +++ b/packages/llm/src/scrub-secrets.ts @@ -5,7 +5,7 @@ import { stripTerminalControls } from '@relavium/shared/terminal-safe'; * still responsible for passing an already-redacted message; this is the **structural backstop** — like * the engine's `deInlineMedia` pass, leak-freedom here is an active transform at one boundary, not * per-adapter discipline. It masks finite common secret **shapes**: prefixed keys, literal secret query - * names, URL userinfo and Bearer/Basic headers. Each non-query rule probes the original diagnostic + * names, URL userinfo and finite API-key/Bearer/Basic headers. Each non-query rule probes the original diagnostic * independently, including terminal-normalized and whitespace-joined residual views, before any * other rule can erase its identifying context. A match revealed by normalization masks the whole * diagnostic. The finite patterns retain their word boundaries and already-masked-span limits; @@ -22,6 +22,7 @@ export function scrubSecrets(text: string): string { interface SecretRule { readonly pattern: RegExp; readonly replacement: string; + readonly joinMaskedLines?: boolean; } /** One private authority per existing credential family, shared by probes and output masking. */ @@ -29,10 +30,25 @@ const NON_QUERY_SECRET_RULES: readonly SecretRule[] = [ // OpenAI sk-/sk-proj-/sk-svcacct- and Anthropic sk-ant- key prefixes. { pattern: /\bsk-(?:ant-)?[A-Za-z0-9\-_]{16,}/g, replacement: '[REDACTED]' }, { pattern: /\bAIza[0-9A-Za-z\-_]{20,}/g, replacement: '[REDACTED]' }, - // URL userinfo: user@ or user:pass@. - { pattern: /(https?:\/\/)[^/\s:@]+(?::[^/\s@]+)?@/gi, replacement: '$1[REDACTED]@' }, - { pattern: /\bBearer\s+\S+/gi, replacement: 'Bearer [REDACTED]' }, - { pattern: /\bBasic\s+\S+/gi, replacement: 'Basic [REDACTED]' }, + // Finite authority schemes, not URL validation. The LAST @ before /, ? or # ends userinfo, + // including malformed raw @/colon characters in a password; path/query @ remain public text. + { + pattern: /\b((?:https?|wss?|ftps?):\/\/)[^/\s?#]*@/gi, + replacement: '$1[REDACTED]@', + joinMaskedLines: true, + }, + // Exact literal header names, also in quoted diagnostic objects. No arbitrary header discovery. + { + pattern: /(^|[^A-Za-z0-9_-])((?:x-)?api-key|x-goog-api-key)(["']?\s*:\s*["']?)[^\s"',;}]+/gi, + replacement: '$1$2$3[REDACTED]', + }, + // LF/TAB may split the finite scheme LABEL. Preserve the required label/value separator and + // ordinary following prose; opaque value fragments cannot be distinguished from that prose. + { + pattern: /\bB[\n\t]*e[\n\t]*a[\n\t]*r[\n\t]*e[\n\t]*r\s+\S+/gi, + replacement: 'Bearer [REDACTED]', + }, + { pattern: /\bB[\n\t]*a[\n\t]*s[\n\t]*i[\n\t]*c\s+\S+/gi, replacement: 'Basic [REDACTED]' }, ]; /** Reuse the display boundary's platform-free normalization without changing benign output. */ @@ -47,6 +63,17 @@ function scrubNonQuerySecrets(text: string): string { const normalizedMasked = normalized.replace(rule.pattern, rule.replacement); // Keep required header whitespace: joining first would hide a newly revealed Bearer/Basic label. if (normalizeSecretProbe(rawMasked) !== normalizedMasked) return '[REDACTED]'; + // A raw @ before LF/TAB can create a partial URL mask while another @ still ends the actual + // authority. Compare this rule's full views before treating its masks as complete delimiters. + // Spaces remain boundaries here so separate same-line diagnostics cannot merge authorities. + if (rule.joinMaskedLines) { + const joined = normalized.replace(/[\n\t]+/g, ''); + if ( + joined.replace(rule.pattern, rule.replacement) !== normalizedMasked.replace(/[\n\t]+/g, '') + ) { + return '[REDACTED]'; + } + } // Other families must not destroy this rule's prefix; only its own complete masks delimit spans. for (const remaining of normalizedMasked.split('[REDACTED]')) { const joined = remaining.replace(/\s+/g, ''); From 09149f7126cdcd1836837c152aff0a10b4c2de6a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 01:36:41 +0300 Subject: [PATCH 163/222] fix(core): refuse undeclared context and align tool schema hints Reject unknown resolved context keys before reading values, preserve owned provenance and align nonempty model-facing tool constraints with runtime admission. Refs: ADR-0113, ADR-0029 Co-Authored-By: Codex --- packages/core/src/agent-parser.ts | 2 +- .../core/src/interpolation/provenance.test.ts | 17 +++++++++++ packages/core/src/interpolation/provenance.ts | 2 ++ packages/core/src/tools/builtins.test.ts | 28 ++++++++++++++++++- packages/core/src/tools/builtins.ts | 9 +++++- 5 files changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/core/src/agent-parser.ts b/packages/core/src/agent-parser.ts index 74a3c28c..f2f921b9 100644 --- a/packages/core/src/agent-parser.ts +++ b/packages/core/src/agent-parser.ts @@ -8,7 +8,7 @@ import { schemaIssueReason } from './schema-issue.js'; * Pure by contract (like `parseWorkflow`): it takes text, never a path — the host surface (CLI catalog scan * 2.I, the authoring commands 2.J) reads the file and passes the string plus an optional workspace-relative * label. An agent file is plain config (model/provider/system_prompt/tools); it carries no secret VALUES (keys - * live in the keychain, ADR-0006), but the error still echoes only field *paths*, never an authored value. + * live in the keychain, ADR-0006). Errors expose field paths and fixed schema reasons, never authored values. */ import { LineCounter, YAMLParseError } from 'yaml'; diff --git a/packages/core/src/interpolation/provenance.test.ts b/packages/core/src/interpolation/provenance.test.ts index 122332c1..32a86c68 100644 --- a/packages/core/src/interpolation/provenance.test.ts +++ b/packages/core/src/interpolation/provenance.test.ts @@ -51,6 +51,23 @@ function expectInvalid(action: () => unknown): void { } describe('createWorkflowProvenance — declared-source authority', () => { + it('refuses undeclared resolved keys before reading their values', () => { + const authority = createWorkflowProvenance(workflow()); + expectInvalid(() => authority.qualifyContext({ public: 'safe', undeclared: CANARY })); + let reads = 0; + const resolved: Record = { public: 'safe' }; + Object.defineProperty(resolved, 'undeclared', { + enumerable: true, + get() { + reads += 1; + return CANARY; + }, + }); + expectInvalid(() => authority.qualifyContext(resolved)); + expect(reads).toBe(0); + // A refused external projection cannot taint the owned authority's next valid resolution. + expect(authority.qualifyContext({ public: 'safe' }).values).toEqual({ public: 'safe' }); + }); it('removes direct and transitive derived values while preserving trusted raw resolution', async () => { const definition = workflow(); const authority = createWorkflowProvenance(definition); diff --git a/packages/core/src/interpolation/provenance.ts b/packages/core/src/interpolation/provenance.ts index 52f07325..81101620 100644 --- a/packages/core/src/interpolation/provenance.ts +++ b/packages/core/src/interpolation/provenance.ts @@ -111,6 +111,7 @@ export function createWorkflowProvenance(workflow: Workflow): WorkflowProvenance const context = (workflow.workflow.context ?? []).map(({ key, value }) => Object.freeze({ key, value }), ); + const declaredContextKeys = new Set(context.map(({ key }) => key)); const taint = computeTaint({ ...workflow.workflow, context }); const contextSource = Object.freeze({ workflow: Object.freeze({ context: Object.freeze(context) }), @@ -126,6 +127,7 @@ export function createWorkflowProvenance(workflow: Workflow): WorkflowProvenance const deniedKeys: string[] = []; const orderedKeys = Object.keys(resolved); for (const key of orderedKeys) { + if (!declaredContextKeys.has(key)) throw new WorkflowContextError(); if (taint.ctx.has(key)) deniedKeys.push(key); else { const value = resolved[key]; diff --git a/packages/core/src/tools/builtins.test.ts b/packages/core/src/tools/builtins.test.ts index 157c6cef..67e4f550 100644 --- a/packages/core/src/tools/builtins.test.ts +++ b/packages/core/src/tools/builtins.test.ts @@ -1,4 +1,4 @@ -import { INLINE_MEDIA_CEILING } from '@relavium/shared'; +import { INLINE_MEDIA_CEILING, compileJsonSchemaToZod } from '@relavium/shared'; import type { Scope } from '@relavium/shared'; import { describe, expect, it, vi } from 'vitest'; @@ -106,6 +106,32 @@ const CASES: readonly ToolCase[] = [ ]; describe('built-in catalog', () => { + it.each([ + { id: 'git_commit', valid: { message: 'commit' }, empty: { message: '' } }, + { + id: 'git_commit', + valid: { message: 'commit', files: ['a.ts'] }, + empty: { message: 'commit', files: [''] }, + }, + { id: 'web_search', valid: { query: 'search' }, empty: { query: '' } }, + { id: 'notify', valid: { title: 'notice', body: '' }, empty: { title: '', body: '' } }, + ])('advertises $id nonempty strings in the model-facing validator', ({ id, valid, empty }) => { + const compiled = compileJsonSchemaToZod( + tool(id).llmVisibleParams, + { + maxDepth: 16, + maxNodes: 2000, + maxEnumMembers: 1000, + maxProperties: 500, + maxStringBytes: 65536, + maxPropertyNameBytes: 256, + }, + 'strict', + ); + if (!compiled.ok) throw new Error(`builtin schema refused: ${compiled.reason}`); + expect(compiled.schema.safeParse(valid).success).toBe(true); + expect(compiled.schema.safeParse(empty).success).toBe(false); + }); it('registers 13 unique built-ins, all sourced "builtin"', () => { expect(BUILTIN_TOOLS).toHaveLength(13); expect(new Set(BUILTIN_TOOL_IDS).size).toBe(13); diff --git a/packages/core/src/tools/builtins.ts b/packages/core/src/tools/builtins.ts index 97572b41..611b5ed4 100644 --- a/packages/core/src/tools/builtins.ts +++ b/packages/core/src/tools/builtins.ts @@ -471,6 +471,7 @@ const gitCommitTool = defineBuiltin({ properties: { message: { type: 'string', + minLength: 1, maxLength: COMMIT_MESSAGE_MAX_BYTES, description: 'Non-empty commit message, at most 16 KiB of UTF-8 bytes.', }, @@ -479,6 +480,7 @@ const gitCommitTool = defineBuiltin({ description: 'Optional file pathspecs to include in the commit.', items: { type: 'string', + minLength: 1, description: 'A non-empty file pathspec that does not begin with a hyphen.', }, }, @@ -579,6 +581,7 @@ const webSearchTool = defineBuiltin({ properties: { query: { type: 'string', + minLength: 1, description: 'Non-empty search query sent to the configured search provider.', }, maxResults: { @@ -658,7 +661,11 @@ const notifyTool = defineBuiltin({ llmVisibleParams: { type: 'object', properties: { - title: { type: 'string', description: 'Non-empty title shown in the desktop notification.' }, + title: { + type: 'string', + minLength: 1, + description: 'Non-empty title shown in the desktop notification.', + }, body: { type: 'string', description: 'Notification body text; an empty body is allowed.' }, }, required: ['title', 'body'], From ad0533c5b9ccf9dcf063c9f77b55805c7b7ba850 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 01:36:41 +0300 Subject: [PATCH 164/222] fix(mcp): report cleanup faults without acknowledging closure Report infrastructure observation failure promptly while retaining unknown native closure and every admitted sibling close. Refs: ADR-0088, ADR-0103 Co-Authored-By: Codex --- .../mcp/src/manager-startup-cleanup.test.ts | 51 +++++++++++++++++++ packages/mcp/src/manager.ts | 19 ++++--- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/packages/mcp/src/manager-startup-cleanup.test.ts b/packages/mcp/src/manager-startup-cleanup.test.ts index 9ec2ee68..bd9f3c0c 100644 --- a/packages/mcp/src/manager-startup-cleanup.test.ts +++ b/packages/mcp/src/manager-startup-cleanup.test.ts @@ -105,6 +105,57 @@ describe('successful close reporters observe faults before sibling joins', () => }); describe('successful manager cleanup keeps its original live budget at transport entry', () => { + it('reports and retains a budget-port failure without acknowledging unknown or held cleanup', async () => { + const budgetFailure = new Error('PRIVATE_BUDGET_FAILURE'); + const unknownClose = vi.fn(() => Promise.resolve()); + const held = gate(); + const siblingClose = vi.fn(() => held.promise); + const remainingMs = vi + .fn() + .mockImplementationOnce(() => { + throw budgetFailure; + }) + .mockReturnValue(1); + const withPid = (config: McpServerConfig, childPid: number): McpServerConfig => ({ + ...config, + open: async () => ({ ...(await config.open()), childPid }), + }); + const manager = await startMcpClient( + [withPid(server('unknown', unknownClose), 901), withPid(server('held', siblingClose), 902)], + undefined, + undefined, + { budget: { remainingMs } }, + ); + const report = vi.fn(); + let observed: unknown; + const closing = manager.close(report).catch((cause: unknown) => { + observed = cause; + return cause; + }); + try { + await reactions(); + expect(observed, 'MCP_SUCCESSFUL_BUDGET_FAILURE_IS_PROMPT').toMatchObject({ + message: 'MCP cleanup observation failed.', + cause: { observer: budgetFailure }, + }); + expect(unknownClose).not.toHaveBeenCalled(); + expect(siblingClose).toHaveBeenCalledOnce(); + expect(manager.childPids, 'MCP_BUDGET_FAILURE_IS_NOT_NATIVE_ACK').toEqual([901, 902]); + expect(report).not.toHaveBeenCalled(); + await expect(manager.close()).rejects.toBe(observed); + expect(remainingMs).toHaveBeenCalledTimes(2); + held.release(); + await reactions(); + expect(manager.childPids).toEqual([901]); + await expect(manager.close()).rejects.toBe(observed); + expect(siblingClose).toHaveBeenCalledOnce(); + expect(remainingMs).toHaveBeenCalledTimes(2); + } finally { + held.release(); + void closing; + } + }); + it.each([false, true])( 'rechecks the deferred close boundary (queued expiry=%s)', async (expire) => { diff --git a/packages/mcp/src/manager.ts b/packages/mcp/src/manager.ts index 1dbab688..2843a048 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -68,8 +68,10 @@ export interface McpClient { * reported promptly once per server to each supplied `onCloseError` callback, even while * another close is held. Late reporters receive retained faults. Successful connections are removed * only after their exact close completes. Reporting is not a cleanup join; reporter exceptions - * are returned after all entered closes settle. The default remains best-effort reporting, not - * a semantic receipt or proof that a failed transport acknowledged native resource closure. + * are returned after all entered closes settle. A budget-port infrastructure failure rejects + * promptly while retaining unknown cleanup and every entered sibling close; rejection is no ACK. + * The default remains best-effort reporting, not a semantic receipt or proof that a failed + * transport acknowledged native resource closure. */ close(onCloseError?: (serverId: string, cause: unknown) => void): Promise; /** @@ -176,9 +178,14 @@ export async function startMcpClient( }, (cause) => rejectObserver( - new Error('MCP startup cleanup observation failed.', { - cause: { primary: startupFailure?.cause, observer: cause }, - }), + new Error( + failedStartup + ? 'MCP startup cleanup observation failed.' + : 'MCP cleanup observation failed.', + { + cause: { primary: startupFailure?.cause, observer: cause }, + }, + ), ), ), ) @@ -197,7 +204,7 @@ export async function startMcpClient( // A caller attaching during a held sibling close must see earlier faults now too. for (const [serverId, cause] of closeFailures) deliver(onCloseError, state, serverId, cause); } - return closing.then(() => { + return Promise.race([closing, observerFailure]).then(() => { if (state?.failure !== undefined) throw state.failure.cause; }); }; From fc427d2bc4dbdca0e09ed1dd21d94155586dc306 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 01:36:41 +0300 Subject: [PATCH 165/222] fix(cli): preserve owners through cancellation and cleanup Break the actual session/MCP close cycle after core quiescence and before joining native acknowledgement. Preserve limiting startup signals, promised catalog refusals and original driver ownership. Pin real active SIGINT, JSON and cleanup paths; share native acquisition fixtures. Refs: ADR-0103, ADR-0110, PR #95 Co-Authored-By: Codex --- .../src/chat/session-chat-terminal.test.ts | 47 ++++ apps/cli/src/chat/session-host-owner.ts | 38 ++- apps/cli/src/chat/session-host.test.ts | 139 +++++++++- apps/cli/src/chat/session-host.ts | 41 ++- apps/cli/src/chat/session-hosts.test.ts | 8 +- .../src/chat/session-mcp-close-owner.test.ts | 125 +++++++++ .../cli/src/chat/session-presentation.test.ts | 63 +++++ .../active-agent-signal-native.test.ts | 187 +++++++++++++ .../db-fault-acquisition.test-support.ts | 137 ++++++++++ apps/cli/src/commands/drive-budget.test.ts | 8 + .../src/commands/drive-diagnostics.test.ts | 109 ++++++++ .../commands/drive-startup-ownership.test.ts | 8 + apps/cli/src/commands/drive.test.ts | 42 ++- apps/cli/src/commands/drive.ts | 7 +- .../fixtures/active-agent-signal-child.ts | 213 +++++++++++++++ apps/cli/src/commands/gate.test.ts | 73 ++++-- .../notice-context-forwarding.test.ts | 118 +++++++++ .../commands/partial-db-acquisition.test.ts | 246 ++++++------------ apps/cli/src/commands/run.test.ts | 111 ++++---- apps/cli/src/commands/run.ts | 24 +- apps/cli/src/commands/safe-close.test.ts | 6 + .../commands/surface-db-acquisition.test.ts | 138 +++------- apps/cli/src/commands/workflow-lifetime.ts | 4 +- .../src/commands/workflow-terminal.test.ts | 19 ++ .../cli/src/engine/model-catalog-port.test.ts | 47 ++++ apps/cli/src/engine/model-catalog-port.ts | 2 +- .../src/home/home-doctor-ownership.test.ts | 10 + .../home/home-onboarding-raw-owner.test.ts | 10 + apps/cli/src/process/cli-error.ts | 2 +- .../src/process/invocation-registry.test.ts | 47 ++++ apps/cli/src/process/invocation-registry.ts | 12 + apps/cli/src/process/invocation-work-owner.ts | 4 + apps/cli/src/process/native-bootstrap.test.ts | 46 ++++ .../src/process/native-process-owner.test.ts | 126 +++++++++ .../src/process/pure-substrate-graph.test.ts | 57 ++++ .../process/supported-leaf-exports.test.ts | 4 + apps/cli/src/render/run-job-control.test.ts | 63 +++++ apps/cli/src/render/run-job-control.ts | 16 +- .../tui/ink-terminal-visibility.test.ts | 8 + 39 files changed, 1983 insertions(+), 382 deletions(-) create mode 100644 apps/cli/src/chat/session-mcp-close-owner.test.ts create mode 100644 apps/cli/src/commands/active-agent-signal-native.test.ts create mode 100644 apps/cli/src/commands/db-fault-acquisition.test-support.ts create mode 100644 apps/cli/src/commands/drive-diagnostics.test.ts create mode 100644 apps/cli/src/commands/fixtures/active-agent-signal-child.ts create mode 100644 apps/cli/src/commands/notice-context-forwarding.test.ts diff --git a/apps/cli/src/chat/session-chat-terminal.test.ts b/apps/cli/src/chat/session-chat-terminal.test.ts index 9796c3ee..3b5085ce 100644 --- a/apps/cli/src/chat/session-chat-terminal.test.ts +++ b/apps/cli/src/chat/session-chat-terminal.test.ts @@ -41,6 +41,53 @@ function fixture(write?: (text: string) => Promise) { } describe('persistent chat terminal and actual child mounts', () => { + it('retains a partially registered exit fallback without acknowledging terminal cleanup', async () => { + const f = invocationFixture(); + const lifetime = new SessionChatLifetime(f.invocation, { + onInterrupt: () => () => undefined, + onTerminationSignal: () => () => undefined, + }); + const captured = captureIo(); + const write = vi.fn(() => Promise.resolve()); + const terminal = new SessionChatTerminal({ + lifetime, + active: true, + mouse: true, + io: { ...captured.io, writeOutAcknowledged: write }, + }); + const cause = new TypeError('PRIVATE_PARTIAL_EXIT_REGISTRATION'); + let fallback: (() => void) | undefined; + expect(() => + terminal.enter((callback) => { + fallback = callback; // Registration happened; the API threw before returning its remover. + throw cause; + }), + ).toThrow(cause); + expect(lifetime.surface.work.firstFailure?.cause).toBe(cause); + expect(lifetime.surface.work.hasCleanupUncertainty).toBe(true); + expect(f.registry.observations.exitCode()).toBe(9); + let acknowledged = false; + void terminal.close().then(() => { + acknowledged = true; + }); + await turn(); + expect(write.mock.calls.flat().join('')).toContain(EXIT_ALT_SCREEN); + const enteredWrites = write.mock.calls.length; + if (fallback === undefined) throw new Error('missing partially registered exit fallback'); + expect(fallback).not.toThrow(); + await turn(); + expect(write).toHaveBeenCalledTimes(enteredWrites); // Exit fallback starts no async delivery. + expect(acknowledged, 'CHAT_PARTIAL_EXIT_REGISTRATION_NOT_ACK').toBe(false); + expect(f.registry.activeOwners).toBe(2); + expect(lifetime.surface.work.firstFailure?.cause).toBe(cause); + expect(captured.out() + captured.err()).not.toContain(cause.message); + f.advance(3000); + await turn(); + expect(f.exit).toHaveBeenCalledWith(9); + expect(acknowledged).toBe(false); + expect(f.registry.activeOwners).toBe(2); + }); + it('preserves output failure immediately while a sibling native mount remains held', async () => { const held = gate(); const f = fixture(() => Promise.reject(new CliError('output_unavailable', ''))); diff --git a/apps/cli/src/chat/session-host-owner.ts b/apps/cli/src/chat/session-host-owner.ts index 47cda55f..23ef5acb 100644 --- a/apps/cli/src/chat/session-host-owner.ts +++ b/apps/cli/src/chat/session-host-owner.ts @@ -11,10 +11,12 @@ export class SessionHostOwner { #cancelled: BuiltChatSession | undefined; #persister: SessionPersister | undefined; #constructionEntered = false; + #mcpClosing: Promise | undefined; constructor(invocation: CliInvocation, surface: SessionOwner['surface']) { this.work = new SessionWorkOwner(invocation, surface, { cancel: () => this.#cancel(), + beforeWorkJoin: () => this.#closeMcp(), cleanup: () => this.#cleanup(), }); } @@ -74,6 +76,33 @@ export class SessionHostOwner { } } + #closeMcp(): Promise { + if (this.#mcpClosing !== undefined) return this.#mcpClosing; + const built = this.#built; + if (built === undefined || built.closeMcp === undefined) return Promise.resolve(); + // Publish the exact retained operation before entering callbacks. The SDK's native-close + // receipt is itself transferred work, so its close request must precede that work's join. + // Entered roots are already settled; the core barrier additionally joins actual provider, + // tool and accounting work before an otherwise idle MCP connection can close. + const close = built.closeMcp; + const raw = Promise.resolve().then(async () => { + try { + await built.whenHostSafeToClose(); + } catch (cause) { + this.work.cleanupUncertain(cause); + } + if (this.work.invocation.registry.budget.remainingMs() === 0) + await new Promise(() => {}); + try { + await close.call(built, (_id, cause) => this.work.cleanupUncertain(cause)); + } catch (cause) { + this.work.cleanupUncertain(cause); + } + }); + this.#mcpClosing = this.work.work.retainWork(() => raw); + return this.#mcpClosing; + } + async #cleanup(): Promise { const built = this.#built; if (built === undefined) return; @@ -95,13 +124,6 @@ export class SessionHostOwner { this.work.cleanupUncertain(cause); } } - if (built.closeMcp === undefined) return; - if (this.work.invocation.registry.budget.remainingMs() === 0) - await new Promise(() => {}); - try { - await built.closeMcp((_id, cause) => this.work.cleanupUncertain(cause)); - } catch (cause) { - this.work.cleanupUncertain(cause); - } + await this.#mcpClosing; } } diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index d3325f0a..925fd62f 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -3,6 +3,8 @@ import type { StdioNativeLifetime } from '@relavium/mcp'; import type { NativeProcessController } from '../process/native-process-owner.js'; import { resolveStdioSpawn } from '../engine/mcp-consent.js'; import { createCliInvocation } from '../process/invocation.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { SessionWorkOwner } from './session-work-owner.js'; import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; import { prepareOutputCapPlan } from '@relavium/llm'; import type { PreEgressInfo } from '@relavium/core'; @@ -20,11 +22,12 @@ import { import type { SessionStreamHandleEvent } from '@relavium/core'; import { buildServerToolDefs, + McpAbortedError, startMcpClient as realStartMcpClient, type McpClient, type McpConnection, } from '@relavium/mcp'; -import type { AgentSessionRecord, SessionMessage } from '@relavium/shared'; +import type { AbortSignalLike, AgentSessionRecord, SessionMessage } from '@relavium/shared'; import { describe, expect, it, vi } from 'vitest'; import type { ResolvedChatConfig } from '../config/resolve.js'; @@ -1776,6 +1779,140 @@ it('reads the attached initial usage snapshot once across a streamed turn', asyn expect(initialCoverage).toHaveBeenCalledTimes(1); }); +describe('owned session MCP startup cancellation', () => { + it.each( + (['fresh', 'resumed'] as const).flatMap((surface) => + (['caller', 'owner', 'pre-aborted-caller'] as const).map((cancel) => ({ surface, cancel })), + ), + )('$surface honors $cancel with both startup signals present', async ({ surface, cancel }) => { + const f = invocationFixture(); + const owner = new SessionWorkOwner(f.invocation, 'chat', { + cancel: () => undefined, + cleanup: () => Promise.resolve(), + }); + const caller = new AbortController(); + if (cancel === 'pre-aborted-caller') caller.abort(); + let acknowledgeClose: () => void = () => undefined; + const closeAck = new Promise((resolve) => { + acknowledgeClose = resolve; + }); + const cancelled = vi.fn(); + const listTools = vi.fn((signal?: AbortSignalLike): Promise<[]> => { + let onAbort: () => void = () => undefined; + return new Promise<[]>((_resolve, reject) => { + onAbort = () => { + cancelled(); + reject(new McpAbortedError('fs', 'discovery')); + }; + signal?.addEventListener('abort', onAbort); + }).finally(() => signal?.removeEventListener('abort', onAbort)); + }); + const close = vi.fn(() => closeAck); + let observedSignal: AbortSignalLike | undefined; + const startMcpClient = vi.fn< + NonNullable[0]['startMcpClient']> + >((_servers, signal, work, lifecycle) => { + observedSignal = signal; + return realStartMcpClient( + [ + { + id: 'fs', + open: () => + Promise.resolve({ + listTools, + callTool: () => Promise.resolve({ content: [], isError: false }), + close, + }), + }, + ], + signal, + work, + lifecycle, + ); + }); + const agent = { + ...buildDefaultChatAgent('claude-sonnet-4-6'), + mcp_servers: [{ id: 'fs', transport: 'stdio' as const, command: 'synthetic-server' }], + }; + const common = { + chat: EMPTY_CHAT, + now: () => 0, + providers: scriptedResolver([]), + workOwner: owner, + mcpConnectSignal: caller.signal, + consentGate: () => Promise.resolve(new Map()), + startMcpClient, + }; + const record: AgentSessionRecord = { + id: 'signal-resume', + agentSlug: agent.id, + agentSnapshot: agent, + context: { workingDir: '/tmp', fsScopeTier: 'project' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: '2026-10-16T00:00:00.000Z', + updatedAt: '2026-10-16T00:00:00.000Z', + }; + let settled = false; + const opening = owner + .enter(() => + surface === 'fresh' + ? buildFreshSession({ + ...common, + agent, + agentRef: undefined, + cwd: '/tmp', + projectConfigDir: undefined, + uuid: () => 'signal-fresh', + }) + : buildRestoredSession({ ...common, record, messages: [] }), + ) + .then( + (value) => { + settled = true; + return value; + }, + (cause: unknown) => { + settled = true; + return cause; + }, + ); + try { + if (cancel === 'pre-aborted-caller') { + expect(await opening).toBeInstanceOf(Error); + expect(startMcpClient).not.toHaveBeenCalled(); + } else { + await vi.waitFor(() => expect(listTools).toHaveBeenCalledOnce()); + if (cancel === 'owner') owner.stop(); + else caller.abort(); + expect(observedSignal?.aborted, 'BOTH_SESSION_SIGNALS_LIMIT_STARTUP').toBe(true); + await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled, 'MCP_CANCEL_DOES_NOT_ACKNOWLEDGE_ACTUAL_CLOSE').toBe(false); + expect(f.registry.activeOwners).toBe(1); + // Both inputs abort during cleanup, but their composed signal emits once. + caller.abort(); + owner.stop(); + expect(cancelled).toHaveBeenCalledOnce(); + acknowledgeClose(); + expect(await opening).toBeInstanceOf(Error); + } + } finally { + caller.abort(); + owner.stop(); + acknowledgeClose(); + await opening; + await owner.close(); + } + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.hasInfrastructureFailure()).toBe(false); + expect(f.exit).not.toHaveBeenCalled(); + }); +}); + describe('session MCP native owner forwarding during pending startup', () => { it.each(['fresh', 'resumed'] as const)( '%s forwards authority before the actual SDK initializer resolves', diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index 8797e395..3e2c7d72 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -871,6 +871,41 @@ function mcpOptionsFor( mcpArtifact: string | undefined, cwd: string, ): ConnectAgentMcpOptions { + const ownerSignal = opts.workOwner?.signal; + const callerSignal = opts.mcpConnectSignal; + // Both authorities can limit startup. Subscribe lazily so discovery's own + // listener removal also releases both inputs when startup settles. + const subscriptions = new Map<() => void, () => void>(); + const connectSignal: AbortSignalLike | undefined = + ownerSignal === undefined + ? callerSignal + : callerSignal === undefined || callerSignal === ownerSignal + ? ownerSignal + : { + get aborted() { + return ownerSignal.aborted || callerSignal.aborted; + }, + addEventListener: (type, listener) => { + if (subscriptions.has(listener) || ownerSignal.aborted || callerSignal.aborted) + return; + const notify = (): void => { + if (!subscriptions.delete(listener)) return; + ownerSignal.removeEventListener(type, notify); + callerSignal.removeEventListener(type, notify); + listener(); + }; + subscriptions.set(listener, notify); + ownerSignal.addEventListener(type, notify); + callerSignal.addEventListener(type, notify); + }, + removeEventListener: (type, listener) => { + const notify = subscriptions.get(listener); + if (notify === undefined) return; + subscriptions.delete(listener); + ownerSignal.removeEventListener(type, notify); + callerSignal.removeEventListener(type, notify); + }, + }; return { cwd, ...(opts.workOwner === undefined @@ -890,11 +925,7 @@ function mcpOptionsFor( ...(opts.startMcpClient === undefined ? {} : { startMcpClient: opts.startMcpClient }), ...(opts.mcpSecretResolver === undefined ? {} : { resolveSecret: opts.mcpSecretResolver }), ...(opts.mcpRegistrations === undefined ? {} : { registrations: opts.mcpRegistrations }), - ...(opts.workOwner !== undefined - ? { connectSignal: opts.workOwner.signal } - : opts.mcpConnectSignal === undefined - ? {} - : { connectSignal: opts.mcpConnectSignal }), + ...(connectSignal === undefined ? {} : { connectSignal }), }; } diff --git a/apps/cli/src/chat/session-hosts.test.ts b/apps/cli/src/chat/session-hosts.test.ts index f28aacbc..c98d7802 100644 --- a/apps/cli/src/chat/session-hosts.test.ts +++ b/apps/cli/src/chat/session-hosts.test.ts @@ -114,6 +114,7 @@ describe('actual host instance collection, independent of UI selection', () => { const cleanup = gate(); const close = vi.fn(() => cleanup.promise); const cause = new Error('PRIVATE_POST_BUILD_FAILURE'); + let settled = false; const opening = f.hosts .construct(async (owner) => { await owner.build(async (workOwner) => ({ @@ -122,7 +123,10 @@ describe('actual host instance collection, independent of UI selection', () => { })); throw cause; }) - .catch((error: unknown) => error); + .catch((error: unknown) => { + settled = true; + return error; + }); try { await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()); expect(f.registry.observations.exitCode()).toBe(9); @@ -130,6 +134,8 @@ describe('actual host instance collection, independent of UI selection', () => { 3000, ); expect(f.registry.activeOwners).toBe(1); + await reactions(); + expect(settled, 'FAILED_CONSTRUCTION_AWAITS_ACTUAL_HOST_CLOSE').toBe(false); } finally { cleanup.release(); } diff --git a/apps/cli/src/chat/session-mcp-close-owner.test.ts b/apps/cli/src/chat/session-mcp-close-owner.test.ts new file mode 100644 index 00000000..df77cef0 --- /dev/null +++ b/apps/cli/src/chat/session-mcp-close-owner.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from 'vitest'; +import { resolveConfig } from '../config/resolve.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { buildChatSession } from './session-host.js'; +import { SessionHostOwner } from './session-host-owner.js'; +import { scriptedResolver } from './test-support.js'; + +function gate() { + let release = (): void => {}; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} +const reactions = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +async function fixture(refuseCore = false) { + const control = invocationFixture(); + const owner = new SessionHostOwner(control.invocation, 'chat'); + const core = gate(); + const native = gate(); + const close = vi.fn(() => native.promise); + const built = await owner.build(async (workOwner) => { + const actual = await buildChatSession({ + workOwner, + chat: resolveConfig({}).chat, + agentRef: undefined, + cwd: '/tmp', + projectConfigDir: undefined, + now: Date.now, + uuid: () => 'mcp-close-order', + providers: scriptedResolver([]), + }); + return { + ...actual, + whenHostSafeToClose: async () => { + await core.promise; + if (refuseCore) throw new Error('synthetic core accounting refusal'); + await actual.whenHostSafeToClose(); + }, + closeMcp: close, + }; + }); + // Unit representation of the actual SDK's transferred nativeClosed lifetime. + // The separate child-process suite verifies the real native transport/OS signal boundaries. + const receipt = owner.work.work.retainWork(() => native.promise); + return { control, owner, core, native, close, built, receipt }; +} + +describe('MCP close initiation precedes transferred native receipt join', () => { + it('joins entered roots and actual core work before closing once, then joins every transferred tail', async () => { + const f = await fixture(); + const root = gate(); + const tail = gate(); + const entered = f.owner.work.enter(() => root.promise); + const transferred = f.owner.work.work.retainWork(() => tail.promise); + let settled = false; + const closing = f.owner.work.close().then(() => { + settled = true; + }); + await reactions(); + expect(f.close, 'ENTERED_ROOT_PRECEDES_MCP_CLOSE').not.toHaveBeenCalled(); + root.release(); + await entered; + await reactions(); + expect(f.close, 'ACTUAL_CORE_BARRIER_PRECEDES_MCP_CLOSE').not.toHaveBeenCalled(); + f.core.release(); + await reactions(); + expect(f.close, 'MCP_CLOSE_REQUEST_PRECEDES_NATIVE_ACK_JOIN').toHaveBeenCalledOnce(); + expect(f.close.mock.contexts[0], 'MCP_CLOSE_PRESERVES_BUILT_HOST_RECEIVER').toMatchObject({ + session: f.built.session, + closeMcp: f.close, + }); + expect(settled, 'CLOSE_REQUEST_IS_NOT_ACK').toBe(false); + expect(f.control.registry.activeOwners).toBe(1); + f.owner.cancel(); + expect(f.owner.work.close()).toBe(f.owner.work.close()); + f.native.release(); + await f.receipt; + await reactions(); + expect(settled, 'FINAL_CLEANUP_STILL_OWES_OTHER_TRANSFERRED_TAILS').toBe(false); + expect(f.close).toHaveBeenCalledOnce(); + tail.release(); + await transferred; + await closing; + expect(f.control.registry.activeOwners).toBe(0); + }); + + it('does not enter MCP close after the original finite budget expires while core work joins', async () => { + const f = await fixture(); + let settled = false; + void f.owner.work.close().then(() => { + settled = true; + }); + f.control.invocation.finiteExit.signal(15); + f.control.advance(3000); + await reactions(); + f.core.release(); + await reactions(); + expect(f.close, 'EXHAUSTED_BUDGET_REFUSES_NEW_MCP_CLOSE').not.toHaveBeenCalled(); + expect(f.control.registry.budget.deadline()).toBe(3000); + expect(f.control.exit).toHaveBeenCalledExactlyOnceWith(143); + expect(settled).toBe(false); + expect(f.control.registry.activeOwners).toBe(1); + // Administrative unit fixture disposal does not grant product native closure or retirement. + f.native.release(); + await f.receipt; + }); + it('attempts sibling MCP cleanup after a quiescent core refusal without granting retirement', async () => { + const f = await fixture(true); + let settled = false; + void f.owner.work.close().then(() => { + settled = true; + }); + f.core.release(); + await reactions(); + expect(f.close, 'QUIESCENT_CORE_FAILURE_STILL_ATTEMPTS_MCP_CLOSE').toHaveBeenCalledOnce(); + f.native.release(); + await f.receipt; + await reactions(); + expect(f.control.registry.observations.exitCode()).toBe(9); + expect(f.control.registry.activeOwners).toBe(1); + expect(settled, 'CORE_FAILURE_NEVER_FABRICATES_CLEANUP_ACK').toBe(false); + }); +}); diff --git a/apps/cli/src/chat/session-presentation.test.ts b/apps/cli/src/chat/session-presentation.test.ts index f522ba15..56f9643b 100644 --- a/apps/cli/src/chat/session-presentation.test.ts +++ b/apps/cli/src/chat/session-presentation.test.ts @@ -22,6 +22,69 @@ const event: SessionStreamHandleEvent = { }; describe('owned passive session presentation', () => { + it.each(['writeOut', 'writeErr'] as const)( + 'classifies a synchronous %s fault before cancellation and retains actual cleanup', + async (method) => { + const f = invocationFixture(); + let releaseCleanup: () => void = () => {}; + const heldCleanup = new Promise((resolve) => { + releaseCleanup = resolve; + }); + const cleanup = vi.fn(() => heldCleanup); + const owner = new SessionWorkOwner(f.invocation, 'chat', { cancel: () => {}, cleanup }); + const captured = captureIo(); + const cause = new TypeError('PRIVATE_SYNCHRONOUS_OUTPUT_FAILURE'); + const writeOut = vi.fn(() => { + if (method === 'writeOut') throw cause; + }); + const writeErr = vi.fn(() => { + if (method === 'writeErr') throw cause; + }); + const writeOutAcknowledged = vi.fn(() => Promise.resolve()); + const writeErrAcknowledged = vi.fn(() => Promise.resolve()); + const output = ownSessionOutput( + { ...captured.io, writeOut, writeErr, writeOutAcknowledged, writeErrAcknowledged }, + owner, + ); + let thrown: unknown; + try { + output[method]('business output'); + } catch (failure) { + thrown = failure; + } + expect(thrown).toBeInstanceOf(CliError); + expect(thrown).toMatchObject({ code: 'output_unavailable', exitCode: 9, cause }); + expect(owner.firstFailure?.cause).toBe(thrown); + expect(f.registry.observations.infrastructureCode()).toBe('output_unavailable'); + expect(f.registry.observations.exitCode()).toBe(9); + if (!(thrown instanceof CliError)) throw new Error('missing classified output failure'); + expect(thrown.message).not.toContain(cause.message); + for (const write of [ + () => output.writeOut('late stdout'), + () => output.writeErr('late stderr'), + () => output.writeOutAcknowledged('late acknowledged stdout'), + () => output.writeErrAcknowledged('late acknowledged stderr'), + ]) + expect(write, 'SESSION_SYNC_OUTPUT_SEALS_ALL_BUSINESS_OUTPUT').toThrow(); + expect(writeOut).toHaveBeenCalledTimes(method === 'writeOut' ? 1 : 0); + expect(writeErr).toHaveBeenCalledTimes(method === 'writeErr' ? 1 : 0); + expect(writeOutAcknowledged).not.toHaveBeenCalled(); + expect(writeErrAcknowledged).not.toHaveBeenCalled(); + let retired = false; + void owner.retired.then(() => { + retired = true; + }); + const closing = owner.close(); + await vi.waitFor(() => expect(cleanup).toHaveBeenCalledOnce()); + expect(retired, 'SESSION_SYNC_OUTPUT_REQUIRES_ACTUAL_CLEANUP').toBe(false); + expect(f.registry.activeOwners).toBe(1); + releaseCleanup(); + await closing; + expect(retired).toBe(true); + expect(f.registry.activeOwners).toBe(0); + }, + ); + it('observes the notification failure before advisory recovery and reentrant cancellation', async () => { const f = invocationFixture(); let notify: (value: string) => void = () => {}; diff --git a/apps/cli/src/commands/active-agent-signal-native.test.ts b/apps/cli/src/commands/active-agent-signal-native.test.ts new file mode 100644 index 00000000..e96e6bae --- /dev/null +++ b/apps/cli/src/commands/active-agent-signal-native.test.ts @@ -0,0 +1,187 @@ +import { spawn } from 'node:child_process'; +import { mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const register = fileURLToPath( + new URL('./fixtures/workflow-cleanup-register.mjs', import.meta.url), +); +const fixture = fileURLToPath(new URL('./fixtures/active-agent-signal-child.ts', import.meta.url)); +const modes = [ + 'chat-plain', + 'chat-json', + 'resume-plain', + 'resume-json', + 'agent-plain', + 'agent-json', + 'chat-eof-plain', + 'chat-eof-json', +]; + +// Windows child.kill(SIGINT) forcibly terminates instead of delivering a cooperative OS signal. +// Existing portable listener/native-owner units cover Windows; these cases claim POSIX OS delivery only. +describe.skipIf(process.platform === 'win32')( + 'shipping agent surfaces retain active shutdown ACKs', + () => { + it.each(modes)( + '%s joins provider and actual MCP native close ACKs', + async (mode) => { + const eof = mode.includes('-eof-'); + const root = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-active-signal-'))); + const child = spawn( + process.execPath, + [ + '--no-warnings', + '--experimental-transform-types', + '--import', + register, + fixture, + mode, + root, + ], + { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + env: { ...process.env, HOME: root, USERPROFILE: root }, + }, + ); + const messages: unknown[] = []; + const phases: string[] = []; + const deliveryErrors: Error[] = []; + let stdout = ''; + let stderr = ''; + let watchdogFired = false; + let releasedNative = false; + let nativePid: number | undefined; + let confirmedNativeClose = false; + const send = (message: string): void => { + child.send(message, (error) => { + if (error !== null) deliveryErrors.push(error); + }); + }; + child.stdin?.on('error', () => {}); + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on('message', (message: unknown) => { + messages.push(message); + const phase: unknown = + typeof message === 'object' && message !== null + ? Object.getOwnPropertyDescriptor(message, 'phase')?.value + : undefined; + if (typeof phase !== 'string') return; + phases.push(phase); + const pid: unknown = Object.getOwnPropertyDescriptor(message, 'nativePid')?.value; + if (typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0) nativePid = pid; + if (phase === 'provider-active') { + if (eof) { + child.stdin?.end(); + send('release-provider'); + } else child.kill('SIGINT'); + } + if (phase === 'provider-abort-held') send('release-provider'); + // Both deliveries are independently held. Native close can race provider shutdown. + if ( + !releasedNative && + phases.includes('provider-ack') && + phases.includes('native-close-held') + ) { + releasedNative = true; + send('release-native'); + } + }); + const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve, reject) => { + child.once('error', reject); + child.once('close', (code, signal) => resolve({ code, signal })); + }, + ); + const watchdog = setTimeout(() => { + watchdogFired = true; + child.kill('SIGKILL'); + }, 9000); + if (mode.startsWith('agent-')) child.stdin?.end('synthetic active prompt\n'); + else child.stdin?.write('synthetic active prompt\n'); + try { + const exit = await closed; + expect(stderr, 'ACTIVE_SIGNAL_FIXTURE_INFRASTRUCTURE').toBe(''); + expect(stdout, 'ACTIVE_SIGNAL_FIXTURE_STDOUT_ISOLATION').toBe(''); + expect(watchdogFired, JSON.stringify(messages)).toBe(false); + expect(deliveryErrors).toEqual([]); + expect(phases[0], 'ACTIVE_PROVIDER_PRECEDES_OS_SIGNAL').toBe('provider-active'); + if (!eof) expect(phases).toContain('provider-abort-held'); + expect(phases).toContain('provider-ack'); + expect(phases).toContain('native-close-held'); + const witness = (phase: string) => messages[phases.indexOf(phase)]; + expect(witness('provider-active')).toMatchObject({ + providerAborted: false, + providerAcknowledged: false, + nativeAcknowledged: false, + settled: false, + }); + if (!eof) + expect( + witness('provider-abort-held'), + 'ABORT_REQUEST_IS_NOT_PROVIDER_ACK', + ).toMatchObject({ + providerAborted: true, + providerAcknowledged: false, + settled: false, + }); + expect( + witness('native-close-held'), + 'NATIVE_EXIT_IS_NOT_NATIVE_CLOSE_DELIVERY', + ).toMatchObject({ + nativeCloseHeld: true, + nativeCloseDelivered: false, + nativeAcknowledged: false, + settled: false, + }); + for (const phase of eof + ? ['native-close-held'] + : ['provider-abort-held', 'native-close-held']) { + const message = witness(phase); + const count: unknown = + typeof message === 'object' && message !== null + ? Object.getOwnPropertyDescriptor(message, 'activeOwners')?.value + : undefined; + expect(count, 'ACTUAL_WORK_RETAINS_ORIGINAL_INVOCATION').toBeGreaterThan(0); + } + expect(exit).toEqual({ code: mode.startsWith('agent-') ? 130 : 4, signal: null }); + const final: unknown = JSON.parse(readFileSync(join(root, 'exit.json'), 'utf8')); + expect(final, 'PHYSICAL_EXIT_REQUIRES_BOTH_ACTUAL_ACKS').toMatchObject({ + providerAborted: !eof, + providerAcknowledged: true, + nativeCloseHeld: true, + nativeCloseDelivered: true, + nativeAcknowledged: true, + activeOwners: 0, + stderr: '', + }); + confirmedNativeClose = true; + if (!mode.startsWith('agent-')) { + expect(phases.at(-1)).toBe('settled'); + expect(final).toMatchObject({ settled: true, result: 4 }); + } + } finally { + clearTimeout(watchdog); + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await closed; + if (!confirmedNativeClose && nativePid !== undefined) { + try { + process.kill(nativePid, 'SIGKILL'); + } catch { + /* Actual child already gone. */ + } + } + rmSync(root, { recursive: true, force: true }); + } + }, + 10000, + ); + }, +); diff --git a/apps/cli/src/commands/db-fault-acquisition.test-support.ts b/apps/cli/src/commands/db-fault-acquisition.test-support.ts new file mode 100644 index 00000000..9735871e --- /dev/null +++ b/apps/cli/src/commands/db-fault-acquisition.test-support.ts @@ -0,0 +1,137 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, vi } from 'vitest'; + +type Client = ReturnType; + +interface DbFaultCase { + readonly root: string; + readonly assertCurrent: () => void; + readonly enableFault: () => void; + readonly native: () => Client | undefined; + readonly closeAttempts: () => number; + readonly disposeCommand: (dispose: () => Promise) => void; +} + +/** Per-file raw-case custody; Vitest's timeout is never the scenario's cleanup ACK. */ +export function dbFaultAcquisitionCases() { + let rawCase: Promise | undefined; + let currentSignal: AbortSignal | undefined; + beforeEach((context) => { + currentSignal = context.signal; + }); + afterEach(async () => { + await rawCase?.catch(() => undefined); + rawCase = undefined; + }); + + return ( + options: { + readonly prefix: string; + readonly closeFails: boolean; + readonly closeRecovers?: boolean; + readonly initiallyEnabled?: boolean; + }, + scenario: (fixture: DbFaultCase) => Promise, + ): Promise => { + const signal = currentSignal; + if (signal === undefined) throw new Error('Synthetic case context was not captured'); + rawCase = (async () => { + const root = mkdtempSync(join(tmpdir(), options.prefix)); + let sealed = false; + const assertCurrent = (): void => { + signal.throwIfAborted(); + if (sealed) throw new Error('Synthetic database case no longer admits work'); + }; + let native: Client | undefined; + let attempts = 0; + let faultEnabled = options.initiallyEnabled ?? true; + let disposeCommand: (() => Promise) | undefined; + const nativeDisposals = new Set<() => void>(); + let primary: { readonly cause: unknown } | undefined; + const failures: unknown[] = []; + try { + const actual = await import('@relavium/db'); + assertCurrent(); + const setupFailure = new Error('synthetic database setup failure'); + const closeFailure = new Error('synthetic native close failure'); + vi.stubEnv('HOME', root); + vi.stubEnv('USERPROFILE', root); + writeFileSync(join(root, 'config.toml'), ''); + vi.doMock('@relavium/db', () => ({ + ...actual, + createClient: (...args: Parameters) => { + assertCurrent(); + const client = actual.createClient(...args); + if (faultEnabled) { + native = client; + const nativeClose = client.sqlite.close.bind(client.sqlite); + // Capture every exact handle; this administrative close grants no product ACK. + nativeDisposals.add(() => { + if (client.sqlite.open) nativeClose(); + }); + client.sqlite.close = () => { + attempts++; + if (options.closeFails && (!options.closeRecovers || attempts === 1)) + throw closeFailure; + return nativeClose(); + }; + } + return client; + }, + runMigrations: (...args: Parameters) => { + if (faultEnabled) throw setupFailure; + return actual.runMigrations(...args); + }, + })); + vi.resetModules(); + await scenario({ + root, + assertCurrent, + enableFault: () => { + faultEnabled = true; + }, + native: () => native, + closeAttempts: () => attempts, + disposeCommand: (dispose) => { + disposeCommand = dispose; + }, + }); + } catch (cause) { + primary = { cause }; + } finally { + sealed = true; + try { + await disposeCommand?.(); + } catch (cause) { + failures.push(cause); + } + for (const close of nativeDisposals) { + try { + close(); + } catch (cause) { + failures.push(cause); + } + } + for (const cleanup of [ + () => vi.restoreAllMocks(), + () => vi.doUnmock('@relavium/db'), + () => vi.resetModules(), + () => vi.unstubAllEnvs(), + () => rmSync(root, { recursive: true, force: true }), + ]) { + try { + cleanup(); + } catch (cause) { + failures.push(cause); + } + } + } + if (primary !== undefined) throw primary.cause; + if (failures.length !== 0) + throw new AggregateError(failures, 'Synthetic database fixture cleanup failed'); + })(); + return rawCase; + }; +} diff --git a/apps/cli/src/commands/drive-budget.test.ts b/apps/cli/src/commands/drive-budget.test.ts index f2bf920c..ea8784c1 100644 --- a/apps/cli/src/commands/drive-budget.test.ts +++ b/apps/cli/src/commands/drive-budget.test.ts @@ -6,6 +6,8 @@ import { buildEngine } from '../engine/build-engine.js'; import type { GatePrompter } from '../gate/prompter.js'; import { captureIo, CHAT_TEXT_CAPABILITY_FLAGS } from '../test-support.js'; import { driveRun } from './drive.js'; +import { WorkflowLifetime } from './workflow-lifetime.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; const MODEL = 'offline-budget-ui'; const PRICE: ModelPricing = { provider: 'openai', @@ -64,6 +66,8 @@ for (const mode of ['approve', 'reject', 'stale', 'missing_amount', 'input'] as }); const events: RunEvent[] = []; const { io, err } = captureIo(); + const f = invocationFixture(); + const lifetime = new WorkflowLifetime(f.invocation, io, () => () => undefined); let prompts = 0; let approvedAmount: number | undefined; const prompter: GatePrompter = { @@ -94,6 +98,7 @@ for (const mode of ['approve', 'reject', 'stale', 'missing_amount', 'input'] as }; try { const result = await driveRun({ + lifetime, engine, handle, makeRenderer: () => ({ onEvent: (e) => events.push(e) }), @@ -151,6 +156,9 @@ for (const mode of ['approve', 'reject', 'stale', 'missing_amount', 'input'] as expect(events.some((e) => e.type === 'run:cancelled')).toBe(false); } unsubscribe(); + await lifetime.finish(); + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); expect(host.armedCount()).toBe(0); expect(host.livenessCount()).toBe(0); expect(host.deadlineCount()).toBe(0); diff --git a/apps/cli/src/commands/drive-diagnostics.test.ts b/apps/cli/src/commands/drive-diagnostics.test.ts new file mode 100644 index 00000000..5b361c21 --- /dev/null +++ b/apps/cli/src/commands/drive-diagnostics.test.ts @@ -0,0 +1,109 @@ +import { WorkflowEngine, createInMemoryHost, parseWorkflow } from '@relavium/core'; +import { describe, expect, it, vi } from 'vitest'; + +import { CliError } from '../process/cli-error.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; +import { driveRun } from './drive.js'; +import { WorkflowLifetime } from './workflow-lifetime.js'; + +function deferred() { + let resolve: () => void = () => undefined; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe('original driver owner observes cleanup diagnostic sink failures', () => { + it.each( + (['cleanup_pending', 'cleanup_failed'] as const).flatMap((diagnostic) => + (['known', 'opaque'] as const).map((fault) => ({ diagnostic, fault })), + ), + )( + '$diagnostic with a $fault sink refusal retains actual cleanup', + async ({ diagnostic, fault }) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const f = invocationFixture(); + const captured = captureIo(); + const entered = deferred(); + const released = deferred(); + const cause = + fault === 'known' + ? new CliError('invalid_invocation', 'PRIVATE_DIAGNOSTIC_REFUSAL') + : new Error('PRIVATE_DIAGNOSTIC_REFUSAL'); + const io = { + ...captured.io, + writeErr: () => { + throw cause; + }, + }; + const lifetime = new WorkflowLifetime(f.invocation, io, () => () => undefined); + const engine = new WorkflowEngine({ + host: createInMemoryHost(), + executor: { execute: () => Promise.resolve({ kind: 'completed', output: 'answer' }) }, + }); + const handle = engine.start({ + workflow: parseWorkflow( + JSON.stringify({ + schema_version: '1.0', + workflow: { id: 'diagnostic-owner', nodes: [{ id: 'work', type: 'input' }], edges: [] }, + }), + ), + }); + let attempts = 0; + let settled = false; + const driving = driveRun({ + lifetime, + engine, + handle, + io, + json: true, + makeRenderer: () => ({ + onEvent: () => undefined, + releaseInput: () => { + attempts++; + if (diagnostic === 'cleanup_failed' && attempts === 1) + return Promise.reject(new CliError('internal', 'PRIVATE_INPUT_RELEASE_REFUSAL')); + entered.resolve(); + return released.promise; + }, + }), + }).then( + (outcome) => { + settled = true; + return outcome; + }, + (failure: unknown) => { + settled = true; + return failure; + }, + ); + try { + await entered.promise; + if (diagnostic === 'cleanup_pending') await vi.advanceTimersByTimeAsync(250); + expect( + f.registry.observations.infrastructureCode(), + 'DIAGNOSTIC_SINK_FAILURE_OWNS_OUTPUT_REFUSAL', + ).toBe('output_unavailable'); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.budget.deadline()).toBe(3000); + expect(settled, 'DIAGNOSTIC_SINK_FAILURE_IS_NOT_INPUT_CLOSE_ACK').toBe(false); + expect(f.registry.activeOwners).toBe(2); + expect(captured.out() + captured.err()).not.toContain('PRIVATE_'); + released.resolve(); + const result = await driving; + if (diagnostic === 'cleanup_failed') expect(result).toMatchObject({ code: 'internal' }); + else expect(result).toBe('completed'); + await lifetime.finish(); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.exitCode()).toBe(9); + } finally { + released.resolve(); + await driving; + await lifetime.finish(); + vi.useRealTimers(); + } + }, + ); +}); diff --git a/apps/cli/src/commands/drive-startup-ownership.test.ts b/apps/cli/src/commands/drive-startup-ownership.test.ts index dfa793e2..6dede245 100644 --- a/apps/cli/src/commands/drive-startup-ownership.test.ts +++ b/apps/cli/src/commands/drive-startup-ownership.test.ts @@ -2,6 +2,8 @@ import { WorkflowEngine, createInMemoryHost, parseWorkflow } from '@relavium/cor import { expect, it } from 'vitest'; import { captureIo } from '../test-support.js'; import { driveRun } from './drive.js'; +import { WorkflowLifetime } from './workflow-lifetime.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; for (const fails of [false, true]) it(`owns input-adapter construction after engine admission (fails=${fails})`, async () => { @@ -23,10 +25,13 @@ for (const fails of [false, true]) ), }); const { io } = captureIo(); + const f = invocationFixture(); + const lifetime = new WorkflowLifetime(f.invocation, io, () => () => undefined); let constructions = 0; let mounts = 0; const signals = process.listenerCount('SIGINT'); const driving = driveRun({ + lifetime, engine, handle, io, @@ -42,6 +47,9 @@ for (const fails of [false, true]) }); if (fails) await expect(driving).rejects.toThrow('input adapter construction failed'); else await expect(driving).resolves.toBe('completed'); + await lifetime.finish(); + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); expect(constructions).toBe(1); expect(mounts).toBe(fails ? 0 : 1); await expect(handle.depart()).resolves.toMatchObject({ diff --git a/apps/cli/src/commands/drive.test.ts b/apps/cli/src/commands/drive.test.ts index ded6bf4e..d912b29e 100644 --- a/apps/cli/src/commands/drive.test.ts +++ b/apps/cli/src/commands/drive.test.ts @@ -1,5 +1,5 @@ import { WorkflowLifetime } from './workflow-lifetime.js'; -import { createCliInvocation } from '../process/invocation.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; import { EngineStateError, createInMemoryHost, @@ -66,14 +66,21 @@ describe('driveRun — interactive gate', () => { const { renderer, events } = recordingRenderer(); const prompter = prompterReturning({ decision: 'approved', decidedBy: 'cli' }); const { io } = captureIo(); + const lifetime = new WorkflowLifetime( + invocationFixture().invocation, + io, + () => () => undefined, + ); const outcome = await driveRun({ + lifetime, engine, handle, makeRenderer: () => renderer, gatePrompter: prompter, io, }); + await lifetime.finish(); expect(outcome).toBe('completed'); expect(prompter.prompt).toHaveBeenCalledTimes(1); @@ -88,14 +95,21 @@ describe('driveRun — interactive gate', () => { const { engine, handle } = await startGatedRun(); const { renderer, events } = recordingRenderer(); const { io } = captureIo(); + const lifetime = new WorkflowLifetime( + invocationFixture().invocation, + io, + () => () => undefined, + ); await driveRun({ + lifetime, engine, handle, makeRenderer: () => renderer, gatePrompter: prompterReturning({ decision: 'rejected', decidedBy: 'cli', comment: 'no' }), io, }); + await lifetime.finish(); const resumed = events.find((e) => e.type === 'human_gate:resumed'); expect(resumed).toMatchObject({ decision: 'rejected', decidedBy: 'cli' }); @@ -105,14 +119,21 @@ describe('driveRun — interactive gate', () => { const { engine, handle } = await startGatedRun(); const { renderer, events } = recordingRenderer(); const { io } = captureIo(); + const lifetime = new WorkflowLifetime( + invocationFixture().invocation, + io, + () => () => undefined, + ); const outcome = await driveRun({ + lifetime, engine, handle, makeRenderer: () => renderer, gatePrompter: prompterReturning(null), io, }); + await lifetime.finish(); expect(outcome).toBe('cancelled'); expect(events.at(-1)?.type).toBe('run:cancelled'); @@ -128,7 +149,7 @@ describe('driveRun — interactive gate', () => { const cancelSpy = vi.spyOn(handle, 'cancel'); const lifetime = new WorkflowLifetime( - createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }), + invocationFixture().invocation, io, () => () => undefined, ); @@ -170,7 +191,7 @@ describe('driveRun — interactive gate', () => { const { io, err } = captureIo(); const lifetime = new WorkflowLifetime( - createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }), + invocationFixture().invocation, io, () => () => undefined, ); @@ -200,7 +221,13 @@ describe('driveRun — interactive gate', () => { const { engine, handle } = await startGatedRun(); const { renderer } = recordingRenderer(); const { io } = captureIo(); - const outcome = await driveRun({ engine, handle, makeRenderer: () => renderer, io }); + const lifetime = new WorkflowLifetime( + invocationFixture().invocation, + io, + () => () => undefined, + ); + const outcome = await driveRun({ lifetime, engine, handle, makeRenderer: () => renderer, io }); + await lifetime.finish(); expect(outcome).toBe('paused'); }); @@ -208,6 +235,11 @@ describe('driveRun — interactive gate', () => { const { engine, handle } = await startGatedRun(); const { renderer } = recordingRenderer(); const { io } = captureIo(); + const lifetime = new WorkflowLifetime( + invocationFixture().invocation, + io, + () => () => undefined, + ); // The SIGINT-during-prompt race: the prompt cooperatively cancels the run (→ run:cancelled), then returns // a decision the engine now refuses with run_already_terminal. driveRun must swallow that and drain to the // cancelled terminal — never surface it as a generic "unexpected internal error". @@ -224,12 +256,14 @@ describe('driveRun — interactive gate', () => { }; const outcome = await driveRun({ + lifetime, engine, handle, makeRenderer: () => renderer, gatePrompter: prompter, io, }); + await lifetime.finish(); expect(resumeSpy).toHaveBeenCalledTimes(1); // the race path WAS taken... expect(outcome).toBe('cancelled'); // ...and resolved cleanly to the cancelled terminal, no throw diff --git a/apps/cli/src/commands/drive.ts b/apps/cli/src/commands/drive.ts index ecf88d8a..c9db79eb 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -25,7 +25,7 @@ import { detectOutputMode, isCiEnv } from '../process/output-mode.js'; import type { CliIo } from '../process/io.js'; import type { RunRenderer } from '../render/renderer.js'; import type { RendererCreationOwner } from './workflow-terminal.js'; -import { WorkflowLifetime } from './workflow-lifetime.js'; +import type { WorkflowLifetime } from './workflow-lifetime.js'; import { wireRunJobControl } from '../render/run-job-control.js'; /** @@ -57,7 +57,7 @@ function rethrowDriveFailure(original: unknown): never { } export interface DriveRunDeps { - readonly lifetime?: WorkflowLifetime; + readonly lifetime: WorkflowLifetime; readonly engine: WorkflowEngine; readonly handle: RunHandle; /** @@ -88,7 +88,7 @@ export interface DriveRunDeps { */ export async function driveRun(deps: DriveRunDeps): Promise { const { engine, handle, makeRenderer, io } = deps; - const lifetime = deps.lifetime ?? new WorkflowLifetime(undefined, io); + const lifetime = deps.lifetime; lifetime.adoptEngine(engine, handle.runId); lifetime.adoptHandle(handle); const terminal = lifetime.terminal; @@ -497,7 +497,6 @@ export async function driveRun(deps: DriveRunDeps): Promise void = () => {}; +const providerRelease = new Promise((resolve) => { + releaseProvider = resolve; +}); +let releaseNative: (() => void) | undefined; +const snapshot = () => ({ + mode, + providerAborted, + providerAcknowledged, + nativeCloseHeld, + nativeCloseDelivered, + nativeAcknowledged: lifetime?.isClosed() === true, + nativePid, + activeOwners: invocation.registry.activeOwners, + settled, + result: outcome.result, +}); +const report = (phase: string): void => { + process.send?.({ phase, ...snapshot() }); +}; + +// Withhold delivery of the real child's close event, after the OS has actually closed it. +// No fake close or fulfilled promise supplies the ACK. This process owns exactly one MCP child. +// eslint-disable-next-line @typescript-eslint/unbound-method -- captured intrinsic is invoked only with its actual child receiver below +const nativeEmit = ChildProcess.prototype.emit; +ChildProcess.prototype.emit = function (event: string | symbol, ...args: unknown[]): boolean { + if (event === 'close' && this.pid === nativePid) { + nativeCloseHeld = true; + releaseNative = () => { + releaseNative = undefined; + nativeCloseDelivered = true; + Reflect.apply(nativeEmit, this, [event, ...args]); + }; + setImmediate(() => report('native-close-held')); + return true; + } + const returned: unknown = Reflect.apply(nativeEmit, this, [event, ...args]); + if (typeof returned !== 'boolean') throw new Error('Unexpected native event result.'); + return returned; +}; +const observeNative = (native: NativeProcessController | undefined): NativeProcessController => { + if (native === undefined) throw new Error('Shipping native controller required.'); + return { + reserve: native.reserve, + observeMcp: async () => { + const reservation = await native.observeMcp(); + return { + ...reservation, + attach: (value) => { + lifetime = value; + reservation.attach(value); + }, + }; + }, + }; +}; +const provider: LlmProvider = { + id: 'anthropic', + supports: CHAT_TEXT_CAPABILITY_FLAGS, + generate: () => Promise.reject(new Error('Only streaming is supported by this probe.')), + stream: async function* (request): AsyncGenerator { + const signal = request.signal; + if (signal === undefined || signal.aborted) throw new Error('Active egress signal required.'); + nativePid = lifetime?.readPid(); + if (nativePid === undefined) throw new Error('Real MCP child must precede provider entry.'); + process.kill(nativePid, 0); // Readiness proves an actually live child, not a remembered PID. + const aborted = new Promise((resolve) => { + signal.addEventListener('abort', () => { + providerAborted = true; + resolve(); + }); + }); + yield { type: 'text_delta', text: 'ACTIVE_STREAM' }; + report('provider-active'); + if (eof) { + await providerRelease; + providerAcknowledged = true; + report('provider-ack'); + yield { + type: 'stop', + stopReason: 'stop', + usage: { kind: 'complete', usage: { inputTokens: 1, outputTokens: 1 } }, + }; + return; + } + await aborted; + // The provider has observed cancellation but has not acknowledged actual work completion. + await new Promise((resolve) => setImmediate(resolve)); + report('provider-abort-held'); + await providerRelease; + providerAcknowledged = true; + report('provider-ack'); + }, +}; +process.on('message', (message: unknown) => { + if (message === 'release-provider') releaseProvider(); + if (message === 'release-native') releaseNative?.(); +}); +process.on('exit', () => { + writeFileSync( + join(root, 'exit.json'), + JSON.stringify({ + ...snapshot(), + observedExit: invocation.registry.observations.exitCode(), + stdout: captured.out(), + stderr: captured.err(), + }), + ); +}); +if (resumed) { + const opened = openSessionStore(root); + const iso = new Date().toISOString(); + opened.store.createSession({ + id: 'active-resume', + agentSlug: agent.id, + agentSnapshot: agent, + context: { workingDir: root, fsScopeTier: 'sandboxed' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: iso, + updatedAt: iso, + }); + opened.close(); +} +const deps = { + invocation, + io: { ...captured.io, stdin: process.stdin }, + global: { + cwd: root, + configPath: join(root, 'config.toml'), + json, + color: false, + verbosity: 'normal' as const, + }, + providers: { resolveProvider: () => provider, keyFor: () => 'synthetic-offline-key' }, + buildSession: (options: Parameters[0]) => + buildChatSession({ + ...options, + consentGate: () => Promise.resolve(new Map()), + nativeProcesses: observeNative(options.nativeProcesses), + }), + buildResumedSession: (options: Parameters[0]) => + buildResumedChatSession({ + ...options, + consentGate: () => Promise.resolve(new Map()), + nativeProcesses: observeNative(options.nativeProcesses), + }), + drive: async (context: Parameters[0]) => { + const outcome = await (json ? driveJson : drivePlain)(context); + report('driver-settled'); + return outcome; + }, +}; +const operation = agentRun + ? agentRunCommand({ agent: agentPath, input: [], allowMcpStdio: [] }, deps) + : resumed + ? chatResumeCommand({ sessionId: 'active-resume' }, deps) + : chatCommand({ agent: agentPath }, deps); +outcome.result = await operation; +settled = true; +report('settled'); +process.exitCode = outcome.result; +process.disconnect(); diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index 7636a663..c6d4a4b4 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -1360,19 +1360,25 @@ workflow: }); }); - it('maps a closed-handle resume (a concurrent-settle race) to an idempotent exit 0', async () => { - const { runId } = await setupPausedRun(); - const { io, out } = captureIo(); - const code = await gateCommand( - { runId, approve: true }, - { - ...deps(io), - buildEngine: () => Promise.resolve(stubEngine(() => Promise.resolve(emptyHandle(runId)))), - }, - ); - expect(code).toBe(EXIT_CODES.success); // a closed handle (already terminal) is an idempotent no-op, not a failure - expect(out()).toContain('already settled'); - }); + it.each([false, true])( + 'maps a closed-handle resume to an idempotent exit 0; json=%s', + async (json) => { + const { runId } = await setupPausedRun(); + const { io, out, err } = captureIo(); + const commandDeps = deps(io); + const code = await gateCommand( + { runId, approve: true }, + { + ...commandDeps, + global: { ...commandDeps.global, json }, + buildEngine: () => Promise.resolve(stubEngine(() => Promise.resolve(emptyHandle(runId)))), + }, + ); + expect(code).toBe(EXIT_CODES.success); // a closed handle (already terminal) is an idempotent no-op, not a failure + expect(json ? err() : out()).toBe(`run ${runId} already settled; nothing to resume\n`); + expect(json ? out() : err()).toBe(''); + }, + ); it('maps a workflow_mismatch EngineStateError from resumeFromCheckpoint to exit-2', async () => { const { runId } = await setupPausedRun(); @@ -1447,22 +1453,33 @@ workflow: }); }); - it('is idempotent: a doubled decision on an already-completed run is a clean exit-0 no-op (no double-advance)', async () => { - const { runId } = await setupPausedRun(); - const { io, out } = captureIo(); - const reader = createRunHistoryStore(db, { - uuid: () => randomUUID(), - now: () => Date.now(), - workflow: { slug: 'gate-resume', name: 'gate-resume', definitionJson: '{}' }, - }); - await gateCommand({ runId, approve: true }, deps(io)); // first resume → completes - const eventsAfterFirst = reader.loadRunEvents(runId).length; + it.each([false, true])( + 'keeps a doubled decision on a completed run idempotent; json=%s', + async (json) => { + const { runId } = await setupPausedRun(); + const { io } = captureIo(); + const reader = createRunHistoryStore(db, { + uuid: () => randomUUID(), + now: () => Date.now(), + workflow: { slug: 'gate-resume', name: 'gate-resume', definitionJson: '{}' }, + }); + await gateCommand({ runId, approve: true }, deps(io)); // first resume → completes + const eventsAfterFirst = reader.loadRunEvents(runId).length; - const code = await gateCommand({ runId, approve: true }, deps(io)); // second decision - expect(code).toBe(EXIT_CODES.success); - expect(out()).toContain('nothing to resume'); // idempotent message, not a re-run - expect(reader.loadRunEvents(runId)).toHaveLength(eventsAfterFirst); // NO new events — not advanced twice - }); + const second = captureIo(); + const commandDeps = deps(second.io); + const buildEngine = vi.fn(() => Promise.reject(new Error('unexpected engine construction'))); + const code = await gateCommand( + { runId, approve: true }, + { ...commandDeps, global: { ...commandDeps.global, json }, buildEngine }, + ); + expect(code).toBe(EXIT_CODES.success); + expect(json ? second.err() : second.out()).toContain('nothing to resume'); + expect(json ? second.out() : second.err()).toBe(''); + expect(buildEngine).not.toHaveBeenCalled(); + expect(reader.loadRunEvents(runId)).toHaveLength(eventsAfterFirst); // NO new events — not advanced twice + }, + ); it('rejects an unknown runId (exit 2)', async () => { const { io } = captureIo(); diff --git a/apps/cli/src/commands/notice-context-forwarding.test.ts b/apps/cli/src/commands/notice-context-forwarding.test.ts new file mode 100644 index 00000000..7dae5e6b --- /dev/null +++ b/apps/cli/src/commands/notice-context-forwarding.test.ts @@ -0,0 +1,118 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect, it, vi } from 'vitest'; + +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; +import { dbFaultAcquisitionCases } from './db-fault-acquisition.test-support.js'; + +const runCase = dbFaultAcquisitionCases(); + +it.each(['run', 'gate', 'logs', 'status', 'chat-list', 'chat-export'] as const)( + '%s supplies captured notice I/O and its original invocation to the database hook', + (surface) => + runCase({ prefix: 'relavium-notice-context-', closeFails: false }, async (fixture) => { + const { root, assertCurrent } = fixture; + const { invocationFixture } = await import('../process/invocation.test-support.js'); + assertCurrent(); + const { captureIo } = await import('../test-support.js'); + assertCurrent(); + const { CommandLifetime } = await import('../process/command-lifetime.js'); + assertCurrent(); + const [run, gate, logs, status, chatList, chatExport] = await Promise.all([ + import('./run.js'), + import('./gate.js'), + import('./logs.js'), + import('./status.js'), + import('./chat-list.js'), + import('./chat-export.js'), + ]); + assertCurrent(); + const { CliError } = await import('../process/cli-error.js'); + assertCurrent(); + const f = invocationFixture(); + const captured = captureIo(); + const command = + surface === 'run' || surface === 'gate' + ? undefined + : new CommandLifetime(f.invocation, captured.io, () => () => undefined); + const io = command?.io ?? captured.io; + const refusal = new CliError('invalid_invocation', 'Synthetic notice hook stop'); + let seen: LocalDbNoticeContext | undefined; + const observe = vi.fn((context: LocalDbNoticeContext | undefined): never => { + seen = context; + throw refusal; + }); + const openDb = ( + _home: string, + _capture?: (close: () => void) => void, + context?: LocalDbNoticeContext, + ): never => observe(context); + const common = { + io, + invocation: f.invocation, + ...(command === undefined ? {} : { commandLifetime: command }), + subscribeSignals: () => () => undefined, + global: { + cwd: root, + configPath: join(root, 'config.toml'), + json: true, + color: false, + verbosity: 'normal' as const, + }, + }; + const workflow = join(root, 'notice.relavium.yaml'); + writeFileSync( + workflow, + `schema_version: '1.0' +workflow: + id: notice-context + nodes: + - {id: start, type: input} + - {id: end, type: output} + edges: + - {from: start, to: end} +`, + ); + const invoke = () => { + switch (surface) { + case 'run': + return run.runCommand( + { workflow, input: [], allowMcpStdio: [] }, + { + ...common, + openRunStore: (_definition, _home, _cwd, _close, context) => observe(context), + }, + ); + case 'gate': + return gate.gateCommand({ runId: 'synthetic', approve: true }, { ...common, openDb }); + case 'logs': + return logs.logsCommand({ runId: 'synthetic' }, { ...common, openDb }); + case 'status': + return status.statusCommand({ ...common, openDb }); + case 'chat-list': + return chatList.chatListCommand({ ...common, openSessionStore: openDb }); + case 'chat-export': + return chatExport.chatExportCommand( + { sessionId: 'synthetic', force: false }, + { + ...common, + openSessionStore: openDb, + }, + ); + } + }; + const outcome = await ( + command === undefined + ? Promise.resolve().then(invoke) + : command.execute(() => Promise.resolve().then(invoke)) + ).catch((cause: unknown) => cause); + expect(outcome).toBe(refusal); + expect(observe).toHaveBeenCalledOnce(); + expect(seen?.io, 'DATABASE_NOTICE_USES_CAPTURED_IO').toBe(io); + expect(seen?.invocation, 'DATABASE_NOTICE_USES_ORIGINAL_INVOCATION').toBe(f.invocation); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.hasInfrastructureFailure()).toBe(false); + expect(f.exit).not.toHaveBeenCalled(); + expect(captured.out()).toBe(''); + }), +); diff --git a/apps/cli/src/commands/partial-db-acquisition.test.ts b/apps/cli/src/commands/partial-db-acquisition.test.ts index bb8370dc..de5abaa0 100644 --- a/apps/cli/src/commands/partial-db-acquisition.test.ts +++ b/apps/cli/src/commands/partial-db-acquisition.test.ts @@ -1,19 +1,9 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { dbFaultAcquisitionCases } from './db-fault-acquisition.test-support.js'; -let rawCase: Promise | undefined; -let currentSignal: AbortSignal | undefined; -beforeEach((context) => { - currentSignal = context.signal; -}); -afterEach(async () => { - // A Vitest deadline rejects its wrapper, not this scenario's late module continuation. - // Do not let the next case replace mocks/environment before the raw case cleans up. - await rawCase?.catch(() => undefined); - rawCase = undefined; -}); +const runCase = dbFaultAcquisitionCases(); beforeAll(async () => { // Source transformation is setup; the unchanged five-second case tests native custody. await import('./dispatch.js'); @@ -39,34 +29,13 @@ const routes = [ describe('management custody before local database setup', () => { it.each(routes.flatMap((route) => [false, true].map((closeFails) => ({ ...route, closeFails }))))( '$id joins actual SQLite after setup fails; close failure=$closeFails', - ({ id, positionals, options, closeFails }) => { - const signal = currentSignal; - if (signal === undefined) throw new Error('Synthetic case context was not captured'); - rawCase = (async () => { - const root = mkdtempSync(join(tmpdir(), 'relavium-partial-db-')); - let sealed = false; - const assertCurrent = (): void => { - signal.throwIfAborted(); - if (sealed) throw new Error('Synthetic database case no longer admits work'); - }; - let disposeCommand: (() => Promise) | undefined; - let opened: ReturnType | undefined; - const nativeDisposals = new Set<() => void>(); - let primary: { readonly cause: unknown } | undefined; - const failures: unknown[] = []; - try { - const actual = await import('@relavium/db'); - assertCurrent(); - let closeAttempts = 0; - const setupFailure = new Error('synthetic database setup failure'); - const closeFailure = new Error('synthetic native close failure'); - vi.stubEnv('HOME', root); - vi.stubEnv('USERPROFILE', root); - writeFileSync(join(root, 'config.toml'), ''); - mkdirSync(join(root, '.relavium', 'workflows'), { recursive: true }); - writeFileSync( - join(root, '.relavium', 'workflows', 'synthetic.relavium.yaml'), - `schema_version: '1.0' + ({ id, positionals, options, closeFails }) => + runCase({ prefix: 'relavium-partial-db-', closeFails }, async (fixture) => { + const { root, assertCurrent } = fixture; + mkdirSync(join(root, '.relavium', 'workflows'), { recursive: true }); + writeFileSync( + join(root, '.relavium', 'workflows', 'synthetic.relavium.yaml'), + `schema_version: '1.0' workflow: id: synthetic nodes: @@ -75,133 +44,76 @@ workflow: edges: - { from: start, to: end } `, - ); - vi.doMock('@relavium/db', () => ({ - ...actual, - createClient: (...args: Parameters) => { - assertCurrent(); - const client = actual.createClient(...args); - opened = client; - const nativeClose = client.sqlite.close.bind(client.sqlite); - nativeDisposals.add(() => { - if (client.sqlite.open) nativeClose(); - }); - client.sqlite.close = () => { - closeAttempts++; - if (closeFails) throw closeFailure; - return nativeClose(); - }; - return client; - }, - runMigrations: () => { - throw setupFailure; - }, - })); - vi.resetModules(); - const { executeCommand } = await import('./dispatch.js'); - assertCurrent(); - const { CommandLifetime } = await import('../process/command-lifetime.js'); - assertCurrent(); - const { invocationFixture } = await import('../process/invocation.test-support.js'); - assertCurrent(); - const { captureIo } = await import('../test-support.js'); - assertCurrent(); - const f = invocationFixture(); - const captured = captureIo(); - const removeSignals = vi.fn(); - const lifetime = new CommandLifetime(f.invocation, captured.io, () => removeSignals); - let settled = false; - const command = lifetime.execute(() => - executeCommand( - id, - { positionals, options }, - { - io: lifetime.io, - invocation: f.invocation, - commandLifetime: lifetime, - global: { - cwd: root, - configPath: join(root, 'config.toml'), - json: true, - color: false, - verbosity: 'normal', - }, + ); + const { executeCommand } = await import('./dispatch.js'); + assertCurrent(); + const { CommandLifetime } = await import('../process/command-lifetime.js'); + assertCurrent(); + const { invocationFixture } = await import('../process/invocation.test-support.js'); + assertCurrent(); + const { captureIo } = await import('../test-support.js'); + assertCurrent(); + const f = invocationFixture(); + const captured = captureIo(); + const removeSignals = vi.fn(); + const lifetime = new CommandLifetime(f.invocation, captured.io, () => removeSignals); + let settled = false; + const command = lifetime.execute(() => + executeCommand( + id, + { positionals, options }, + { + io: lifetime.io, + invocation: f.invocation, + commandLifetime: lifetime, + global: { + cwd: root, + configPath: join(root, 'config.toml'), + json: true, + color: false, + verbosity: 'normal', }, - ), - ); - const outcome = command.then( - (value) => { - settled = true; - return value; - }, - (cause: unknown) => { - settled = true; - return cause; }, - ); - disposeCommand = async () => { - // Stop admission before any pending route import resumes. A proved failed - // native close intentionally has no product ACK; harness disposal below is distinct. - void f.registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); - f.advance(3_000); - if (!opened?.sqlite.open) await outcome; - }; - await new Promise((resolve) => setImmediate(resolve)); - expect(captured.out()).toBe(''); - expect(f.registry.observations.exitCode()).toBe(9); - expect(f.registry.budget.deadline()).toBe(3_000); - if (closeFails) { - expect(settled, 'PARTIAL_DATABASE_FAILURE_RETAINS_ACTUAL_NATIVE_CLOSE').toBe(false); - expect(opened?.sqlite.open).toBe(true); - expect(f.registry.activeOwners).toBe(1); - expect(removeSignals).not.toHaveBeenCalled(); - expect(closeAttempts).toBe(2); // Opener's immediate release and adopted actual-handle cleanup. - f.advance(3_000); - expect(await f.invocation.finiteExit.exited).toBe(9); - expect(f.registry.activeOwners).toBe(1); - expect(opened?.sqlite.open).toBe(true); - } else { - expect(await outcome).toMatchObject({ code: 'cli_internal', exitCode: 9 }); - expect(opened?.sqlite.open).toBe(false); - expect(f.registry.activeOwners).toBe(0); - expect(removeSignals).toHaveBeenCalledOnce(); - expect(closeAttempts).toBe(1); - } - } catch (cause) { - primary = { cause }; - } finally { - sealed = true; - try { - await disposeCommand?.(); - } catch (cause) { - failures.push(cause); - } - // Every exact captured native handle is administrative disposal, never a product ACK. - for (const close of nativeDisposals) { - try { - close(); - } catch (cause) { - failures.push(cause); - } - } - for (const cleanup of [ - () => vi.doUnmock('@relavium/db'), - () => vi.resetModules(), - () => vi.unstubAllEnvs(), - () => rmSync(root, { recursive: true, force: true }), - ]) { - try { - cleanup(); - } catch (cause) { - failures.push(cause); - } - } + ), + ); + const outcome = command.then( + (value) => { + settled = true; + return value; + }, + (cause: unknown) => { + settled = true; + return cause; + }, + ); + fixture.disposeCommand(async () => { + // Stop admission before any pending route import resumes. A proved failed + // native close intentionally has no product ACK; harness disposal below is distinct. + void f.registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + f.advance(3_000); + if (!fixture.native()?.sqlite.open) await outcome; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(captured.out()).toBe(''); + expect(f.registry.observations.exitCode()).toBe(9); + expect(f.registry.budget.deadline()).toBe(3_000); + if (closeFails) { + expect(settled, 'PARTIAL_DATABASE_FAILURE_RETAINS_ACTUAL_NATIVE_CLOSE').toBe(false); + expect(fixture.native()?.sqlite.open).toBe(true); + expect(f.registry.activeOwners).toBe(1); + expect(removeSignals).not.toHaveBeenCalled(); + expect(fixture.closeAttempts()).toBe(2); // Opener's immediate release and adopted actual-handle cleanup. + f.advance(3_000); + expect(await f.invocation.finiteExit.exited).toBe(9); + expect(f.registry.activeOwners).toBe(1); + expect(fixture.native()?.sqlite.open).toBe(true); + } else { + expect(await outcome).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + expect(fixture.native()?.sqlite.open).toBe(false); + expect(f.registry.activeOwners).toBe(0); + expect(removeSignals).toHaveBeenCalledOnce(); + expect(fixture.closeAttempts()).toBe(1); } - if (primary !== undefined) throw primary.cause; - if (failures.length !== 0) - throw new AggregateError(failures, 'Synthetic database fixture cleanup failed'); - })(); - return rawCase; - }, + }), ); }); diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts index f05394c5..f004b889 100644 --- a/apps/cli/src/commands/run.test.ts +++ b/apps/cli/src/commands/run.test.ts @@ -2181,56 +2181,73 @@ describe('first-run secret admission before service construction (ADR-0107)', () expect(Object.hasOwn(start.mock.calls[0]?.[0].inputs ?? {}, 'optional')).toBe(false); }); - it('uses the real default reader exactly once and suppresses even an injected gate selector', async () => { - const path = writeWorkflow( - 'secret-gate.relavium.yaml', - GATED.replace( - ' nodes:', - ' inputs:\n - { name: token, type: secret, required: true }\n nodes:', - ), - ); - const { io } = captureIo(); - const source = new Readable({ read() {} }); - const iterator = vi.fn(async function* () { - yield await Promise.resolve(Buffer.from('token= opaque=verbatim \r\n')); - }); - Object.defineProperties(source, { - isTTY: { value: false }, - [Symbol.asyncIterator]: { value: iterator }, - }); - const prompt = vi.fn(() => Promise.resolve(null)); - const selectGatePrompter = vi.fn(() => ({ prompt, dispose: () => {} })); - const engine = await buildEngine({ host: createInMemoryHost() }); - const start = vi.spyOn(engine, 'start'); - try { - let code: number | undefined; - let caught: unknown; - try { - code = await runCommand( - { workflow: path, input: [], allowMcpStdio: [], secretInputsStdin: true }, - deps({ ...io, stdin: source }, globalOptions(), { - buildEngine: () => Promise.resolve(engine), - selectGatePrompter, - }), + it.each(['native', 'structural'] as const)( + 'uses the real %s default reader once and suppresses an injected gate selector', + async (kind) => { + const path = writeWorkflow( + 'secret-gate.relavium.yaml', + GATED.replace( + ' nodes:', + ' inputs:\n - { name: token, type: secret, required: true }\n nodes:', + ), + ); + const { io } = captureIo(); + const source = new Readable({ read() {} }); + if (kind === 'structural') { + // A structurally equivalent embedding stream need not share the native constructor identity. + // Copy its method descriptors without mutating the global prototype or changing input bytes. + const prototype = Object.defineProperties( + {}, + Object.getOwnPropertyDescriptors(Readable.prototype), ); - } catch (error) { - caught = error; + const parent: unknown = Object.getPrototypeOf(Readable.prototype); + if (parent !== null && typeof parent !== 'object') + throw new Error('Invalid stream prototype.'); + Object.setPrototypeOf(prototype, parent); + Object.setPrototypeOf(source, prototype); + expect(source instanceof Readable).toBe(false); } - expect(iterator, 'SECRETS_CAUSAL_RUN_DEFAULT_READER').toHaveBeenCalledOnce(); - expect(start.mock.calls[0]?.[0].inputs, 'SECRETS_CAUSAL_RUN_DEFAULT_READER').toEqual({ - token: ' opaque=verbatim ', + const iterator = vi.fn(async function* () { + yield await Promise.resolve(Buffer.from('token= opaque=verbatim \r\n')); }); - expect( - selectGatePrompter, - 'SECRETS_CAUSAL_RUN_GATE_PROMPT_SUPPRESSION', - ).not.toHaveBeenCalled(); - expect(prompt).not.toHaveBeenCalled(); - expect(caught).toBeUndefined(); - expect(code).toBe(EXIT_CODES.gatePaused); - } finally { - source.destroy(); - } - }); + Object.defineProperties(source, { + isTTY: { value: false }, + [Symbol.asyncIterator]: { value: iterator }, + }); + const prompt = vi.fn(() => Promise.resolve(null)); + const selectGatePrompter = vi.fn(() => ({ prompt, dispose: () => {} })); + const engine = await buildEngine({ host: createInMemoryHost() }); + const start = vi.spyOn(engine, 'start'); + try { + let code: number | undefined; + let caught: unknown; + try { + code = await runCommand( + { workflow: path, input: [], allowMcpStdio: [], secretInputsStdin: true }, + deps({ ...io, stdin: source }, globalOptions(), { + buildEngine: () => Promise.resolve(engine), + selectGatePrompter, + }), + ); + } catch (error) { + caught = error; + } + expect(iterator, 'SECRETS_CAUSAL_RUN_DEFAULT_READER').toHaveBeenCalledOnce(); + expect(start.mock.calls[0]?.[0].inputs, 'SECRETS_CAUSAL_RUN_DEFAULT_READER').toEqual({ + token: ' opaque=verbatim ', + }); + expect( + selectGatePrompter, + 'SECRETS_CAUSAL_RUN_GATE_PROMPT_SUPPRESSION', + ).not.toHaveBeenCalled(); + expect(prompt).not.toHaveBeenCalled(); + expect(caught).toBeUndefined(); + expect(code).toBe(EXIT_CODES.gatePaused); + } finally { + source.destroy(); + } + }, + ); it.each([true, false])( 'the real default reader refuses TTY/oversize before services (TTY=%s)', diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 6884e50c..3466340f 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -68,8 +68,11 @@ import type { RunRenderer } from '../render/renderer.js'; import { selectRenderer } from '../render/select.js'; import { createMcpSecretResolver, type McpSecretResolver } from '../secrets/mcp-secret.js'; import { parseSecretLines } from '../secrets/parse-secret-lines.js'; -import { readSecretFromStdin, type StdinSecretContext } from '../secrets/read-secret.js'; -import { Readable } from 'node:stream'; +import { + readSecretFromStdin, + type StdinSecretContext, + type StdinSecretSource, +} from '../secrets/read-secret.js'; import { resolveWorkflowSource } from '../workflows/resolve.js'; import { assertWorkflowCatalogValid, @@ -619,7 +622,11 @@ async function readRunSecrets( const read = deps.readSecretInput ?? ((): Promise => { - if (!(deps.io.stdin instanceof Readable)) throw new CliError('cli_internal', ''); + if (!isSecretInputSource(deps.io.stdin)) + throw new CliError( + 'invalid_invocation', + 'Secret input requires an async-iterable stdin source.', + ); return readSecretFromStdin(RUN_SECRET_STDIN_CONTEXT, deps.io.stdin, { signal: lifetime.signal, onFailure: (cause) => lifetime.failure(cause), @@ -629,3 +636,14 @@ async function readRunSecrets( lifetime.checkpoint(); return parseSecretLines(raw, 'run --secret-inputs-stdin'); } + +/** Native and embedding inputs share the reader's structural source contract. */ +function isSecretInputSource( + value: NodeJS.ReadableStream, +): value is NodeJS.ReadableStream & StdinSecretSource { + try { + return typeof Reflect.get(value, Symbol.asyncIterator) === 'function'; + } catch { + return false; + } +} diff --git a/apps/cli/src/commands/safe-close.test.ts b/apps/cli/src/commands/safe-close.test.ts index ba6c4bef..5e428acc 100644 --- a/apps/cli/src/commands/safe-close.test.ts +++ b/apps/cli/src/commands/safe-close.test.ts @@ -33,6 +33,7 @@ import { agentRunCommand } from './agent-run.js'; import { chatListCommand } from './chat-list.js'; import { gateCommand } from './gate.js'; import { runCommand, type RunCommandDeps } from './run.js'; +import { WorkflowLifetime } from './workflow-lifetime.js'; const YAML = `schema_version: '1.0' workflow: @@ -279,6 +280,7 @@ describe('command teardown preserves primary outcomes (#22)', () => { it('run retains the first infrastructure failure through opaque close and warning-sink faults', async () => { const primary = new Error('engine build failed'); + const observedFailure = vi.spyOn(WorkflowLifetime.prototype, 'failure'); const captured = captureIo(); const io = { ...captured.io, @@ -301,6 +303,10 @@ describe('command teardown preserves primary outcomes (#22)', () => { ), invocation, ); + expect(observedFailure.mock.calls[0]?.[0], 'PRIMARY_FAILURE_PRECEDES_OPAQUE_CLEANUP').toBe( + primary, + ); + expect(observedFailure.mock.calls.length).toBeGreaterThan(1); assertClosed(captured.err()); }); diff --git a/apps/cli/src/commands/surface-db-acquisition.test.ts b/apps/cli/src/commands/surface-db-acquisition.test.ts index ea803e40..a7623602 100644 --- a/apps/cli/src/commands/surface-db-acquisition.test.ts +++ b/apps/cli/src/commands/surface-db-acquisition.test.ts @@ -1,18 +1,10 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { Readable } from 'node:stream'; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { dbFaultAcquisitionCases } from './db-fault-acquisition.test-support.js'; -let rawCase: Promise | undefined; -let currentSignal: AbortSignal | undefined; -beforeEach((context) => { - currentSignal = context.signal; -}); -afterEach(async () => { - await rawCase?.catch(() => undefined); - rawCase = undefined; -}); +const runCase = dbFaultAcquisitionCases(); beforeAll(async () => { // Warm source transformation, never a command/service, outside the native case budget. await Promise.all([ @@ -35,31 +27,16 @@ describe('original surface custody of partially opened SQLite', () => { { surface: 'pricing' as const, closeFails: true, closeRecovers: true }, ])( '$surface preserves partial acquisition; close failure=$closeFails; later release=$closeRecovers', - ({ surface, closeFails, closeRecovers }) => { - const signal = currentSignal; - if (signal === undefined) throw new Error('Synthetic case context was not captured'); - rawCase = (async () => { - const root = mkdtempSync(join(tmpdir(), 'relavium-surface-db-')); - let sealed = false; - const assertCurrent = (): void => { - signal.throwIfAborted(); - if (sealed) throw new Error('Synthetic surface case no longer admits work'); - }; - let native: ReturnType | undefined; - const nativeDisposals = new Set<() => void>(); - let primary: { readonly cause: unknown } | undefined; - const failures: unknown[] = []; - let disposeCommand: (() => Promise) | undefined; - try { - const actual = await import('@relavium/db'); - assertCurrent(); - let faultEnabled = !['chat', 'agent'].includes(surface); - let attempts = 0; - const primary = new Error('synthetic migration refusal'); - const secondary = new Error('synthetic native close refusal'); - vi.stubEnv('HOME', root); - vi.stubEnv('USERPROFILE', root); - writeFileSync(join(root, 'config.toml'), ''); + ({ surface, closeFails, closeRecovers }) => + runCase( + { + prefix: 'relavium-surface-db-', + closeFails, + closeRecovers, + initiallyEnabled: !['chat', 'agent'].includes(surface), + }, + async (fixture) => { + const { root, assertCurrent } = fixture; const agent = join(root, 'synthetic.agent.yaml'); writeFileSync( agent, @@ -78,31 +55,6 @@ workflow: - { from: start, to: end } `, ); - vi.doMock('@relavium/db', () => ({ - ...actual, - createClient: (...args: Parameters) => { - assertCurrent(); - const opened = actual.createClient(...args); - if (faultEnabled) { - native = opened; - const nativeClose = opened.sqlite.close.bind(opened.sqlite); - nativeDisposals.add(() => { - if (opened.sqlite.open) nativeClose(); - }); - opened.sqlite.close = () => { - attempts++; - if (closeFails && (!closeRecovers || attempts === 1)) throw secondary; - return nativeClose(); - }; - } - return opened; - }, - runMigrations: (...args: Parameters) => { - if (faultEnabled) throw primary; - return actual.runMigrations(...args); - }, - })); - vi.resetModules(); const signals = await import('../process/signals.js'); assertCurrent(); const removeSignals = vi.fn(); @@ -118,7 +70,7 @@ workflow: const nativeOpen = session.openSessionStore; vi.spyOn(session, 'openSessionStore').mockImplementation( (home, captureClose, noticeContext) => { - faultEnabled = true; + fixture.enableFault(); return nativeOpen(home, captureClose, noticeContext); }, ); @@ -194,23 +146,27 @@ workflow: return cause; }, ); - disposeCommand = async () => { + fixture.disposeCommand(async () => { void f.registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); f.advance(3_000); // Before a failed native close exists, join the full import/preparation root. // Otherwise the deliberately unacknowledged product result remains pending. - if (!native?.sqlite.open) await result; - }; + if (!fixture.native()?.sqlite.open) await result; + }); await vi.waitFor(() => - expect(attempts, 'ACTUAL_SURFACE_REACHED_SQLITE_SETUP').toBeGreaterThan(0), + expect(fixture.closeAttempts(), 'ACTUAL_SURFACE_REACHED_SQLITE_SETUP').toBeGreaterThan( + 0, + ), ); await new Promise((resolve) => setImmediate(resolve)); expect(captured.out()).toBe(''); expect(f.registry.observations.exitCode()).toBe(9); expect(f.registry.budget.deadline()).toBe(3_000); if (closeFails && !closeRecovers) { - expect(native?.sqlite.open).toBe(true); - expect(attempts, 'PARTIAL_SURFACE_RETAINS_NATIVE_CLOSE_CAPABILITY').toBe(2); + expect(fixture.native()?.sqlite.open).toBe(true); + expect(fixture.closeAttempts(), 'PARTIAL_SURFACE_RETAINS_NATIVE_CLOSE_CAPABILITY').toBe( + 2, + ); expect(f.registry.activeOwners).toBe(1); expect(removeSignals).not.toHaveBeenCalled(); // A synchronous transient operation rejects immediately while its native obligation stays owned. @@ -219,50 +175,16 @@ workflow: expect(await result).toMatchObject({ code: 'cli_internal', exitCode: 9 }); f.advance(3_000); expect(await f.invocation.finiteExit.exited).toBe(9); - expect(native?.sqlite.open).toBe(true); + expect(fixture.native()?.sqlite.open).toBe(true); expect(f.registry.activeOwners).toBe(1); } else { expect(await result).toMatchObject({ code: 'cli_internal', exitCode: 9 }); - expect(native?.sqlite.open).toBe(false); - expect(attempts).toBe(closeRecovers ? 2 : 1); + expect(fixture.native()?.sqlite.open).toBe(false); + expect(fixture.closeAttempts()).toBe(closeRecovers ? 2 : 1); expect(f.registry.observations.hasInfrastructureFailure()).toBe(true); expect(f.registry.activeOwners).toBe(0); } - } catch (cause) { - primary = { cause }; - } finally { - sealed = true; - try { - await disposeCommand?.(); - } catch (cause) { - failures.push(cause); - } - for (const close of nativeDisposals) { - try { - close(); - } catch (cause) { - failures.push(cause); - } - } - for (const cleanup of [ - () => vi.restoreAllMocks(), - () => vi.doUnmock('@relavium/db'), - () => vi.resetModules(), - () => vi.unstubAllEnvs(), - () => rmSync(root, { recursive: true, force: true }), - ]) { - try { - cleanup(); - } catch (cause) { - failures.push(cause); - } - } - } - if (primary !== undefined) throw primary.cause; - if (failures.length !== 0) - throw new AggregateError(failures, 'Synthetic surface fixture cleanup failed'); - })(); - return rawCase; - }, + }, + ), ); }); diff --git a/apps/cli/src/commands/workflow-lifetime.ts b/apps/cli/src/commands/workflow-lifetime.ts index 7cadd8f3..56490a29 100644 --- a/apps/cli/src/commands/workflow-lifetime.ts +++ b/apps/cli/src/commands/workflow-lifetime.ts @@ -195,8 +195,8 @@ export class WorkflowLifetime { return combined >= 0 && combined <= 9 ? combinedExit(combined) : result; } /** Called only after the command's actual cleanup tail, never by a deadline or cancellation. */ - async finish(cleanupAcknowledged = true): Promise { - if (!cleanupAcknowledged || !this.#cleanupAcknowledged) this.#hostSafe = false; + async finish(): Promise { + if (!this.#cleanupAcknowledged) this.#hostSafe = false; if (this.#hostSafe) this.#resolveRetired({ kind: 'settled' }); await this.terminal.finish(); if (!this.#hostSafe) await new Promise(() => {}); diff --git a/apps/cli/src/commands/workflow-terminal.test.ts b/apps/cli/src/commands/workflow-terminal.test.ts index 7468bcd6..cf66693f 100644 --- a/apps/cli/src/commands/workflow-terminal.test.ts +++ b/apps/cli/src/commands/workflow-terminal.test.ts @@ -26,6 +26,25 @@ function fixture(tty = false) { } describe('reserved workflow terminal creation and actual restoration', () => { + it.each([true, false])( + 'refuses sealed view operation entry; mayAcquire=%s', + async (mayAcquire) => { + const f = fixture(); + f.terminal.seal(); + const operation = vi.fn(() => Promise.resolve()); + let failure: unknown; + try { + void f.terminal.runViewOperation(operation, mayAcquire); + } catch (cause) { + failure = cause; + } + expect(failure).toMatchObject({ code: 'cli_internal' }); + expect(operation).not.toHaveBeenCalled(); + await f.terminal.finish(); + expect(f.registry.activeOwners).toBe(0); + }, + ); + it('retires a native-free preparation without writing cursor codes to NDJSON output', async () => { const f = fixture(); expect(f.registry.activeOwners).toBe(1); diff --git a/apps/cli/src/engine/model-catalog-port.test.ts b/apps/cli/src/engine/model-catalog-port.test.ts index d6ed161d..87ea0969 100644 --- a/apps/cli/src/engine/model-catalog-port.test.ts +++ b/apps/cli/src/engine/model-catalog-port.test.ts @@ -34,6 +34,53 @@ describe('createModelCatalogPort', () => { hasKey: () => hasKey, }); + it.each(['refresh', 'refreshIfStale'] as const)( + '%s returns a rejecting Promise when its owner already stopped', + async (method) => { + const f = invocationFixture(); + const home = new SessionHomeLifetime(f.invocation, () => () => undefined); + const providers = resolver(true); + const resolveProvider = vi.spyOn(providers, 'resolveProvider'); + const port = createModelCatalogPort({ + db: client.db, + providers, + now: () => 0, + uuid: () => 'stopped-catalog', + workOwner: home.surface.work, + }); + home.surface.work.stop(); + // An immediate .catch is part of the Promise API, including refused entry. + const cause = await port[method]().catch((error: unknown) => error); + expect(home.surface.work.isStoppedFailure(cause)).toBe(true); + expect(resolveProvider).not.toHaveBeenCalled(); + await home.surface.work.close(); + }, + ); + + it.each(['refresh', 'refreshIfStale'] as const)( + '%s enters admitted discovery before returning its Promise', + async (method) => { + const f = invocationFixture(); + const home = new SessionHomeLifetime(f.invocation, () => () => undefined); + const providers = resolver(false); + const keyFor = vi.spyOn(providers, 'keyFor'); + const port = createModelCatalogPort({ + db: client.db, + providers, + now: () => 0, + uuid: () => 'admitted-catalog', + workOwner: home.surface.work, + }); + const refresh = port[method]().catch((cause: unknown) => cause); + try { + expect(keyFor, 'CATALOG_ADMISSION_PRECEDES_PROMISE_RETURN').toHaveBeenCalled(); + } finally { + await home.surface.work.close(); + await refresh; + } + }, + ); + it('retains the existing recoverable cache-read policy while joining its actual work', async () => { const f = invocationFixture(); const home = new SessionHomeLifetime(f.invocation, () => () => undefined); diff --git a/apps/cli/src/engine/model-catalog-port.ts b/apps/cli/src/engine/model-catalog-port.ts index 3637bb0a..202aea41 100644 --- a/apps/cli/src/engine/model-catalog-port.ts +++ b/apps/cli/src/engine/model-catalog-port.ts @@ -57,7 +57,7 @@ export function createModelCatalogPort(params: { // not repeat on every `/models` open + every refresh completion on the live ink render thread. The resolvable key // set is fixed for the process (a mid-session key change ⇒ restart), the same posture as the startup key probe. let keyedProviders: ReadonlySet<(typeof KNOWN_PROVIDER_IDS)[number]> | undefined; - const retain = (operation: () => Promise): Promise => { + const retain = async (operation: () => Promise): Promise => { if (workOwner === undefined) return operation(); workOwner.assertAdmitting(); // Discovery/cache reads preserve their recoverable UI policy. Local publication, diff --git a/apps/cli/src/home/home-doctor-ownership.test.ts b/apps/cli/src/home/home-doctor-ownership.test.ts index 3e7b2f61..cb7c2f13 100644 --- a/apps/cli/src/home/home-doctor-ownership.test.ts +++ b/apps/cli/src/home/home-doctor-ownership.test.ts @@ -9,6 +9,8 @@ import { scriptedResolver } from '../chat/test-support.js'; import { ConfigError } from '../config/errors.js'; import { invocationFixture } from '../process/invocation.test-support.js'; import type { InvocationWorkOwner } from '../process/invocation-work-owner.js'; +import { DISABLE_MOUSE } from '../render/alt-screen.js'; +import { DISABLE_BRACKETED_PASTE } from '../render/tui/home-input.js'; import type { RootAppProps } from '../render/tui/home-app.js'; import { captureIo } from '../test-support.js'; import { driveHome } from './drive-home.js'; @@ -78,6 +80,8 @@ it.each(cases)('actual Home doctor retains original publication and work: %s', a let hits = 0; const running = driveHome({ ...(owned ? { invocation: f.invocation } : {}), + // The compatibility driver uses this explicit control sink; owned cases keep their ACK seam. + writeControl: (sequence) => captured.io.writeOut(sequence), io: { ...captured.io, stdoutIsTty: true, stdinIsTty: true }, global: { cwd, @@ -199,6 +203,12 @@ it.each(cases)('actual Home doctor retains original publication and work: %s', a expect(closes).toBe(1); expect(client.sqlite.open).toBe(false); expect(f.registry.activeOwners).toBe(0); + if (!owned) { + expect(captured.out(), 'OWNERLESS_HOME_CAPTURES_TERMINAL_RESTORE').toContain( + DISABLE_BRACKETED_PASTE, + ); + expect(captured.out()).toContain(DISABLE_MOUSE); + } expect(captured.out() + captured.err()).not.toContain('PRIVATE_HOME_DOCTOR'); } finally { remove(); diff --git a/apps/cli/src/home/home-onboarding-raw-owner.test.ts b/apps/cli/src/home/home-onboarding-raw-owner.test.ts index 86f921c5..be6b17cf 100644 --- a/apps/cli/src/home/home-onboarding-raw-owner.test.ts +++ b/apps/cli/src/home/home-onboarding-raw-owner.test.ts @@ -8,6 +8,8 @@ import { expect, it, vi } from 'vitest'; import { ConfigError } from '../config/errors.js'; import { invocationFixture } from '../process/invocation.test-support.js'; import type { InvocationWorkOwner } from '../process/invocation-work-owner.js'; +import { DISABLE_MOUSE } from '../render/alt-screen.js'; +import { DISABLE_BRACKETED_PASTE } from '../render/tui/home-input.js'; import type { RootAppProps } from '../render/tui/home-app.js'; import { captureIo, OwnedTtyInput, OwnedTtyOutput } from '../test-support.js'; import { driveHome } from './drive-home.js'; @@ -83,6 +85,8 @@ it.each(scenarios)('Home retains the original onboarding raw-mode fault: %s', as let settled = false; const running = driveHome({ ...(owned ? { invocation: f.invocation } : {}), + // The compatibility driver uses this explicit control sink; owned cases keep their ACK seam. + writeControl: (sequence) => captured.io.writeOut(sequence), io: { ...captured.io, stdoutIsTty: true, @@ -208,6 +212,12 @@ it.each(scenarios)('Home retains the original onboarding raw-mode fault: %s', as expect(closes).toBe(1); expect(client.sqlite.open).toBe(false); expect(f.registry.activeOwners).toBe(0); + if (!owned) { + expect(captured.out(), 'OWNERLESS_HOME_CAPTURES_TERMINAL_RESTORE').toContain( + DISABLE_BRACKETED_PASTE, + ); + expect(captured.out()).toContain(DISABLE_MOUSE); + } expect(captured.out() + captured.err()).not.toContain('PRIVATE_HOME_RAW_FAULT'); } finally { armed = false; diff --git a/apps/cli/src/process/cli-error.ts b/apps/cli/src/process/cli-error.ts index c71cb81b..6c733e71 100644 --- a/apps/cli/src/process/cli-error.ts +++ b/apps/cli/src/process/cli-error.ts @@ -125,6 +125,6 @@ function fixedInternalFailure(): UserFacingError { return { code: 'cli_internal', message: CLI_ERROR_POLICY.cli_internal.fixedMessage ?? '', - exitCode: 9, + exitCode: EXIT_CODES.infrastructureFailed, }; } diff --git a/apps/cli/src/process/invocation-registry.test.ts b/apps/cli/src/process/invocation-registry.test.ts index 28ee098e..65b4b841 100644 --- a/apps/cli/src/process/invocation-registry.test.ts +++ b/apps/cli/src/process/invocation-registry.test.ts @@ -595,6 +595,53 @@ describe('InvocationRegistry ownership', () => { expect(registry.failureKinds).toBe(1); }); + it.each(['unknown', '__proto__', 'toString', undefined, null, 1])( + 'rejects an unknown runtime owner kind %j before admission', + async (kind) => { + const registry = new InvocationRegistry(new ManualClock()); + const life = deferred(); + const stop = vi.fn(); + const join = vi.fn(); + const owner = session(life.promise, { stop, join }); + Object.defineProperty(owner, 'kind', { value: kind }); + let failure: unknown; + try { + registry.register(owner); + } catch (cause) { + failure = cause; + } + expect(failure).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + expect(registry.activeOwners).toBe(0); + expect(stop).not.toHaveBeenCalled(); + expect(join).not.toHaveBeenCalled(); + // Refusal grants no owner and does not poison later valid registration. + const valid = registry.register(session(life.promise)); + await retire(life); + expect(await valid.close()).toEqual({ kind: 'retired' }); + expect(registry.activeOwners).toBe(0); + }, + ); + + it('distinguishes an absent retirement failure from an explicitly undefined failure', async () => { + const clock = new ManualClock(); + const registry = new InvocationRegistry(clock); + const valid = deferred(); + const malformed = deferred(); + registry.register(session(valid.promise)); + registry.register(session(malformed.promise)); + const payload: OwnerRetirement = { kind: 'settled' }; + // The exact optional protocol permits absence, not a present non-code value. + Object.defineProperty(payload, 'failure', { value: undefined }); + await retire(valid); + expect(registry.activeOwners).toBe(1); + await retire(malformed, payload); + expect(registry.activeOwners).toBe(1); + expect(registry.observations.infrastructureCode()).toBe('cli_internal'); + const closing = registry.terminate({ kind: 'infrastructure', code: 'cli_internal' }); + clock.advanceTo(3000); + expect(await closing).toMatchObject({ kind: 'deadline', unacknowledgedOwners: 1 }); + }); + it('does not invoke hostile retirement payload getters or accept them as an ACK', async () => { const clock = new ManualClock(); const registry = new InvocationRegistry(clock); diff --git a/apps/cli/src/process/invocation-registry.ts b/apps/cli/src/process/invocation-registry.ts index 815d58ec..51ab9cb6 100644 --- a/apps/cli/src/process/invocation-registry.ts +++ b/apps/cli/src/process/invocation-registry.ts @@ -200,6 +200,18 @@ export class InvocationRegistry { this.assertAdmitting(); // Creation-specific capabilities cannot change after registration through the caller's object. owner = Object.freeze({ ...owner }); + switch (owner.kind) { + case 'workflow': + case 'session': + case 'command': + case 'terminal': + case 'native-group': + case 'native-pid': + case 'windows-live-root': + break; + default: + throw new CliError('cli_internal', ''); + } let resolveRetirement = (): void => undefined; const retirement = new Promise((resolve) => { resolveRetirement = resolve; diff --git a/apps/cli/src/process/invocation-work-owner.ts b/apps/cli/src/process/invocation-work-owner.ts index 446960df..4c29f345 100644 --- a/apps/cli/src/process/invocation-work-owner.ts +++ b/apps/cli/src/process/invocation-work-owner.ts @@ -8,6 +8,9 @@ const observePromise: unknown = Object.getOwnPropertyDescriptor(Promise.prototyp export interface InvocationWorkOwnerPorts { /** Finite cancellation only, including when the common termination budget is exhausted. */ readonly cancel: () => void; + /** After entered roots settle, initiate shutdown that transferred lifetime ACKs depend on. + * May retain its exact cleanup work; final cleanup still follows every transferred ACK. */ + readonly beforeWorkJoin?: () => Promise; /** Fulfilment must mean actual complete cleanup, independently of any retained receipt failure. */ readonly cleanup: () => Promise; } @@ -153,6 +156,7 @@ export class InvocationWorkOwner { // Cancellation seals future roots, not the cleanup continuations of roots already entered. // Those roots can still register iterator.return/receipt tails before their actual settlement. await this.#roots.done; + await this.#ports.beforeWorkJoin?.(); this.#work.seal(); await this.#work.done; if (this.invocation.registry.budget.remainingMs() === 0) return; diff --git a/apps/cli/src/process/native-bootstrap.test.ts b/apps/cli/src/process/native-bootstrap.test.ts index d3783f7a..bd4632c9 100644 --- a/apps/cli/src/process/native-bootstrap.test.ts +++ b/apps/cli/src/process/native-bootstrap.test.ts @@ -118,6 +118,52 @@ describe('native product bootstrap', () => { }, ); + it.each([5, 7, 8] as const)( + 'accepts actual workflow health %s without inventing a fatal failure', + async (code) => { + const f = fixture(); + const retired = deferred(); + const native = f.start(() => + Promise.resolve({ + run: () => { + f.registry.register({ + kind: 'workflow', + retired: retired.promise, + stop: () => retired.resolve({ kind: 'settled' }), + join: () => retired.promise.then(() => undefined), + readSafetyExit: () => code, + classifyFailure: () => 'cli_internal', + }); + return Promise.resolve(code); + }, + }), + ); + expect(await native.finished).toBe(code); + expect(f.exitCode()).toBe(code); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.hasInfrastructureFailure()).toBe(false); + expect(f.registry.budget.deadline()).toBeUndefined(); + expect(f.stderr.frames).toEqual([]); + expect(f.exit).not.toHaveBeenCalled(); + expect(f.ports.exit).not.toHaveBeenCalled(); + }, + ); + + it('rejects an invalid resolved runtime code through the single fatal authority', async () => { + const f = fixture(); + const run = vi.fn<(typeof import('../run.js'))['run']>(); + // @ts-expect-error Deliberately model an invalid value crossing the runtime module boundary. + run.mockResolvedValueOnce(10); + const native = f.start(() => Promise.resolve({ run })); + expect(await native.finished).toBe(9); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.infrastructureCode()).toBe('cli_internal'); + expect(f.stderr.frames).toHaveLength(1); + expect(f.stderr.frames.join('')).toContain('relavium:'); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(f.ports.exit).not.toHaveBeenCalled(); + }); + it.each([{ args: [] }, { args: ['--json', '--verbose'] }])( 'reports a loader rejection once without its private text (%j)', async ({ args }) => { diff --git a/apps/cli/src/process/native-process-owner.test.ts b/apps/cli/src/process/native-process-owner.test.ts index 6af6fa66..498b6aee 100644 --- a/apps/cli/src/process/native-process-owner.test.ts +++ b/apps/cli/src/process/native-process-owner.test.ts @@ -523,6 +523,132 @@ describe('creation-owned native processes', () => { await vi.waitFor(() => expect(h.registry.activeOwners).toBe(0)); }); + it('retains a rejected MCP native-close observation as uncertainty through finite expiry', async () => { + const h = harness(); + const observer = await h.controller.observeMcp(); + let rejectClosed: (cause: unknown) => void = () => undefined; + const nativeClosed = new Promise((_resolve, reject) => { + rejectClosed = reject; + }); + const cause = new TypeError('PRIVATE_MCP_NATIVE_CLOSE_OBSERVATION'); + const close = vi.fn(() => Promise.resolve()); + observer.attach({ readPid: () => 70, isClosed: () => false, whenClosed: nativeClosed, close }); + rejectClosed(cause); + await vi.waitFor(() => expect(h.registry.observations.exitCode()).toBe(9)); + expect(h.registry.observations.infrastructureCode()).toBe('cli_internal'); + expect(close).toHaveBeenCalledOnce(); + expect(h.registry.activeOwners, 'MCP_REJECTED_NATIVE_CLOSE_NOT_ACK').toBe(1); + observer.abandonIfUnattached(); + h.advance(3000); + const result = await h.registry.terminate({ kind: 'normal' }); + expect(result).toMatchObject({ kind: 'deadline', unacknowledgedOwners: 1 }); + const priorTermination = h.registry.termination; + h.exit(); + expect(h.registry.termination).toBe(priorTermination); + expect(close).toHaveBeenCalledOnce(); + expect(h.registry.activeOwners).toBe(1); + expect(h.removeExit).not.toHaveBeenCalled(); + }); + + it.each(['synchronous throw', 'promise rejection'] as const)( + 'retains an MCP cooperative-close %s until the exact native-close acknowledgment', + async (failure) => { + const h = harness(); + const observer = await h.controller.observeMcp(); + const nativeClosed = deferred(); + let closed = false; + const cause = new TypeError('PRIVATE_MCP_COOPERATIVE_CLOSE'); + const close = vi.fn(() => { + if (failure === 'synchronous throw') throw cause; + return Promise.reject(cause); + }); + observer.attach({ + readPid: () => 70, + isClosed: () => closed, + whenClosed: nativeClosed.promise, + close, + }); + const stopping = h.registry.terminate({ kind: 'normal' }); + await vi.waitFor(() => expect(h.registry.observations.exitCode()).toBe(9)); + expect(h.registry.observations.infrastructureCode()).toBe('cli_internal'); + expect(close).toHaveBeenCalledOnce(); + let acknowledged = false; + void stopping.then(() => { + acknowledged = true; + }); + // SDK liveness and successful finite requests are not the transferred native-close promise. + closed = true; + h.exit(); + await Promise.resolve(); + expect(acknowledged, 'MCP_COOPERATIVE_CLOSE_FAILURE_NOT_ACK').toBe(false); + expect(h.registry.activeOwners).toBe(1); + expect(h.removeExit).not.toHaveBeenCalled(); + expect(close).toHaveBeenCalledOnce(); + nativeClosed.resolve(); + expect(await stopping).toMatchObject({ kind: 'settled', unacknowledgedOwners: 0 }); + expect(h.registry.activeOwners).toBe(0); + expect(h.removeExit).toHaveBeenCalledOnce(); + expect(h.registry.observations.exitCode()).toBe(9); + expect(h.scheduled.size).toBe(0); + }, + ); + + it.each(['linux', 'darwin'] as const)( + 'finite exit retains explicit group authority after simulated leader exit on %s', + async (platform) => { + const h = harness(platform); + const lease = await h.controller.reserve('group'); + const target = child(); + lease.bindChild(target); + target.emit('exit', 0); // The leader exited; inherited descriptors still owe native close. + h.signal.mockClear(); + let acknowledged = false; + void lease.whenClosed.then(() => { + acknowledged = true; + }); + h.exit(); + expect(h.signal.mock.calls, 'POSIX_FINITE_EXIT_GROUP_AUTHORITY').toEqual([[-40, 'SIGKILL']]); + expect(h.registry.termination).toBeUndefined(); + expect(h.request).not.toHaveBeenCalled(); + expect(h.scheduled.size).toBe(0); + await Promise.resolve(); + expect(acknowledged, 'POSIX_FINITE_EXIT_NOT_NATIVE_ACK').toBe(false); + expect(h.registry.activeOwners).toBe(1); + target.emit('close', 0); + await lease.whenClosed; + expect(h.registry.activeOwners).toBe(0); + expect(h.signal.mock.calls).toEqual([ + [-40, 'SIGKILL'], + [-40, 'SIGKILL'], + ]); + h.exit(); + expect(h.signal).toHaveBeenCalledTimes(2); + expect(h.removeExit).toHaveBeenCalledOnce(); + }, + ); + + it('records finite POSIX group failure without starting async shutdown or acknowledging close', async () => { + const h = harness(); + const lease = await h.controller.reserve('group'); + const target = child(); + lease.bindChild(target); + target.emit('exit', 0); + h.signal.mockClear(); + h.signal.mockImplementationOnce(() => { + throw Object.assign(new Error('PRIVATE_FINITE_GROUP_SIGNAL_FAILURE'), { code: 'EPERM' }); + }); + h.exit(); + expect(h.signal.mock.calls, 'POSIX_FINITE_GROUP_FAILURE_REQUEST').toEqual([[-40, 'SIGKILL']]); + expect(h.registry.observations.exitCode()).toBe(9); + expect(h.registry.termination, 'POSIX_FINITE_GROUP_NO_ASYNC_SHUTDOWN').toBeUndefined(); + expect(h.scheduled.size).toBe(0); + expect(h.registry.activeOwners).toBe(1); + target.emit('close', 0); + await lease.whenClosed; + expect(h.registry.activeOwners).toBe(0); + expect(h.registry.observations.exitCode()).toBe(9); + }); + it.each( (['linux', 'win32'] as const).flatMap((platform) => (['attached', 'late-attach', 'liveness-reentry'] as const).map((boundary) => ({ diff --git a/apps/cli/src/process/pure-substrate-graph.test.ts b/apps/cli/src/process/pure-substrate-graph.test.ts index 21c807f4..2d5ce2f3 100644 --- a/apps/cli/src/process/pure-substrate-graph.test.ts +++ b/apps/cli/src/process/pure-substrate-graph.test.ts @@ -120,6 +120,7 @@ describe('pure Step 6 substrate runtime import closure', () => { 'apps/cli/src/process/diagnostic-policy.ts', 'apps/cli/src/process/options.ts', 'apps/cli/src/process/exit-observations.ts', + 'apps/cli/src/process/exit-codes.ts', 'apps/cli/src/process/invocation-registry.ts', 'apps/cli/src/process/native-invocation.ts', 'apps/cli/src/process/fixed-diagnostic.ts', @@ -231,3 +232,59 @@ describe('lightweight Commander adapter runtime import closure', () => { ); }); }); + +// Step 6's legacy manifest intentionally evaluates Zod. Keep that schema boundary +// separate from the dependency-free pure leaves and the Commander adapter above. +describe('legacy manifest schema boundary runtime import closure', () => { + const root = 'apps/cli/src/commands/manifest.ts'; + const permittedPackages = new Set([ + 'zod', + '@relavium/shared/fs-root-limits', + '@relavium/shared/relavium-error', + ]); + function manifestClosure(sourceOverrides?: ReadonlyMap): Closure { + return runtimeClosure([root], { + permittedPackages, + opaquePackages: new Set(['zod']), + ...(sourceOverrides === undefined ? {} : { sourceOverrides }), + }); + } + it('keeps the legacy model wrapper within its explicit schema and descriptor boundary', () => { + const graph = manifestClosure(); + expect([...graph.packages].sort(), graph.edges.join('\n')).toEqual([ + '@relavium/shared/fs-root-limits', + '@relavium/shared/relavium-error', + 'zod', + ]); + const files = [...graph.files].map((file) => relative(repository, file)); + expect(files).toContain(root); + expect( + files.filter((file) => + /(?:process\/errors|commands\/(?:dispatch|specs)|test-support|engine\/|db\/|render\/|config\/)/.test( + file, + ), + ), + ).toEqual([]); + }); + it('rejects a service value edge while permitting the corresponding erased type', () => { + const file = resolve(repository, root); + const source = readFileSync(file, 'utf8'); + expect(() => + manifestClosure( + new Map([[file, `import type { WorkflowEngine } from '@relavium/core';\n${source}`]]), + ), + ).not.toThrow(); + expect(() => + manifestClosure( + new Map([ + [ + file, + `import { WorkflowEngine } from '@relavium/core';\nvoid WorkflowEngine;\n${source}`, + ], + ]), + ), + ).toThrowError( + 'forbidden runtime import: @relavium/core from apps/cli/src/commands/manifest.ts', + ); + }); +}); diff --git a/apps/cli/src/process/supported-leaf-exports.test.ts b/apps/cli/src/process/supported-leaf-exports.test.ts index cc426edb..57da3b71 100644 --- a/apps/cli/src/process/supported-leaf-exports.test.ts +++ b/apps/cli/src/process/supported-leaf-exports.test.ts @@ -54,7 +54,11 @@ describe('supported compiled dependency leaves', () => { it('retains the shared root and terminal-safe function identity in actual compiled exports', async () => { const root = await import('@relavium/shared'); const leaf = await import('@relavium/shared/terminal-safe'); + expect(typeof leaf.stripTerminalControls).toBe('function'); expect(root.stripTerminalControls).toBe(leaf.stripTerminalControls); + expect(leaf.stripTerminalControls('ordinary\u001b[31m text\u001b[0m\u202e')).toBe( + 'ordinary text', + ); }); it('retains one function through the supported compiled root and leaf exports', async () => { diff --git a/apps/cli/src/render/run-job-control.test.ts b/apps/cli/src/render/run-job-control.test.ts index c9fc2d79..4cf4fecd 100644 --- a/apps/cli/src/render/run-job-control.test.ts +++ b/apps/cli/src/render/run-job-control.test.ts @@ -3,6 +3,9 @@ import { describe, expect, it, vi } from 'vitest'; import { HIDE_CURSOR, SHOW_CURSOR } from './alt-screen.js'; import { wireRunJobControl } from './run-job-control.js'; import type { JobControlLifecycle } from './suspend.js'; +import { WorkflowTerminal } from '../commands/workflow-terminal.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { captureIo } from '../test-support.js'; /** * G0's residue on the run/gate path. ink hides the cursor while it renders and `signal-exit`'s signal list @@ -45,6 +48,66 @@ function harness(supported = true) { } describe('wireRunJobControl (G0, the run/gate path)', () => { + it.each(['suspend', 'continue'] as const)( + 'reports a native %s cursor write failure while retaining actual terminal restoration', + async (phase) => { + const h = harness(); + const f = invocationFixture(); + const captured = captureIo(); + const cause = new Error('PRIVATE_NATIVE_CURSOR_WRITE'); + let release: () => void = () => undefined; + const inputReleased = new Promise((resolve) => { + release = resolve; + }); + const fail = vi.fn(); + const terminal = new WorkflowTerminal( + f.registry, + { ...captured.io, stdoutIsTty: true }, + () => undefined, + fail, + ); + terminal.createRenderer(() => ({ + onEvent: () => undefined, + releaseInput: () => inputReleased, + })); + const onFailure = vi.fn((failure: unknown) => { + fail(failure); + terminal.seal(); + }); + const control = terminal.createJobControl((adopt) => + wireRunJobControl({ + adopt, + lifecycle: h.lifecycle, + onFailure, + write: (text) => { + if (text === (phase === 'suspend' ? SHOW_CURSOR : HIDE_CURSOR)) throw cause; + h.write(text); + }, + }), + ); + if (phase === 'continue') h.fireSuspend(); + expect(phase === 'suspend' ? h.fireSuspend : h.fireContinue).not.toThrow(); + expect(onFailure).toHaveBeenCalledExactlyOnceWith(cause); + expect(fail).toHaveBeenCalledExactlyOnceWith(cause); + expect(h.suspendSelf).toHaveBeenCalledTimes(phase === 'suspend' ? 0 : 1); + await control.released; + let finished = false; + const done = terminal.finish().then(() => { + finished = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(finished, 'CURSOR_WRITE_REFUSAL_RETAINS_TERMINAL_RELEASE').toBe(false); + expect(f.registry.activeOwners).toBe(1); + h.fireSuspend(); + h.fireContinue(); + expect(onFailure).toHaveBeenCalledOnce(); + release(); + await done; + expect(f.registry.activeOwners).toBe(0); + expect(captured.out()).toBe(SHOW_CURSOR + SHOW_CURSOR); + }, + ); + it('SHOWS the cursor before stopping, then re-raises so the process actually suspends', () => { const h = harness(); const jc = wireRunJobControl({ write: h.write, lifecycle: h.lifecycle }); diff --git a/apps/cli/src/render/run-job-control.ts b/apps/cli/src/render/run-job-control.ts index 5270bf95..b1d58b4b 100644 --- a/apps/cli/src/render/run-job-control.ts +++ b/apps/cli/src/render/run-job-control.ts @@ -102,9 +102,21 @@ export function wireRunJobControl(opts: WireRunJobControlOptions): RunJobControl if (failed !== undefined) throw new CliError('cli_internal', '', { cause: failed.cause }); acknowledgeReleased(); }; + const writeCursor = (text: string): boolean => { + try { + opts.write(text); + return true; + } catch (cause) { + // Native signal callbacks cannot throw past the creation owner. Its failure + // path seals callbacks and retains the actual terminal restoration barrier. + if (opts.onFailure === undefined) throw cause; + opts.onFailure(cause); + return false; + } + }; const onSuspend = (): void => { if (disposed) return; - opts.write(SHOW_CURSOR); + if (!writeCursor(SHOW_CURSOR)) return; if (disposed) return; detached(); if (disposed) return; @@ -145,7 +157,7 @@ export function wireRunJobControl(opts: WireRunJobControlOptions): RunJobControl removeContinue = lifecycle.onContinue(() => { if (disposed) return; attach(); - if (!disposed) opts.write(HIDE_CURSOR); + if (!disposed) writeCursor(HIDE_CURSOR); }); continueRegistered = true; } catch (cause) { diff --git a/apps/cli/src/render/tui/ink-terminal-visibility.test.ts b/apps/cli/src/render/tui/ink-terminal-visibility.test.ts index 7ffef0a8..0d149c0a 100644 --- a/apps/cli/src/render/tui/ink-terminal-visibility.test.ts +++ b/apps/cli/src/render/tui/ink-terminal-visibility.test.ts @@ -4,6 +4,8 @@ import { WorkflowEngine, createInMemoryHost, parseWorkflow } from '@relavium/cor import { driveRun } from '../../commands/drive.js'; import { captureIo } from '../../test-support.js'; import { createInkRenderer } from './ink-renderer.js'; +import { WorkflowLifetime } from '../../commands/workflow-lifetime.js'; +import { invocationFixture } from '../../process/invocation.test-support.js'; function deferred() { let resolve!: () => void; @@ -64,10 +66,13 @@ it('actual Ink displays a terminal after native input ACK while the host receipt }); const handle = engine.start({ workflow }); const io = captureIo(); + const f = invocationFixture(); + const lifetime = new WorkflowLifetime(f.invocation, io.io, () => () => undefined); const summaries: string[] = []; const listeners = new Set(process.rawListeners('beforeExit')); let settled = false; const driving = driveRun({ + lifetime, engine, handle, io: io.io, @@ -117,6 +122,9 @@ it('actual Ink displays a terminal after native input ACK while the host receipt } finally { raw.resolve(); await driving; + await lifetime.finish(); + expect(f.registry.activeOwners).toBe(0); + expect(f.exit).not.toHaveBeenCalled(); for (const listener of process.rawListeners('beforeExit')) { if (!listeners.has(listener)) Reflect.apply(process.removeListener.bind(process), process, ['beforeExit', listener]); From 4b1f6f6b52be8e5264237c0ea1640aea82e68210 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 01:36:42 +0300 Subject: [PATCH 166/222] test(repo): pin acceptance gates and packaged regressions Protect required smoke steps, finalized seals and HTTP/2 denial. Restore installed provider/history refusal cases and state the native keyring measurement scope honestly. Refs: ADR-0111, PR #95 Co-Authored-By: Codex --- package.json | 2 +- tools/ci/require-packed-cli.test.mjs | 99 +++++++++++ tools/cli-bootstrap/policy.mjs | 1 + tools/cli-bootstrap/witness.test.mjs | 39 ++++- tools/cli-package/README.md | 2 +- tools/cli-package/aggregate.mjs | 2 +- tools/cli-package/binding.test.mjs | 21 ++- tools/cli-package/seal.test.mjs | 161 ++++++++++++++++++ tools/cli-package/smoke/README.md | 5 +- tools/cli-package/smoke/cases.mjs | 2 + .../cli-package/smoke/failure-diagnostic.mjs | 2 +- tools/cli-package/smoke/native-load.mjs | 4 +- tools/cli-package/smoke/native-process.mjs | 37 +++- .../cli-package/smoke/native-process.test.mjs | 38 +++++ tools/cli-package/smoke/preload.mjs | 3 + tools/cli-package/smoke/preload.test.mjs | 56 ++++++ tools/cli-package/smoke/worker.mjs | 38 +++++ 17 files changed, 493 insertions(+), 19 deletions(-) create mode 100644 tools/cli-package/seal.test.mjs create mode 100644 tools/cli-package/smoke/preload.test.mjs diff --git a/package.json b/package.json index f165385b..efa79ef4 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "smoke:budget-replay": "node tools/budget-replay-compat/check.mjs", "check:wire-mutations": "node tools/wire-conformance/check.mjs", "check:usage-mutations": "node tools/usage-coverage-controls/check.mjs", - "test:acceptance-tools": "node --test tools/ci/windows-headless-smoke.test.mjs tools/ci/workspace-scheduling.test.mjs tools/ci/require-native-db-diagnostics.test.mjs tools/usage-coverage-controls/outcome.test.mjs tools/usage-coverage-controls/workload.test.mjs tools/ci/require-w8-acceptance.test.mjs tools/acceptance-evidence/export.test.mjs tools/wire-conformance/provenance.test.mjs tools/causal-controls/outcome.test.mjs tools/causal-controls/process.test.mjs tools/causal-controls/provenance.test.mjs tools/causal-controls/retention.test.mjs tools/causal-controls/run.test.mjs tools/causal-controls/timeout-policy.test.mjs tools/bundle-closure/closure.test.mjs tools/cli-bootstrap/witness.test.mjs tools/ci/require-packed-cli.test.mjs tools/cli-package/archive.test.mjs tools/cli-package/binding.test.mjs tools/cli-package/process.test.mjs tools/cli-package/files.test.mjs tools/cli-package/smoke/process.test.mjs tools/cli-package/smoke/failure-diagnostic.test.mjs tools/cli-package/smoke/native-process.test.mjs tools/cli-package/smoke/runtime-stderr.test.mjs tools/cli-package/smoke/native-spawn-witness.test.mjs tools/cli-package/pipeline.test.mjs tools/cli-package/consumer-lifetime.test.mjs", + "test:acceptance-tools": "node --test tools/ci/windows-headless-smoke.test.mjs tools/ci/workspace-scheduling.test.mjs tools/ci/require-native-db-diagnostics.test.mjs tools/usage-coverage-controls/outcome.test.mjs tools/usage-coverage-controls/workload.test.mjs tools/ci/require-w8-acceptance.test.mjs tools/acceptance-evidence/export.test.mjs tools/wire-conformance/provenance.test.mjs tools/causal-controls/outcome.test.mjs tools/causal-controls/process.test.mjs tools/causal-controls/provenance.test.mjs tools/causal-controls/retention.test.mjs tools/causal-controls/run.test.mjs tools/causal-controls/timeout-policy.test.mjs tools/bundle-closure/closure.test.mjs tools/cli-bootstrap/witness.test.mjs tools/ci/require-packed-cli.test.mjs tools/cli-package/archive.test.mjs tools/cli-package/seal.test.mjs tools/cli-package/binding.test.mjs tools/cli-package/process.test.mjs tools/cli-package/files.test.mjs tools/cli-package/smoke/process.test.mjs tools/cli-package/smoke/preload.test.mjs tools/cli-package/smoke/failure-diagnostic.test.mjs tools/cli-package/smoke/native-process.test.mjs tools/cli-package/smoke/runtime-stderr.test.mjs tools/cli-package/smoke/native-spawn-witness.test.mjs tools/cli-package/pipeline.test.mjs tools/cli-package/consumer-lifetime.test.mjs", "check:fs-mutations": "node tools/fs-policy-controls/check.mjs", "check:secrets-mutations": "node tools/secrets-controls/check.mjs", "smoke:cli-bootstrap": "node tools/cli-bootstrap/check.mjs", diff --git a/tools/ci/require-packed-cli.test.mjs b/tools/ci/require-packed-cli.test.mjs index 778db191..ac0007c2 100644 --- a/tools/ci/require-packed-cli.test.mjs +++ b/tools/ci/require-packed-cli.test.mjs @@ -245,3 +245,102 @@ for (const [label, alter] of [ assert.throws(() => checkConsumer(job, 'pack-cli')); }); } + +function checkRequiredSourceSmokes(workflow) { + const job = workflow.jobs.ci; + assert.ok(job, 'source smokes must run in the required ci job'); + assert.equal(job.if, '${{ always() }}'); + assert.equal(job['continue-on-error'], undefined); + assert.ok(job['timeout-minutes'] > 0 && job['timeout-minutes'] <= 30); + assert.equal(workflow.defaults?.run?.['working-directory'], undefined); + assert.equal(job.defaults?.run?.['working-directory'], undefined); + const commands = [ + 'pnpm install --frozen-lockfile', + 'pnpm turbo run build', + 'pnpm smoke:cli-bootstrap', + 'pnpm smoke:budget-replay', + ]; + const indices = commands.map((command) => { + const steps = job.steps.filter((step) => step.run === command); + assert.equal(steps.length, 1, `${command} must be one actual required step`); + const step = steps[0]; + assert.equal(step.if, undefined); + assert.equal(step['continue-on-error'], undefined); + assert.equal(step['working-directory'], undefined); + assert.equal(step.uses, undefined); + return job.steps.indexOf(step); + }); + assert.ok(indices[0] < indices[1]); + assert.ok(indices[1] < indices[2] && indices[1] < indices[3]); +} + +test('required CI executes bootstrap and replay source smokes after install/build', () => { + checkRequiredSourceSmokes(readWorkflow('ci')); + const scripts = JSON.parse(readFileSync(new URL('package.json', root), 'utf8')).scripts; + assert.equal(scripts['smoke:cli-bootstrap'], 'node tools/cli-bootstrap/check.mjs'); + assert.equal(scripts['smoke:budget-replay'], 'node tools/budget-replay-compat/check.mjs'); +}); + +for (const command of ['pnpm smoke:cli-bootstrap', 'pnpm smoke:budget-replay']) { + for (const [name, mutate] of [ + ['removed', (job, step) => job.steps.splice(job.steps.indexOf(step), 1)], + [ + 'name-only', + (_job, step) => { + step.name = command; + delete step.run; + }, + ], + [ + 'echo-only', + (_job, step) => { + step.run = `echo ${command}`; + }, + ], + [ + 'conditional', + (_job, step) => { + step.if = '${{ false }}'; + }, + ], + [ + 'ignored failure', + (_job, step) => { + step['continue-on-error'] = true; + }, + ], + [ + 'different checkout directory', + (_job, step) => { + step['working-directory'] = 'scratch'; + }, + ], + [ + 'before build', + (job, step) => { + job.steps.splice(job.steps.indexOf(step), 1); + job.steps.unshift(step); + }, + ], + [ + 'advisory job', + (job) => { + job['continue-on-error'] = true; + }, + ], + [ + 'disabled job', + (job) => { + job.if = '${{ false }}'; + }, + ], + ]) { + test(`required source smoke guard rejects ${command}: ${name}`, () => { + const workflow = readWorkflow('ci'); + const job = workflow.jobs.ci; + const step = job.steps.find((step) => step.run === command); + mutate(job, step); + assert.throws(() => checkRequiredSourceSmokes(workflow)); + }); + } +} diff --git a/tools/cli-bootstrap/policy.mjs b/tools/cli-bootstrap/policy.mjs index 9eb661af..249f4f21 100644 --- a/tools/cli-bootstrap/policy.mjs +++ b/tools/cli-bootstrap/policy.mjs @@ -12,6 +12,7 @@ export const servicePackages = Object.freeze([ '@clack/prompts', 'yaml', 'smol-toml', + 'string-width', 'zod', 'quickjs-emscripten-core', '@jitl/quickjs-singlefile-mjs-release-sync', diff --git a/tools/cli-bootstrap/witness.test.mjs b/tools/cli-bootstrap/witness.test.mjs index 5487cf7c..eb03a9ee 100644 --- a/tools/cli-bootstrap/witness.test.mjs +++ b/tools/cli-bootstrap/witness.test.mjs @@ -10,6 +10,40 @@ import { spawnBounded } from './process.mjs'; import { BOOTSTRAP_CONTROL_COUNT } from './inventory.mjs'; import ts from 'typescript'; +// Pinned independently of the policy so deleting a boundary cannot delete its own control. +// Commander alone belongs to the permitted compiled meta-operation grammar. +const requiredServicePackages = [ + 'better-sqlite3', + '@napi-rs/keyring', + '@anthropic-ai/sdk', + 'openai', + '@google/genai', + '@modelcontextprotocol/sdk', + 'drizzle-orm', + 'ink', + 'react', + '@clack/prompts', + 'yaml', + 'smol-toml', + 'string-width', + 'zod', + 'quickjs-emscripten-core', + '@jitl/quickjs-singlefile-mjs-release-sync', +]; +test('bootstrap service boundaries retain their independently pinned inventory', () => { + assert.deepEqual([...servicePackages].sort(), [...requiredServicePackages].sort()); + const manifest = JSON.parse( + readFileSync(new URL('../../apps/cli/package.json', import.meta.url), 'utf8'), + ); + assert.deepEqual( + Object.keys(manifest.dependencies) + .filter((name) => name !== 'commander') + .sort(), + [...requiredServicePackages].sort(), + 'Every declared runtime service except the meta grammar must have a boundary', + ); +}); + // Inspect the actual assertion operands: a copied test regex could pass after either // acceptance runner regressed. Parsing never imports or executes the native harness. for (const [path, markers] of [ @@ -100,7 +134,10 @@ test('production child retains file-URL semantics for a preload path with URL me }); test('each denied ESM package/subpath is witnessed even when the application swallows it', async () => { await withFixture(async (fixture) => { - const specifiers = servicePackages.flatMap((name) => [name, `${name}/synthetic-subpath`]); + const specifiers = requiredServicePackages.flatMap((name) => [ + name, + `${name}/synthetic-subpath`, + ]); const entry = fixtureModule( fixture, `for (const specifier of ${JSON.stringify(specifiers)}) { try { await import(specifier); } catch {} }`, diff --git a/tools/cli-package/README.md b/tools/cli-package/README.md index d798c906..f73e21c8 100644 --- a/tools/cli-package/README.md +++ b/tools/cli-package/README.md @@ -38,7 +38,7 @@ access is refused. No workspace install, product build, repack, provider request credential is used by a consumer. Actual installed dependency versions and metadata digests are recorded; a workspace resolution is never substituted. -Each consumer repeats the 177 untouched-entry bootstrap controls and the eight +Each consumer repeats the 177 untouched-entry bootstrap controls and the ten [installed behavior cases](smoke/README.md). Detailed local reports remain in the support directory. CI uploads **only** the bounded public `installed-consumer.json`: source/artifact/origin/runtime identities, public file/dependency digests, and exact diff --git a/tools/cli-package/aggregate.mjs b/tools/cli-package/aggregate.mjs index 6483e1c1..46343600 100644 --- a/tools/cli-package/aggregate.mjs +++ b/tools/cli-package/aggregate.mjs @@ -55,7 +55,7 @@ export function runAggregate(args, environment) { const repository = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); const source = environment.RELAVIUM_PACKAGE_SOURCE_SHA ?? - execFileSync('git', ['rev-parse', 'HEAD'], { + execFileSync('git', ['--no-replace-objects', 'rev-parse', 'HEAD'], { cwd: repository, encoding: 'utf8', timeout: 10000, diff --git a/tools/cli-package/binding.test.mjs b/tools/cli-package/binding.test.mjs index 7accf4db..97fed5f3 100644 --- a/tools/cli-package/binding.test.mjs +++ b/tools/cli-package/binding.test.mjs @@ -243,15 +243,24 @@ test('consumer refuses replay, mismatched files, failed/skipped/incomplete contr ); }); -test('each required platform refuses a missing or failed native owner/bootstrap case', () => { - assert.ok(REQUIRED_SMOKE_CASES.includes('actual-native-process-owner')); - assert.ok(REQUIRED_SMOKE_CASES.includes('actual-native-bootstrap-lifecycle')); - assert.equal(REQUIRED_SMOKE_CASES.length, 8); - for (const nativeCase of ['actual-native-process-owner', 'actual-native-bootstrap-lifecycle']) +test('each required platform refuses every missing or failed independently pinned installed case', () => { + assert.deepEqual(REQUIRED_SMOKE_CASES, [ + 'native-addon-load-only', + 'installed-version', + 'installed-provider-list', + 'installed-unknown-logs', + 'workflow-gate-budget-effects', + 'real-sdk-chat-persistence-resume', + 'actual-mcp-initialize-version', + 'actual-media-cas-iterator-cleanup', + 'actual-native-process-owner', + 'actual-native-bootstrap-lifecycle', + ]); + for (const nativeCase of REQUIRED_SMOKE_CASES) for (const leg of REQUIRED_CONSUMER_LEGS) { const report = consumer(leg); const cases = report.smoke.cases.filter((item) => item.id !== nativeCase); - assert.equal(cases.length, 7); + assert.equal(cases.length, 9); refuses(() => validateConsumerRecord({ ...report, smoke: { success: true, cases } }, receipt(), expected), ); diff --git a/tools/cli-package/seal.test.mjs b/tools/cli-package/seal.test.mjs new file mode 100644 index 00000000..c5586937 --- /dev/null +++ b/tools/cli-package/seal.test.mjs @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { test } from 'node:test'; +import { finalizedPublicFiles } from './seal.mjs'; + +function withFinalizedFixture(body) { + const root = mkdtempSync(join(tmpdir(), 'relavium-seal-fixture-')); + const write = (path, bytes) => { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), bytes); + }; + const graph = (outputs) => write('apps/cli/dist/metafile-esm.json', JSON.stringify({ outputs })); + try { + graph({ 'dist/index.js': {}, 'dist/chunk-ABC.js': {} }); + write('apps/cli/dist/index.js', '#!/usr/bin/env node\nimport "./chunk-ABC.js";\n'); + write('apps/cli/dist/chunk-ABC.js', 'export {};\n'); + for (const path of ['0000_first.sql', 'meta/_journal.json', 'meta/0000_snapshot.json']) { + write(`packages/db/drizzle/${path}`, `canonical ${path}\n`); + write(`apps/cli/drizzle/${path}`, `canonical ${path}\n`); + } + for (const name of ['package.json', 'README.md', 'CHANGELOG.md']) + write(`apps/cli/${name}`, name); + write('LICENSE', 'canonical repository license\n'); + return body({ root, write, graph, remove: (path) => rmSync(join(root, path)) }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +test('finalized seal inventory contains exact graph bytes, canonical migrations and public documents', () => { + withFinalizedFixture(({ root }) => { + const files = finalizedPublicFiles(root); + assert.deepEqual( + [...files.keys()].sort(), + [ + 'package/dist/index.js', + 'package/dist/chunk-ABC.js', + 'package/drizzle/0000_first.sql', + 'package/drizzle/meta/_journal.json', + 'package/drizzle/meta/0000_snapshot.json', + 'package/package.json', + 'package/README.md', + 'package/CHANGELOG.md', + 'package/LICENSE', + ].sort(), + ); + assert.equal( + files.get('package/dist/index.js').toString(), + '#!/usr/bin/env node\nimport "./chunk-ABC.js";\n', + ); + assert.equal( + files.get('package/drizzle/0000_first.sql').toString(), + 'canonical 0000_first.sql\n', + ); + assert.equal(files.get('package/LICENSE').toString(), 'canonical repository license\n'); + assert.equal(files.has('package/dist/metafile-esm.json'), false); + }); +}); + +for (const [name, mutate, refusal] of [ + [ + 'empty compiled graph', + ({ graph, remove }) => { + graph({}); + remove('apps/cli/dist/index.js'); + remove('apps/cli/dist/chunk-ABC.js'); + }, + undefined, + ], + [ + 'oversized compiled graph', + ({ graph, write, remove }) => { + const outputs = Array.from({ length: 257 }, (_, index) => `dist/chunk-${index}.js`); + graph(Object.fromEntries(outputs.map((path) => [path, {}]))); + remove('apps/cli/dist/index.js'); + remove('apps/cli/dist/chunk-ABC.js'); + for (const path of outputs) write(`apps/cli/${path}`, 'export {};'); + }, + undefined, + ], + ...[ + 'dist/index.js.map', + 'dist/nested/index.js', + '../outside.js', + '/dist/index.js', + 'dist/index.json', + 'dist/index space.js', + ].map((path) => [ + `unreviewed graph output ${path}`, + ({ graph }) => graph({ [path]: {} }), + /PACKED_CLI_UNREVIEWED_GRAPH_OUTPUT/, + ]), + ['missing emitted chunk', ({ remove }) => remove('apps/cli/dist/chunk-ABC.js'), undefined], + [ + 'unlisted emitted chunk', + ({ write }) => write('apps/cli/dist/extra.js', 'extra'), + /PACKED_CLI_EXTRA_FINAL_OUTPUT/, + ], + [ + 'unlisted emitted map', + ({ write }) => write('apps/cli/dist/index.js.map', '{}'), + /PACKED_CLI_EXTRA_FINAL_OUTPUT/, + ], + [ + 'changed migration', + ({ write }) => write('apps/cli/drizzle/0000_first.sql', 'changed'), + /PACKED_CLI_MIGRATION_COPY_CHANGED/, + ], + [ + 'missing migration', + ({ remove }) => remove('apps/cli/drizzle/0000_first.sql'), + /PACKED_CLI_MIGRATION_COPY_CHANGED/, + ], + [ + 'extra copied migration', + ({ write }) => write('apps/cli/drizzle/0001_extra.sql', 'extra'), + /PACKED_CLI_MIGRATION_COPY_CHANGED/, + ], + [ + 'new uncopied canonical migration', + ({ write }) => write('packages/db/drizzle/0001_next.sql', 'new'), + /PACKED_CLI_MIGRATION_COPY_CHANGED/, + ], + [ + 'changed migration journal', + ({ write }) => write('apps/cli/drizzle/meta/_journal.json', 'changed'), + /PACKED_CLI_MIGRATION_COPY_CHANGED/, + ], + ['missing public document', ({ remove }) => remove('apps/cli/README.md'), undefined], + [ + 'oversized public document', + ({ write }) => write('apps/cli/CHANGELOG.md', 'x'.repeat(128 * 1024 + 1)), + /PACKED_CLI_FILE_KIND_OR_BOUND/, + ], + [ + 'oversized emitted file', + ({ write }) => write('apps/cli/dist/chunk-ABC.js', 'x'.repeat(8 * 1024 * 1024 + 1)), + /PACKED_CLI_FILE_KIND_OR_BOUND/, + ], +]) { + test(`finalized seal refuses ${name}`, () => { + withFinalizedFixture((fixture) => { + mutate(fixture); + assert.throws(() => finalizedPublicFiles(fixture.root), refusal); + }); + }); +} + +test( + 'finalized seal refuses a symlink masquerading as an emitted file', + { skip: process.platform === 'win32' }, + () => { + withFinalizedFixture(({ root, remove }) => { + remove('apps/cli/dist/chunk-ABC.js'); + symlinkSync(join(root, 'LICENSE'), join(root, 'apps/cli/dist/chunk-ABC.js')); + assert.throws(() => finalizedPublicFiles(root), /PACKED_CLI_FILE_KIND_OR_BOUND/); + }); + }, +); diff --git a/tools/cli-package/smoke/README.md b/tools/cli-package/smoke/README.md index 763c1cef..5a47069c 100644 --- a/tools/cli-package/smoke/README.md +++ b/tools/cli-package/smoke/README.md @@ -25,6 +25,9 @@ aggregate. Each result has `id`, `passed`, and evidence or an error. Overall native binary resolution must stay in the installed consumer's `node_modules`. - Execute installed version and compare it with installed package metadata. The surrounding bootstrap controls own exhaustive meta-operation isolation. +- Preserve the earlier release smoke's installed human `provider list` (exit 0) + and unknown `logs nope` (exit 2) checks in separate required cases. Each owns a + fresh HOME and refuses any keychain or network dispatch. - Run the actual transform workflow and installed migrations; discover a paused human gate and resume it in a fresh process. Produce a real budget pause, reject four invalid decisions without durable mutation, abort and repeat idempotently. @@ -102,7 +105,7 @@ with background-only exit 9. Each independent mode retains its own sibling, watchdog, actual close and exact-PID cleanup evidence. These are plain one-shot activation/ACK-order controls: interactive terminal restoration and absolute budget nonrenewal retain their separately attributed source controls. Whole -Step 8d acceptance remains open. Original-TS maps/custody remain Step 9. All eight +Step 8d acceptance remains open. Original-TS maps/custody remain Step 9. The current ten behavior cases and 177 bootstrap controls are required. Windows bootstrap modes require five actual target/helper creations (two fatal, one background, two output); the other platform/cohort combinations require three. diff --git a/tools/cli-package/smoke/cases.mjs b/tools/cli-package/smoke/cases.mjs index 72262629..9369540f 100644 --- a/tools/cli-package/smoke/cases.mjs +++ b/tools/cli-package/smoke/cases.mjs @@ -2,6 +2,8 @@ export const REQUIRED_SMOKE_CASES = Object.freeze([ 'native-addon-load-only', 'installed-version', + 'installed-provider-list', + 'installed-unknown-logs', 'workflow-gate-budget-effects', 'real-sdk-chat-persistence-resume', 'actual-mcp-initialize-version', diff --git a/tools/cli-package/smoke/failure-diagnostic.mjs b/tools/cli-package/smoke/failure-diagnostic.mjs index bc594d40..71f56b7d 100644 --- a/tools/cli-package/smoke/failure-diagnostic.mjs +++ b/tools/cli-package/smoke/failure-diagnostic.mjs @@ -208,7 +208,7 @@ export function formatInstalledWorkerFailure(raw) { } line = JSON.stringify(diagnostic) + '\n'; } - // The compact fixed inventory fits well below this bound even with all eight failures. + // The compact fixed inventory fits well below this bound even with the complete required failure inventory. if (Buffer.byteLength(line) > MAX_WORKER_FAILURE_BYTES) throw new Error('Worker failure diagnostic bound exceeded'); return line; diff --git a/tools/cli-package/smoke/native-load.mjs b/tools/cli-package/smoke/native-load.mjs index d10c5494..91b2a96e 100644 --- a/tools/cli-package/smoke/native-load.mjs +++ b/tools/cli-package/smoke/native-load.mjs @@ -27,7 +27,9 @@ process.stdout.write( nativeModules, keyring: require.resolve('@napi-rs/keyring'), sqlite: require.resolve('better-sqlite3'), - keyringApiCalls: 0, + keyringCapabilityProof: 'actual native binary loaded; Entry export is a function', + keyringApiObservation: + 'not instrumented; this load-only probe does not construct Entry or invoke credential APIs', sqliteValue: 42, }) + '\n', ); diff --git a/tools/cli-package/smoke/native-process.mjs b/tools/cli-package/smoke/native-process.mjs index d10cb1fb..b9dac9c7 100644 --- a/tools/cli-package/smoke/native-process.mjs +++ b/tools/cli-package/smoke/native-process.mjs @@ -260,6 +260,36 @@ function cassette() { } /** Public offline route; all native mechanism/controller/registry bytes are from the selected installation. */ +/** Fixed public projections are independently authored here, never imported from product policy. */ +export function assertNativeBootstrapDiagnostic(diagnostics, mode) { + const expected = { + 'bootstrap-fatal': { + type: 'error', + code: 'cli_internal', + message: + 'An unexpected CLI failure occurred. Use --verbose for safe diagnostics. Inspect existing work before repeating paid or effectful operations.', + }, + 'bootstrap-output': { + type: 'error', + code: 'output_unavailable', + message: + 'CLI output could not be delivered. Inspect existing work before repeating paid or effectful operations.', + }, + 'bootstrap-background': { + type: 'error', + code: 'cli_internal', + message: + 'A background operation failed. Inspect existing work before repeating paid or effectful operations.', + }, + }; + assert.ok(Object.hasOwn(expected, mode), 'Unknown native bootstrap diagnostic mode'); + assert.deepEqual( + diagnostics, + [expected[mode]], + 'Exactly one complete fixed native diagnostic is owed', + ); +} + export async function assertInstalledNativeProcesses(context, { bootstrap = false } = {}) { const { root, cli, environment, calls } = context; const home = join(root, bootstrap ? 'bootstrap-home' : 'native-home'); @@ -514,13 +544,8 @@ export async function assertInstalledNativeProcesses(context, { bootstrap = fals }); outcome.runtimeWarnings = projected.runtimeWarnings; const diagnostics = ndjson(projected.diagnostics); - assert.equal(diagnostics.length, 1, 'Exactly one native diagnostic is owed'); - assert.equal( - diagnostics[0].code, - mode === 'bootstrap-output' ? 'output_unavailable' : 'cli_internal', - ); + assertNativeBootstrapDiagnostic(diagnostics, mode); if (mode === 'bootstrap-background') { - assert.match(diagnostics[0].message, /^A background operation failed\./); assert.equal( observed.find((item) => item.operation === 'native-target-actual-close').code, 0, diff --git a/tools/cli-package/smoke/native-process.test.mjs b/tools/cli-package/smoke/native-process.test.mjs index ff62ab43..d668f410 100644 --- a/tools/cli-package/smoke/native-process.test.mjs +++ b/tools/cli-package/smoke/native-process.test.mjs @@ -17,6 +17,7 @@ import { clearTimeout, setTimeout } from 'node:timers'; import { fileURLToPath } from 'node:url'; import { assertObservedGroupRefusal, + assertNativeBootstrapDiagnostic, copyFixtureNode, copyFixtureRuntimeSupport, createFixtureSentinel, @@ -368,3 +369,40 @@ test('fixture watchdog never reacquires a root/helper PID after actual native cl rmSync(root, { recursive: true, force: true }); } }); + +for (const [mode, code, message] of [ + [ + 'bootstrap-fatal', + 'cli_internal', + 'An unexpected CLI failure occurred. Use --verbose for safe diagnostics. Inspect existing work before repeating paid or effectful operations.', + ], + [ + 'bootstrap-output', + 'output_unavailable', + 'CLI output could not be delivered. Inspect existing work before repeating paid or effectful operations.', + ], + [ + 'bootstrap-background', + 'cli_internal', + 'A background operation failed. Inspect existing work before repeating paid or effectful operations.', + ], +]) { + test(`${mode} requires its complete fixed diagnostic without private or extra fields`, () => { + const exact = { type: 'error', code, message }; + assertNativeBootstrapDiagnostic([exact], mode); + for (const diagnostics of [ + [], + [exact, exact], + [{ ...exact, message: '' }], + [{ ...exact, message: message + ' PRIVATE_NATIVE_CANARY' }], + [{ ...exact, code: 'different' }], + [{ ...exact, type: 'different' }], + [{ ...exact, stack: 'private native stack' }], + [{ code, message }], + ]) + assert.throws(() => assertNativeBootstrapDiagnostic(diagnostics, mode)); + }); +} +test('unknown native bootstrap diagnostic mode grants no acceptance', () => { + assert.throws(() => assertNativeBootstrapDiagnostic([undefined], 'unknown')); +}); diff --git a/tools/cli-package/smoke/preload.mjs b/tools/cli-package/smoke/preload.mjs index 582b2696..921f5c56 100644 --- a/tools/cli-package/smoke/preload.mjs +++ b/tools/cli-package/smoke/preload.mjs @@ -5,6 +5,7 @@ import net from 'node:net'; import tls from 'node:tls'; import http from 'node:http'; import https from 'node:https'; +import http2 from 'node:http2'; import dns from 'node:dns'; import child from 'node:child_process'; import dgram from 'node:dgram'; @@ -24,6 +25,8 @@ for (const module of [http, https]) { module.get = denied('http.get'); module.createServer = denied('http.createServer'); } +for (const name of ['connect', 'createServer', 'createSecureServer']) + http2[name] = denied(`http2.${name}`); for (const [label, module] of [ ['dns', dns], ['dns.promises', dns.promises], diff --git a/tools/cli-package/smoke/preload.test.mjs b/tools/cli-package/smoke/preload.test.mjs new file mode 100644 index 00000000..8892477e --- /dev/null +++ b/tools/cli-package/smoke/preload.test.mjs @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { explicitEnvironment, invoke, ndjson } from './process.mjs'; + +test('package preload refuses and witnesses HTTP/2 default, named and CommonJS entry points', async () => { + const root = mkdtempSync(join(tmpdir(), 'relavium-http2-denial-')); + const log = join(root, 'calls.ndjson'); + try { + const result = await invoke( + [ + '--import', + new URL('./preload.mjs', import.meta.url).href, + '--input-type=module', + '-e', + ` + import assert from 'node:assert/strict'; + import http2, { connect, createServer, createSecureServer } from 'node:http2'; + import { createRequire } from 'node:module'; + const common = createRequire(import.meta.url)('node:http2'); + for (const methods of [http2, { connect, createServer, createSecureServer }, common]) { + for (const name of ['connect', 'createServer', 'createSecureServer']) { + assert.throws(() => { + const resource = methods[name]('http://127.0.0.1:1'); + resource?.on?.('error', () => {}); + resource?.destroy?.(); + resource?.close?.(); + }, { message: 'Package smoke refuses http2.' + name }); + } + } + `, + ], + { + cwd: root, + env: explicitEnvironment(root, root, { RELAVIUM_PACKAGE_SMOKE_LOG: log }), + timeout: 5000, + }, + ); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.signal, null); + assert.equal(result.stderr, ''); + assert.deepEqual( + ndjson(readFileSync(log, 'utf8')), + Array.from({ length: 3 }, () => + ['connect', 'createServer', 'createSecureServer'].map((name) => ({ + operation: `http2.${name}`, + denied: true, + })), + ).flat(), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tools/cli-package/smoke/worker.mjs b/tools/cli-package/smoke/worker.mjs index 8c3eb61e..d5712326 100644 --- a/tools/cli-package/smoke/worker.mjs +++ b/tools/cli-package/smoke/worker.mjs @@ -177,6 +177,44 @@ async function exerciseInstalledSmoke({ packageRoot, supportRoot, artifactSha256 assert.equal(calls().length, 0, 'Version must not load/use external services'); return { version, outputBytes: result.bytes }; }); + await runCase('installed-provider-list', async () => { + const home = join(root, 'provider-list-home'); + mkdirSync(home); + const before = calls().length; + const result = await invoke(cli(['provider', 'list']), { cwd: home, env: environment(home) }); + assertExit(result, 0); + assert.ok( + result.stdout.trim().length > 0, + 'Installed provider list produced no human listing', + ); + const guardedKeychainOrNetworkAttempts = calls() + .slice(before) + .filter((call) => call.denied || call.operation === 'sdk-fetch').length; + assert.equal( + guardedKeychainOrNetworkAttempts, + 0, + 'Offline provider listing attempted keychain/network access', + ); + return { exit: result.code, outputBytes: result.bytes, guardedKeychainOrNetworkAttempts }; + }); + await runCase('installed-unknown-logs', async () => { + const home = join(root, 'unknown-logs-home'); + mkdirSync(home); + const before = calls().length; + const result = await invoke(cli(['logs', 'nope']), { cwd: home, env: environment(home) }); + assertExit(result, 2); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /no run found with id nope/); + const guardedKeychainOrNetworkAttempts = calls() + .slice(before) + .filter((call) => call.denied || call.operation === 'sdk-fetch').length; + assert.equal( + guardedKeychainOrNetworkAttempts, + 0, + 'Unknown-run lookup attempted keychain/network access', + ); + return { exit: result.code, outputBytes: result.bytes, guardedKeychainOrNetworkAttempts }; + }); await runCase('workflow-gate-budget-effects', () => assertInstalledWorkflows(context)); await runCase('real-sdk-chat-persistence-resume', () => assertInstalledChat(context)); await runCase('actual-mcp-initialize-version', async () => { From f0c1feedd814a41b9d77ffc52ee682e94869df9a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 01:36:42 +0300 Subject: [PATCH 167/222] docs(db): clarify frozen workflow snapshot validation Name the actual historical snapshot schema used by the resume caller. Refs: ADR-0075, PR #95 Co-Authored-By: Codex --- packages/db/src/run-history-store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/src/run-history-store.ts b/packages/db/src/run-history-store.ts index e48f0463..d4c2227c 100644 --- a/packages/db/src/run-history-store.ts +++ b/packages/db/src/run-history-store.ts @@ -1435,7 +1435,7 @@ function stepMatch(runId: string, nodeId: string, attemptNumber: number | undefi /** A paused run's frozen workflow snapshot + inputs, read by id (just the bits a cross-process resume rebuilds from). */ export interface RunResumeSnapshot { /** `JSON.stringify(WorkflowDefinition)` — the frozen `runs.workflow_definition_snapshot` (the events don't - * carry the graph). The caller re-validates it with the shared `WorkflowSchema` before resuming. */ + * carry the graph). The caller re-validates it with the shared `WorkflowSnapshotSchema` before resuming. */ readonly workflowDefinitionSnapshot: string; /** `JSON.stringify(inputs)` from `run:started` (`runs.input_json`) — restored on resume so a post-gate node * that reads `{{ inputs.x }}` sees the value the run started with (the events don't replay the inputs). */ From 0d963a1ae0dc5858a774d5edbd9059fbec86d08f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 01:36:42 +0300 Subject: [PATCH 168/222] docs(docs): reconcile pr 95 contracts and evidence Record conservative legacy compatibility, actual close ordering, finite redaction, package status and precise Sonar accounting. Historical evidence retains its source scope; fresh independent review and publication gates remain pending. Refs: ADR-0080, ADR-0103, ADR-0113, PR #95 Co-Authored-By: Codex --- ...-journal-and-the-tiered-effect-contract.md | 13 ++++ ...-local-producers-before-its-host-closes.md | 12 ++++ ...ontext-keeps-provenance-at-public-sinks.md | 8 +++ docs/reference/cli/README.md | 2 +- docs/reference/cli/chat-session.md | 2 +- docs/reference/cli/commands.md | 10 ++-- docs/reference/cli/packaging.md | 11 ++++ .../reference/desktop/keychain-and-secrets.md | 2 +- docs/reference/shared-core/agent-runner.md | 2 + docs/reference/shared-core/database-schema.md | 2 +- docs/reference/shared-core/effect-journal.md | 17 +++++- docs/reference/shared-core/mcp-integration.md | 13 ++++ docs/roadmap/current.md | 2 +- docs/roadmap/deferred-tasks.md | 2 +- .../phase-2.5.5-hardening-and-remediation.md | 60 ++++++++++++++++++- 15 files changed, 144 insertions(+), 14 deletions(-) diff --git a/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md index c38dd60b..011959d6 100644 --- a/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md +++ b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md @@ -339,3 +339,16 @@ The approved W7 implementation and scoped independent reviews are complete. Fina acceptance, per-item causal evidence, canonical landing checks and approved residuals are joined in the [W7 closing register](../roadmap/phases/phase-2.6.5-core-reliability-remediation.md#w7-closing-register--2026-10-10). This dated note preserves the earlier decision and status history; W8 and the phase remain open. + +### PR #95 collision-guard correction — 2026-10-18 + +The maintainer review reproduced a long-argument collision: the display's prefix and +total-length marker had also fed the journal digest, losing different equal-length tails. +The dispatch now supplies the full redacted projection to the existing host-side canonical +hash. This restores §4's different-args refusal without hashing raw credentials or changing +session non-replay. The persisted version prefix also prevents a current literal preview +from matching a lossy legacy projection. All unversioned rows refuse matching prepare, +including unchanged small arguments; no fallback or migration invents an identity or +permission to repeat an effect. The exact projection and credential-equality +limits remain in [effect-journal.md](../reference/shared-core/effect-journal.md#11-secrets-what-a-row-may-hold). +This author correction is subject to fresh review and source-pinned validation in PR #95. diff --git a/docs/decisions/0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md b/docs/decisions/0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md index 84467a22..8425998a 100644 --- a/docs/decisions/0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md +++ b/docs/decisions/0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md @@ -1245,3 +1245,15 @@ future feature, deferral or claim of merge-commit execution. ## Wave 3 decision acceptance — 2026-10-16 > Amended 2026-10-16: [ADR-0110](0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md) is accepted by the maintainer. The invocation shutdown registry preserves actual owner stop/join and run-only health on return and throw paths. The total fatal-process budget is not a receipt deadline or host-safe-close acknowledgement. Sessions keep their existing lifetime/publication failures without fabricated RunDeparture health. ADR-0111 requires a single registry instance in the finite package graph. The approved [execution plan](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns implementation and review gates; this note does not claim implementation landing. + +### PR #95 session close initiation correction — 2026-10-18 + +Actual active-turn SIGINT and ordinary EOF regressions exposed a session-host cycle: +joining the transferred MCP native lifetime prevented its only close request from starting. +The host now initiates that once-retained close after entered roots and actual core work +settle, before joining transferred lifetimes. Final persistence/database cleanup remains +after the actual acknowledgements. This repairs the existing order without making logical +cancellation, close initiation, a rejected observer or budget expiry into acknowledgement. +The exact host sequence and exceptional budget-observation path live in +[mcp-integration.md](../reference/shared-core/mcp-integration.md#failed-start-cleanup-lifecycle). +Fresh cumulative review and corrected-source gates remain required in PR #95. diff --git a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md index b49543d5..44d6bf13 100644 --- a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md +++ b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md @@ -228,3 +228,11 @@ historical-content limits. Earlier decision-acceptance notes do not claim this later implementation landing. Other Wave 3 decisions and increments remain staged; this note does not close ADR-0112 policy work, whole-wave publication or merge. No historical ADR body is rewritten. + +### PR #95 external-projection guard — 2026-10-18 + +The owned provenance authority now refuses a resolved context key absent from its admitted +declaration snapshot before reading that value. This closes an embedding-only implicit-clean +fallback while preserving normal fresh/resume/preparation resolution and the existing fixed +validation refusal. The [runner contract](../reference/shared-core/agent-runner.md#qualified-context-consumers) +owns this boundary; the accepted decision and historical implementation evidence remain unchanged. diff --git a/docs/reference/cli/README.md b/docs/reference/cli/README.md index b663b04b..bb56d98f 100644 --- a/docs/reference/cli/README.md +++ b/docs/reference/cli/README.md @@ -13,5 +13,5 @@ Part of [reference/](../README.md). | [commands.md](commands.md) | The full `relavium` command surface (run, chat, chat-resume/list/export, agent run, list, create, import, export, logs, gate, init, provider) plus interactive TUI vs CI JSON mode. | | [home.md](home.md) | The bare-invocation interactive **Home** — the TTY gate, the read-only management strip over `history.db`, the Home↔chat mode machine + keymap, bracketed paste, the min-terminal degrade, and the signal/exit-code lifecycle (2.5.B / ADR-0054). | | [chat-session.md](chat-session.md) | The `relavium chat` agent-session REPL — entry, agent/model selection, the multi-turn loop, streaming, tool availability, the `--json` session-event stream, and exit code 4. | -| [packaging.md](packaging.md) | Approved Wave 3 finite runtime package, exact source maps, encrypted custody and release retry design; unshipped pending implementation. | +| [packaging.md](packaging.md) | Wave 3 finite runtime graph, installed-package consumers and Step 8 native activation accepted on `development`; Step 9 source maps, encrypted custody and release retry remain staged. | | [agent-run-fixture.md](agent-run-fixture.md) | The `relavium agent run --fixture` cassette format — a `StreamChunk[][]` JSON recording of an agent run's LLM stream, for deterministic offline replay. | diff --git a/docs/reference/cli/chat-session.md b/docs/reference/cli/chat-session.md index f116f3b2..59947821 100644 --- a/docs/reference/cli/chat-session.md +++ b/docs/reference/cli/chat-session.md @@ -1,6 +1,6 @@ # `relavium chat` — Agent Session REPL -> Last updated: 2026-10-10 — W7 compaction/recovery implemented; final wave review is tracked in the roadmap. +> Last updated: 2026-10-18 - **Status**: Reference — the whole chat family is live: the interactive REPL + `--agent`, `/exit`/`/cancel`, exit code 4, durable persistence (**2.M**); `chat-resume` (**2.N**); `chat-list` (**2.O**); `chat-export` + the in-REPL `/export` (**2.P**); `chat --json` + `agent run` (+ `--fixture`) (**2.Q**); the chat modes + fail-closed per-tool approval (**2.5.E**); and the mid-chat **`/models` reseat** + per-message `modelId` attribution + the `/effort` control (**2.5.G**) — this page is the reseat's behaviour home - **Surface**: CLI (`relavium chat`) diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index d62886b1..f8d4e1da 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1,6 +1,6 @@ # CLI Command Reference (`relavium`) -> Last updated: 2026-10-17 +> Last updated: 2026-10-18 - **Status**: Reference (partial — surface defined, exact flags to be finalized as the CLI is built) - **Surface**: CLI (`relavium`) @@ -12,7 +12,7 @@ The `relavium` CLI is the terminal surface of the platform and the fastest way t ## Install & distribution - **Package**: published to npm as `relavium`, installed globally. The artifact is an **engine-inlined ESM bundle** — the proprietary `@relavium/*` engine is bundled in; every third-party dependency (including the prebuilt native addons) installs normally ([ADR-0051](../../decisions/0051-cli-distribution-thin-bundle-private-engine.md)). A global install needs no compiler toolchain. -- **Build**: TypeScript bundled with `tsup` to one executable ESM entry. Wave 3 Step 7a implements finite lazy chunks on `development`; installed-package/platform acceptance and release custody remain staged in the [packaging contract](packaging.md). The published single-bundle release uses the `Release CLI` workflow (pack → cross-OS install-smoke on macOS/Linux/Windows → npm publish with provenance), see [release-a-surface.md](../../runbooks/release-a-surface.md). +- **Build**: TypeScript bundled with `tsup` to one executable ESM entry and finite lazy chunks. Wave 3 Steps 7–8, including actual same-tarball Linux/macOS/Windows and minimum Node 22 consumers, are independently accepted on `development`; Step 9 maps, release custody and later whole-wave gates remain staged in the [packaging contract](packaging.md). The published release retains its historical single bundle; see [release-a-surface.md](../../runbooks/release-a-surface.md). - **Stack**: `commander.js` for argument parsing, `ink` (React for terminals) for the interactive TUI, `@clack/prompts` for setup wizards. - **API keys**: stored in the OS keychain via `@napi-rs/keyring` (macOS Keychain / Windows Credential Manager / Linux libsecret) — never plaintext, and never the archived `keytar` (see [ADR-0019](../../decisions/0019-cli-node-keychain-library.md) and [add-a-provider-key.md](../../runbooks/add-a-provider-key.md)). - **Workflow discovery**: reads workflows from the `.relavium/` directory in the project root, or from an explicit path argument. @@ -152,7 +152,7 @@ The command set below is the confirmed surface. Commands ship **per workstream** > **Canonical home** for the command manifest ([ADR-0056](../../decisions/0056-cli-in-app-slash-command-system-and-manifest.md), 2.5.C). The runtime form lives in `apps/cli/src/commands/manifest.ts` (a **CLI-only** contract — no other surface consumes a CLI command list). -The **command manifest** is the one source the **shell** command surfaces derive from — the `commander` parser, the `executeCommand` dispatch table (`apps/cli/src/commands/dispatch.ts`), and `relavium --help --json` — so they can **never diverge**. (The in-REPL `/` palette + slash commands are a separate, curated registry — see [In-REPL slash commands](#in-repl-slash-commands) below.) The set is deliberately **small and alias-free**; every entry is canonical by construction (there is no per-entry alias flag). Each entry is: +The **command manifest** is a derived, alias-free projection of the CLI descriptor registry. That registry owns the shell grammar and metadata used by `commander`, discovery and the finite executor mapping; `apps/cli/src/commands/manifest.ts` is not a second editing authority. Drift guards compare the actual parser and executor set with the descriptors and derived manifest. (The in-REPL `/` palette + slash commands are a separate, curated registry — see [In-REPL slash commands](#in-repl-slash-commands) below.) Each manifest entry is: ```text { @@ -165,7 +165,7 @@ The **command manifest** is the one source the **shell** command surfaces derive } ``` -- **`effect`** is a forward-looking annotation: `read` never mutates, `write` creates/modifies, `destructive` irreversibly removes (today only `provider.remove-key`). It is **marked** for agent discoverability now; approval **enforcement** of a `destructive` entry is owned by [ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md) (workstream 2.5.E), not here. +- **`effect`** is a forward-looking annotation: `read` never mutates, `write` creates/modifies, `destructive` irreversibly removes (`provider.remove-key` and `media.prune-temporaries`). It is **marked** for agent discoverability now; approval **enforcement** of a `destructive` entry is owned by [ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md) (workstream 2.5.E), not here. - **`modeScope`** lists the chat modes a command appears in (omit ⇒ all). The mode values (`ask` / `plan` / `accept-edits` / `auto`) are defined in [ADR-0057](../../decisions/0057-cli-chat-modes-and-per-tool-approval.md) (2.5.E); 2.5.C ships the field with `omit = all`. - A `manifest ↔ commander` drift guard (a unit test) asserts every real `commander` command has an entry with the **same description** (command + each option), so `commander`, the `executeCommand` table, and `--help --json` stay byte-consistent. @@ -232,7 +232,7 @@ Both are **surface-agnostic** git-native YAML operations — they read/write `.r `create` and `import` emit these notices only after their YAML file is written. A refused write emits no authored-content notice or success output. -The review visits at most 4,096 nodes through depth 64, checks strings of at most 16,384 UTF-16 code units and at most 262,144 checked code units in total, including property names. It reports only a count of strings matching the existing finite detector (prefixed OpenAI/Anthropic or Google keys, URL userinfo, Bearer/Basic credentials and literal secret query names). It does not classify every credential format. Over-budget strings or structure, repeated objects and unsupported object shapes produce a separate fixed incomplete-review notice. Object-key enumeration relies on the existing parser source ceiling; the node limit is not an independent bound on that enumeration. Neither notice includes values or arbitrary property names. +The review visits at most 4,096 nodes through depth 64, checks strings of at most 16,384 UTF-16 code units and at most 262,144 checked code units in total, including property names. It reports only a count of strings matching the finite detector: prefixed OpenAI/Anthropic or Google keys, HTTP(S)/WS(S)/FTP(S) URL userinfo, exact `api-key`/`x-api-key`/`x-goog-api-key` headers, Bearer/Basic credentials and literal secret query names. It does not classify every credential format or distinguish arbitrary opaque credential fragments from following diagnostic prose. Over-budget strings or structure, repeated objects and unsupported object shapes produce a separate fixed incomplete-review notice. Object-key enumeration relies on the existing parser source ceiling; the node limit is not an independent bound on that enumeration. Neither notice includes values or arbitrary property names. For this detector probe only, a recognised bare template reference without a path or filter is projected to an already masked span, provided its raw reference contains no recognised credential shape. This keeps `Bearer {{secrets.token}}` and equivalent query placeholders quiet. Adjacent literals, credential-shaped identifiers, paths, filter arguments and unknown or malformed references retain their evidence. This projection neither resolves references nor changes authored content. diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index cb4b7571..8c9aa8cc 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -88,6 +88,17 @@ retained separately from product diagnostics; unknown runtime output still fails the diagnostic grammar and ordering controls. [Step 8d and whole Step 8 acceptance](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) records cumulative reviews, separate security and actual same-artifact platforms. +The PR #95 review correction adds two installed regressions: `provider list` succeeds +without credential/network access and unknown `logs nope` refuses with exit 2 and empty +stdout. The current required inventory is ten cases; the earlier eight-case receipts keep +their historical source attribution. A changed inventory requires fresh same-artifact +consumer receipts before current-source platform acceptance is claimed. + +These receipts certify one chosen archive and its exact bytes. Independent `pnpm pack` +runs can reorder package metadata and produce a different digest; the contract does not +claim independently reproducible tarballs. Retry and publication reuse the recorded candidate +rather than treating a fresh build as that same artifact. + The emitted graph checker parses actual JavaScript and compares every loading edge with the build inventory. It rejects missing/extra/unreachable/escaping chunks, computed or unreviewed loaders, private external imports, undeclared dependencies diff --git a/docs/reference/desktop/keychain-and-secrets.md b/docs/reference/desktop/keychain-and-secrets.md index c3f6bb45..b5a22d4c 100644 --- a/docs/reference/desktop/keychain-and-secrets.md +++ b/docs/reference/desktop/keychain-and-secrets.md @@ -1,6 +1,6 @@ # Desktop Keychain & Secrets -> Last updated: 2026-10-13 +> Last updated: 2026-10-18 - **Status**: Reference - **Surface**: Desktop (Tauri v2) diff --git a/docs/reference/shared-core/agent-runner.md b/docs/reference/shared-core/agent-runner.md index efe8cbef..65b29944 100644 --- a/docs/reference/shared-core/agent-runner.md +++ b/docs/reference/shared-core/agent-runner.md @@ -398,6 +398,8 @@ passes both through `resumePreparation.context` / `resumePreparation.provenance` execution reuses them rather than deriving a second mutable secret-name set. Context analysis and resolution use the authority's same owned definition snapshot, so caller edits after admission or during an awaited context capability cannot change the analysed source. +Qualification refuses a resolved key absent from that owned declaration snapshot before reading +its value; an undeclared key never acquires an implicit clean classification. `NodeExecContext` and `NodePreparationContext` require the same context projection and owned `secretInputNames` membership; the preparation Pick includes both. Agent prompt resolution diff --git a/docs/reference/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index 71666dde..046c6480 100644 --- a/docs/reference/shared-core/database-schema.md +++ b/docs/reference/shared-core/database-schema.md @@ -730,7 +730,7 @@ OCCURRENCE, written by a `prepare` **before** an effectful tool dispatch leaves | `tool_id` | TEXT | NOT NULL | | `tier` | INTEGER | NOT NULL — `1` \| `2` \| `3`; everything that ships is `3` | | `state` | TEXT | NOT NULL — `prepared` \| `dispatched` \| `committed` \| `ambiguous` \| `needs_attention` | -| `args_digest` | TEXT | NOT NULL — SHA-256 over canonical JSON of the effective args with every secret-tainted key **removed before hashing** | +| `args_digest` | TEXT | NOT NULL — current `v2:` prefix + SHA-256 over canonical JSON of the full redacted effective args; secret-tainted keys are **removed before hashing**. Unversioned legacy rows refuse matching prepare; see [effect-journal.md §11](effect-journal.md#11-secrets-what-a-row-may-hold). | | `target_idempotency_key` | TEXT | NULL — tier 1 only; what a safe retry reuses verbatim | | `result_json` | TEXT | Always NULL for `session:` scopes, including legacy rows after open-time clearing; RUN scopes retain the bounded result when re-delivery is possible | | `attempt_json` | TEXT | NOT NULL — the audit occurrence (node attempt, provider attempt, tool-call id, owning fence) | diff --git a/docs/reference/shared-core/effect-journal.md b/docs/reference/shared-core/effect-journal.md index ad660e6e..7754adc1 100644 --- a/docs/reference/shared-core/effect-journal.md +++ b/docs/reference/shared-core/effect-journal.md @@ -351,6 +351,17 @@ unencrypted at rest. "A digest, not the bytes" is therefore not by itself a suff contract does not make it. The hash is SHA-256 over a canonical JSON serialization (sorted keys, no insignificant whitespace) of the redacted projection, using a vetted implementation — never a hand-rolled one. +The current persisted form is `v2:` followed by the SHA-256 hex digest. The version +prefix is outside the hash and separates current full-value projections from legacy +unversioned digests; a JSON wrapper alone could itself equal arbitrary old tool args. +The digest consumes the full surviving non-secret values, including long string tails. +The bounded `toolInput` display preview is not a journal input: equal-length values can +differ beyond its retained prefix. Every unversioned legacy row refuses matching prepare, +including unchanged small arguments: its original projection cannot be proven from the +digest alone. There is no fallback to the old digest or automatic retry permission. Secret and +inline-media redaction remain deliberate projection equality; raw credential bytes are never +added to the digest to distinguish calls. + A **session** row's `result_json` is always SQL NULL; even a supplied result whose serializer throws is never examined. A matching prepare refuses rather than replaying, and session audit readers omit the stored result column entirely; run readers retain replay results. @@ -427,8 +438,10 @@ Trigger: a user who must resume a partially completed tool loop rather than fail Trigger to revisit: the first supported concurrent-resume flow. - **Physical erasure is conditional.** Busy checkpoints defer it; pre-upgrade freed pages are outside the secure-delete guarantee (§11). No automatic `VACUUM` or encryption change is implied. -- **A credential rotation changes the redacted projection**, so an effect whose args reference a rotated - credential gets a fresh identity and degrades to tier-3 behaviour for that occurrence. +- **Credential-only changes deliberately do not distinguish redacted digests.** Declared secret keys and + recognized credential shapes are excluded or masked before hashing, so the digest is not a credential + equality oracle. A changed surviving non-secret value at an existing slot refuses replay (§4); it does + not grant a fresh effect identity or permission to repeat the operation. - **`EffectSlot` is not stable across a model replay**, which is why the gate is at node granularity. A design that later needs slot-granular resume needs a durable record of the model response, which is CR-95's long-term continuation checkpoint, not this. diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index 69fd4b03..2a04c277 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -223,6 +223,12 @@ entered close remains joined, and a throwing reporter's original exception is returned only after those joins settle. The failed-start lifecycle observer is not delivered a second time for an ordinary successful-client close. +A successful-client budget-port failure likewise rejects `close` promptly with a fixed +generic infrastructure error and a retained private cause. Rejection does not acknowledge +cleanup: the unknown connection and original shared cleanup join remain retained, while +eligible sibling closes still enter and settle independently. Repeated `close` calls +observe the retained failure without restarting cleanup or renewing its budget. + Failed-start cleanup transfers its own work scope before server construction. All exact entered closes remain joined even if the lifecycle reporter throws. A reporter/budget-port failure surfaces promptly as a fixed generic @@ -238,6 +244,13 @@ receipt success or new engine error code is carried by this channel. Final session/terminal integration and global activation are covered by [Step 8d acceptance](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17). +Session-host shutdown initiates the once-retained MCP close after entered construction/turn +roots and actual core provider/tool/accounting work settle, before joining transferred native +connection lifetimes. Otherwise that join would wait on the close it prevents from starting. +The close request is not acknowledgement: native receipts and other transferred work still +join before persister/final host cleanup and database release. The original finite budget +is checked before entering close; expiry or a barrier/close refusal preserves uncertainty. + ### Discovery and result ingress bounds A server's own output is untrusted input, and it is bounded diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 843d04fe..9e9ffec2 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1432,7 +1432,7 @@ on `home-controller.ts`/`chat-ink.tsx` (the decomposition moves already-guarded - **Viewport (strict)** — 2.5.5.I · share the ADR-0069 grapheme segmenter (#58, #62, G40, G51) → 2.5.5.G · tab expansion before width measurement (#65) → per-cluster width cache + resize debounce (#64, #66), refined to frame coalescing in the proposed plan. Alt-screen word-wrap - (#61) retains Wave 5a ownership; its formal disposition follows plan approval. + (#61) retains the approved Wave 5a ownership; it is not an unapproved Wave 3 prerequisite. - **`packages/shared` (strict)** — the `.code` ruling (G50, D10) → `findDuplicates`/`superRefine` implementation or preservation at the schema dispositions (#89, #90, G16, G17, G18, #210, #211, #212) → `BaseEventSchema` disposition (#213) → the `RelaviumError` base class (#219). *The ruling must precede the base class, or it is baked in permanently.* diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 95963ecb..436ff9b0 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -2,7 +2,7 @@ > Status: Living -> Last updated: 2026-10-15 — Wave 2 merged in PR #94; existing approved deferrals remain. +> Last updated: 2026-10-18 — Wave 2 is merged in PR #94; Wave 3 Steps 1–8 are accepted on `development`, with PR #95 under review. Existing approved deferrals remain. > The [merge checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) records completed prerequisites; full M/N lifecycle and other scheduled work remain with their owners. > > Earlier triage: the Phase-2.6 rewrite triaged every open item, the 2026-07-19 full-project diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index ab54b0a5..52e61281 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -4537,7 +4537,8 @@ whole-wave acceptance, a release or new permission. Detailed records stay ignore Step 3 is independently accepted at `5d8c0b0cf91604c2c270ff0351b4168508fe6041`. Two NEW independent complete cumulative clean reviews after the final material -fix cover all **42 changed paths / 108 hunks**, including all thirteen added +fix cover all **42 changed paths / 108 default-context (`--unified=3`) hunks** +from accepted Step 2 `873da1c3` through `5d8c0b0c`, including all thirteen added files and actual consumers. Selected reviewers are gpt-6-astra/high (30 files / 1,513 independently passing tests) and gpt-6.1-sol/high (20 files / 1,193 tests). Both report zero findings and independently run the 26 retained ordinary @@ -8355,3 +8356,60 @@ Fresh push/PR CI and Sonar analysis must be evaluated at the new published head; the earlier green publication runs are not attributed to these changed bytes. Detailed triage, correction reviews and receipts remain local and Git-ignored. Steps 9–19 have not started; Wave 3 and whole-PR acceptance remain open. + +### PR #95 systematic-review corrections — 2026-10-18 + +The maintainer's systematic review is pinned to `05061d55`, with no Blocker/High, +thirteen Medium findings and grouped Low observations. The authorized correction +stays within the Steps 1–8 publication scope on `development`; it starts no +Step 9 release/custody work. Earlier independent acceptance retains its exact source +attribution. This checkpoint records implementation and author verification; +two NEW complete cumulative clean review rounds, a separate security sitting, +corrected-source CI/Sonar and fresh same-artifact consumers remain required. + +The effect journal now hashes the full redacted projection instead of a display +preview. Real registry/reference and registry/SQLite regressions distinguish equal-length +long tails, preserve current-version identical replay and refuse changed same-slot +arguments. A persisted digest version separates old lossy projections: all unversioned +rows refuse matching prepare, including unchanged small arguments and literal old previews. +Declared and recognized credentials remain excluded before hashing; no raw +argument storage or new hash primitive is introduced. The owned context authority also +refuses undeclared resolved keys before reading them. Its ordinary producer paths remain +unchanged. Finite credential diagnostics cover malformed multi-`@` URL authorities, +supported additional URL schemes, exact API-key headers and split Bearer/Basic labels; +the canonical detector contract retains its explicit opaque-fragment limits. + +Actual active-turn OS SIGINT tests additionally expose and repair a session/MCP +close-order cycle. Six POSIX cases exercise fresh chat, resumed chat and standalone +agent, plain/JSON; two ordinary EOF cases retain real MCP children. Provider/core +quiescence precedes the once-retained MCP close request, native acknowledgement +precedes final persistence cleanup, and the original termination budget is unchanged. +These local POSIX cases do not claim native Windows signal execution. Catalog refresh +always rejects through its Promise boundary without delaying admission; both owner and +caller signals limit MCP startup. Exceptional MCP budget observation is prompt and +retains unknown closure, with independent sibling cleanup and no fabricated ACK. + +Permanent regressions also pin failed-construction cleanup, accepted bootstrap exits, +gate JSON no-op stdout purity, shared DB-acquisition custody, notice-context forwarding, +partial terminal registration, native-close failures and finite reap, diagnostic sink +failures and explicit driver lifetime ownership. CI smoke-step guards reject removal +or advisory substitution. The installed cohort restores provider listing and unknown-run +log refusal, making ten required cases; historical eight-case receipts are not relabeled. +Direct seal and HTTP/2 denial tests enter the ordinary acceptance-tool gate. + +The Sonar count correction distinguishes the initial **214 records / 80 files** +from the independently fetched all-record snapshot at reviewed source `05061d55`: +**215 records / 81 files = 53 false positives + 86 accepted + 9 fixed + 67 open**. +The additional workflow false positive was already classified outside the initial +214-record intake; it is not a newly performed disposition. The 67 valid maintenance +suggestions remain visible, without changing rules, quality gates or approved deferrals. +Later analyses must retain their own source and counts. + +Several review observations required precise disposition rather than a contract change: +explicit `failure: undefined` remains an invalid retirement witness; the three claimed +missing pure roots are not three uncovered production boundaries; Step 3's 108 hunks +are the reproducible default-context count (143 with `--unified=0`). Fixture-retirement +tests test the native test harness and are not presented as additional product-path +coverage. Separate tarball builds may differ in metadata ordering: existing acceptance +certifies one chosen immutable archive, not independently reproducible builds. +Detailed per-finding evidence, controls and review records remain local and Git-ignored. From e0c27c7018d965c09997fedcae6ab30925d8461d Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 01:54:28 +0300 Subject: [PATCH 169/222] fix(core): avoid impossible full-argument URL scans Skip only URL-userinfo passes when their mandatory delimiter is absent. Preserve full redacted journal arguments, unchanged finite detector rules and the exact 8 MiB admission fixture. Refs: ADR-0080 Co-Authored-By: Codex --- packages/core/src/tools/bounding.test.ts | 29 ++++++++++++++++++++++++ packages/core/src/tools/bounding.ts | 13 ++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index 18cd0191..ebde58d5 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -335,6 +335,26 @@ describe('redactSecretShapedText', () => { expect(redactSecretShapedText('count = 42')).toBe('count = 42'); // not a secret-ish key }); + it('scrubs every non-URL credential family after a long public prefix and preserves the full tail', () => { + // No URL delimiter occurs in these values. Skipping impossible userinfo matches must not bypass + // another credential family or truncate the journal input to the display projection's 16 KiB. + const prefix = `${'a'.repeat(32 * 1024)}\n`; + const cases = [ + [`?opaque=${'Q'.repeat(24)}`, '?opaque=[redacted]'], + ['-----BEGIN PRIVATE KEY-----opaque-----END PRIVATE KEY-----', '[redacted]'], + ['Bearer abcdefghijklmnop', 'Bearer [redacted]'], + ['password="ordinary opaque value"', '[redacted]'], + [`sk-${'A'.repeat(16)}`, '[redacted]'], + ]; + for (const [credential, expected] of cases) { + for (const tail of ['public-tail-A', 'public-tail-B']) { + expect(redactSecretShapedText(`${prefix}${credential}\n${tail}`)).toBe( + `${prefix}${expected}\n${tail}`, + ); + } + } + }); + it('over-redaction is the safe direction — a benign `token: ` is swept up (documented tradeoff)', () => { // A DISPLAY-field false positive is acceptable (the model's copy is untouched). This pins the deliberate // over-redaction so a future "tighten precision" change is a conscious test edit, not a silent secret leak. @@ -448,6 +468,15 @@ describe('redactSecretShapedText — URL userinfo (ADR-0080 §11)', () => { ); }); + it('still finds both userinfo forms after a long public prefix', () => { + const prefix = `${'a'.repeat(32 * 1024)}\n`; + for (const userinfo of ['user:opaque-password', 'opaque-token-value-without-colon']) { + expect(redactSecretShapedText(`${prefix}custom+scheme://${userinfo}@host.example/tail`)).toBe( + `${prefix}custom+scheme://[redacted]@host.example/tail`, + ); + } + }); + it('leaves a credential-free URL untouched — the negative control', () => { // Without this the rule above passes for an implementation that mangled every URL, which would destroy // the diagnostic value of the row for the overwhelmingly common case. diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 6d76d1da..b277d7e3 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -126,8 +126,12 @@ function redactInlineMediaForText(value: unknown, seen: WeakSet): unknow * no-backtracking-RegExp / no-ReDoS posture. */ export function redactSecretShapedText(text: string): string { - return ( - text + let withoutUserinfo = text; + // Both URL rules require this exact delimiter. Skip only those rules when it is absent: their + // bounded scheme prefixes otherwise retry at every letter of a multi-megabyte argument. Other + // credential rules still scan the whole value, including its tail; no display bound applies here. + if (text.includes('://')) { + withoutUserinfo = text // **URL userinfo — `scheme://user:pass@host`.** The single most common shape a real credential takes in a // tool argument: a database connection string, a Redis URL, a webhook with an embedded token. It has no // `key=value` and no well-known prefix, so every other pattern here misses it — a review REPRODUCED the @@ -144,7 +148,10 @@ export function redactSecretShapedText(text: string): string { // The rule above cannot see any of them: it requires a `:` with a non-empty right side. The `{16,}` // floor is what keeps `https://example.com` and a short vanity handle out — a credential in this // position is long, and a userinfo short enough to fall under the floor carries little to recover. - .replace(/([a-z][\w+.-]{0,31}:\/\/)[^/@\s"',}\]]{16,256}@/gi, '$1[redacted]@') + .replace(/([a-z][\w+.-]{0,31}:\/\/)[^/@\s"',}\]]{16,256}@/gi, '$1[redacted]@'); + } + return ( + withoutUserinfo // …and a token in a URL QUERY. `?token=…` is already caught by the `key=value` rule below, but the // SIGNED-URL shape — a long opaque value under an unrecognised parameter name — is not, and a // pre-signed S3 URL or a webhook `?t=` is exactly that. Bounded to a long value so an ordinary From 6d8ebd835b3152cc0751146cc93763fd368947d6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 01:54:28 +0300 Subject: [PATCH 170/222] fix(cli): deliver startup cancellation despite cleanup faults Deliver the consumer abort independently of both input unlink attempts. Retain partial subscription and failed cleanup on the original owner, including reentrant setup and actual fresh/resumed manager paths. Refs: ADR-0103 Co-Authored-By: Codex --- apps/cli/src/chat/mcp-connect-signal.test.ts | 196 +++++++++++++++++++ apps/cli/src/chat/mcp-connect-signal.ts | 103 ++++++++++ apps/cli/src/chat/session-host.test.ts | 161 +++++++++++++++ apps/cli/src/chat/session-host.ts | 35 +--- 4 files changed, 465 insertions(+), 30 deletions(-) create mode 100644 apps/cli/src/chat/mcp-connect-signal.test.ts create mode 100644 apps/cli/src/chat/mcp-connect-signal.ts diff --git a/apps/cli/src/chat/mcp-connect-signal.test.ts b/apps/cli/src/chat/mcp-connect-signal.test.ts new file mode 100644 index 00000000..62eb29d2 --- /dev/null +++ b/apps/cli/src/chat/mcp-connect-signal.test.ts @@ -0,0 +1,196 @@ +import type { AbortSignalLike } from '@relavium/shared'; +import { describe, expect, it, vi } from 'vitest'; + +import { combineMcpConnectSignals } from './mcp-connect-signal.js'; + +function signalFixture() { + let aborted = false; + const listeners = new Set<() => void>(); + const signal = { + get aborted() { + return aborted; + }, + addEventListener: vi.fn((_type: 'abort', listener: () => void) => { + listeners.add(listener); + }), + removeEventListener: vi.fn((_type: 'abort', listener: () => void) => { + listeners.delete(listener); + }), + } satisfies AbortSignalLike; + return { + signal, + listeners, + abort: () => { + aborted = true; + for (const listener of [...listeners]) listener(); + }, + }; +} + +describe('owned MCP connect signal subscriptions', () => { + it.each(['owner', 'caller'] as const)( + '%s abort delivers once and unlinks both inputs', + (side) => { + const owner = signalFixture(); + const caller = signalFixture(); + const failure = vi.fn(); + const signal = combineMcpConnectSignals(owner.signal, caller.signal, failure); + const notify = vi.fn(); + signal.addEventListener('abort', notify); + signal.addEventListener('abort', notify); + expect(owner.listeners.size).toBe(1); + expect(caller.listeners.size).toBe(1); + (side === 'owner' ? owner : caller).abort(); + owner.abort(); + caller.abort(); + expect(notify).toHaveBeenCalledOnce(); + expect(signal.aborted).toBe(true); + expect(owner.listeners.size).toBe(0); + expect(caller.listeners.size).toBe(0); + expect(failure).not.toHaveBeenCalled(); + }, + ); + + it.each(['owner', 'caller'] as const)( + 'a failing %s unlink cannot suppress cancellation or its sibling unlink', + (side) => { + const owner = signalFixture(); + const caller = signalFixture(); + const fault = new Error('foreign unlink refused'); + const broken = side === 'owner' ? owner : caller; + broken.signal.removeEventListener.mockImplementation(() => { + throw fault; + }); + // The real owner reports uncertainty by synchronously stopping, which reenters notify. + const failure = vi.fn<(cause: unknown) => void>(() => owner.abort()); + const signal = combineMcpConnectSignals(owner.signal, caller.signal, failure); + const notify = vi.fn(); + signal.addEventListener('abort', notify); + expect(() => caller.abort(), 'UNLINK_FAILURE_MUST_NOT_SUPPRESS_CANCEL').not.toThrow(); + expect(notify).toHaveBeenCalledOnce(); + expect(owner.signal.removeEventListener).toHaveBeenCalled(); + expect(caller.signal.removeEventListener).toHaveBeenCalled(); + expect(failure).toHaveBeenCalledWith(fault); + expect(broken.listeners.size).toBe(1); + // Possible ownership remains retryable; the original cause is already retained by the owner. + broken.signal.removeEventListener.mockImplementation((_type, listener) => { + broken.listeners.delete(listener); + }); + signal.removeEventListener('abort', notify); + expect(broken.listeners.size).toBe(0); + expect(notify).toHaveBeenCalledOnce(); + }, + ); + + it('attempts both removals when both fail and reports the first cause', () => { + const owner = signalFixture(); + const caller = signalFixture(); + const first = new Error('owner unlink'); + const second = new Error('caller unlink'); + owner.signal.removeEventListener.mockImplementation(() => { + throw first; + }); + caller.signal.removeEventListener.mockImplementation(() => { + throw second; + }); + const failure = vi.fn(); + const signal = combineMcpConnectSignals(owner.signal, caller.signal, failure); + const notify = vi.fn(); + signal.addEventListener('abort', notify); + caller.abort(); + expect(notify).toHaveBeenCalledOnce(); + expect(owner.signal.removeEventListener).toHaveBeenCalledOnce(); + expect(caller.signal.removeEventListener).toHaveBeenCalledOnce(); + expect(failure).toHaveBeenCalledExactlyOnceWith(first); + }); + + it('rolls back both possible attachments when caller add attaches then throws', () => { + const owner = signalFixture(); + const caller = signalFixture(); + const fault = new Error('partial add'); + caller.signal.addEventListener.mockImplementation((_type, listener) => { + caller.listeners.add(listener); + throw fault; + }); + const failure = vi.fn<(cause: unknown) => void>(() => owner.abort()); + const signal = combineMcpConnectSignals(owner.signal, caller.signal, failure); + const notify = vi.fn(); + expect(() => signal.addEventListener('abort', notify)).toThrow(fault); + expect(owner.listeners.size).toBe(0); + expect(caller.listeners.size).toBe(0); + expect(owner.signal.removeEventListener).toHaveBeenCalledOnce(); + expect(caller.signal.removeEventListener).toHaveBeenCalledOnce(); + expect(failure).toHaveBeenCalledWith(fault); + expect(notify).not.toHaveBeenCalled(); + }); + + it('removes a foreign attachment made after a reentrant abort during add', () => { + const owner = signalFixture(); + const caller = signalFixture(); + caller.signal.addEventListener.mockImplementation((_type, listener) => { + owner.abort(); + caller.listeners.add(listener); + }); + const failure = vi.fn(); + const signal = combineMcpConnectSignals(owner.signal, caller.signal, failure); + const notify = vi.fn(); + signal.addEventListener('abort', notify); + expect(notify).toHaveBeenCalledOnce(); + expect(caller.signal.removeEventListener).toHaveBeenCalledOnce(); + expect(owner.listeners.size).toBe(0); + expect(caller.listeners.size).toBe(0); + expect(failure).not.toHaveBeenCalled(); + }); + + it('retains the original partial-add cause before a secondary rollback fault', () => { + const owner = signalFixture(); + const caller = signalFixture(); + const primary = new Error('partial attachment'); + const secondary = new Error('rollback unlink refused'); + caller.signal.addEventListener.mockImplementation((_type, listener) => { + caller.listeners.add(listener); + throw primary; + }); + owner.signal.removeEventListener.mockImplementation(() => { + throw secondary; + }); + const failure = vi.fn<(cause: unknown) => void>(() => owner.abort()); + const signal = combineMcpConnectSignals(owner.signal, caller.signal, failure); + expect(() => signal.addEventListener('abort', vi.fn())).toThrow(primary); + expect(failure.mock.calls.map(([cause]) => cause)).toEqual([primary, secondary]); + expect(caller.listeners.size).toBe(0); + expect(owner.listeners.size).toBe(1); + }); + + it('a settled consumer stays unsubscribed when failed cleanup reenters abort', () => { + const owner = signalFixture(); + const caller = signalFixture(); + const fault = new Error('remove after settlement'); + caller.signal.removeEventListener.mockImplementation(() => { + throw fault; + }); + const failure = vi.fn<(cause: unknown) => void>(() => owner.abort()); + const signal = combineMcpConnectSignals(owner.signal, caller.signal, failure); + const notify = vi.fn(); + signal.addEventListener('abort', notify); + signal.removeEventListener('abort', notify); + expect(notify).not.toHaveBeenCalled(); + expect(failure).toHaveBeenCalledWith(fault); + expect(caller.listeners.size).toBe(1); + }); + + it('retains a throwing consumer cause while still attempting both input removals', () => { + const owner = signalFixture(); + const caller = signalFixture(); + const fault = new Error('consumer callback'); + const failure = vi.fn<(cause: unknown) => void>(() => owner.abort()); + const signal = combineMcpConnectSignals(owner.signal, caller.signal, failure); + signal.addEventListener('abort', () => { + throw fault; + }); + expect(() => caller.abort()).not.toThrow(); + expect(owner.listeners.size).toBe(0); + expect(caller.listeners.size).toBe(0); + expect(failure).toHaveBeenCalledWith(fault); + }); +}); diff --git a/apps/cli/src/chat/mcp-connect-signal.ts b/apps/cli/src/chat/mcp-connect-signal.ts new file mode 100644 index 00000000..c0cf8996 --- /dev/null +++ b/apps/cli/src/chat/mcp-connect-signal.ts @@ -0,0 +1,103 @@ +import type { AbortSignalLike } from '@relavium/shared'; + +interface Subscription { + readonly notify: () => void; + readonly attached: Set; + delivered: boolean; + adding: boolean; + removing: boolean; + cleanupRequested: boolean; +} + +/** Both inputs limit MCP startup; a foreign listener fault never suppresses cancellation. + * The original invocation owns cleanup uncertainty, independently of consumer settlement. */ +export function combineMcpConnectSignals( + owner: AbortSignalLike, + caller: AbortSignalLike, + cleanupUncertain: (cause: unknown) => void, +): AbortSignalLike { + const subscriptions = new Map<() => void, Subscription>(); + + const unlink = (listener: () => void, entry: Subscription): void => { + entry.cleanupRequested = true; + // A foreign add may attach AFTER reentering notify; only remove after it returns. + if (entry.adding || entry.removing) return; + entry.removing = true; + let failure: { readonly cause: unknown } | undefined; + try { + for (const source of [...entry.attached]) { + try { + source.removeEventListener('abort', entry.notify); + entry.attached.delete(source); + } catch (cause) { + // Retain possible attachment, but attempt the other unlink independently. + failure ??= { cause }; + } + } + } finally { + entry.removing = false; + } + if (entry.attached.size === 0 && subscriptions.get(listener) === entry) + subscriptions.delete(listener); + // Reporting can synchronously stop the owner; all removals above were attempted. + if (failure !== undefined) cleanupUncertain(failure.cause); + }; + + return { + get aborted() { + return owner.aborted || caller.aborted; + }, + addEventListener: (_type, listener) => { + if (subscriptions.has(listener) || owner.aborted || caller.aborted) return; + const entry: Subscription = { + attached: new Set(), + delivered: false, + adding: false, + removing: false, + cleanupRequested: false, + notify: () => { + if (entry.delivered) return; + entry.delivered = true; + try { + listener(); + } catch (cause) { + cleanupUncertain(cause); + } finally { + unlink(listener, entry); + } + }, + }; + subscriptions.set(listener, entry); + try { + for (const source of [owner, caller]) { + if (entry.delivered || entry.cleanupRequested) break; + // addEventListener may attach then throw: ownership begins before entry. + entry.attached.add(source); + entry.adding = true; + try { + source.addEventListener('abort', entry.notify); + } finally { + entry.adding = false; + if (entry.cleanupRequested) unlink(listener, entry); + } + if (source.aborted) entry.notify(); + } + } catch (cause) { + entry.delivered = true; + try { + cleanupUncertain(cause); + } finally { + unlink(listener, entry); + } + throw cause; + } + }, + removeEventListener: (_type, listener) => { + const entry = subscriptions.get(listener); + if (entry === undefined) return; + // A settled consumer has unsubscribed, even if an input retains its wrapper. + entry.delivered = true; + unlink(listener, entry); + }, + }; +} diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 925fd62f..1542d732 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -23,6 +23,7 @@ import type { SessionStreamHandleEvent } from '@relavium/core'; import { buildServerToolDefs, McpAbortedError, + raceDeadline, startMcpClient as realStartMcpClient, type McpClient, type McpConnection, @@ -1780,6 +1781,166 @@ it('reads the attached initial usage snapshot once across a streamed turn', asyn }); describe('owned session MCP startup cancellation', () => { + it.each( + (['fresh', 'resumed'] as const).flatMap((surface) => + (['caller-unlink', 'owner-unlink', 'partial-add'] as const).map((fault) => ({ + surface, + fault, + })), + ), + )( + '$surface retains $fault uncertainty without losing discovery cancellation or inventing close ACK', + async ({ surface, fault }) => { + const f = invocationFixture(); + const owner = new SessionWorkOwner(f.invocation, 'chat', { + cancel: () => undefined, + cleanup: () => Promise.resolve(), + }); + const primary = new Error('foreign caller signal fault'); + let aborted = false; + let allowRemoval = false; + const listeners = new Set<() => void>(); + const caller: AbortSignalLike = { + get aborted() { + return aborted; + }, + addEventListener: (_type, listener) => { + listeners.add(listener); + if (fault === 'partial-add') throw primary; + }, + removeEventListener: (_type, listener) => { + if (!allowRemoval && fault !== 'partial-add') throw primary; + listeners.delete(listener); + }, + }; + let releaseDiscovery: () => void = () => undefined; + const rawDiscovery = new Promise<[]>((resolve) => { + releaseDiscovery = () => resolve([]); + }); + const listTools = vi.fn((signal?: AbortSignalLike) => + raceDeadline( + 'fs', + 'discovery', + { startedAt: Date.now(), timeoutMs: 15_000 }, + () => rawDiscovery, + signal, + ), + ); + let acknowledgeClose: () => void = () => undefined; + const closeAck = new Promise((resolve) => { + acknowledgeClose = resolve; + }); + const close = vi.fn(() => closeAck); + const agent = { + ...buildDefaultChatAgent('claude-sonnet-4-6'), + mcp_servers: [{ id: 'fs', transport: 'stdio' as const, command: 'synthetic-server' }], + }; + const common = { + chat: EMPTY_CHAT, + now: () => 0, + providers: scriptedResolver([]), + workOwner: owner, + mcpConnectSignal: caller, + consentGate: () => Promise.resolve(new Map()), + startMcpClient: ( + _servers: Parameters[0], + signal?: AbortSignalLike, + work?: Parameters[2], + lifecycle?: Parameters[3], + ) => + realStartMcpClient( + [ + { + id: 'fs', + open: () => + Promise.resolve({ + listTools, + callTool: () => Promise.resolve({ content: [], isError: false }), + close, + }), + }, + ], + signal, + work, + lifecycle, + ), + }; + const record: AgentSessionRecord = { + id: 'fault-resume', + agentSlug: agent.id, + agentSnapshot: agent, + context: { workingDir: '/tmp', fsScopeTier: 'project' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: '2026-10-16T00:00:00.000Z', + updatedAt: '2026-10-16T00:00:00.000Z', + }; + let settled = false; + const opening = owner + .enter(() => + surface === 'fresh' + ? buildFreshSession({ + ...common, + agent, + agentRef: undefined, + cwd: '/tmp', + projectConfigDir: undefined, + uuid: () => 'fault-fresh', + }) + : buildRestoredSession({ ...common, record, messages: [] }), + ) + .then( + (value) => { + settled = true; + return value; + }, + (cause: unknown) => { + settled = true; + return cause; + }, + ); + try { + await vi.waitFor(() => expect(listTools).toHaveBeenCalledOnce()); + if (fault !== 'partial-add') { + expect(() => { + if (fault === 'owner-unlink') owner.stop(); + else { + aborted = true; + for (const listener of [...listeners]) listener(); + } + }, 'FOREIGN_UNLINK_CANNOT_SUPPRESS_MCP_CANCELLATION').not.toThrow(); + } + await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()); + expect(settled, 'SUBSCRIPTION_FAULT_IS_NOT_NATIVE_CLOSE_ACK').toBe(false); + expect(owner.hasCleanupUncertainty).toBe(true); + expect(owner.firstFailure?.cause).toBe(primary); + acknowledgeClose(); + expect(await opening).toBeInstanceOf(Error); + let retired = false; + void owner.close().then(() => { + retired = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(retired, 'REJECTED_LISTENER_CLEANUP_IS_NOT_OWNER_RETIREMENT').toBe(false); + expect(f.registry.activeOwners).toBe(1); + f.advance(3_000); + expect(await f.invocation.finiteExit.exited).toBe(9); + expect(f.registry.activeOwners).toBe(1); + } finally { + // Join every entered synthetic connection; sticky uncertainty intentionally has no positive ACK. + allowRemoval = true; + releaseDiscovery(); + acknowledgeClose(); + owner.stop(); + await opening; + void owner.close(); + } + }, + ); + it.each( (['fresh', 'resumed'] as const).flatMap((surface) => (['caller', 'owner', 'pre-aborted-caller'] as const).map((cancel) => ({ surface, cancel })), diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index 3e2c7d72..f2c8e208 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -1,4 +1,5 @@ import type { NativeProcessController } from '../process/native-process-owner.js'; +import { combineMcpConnectSignals } from './mcp-connect-signal.js'; import { type AgentDefinition, AgentSession, @@ -871,41 +872,15 @@ function mcpOptionsFor( mcpArtifact: string | undefined, cwd: string, ): ConnectAgentMcpOptions { - const ownerSignal = opts.workOwner?.signal; + const owner = opts.workOwner; + const ownerSignal = owner?.signal; const callerSignal = opts.mcpConnectSignal; - // Both authorities can limit startup. Subscribe lazily so discovery's own - // listener removal also releases both inputs when startup settles. - const subscriptions = new Map<() => void, () => void>(); const connectSignal: AbortSignalLike | undefined = - ownerSignal === undefined + owner === undefined || ownerSignal === undefined ? callerSignal : callerSignal === undefined || callerSignal === ownerSignal ? ownerSignal - : { - get aborted() { - return ownerSignal.aborted || callerSignal.aborted; - }, - addEventListener: (type, listener) => { - if (subscriptions.has(listener) || ownerSignal.aborted || callerSignal.aborted) - return; - const notify = (): void => { - if (!subscriptions.delete(listener)) return; - ownerSignal.removeEventListener(type, notify); - callerSignal.removeEventListener(type, notify); - listener(); - }; - subscriptions.set(listener, notify); - ownerSignal.addEventListener(type, notify); - callerSignal.addEventListener(type, notify); - }, - removeEventListener: (type, listener) => { - const notify = subscriptions.get(listener); - if (notify === undefined) return; - subscriptions.delete(listener); - ownerSignal.removeEventListener(type, notify); - callerSignal.removeEventListener(type, notify); - }, - }; + : combineMcpConnectSignals(ownerSignal, callerSignal, owner.cleanupUncertain); return { cwd, ...(opts.workOwner === undefined From 3b74d516296633d81577e51458329949859bac11 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 01:54:28 +0300 Subject: [PATCH 171/222] test(cli): separate native writer readiness from lock contention Initialize both native writers before taking the parent lock. Require positive write readiness and join actual child close on failure or abort before releasing fixture custody. Preserve the original time bound, two 40-write bursts and all persisted-row assertions. Refs: ADR-0064 Co-Authored-By: Codex --- apps/cli/src/harness/concurrency.e2e.test.ts | 354 ++++++++++++++---- .../harness/fixtures/concurrent-writer.mjs | 42 ++- 2 files changed, 307 insertions(+), 89 deletions(-) diff --git a/apps/cli/src/harness/concurrency.e2e.test.ts b/apps/cli/src/harness/concurrency.e2e.test.ts index 67cfcc28..197ceddb 100644 --- a/apps/cli/src/harness/concurrency.e2e.test.ts +++ b/apps/cli/src/harness/concurrency.e2e.test.ts @@ -87,41 +87,97 @@ const message = (sessionId: string, seq: number): SessionMessage => ({ }); interface SpawnedChild { - /** Resolves when the child prints `READY` (about to write) OR exits — so the parent never hangs on a dead child. */ + /** Positive initialization ACK; the parent must not hold the write lock during child startup. */ + readonly opened: Promise; + /** Positive first-write handshake. Early exit rejects rather than impersonating readiness. */ readonly ready: Promise; - /** Resolves with the child's exit code + captured stderr once it closes. */ - readonly done: Promise<{ code: number; stderr: string }>; + /** Resolves only on actual native close, including after a spawn error or stop request. */ + readonly done: Promise<{ code: number; stderr: string; spawnError: Error | undefined }>; + readonly start: () => void; + readonly stop: () => void; + readonly isClosed: () => boolean; } -/** Spawn a child; expose a `ready` handshake (printed just before its first write) + its final exit/stderr. */ +/** Spawn once and retain its actual close even if setup, the test body or readiness fails. */ function runChild(args: readonly string[]): SpawnedChild { const child = spawn(process.execPath, [CHILD_SCRIPT, ...args], { - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['pipe', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; + let closed = false; + let spawnError: Error | undefined; + let signalOpened: () => void = () => {}; let signalReady: () => void = () => {}; + const opened = new Promise((resolve) => { + signalOpened = resolve; + }); const ready = new Promise((resolve) => { signalReady = resolve; }); child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); - if (stdout.includes('READY')) signalReady(); + if (stdout.split('\n').includes('OPENED')) signalOpened(); + if (stdout.split('\n').includes('READY')) signalReady(); }); child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); }); - const done = new Promise<{ code: number; stderr: string }>((resolve, reject) => { - child.on('error', (err) => { - signalReady(); // a spawn failure (ENOENT/EAGAIN) must ALSO release the ready-wait, else `await child.ready` hangs - reject(err); - }); - child.on('close', (code) => { - signalReady(); // a child that died before printing READY must not hang the parent's ready-wait - resolve({ code: code ?? -1, stderr }); - }); + const done = new Promise<{ code: number; stderr: string; spawnError: Error | undefined }>( + (resolve) => { + child.on('error', (err) => { + spawnError ??= err; + }); + child.stdin.on('error', (err: Error) => { + spawnError ??= err; + }); + child.on('close', (code) => { + closed = true; + resolve({ code: code ?? -1, stderr, spawnError }); + }); + }, + ); + const prematureClose = done.then((result) => { + throw new Error( + `Concurrent writer closed before its handshake (exit ${result.code}). ${result.stderr}`, + { + cause: result.spawnError, + }, + ); }); - return { ready, done }; + const observedOpened = Promise.race([opened, prematureClose]); + const observedReady = Promise.race([ready, prematureClose]); + // Both phases are observed from creation, even if the other phase fails first. + void observedOpened.catch(() => undefined); + void observedReady.catch(() => undefined); + return { + opened: observedOpened, + ready: observedReady, + done, + start: () => { + child.stdin.end('START\n'); + }, + stop: () => { + if (!closed) child.kill(); + }, + isClosed: () => closed, + }; +} + +/** Abort abandons only the wait; the caller still owes each child's actual close in finally. */ +async function untilCaseAbort(work: Promise, signal: AbortSignal): Promise { + let abort = (): void => {}; + const stopped = new Promise((_resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- preserve the test context's original abort reason + abort = () => reject(signal.reason); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + }); + try { + return await Promise.race([stopped, work]); + } finally { + signal.removeEventListener('abort', abort); + } } interface CoexistencePorts { @@ -311,7 +367,163 @@ async function coexistenceCase(signal: AbortSignal, ports: CoexistencePorts): Pr throw new AggregateError(cleanupFailures, 'Concurrency fixture cleanup failed.'); } +interface CrossProcessPorts { + readonly firstMode?: 'fail-before-ready'; + readonly onChild?: (child: SpawnedChild) => void; + readonly onDirectory?: (directory: string) => void; + readonly onOpened?: () => void; +} + +function startCrossProcessCase(signal: AbortSignal, ports: CrossProcessPorts = {}): Promise { + rawCase = Promise.resolve().then(() => crossProcessCase(signal, ports)); + return rawCase; +} + +async function crossProcessCase(signal: AbortSignal, ports: CrossProcessPorts): Promise { + signal.throwIfAborted(); + const dir = mkdtempSync(join(tmpdir(), 'relavium-concurrency-2p-')); + const dbPath = join(dir, 'history.db'); + const children: SpawnedChild[] = []; + const nativeCloses: Array<() => void> = []; + const phases: Array<{ readonly phase: string; readonly milliseconds: number }> = []; + const began = performance.now(); + const phase = (name: string): void => { + phases.push({ phase: name, milliseconds: Math.round(performance.now() - began) }); + }; + let holder: DbClient | undefined; + let primary: { readonly cause: unknown } | undefined; + const cleanupFailures: unknown[] = []; + const releaseLock = (): void => { + if (holder?.sqlite.inTransaction) holder.sqlite.exec('COMMIT'); + }; + phase('case-enter'); + try { + ports.onDirectory?.(dir); + // Retain one parent connection through migration, contention and verification. Closing/reopening + // the last connection adds an unrelated WAL checkpoint/recovery cycle to a writer contention case. + holder = createClient(dbPath, (close) => nativeCloses.push(close)); + runMigrations(holder.db); + phase('migrations-done'); + signal.throwIfAborted(); + const PER_CHILD = 40; + for (const prefix of ['a', 'b']) { + const child = runChild([ + DB_DIST_URL.href, + dbPath, + prefix, + String(PER_CHILD), + prefix === 'a' ? (ports.firstMode ?? 'write') : 'write', + ]); + children.push(child); + ports.onChild?.(child); + } + // Import/PRAGMA initialization must not compete with the write lock whose release waits for them. + // OPENED is separate from READY: each child still enters its first upsert only after START. + await untilCaseAbort(Promise.all(children.map((child) => child.opened)), signal); + phase('children-opened'); + ports.onOpened?.(); + signal.throwIfAborted(); + holder.sqlite.exec('BEGIN IMMEDIATE'); + phase('lock-held'); + for (const child of children) child.start(); + await untilCaseAbort(Promise.all(children.map((child) => child.ready)), signal); + phase('children-ready'); + // Retain the existing overlap margin and the actual 5-second native busy handler. The deterministic + // write-policy clause guards remain in db's provider-store/retry tests; this is a native smoke. + await untilCaseAbort( + new Promise((resolve) => { + setTimeout(resolve, 100); + }), + signal, + ); + releaseLock(); + phase('lock-released'); + const results = await untilCaseAbort(Promise.all(children.map((child) => child.done)), signal); + phase('children-closed'); + signal.throwIfAborted(); + for (const result of results) { + expect(result.spawnError).toBeUndefined(); + expect(result.code).toBe(0); + expect(result.stderr).not.toMatch(/SQLITE|Error/i); + } + const providers = createProviderStore(holder.db, { uuid: () => randomUUID(), now: () => 0 }); + const names = providers.list().map((provider) => provider.name); + expect(names).toHaveLength(2 * PER_CHILD); + for (let i = 0; i < PER_CHILD; i += 1) { + expect(names).toContain(`a-${i}`); + expect(names).toContain(`b-${i}`); + } + phase('assertions-done'); + } catch (cause) { + primary = { cause }; + } finally { + // Release the lock before asking children to stop, then join actual close before any SQLite close + // or removal. A test timeout, early-exit handshake failure and kill request are never close ACKs. + try { + releaseLock(); + } catch (cause) { + cleanupFailures.push(cause); + } + if (primary !== undefined || signal.aborted) { + for (const child of children) { + try { + child.stop(); + } catch (cause) { + cleanupFailures.push(cause); + } + } + } + await Promise.all(children.map((child) => child.done)); + phase('raw-children-joined'); + for (const close of nativeCloses) { + try { + close(); + } catch (cause) { + cleanupFailures.push(cause); + } + } + phase('native-close-attempts-done'); + try { + rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + phase('directory-removed'); + } catch (cause) { + cleanupFailures.push(cause); + } + if (primary !== undefined || cleanupFailures.length > 0 || signal.aborted) + console.error( + 'CONCURRENCY_CHILD_PHASES ' + + JSON.stringify({ + phases, + childrenClosed: children.filter((child) => child.isClosed()).length, + cleanupFailures: cleanupFailures.length, + }), + ); + } + if (primary !== undefined) { + if (cleanupFailures.length > 0) + throw new AggregateError( + [primary.cause, ...cleanupFailures], + 'Cross-process fixture failed and cleanup also failed.', + { cause: primary.cause }, + ); + throw primary.cause; + } + if (cleanupFailures.length > 0) + throw new AggregateError(cleanupFailures, 'Cross-process fixture cleanup failed.'); +} + describe('concurrency e2e (2.5.I S3) — a run and a chat share one history.db', () => { + it('an already-aborted fixture wait still observes the raw child-phase rejection', async () => { + const controller = new AbortController(); + const reason = new Error('synthetic case already stopped'); + controller.abort(reason); + await expect( + untilCaseAbort(Promise.reject(new Error('synthetic child-phase fault')), controller.signal), + ).rejects.toBe(reason); + // A throw before installing the race would leave the already-created work unobserved. + await new Promise((resolve) => setImmediate(resolve)); + }); + it('coexistence: a real run and a real chat session write the same file concurrently — both land', ({ signal, }) => startCoexistenceCase(signal)); @@ -457,74 +669,52 @@ describe('concurrency e2e (2.5.I S3) — a run and a chat share one history.db', it.skipIf(!existsSync(DB_DIST_PATH))( 'cross-process safety: two child processes contend on one file (parent holds the lock) — all writes land', - async () => { - const dir = mkdtempSync(join(tmpdir(), 'relavium-concurrency-2p-')); - const dbPath = join(dir, 'history.db'); - // The parent migrates ONCE so the children never race the migrator — they only write. - const setup = createClient(dbPath); - runMigrations(setup.db); - setup.sqlite.close(); - const PER_CHILD = 40; - // A third connection that HOLDS the single WAL write lock while the children start, so their first - // upsert genuinely busy-waits cross-process. `inTransaction` is the truth of whether the lock is held — - // it double-guards release and never COMMITs when no transaction is open. - const holder = createClient(dbPath); - const releaseLock = (): void => { - if (holder.sqlite.inTransaction) holder.sqlite.exec('COMMIT'); - }; - try { - holder.sqlite.exec('BEGIN IMMEDIATE'); // acquire the write lock; the children block on it - const childA = runChild([DB_DIST_URL.href, dbPath, 'a', String(PER_CHILD)]); - const childB = runChild([DB_DIST_URL.href, dbPath, 'b', String(PER_CHILD)]); - - // Deterministic handshake: both children print READY the instant before their first write, so once - // both signal they are (about to be) busy-waiting on the held lock. A small margin then covers the - // microgap between the signal and the child's BEGIN IMMEDIATE, so releasing here guarantees a real - // cross-process busy-wait every run — independent of Node/import startup speed (which can exceed a - // fixed timer). The `close`-resolves-`ready` fallback means a dead child can never hang this wait. - await Promise.all([childA.ready, childB.ready]); - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - releaseLock(); - const [a, b] = await Promise.all([childA.done, childB.done]); + ({ signal }) => startCrossProcessCase(signal), + ); - // Both children exited cleanly. A real failure prints the error to stderr + exits 1; a benign Node - // warning line (deprecation, experimental flag) is tolerated — assert the exit code and the ABSENCE - // of a SQLite/Error line, not exact-empty stderr (which a CI runner's warnings could break). - expect(a.code).toBe(0); - expect(a.stderr).not.toMatch(/SQLITE|Error/i); - expect(b.code).toBe(0); - expect(b.stderr).not.toMatch(/SQLITE|Error/i); + it.skipIf(!existsSync(DB_DIST_PATH))( + 'a child exiting before READY fails and both actual child closes precede directory removal', + async ({ signal }) => { + const children: SpawnedChild[] = []; + let directory: string | undefined; + await expect( + startCrossProcessCase(signal, { + firstMode: 'fail-before-ready', + onChild: (child) => children.push(child), + onDirectory: (path) => { + directory = path; + }, + }), + ).rejects.toThrow('Concurrent writer closed before its handshake'); + expect(children).toHaveLength(2); + expect(children.every((child) => child.isClosed())).toBe(true); + expect(directory !== undefined && existsSync(directory)).toBe(false); + }, + ); - // Every write of BOTH children landed: 2 × PER_CHILD distinct providers, none lost or corrupted. - const verify = createClient(dbPath); - try { - const providers = createProviderStore(verify.db, { - uuid: () => randomUUID(), - now: () => 0, - }); - const names = providers.list().map((p) => p.name); - expect(names).toHaveLength(2 * PER_CHILD); - for (let i = 0; i < PER_CHILD; i += 1) { - expect(names).toContain(`a-${i}`); - expect(names).toContain(`b-${i}`); - } - } finally { - verify.sqlite.close(); - } - } finally { - // Nested so a failure in one cleanup step never skips the rest (release → close → remove temp dir). - try { - releaseLock(); - } finally { - try { - holder.sqlite.close(); - } finally { - rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); - } - } - } + it.skipIf(!existsSync(DB_DIST_PATH))( + 'case abort after child initialization joins both native closes before releasing fixture custody', + async () => { + const controller = new AbortController(); + const reason = new Error('synthetic cross-process abort'); + const children: SpawnedChild[] = []; + let directory: string | undefined; + await expect( + startCrossProcessCase(controller.signal, { + onChild: (child) => children.push(child), + onDirectory: (path) => { + directory = path; + }, + onOpened: () => { + expect(children).toHaveLength(2); + expect(children.every((child) => !child.isClosed())).toBe(true); + expect(directory !== undefined && existsSync(directory)).toBe(true); + controller.abort(reason); + }, + }), + ).rejects.toBe(reason); + expect(children.every((child) => child.isClosed())).toBe(true); + expect(directory !== undefined && existsSync(directory)).toBe(false); }, ); }); diff --git a/apps/cli/src/harness/fixtures/concurrent-writer.mjs b/apps/cli/src/harness/fixtures/concurrent-writer.mjs index 8783b40f..161a163f 100644 --- a/apps/cli/src/harness/fixtures/concurrent-writer.mjs +++ b/apps/cli/src/harness/fixtures/concurrent-writer.mjs @@ -5,11 +5,34 @@ // reproduce. It writes distinct provider names, so success = every write of BOTH children landed with no // escaped `SQLITE_BUSY` and no lost/corrupt row (the parent asserts the union). // -// argv: [node, thisFile, , , , ] +// argv: [node, thisFile, , , , , ] +// The optional fail-before-ready mode is a fixture refusal control; the normal mode always writes count rows. /* global process, console -- a Node child-process fixture (not TS source); it uses only these Node globals. */ -const [, , distPath, dbPath, prefix, countArg] = process.argv; +const [, , distPath, dbPath, prefix, countArg, mode = 'write'] = process.argv; const count = Number(countArg); +// Await pipe acknowledgement before entering synchronous SQLite: no buffered readiness bytes are +// allowed to depend on the event loop that the first contended write is about to block. +const announce = (line) => + new Promise((resolve, reject) => { + process.stdout.write(line + '\n', (error) => (error ? reject(error) : resolve())); + }); + +const start = new Promise((resolve, reject) => { + let command = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { + command += chunk; + }); + process.stdin.once('error', reject); + process.stdin.once('end', () => { + if (command === 'START\n') resolve(); + else reject(new Error('Concurrent writer did not receive START.')); + }); +}); +// Startup may fail before this wait is reached; always observe the already-owned input task. +void start.catch(() => undefined); + let client; try { // test-harness mechanism: the child can't use vitest's source resolution, so it imports the BUILT @@ -25,10 +48,11 @@ try { uuid: () => `${prefix}0000000-0000-4000-8000-${String(++uuidN).padStart(12, '0')}`, now: () => 1_700_000_000_000, }); - // Signal readiness (the import + the connection are up) so the parent releases the held write lock only - // once we are about to write — a deterministic handshake that forces real cross-process contention every - // run, independent of Node/import startup latency (which can exceed a fixed parent-side timer). - process.stdout.write('READY\n'); + await announce('OPENED'); + await start; + if (mode === 'fail-before-ready') throw new Error('synthetic refusal before READY'); + // The parent now holds the write lock. READY is positive evidence, never synthesized from child exit. + await announce('READY'); for (let i = 0; i < count; i += 1) { providers.upsert({ name: `${prefix}-${i}`, @@ -40,5 +64,9 @@ try { console.error(err instanceof Error ? (err.stack ?? err.message) : String(err)); process.exitCode = 1; } finally { - client?.sqlite.close(); + try { + client?.sqlite.close(); + } finally { + process.stdin.destroy(); + } } From 4a2e6a90c5af350ae595f70da0d2a5f31bd15840 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 01:54:28 +0300 Subject: [PATCH 172/222] test(repo): reconcile complete secrets control workloads Pin the updated full run and gate inventories and require both native and structural reader removal witnesses. Keep the coordinator fail-closed and independently refuse stale counts or missing witnesses. Refs: ADR-0107 Co-Authored-By: Codex --- tools/causal-controls/outcome.test.mjs | 43 +++++++++++++++++++++++ tools/causal-controls/provenance.test.mjs | 31 ++++++++++++++++ tools/secrets-controls/README.md | 12 ++++--- tools/secrets-controls/mutations.mjs | 15 +++++--- 4 files changed, 93 insertions(+), 8 deletions(-) diff --git a/tools/causal-controls/outcome.test.mjs b/tools/causal-controls/outcome.test.mjs index 22ed063d..348fcd98 100644 --- a/tools/causal-controls/outcome.test.mjs +++ b/tools/causal-controls/outcome.test.mjs @@ -20,6 +20,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { digest } from '../usage-coverage-controls/source-graph.mjs'; import RuntimeReporter from './runtime-reporter.mjs'; +import { mutations as secretsControls } from '../secrets-controls/mutations.mjs'; const mechanism = { id: 'boundary', target: 'named regression', @@ -92,6 +93,48 @@ test('complete named assertion failure certifies its specific removal', () => { const { data, child } = sample(); assert.doesNotThrow(() => checkOutcome(data, child, mechanism, 'mutated', '/graph')); }); +test('the complete reader workload still requires both native and structural removal witnesses', () => { + const control = secretsControls.find((entry) => entry.id === 'run-default-secret-reader'); + assert.ok(control); + const { data, child } = sample(); + data.testResults[0].name = `/graph/${control.files[0]}`; + data.testResults[0].assertionResults = [ + ...Array.from({ length: 82 }, (_, index) => ({ + fullName: `preserved reader workload ${index}`, + status: 'passed', + failureMessages: [], + })), + ...[ + 'uses the real native default reader once and suppresses an injected gate selector', + 'uses the real structural default reader once and suppresses an injected gate selector', + ].map((fullName) => ({ + fullName, + status: 'failed', + failureMessages: ['AssertionError: SECRETS_CAUSAL_RUN_DEFAULT_READER expected one read'], + })), + ]; + data.numTotalTests = 84; + data.numPassedTests = 82; + data.numFailedTests = 2; + const check = (candidate = data, declaration = control) => + checkOutcome(candidate, child, declaration, 'mutated', '/graph'); + assert.doesNotThrow(() => check()); + const missing = globalThis.structuredClone(data); + missing.testResults[0].assertionResults.shift(); + missing.numTotalTests = 83; + missing.numPassedTests = 81; + assert.throws(() => check(missing), /Declared workload drift/); + assert.throws(() => check(data, { ...control, expectedTests: 83 }), /Declared workload drift/); + const altered = globalThis.structuredClone(data); + altered.testResults[0].assertionResults[83].fullName = 'unrelated failure'; + assert.throws(() => check(altered), /missing additional named assertion/); + const passing = globalThis.structuredClone(data); + passing.testResults[0].assertionResults[83].status = 'passed'; + passing.testResults[0].assertionResults[83].failureMessages = []; + passing.numPassedTests = 83; + passing.numFailedTests = 1; + assert.throws(() => check(passing), /missing additional named assertion/); +}); test('a native assertion prefix retains the same named semantic authority', () => { const { data, child } = sample(); data.testResults[0].assertionResults[0].failureMessages = [ diff --git a/tools/causal-controls/provenance.test.mjs b/tools/causal-controls/provenance.test.mjs index 1a9cb058..ef92b24e 100644 --- a/tools/causal-controls/provenance.test.mjs +++ b/tools/causal-controls/provenance.test.mjs @@ -265,3 +265,34 @@ test('every declared filesystem and secrets anchor matches current production so assertSelectedAnchors(controls, repository, files); } }); + +test('secrets workload corrections retain independent complete-count and reader-witness pins', () => { + assert.equal(secretsControls.length, 41); + assert.equal( + secretsControls.reduce((count, control) => count + 1 + (control.witnesses ?? []).length, 0), + 277, + ); + for (const [id, file, expectedTests] of [ + ['run-default-secret-reader', 'apps/cli/src/commands/run.test.ts', 84], + ['snapshot-run-warning', 'apps/cli/src/commands/run.test.ts', 84], + ['gate-consumed-stdin-prompt-authority', 'apps/cli/src/commands/gate.test.ts', 73], + ]) { + const control = secretsControls.find((entry) => entry.id === id); + assert.ok(control, `${id}: missing control`); + assert.deepEqual(control.files, [file]); + assert.equal(control.expectedTests, expectedTests, `${id}: complete workload drift`); + } + const reader = secretsControls.find((entry) => entry.id === 'run-default-secret-reader'); + assert.equal( + reader.target, + 'uses the real native default reader once and suppresses an injected gate selector', + ); + assert.equal(reader.marker, 'SECRETS_CAUSAL_RUN_DEFAULT_READER'); + assert.deepEqual(reader.witnesses, [ + { + target: + 'uses the real structural default reader once and suppresses an injected gate selector', + marker: 'SECRETS_CAUSAL_RUN_DEFAULT_READER', + }, + ]); +}); diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index 0c8c06e2..371d118b 100644 --- a/tools/secrets-controls/README.md +++ b/tools/secrets-controls/README.md @@ -4,7 +4,7 @@ Step 4 first-run input matrix, Step 5 snapshot/export and Step 7 provider-header controls on physical source copies. `--mechanism ` supports diagnosis only; selected runs retain `complete: false`. The current manifest -declares **41 independent controls and 276 required named assertion witnesses**. +declares **41 independent controls and 277 required named assertion witnesses**. These counts describe the workload; a declaration is not evidence that its removal passed. Actual fixed/removal/restored results belong to the coordinator's summary. @@ -66,14 +66,18 @@ The eleven Step 4 controls remove separate production mechanisms: | `run-commander-diagnostic-projection` | Remove the run-local human-output and thrown-error projection while retaining Commander exception handling; all four parser-diagnostic modes must expose the opaque payload to their named assertion. | | `run-core-admission-before-services` | Remove the pure pre-service core admission while retaining final engine validation; secret length/pattern/format and ordinary min/max/enum failures must violate the native zero-key-factory assertion in all four output modes. | -The Step 4 declarations require 51 additional named assertion witnesses. Every +The Step 4 declarations require 52 additional named assertion witnesses. Every selected workload remains a complete test file: 33 input cases, 14 action-forwarding -cases, 32 dispatch cases, 83 run-command cases, three run-consent cases, 71 gate cases, +cases, 32 dispatch cases, 84 run-command cases, three run-consent cases, 73 gate cases, 28 top-level parser cases, or 131 native integration cases, as declared per control. The coordinator independently enforces exact case counts, unique complete case identities and identical fixed/removal/restored inventories. It accepts only a completed `AssertionError` carrying the expected marker in each selected target; setup/import errors, thrown mock blockers or a failure elsewhere cannot substitute. +The default-reader control requires both native and structurally equivalent stdin +reader witnesses. An ordinary tooling test independently pins the corrected complete-file +counts and both reader witnesses. The coordinator still validates actual collection in each +phase; collection never supplies or rewrites its expected counts. The native suite keeps the actual top-level parser/dispatch, default stdin reader, engine, production history openers and SQLite persistence. It observes both the @@ -121,7 +125,7 @@ permanent negative cases; no notice is produced before a successful export. Four snapshot controls remove the finite scrubber detection site and the actual `create`, `import`, and `run` warning calls. Each has a named value-free assertion in -its complete 10-case detector, 11-case create, 11-case import or 83-case run file. +its complete 10-case detector, 11-case create, 11-case import or 84-case run file. The adjacent tests check preserved authored files and admitted run snapshots, JSON stdout, and scan-budget/incomplete notices. These controls certify warning delivery for the planted supported shapes at these entry points, not universal secret diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index 512563b2..a7d95349 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -347,9 +347,16 @@ export const mutations = [ ' const raw = await read();', " const raw = await (deps.readSecretInput?.() ?? Promise.resolve(''));", ['apps/cli/src/commands/run.test.ts'], - 83, - 'uses the real default reader exactly once and suppresses even an injected gate selector', + 84, + 'uses the real native default reader once and suppresses an injected gate selector', 'SECRETS_CAUSAL_RUN_DEFAULT_READER', + [ + { + target: + 'uses the real structural default reader once and suppresses an injected gate selector', + marker: 'SECRETS_CAUSAL_RUN_DEFAULT_READER', + }, + ], ), control( 'run-consumed-stdin-prompt-authority', @@ -367,7 +374,7 @@ export const mutations = [ ' const mayPrompt = args.secretStdin !== true;', ' const mayPrompt = true;', ['apps/cli/src/commands/gate.test.ts'], - 71, + 73, 'refuses unapproved stdio after a real read despite stale TTY, without prompting or writing a grant', 'SECRETS_CAUSAL_RESUME_CONSUMED_STDIN_CONSENT', ), @@ -581,7 +588,7 @@ export const mutations = [ ' warnSnapshotLiterals(def, deps.io);\n', '', ['apps/cli/src/commands/run.test.ts'], - 83, + 84, 'warns after admission before services and preserves the actual augmented frozen workflow', 'SNAPSHOT_CAUSAL_RUN_CALLER', ), From 060830f692869125bdd3dd7afc2b9fe14b237173 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 01:54:28 +0300 Subject: [PATCH 173/222] docs(docs): record first corrective review and cleanup contracts Record the first nonclean corrective round and its exact scopes, subsequent source repairs and remaining independent acceptance gates. Link historical Step 8 acceptance separately from current corrected-source platform evidence. Refs: ADR-0103, ADR-0110 Co-Authored-By: Codex --- docs/reference/shared-core/mcp-integration.md | 7 +++++ .../phase-2.5.5-hardening-and-remediation.md | 28 +++++++++++++++++++ tools/cli-package/smoke/README.md | 6 ++-- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/docs/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index 2a04c277..0730fdcc 100644 --- a/docs/reference/shared-core/mcp-integration.md +++ b/docs/reference/shared-core/mcp-integration.md @@ -196,6 +196,13 @@ proved-empty scope. A successful client's close joins startup descendants as wel CLI run/resume hosts retain this aggregate before connect and keep their signal guard until its cleanup acknowledgement, including failure before a client or engine handle exists. +For owned CLI sessions, both the invocation signal and an optional caller startup +signal limit discovery. Subscription entry is synchronous. Abort delivery is +independent of input-listener cleanup: both removals are attempted even when one +fails. Cleanup of partial registration is attempted after the registering call returns. +Any failed or unknown unlink is retained as cleanup uncertainty by the original +invocation owner; consumer settlement or bounded refusal does not retire that owner. + The engine's [departure contract](../../architecture/shared-core-engine.md#internal-departure-foundations-adr-0103) owns all run actors and final receipt health. MCP connection close supplies its own actual-work acknowledgement; a bounded public failure does not substitute for it. Consolidated independent diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 52e61281..a48b5f01 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8413,3 +8413,31 @@ tests test the native test harness and are not presented as additional product-p coverage. Separate tarball builds may differ in metadata ordering: existing acceptance certifies one chosen immutable archive, not independently reproducible builds. Detailed per-finding evidence, controls and review records remain local and Git-ignored. + +The first fresh cumulative correction round at `0d963a1a` is **NONCLEAN**: +all 89 corrective paths / 171 default-context hunks were assigned without gaps. +The library assignment is clean (18 paths; 259 independent focused tests). CLI +review covers 39 paths and 538 focused tests but reproduces a High in the new +MCP signal composition: a foreign listener-removal failure suppresses cancellation. +Tooling/docs review covers 32 paths and 137 focused tests, with one stale Step 8d +status sentence. These are complete scoped reviews, not whole-PR recertification. + +The subsequent correction delivers abort independently of cleanup, attempts both +input removals, rolls back partial registration after its entry returns, and retains +failed unlink as uncertainty on the original owner. Fresh/resumed actual-manager +regressions fail before the repair; neither bounded refusal nor a connection-close +result fabricates that owner's retirement. The tooling guide now links historical +Step 8 acceptance and separates fresh corrected-source publication evidence. + +Push CI at `0d963a1a` also exposes stale secrets workload declarations, an exact-cap +redaction timeout on the minimum Node line and a Windows concurrency-fixture timeout. +The declarations now retain both native/structural reader witnesses with explicit +complete-file counts; no coordinator gate is loosened. A necessary-delimiter prefilter +skips only impossible URL-userinfo matches, retaining every other full-value redaction +pass and the unchanged 8 MiB fixture. URL-containing values retain their prior cost. +The native fixture separates child initialization from the held-lock write handshake +and joins actual child close before deleting its database, including failure/abort. +Its unchanged timeout, two 40-write bursts and all row assertions remain. Local +validation of these repairs does not establish a Windows or minimum-Node CI result. +Two NEW complete cumulative clean rounds, a NEW separate security sitting and fresh +corrected-source CI/platform evidence remain required after this nonclean round. diff --git a/tools/cli-package/smoke/README.md b/tools/cli-package/smoke/README.md index 5a47069c..be12c5d1 100644 --- a/tools/cli-package/smoke/README.md +++ b/tools/cli-package/smoke/README.md @@ -104,8 +104,10 @@ both native PIDs alive, permits the fixture's ordinary successful turn, and ends with background-only exit 9. Each independent mode retains its own sibling, watchdog, actual close and exact-PID cleanup evidence. These are plain one-shot activation/ACK-order controls: interactive terminal restoration and absolute -budget nonrenewal retain their separately attributed source controls. Whole -Step 8d acceptance remains open. Original-TS maps/custody remain Step 9. The current ten +budget nonrenewal retain their separately attributed source controls. [Step 8d and whole +Step 8 acceptance](../../../docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) +retains its reviewed-source attribution. PR #95's corrected ten-case source still requires +fresh same-artifact platform evidence. Original-TS maps/custody remain Step 9. The current ten behavior cases and 177 bootstrap controls are required. Windows bootstrap modes require five actual target/helper creations (two fatal, one background, two output); the other platform/cohort combinations require three. From 21411e660b6b1dfddc2aa8c12242a0b8b6647139 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 02:44:36 +0300 Subject: [PATCH 174/222] fix(core): mask complete credentials in journal projections Consume whole recognized query and URL userinfo values, honor escaped quotes, and preserve following public fields. Keep admitted-scale token matches out of V8 stack exhaustion without changing their existing minimum lengths. Atomically update the DB digest domain to v3 and its actual CLI/SQLite regressions: unversioned and partial v2 rows cannot authorize replay, even when a current literal equals an old projection. Do not rewrite old evidence or grant an automatic retry. Nine author removal controls kill their named assertions and restore exact source bytes. Refs: ADR-0080 Co-Authored-By: Codex --- .../engine/tool-alias-journal-digest.test.ts | 146 +++++++++++++++- packages/core/src/tools/bounding.test.ts | 161 ++++++++++++++++++ packages/core/src/tools/bounding.ts | 124 +++++++++----- packages/db/src/effect-journal-store.test.ts | 22 ++- packages/db/src/effect-journal-store.ts | 6 +- 5 files changed, 401 insertions(+), 58 deletions(-) diff --git a/apps/cli/src/engine/tool-alias-journal-digest.test.ts b/apps/cli/src/engine/tool-alias-journal-digest.test.ts index 88f90110..4b299a98 100644 --- a/apps/cli/src/engine/tool-alias-journal-digest.test.ts +++ b/apps/cli/src/engine/tool-alias-journal-digest.test.ts @@ -1,6 +1,12 @@ import { createHash } from 'node:crypto'; -import { createToolRegistry, type ToolDef, type ToolDispatchContext } from '@relavium/core'; +import { + BUILTIN_TOOLS, + createToolRegistry, + type ToolDef, + type ToolDispatchContext, + type ToolHost, +} from '@relavium/core'; import { createClient, createEffectJournalPort, @@ -25,7 +31,7 @@ const EXPECTED_CANONICAL = { }; function sha256(text: string): string { - return `v2:${createHash('sha256').update(text).digest('hex')}`; + return `v3:${createHash('sha256').update(text).digest('hex')}`; } function registryFixture(effects: EffectDispatchPort) { @@ -110,7 +116,7 @@ describe.each(METADATA)( ); expect(rows).toHaveLength(2); expect(rows.map((row) => row.state)).toEqual(['committed', 'committed']); - for (const row of rows) expect(row.args_digest).toMatch(/^v2:[0-9a-f]{64}$/); + for (const row of rows) expect(row.args_digest).toMatch(/^v3:[0-9a-f]{64}$/); expect(rows[0]?.args_digest, 'PHYSICAL_ALIAS_DIGEST_DISTINCT').not.toBe( rows[1]?.args_digest, ); @@ -374,3 +380,137 @@ describe('PR #95 full redacted journal digest', () => { }, ); }); + +const CREDENTIAL_CASES = [ + { + name: 'URL password with multiple @ separators', + body: (suffix: string) => + `https://a:p@SYNTHETIC_USERINFO_TAIL_${suffix}@api.invalid;custom://u:second-password@two.invalid/path`, + projected: 'https://[redacted]@api.invalid;custom://[redacted]@two.invalid/path', + oldProjection: (suffix: string) => + `https://[redacted]@SYNTHETIC_USERINFO_TAIL_${suffix}@api.invalid;custom://[redacted]@two.invalid/path`, + }, + { + name: 'opaque query beyond the old 512-character match', + body: (suffix: string) => + `https://public.invalid/?opaque=${'Q'.repeat(599)}${suffix}#public-fragment`, + projected: 'https://public.invalid/?opaque=[redacted]#public-fragment', + oldProjection: (suffix: string) => + `https://public.invalid/?opaque=[redacted]${'Q'.repeat(87)}${suffix}#public-fragment`, + }, + { + name: 'JSON password with an escaped quote', + body: (suffix: string) => + JSON.stringify({ password: `ordinary"SYNTHETIC_SECRET_TAIL_${suffix}` }), + projected: '{"[redacted]}', + oldProjection: (suffix: string) => `{"[redacted]SYNTHETIC_SECRET_TAIL_${suffix}"}`, + }, +]; + +describe.each(CREDENTIAL_CASES)('PR #95 complete recognized credential: $name', (fixture) => { + it('PHYSICAL_CREDENTIAL_EQUALITY keeps credential changes out of actual HTTP journal digests', async () => { + const client = createClient(':memory:'); + try { + runMigrations(client.db); + let ids = 0; + const store = createEffectJournalStore(client.db, { + uuid: () => `credential-${++ids}`, + now: () => 6, + }); + const effects = recordingPort( + createEffectJournalPort( + store, + { kind: 'run', runId: 'r-credential', nodeId: 'n', attempt: 1 }, + { providerAttempt: 1, toolCallId: 'credential-call' }, + ), + ); + const fetch = vi.fn['fetch']>(() => + Promise.resolve({ status: 200, headers: {}, body: 'receipt' }), + ); + const registry = createToolRegistry({ tools: BUILTIN_TOOLS, host: { egress: { fetch } } }); + const context: ToolDispatchContext = { + nodeId: 'n', + grantedToolIds: new Set(['http_request']), + config: {}, + toolPolicy: { allowedDomains: ['service.invalid'] }, + fsScope: 'sandboxed', + gateApproved: false, + effects, + effectSlot: 0, + }; + const prefix = `${'x'.repeat(20 * 1024)}\n`; + const args = (body: string) => ({ body, method: 'POST', url: 'https://service.invalid/x' }); + const request = (suffix: string, publicTail = '\npublic-tail') => + ({ + type: 'tool_call', + id: 'credential-call', + name: 'http_request', + args: args(`${prefix}${fixture.body(suffix)}${publicTail}`), + }) as const; + const first = await registry.dispatch(request('A'), context); + const replay = await registry.dispatch(request('B'), context).then( + (result) => ({ output: result.output }), + (failure: unknown) => ({ failure }), + ); + expect(replay, 'PHYSICAL_CREDENTIAL_EQUALITY').toEqual({ output: first.output }); + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch.mock.calls[0]?.[0]).toMatchObject(request('A').args); + await registry.dispatch(request('B'), { ...context, effectSlot: 1 }); + expect(fetch).toHaveBeenCalledTimes(2); + const projected = args(`${prefix}${fixture.projected}\npublic-tail`); + // Independent sorted-key bytes; no production redactor/canonical helper supplies the oracle. + const expectedDigest = sha256(JSON.stringify(projected)); + const rows = PhysicalRowsSchema.parse( + client.sqlite + .prepare('SELECT slot, state, args_digest FROM run_effects ORDER BY slot') + .all(), + ); + expect(rows, 'PHYSICAL_CREDENTIAL_EQUALITY').toEqual([ + { slot: 0, state: 'committed', args_digest: expectedDigest }, + { slot: 1, state: 'committed', args_digest: expectedDigest }, + ]); + for (const callArgs of effects.prepare.mock.calls) { + expect(callArgs[3], 'PHYSICAL_CREDENTIAL_PROJECTION').toEqual(projected); + } + await expect( + registry.dispatch(request('B', '\nchanged-public-tail'), context), + 'PHYSICAL_CREDENTIAL_PUBLIC_TAIL_CONFLICT', + ).rejects.toMatchObject({ name: 'ToolEffectConflictError', retryable: false }); + expect(fetch).toHaveBeenCalledTimes(2); + expect( + JSON.stringify(client.sqlite.prepare('SELECT * FROM run_effects').all()), + ).not.toContain('SYNTHETIC_SECRET_TAIL_'); + } finally { + client.sqlite.close(); + } + }); + + it('PHYSICAL_PARTIAL_V2_REFUSAL refuses a literal equal to an old partially redacted projection', async () => { + const client = createClient(':memory:'); + try { + runMigrations(client.db); + const store = createEffectJournalStore(client.db, { uuid: () => 'partial-v2', now: () => 6 }); + const correlation = { kind: 'run', runId: 'r-v2', nodeId: 'n', attempt: 1 } as const; + const body = `${'x'.repeat(20 * 1024)}\n${fixture.oldProjection('A')}`; + const args = { body, method: 'POST', url: 'https://service.invalid/x' }; + const identity = { scope: 'run:r-v2:n', slot: 0, toolId: 'http_request' }; + const v2 = `v2:${createHash('sha256').update(JSON.stringify(args)).digest('hex')}`; + store.prepare(identity, correlation, { providerAttempt: 1, toolCallId: 'old' }, 3, v2); + store.settle(identity, 'committed', { receipt: 'old result' }); + const effects = createEffectJournalPort(store, correlation, { + providerAttempt: 1, + toolCallId: 'new', + }); + const attempt = await effects.prepare(0, 'http_request', 3, args).then( + (value) => ({ value }), + (failure: unknown) => ({ failure }), + ); + expect(attempt, 'PHYSICAL_PARTIAL_V2_REFUSAL').toMatchObject({ + failure: { name: 'EffectConflictError' }, + }); + expect(store.recordsFor(correlation)).toHaveLength(1); + } finally { + client.sqlite.close(); + } + }); +}); diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index ebde58d5..3a7307eb 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -246,6 +246,65 @@ describe('boundForModel', () => { }); describe('redactSecretShapedText', () => { + it.each([ + { prefix: 'Bearer ', minimum: 8, expected: 'Bearer [redacted]' }, + { prefix: 'Basic ', minimum: 8, expected: 'Basic [redacted]' }, + { prefix: 'Token ', minimum: 8, expected: 'Token [redacted]' }, + { prefix: 'password=', minimum: 6, expected: '[redacted]' }, + { prefix: 'sk-', minimum: 16, expected: '[redacted]' }, + { prefix: 'sk_live_', minimum: 16, expected: '[redacted]' }, + { prefix: 'ghp_', minimum: 20, expected: '[redacted]' }, + { prefix: 'github_pat_', minimum: 20, expected: '[redacted]' }, + { prefix: 'glpat-', minimum: 16, expected: '[redacted]' }, + { prefix: 'xoxb-', minimum: 10, expected: '[redacted]' }, + { prefix: 'AIza', minimum: 30, expected: '[redacted]' }, + { prefix: 'ya29.', minimum: 20, expected: '[redacted]' }, + { prefix: 'hf_', minimum: 20, expected: '[redacted]' }, + { prefix: 'npm_', minimum: 20, expected: '[redacted]' }, + ])( + 'CREDENTIAL_FLOOR preserves the $minimum-character floor for $prefix', + ({ prefix, minimum, expected }) => { + for (const length of [minimum - 1, minimum, minimum + 1]) { + const raw = prefix + 'A'.repeat(length); + expect(redactSecretShapedText(raw), 'CREDENTIAL_FLOOR').toBe( + length < minimum ? raw : expected, + ); + } + }, + ); + + it('JWT_FLOORS preserves all three segment minimums', () => { + const floors = [10, 10, 6]; + for (const [index, minimum] of floors.entries()) { + for (const length of [minimum - 1, minimum]) { + const segments = floors.map((floor, i) => 'A'.repeat(i === index ? length : floor)); + const raw = `eyJ${segments.join('.')}`; + expect(redactSecretShapedText(raw), 'JWT_FLOORS').toBe( + length < minimum ? raw : '[redacted]', + ); + } + } + }); + + it.each(['bearer', 'unquoted', 'openai-shaped', 'jwt-shaped', 'escaped-quoted'] as const)( + 'ADMITTED_CREDENTIAL_WHOLE masks the whole admitted-scale %s value without a RegExp stack failure', + (kind) => { + const large = 'A'.repeat(7_500_000); + const values = { + bearer: `Bearer ${large}`, + unquoted: `password=${large}`, + 'openai-shaped': `sk-${large}`, + 'jwt-shaped': `eyJ${large}.BBBBBBBBBBBB.CCCCCCCCCCCC`, + 'escaped-quoted': `password="${'\\"'.repeat(3_750_000)}"`, + }; + const raw = values[kind]; + expect(() => redactSecretShapedText(raw), 'ADMITTED_CREDENTIAL_WHOLE').not.toThrow(); + expect(redactSecretShapedText(raw), 'ADMITTED_CREDENTIAL_WHOLE').toBe( + kind === 'bearer' ? 'Bearer [redacted]' : '[redacted]', + ); + }, + ); + it('redacts Authorization schemes, secret=value pairs, and known token shapes', () => { // `Authorization: Bearer ` is caught by BOTH the scheme pattern and the `authorization=value` pattern; // the over-redaction is the safe direction — assert the token is gone, not an exact shape. @@ -282,6 +341,65 @@ describe('redactSecretShapedText', () => { expect(out).not.toContain('rider'); }); + it.each([24, 512, 513, 600, 32 * 1024])( + 'OPAQUE_QUERY_WHOLE redacts all %i characters while preserving the next parameter and public text', + (length) => { + const prefix = `${'x'.repeat(20 * 1024)}\nhttps://public.invalid/?opaque=`; + for (const suffix of ['A', 'B']) { + expect( + redactSecretShapedText( + `${prefix}${'Q'.repeat(length - 1)}${suffix}&limit=10\npublic-tail`, + ), + 'OPAQUE_QUERY_WHOLE', + ).toBe(`${prefix}[redacted]&limit=10\npublic-tail`); + } + expect(redactSecretShapedText('?opaque=short&limit=10')).toBe('?opaque=short&limit=10'); + }, + ); + + it('QUERY_FRAGMENT_BOUNDARY does not count or redact a public fragment as part of an opaque query value', () => { + const fragment = '#public@example.invalid'; + expect( + redactSecretShapedText(`?opaque=${'Q'.repeat(23)}${fragment}`), + 'QUERY_FRAGMENT_BOUNDARY', + ).toBe(`?opaque=${'Q'.repeat(23)}${fragment}`); + expect( + redactSecretShapedText(`?opaque=${'Q'.repeat(600)}${fragment}`), + 'QUERY_FRAGMENT_BOUNDARY', + ).toBe(`?opaque=[redacted]${fragment}`); + }); + + it('ADMITTED_QUERY_WHOLE masks a multi-megabyte admitted-scale value without a RegExp stack failure', () => { + const text = `?opaque=${'Q'.repeat(7_500_000)}#public-fragment`; + expect(() => redactSecretShapedText(text), 'ADMITTED_QUERY_WHOLE').not.toThrow(); + expect(redactSecretShapedText(text), 'ADMITTED_QUERY_WHOLE').toBe( + '?opaque=[redacted]#public-fragment', + ); + }); + + it.each([ + 'ordinary"SYNTHETIC_QUOTED_TAIL', + 'ordinary\\"SYNTHETIC_QUOTED_TAIL', + 'ordinary\\\\"SYNTHETIC_QUOTED_TAIL', + 'ordinary\\', + "ordinary'single quote and space", + ])('ESCAPED_SECRET_WHOLE consumes the complete JSON password %j', (password) => { + const text = JSON.stringify({ password, payee: 'alice@example.invalid' }); + expect(redactSecretShapedText(text), 'ESCAPED_SECRET_WHOLE').toBe( + '{"[redacted],"payee":"alice@example.invalid"}', + ); + expect(JSON.parse(text)).toEqual({ password, payee: 'alice@example.invalid' }); + }); + + it('redacts escaped single quotes and resumes after the real closing quote', () => { + expect(redactSecretShapedText("password='ordinary\\'SECRET_TAIL'; count=42")).toBe( + '[redacted]; count=42', + ); + expect(redactSecretShapedText('password="backslash\\\\"; count=42')).toBe( + '[redacted]; count=42', + ); + }); + it('redacts a PEM private-key block (space-separated markers the key-pattern cannot see)', () => { const pem = [ '-----BEGIN ' + 'OPENSSH PRIVATE KEY-----', @@ -426,6 +544,49 @@ describe('redactSecretShapedValue', () => { }); describe('redactSecretShapedText — URL userinfo (ADR-0080 §11)', () => { + it.each([';', '|'])( + 'USERINFO_BOUNDARY retains the next scheme after a public host and %s separator', + (separator) => { + expect( + redactSecretShapedText( + `https://u:p@one.invalid${separator}custom://u:second-password@two.invalid/path`, + ), + 'USERINFO_BOUNDARY', + ).toBe(`https://[redacted]@one.invalid${separator}custom://[redacted]@two.invalid/path`); + }, + ); + it.each(['A', 'B'])( + 'USERINFO_WHOLE masks through the last @ without retaining the credential tail %s', + (suffix) => { + expect( + redactSecretShapedText( + `https://a:p@SYNTHETIC_USERINFO_TAIL_${suffix}@api.invalid:8443/x?q=public@example.invalid#public@example.invalid`, + ), + 'USERINFO_WHOLE', + ).toBe( + 'https://[redacted]@api.invalid:8443/x?q=public@example.invalid#public@example.invalid', + ); + expect(redactSecretShapedText(`custom://a::@api.invalid/x`)).toBe( + 'custom://[redacted]@api.invalid/x', + ); + }, + ); + + it('keeps short vanity userinfo and public query/fragment @ while masking a whole long authority', () => { + for (const text of [ + 'https://vanity@api.invalid/x', + 'https://api.invalid?public=alice@example.invalid', + 'https://api.invalid#alice@example.invalid', + ]) + expect(redactSecretShapedText(text)).toBe(text); + expect(redactSecretShapedText(`https://${'u'.repeat(600)}@api.invalid/x`)).toBe( + 'https://[redacted]@api.invalid/x', + ); + expect(redactSecretShapedText('https://u:p@[::1]:8443/x')).toBe( + 'https://[redacted]@[::1]:8443/x', + ); + }); + it('strips `user:pass@` from a URL while KEEPING the host', () => { // The shape every other pattern here misses: no `key=value`, no well-known prefix. A review recovered a // stored `run_effects` digest from guessed plaintext through exactly this gap — and since ADR-0080 the diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index b277d7e3..6296264f 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -115,48 +115,20 @@ function redactInlineMediaForText(value: unknown, seen: WeakSet): unknow } /** - * Redact secret-SHAPED substrings from a text projection bound for an **observability field** (the event - * `outputSummary` / `toolInput`) — NOT the model-facing value (the model must still see the real tool result + * Redact secret-SHAPED substrings from observability and journal projections — NOT the model-facing value + * (the model must still see the real tool result * and its own args). A model can put a live credential in an `http_request` header/body, and a tool result can * carry one (a `read_clipboard`, an egress response body, a `.env` read); either would otherwise ride the * `--json` machine stream (ADR-0049) / a log verbatim. High-recall and deliberately conservative (a false - * positive redacts a non-secret in a DISPLAY field only — never the model's copy): it targets `Authorization` + * positive changes the projection, never the model/dispatch copy). Journal consumers deliberately treat + * recognized credential projections as equal; see effect-journal.md §11. It targets `Authorization` * schemes, `…secret…=value` pairs, and well-known token shapes (OpenAI/AWS/GitHub/Slack/Google/JWT). Every - * pattern is LINEAR — bounded repetition + single character classes, no nested quantifiers — per the engine's + * pattern uses bounded context or disjoint character runs, with no ambiguous nested repetition — per the engine's * no-backtracking-RegExp / no-ReDoS posture. */ export function redactSecretShapedText(text: string): string { - let withoutUserinfo = text; - // Both URL rules require this exact delimiter. Skip only those rules when it is absent: their - // bounded scheme prefixes otherwise retry at every letter of a multi-megabyte argument. Other - // credential rules still scan the whole value, including its tail; no display bound applies here. - if (text.includes('://')) { - withoutUserinfo = text - // **URL userinfo — `scheme://user:pass@host`.** The single most common shape a real credential takes in a - // tool argument: a database connection string, a Redis URL, a webhook with an embedded token. It has no - // `key=value` and no well-known prefix, so every other pattern here misses it — a review REPRODUCED the - // gap by recovering a stored `run_effects` digest from guessed plaintext. That matters more since - // ADR-0080: the same projection now feeds a durable, never-swept digest, which is a permanent offline - // oracle rather than a line in an ephemeral event stream. The host is deliberately KEPT — it is the - // diagnostic half, and it is not the secret. - .replace( - /([a-z][\w+.-]{0,31}:\/\/)[^/@\s:"',}\]]{0,256}:[^/@\s"',}\]]{1,256}@/gi, - '$1[redacted]@', - ) - // …and the COLON-LESS form — `scheme://TOKEN@host`. A webhook with an embedded bearer, a Stripe-style - // key-as-username (`https://sk_live_…:@api.stripe.com`, whose password half is empty), `curl -u tok:`. - // The rule above cannot see any of them: it requires a `:` with a non-empty right side. The `{16,}` - // floor is what keeps `https://example.com` and a short vanity handle out — a credential in this - // position is long, and a userinfo short enough to fall under the floor carries little to recover. - .replace(/([a-z][\w+.-]{0,31}:\/\/)[^/@\s"',}\]]{16,256}@/gi, '$1[redacted]@'); - } return ( - withoutUserinfo - // …and a token in a URL QUERY. `?token=…` is already caught by the `key=value` rule below, but the - // SIGNED-URL shape — a long opaque value under an unrecognised parameter name — is not, and a - // pre-signed S3 URL or a webhook `?t=` is exactly that. Bounded to a long value so an ordinary - // `?limit=10` or `?q=cats` is untouched; the parameter NAME is kept as the diagnostic half. - .replace(/([?&][\w.-]{1,64}=)[^\s&"',}\]]{24,512}/g, '$1[redacted]') + redactOpaqueQueryValues(redactUrlUserinfo(text)) // A PEM private-key block (multi-line, space-separated markers the `private_key` key-pattern can't see). // The body span is bounded (`{0,20000}?`, lazy) so an unterminated block can't drive an unbounded scan. .replace( @@ -165,18 +137,22 @@ export function redactSecretShapedText(text: string): string { ) // An `Authorization`-style scheme + its token: `Bearer ` / `Basic ` / `Token `. The token class // `[\w.~+/-]` is base64url + padding (`\w` folds `[A-Za-z0-9_]`). - .replace(/\b(bearer|basic|token)\s+[\w.~+/-]{8,}={0,2}/gi, '$1 [redacted]') + // For every open-ended token minimum below, a bounded lookahead enforces the same floor before + // one simple greedy run. V8 can exhaust its RegExp stack on an admitted-scale `{N,}` run; the + // equivalent `(?=class{N})class+` form keeps the whole value instead of clipping a credential. + .replace(/\b(bearer|basic|token)\s+(?=[\w.~+/-]{8})[\w.~+/-]+={0,2}/gi, '$1 [redacted]') // A secret-ish key (bounded wrappers keep this ReDoS-safe) + `=`/`:` + its value. The optional `["']?` // BEFORE the separator catches the JSON `"access_token":"…"` shape (an OAuth/egress response body). The - // value has two branches: a QUOTED value consumes lazily through the FIRST matching closing quote (so a - // passphrase with interior spaces — `"hunter2 dragon"` — is redacted whole, however long, not just up to - // the first space), bounded by the line (`[^\r\n]`); an UNQUOTED value runs to the next whitespace/ - // delimiter. Lazy `*?` with a single-class body + a fixed backref is linear (finds the first close) ⇒ - // ReDoS-safe. Kept a single analyzable LITERAL (not composed via `new RegExp`) so Sonar's static + // value has QUOTED and UNQUOTED branches. A quoted value ends at the first UNESCAPED matching quote, + // including JSON's escaped quotes/backslashes; otherwise a valid password such as `a\"secret-tail` + // left its tail exposed. The two alternatives inside each repetition are disjoint: a backslash + // consumes exactly the following non-newline character, while the ordinary class excludes it. + // No ambiguous nested repetition/backref is needed. Unquoted values retain their delimiter and + // minimum-length policy. Kept a single analyzable LITERAL (not composed via `new RegExp`) so Sonar's static // super-linear-runtime (S5852) check still covers this security-critical pattern; its keyword-alternation // breadth is deliberate (and `apikey` is dropped — `api[_-]?key` already subsumes it). .replace( - /\b[\w-]{0,32}(?:password|passwd|secret|token|api[_-]?key|authorization|access[_-]?key|private[_-]?key|client[_-]?secret)[\w-]{0,16}["']?\s*[=:]\s*(?:(["'])[^\r\n]*?\1|[^\s"',;&]{6,})/gi, + /\b[\w-]{0,32}(?:password|passwd|secret|token|api[_-]?key|authorization|access[_-]?key|private[_-]?key|client[_-]?secret)[\w-]{0,16}["']?\s*[=:]\s*(?:"(?:[^"\\\r\n]|\\[^\r\n])*"|'(?:[^'\\\r\n]|\\[^\r\n])*'|(?=[^\s"',;&]{6})[^\s"',;&]+)/gi, '[redacted]', ) // Well-known standalone credential shapes (OpenAI, Stripe, AWS incl. STS ASIA/ABIA, GitHub token + PAT, @@ -192,19 +168,81 @@ export function redactSecretShapedText(text: string): string { // the tighter `[A-Za-z0-9]` / `[A-Za-z0-9-]` families keep their narrower class (no `_`). .replace( // NOSONAR — the per-regex complexity is the deliberate, documented exception explained above - /\b(?:sk-[A-Za-z0-9]{16,}|sk_(?:live|test)_[A-Za-z0-9]{16,}|A[KSB]IA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_\w{20,}|glpat-[\w-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[\w-]{30,}|ya29\.[\w-]{20,}|hf_[A-Za-z0-9]{20,}|npm_[A-Za-z0-9]{20,}|eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{6,})/g, + /\b(?:sk-(?=[A-Za-z0-9]{16})[A-Za-z0-9]+|sk_(?:live|test)_(?=[A-Za-z0-9]{16})[A-Za-z0-9]+|A[KSB]IA[0-9A-Z]{16}|gh[pousr]_(?=[A-Za-z0-9]{20})[A-Za-z0-9]+|github_pat_(?=\w{20})\w+|glpat-(?=[\w-]{16})[\w-]+|xox[baprs]-(?=[A-Za-z0-9-]{10})[A-Za-z0-9-]+|AIza(?=[\w-]{30})[\w-]+|ya29\.(?=[\w-]{20})[\w-]+|hf_(?=[A-Za-z0-9]{20})[A-Za-z0-9]+|npm_(?=[A-Za-z0-9]{20})[A-Za-z0-9]+|eyJ(?=[\w-]{10})[\w-]+\.(?=[\w-]{10})[\w-]+\.(?=[\w-]{6})[\w-]+)/g, '[redacted]', ) ); } +/** + * Signed-URL/webhook values can be opaque credentials under an unrecognised query key. + * Preserve short values, parameter names and public delimiters/fragments. Once recognized, + * mask the whole value: a fixed-length prefix leaves a permanent credential equality oracle. + * Scan delimiters separately instead of matching a multi-megabyte repeated value in V8's + * RegExp stack. Both cursors advance; short unrecognized values add only bounded overlap. + */ +function redactOpaqueQueryValues(text: string): string { + if (!text.includes('?') && !text.includes('&')) return text; + const prefix = /[?&][\w.-]{1,64}=/g; + const delimiter = /[\s&#"',}\]]/g; + const parts: string[] = []; + let cursor = 0; + for (let match = prefix.exec(text); match !== null; match = prefix.exec(text)) { + const valueStart = prefix.lastIndex; + delimiter.lastIndex = valueStart; + const valueEnd = delimiter.exec(text)?.index ?? text.length; + if (valueEnd - valueStart < 24) continue; + parts.push(text.slice(cursor, valueStart), '[redacted]'); + cursor = valueEnd; + prefix.lastIndex = valueEnd; + } + if (parts.length === 0) return text; + parts.push(text.slice(cursor)); + return parts.join(''); +} + +/** + * One whole authority, with its last `@` separating userinfo from the host. Prefix-only + * matches can mask the first password fragment and leave another `@` plus a credential + * tail in the durable digest. Delimiters stop before paths, queries, fragments or adjacent + * JSON fields; a short colonless vanity handle retains the existing non-secret policy. + */ +function redactUrlUserinfo(text: string): string { + // This necessary delimiter also avoids retrying a scheme prefix across large non-URL values. + if (!text.includes('://')) return text; + const scheme = /[a-z][\w+.-]{0,31}:\/\//gi; + const delimiter = /[/\s?#"',}\]]/g; + const parts: string[] = []; + let cursor = 0; + for (let match = scheme.exec(text); match !== null; match = scheme.exec(text)) { + const authorityStart = scheme.lastIndex; + delimiter.lastIndex = authorityStart; + const end = delimiter.exec(text)?.index ?? text.length; + const authority = text.slice(authorityStart, end); + const at = authority.lastIndexOf('@'); + if (at < 0) continue; + const userinfo = authority.slice(0, at); + const colon = userinfo.indexOf(':'); + const hasPassword = colon >= 0 && colon < userinfo.length - 1; + if (!hasPassword && userinfo.length < 16) continue; + parts.push(text.slice(cursor, authorityStart), '[redacted]'); + cursor = authorityStart + at; + // Do not consume the host while examining it: `host;other://...` must leave the next + // scheme available to this scanner, rather than swallowing `other:` at its first slash. + scheme.lastIndex = cursor + 1; + } + if (parts.length === 0) return text; + parts.push(text.slice(cursor)); + return parts.join(''); +} + /** * Walk an arbitrary value applying {@link redactSecretShapedText} to every string, leaving structure intact — * the toolInput twin of the summary scrub. Applied to the `agent:tool_call.toolInput` event field so a * model-set credential in a header VALUE / body / url-query never rides the observability stream. Object KEYS * are scrubbed with the SAME detector as values (a normal header name — `Authorization`, `X-Trace` — is not * secret-SHAPED, so it passes through unchanged; only a model that placed a live-token-shaped string in a KEY - * position is redacted, closing that leak path too). Cycle-safe; display-only. + * position is redacted, closing that leak path too). Cycle-safe; model/dispatch values stay unchanged. */ export function redactSecretShapedValue(value: unknown): unknown { return redactSecretShapedWalk(value, new WeakSet()); diff --git a/packages/db/src/effect-journal-store.test.ts b/packages/db/src/effect-journal-store.test.ts index 17ed710d..8740be29 100644 --- a/packages/db/src/effect-journal-store.test.ts +++ b/packages/db/src/effect-journal-store.test.ts @@ -341,15 +341,19 @@ describe('the effect journal store', () => { const port = (): ReturnType => createEffectJournalPort(store, RUN, ATTEMPT); - it('refuses even unchanged small legacy args whose unversioned digest cannot prove the original projection', async () => { - const legacy = createHash('sha256').update('{"body":"x","path":"/tmp/a"}').digest('hex'); - store.prepare(ID, RUN, ATTEMPT, 3, legacy); - store.settle(ID, 'committed', { receipt: 'legacy' }); - await expect( - port().prepare(ID.slot, ID.toolId, 3, { path: '/tmp/a', body: 'x' }), - ).rejects.toSatisfy(isEffectConflictError); - expect(store.recordsFor(RUN)).toHaveLength(1); - }); + it.each(['', 'v2:'])( + 'refuses unchanged small legacy args in the %j digest domain', + async (prefix) => { + const legacy = + prefix + createHash('sha256').update('{"body":"x","path":"/tmp/a"}').digest('hex'); + store.prepare(ID, RUN, ATTEMPT, 3, legacy); + store.settle(ID, 'committed', { receipt: 'legacy' }); + await expect( + port().prepare(ID.slot, ID.toolId, 3, { path: '/tmp/a', body: 'x' }), + ).rejects.toSatisfy(isEffectConflictError); + expect(store.recordsFor(RUN)).toHaveLength(1); + }, + ); it('a realistic redacted argument shape digests, and key ORDER cannot change it', async () => { // `canonicalJson` moved to `@relavium/shared` and grew refusals it never had. This is the behaviour diff --git a/packages/db/src/effect-journal-store.ts b/packages/db/src/effect-journal-store.ts index 0809ff94..96f294f2 100644 --- a/packages/db/src/effect-journal-store.ts +++ b/packages/db/src/effect-journal-store.ts @@ -641,7 +641,7 @@ function coerceEffectTier(value: number): EffectTier { * by one package's module-private helper is not a contract a second implementation can be held to. */ function digestOf(redactedArgs: unknown): string { - // Legacy unversioned digests may describe a lossy display preview. Even a current literal - // preview can match one, so every current projection uses a disjoint persisted version domain. - return `v2:${createHash('sha256').update(canonicalJson(redactedArgs)).digest('hex')}`; + // Unversioned digests may describe a lossy display preview; v2 may include a partially redacted + // credential. A current literal can equal either old projection, so neither is a replay authority. + return `v3:${createHash('sha256').update(canonicalJson(redactedArgs)).digest('hex')}`; } From 63d8de412d5ebb3e0f9d1389bb4245198bb9dc28 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 02:44:36 +0300 Subject: [PATCH 175/222] docs(docs): record complete credential projection correction Record the nonclean cumulative library/security review and the author sweep, link the current persisted representation from its canonical home, and state old-row compatibility honestly. New cumulative clean rounds and publication evidence remain required; this does not close Wave 3 or start Steps 9-19. Refs: ADR-0080 Co-Authored-By: Codex --- ...-journal-and-the-tiered-effect-contract.md | 17 +++++++++++++++ docs/reference/shared-core/database-schema.md | 2 +- docs/reference/shared-core/effect-journal.md | 14 ++++++++++--- .../phase-2.5.5-hardening-and-remediation.md | 21 +++++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md index 011959d6..06ceae02 100644 --- a/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md +++ b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md @@ -352,3 +352,20 @@ including unchanged small arguments; no fallback or migration invents an identit permission to repeat an effect. The exact projection and credential-equality limits remain in [effect-journal.md](../reference/shared-core/effect-journal.md#11-secrets-what-a-row-may-hold). This author correction is subject to fresh review and source-pinned validation in PR #95. + +### PR #95 recognized-credential correction — 2026-10-18 + +The next independent review reproduced two incomplete recognized-value masks in the +full journal input: an opaque query value longer than 512 characters and a quoted +JSON password containing an escaped quote. Their remaining suffixes changed physical +SQLite digests despite changing only credentials. The correction consumes each complete +recognized value, preserving subsequent public fields. A new persisted digest domain +refuses older partial projections, including a current literal equal to an old projection; +it does not rewrite old rows or permit a repeated effect. This restores the existing +credential-equality requirement rather than adding a new hashing or secret-storage +decision. The exact current representation and compatibility limits remain in +[effect-journal.md §11](../reference/shared-core/effect-journal.md#11-secrets-what-a-row-may-hold). +An author sweep of the same class also reproduces multi-`@` URL userinfo retaining a +password suffix. One whole-authority projection masks through its last separator, +preserving the host and subsequent public fields. Fresh independent review and +publication validation remain required for the complete correction. diff --git a/docs/reference/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index 046c6480..41721a9c 100644 --- a/docs/reference/shared-core/database-schema.md +++ b/docs/reference/shared-core/database-schema.md @@ -730,7 +730,7 @@ OCCURRENCE, written by a `prepare` **before** an effectful tool dispatch leaves | `tool_id` | TEXT | NOT NULL | | `tier` | INTEGER | NOT NULL — `1` \| `2` \| `3`; everything that ships is `3` | | `state` | TEXT | NOT NULL — `prepared` \| `dispatched` \| `committed` \| `ambiguous` \| `needs_attention` | -| `args_digest` | TEXT | NOT NULL — current `v2:` prefix + SHA-256 over canonical JSON of the full redacted effective args; secret-tainted keys are **removed before hashing**. Unversioned legacy rows refuse matching prepare; see [effect-journal.md §11](effect-journal.md#11-secrets-what-a-row-may-hold). | +| `args_digest` | TEXT | NOT NULL — current `v3:` prefix + SHA-256 over canonical JSON of the full redacted effective args; secret-tainted keys are **removed before hashing**. Unversioned and `v2:` legacy rows refuse matching prepare; see [effect-journal.md §11](effect-journal.md#11-secrets-what-a-row-may-hold). | | `target_idempotency_key` | TEXT | NULL — tier 1 only; what a safe retry reuses verbatim | | `result_json` | TEXT | Always NULL for `session:` scopes, including legacy rows after open-time clearing; RUN scopes retain the bounded result when re-delivery is possible | | `attempt_json` | TEXT | NOT NULL — the audit occurrence (node attempt, provider attempt, tool-call id, owning fence) | diff --git a/docs/reference/shared-core/effect-journal.md b/docs/reference/shared-core/effect-journal.md index 7754adc1..5533b3c0 100644 --- a/docs/reference/shared-core/effect-journal.md +++ b/docs/reference/shared-core/effect-journal.md @@ -351,17 +351,25 @@ unencrypted at rest. "A digest, not the bytes" is therefore not by itself a suff contract does not make it. The hash is SHA-256 over a canonical JSON serialization (sorted keys, no insignificant whitespace) of the redacted projection, using a vetted implementation — never a hand-rolled one. -The current persisted form is `v2:` followed by the SHA-256 hex digest. The version +The current persisted form is `v3:` followed by the SHA-256 hex digest. The version prefix is outside the hash and separates current full-value projections from legacy -unversioned digests; a JSON wrapper alone could itself equal arbitrary old tool args. +unversioned digests and `v2:` projections with incomplete credential masks; a JSON +wrapper alone could itself equal arbitrary old tool args. The digest consumes the full surviving non-secret values, including long string tails. The bounded `toolInput` display preview is not a journal input: equal-length values can -differ beyond its retained prefix. Every unversioned legacy row refuses matching prepare, +differ beyond its retained prefix. Every unversioned or `v2:` legacy row refuses matching prepare, including unchanged small arguments: its original projection cannot be proven from the digest alone. There is no fallback to the old digest or automatic retry permission. Secret and inline-media redaction remain deliberate projection equality; raw credential bytes are never added to the digest to distinguish calls. +Recognized URL userinfo is masked through the authority's last `@`, preserving the host, +path, query and fragment. Recognized opaque URL-query values are masked through their delimiter, including values +longer than 512 characters. Quoted secret-key values honor escaped quotes and backslashes +through the actual closing quote. A recognized value must not leave a credential suffix +in the projection. This remains a finite shape detector, not a claim to identify arbitrary +unlabelled secrets. Existing old digests are not retroactively erased or rewritten. + A **session** row's `result_json` is always SQL NULL; even a supplied result whose serializer throws is never examined. A matching prepare refuses rather than replaying, and session audit readers omit the stored result column entirely; run readers retain replay results. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index a48b5f01..93bf8285 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8441,3 +8441,24 @@ Its unchanged timeout, two 40-write bursts and all row assertions remain. Local validation of these repairs does not establish a Windows or minimum-Node CI result. Two NEW complete cumulative clean rounds, a NEW separate security sitting and fresh corrected-source CI/platform evidence remain required after this nonclean round. + +The next candidate, `060830f6`, has one complete clean cumulative round across +99 corrective paths / 194 hunks. The subsequent library review is **NONCLEAN** +(20 paths / 47 hunks): native registry/HTTP/SQLite probes independently confirm +that long opaque-query and escaped-quote password masks retain credential suffixes +in the new full-value digest input. CLI review is complete clean (43 paths / 93 hunks); +the separate security probe confirms the same journal equality oracle. None of +these reports is whole-PR acceptance, and the clean round cannot certify the repair. +An author sweep also reproduces a partial multi-`@` userinfo mask in this same core +journal path and adds it to the correction and native SQLite regressions. +Admitted-scale probes additionally expose V8 stack exhaustion in open-ended token +repetitions. Bounded minimum lookaheads and simple greedy runs preserve the existing +token floors; delimiter scanners retain whole query values and following public +fragments. Permanent multi-megabyte cases protect the accepted size instead of +lowering admission or truncating a credential. Author removal/restoration controls +cover the value and runtime guards; they do not substitute for independent acceptance. +The correction masks complete recognized values and separates the persisted digest +domain from earlier partial projections. Existing old digests are not rewritten, +and old rows grant neither replay nor retry permission. Two NEW complete cumulative +clean rounds, a NEW separate clean security sitting and corrected-source publication +evidence remain required after this finding. From 1e3029d952d8c15bd96b5b0a4ca0afdb31b12ee8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 03:08:03 +0300 Subject: [PATCH 176/222] fix(core): mask whole structurally identified credentials Give recognized credential fields precedence over partial inner text masks, retaining only canonical known authentication-scheme labels. Apply complete credential projection to both argv spellings and keep port exceptions local to the ambiguous -p flag. Preserve public args and original host dispatch. Separate persisted identity with v4 digests and refuse all older domains without rewriting rows or authorizing retries. Pin helper and actual builtin registry/native SQLite equality, conflict, dispatch and legacy controls. Refs: ADR-0080, PR #95 Co-Authored-By: Codex --- .../engine/tool-alias-journal-digest.test.ts | 268 ++++++++++++++++-- packages/core/src/tools/bounding.test.ts | 76 ++++- packages/core/src/tools/bounding.ts | 65 +++-- packages/db/src/effect-journal-store.test.ts | 2 +- packages/db/src/effect-journal-store.ts | 6 +- 5 files changed, 370 insertions(+), 47 deletions(-) diff --git a/apps/cli/src/engine/tool-alias-journal-digest.test.ts b/apps/cli/src/engine/tool-alias-journal-digest.test.ts index 4b299a98..9afc3e9d 100644 --- a/apps/cli/src/engine/tool-alias-journal-digest.test.ts +++ b/apps/cli/src/engine/tool-alias-journal-digest.test.ts @@ -31,7 +31,7 @@ const EXPECTED_CANONICAL = { }; function sha256(text: string): string { - return `v3:${createHash('sha256').update(text).digest('hex')}`; + return `v4:${createHash('sha256').update(text).digest('hex')}`; } function registryFixture(effects: EffectDispatchPort) { @@ -116,7 +116,7 @@ describe.each(METADATA)( ); expect(rows).toHaveLength(2); expect(rows.map((row) => row.state)).toEqual(['committed', 'committed']); - for (const row of rows) expect(row.args_digest).toMatch(/^v3:[0-9a-f]{64}$/); + for (const row of rows) expect(row.args_digest).toMatch(/^v4:[0-9a-f]{64}$/); expect(rows[0]?.args_digest, 'PHYSICAL_ALIAS_DIGEST_DISTINCT').not.toBe( rows[1]?.args_digest, ); @@ -485,30 +485,260 @@ describe.each(CREDENTIAL_CASES)('PR #95 complete recognized credential: $name', } }); - it('PHYSICAL_PARTIAL_V2_REFUSAL refuses a literal equal to an old partially redacted projection', async () => { + it.each(['v2:', 'v3:'])( + 'PHYSICAL_LEGACY_PROJECTION_REFUSAL refuses a current literal in the %s digest domain', + async (prefix) => { + const client = createClient(':memory:'); + try { + runMigrations(client.db); + const store = createEffectJournalStore(client.db, { + uuid: () => 'partial-v2', + now: () => 6, + }); + const correlation = { kind: 'run', runId: 'r-v2', nodeId: 'n', attempt: 1 } as const; + const body = `${'x'.repeat(20 * 1024)}\n${fixture.oldProjection('A')}`; + const args = { body, method: 'POST', url: 'https://service.invalid/x' }; + const identity = { scope: 'run:r-v2:n', slot: 0, toolId: 'http_request' }; + const legacyDigest = `${prefix}${createHash('sha256').update(JSON.stringify(args)).digest('hex')}`; + store.prepare( + identity, + correlation, + { providerAttempt: 1, toolCallId: 'old' }, + 3, + legacyDigest, + ); + store.settle(identity, 'committed', { receipt: 'old result' }); + const effects = createEffectJournalPort(store, correlation, { + providerAttempt: 1, + toolCallId: 'new', + }); + const attempt = await effects.prepare(0, 'http_request', 3, args).then( + (value) => ({ value }), + (failure: unknown) => ({ failure }), + ); + expect(attempt, 'PHYSICAL_LEGACY_PROJECTION_REFUSAL').toMatchObject({ + failure: { name: 'EffectConflictError' }, + }); + expect(store.recordsFor(correlation)).toHaveLength(1); + } finally { + client.sqlite.close(); + } + }, + ); +}); + +const STRUCTURED_CREDENTIAL_CASES = [ + { + name: 'X-Api-Key with a recognized token prefix', + key: 'X-Api-Key', + value: (tail: string) => `sk-${'Q'.repeat(16)};${tail}`, + projected: '[redacted]', + }, + { + name: 'a recognized prefix beyond the display boundary', + key: 'X-Api-Key', + value: (tail: string) => `${'x'.repeat(20 * 1024)};sk-${'Q'.repeat(16)};${tail}`, + projected: '[redacted]', + }, + { + name: 'an embedded scheme is not a public header scheme', + key: 'X-Auth-Token', + value: (tail: string) => `ordinary;Bearer ${'Q'.repeat(16)};${tail}`, + projected: '[redacted]', + }, + { + name: 'escaped quoted text inside a credential header', + key: 'X-Client-Secret', + value: (tail: string) => `${JSON.stringify({ password: 'inner"secret' })};${tail}`, + projected: '[redacted]', + }, + { + name: 'Bearer retains only its canonical scheme', + key: 'Authorization', + value: (tail: string) => `bEaReR ${'Q'.repeat(16)};${tail}`, + projected: 'Bearer [redacted]', + }, + { + name: 'Basic retains only its canonical scheme', + key: 'Proxy-Authorization', + value: (tail: string) => `bAsIc ${'Q'.repeat(16)};${tail}`, + projected: 'Basic [redacted]', + }, + { + name: 'Token retains only its canonical scheme', + key: 'X-Auth-Token', + value: (tail: string) => `tOkEn ${'Q'.repeat(16)};${tail}`, + projected: 'Token [redacted]', + }, +]; + +describe.each(STRUCTURED_CREDENTIAL_CASES)('PR #95 structural credential: $name', (fixture) => { + it('PHYSICAL_STRUCTURAL_SECRET replays credential-only changes and distinguishes public content', async () => { const client = createClient(':memory:'); try { runMigrations(client.db); - const store = createEffectJournalStore(client.db, { uuid: () => 'partial-v2', now: () => 6 }); - const correlation = { kind: 'run', runId: 'r-v2', nodeId: 'n', attempt: 1 } as const; - const body = `${'x'.repeat(20 * 1024)}\n${fixture.oldProjection('A')}`; - const args = { body, method: 'POST', url: 'https://service.invalid/x' }; - const identity = { scope: 'run:r-v2:n', slot: 0, toolId: 'http_request' }; - const v2 = `v2:${createHash('sha256').update(JSON.stringify(args)).digest('hex')}`; - store.prepare(identity, correlation, { providerAttempt: 1, toolCallId: 'old' }, 3, v2); - store.settle(identity, 'committed', { receipt: 'old result' }); - const effects = createEffectJournalPort(store, correlation, { - providerAttempt: 1, - toolCallId: 'new', + let ids = 0; + const store = createEffectJournalStore(client.db, { + uuid: () => `structured-${++ids}`, + now: () => 6, }); - const attempt = await effects.prepare(0, 'http_request', 3, args).then( - (value) => ({ value }), + const effects = recordingPort( + createEffectJournalPort( + store, + { kind: 'run', runId: 'r-structured', nodeId: 'n', attempt: 1 }, + { providerAttempt: 1, toolCallId: 'structured-call' }, + ), + ); + const fetch = vi.fn['fetch']>(() => + Promise.resolve({ status: 200, headers: {}, body: 'receipt' }), + ); + const registry = createToolRegistry({ tools: BUILTIN_TOOLS, host: { egress: { fetch } } }); + const context: ToolDispatchContext = { + nodeId: 'n', + grantedToolIds: new Set(['http_request']), + config: {}, + toolPolicy: { allowedDomains: ['service.invalid'] }, + fsScope: 'sandboxed', + gateApproved: false, + effects, + effectSlot: 0, + }; + const prefix = 'public content '.repeat(1600); + const args = (suffix: string, publicTail = 'first') => ({ + body: `${prefix}${publicTail}`, + headers: { [fixture.key]: fixture.value(`${'x'.repeat(20 * 1024)}${suffix}`) }, + method: 'POST', + url: 'https://service.invalid/x', + }); + const request = (suffix: string, publicTail = 'first') => + ({ + type: 'tool_call', + id: 'structured-call', + name: 'http_request', + args: args(suffix, publicTail), + }) as const; + const first = await registry.dispatch(request('A'), context); + const replay = await registry.dispatch(request('B'), context).then( + (result) => ({ output: result.output }), (failure: unknown) => ({ failure }), ); - expect(attempt, 'PHYSICAL_PARTIAL_V2_REFUSAL').toMatchObject({ - failure: { name: 'EffectConflictError' }, + expect(replay, 'PHYSICAL_STRUCTURAL_SECRET_REPLAY').toEqual({ output: first.output }); + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch.mock.calls[0]?.[0]).toEqual(request('A').args); + await registry.dispatch(request('B'), { ...context, effectSlot: 1 }); + expect(fetch.mock.calls[1]?.[0]).toEqual(request('B').args); + const projected = { + body: `${prefix}first`, + headers: { [fixture.key]: fixture.projected }, + method: 'POST', + url: 'https://service.invalid/x', + }; + // Independently authored sorted-key bytes; the production projection is not its own oracle. + const expected = sha256(JSON.stringify(projected)); + const rows = PhysicalRowsSchema.parse( + client.sqlite + .prepare('SELECT slot, state, args_digest FROM run_effects ORDER BY slot') + .all(), + ); + expect(rows, 'PHYSICAL_STRUCTURAL_SECRET_DIGEST').toEqual([ + { slot: 0, state: 'committed', args_digest: expected }, + { slot: 1, state: 'committed', args_digest: expected }, + ]); + for (const callArgs of effects.prepare.mock.calls) + expect(callArgs[3], 'PHYSICAL_STRUCTURAL_SECRET_PROJECTION').toEqual(projected); + await expect( + registry.dispatch(request('B', 'other'), context), + 'PHYSICAL_STRUCTURAL_PUBLIC_TAIL', + ).rejects.toMatchObject({ name: 'ToolEffectConflictError', retryable: false }); + expect(fetch).toHaveBeenCalledTimes(2); + } finally { + client.sqlite.close(); + } + }); +}); + +describe.each(['separate', 'inline', 'numeric'] as const)('PR #95 credential argv: %s', (mode) => { + it('PHYSICAL_ARGV_SECRET keeps only public argument differences in the native journal', async () => { + const client = createClient(':memory:'); + try { + runMigrations(client.db); + let ids = 0; + const store = createEffectJournalStore(client.db, { + uuid: () => `argv-${++ids}`, + now: () => 6, }); - expect(store.recordsFor(correlation)).toHaveLength(1); + const effects = recordingPort( + createEffectJournalPort( + store, + { kind: 'run', runId: 'r-argv', nodeId: 'n', attempt: 1 }, + { providerAttempt: 1, toolCallId: 'argv-call' }, + ), + ); + const spawn = vi.fn['spawn']>(() => + Promise.resolve({ exitCode: 0, stdout: 'receipt', stderr: '', durationMs: 0 }), + ); + const registry = createToolRegistry({ tools: BUILTIN_TOOLS, host: { process: { spawn } } }); + const argv = (suffix: string, count = 'first') => { + const credential = + mode === 'numeric' ? `123${suffix}` : `sk-${'Q'.repeat(16)};${'x'.repeat(1000)}${suffix}`; + return mode === 'inline' + ? [`--token=${credential}`, `--count=${count}`] + : ['--token', credential, `--count=${count}`]; + }; + const context: ToolDispatchContext = { + nodeId: 'n', + grantedToolIds: new Set(['run_command']), + config: {}, + toolPolicy: { + allowedCommands: [argv('4'), argv('5'), argv('5', 'other')].map((args) => + ['synthetic-publish', ...args].join(' '), + ), + }, + fsScope: 'sandboxed', + gateApproved: false, + effects, + effectSlot: 0, + }; + const request = (suffix: string, count = 'first') => + ({ + type: 'tool_call', + id: 'argv-call', + name: 'run_command', + args: { command: 'synthetic-publish', args: argv(suffix, count) }, + }) as const; + const first = await registry.dispatch(request('4'), context); + const replay = await registry.dispatch(request('5'), context).then( + (result) => ({ output: result.output }), + (failure: unknown) => ({ failure }), + ); + expect(replay, 'PHYSICAL_ARGV_SECRET_REPLAY').toEqual({ output: first.output }); + expect(spawn).toHaveBeenCalledOnce(); + expect(spawn.mock.calls[0]?.slice(0, 2)).toEqual(['synthetic-publish', argv('4')]); + await registry.dispatch(request('5'), { ...context, effectSlot: 1 }); + expect(spawn.mock.calls[1]?.slice(0, 2)).toEqual(['synthetic-publish', argv('5')]); + const projected = { + args: + mode === 'inline' + ? ['--token=[redacted]', '--count=first'] + : ['--token', '[redacted]', '--count=first'], + command: 'synthetic-publish', + }; + const expected = sha256(JSON.stringify(projected)); + const rows = PhysicalRowsSchema.parse( + client.sqlite + .prepare('SELECT slot, state, args_digest FROM run_effects ORDER BY slot') + .all(), + ); + expect(rows, 'PHYSICAL_ARGV_SECRET_DIGEST').toEqual([ + { slot: 0, state: 'committed', args_digest: expected }, + { slot: 1, state: 'committed', args_digest: expected }, + ]); + for (const callArgs of effects.prepare.mock.calls) expect(callArgs[3]).toEqual(projected); + await expect( + registry.dispatch(request('5', 'other'), context), + 'PHYSICAL_ARGV_PUBLIC_TAIL', + ).rejects.toMatchObject({ name: 'ToolEffectConflictError', retryable: false }); + expect(spawn).toHaveBeenCalledTimes(2); } finally { client.sqlite.close(); } diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index 3a7307eb..fa60a27d 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -730,6 +730,28 @@ describe('redactSecretShapedValue — the KEY decides, whatever the value looks }); }); + it.each(['--token', '--password', '--api-key', '--client-secret'])( + 'STRUCTURAL_ARGV_WHOLE treats %s as credential authority for both argument spellings', + (flag) => { + const value = `sk-${'Q'.repeat(16)};${'x'.repeat(20 * 1024)}SYNTHETIC_TAIL`; + expect(redactSecretShapedValue({ args: [flag, value, '--count=4'] })).toEqual({ + args: [flag, '[redacted]', '--count=4'], + }); + expect(redactSecretShapedValue({ args: [`${flag}=${value}`, '--count=4'] })).toEqual({ + args: [`${flag}=[redacted]`, '--count=4'], + }); + expect(redactSecretShapedValue({ args: [flag, '1234', `${flag}=1234`] })).toEqual({ + args: [flag, '[redacted]', `${flag}=[redacted]`], + }); + }, + ); + + it('STRUCTURAL_ARGV_PORT preserves the ambiguous -p port exception without widening it', () => { + expect(redactSecretShapedValue({ args: ['-p=8080:80', '-p', '2222', '--port=3333'] })).toEqual({ + args: ['-p=8080:80', '-p', '2222', '--port=3333'], + }); + }); + it('leaves ordinary structured args alone — the negative control', () => { // Without this the rule above is satisfied by redacting everything, which would strip the stored row of // the diagnostic value that is its whole remaining purpose. @@ -745,14 +767,62 @@ describe('redactSecretShapedValue — the KEY decides, whatever the value looks expect(redactSecretShapedValue(benign)).toEqual(benign); }); - it('keeps the auth SCHEME when the shape scrub already fired', () => { - // `Bearer` is not the secret, and which scheme a call used is exactly what a stored row is for. The - // wholesale key redaction is the fallback for what the shape scrub structurally cannot see. + it('keeps only the bounded auth scheme under a recognized credential key', () => { + // The scheme is public diagnostic information; a partial inner match cannot prove that the rest + // of a structurally identified credential is public. expect(redactSecretShapedValue({ Authorization: 'Bearer tok_abcdef123456' })).toEqual({ Authorization: 'Bearer [redacted]', }); }); + it.each(['X-Api-Key', 'password', 'authToken', 'client_secret', 'sessionKey', 'credentials'])( + 'STRUCTURAL_SECRET_WHOLE masks the entire %s value despite an inner shape match', + (key) => { + for (const prefix of ['', 'x'.repeat(20 * 1024)]) { + for (const recognized of [ + `sk-${'Q'.repeat(16)}`, + 'Bearer QQQQQQQQQQQQQQQQ', + 'password="synthetic inner secret"', + 'https://user:synthetic-password@service.invalid', + ]) { + const value = `${prefix}ordinary:${recognized};${'x'.repeat(20 * 1024)}SYNTHETIC_TAIL`; + expect(redactSecretShapedValue({ [key]: value }), 'STRUCTURAL_SECRET_WHOLE').toEqual({ + [key]: '[redacted]', + }); + } + } + }, + ); + + it.each([ + ['bEaReR', 'Bearer'], + ['bAsIc', 'Basic'], + ['tOkEn', 'Token'], + ])('STRUCTURAL_SECRET_SCHEME retains only canonical %s diagnostics', (input, canonical) => { + const value = `${input}\tQQQQQQQQQQQQQQQQ;${'x'.repeat(20 * 1024)}SYNTHETIC_TAIL`; + expect(redactSecretShapedValue({ Authorization: value })).toEqual({ + Authorization: `${canonical} [redacted]`, + }); + }); + + it('STRUCTURAL_SECRET_OPAQUE does not inspect a recognized secret container', () => { + const inspect = vi.fn(() => { + throw new Error('secret contents are outside the projection'); + }); + const nested = Object.defineProperty({}, 'value', { enumerable: true, get: inspect }); + expect( + () => redactSecretShapedValue({ credentials: nested }), + 'STRUCTURAL_SECRET_OPAQUE', + ).not.toThrow(); + expect(redactSecretShapedValue({ credentials: nested })).toEqual({ credentials: '[redacted]' }); + expect(inspect).not.toHaveBeenCalled(); + expect(redactSecretShapedValue({ password: null, token: undefined, pin: 123456 })).toEqual({ + password: null, + token: undefined, + pin: '[redacted]', + }); + }); + it('redacts a long opaque value in a URL query under an unrecognised parameter name', () => { // A pre-signed URL or a webhook `?t=`: the parameter name means nothing to the keyword rule, and // the value has no well-known prefix. The NAME is kept — it is the diagnostic half. diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 6296264f..c320c1aa 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -331,29 +331,43 @@ const KEY_QUALIFIERS: ReadonlySet = new Set([ */ function redactArgVector(items: readonly unknown[], seen: WeakSet): unknown[] { const out: unknown[] = []; - let redactNext = false; + let redactNext: string | undefined; for (const item of items) { - if (redactNext && typeof item === 'string') { - // A port is not a credential, however password-shaped its flag was. - out.push(looksLikePort(item) ? redactSecretShapedWalk(item, seen) : '[redacted]'); - redactNext = false; + if (redactNext !== undefined) { + // Only the ambiguous -p flag has the documented port interpretation. --password/--token do not. + out.push( + typeof item === 'string' && redactNext.toLowerCase() === '-p' && looksLikePort(item) + ? redactSecretShapedWalk(item, seen) + : '[redacted]', + ); + redactNext = undefined; continue; } - redactNext = typeof item === 'string' && isSecretishFlag(item); + if (typeof item === 'string') { + const separator = item.indexOf('='); + const flag = separator > 0 ? item.slice(0, separator) : ''; + if (isSecretishFlag(flag)) { + const value = item.slice(separator + 1); + out.push(flag.toLowerCase() === '-p' && looksLikePort(value) ? item : `${flag}=[redacted]`); + continue; + } + if (isSecretishFlag(item)) redactNext = item; + } out.push(redactSecretShapedWalk(item, seen)); } return out; } /** - * A CLI flag that introduces a credential — `--token`, `--password`, `-p`. (`--password=…` is the text - * rule's job, since flag and value are one string there.) + * A CLI flag that introduces a credential — `--token`, `--password`, `-p`. * * `-p` is deliberately kept AND deliberately qualified. It really is the password flag for `mysql` and * `psql`, and dropping it would leak those verbatim — but it is also the port flag for `ssh` and the * port-mapping flag for `docker run`, and a review caught this destroying `ssh -p 2222` and - * `docker run -p 8080:80`. The value shape settles it: a port is digits, optionally `host:container`, and a - * password never is. See {@link looksLikePort}. + * `docker run -p 8080:80`. A port is digits, optionally `host:container`. Only the + * `-p` ambiguity retains that exception; explicitly named credential flags do not. Both separate and + * `--password=…` argument spellings use the entire value, rather than a partial text-pattern match. + * See {@link looksLikePort}. */ /** * The flag names, as one anchored alternation. @@ -369,7 +383,7 @@ function isSecretishFlag(item: string): boolean { return SECRETISH_FLAG.test(item); } -/** `2222`, `8080:80`, `127.0.0.1:8080:80` — a port or a port mapping, never a credential. */ +/** Port-shaped values disambiguate `-p`; an explicit credential flag has no port exception. */ function looksLikePort(value: string): boolean { return /^(?:[\d.]{1,15}:)?\d{1,5}(?::\d{1,5})?(?:\/(?:tcp|udp))?$/i.test(value); } @@ -377,17 +391,26 @@ function looksLikePort(value: string): boolean { /** * One member of an object, redacted with its KEY taken into account. * - * The shape scrub runs first and WINS when it fires, so `{"Authorization": "Bearer tok…"}` keeps its - * diagnostic scheme (`Bearer [redacted]`) rather than collapsing to a bare `[redacted]` — the scheme is not - * the secret, and knowing which auth scheme a call used is exactly the sort of thing a stored row is for. - * The wholesale redaction is the FALLBACK, for the case the shape scrub structurally cannot see: an opaque - * value with no recognisable prefix, a nested object, an array, or a number. + * A recognized credential key owns the WHOLE value. An inner shape match does not establish that the + * remaining text is public. Do not traverse a credential container or carry any partially scrubbed value + * into the journal. Only an anchored, known auth scheme may survive as a fixed diagnostic label. */ function redactUnderKey(key: string, item: unknown, seen: WeakSet): unknown { - const walked = redactSecretShapedWalk(item, seen); - if (!isSecretishKey(key) || item === null || item === undefined) return walked; - // The shape scrub already fired on this string — keep its more informative output. - if (typeof item === 'string' && walked !== item) return walked; + if (!isSecretishKey(key) || item === null || item === undefined) + return redactSecretShapedWalk(item, seen); + if (typeof item === 'string') { + // Match only the bounded label and the start of a nonempty remainder, never return that remainder. + // Canonical labels also keep caller-controlled casing out of the credential projection. + const scheme = /^(bearer|basic|token)[ \t]+\S/i.exec(item)?.[1]?.toLowerCase(); + switch (scheme) { + case 'bearer': + return 'Bearer [redacted]'; + case 'basic': + return 'Basic [redacted]'; + case 'token': + return 'Token [redacted]'; + } + } return '[redacted]'; } @@ -401,7 +424,7 @@ function redactSecretShapedWalk(value: unknown, seen: WeakSet): unknown if (!isPlainObject(value)) return value; // Date/RegExp/Map/… — leave for native handling const out: Record = {}; // Scrub the KEY too (a secret-shaped key is redacted; a normal name is unchanged — see the doc above). A - // display-only field, so a rare collision of two keys both redacting to `[redacted]` losing one is acceptable. + // Recognized credentials deliberately share a redacted projection; raw invocation keys stay unchanged. for (const [key, item] of Object.entries(value)) { // **The key decides, whatever the value's shape.** Checked BEFORE the walk, because the walk only // inspects strings by shape — so a numeric PIN, a nested credential object, or a high-entropy opaque diff --git a/packages/db/src/effect-journal-store.test.ts b/packages/db/src/effect-journal-store.test.ts index 8740be29..df63b77d 100644 --- a/packages/db/src/effect-journal-store.test.ts +++ b/packages/db/src/effect-journal-store.test.ts @@ -341,7 +341,7 @@ describe('the effect journal store', () => { const port = (): ReturnType => createEffectJournalPort(store, RUN, ATTEMPT); - it.each(['', 'v2:'])( + it.each(['', 'v2:', 'v3:'])( 'refuses unchanged small legacy args in the %j digest domain', async (prefix) => { const legacy = diff --git a/packages/db/src/effect-journal-store.ts b/packages/db/src/effect-journal-store.ts index 96f294f2..7249182d 100644 --- a/packages/db/src/effect-journal-store.ts +++ b/packages/db/src/effect-journal-store.ts @@ -641,7 +641,7 @@ function coerceEffectTier(value: number): EffectTier { * by one package's module-private helper is not a contract a second implementation can be held to. */ function digestOf(redactedArgs: unknown): string { - // Unversioned digests may describe a lossy display preview; v2 may include a partially redacted - // credential. A current literal can equal either old projection, so neither is a replay authority. - return `v3:${createHash('sha256').update(canonicalJson(redactedArgs)).digest('hex')}`; + // Unversioned digests may describe a lossy display preview; v2/v3 may include a partially redacted + // credential. A current literal can equal an old projection, so none is a replay authority. + return `v4:${createHash('sha256').update(canonicalJson(redactedArgs)).digest('hex')}`; } From 8d1e931c27ec7bd86f051128937d07246b49e95a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 03:08:26 +0300 Subject: [PATCH 177/222] docs(docs): record structural credential projection correction Document current projection and legacy-domain refusal in their canonical home. Preserve the clean general review and the separate interrupted security finding with exact attribution. Fresh complete independent reviews and corrected-source publication remain required; Steps 9-19 stay unstarted. Refs: ADR-0080, PR #95 Co-Authored-By: Codex --- ...-journal-and-the-tiered-effect-contract.md | 16 +++++++++ docs/reference/shared-core/database-schema.md | 2 +- docs/reference/shared-core/effect-journal.md | 16 +++++++-- .../phase-2.5.5-hardening-and-remediation.md | 36 +++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md index 06ceae02..898cf478 100644 --- a/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md +++ b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md @@ -369,3 +369,19 @@ An author sweep of the same class also reproduces multi-`@` URL userinfo retaini password suffix. One whole-authority projection masks through its last separator, preserving the host and subsequent public fields. Fresh independent review and publication validation remain required for the complete correction. + +### PR #95 structural-credential correction — 2026-10-18 + +A separate security review at `63d8de41` confirms that a recognized credential +field could retain the rest of its value when an inner text pattern matched. +The structural field now decides before traversal; only a bounded, canonical +authentication-scheme label may survive. The same whole-value rule applies to +credential flags in both argument-vector spellings. The ambiguous `-p` port +exception does not classify explicitly named credential flags as public ports. +Native registry/store regressions retain original dispatch bytes while checking +credential equality and public-argument distinction. Prior digest domains remain +refused without rewriting history or granting retry authority. This restores the +existing §11 requirement; current details remain in its +[canonical contract](../reference/shared-core/effect-journal.md#11-secrets-what-a-row-may-hold). +Fresh independent reviews, a complete separate security sitting and corrected-source +publication checks are still required. diff --git a/docs/reference/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index 41721a9c..92b37357 100644 --- a/docs/reference/shared-core/database-schema.md +++ b/docs/reference/shared-core/database-schema.md @@ -730,7 +730,7 @@ OCCURRENCE, written by a `prepare` **before** an effectful tool dispatch leaves | `tool_id` | TEXT | NOT NULL | | `tier` | INTEGER | NOT NULL — `1` \| `2` \| `3`; everything that ships is `3` | | `state` | TEXT | NOT NULL — `prepared` \| `dispatched` \| `committed` \| `ambiguous` \| `needs_attention` | -| `args_digest` | TEXT | NOT NULL — current `v3:` prefix + SHA-256 over canonical JSON of the full redacted effective args; secret-tainted keys are **removed before hashing**. Unversioned and `v2:` legacy rows refuse matching prepare; see [effect-journal.md §11](effect-journal.md#11-secrets-what-a-row-may-hold). | +| `args_digest` | TEXT | NOT NULL — current `v4:` prefix + SHA-256 over canonical JSON of the full redacted effective args; secret-tainted keys are **removed before hashing**. Unversioned, `v2:` and `v3:` legacy rows refuse matching prepare; see [effect-journal.md §11](effect-journal.md#11-secrets-what-a-row-may-hold). | | `target_idempotency_key` | TEXT | NULL — tier 1 only; what a safe retry reuses verbatim | | `result_json` | TEXT | Always NULL for `session:` scopes, including legacy rows after open-time clearing; RUN scopes retain the bounded result when re-delivery is possible | | `attempt_json` | TEXT | NOT NULL — the audit occurrence (node attempt, provider attempt, tool-call id, owning fence) | diff --git a/docs/reference/shared-core/effect-journal.md b/docs/reference/shared-core/effect-journal.md index 5533b3c0..6428a78b 100644 --- a/docs/reference/shared-core/effect-journal.md +++ b/docs/reference/shared-core/effect-journal.md @@ -351,13 +351,13 @@ unencrypted at rest. "A digest, not the bytes" is therefore not by itself a suff contract does not make it. The hash is SHA-256 over a canonical JSON serialization (sorted keys, no insignificant whitespace) of the redacted projection, using a vetted implementation — never a hand-rolled one. -The current persisted form is `v3:` followed by the SHA-256 hex digest. The version +The current persisted form is `v4:` followed by the SHA-256 hex digest. The version prefix is outside the hash and separates current full-value projections from legacy -unversioned digests and `v2:` projections with incomplete credential masks; a JSON +unversioned digests and `v2:`/`v3:` projections with incomplete credential masks; a JSON wrapper alone could itself equal arbitrary old tool args. The digest consumes the full surviving non-secret values, including long string tails. The bounded `toolInput` display preview is not a journal input: equal-length values can -differ beyond its retained prefix. Every unversioned or `v2:` legacy row refuses matching prepare, +differ beyond its retained prefix. Every unversioned, `v2:` or `v3:` legacy row refuses matching prepare, including unchanged small arguments: its original projection cannot be proven from the digest alone. There is no fallback to the old digest or automatic retry permission. Secret and inline-media redaction remain deliberate projection equality; raw credential bytes are never @@ -370,6 +370,16 @@ through the actual closing quote. A recognized value must not leave a credential in the projection. This remains a finite shape detector, not a claim to identify arbitrary unlabelled secrets. Existing old digests are not retroactively erased or rewritten. +A recognized structural credential key masks its entire value before traversal. A partial +inner text match does not make the remaining value public. Only an anchored Bearer, Basic +or Token scheme survives as its canonical label followed by `[redacted]`; no original +credential remainder or casing is retained. Credential flags in an argument vector mask +the whole following element or the complete value in `--token=value` form. Only the +ambiguous `-p` spelling retains the existing port-shaped exception; explicitly named +credential flags have none. Unrecognized public fields and ordinary arguments still +contribute their complete surviving values to identity. These projections do not change +the arguments dispatched to the host. + A **session** row's `result_json` is always SQL NULL; even a supplied result whose serializer throws is never examined. A matching prepare refuses rather than replaying, and session audit readers omit the stored result column entirely; run readers retain replay results. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 93bf8285..5a5431da 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8462,3 +8462,39 @@ domain from earlier partial projections. Existing old digests are not rewritten, and old rows grant neither replay nor retry permission. Two NEW complete cumulative clean rounds, a NEW separate clean security sitting and corrected-source publication evidence remain required after this finding. + +### PR #95 structural-credential correction — 2026-10-18 + +The complete cumulative general round at `63d8de41` is clean across all **99 +corrective paths / 199 ordered default-context hunks**: library 20/52, CLI 43/93, +tooling/docs 36/54. The separate new security sitting is **INCOMPLETE NONCLEAN**: +it confirms a structural credential-field masking defect before the runner stops. +Its incomplete coverage flags remain incomplete. A clean general round does not +overrule that finding, certify the repair or supply separate security acceptance. +The author independently reproduces the defect through the real HTTP registry +and native SQLite store using synthetic values only. + +The repair gives a recognized credential key authority over its whole value, +instead of retaining a partly scrubbed string. Only a fixed canonical auth-scheme +label survives; credential containers are not traversed. The author same-class +check also covers inline credential arguments and numeric credential flags, +retaining the ambiguous `-p` port exception. These changes affect the redacted +projection, not raw host dispatch. Prior digest domains refuse matching prepare; +no history rewrite, automatic retry or new effect authority is introduced. Exact +representation and compatibility remain in the +[effect-journal contract](../../reference/shared-core/effect-journal.md#11-secrets-what-a-row-may-hold). + +Permanent helper and actual built-in HTTP/command registry-to-SQLite cases check +credential-only replay, independently authored canonical hashes, public-argument +refusal and unchanged dispatch. The original structural helper/native cases fail +before the repair. Author verification of code `1e3029d9` passes **181 focused +cases**, with three removal controls producing **27 specific assertion failures** +and byte-exact restoration followed by passing restored files. Workspace lint/ +typecheck is 17/17 (14 cache hits); serialized tasks are 11/11 (seven cache hits), +retaining **748 files / 15,860 passing tests / 21 established skips**. Build is +6/6 (five cache hits), and all **599 acceptance-tool cases** pass without skips. +Cached results are not claimed rerun. This is author evidence; two NEW complete +cumulative clean rounds, a NEW complete clean security sitting and fresh corrected-source +CI/coverage/Sonar/platform evidence remain required. The successful `63d8de41` +same-tarball consumers and Sonar analysis retain their earlier source attribution. +Steps 9–19 remain unstarted and outside PR #95. From a9689fa1d9caaaa97ac19458837d1e75cd265c2c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 03:20:46 +0300 Subject: [PATCH 178/222] refactor(core): share credential argument value projection Keep the argv state walk focused on separate versus inline spelling. Use one private value rule for complete credential masks and the existing -p port exception, preserving redacted bytes and the v4 digest domain. Refs: ADR-0080, PR #95 Co-Authored-By: Codex --- packages/core/src/tools/bounding.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index c320c1aa..b8153e0b 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -334,12 +334,7 @@ function redactArgVector(items: readonly unknown[], seen: WeakSet): unkn let redactNext: string | undefined; for (const item of items) { if (redactNext !== undefined) { - // Only the ambiguous -p flag has the documented port interpretation. --password/--token do not. - out.push( - typeof item === 'string' && redactNext.toLowerCase() === '-p' && looksLikePort(item) - ? redactSecretShapedWalk(item, seen) - : '[redacted]', - ); + out.push(redactCredentialArgument(redactNext, item)); redactNext = undefined; continue; } @@ -348,7 +343,7 @@ function redactArgVector(items: readonly unknown[], seen: WeakSet): unkn const flag = separator > 0 ? item.slice(0, separator) : ''; if (isSecretishFlag(flag)) { const value = item.slice(separator + 1); - out.push(flag.toLowerCase() === '-p' && looksLikePort(value) ? item : `${flag}=[redacted]`); + out.push(`${flag}=${redactCredentialArgument(flag, value)}`); continue; } if (isSecretishFlag(item)) redactNext = item; @@ -358,6 +353,14 @@ function redactArgVector(items: readonly unknown[], seen: WeakSet): unkn return out; } +/** One credential-value rule for both argv spellings; only ambiguous -p may name a public port. */ +function redactCredentialArgument(flag: string, value: unknown): string { + if (flag.toLowerCase() === '-p' && typeof value === 'string' && looksLikePort(value)) { + return value; + } + return '[redacted]'; +} + /** * A CLI flag that introduces a credential — `--token`, `--password`, `-p`. * From c837cd410d0da51022ded1b5ae37d5e959dcf6b6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 03:20:46 +0300 Subject: [PATCH 179/222] docs(docs): record source scoped review and sonar cleanup Retain R5 clean cumulative review attribution at 8d1e931c and distinguish the subsequent behavior-preserving Sonar maintainability refactor. Fresh reviews and separate publication gates remain explicit; no later wave work is started. Refs: ADR-0080, PR #95 Co-Authored-By: Codex --- .../phase-2.5.5-hardening-and-remediation.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 5a5431da..11daba5c 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8498,3 +8498,21 @@ cumulative clean rounds, a NEW complete clean security sitting and fresh correct CI/coverage/Sonar/platform evidence remain required. The successful `63d8de41` same-tarball consumers and Sonar analysis retain their earlier source attribution. Steps 9–19 remain unstarted and outside PR #95. + +The subsequent general R5 at `8d1e931c` is **COMPLETE CLEAN** for the same +99 corrective paths / **205** ordered hunks (library 20/58, CLI 43/93, +tooling/docs 36/54). Independent execution includes 661 library tests, +562 CLI tests, and 599 tooling plus 157 run/gate cases; overlapping suites are +not added into a new unique total. Historical document bodies receive identity, +structure/link/status checks and full semantic review of changed/current clauses, +not a new historical whole-wave certification. Separate security and fresh +publication gates remain distinct. + +Sonar analysis at that source passes its quality gate but identifies a new +maintainability recommendation in the argument-vector projector. The duplicate +credential-value/port decision is extracted into one private helper shared by +both argv spellings. This preserves the existing redacted bytes and `v4:` domain; +it introduces no admission, dispatch, compatibility or policy change. The later +source requires two NEW complete cumulative clean rounds and a complete separate +defensive security code review. Neither the clean R5 nor earlier remote results +are relabeled as execution or acceptance of that refactor. From c09f1924fea67c5c8a207df7f764c48815d81d5c Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 03:33:47 +0300 Subject: [PATCH 180/222] fix(core): preserve own public data in effect identity Define own data members in journal and display projections instead of invoking inherited setters. Keep raw dispatch and credential exclusions unchanged. Separate earlier ambiguous native digest domains and pin the built-in MCP registry-to-SQLite behavior with public-data regressions. Five author removal controls produce 79 specific assertion failures; byte-exact restoration passes all 201 focused cases. Record the independent nonclean findings without inheriting earlier source acceptance. Refs: ADR-0080, PR #95 Co-Authored-By: Codex --- .../engine/tool-alias-journal-digest.test.ts | 97 ++++++++++++++++++- ...-journal-and-the-tiered-effect-contract.md | 14 +++ docs/reference/shared-core/database-schema.md | 2 +- docs/reference/shared-core/effect-journal.md | 18 ++-- .../phase-2.5.5-hardening-and-remediation.md | 38 ++++++++ packages/core/src/tools/bounding.test.ts | 25 +++++ packages/core/src/tools/bounding.ts | 15 ++- .../tools/registry-alias-projection.test.ts | 31 ++++++ packages/core/src/tools/registry.ts | 16 ++- packages/db/src/effect-journal-store.test.ts | 2 +- packages/db/src/effect-journal-store.ts | 5 +- 11 files changed, 245 insertions(+), 18 deletions(-) diff --git a/apps/cli/src/engine/tool-alias-journal-digest.test.ts b/apps/cli/src/engine/tool-alias-journal-digest.test.ts index 9afc3e9d..e2103eea 100644 --- a/apps/cli/src/engine/tool-alias-journal-digest.test.ts +++ b/apps/cli/src/engine/tool-alias-journal-digest.test.ts @@ -31,7 +31,7 @@ const EXPECTED_CANONICAL = { }; function sha256(text: string): string { - return `v4:${createHash('sha256').update(text).digest('hex')}`; + return `v5:${createHash('sha256').update(text).digest('hex')}`; } function registryFixture(effects: EffectDispatchPort) { @@ -84,6 +84,97 @@ const METADATA = [ { name: 'nonempty', keys: new Set(['confidentialNote']) }, ]; +describe.each(METADATA)('native MCP own public data with $name metadata', ({ keys }) => { + it.each(['scalar', 'record'] as const)( + 'PHYSICAL_OWN_DATA retains %s data in the digest, replays equality and refuses a changed value', + async (mode) => { + const client = createClient(':memory:'); + try { + runMigrations(client.db); + let ids = 0; + const store = createEffectJournalStore(client.db, { + uuid: () => `own-${++ids}`, + now: () => 6, + }); + const effects = recordingPort( + createEffectJournalPort( + store, + { kind: 'run', runId: 'r-own', nodeId: 'n', attempt: 1 }, + { providerAttempt: 1, toolCallId: 'own-call' }, + ), + ); + const received: unknown[] = []; + const callMcp = vi.fn((input: Parameters['call']>[0]) => { + received.push(input.args); + return Promise.resolve({ receipt: 'published' }); + }); + const registry = createToolRegistry({ + tools: BUILTIN_TOOLS, + host: { mcp: { call: callMcp } }, + }); + const context: ToolDispatchContext = { + nodeId: 'n', + grantedToolIds: new Set(['mcp_call']), + config: {}, + toolPolicy: {}, + fsScope: 'sandboxed', + gateApproved: false, + effects, + effectSlot: 0, + ...(keys === undefined ? {} : { secretArgKeys: keys }), + }; + const value = mode === 'scalar' ? 'public-value' : { label: 'public-value' }; + const payload = { ['__proto__']: value, confidentialNote: 'SYNTHETIC_NOTE' }; + const request = (args: unknown) => + ({ + type: 'tool_call', + id: 'own-call', + name: 'mcp_call', + args: { server: 'offline', tool: 'publish', args }, + }) as const; + const firstAttempt = await registry.dispatch(request(payload), context).then( + (result) => ({ result }), + (failure: unknown) => ({ failure }), + ); + expect(firstAttempt, 'PHYSICAL_OWN_DATA_ADMISSION').not.toHaveProperty('failure'); + if ('failure' in firstAttempt) throw firstAttempt.failure; + const first = firstAttempt.result; + const replay = await registry.dispatch(request(payload), context); + expect(replay.output).toEqual(first.output); + expect(callMcp).toHaveBeenCalledOnce(); + expect(received[0]).toBe(payload); + const valueBytes = mode === 'scalar' ? '"public-value"' : '{"label":"public-value"}'; + const noteBytes = + keys?.has('confidentialNote') === true ? '' : ',"confidentialNote":"SYNTHETIC_NOTE"'; + const canonical = `{"args":{"__proto__":${valueBytes}${noteBytes}},"server":"offline","tool":"publish"}`; + const expected: unknown = JSON.parse(canonical); + expect(first.events.call.toolInput, 'PHYSICAL_OWN_DATA_EVENT').toEqual(expected); + expect(effects.prepare.mock.calls[0]?.[3], 'PHYSICAL_OWN_DATA_PREPARE').toEqual(expected); + const rows = PhysicalRowsSchema.parse( + client.sqlite + .prepare('SELECT slot, state, args_digest FROM run_effects ORDER BY slot') + .all(), + ); + expect(rows, 'PHYSICAL_OWN_DATA_DIGEST').toEqual([ + { slot: 0, state: 'committed', args_digest: sha256(canonical) }, + ]); + const changed = { ...payload, ['__proto__']: 'changed-public-value' }; + await expect( + registry.dispatch(request(changed), context), + 'PHYSICAL_OWN_DATA_CONFLICT', + ).rejects.toMatchObject({ name: 'ToolEffectConflictError', retryable: false }); + expect(callMcp).toHaveBeenCalledOnce(); + await registry.dispatch(request(changed), { ...context, effectSlot: 1 }); + expect(callMcp).toHaveBeenCalledTimes(2); + expect(received[1]).toBe(changed); + expect(Object.getPrototypeOf(payload)).toBe(Object.prototype); + } finally { + client.sqlite.close(); + } + }, + ); +}); + describe.each(METADATA)( 'W3S5 #94 public registry and native SQLite with $name external metadata', ({ keys }) => { @@ -116,7 +207,7 @@ describe.each(METADATA)( ); expect(rows).toHaveLength(2); expect(rows.map((row) => row.state)).toEqual(['committed', 'committed']); - for (const row of rows) expect(row.args_digest).toMatch(/^v4:[0-9a-f]{64}$/); + for (const row of rows) expect(row.args_digest).toMatch(/^v5:[0-9a-f]{64}$/); expect(rows[0]?.args_digest, 'PHYSICAL_ALIAS_DIGEST_DISTINCT').not.toBe( rows[1]?.args_digest, ); @@ -485,7 +576,7 @@ describe.each(CREDENTIAL_CASES)('PR #95 complete recognized credential: $name', } }); - it.each(['v2:', 'v3:'])( + it.each(['v2:', 'v3:', 'v4:'])( 'PHYSICAL_LEGACY_PROJECTION_REFUSAL refuses a current literal in the %s digest domain', async (prefix) => { const client = createClient(':memory:'); diff --git a/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md index 898cf478..ed588be4 100644 --- a/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md +++ b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md @@ -385,3 +385,17 @@ existing §11 requirement; current details remain in its [canonical contract](../reference/shared-core/effect-journal.md#11-secrets-what-a-row-may-hold). Fresh independent reviews, a complete separate security sitting and corrected-source publication checks are still required. + +### PR #95 own-data projection correction — 2026-10-18 + +The next complete separate security sitting and library review at `c837cd41` +confirm a pre-existing own-data copying defect in the changed projection paths. +Assignment into an ordinary object could invoke an inherited setter instead of +retaining a nested public data member, causing different raw calls to share a +journal identity. Projection copies now define own enumerable data properties. +Declared-secret and media exclusions, top-level argument admission and raw host +dispatch are unchanged. The corrected persisted domain refuses earlier ambiguous +rows without rewriting history or granting retry authority. This restores the +existing different-args refusal; exact representation remains in +[effect-journal.md §11](../reference/shared-core/effect-journal.md#11-secrets-what-a-row-may-hold). +Fresh independent acceptance and source-pinned publication checks remain separate. diff --git a/docs/reference/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index 92b37357..d75762a1 100644 --- a/docs/reference/shared-core/database-schema.md +++ b/docs/reference/shared-core/database-schema.md @@ -730,7 +730,7 @@ OCCURRENCE, written by a `prepare` **before** an effectful tool dispatch leaves | `tool_id` | TEXT | NOT NULL | | `tier` | INTEGER | NOT NULL — `1` \| `2` \| `3`; everything that ships is `3` | | `state` | TEXT | NOT NULL — `prepared` \| `dispatched` \| `committed` \| `ambiguous` \| `needs_attention` | -| `args_digest` | TEXT | NOT NULL — current `v4:` prefix + SHA-256 over canonical JSON of the full redacted effective args; secret-tainted keys are **removed before hashing**. Unversioned, `v2:` and `v3:` legacy rows refuse matching prepare; see [effect-journal.md §11](effect-journal.md#11-secrets-what-a-row-may-hold). | +| `args_digest` | TEXT | NOT NULL — current `v5:` prefix + SHA-256 over canonical JSON of the full redacted effective args; secret-tainted keys are **removed before hashing**. Unversioned, `v2:`, `v3:` and `v4:` legacy rows refuse matching prepare; see [effect-journal.md §11](effect-journal.md#11-secrets-what-a-row-may-hold). | | `target_idempotency_key` | TEXT | NULL — tier 1 only; what a safe retry reuses verbatim | | `result_json` | TEXT | Always NULL for `session:` scopes, including legacy rows after open-time clearing; RUN scopes retain the bounded result when re-delivery is possible | | `attempt_json` | TEXT | NOT NULL — the audit occurrence (node attempt, provider attempt, tool-call id, owning fence) | diff --git a/docs/reference/shared-core/effect-journal.md b/docs/reference/shared-core/effect-journal.md index 6428a78b..9aade3dc 100644 --- a/docs/reference/shared-core/effect-journal.md +++ b/docs/reference/shared-core/effect-journal.md @@ -351,15 +351,19 @@ unencrypted at rest. "A digest, not the bytes" is therefore not by itself a suff contract does not make it. The hash is SHA-256 over a canonical JSON serialization (sorted keys, no insignificant whitespace) of the redacted projection, using a vetted implementation — never a hand-rolled one. -The current persisted form is `v4:` followed by the SHA-256 hex digest. The version +The current persisted form is `v5:` followed by the SHA-256 hex digest. The version prefix is outside the hash and separates current full-value projections from legacy -unversioned digests and `v2:`/`v3:` projections with incomplete credential masks; a JSON -wrapper alone could itself equal arbitrary old tool args. +unversioned digests, `v2:`/`v3:` projections with incomplete credential masks and +`v4:` projections that could omit own public data keys; a JSON wrapper alone could +itself equal arbitrary old tool args. The digest consumes the full surviving non-secret values, including long string tails. -The bounded `toolInput` display preview is not a journal input: equal-length values can -differ beyond its retained prefix. Every unversioned, `v2:` or `v3:` legacy row refuses matching prepare, -including unchanged small arguments: its original projection cannot be proven from the -digest alone. There is no fallback to the old digest or automatic retry permission. Secret and +Nested own enumerable data keys, including `__proto__`, remain data members in the +projection rather than invoking inherited setters. Top-level tool-argument admission +exclusions remain unchanged. The bounded `toolInput` display preview is not a journal +input: equal-length values can differ beyond its retained prefix. Every unversioned, +`v2:`, `v3:` or `v4:` legacy row refuses matching prepare, including unchanged small +arguments: its original projection cannot be proven from the digest alone. There is +no fallback to the old digest or automatic retry permission. Secret and inline-media redaction remain deliberate projection equality; raw credential bytes are never added to the digest to distinguish calls. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 11daba5c..cc2a68f1 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8516,3 +8516,41 @@ it introduces no admission, dispatch, compatibility or policy change. The later source requires two NEW complete cumulative clean rounds and a complete separate defensive security code review. Neither the clean R5 nor earlier remote results are relabeled as execution or acceptance of that refactor. + +### PR #95 own-data projection correction — 2026-10-18 + +At `c837cd41`, R6 library is **COMPLETE NONCLEAN** for its 20 paths / 58 +ordered hunks; CLI is **COMPLETE CLEAN** for its 43/93. Tooling/docs has not +started in that round, so R6 is not a complete 99-path acceptance. The NEW +separate defensive security sitting is **COMPLETE NONCLEAN**, covering all 39 +assigned paths / 93 hunks. Its 533 existing Vitest and 52 Node cases pass, but +source review identifies a pre-existing nested own-data projection defect. No +new crafted probe or live provider request is used in that sitting. The library +review independently confirms the same code path and passes 727 existing cases; +those overlapping executions are not added into a unique total. + +The correction preserves own public data members in all four object-copy paths, +while leaving raw dispatch, declared-secret removal and media projection intact. +The persisted domain refuses earlier ambiguous rows; no historical erasure, +automatic retry, new dependency or authority is introduced. Permanent public-data +regressions use projection helpers, the reference registry and the built-in MCP +registry with actual native SQLite. The canonical +[effect-journal contract](../../reference/shared-core/effect-journal.md#11-secrets-what-a-row-may-hold) +owns representation and compatibility. Two NEW complete cumulative clean rounds, +a NEW complete separate clean security sitting and corrected-source publication +checks remain required. Steps 9–19 remain unstarted and outside PR #95. + +The source-bound Sonar analysis at `c837cd41` passes. It records 231 issues in +90 files: 55 false positives, 86 accepted, 11 fixed and 79 open suggestions. The +private argv-helper complexity suggestion is now FIXED. The two valid regex +maintainability suggestions remain open with reasoned comments; no quality +threshold or rule is weakened. This snapshot does not certify later source. + +Author checks pass 201 focused cases. Removing each of the four own-data copy +protections and the prior-domain barrier produces 79 specific assertion failures +across five controls; every restored complete target batch passes all 201 cases. +The committed runner reporter records zero unhandled errors in these runs, and +all edited source bytes are restored exactly. A preliminary author control that +encountered an unwrapped product refusal is retained as preparation evidence, not +counted as an accepted assertion control. Fresh workspace/publication results and +independent acceptance remain separate. diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index fa60a27d..09ea27d5 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { boundForModel, + redactInlineMedia, redactSecretShapedText, redactSecretShapedValue, utf8ByteLength, @@ -12,6 +13,30 @@ import type { ToolHost, ToolResultLimits } from './types.js'; const BIG: ToolResultLimits = { maxBytes: 50_000, maxLines: 2000 }; const TINY: ToolResultLimits = { maxBytes: 20, maxLines: 3 }; +describe.each([redactInlineMedia, redactSecretShapedValue])( + 'own public data projection %s', + (project) => { + it.each(['public-value', { label: 'public-value' }])( + 'OWN_PUBLIC_KEY preserves an own __proto__ data value %j without changing the prototype', + (value) => { + const input = { + ['__proto__']: value, + constructor: 'public-constructor', + prototype: 'public-prototype', + }; + const output = project(input); + expect(JSON.stringify(output), 'OWN_PUBLIC_KEY').toBe(JSON.stringify(input)); + expect(Object.getPrototypeOf(output)).toBe(Object.prototype); + expect(Object.getOwnPropertyDescriptor(output, '__proto__')).toMatchObject({ + value, + enumerable: true, + }); + expect(Object.getPrototypeOf(input)).toBe(Object.prototype); + }, + ); + }, +); + describe('utf8ByteLength', () => { it('counts ASCII, 2-byte, 3-byte, and surrogate-pair (4-byte) code points', () => { expect(utf8ByteLength('abc')).toBe(3); diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index b8153e0b..10198ee1 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -105,7 +105,13 @@ function redactInlineMediaForText(value: unknown, seen: WeakSet): unknow } const out: Record = {}; for (const [key, item] of Object.entries(value)) { - out[key] = redactInlineMediaForText(item, seen); + // Preserve an own data key such as __proto__; assignment would invoke an inherited setter. + Object.defineProperty(out, key, { + value: redactInlineMediaForText(item, seen), + enumerable: true, + configurable: true, + writable: true, + }); } return out; } finally { @@ -433,7 +439,12 @@ function redactSecretShapedWalk(value: unknown, seen: WeakSet): unknown // inspects strings by shape — so a numeric PIN, a nested credential object, or a high-entropy opaque // token with no recognisable prefix all survive it. `null`/`undefined` are left alone so an absent // field does not become a phantom `[redacted]` in a diagnostic. - out[redactSecretShapedText(key)] = redactUnderKey(key, item, seen); + Object.defineProperty(out, redactSecretShapedText(key), { + value: redactUnderKey(key, item, seen), + enumerable: true, + configurable: true, + writable: true, + }); } return out; } finally { diff --git a/packages/core/src/tools/registry-alias-projection.test.ts b/packages/core/src/tools/registry-alias-projection.test.ts index cfeb9267..05cf351a 100644 --- a/packages/core/src/tools/registry-alias-projection.test.ts +++ b/packages/core/src/tools/registry-alias-projection.test.ts @@ -138,6 +138,37 @@ describe('PR #95 journal identity retains long redacted arguments', () => { describe.each(METADATA)( 'W3S5 #94 actual registry joins with $name external metadata', ({ keys, omitDeclared }) => { + it.each(['public-value', { label: 'public-value' }])( + 'REGISTRY_OWN_DATA preserves nested own data %j in both sinks and refuses a changed value', + async (value) => { + const h = harness(); + const context = { ...h.context, ...(keys === undefined ? {} : { secretArgKeys: keys }) }; + const payload = { ['__proto__']: value, confidentialNote: 'SYNTHETIC_NOTE' }; + const outcome = await h.registry.dispatch(call(payload), context); + const expected = { + payload: { + ['__proto__']: value, + ...(omitDeclared ? {} : { confidentialNote: 'SYNTHETIC_NOTE' }), + }, + }; + for (const projected of [ + outcome.events.call.toolInput, + vi.mocked(h.effects.prepare).mock.calls[0]?.[3], + ]) { + expect(JSON.stringify(projected), 'REGISTRY_OWN_DATA').toBe(JSON.stringify(expected)); + } + const replay = await h.registry.dispatch(call(payload), context); + expect(replay.output).toEqual(outcome.output); + expect(h.received).toEqual([payload]); + expect(h.received[0]).toBe(payload); + await expect( + h.registry.dispatch(call({ ...payload, ['__proto__']: 'changed-public-value' }), context), + 'REGISTRY_OWN_DATA_CONFLICT', + ).rejects.toMatchObject({ name: 'ToolEffectConflictError', retryable: false }); + expect(h.received).toHaveLength(1); + }, + ); + it('REGISTRY_SELECTION preserves selected value in event and prepare copies without changing dispatch identity', async () => { const h = harness(); const context = { ...h.context, ...(keys === undefined ? {} : { secretArgKeys: keys }) }; diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 3fddc0b6..75383ce8 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -1128,7 +1128,14 @@ function boundArgsForDisplay( const proto: unknown = Object.getPrototypeOf(value); if (proto !== Object.prototype && proto !== null) return value; const out: Record = {}; - for (const [key, item] of Object.entries(value)) out[key] = boundArgsForDisplay(item, seen); + for (const [key, item] of Object.entries(value)) { + Object.defineProperty(out, key, { + value: boundArgsForDisplay(item, seen), + enumerable: true, + configurable: true, + writable: true, + }); + } return out; } finally { seen.delete(value); @@ -1163,7 +1170,12 @@ function dropSecretKeysDeep( const out: Record = {}; for (const [key, item] of Object.entries(value)) { if (secretArgKeys.has(key)) continue; - out[key] = dropSecretKeysDeep(item, secretArgKeys, seen); + Object.defineProperty(out, key, { + value: dropSecretKeysDeep(item, secretArgKeys, seen), + enumerable: true, + configurable: true, + writable: true, + }); } return out; } finally { diff --git a/packages/db/src/effect-journal-store.test.ts b/packages/db/src/effect-journal-store.test.ts index df63b77d..55e10d93 100644 --- a/packages/db/src/effect-journal-store.test.ts +++ b/packages/db/src/effect-journal-store.test.ts @@ -341,7 +341,7 @@ describe('the effect journal store', () => { const port = (): ReturnType => createEffectJournalPort(store, RUN, ATTEMPT); - it.each(['', 'v2:', 'v3:'])( + it.each(['', 'v2:', 'v3:', 'v4:'])( 'refuses unchanged small legacy args in the %j digest domain', async (prefix) => { const legacy = diff --git a/packages/db/src/effect-journal-store.ts b/packages/db/src/effect-journal-store.ts index 7249182d..947d3dac 100644 --- a/packages/db/src/effect-journal-store.ts +++ b/packages/db/src/effect-journal-store.ts @@ -642,6 +642,7 @@ function coerceEffectTier(value: number): EffectTier { */ function digestOf(redactedArgs: unknown): string { // Unversioned digests may describe a lossy display preview; v2/v3 may include a partially redacted - // credential. A current literal can equal an old projection, so none is a replay authority. - return `v4:${createHash('sha256').update(canonicalJson(redactedArgs)).digest('hex')}`; + // credential, while v4 may omit own public data keys. A current literal can equal an old + // projection, so none is a replay authority. + return `v5:${createHash('sha256').update(canonicalJson(redactedArgs)).digest('hex')}`; } From 0fa599fbeda120f7775594a49f054eab8b62b9d3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 03:47:59 +0300 Subject: [PATCH 181/222] fix(core): redact complete spans before diagnostic clipping Recognize complete credential spans before tool-input previews and result summaries are clipped. Preserve terminal delimiters needed by the finite recognizer while retaining raw dispatch, model results and journal bytes. Pin both paths through helper and actual registry/native SQLite cases. Seven removals produce 87 assertion failures; every byte-exact restoration passes all 207 targets. Record the independent nonclean source reviews. Refs: ADR-0080, ADR-0050, ADR-0036, PR #95 Co-Authored-By: Codex --- .../engine/tool-alias-journal-digest.test.ts | 67 +++++++++++++++++++ ...-journal-and-the-tiered-effect-contract.md | 13 ++++ docs/reference/shared-core/effect-journal.md | 4 +- docs/reference/shared-core/tool-registry.md | 8 +++ .../phase-2.5.5-hardening-and-remediation.md | 30 +++++++++ packages/core/src/tools/bounding.test.ts | 22 ++++++ packages/core/src/tools/bounding.ts | 12 ++-- .../tools/registry-alias-projection.test.ts | 28 ++++++++ packages/core/src/tools/registry.ts | 9 +-- 9 files changed, 182 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/engine/tool-alias-journal-digest.test.ts b/apps/cli/src/engine/tool-alias-journal-digest.test.ts index e2103eea..179bb8cb 100644 --- a/apps/cli/src/engine/tool-alias-journal-digest.test.ts +++ b/apps/cli/src/engine/tool-alias-journal-digest.test.ts @@ -84,6 +84,73 @@ const METADATA = [ { name: 'nonempty', keys: new Set(['confidentialNote']) }, ]; +it.each([ + { + name: 'quoted body credential', + body: `password="SYNTHETIC_VISIBLE_HEAD_${'Q'.repeat(20 * 1024)}"; public-tail`, + projected: '[redacted]; public-tail', + }, + { + name: 'body URL userinfo', + body: `https://user:SYNTHETIC_VISIBLE_HEAD_${'Q'.repeat(20 * 1024)}@service.invalid/path`, + projected: 'https://[redacted]@service.invalid/path', + }, +])( + 'PHYSICAL_DISPLAY_REDACTION_ORDER masks complete $name before the event preview', + async ({ body, projected }) => { + const client = createClient(':memory:'); + try { + runMigrations(client.db); + const store = createEffectJournalStore(client.db, { + uuid: () => 'display-order', + now: () => 6, + }); + const effects = recordingPort( + createEffectJournalPort( + store, + { kind: 'run', runId: 'r-display', nodeId: 'n', attempt: 1 }, + { providerAttempt: 1, toolCallId: 'display-call' }, + ), + ); + const fetch = vi.fn((input: Parameters['fetch']>[0]) => { + expect(input.body).toBe(body); + return Promise.resolve({ status: 200, headers: {}, body: 'ok' }); + }); + const registry = createToolRegistry({ tools: BUILTIN_TOOLS, host: { egress: { fetch } } }); + const context: ToolDispatchContext = { + nodeId: 'n', + grantedToolIds: new Set(['http_request']), + config: {}, + toolPolicy: { allowedDomains: ['service.invalid'] }, + fsScope: 'sandboxed', + gateApproved: false, + effects, + effectSlot: 0, + }; + const args = { body, method: 'POST', url: 'https://service.invalid/x' }; + const outcome = await registry.dispatch( + { type: 'tool_call', id: 'display-call', name: 'http_request', args }, + context, + ); + const expected = { body: projected, method: 'POST', url: 'https://service.invalid/x' }; + expect(outcome.events.call.toolInput, 'PHYSICAL_DISPLAY_REDACTION_ORDER').toEqual(expected); + expect(effects.prepare.mock.calls[0]?.[3]).toEqual(expected); + expect(fetch).toHaveBeenCalledOnce(); + const rows = PhysicalRowsSchema.parse( + client.sqlite + .prepare('SELECT slot, state, args_digest FROM run_effects ORDER BY slot') + .all(), + ); + // Independent sorted-key bytes; display ordering must not alter the full v5 journal identity. + expect(rows, 'PHYSICAL_DISPLAY_JOURNAL_UNCHANGED').toEqual([ + { slot: 0, state: 'committed', args_digest: sha256(JSON.stringify(expected)) }, + ]); + } finally { + client.sqlite.close(); + } + }, +); + describe.each(METADATA)('native MCP own public data with $name metadata', ({ keys }) => { it.each(['scalar', 'record'] as const)( 'PHYSICAL_OWN_DATA retains %s data in the digest, replays equality and refuses a changed value', diff --git a/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md index ed588be4..f27a7e7e 100644 --- a/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md +++ b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md @@ -399,3 +399,16 @@ rows without rewriting history or granting retry authority. This restores the existing different-args refusal; exact representation remains in [effect-journal.md §11](../reference/shared-core/effect-journal.md#11-secrets-what-a-row-may-hold). Fresh independent acceptance and source-pinned publication checks remain separate. + +### PR #95 diagnostic projection order — 2026-10-18 + +The subsequent independent library and separate security reviews identify +truncation before recognition in tool-input and result-summary diagnostics. A +preview could remove the closing delimiter of an otherwise supported credential +shape. Both diagnostic paths now recognize complete spans before clipping. The +full journal projection and its current digest domain are unchanged; raw host +dispatch, model results and full spill content retain their existing handling. +The finite diagnostic policy lives in +[tool-registry.md](../reference/shared-core/tool-registry.md#result-bounding-and-spill-to-file); +this does not promise removal of arbitrary sensitive content. Fresh acceptance +and publication checks remain source-scoped. diff --git a/docs/reference/shared-core/effect-journal.md b/docs/reference/shared-core/effect-journal.md index 9aade3dc..f1c3a8db 100644 --- a/docs/reference/shared-core/effect-journal.md +++ b/docs/reference/shared-core/effect-journal.md @@ -360,7 +360,9 @@ The digest consumes the full surviving non-secret values, including long string Nested own enumerable data keys, including `__proto__`, remain data members in the projection rather than invoking inherited setters. Top-level tool-argument admission exclusions remain unchanged. The bounded `toolInput` display preview is not a journal -input: equal-length values can differ beyond its retained prefix. Every unversioned, +input, and its clipping follows full recognized-value projection +([tool-registry.md](tool-registry.md#result-bounding-and-spill-to-file)). Equal-length +values can differ beyond its retained prefix. Every unversioned, `v2:`, `v3:` or `v4:` legacy row refuses matching prepare, including unchanged small arguments: its original projection cannot be proven from the digest alone. There is no fallback to the old digest or automatic retry permission. Secret and diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index 18668798..16015e78 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -421,6 +421,14 @@ are unchanged. Sanitized effective arguments also feed the existing journal iden clean sibling avoids incorrectly treating distinct selections as a replay. This does not make the projection a lossless canonical encoding or claim detection of arbitrary sensitive text. +Diagnostic truncation follows complete recognized-span redaction. `toolInput` applies +secret/media projection before clipping its string leaves; `outputSummary` recognizes +secret-shaped spans in the complete materialized text before bounding whitespace and +length. A closing quote or URL authority separator beyond a preview boundary must not +make a supported credential shape unrecognizable. This does not redact the raw model +result, full spill or arbitrary unlabelled sensitive text; those retain their existing +content-sensitive handling. + ## 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 diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index cc2a68f1..fee225be 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8554,3 +8554,33 @@ all edited source bytes are restored exactly. A preliminary author control that encountered an unwrapped product refusal is retained as preparation evidence, not counted as an accepted assertion control. Fresh workspace/publication results and independent acceptance remain separate. + +### PR #95 diagnostic projection order correction — 2026-10-18 + +R7 at `c09f1924` is **INCOMPLETE NONCLEAN**: library 20 paths / 63 hunks +reports one Blocker, while CLI 43/93 is complete clean; tooling/docs has not +started. A separate **COMPLETE NONCLEAN** defensive security sitting covers all +39/98 and independently classifies the same presentation-order defect as High. +Those findings are source-confirmed, with no new crafted probes. Ordinary tool +firehose events reach the live bus/JSON surface; automatic run-event-log +persistence of that firehose is not claimed. The full journal projection is +unaffected by this finding. + +The correction recognizes complete credential spans before tool-input clipping +and result-summary clipping. Permanent helper, registry and actual built-in HTTP +registry-to-SQLite cases cover a closing quote and URL authority separator beyond +the preview limit, unchanged raw/model values and unchanged full journal bytes. +There is no new digest domain, credential detector, automatic retry or authority. +The [canonical tool projection contract](../../reference/shared-core/tool-registry.md#result-bounding-and-spill-to-file) +records the finite scope and sensitive raw-content limits. Fresh two-round +cumulative independent acceptance, a complete separate clean security sitting +and final-source publication remain required; Steps 9–19 remain unstarted. + +Author verification passes all 207 focused cases, with seven removal controls +producing 87 specific assertion failures and byte-exact restoration followed by +all 207 targets passing each time. The committed runner reporter records zero +unhandled errors. A fresh forced build passes 6/6 with no cache hits; the 177 +bootstrap controls and finite 25-output closure pass after clearing stale local +generated chunks through that normal build. Lint/typecheck passes 17/17 (11 +cache hits). These checks establish author evidence only, without inheriting +previous clean reviews or remote results. diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index 09ea27d5..85544898 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -56,6 +56,28 @@ describe('utf8ByteLength', () => { describe('boundForModel', () => { const host = (overrides?: Partial): ToolHost => ({ ...overrides }); + it.each([ + { + name: 'quoted credential', + text: `password="SYNTHETIC_VISIBLE_HEAD_${'Q'.repeat(20 * 1024)}"; public-tail`, + summary: '[redacted]; public-tail', + }, + { + name: 'URL userinfo', + text: `https://user:SYNTHETIC_VISIBLE_HEAD_${'Q'.repeat(20 * 1024)}@service.invalid/path`, + summary: 'https://[redacted]@service.invalid/path', + }, + ])( + 'SUMMARY_REDACTION_ORDER recognizes a complete long $name before clipping', + async ({ text, summary }) => { + const result = await boundForModel(text, BIG, host()); + expect(result.summary, 'SUMMARY_REDACTION_ORDER').toBe(summary); + expect(result.summary.length).toBeLessThanOrEqual(501); + expect(result.value).toBe(text); + expect(result.truncated).toBe(false); + }, + ); + it('passes a within-limits result through untouched', async () => { const result = { ok: true, n: 1 }; const bounded = await boundForModel(result, BIG, host()); diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 10198ee1..3d695e46 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -476,12 +476,12 @@ function toText(result: unknown): string { } function makeSummary(text: string): string { - // Cap the scanned input so the whitespace-collapse never runs over an oversized result (the summary - // is bounded to SUMMARY_MAX regardless). Scrub secret-shaped substrings BEFORE the length cap so a - // credential in the result (a read_clipboard, an egress body, a `.env` read) never rides `outputSummary` - // to the `--json` stream / a log (the model-facing value is untouched — this is the observability copy). - const slice = text.length > SUMMARY_MAX * 8 ? text.slice(0, SUMMARY_MAX * 8) : text; - const oneLine = redactSecretShapedText(slice.replace(/\s+/g, ' ').trim()); + // Recognize complete spans before clipping: a closing quote or authority separator can be beyond + // the preview. The result is already materialized for byte/line bounding; raw model/spill text stays + // unchanged. Bound the subsequent whitespace-collapse and diagnostic independently. + const redacted = redactSecretShapedText(text); + const slice = redacted.length > SUMMARY_MAX * 8 ? redacted.slice(0, SUMMARY_MAX * 8) : redacted; + const oneLine = slice.replace(/\s+/g, ' ').trim(); return oneLine.length <= SUMMARY_MAX ? oneLine : `${oneLine.slice(0, SUMMARY_MAX)}…`; } diff --git a/packages/core/src/tools/registry-alias-projection.test.ts b/packages/core/src/tools/registry-alias-projection.test.ts index 05cf351a..076a92b5 100644 --- a/packages/core/src/tools/registry-alias-projection.test.ts +++ b/packages/core/src/tools/registry-alias-projection.test.ts @@ -80,6 +80,34 @@ const METADATA = [ ]; describe('PR #95 journal identity retains long redacted arguments', () => { + it.each([ + { + name: 'quoted credential', + text: `password="SYNTHETIC_VISIBLE_HEAD_${'Q'.repeat(20 * 1024)}"; public-tail`, + projected: '[redacted]; public-tail', + }, + { + name: 'URL userinfo', + text: `https://user:SYNTHETIC_VISIBLE_HEAD_${'Q'.repeat(20 * 1024)}@service.invalid/path`, + projected: 'https://[redacted]@service.invalid/path', + }, + ])( + 'DISPLAY_REDACTION_ORDER recognizes a complete $name before its preview', + async ({ text, projected }) => { + const h = harness(); + const payload = { text }; + const result = await h.registry.dispatch(call(payload), h.context); + expect(result.events.call.toolInput, 'DISPLAY_REDACTION_ORDER').toEqual({ + payload: { text: projected }, + }); + expect(vi.mocked(h.effects.prepare).mock.calls[0]?.[3]).toEqual({ + payload: { text: projected }, + }); + expect(h.received[0]).toBe(payload); + expect(payload.text).toBe(text); + }, + ); + it('LONG_ARGS_CONFLICT replays identical args but refuses a changed same-length tail at the same slot', async () => { const h = harness(); const prefix = 'x'.repeat(20 * 1024); diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 75383ce8..51454c0b 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -1093,12 +1093,13 @@ function sanitizeInput( // verbatim from a real dispatch. // A display preview may omit a tail; the journal's collision guard may not. Both still exclude // declared secrets and credential/media-shaped values BEFORE the host computes its digest. - const projection = purpose === 'display' ? boundArgsForDisplay(out) : out; - const walked = redactSecretShapedValue(redactInlineMedia(projection)); + // Recognition needs the complete value: clipping may remove a quote or URL authority separator. + const walked = redactSecretShapedValue(redactInlineMedia(out)); + const projection = purpose === 'display' ? boundArgsForDisplay(walked) : walked; // The walk preserves plain-object shape, so this is the same record with scrubbed members; the guard is // a boundary check rather than a cast, and the fallback can only be reached if the walk ever stops // returning an object for an object input. - return isPlainRecord(walked) ? walked : out; + return isPlainRecord(projection) ? projection : out; } /** @@ -1108,7 +1109,7 @@ function sanitizeInput( const ARG_SCAN_MAX_CHARS = 16 * 1024; /** - * Truncate display STRING leaves with an explicit true-length marker. This is never a journal input: + * Truncate already-redacted display STRING leaves with their surviving length. This is never a journal input: * equal-length values can differ after the retained prefix and must not replay each other's result. */ function boundArgsForDisplay( From ba14b1bd179270742534fc460501ec72dba85da1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 03:57:46 +0300 Subject: [PATCH 182/222] fix(core): normalize complete summaries before redaction Keep one-line diagnostic normalization ahead of credential recognition, then clip only the recognized projection. This preserves multiline quoted values while retaining complete delimiters beyond preview boundaries. Add the missing combined regression and retain source-scoped nonclean review records. Eight removals yield 89 named assertion failures; every byte-exact restoration passes all 208 target cases without unhandled errors. Refs: ADR-0080, ADR-0050, PR #95 Co-Authored-By: Codex --- ...-journal-and-the-tiered-effect-contract.md | 10 +++++++ docs/reference/shared-core/tool-registry.md | 6 ++-- .../phase-2.5.5-hardening-and-remediation.md | 29 +++++++++++++++++++ packages/core/src/tools/bounding.test.ts | 5 ++++ packages/core/src/tools/bounding.ts | 10 +++---- 5 files changed, 52 insertions(+), 8 deletions(-) diff --git a/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md index f27a7e7e..b2a75c80 100644 --- a/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md +++ b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md @@ -412,3 +412,13 @@ The finite diagnostic policy lives in [tool-registry.md](../reference/shared-core/tool-registry.md#result-bounding-and-spill-to-file); this does not promise removal of arbitrary sensitive content. Fresh acceptance and publication checks remain source-scoped. + +### PR #95 summary normalization correction — 2026-10-18 + +A subsequent independent review identifies a regression in the diagnostic-order +correction: masking raw text before whitespace collapse loses the existing +recognition of multiline quoted values in one-line summaries. Summary generation +now normalizes the complete materialized text, recognizes complete spans, then +clips. The full journal projection, raw result/spill and recorded replay contract +are unchanged. The [canonical diagnostic contract](../reference/shared-core/tool-registry.md#result-bounding-and-spill-to-file) +owns this finite behavior; no historical-content rewrite is introduced. diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index 16015e78..daf1f412 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -422,12 +422,14 @@ clean sibling avoids incorrectly treating distinct selections as a replay. This projection a lossless canonical encoding or claim detection of arbitrary sensitive text. Diagnostic truncation follows complete recognized-span redaction. `toolInput` applies -secret/media projection before clipping its string leaves; `outputSummary` recognizes -secret-shaped spans in the complete materialized text before bounding whitespace and +secret/media projection before clipping its string leaves; `outputSummary` normalizes +the complete materialized text to one line, recognizes secret-shaped spans, then bounds length. A closing quote or URL authority separator beyond a preview boundary must not make a supported credential shape unrecognizable. This does not redact the raw model result, full spill or arbitrary unlabelled sensitive text; those retain their existing content-sensitive handling. +Recorded replay results retain the [existing journal contract](effect-journal.md); +this diagnostic correction does not rewrite historical content. ## Untrusted-data taint (1.T marks; 1.O places) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index fee225be..4f629de3 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8584,3 +8584,32 @@ bootstrap controls and finite 25-output closure pass after clearing stale local generated chunks through that normal build. Lint/typecheck passes 17/17 (11 cache hits). These checks establish author evidence only, without inheriting previous clean reviews or remote results. + +### PR #95 summary normalization correction — 2026-10-18 + +R8 at local `0fa599fb` is **INCOMPLETE NONCLEAN**. Library is complete for +20 paths / 64 ordered hunks and reports one High normalization-order regression, +with 413 existing cases passing. Tooling/docs completes 37/55 with no additional +independent finding; its 209 tooling, 157 run/gate and 46 adjacent caller cases +pass without being added to a new whole-repository unique total. CLI verifies +all 43/93 hashes and changed hunks but reads only 39/43 full bodies; the exact +remaining four legacy-test ranges are retained as incomplete. Its 562 cases +and in-memory emitted runtime comparisons (220 library and 25 CLI outputs) pass. +Neither a partial review nor passing tests override the confirmed defect. + +The correction preserves the existing one-line diagnostic semantics by +normalizing the complete materialized result text before credential recognition, +then clipping. A permanent multiline quoted-value case pins the previously +missing combination of normalization and a closing delimiter beyond the preview. +The journal projection/domain, raw model/spill values and historical replay +contract are unchanged. This is not arbitrary sensitive-content detection or +retroactive cleanup. Fresh complete clean cumulative rounds and a separate clean +security sitting remain required; the next CLI scope is split between the five +large legacy test bodies and the other 38 paths so every current body can be read. + +Author verification passes 208 focused cases. Eight causal removals produce 89 +specific assertion failures, with byte-exact restoration and all 208 targets +passing after every removal. Runner receipts record zero unhandled errors. A +forced clean build, all 177 bootstrap controls, finite closure and lint/typecheck +(17/17, 11 cache hits) pass. These author checks do not supply the required fresh +independent acceptance or final-source remote publication evidence. diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index 85544898..bfbbf23f 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -62,6 +62,11 @@ describe('boundForModel', () => { text: `password="SYNTHETIC_VISIBLE_HEAD_${'Q'.repeat(20 * 1024)}"; public-tail`, summary: '[redacted]; public-tail', }, + { + name: 'multiline quoted credential', + text: `password="SYNTHETIC_VISIBLE_HEAD_${'Q'.repeat(10 * 1024)}\n${'Q'.repeat(10 * 1024)}"; public-tail`, + summary: '[redacted]; public-tail', + }, { name: 'URL userinfo', text: `https://user:SYNTHETIC_VISIBLE_HEAD_${'Q'.repeat(20 * 1024)}@service.invalid/path`, diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 3d695e46..42fead8a 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -476,12 +476,10 @@ function toText(result: unknown): string { } function makeSummary(text: string): string { - // Recognize complete spans before clipping: a closing quote or authority separator can be beyond - // the preview. The result is already materialized for byte/line bounding; raw model/spill text stays - // unchanged. Bound the subsequent whitespace-collapse and diagnostic independently. - const redacted = redactSecretShapedText(text); - const slice = redacted.length > SUMMARY_MAX * 8 ? redacted.slice(0, SUMMARY_MAX * 8) : redacted; - const oneLine = slice.replace(/\s+/g, ' ').trim(); + // Normalize the complete materialized text before recognition, preserving the one-line diagnostic + // policy even for quoted multiline values. Clip only after recognition: a closing quote or URL + // authority separator can be beyond the preview. Raw model/spill text stays unchanged. + const oneLine = redactSecretShapedText(text.replace(/\s+/g, ' ').trim()); return oneLine.length <= SUMMARY_MAX ? oneLine : `${oneLine.slice(0, SUMMARY_MAX)}…`; } From a5a5d45ebf9a57dbd8dfe0e20ae71ae9b33408cb Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 04:26:17 +0300 Subject: [PATCH 183/222] docs(docs): record pr 95 corrective review completion Close the two fresh cumulative correction reviews and the separate static security sitting at their exact runtime source. Preserve failed historical rounds, cache attribution and final publication ownership. Append landing notes without rewriting decisions or claiming later Wave 3 work, a merge, or remote execution before the final source checks run. Refs: ADR-0080, ADR-0103, ADR-0113, PR #95 Co-Authored-By: Codex --- ...-journal-and-the-tiered-effect-contract.md | 10 ++++ ...-local-producers-before-its-host-closes.md | 10 ++++ ...ontext-keeps-provenance-at-public-sinks.md | 9 ++++ .../phase-2.5.5-hardening-and-remediation.md | 46 +++++++++++++++++++ 4 files changed, 75 insertions(+) diff --git a/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md index b2a75c80..59a7b7b6 100644 --- a/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md +++ b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md @@ -422,3 +422,13 @@ now normalizes the complete materialized text, recognizes complete spans, then clips. The full journal projection, raw result/spill and recorded replay contract are unchanged. The [canonical diagnostic contract](../reference/shared-core/tool-registry.md#result-bounding-and-spill-to-file) owns this finite behavior; no historical-content rewrite is introduced. + +### PR #95 corrective review landing — 2026-10-18 + +The complete corrective implementation at `ba14b1bd` has two NEW clean cumulative +independent review rounds and a NEW separate clean defensive security sitting. +The [correction register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-corrective-review-completion--2026-10-18) +owns exact source, coverage, execution limits and published-source validation. +Earlier pending review notes retain their historical attribution. Canonical +projection/compatibility contracts remain authoritative; this landing rewrites no +history and grants no automatic effect retry or whole-wave completion. diff --git a/docs/decisions/0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md b/docs/decisions/0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md index 8425998a..6f76b0a2 100644 --- a/docs/decisions/0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md +++ b/docs/decisions/0103-a-paused-run-hands-off-its-local-producers-before-its-host-closes.md @@ -1257,3 +1257,13 @@ cancellation, close initiation, a rejected observer or budget expiry into acknow The exact host sequence and exceptional budget-observation path live in [mcp-integration.md](../reference/shared-core/mcp-integration.md#failed-start-cleanup-lifecycle). Fresh cumulative review and corrected-source gates remain required in PR #95. + +### PR #95 corrective review landing — 2026-10-18 + +The session-close correction is included in the two NEW clean complete cumulative +reviews and NEW separate clean defensive security sitting at `ba14b1bd`. The +[correction register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-corrective-review-completion--2026-10-18) +records exact scopes, tests and published-source validation. Earlier pending +checkpoints are historical. Actual native/work/terminal/database acknowledgements +remain distinct from cancellation, initiation, refusal and deadline expiry; this +note adds no shutdown authority or whole-wave acceptance. diff --git a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md index 44d6bf13..6bd36ec4 100644 --- a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md +++ b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md @@ -236,3 +236,12 @@ declaration snapshot before reading that value. This closes an embedding-only im fallback while preserving normal fresh/resume/preparation resolution and the existing fixed validation refusal. The [runner contract](../reference/shared-core/agent-runner.md#qualified-context-consumers) owns this boundary; the accepted decision and historical implementation evidence remain unchanged. + +### PR #95 corrective review landing — 2026-10-18 + +The external-projection guard is included in the two NEW clean complete cumulative +reviews and NEW separate clean defensive security sitting at `ba14b1bd`. The +[correction register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-corrective-review-completion--2026-10-18) +owns exact scopes and validation attribution. The existing trusted-host and +historical-content limits remain; this does not invent a tool-mapping producer, +retroactive erasure or whole-wave acceptance. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 4f629de3..f9e9ca27 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8613,3 +8613,49 @@ passing after every removal. Runner receipts record zero unhandled errors. A forced clean build, all 177 bootstrap controls, finite closure and lint/typecheck (17/17, 11 cache hits) pass. These author checks do not supply the required fresh independent acceptance or final-source remote publication evidence. + +### PR #95 corrective review completion — 2026-10-18 + +The maintainer-review corrections are implemented and independently reviewed on +`development` at `ba14b1bd179270742534fc460501ec72dba85da1`, against the submitted +review base `05061d55`. Two NEW complete cumulative rounds, R9 and R10, each use +four independent reviewers and cover all **100 paths / 212 ordered default-context +hunks**. Root verifies the exact coverage union, current-body/diff/hunk hashes, +reports and semantic assessments. All current code/test bodies and deleted or +replaced branches are read. Long historical documents receive full-byte identity, +structure/status/link checks and complete changed/current affected-clause review; +unrelated historical narrative is not freshly recertified line by line. Both +rounds are CLEAN. Earlier NONCLEAN and incomplete rounds retain their own sources +and do not supply this acceptance. + +A NEW separate defensive security sitting is also COMPLETE CLEAN for **40 paths / +100 hunks** at the same source. It is a static code/contract review; it executes no +tests and inherits no earlier execution result. Root independently verifies its +coverage, source bindings and substantive assessments. The finite redaction, +historical-content, native-acknowledgement and trusted-host limits remain explicit +in their canonical contracts. + +Author checks retain **748 files / 15,887 passes / 21 established skips**. Final +workspace tasks pass 11/11 with eight cache hits; lint/typecheck passes 17/17 with +17 cache hits, following the dedicated current-code check with six fresh tasks +and eleven cache hits. Cached work is not represented as rerun. All 599 +acceptance-tool cases, source/dependency/schema/isolation checks and formatting +pass. The forced clean build is fresh 6/6; all 177 bootstrap controls and the +finite compiled closure pass. Eight author removals produce 89 specific assertion +failures, each followed by byte-exact restoration and all 208 focused targets +passing, with zero unhandled errors in the committed runner reporter. Independent +reviewer test selections retain their separate, overlapping scopes rather than +being added into a new repository total. + +This closes corrective implementation and independent review, not a new whole-PR +or whole-wave review. Subsequent completion metadata is documentation-only and +must retain executable/configuration/dependency equivalence to this reviewed +source and receive two NEW independent documentation checks. Published-source +acceptance is assessed through the actual required CI, ordinary/enforced coverage, +source-bound Sonar analysis and all four same-archive installed consumers attached +to [PR #95](https://github.com/HodeTech/Relavium/pull/95). The current installed +inventory is 177 bootstrap controls plus ten behavior cases per consumer; +downloaded archive/report inspection is distinct from executing another platform. +Earlier pending checkpoints are historical, not outstanding requests to repeat +their old-source reviews. No merge, release, new decision or deferral is added. +Steps 9–19 remain unstarted and outside this PR. From cbc4c0cc6f389e18aeb760033201f9be246d65f8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 11:46:43 +0300 Subject: [PATCH 184/222] fix(core): mask complete recognized credential spans Recognize current key formats and finite split credential spans before display normalization. Preserve full redacted effect identity and unambiguous public data. Refs: ADR-0080, ADR-0110 Co-Authored-By: Codex --- ...-journal-and-the-tiered-effect-contract.md | 17 +++ docs/reference/shared-core/built-in-tools.md | 23 +++ packages/core/src/tools/bounding.test.ts | 131 +++++++++++++++- packages/core/src/tools/bounding.ts | 141 +++++++++++++++--- 4 files changed, 286 insertions(+), 26 deletions(-) diff --git a/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md index 59a7b7b6..b86f80b1 100644 --- a/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md +++ b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md @@ -432,3 +432,20 @@ owns exact source, coverage, execution limits and published-source validation. Earlier pending review notes retain their historical attribution. Canonical projection/compatibility contracts remain authoritative; this landing rewrites no history and grants no automatic effect retry or whole-wave completion. + + +### PR #95 split-credential recognition clarification — 2026-10-18 + +> Amended 2026-10-18: The second maintainer systematic report identifies supported +> token families and LF/TAB-split credential suffixes escaping finite masking. +> Recognition now covers the complete supported original span before summary +> normalization or diagnostic clipping; ambiguous token-alphabet continuation +> after LF/TAB is conservatively part of that span. Ordinary spaces/punctuation +> separate surrounding public prose. This refines recognized-credential +> projection rather than promising removal of arbitrary sensitive content. +> The [finite masking contract](../reference/shared-core/built-in-tools.md#finite-diagnostic-credential-masking) +> and [journal compatibility contract](../reference/shared-core/effect-journal.md#11-secrets-what-a-row-may-hold) +> own current details. Raw dispatch/model content and historical rows are not +> rewritten, and no effect retry authority is added. New independent review, +> separate security and corrected-source publication checks remain pending in +> the [second correction register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-second-systematic-review-corrections--2026-10-18). diff --git a/docs/reference/shared-core/built-in-tools.md b/docs/reference/shared-core/built-in-tools.md index 561a7021..09662fbb 100644 --- a/docs/reference/shared-core/built-in-tools.md +++ b/docs/reference/shared-core/built-in-tools.md @@ -82,6 +82,29 @@ The result a tool hands **back to the model** (which re-enters the next request) 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). + +### Finite diagnostic credential masking + +Tool-input previews, result summaries and journal argument projections recognize a +finite set of credential shapes: labelled credential values, authentication schemes, +URL userinfo and known token families, including hyphenated OpenAI/Anthropic key +bodies. Recognition consumes the complete supported span before diagnostic clipping. +This is conservative pattern masking, not proof that arbitrary sensitive content is +absent; raw dispatch/model results, full spills and historical rows retain their +existing content-sensitive handling. + +LF/TAB inside a recognized token-shaped credential extends the masked original span. +An ambiguous token-alphabet continuation after LF/TAB is conservatively masked with +that credential. Ordinary spaces and punctuation separate public prose; the masker +does not discard the whole diagnostic because a credential appears. Supported split +shapes are recognized **before** complete-text one-line normalization, followed by +ordinary recognition and length clipping. This preserves multiline quoted-value +recognition without exposing a split token's suffix. Public argument bytes are not +globally whitespace-normalized for journal identity. The +[journal contract](effect-journal.md#11-secrets-what-a-row-may-hold) owns recognized- +credential equality and persisted compatibility; diagnostic masking grants no +permission to repeat an effect. + ## 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). diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index bfbbf23f..3814834d 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -508,6 +508,7 @@ describe('redactSecretShapedText', () => { it('scrubs every non-URL credential family after a long public prefix and preserves the full tail', () => { // No URL delimiter occurs in these values. Skipping impossible userinfo matches must not bypass // another credential family or truncate the journal input to the display projection's 16 KiB. + // An ordinary space distinguishes public prose from the LF/TAB token-continuation policy. const prefix = `${'a'.repeat(32 * 1024)}\n`; const cases = [ [`?opaque=${'Q'.repeat(24)}`, '?opaque=[redacted]'], @@ -518,8 +519,8 @@ describe('redactSecretShapedText', () => { ]; for (const [credential, expected] of cases) { for (const tail of ['public-tail-A', 'public-tail-B']) { - expect(redactSecretShapedText(`${prefix}${credential}\n${tail}`)).toBe( - `${prefix}${expected}\n${tail}`, + expect(redactSecretShapedText(`${prefix}${credential}\n ${tail}`)).toBe( + `${prefix}${expected}\n ${tail}`, ); } } @@ -533,7 +534,7 @@ describe('redactSecretShapedText', () => { it( 'is ReDoS-safe on a value-ENGAGING input AND fully redacts it (timing + correctness)', - { timeout: 60_000 }, + { timeout: 15_000 }, () => { // Drives BOTH the scheme-token run (200k) and a long quoted value (50k) — the machinery a quadratic // pattern blows up on. The correctness assertions catch a quantifier-narrowing regression that would @@ -682,6 +683,7 @@ describe('redactSecretShapedText — URL userinfo (ADR-0080 §11)', () => { }); it('still finds both userinfo forms after a long public prefix', () => { + // An ordinary space distinguishes public prose from the LF/TAB token-continuation policy. const prefix = `${'a'.repeat(32 * 1024)}\n`; for (const userinfo of ['user:opaque-password', 'opaque-token-value-without-colon']) { expect(redactSecretShapedText(`${prefix}custom+scheme://${userinfo}@host.example/tail`)).toBe( @@ -890,3 +892,126 @@ describe('redactSecretShapedValue — the KEY decides, whatever the value looks ); }); }); + +function expectCredentialProjection(value: unknown) { + return expect(value, 'SECRETS_B2_CORE_PROJECTION'); +} + +describe('PR95 B2 finite split-credential projections', () => { + const bodies = ['proj-', 'svcacct-', 'ant-', ''].map( + (prefix) => `sk-${prefix}${'A'.repeat(24)}_${'B'.repeat(24)}`, + ); + + it.each(bodies)('masks the complete modern provider shape %s', async (credential) => { + expectCredentialProjection(redactSecretShapedText(`public: ${credential}; tail=A`)).toBe( + 'public: [redacted]; tail=A', + ); + const bounded = await boundForModel(`public: ${credential}; tail=A`, BIG, {}); + expectCredentialProjection(bounded.summary).toBe('public: [redacted]; tail=A'); + }); + + it('masks every LF/TAB split without losing the surrounding public identity', async () => { + for (const credential of bodies) { + for (const separator of ['\n', '\t']) { + for (let index = 1; index < credential.length; index += 1) { + const split = credential.slice(0, index) + separator + credential.slice(index); + for (const tail of ['A', 'B']) { + const input = `provider rejected ${split}; public=${tail}`; + expectCredentialProjection(redactSecretShapedText(input)).toBe( + `provider rejected [redacted]; public=${tail}`, + ); + expectCredentialProjection((await boundForModel(input, BIG, {})).summary).toBe( + `provider rejected [redacted]; public=${tail}`, + ); + } + } + } + } + }); + + it.each(['\n', '\t'])( + 'masks split schemes, finite header names and URL userinfo (%j)', + (separator) => { + for (const input of [ + `Authorization: Be${separator}arer opaque-credential-Z29; public=A`, + `X-Api-${separator}Key: opaque-credential-Z29; public=A`, + `https://alice:opaque${separator}password@provider.invalid/v1; public=A`, + ]) { + const output = redactSecretShapedText(input); + expectCredentialProjection(output).not.toContain('opaque'); + expectCredentialProjection(output).not.toContain('password@'); + expectCredentialProjection(output).toContain('; public=A'); + } + }, + ); + + it.each(['?', '#'])( + 'masks raw %s within password userinfo and preserves contact queries', + (separator) => { + expectCredentialProjection( + redactSecretShapedText(`https://alice:pa${separator}ss@provider.invalid/v1`), + ).toBe('https://[redacted]@provider.invalid/v1'); + for (const host of ['provider.invalid', 'provider.invalid:8443', '[::1]:8443']) { + const publicUrl = `https://${host}${separator}contact=alice@example.invalid`; + expectCredentialProjection(redactSecretShapedText(publicUrl)).toBe(publicUrl); + } + }, + ); + + it('keeps credential-free LF/TAB content byte-for-byte distinct', () => { + for (const input of [ + 'provider\nretry\tlater A', + 'provider\nretry\tlater B', + 'x-trace-id:\nordinary', + 'not_a_sk-short', + ]) { + expectCredentialProjection(redactSecretShapedText(input)).toBe(input); + } + }); +}); + +describe('PR95 B2 projection boundaries and admitted-scale controls', () => { + it('retains prefix evidence inside a header and preserves the same public tail after multiple masks', () => { + const credential = `sk-proj-${'A'.repeat(24)}\n${'B'.repeat(24)}`; + const result = redactSecretShapedText( + `Bearer ${credential}; https://u:pa\tss@host.invalid/a; public=B`, + ); + expectCredentialProjection(result).not.toContain('AAAA'); + expectCredentialProjection(result).not.toContain('BBBB'); + expectCredentialProjection(result).toContain('host.invalid/a; public=B'); + }); + + it('keeps the original left boundary when LF/TAB occurs elsewhere or immediately before a key', () => { + const embedded = `wordsk-proj-${'A'.repeat(32)};\nordinary`; + expectCredentialProjection(redactSecretShapedText(embedded)).toBe(embedded); + const splitEmbedded = `wordsk-proj-${'A'.repeat(32)}\n${'B'.repeat(32)}; public=A`; + expectCredentialProjection(redactSecretShapedText(splitEmbedded)).toBe(splitEmbedded); + expectCredentialProjection( + redactSecretShapedText(`rejected\nsk-proj-AAA\t${'B'.repeat(32)}; public=A`), + ).toBe('rejected\n[redacted]; public=A'); + }); + + it.each(['?', '#'])( + 'preserves public %s tails after userinfo and masks raw password assignments', + (separator) => { + expectCredentialProjection( + redactSecretShapedText( + `https://u:password@host.invalid${separator}contact=a@example.invalid`, + ), + ).toBe(`https://[redacted]@host.invalid${separator}contact=a@example.invalid`); + expectCredentialProjection( + redactSecretShapedText(`https://u:pa${separator}ss=value@host.invalid/v1`), + ).toBe('https://[redacted]@host.invalid/v1'); + }, + ); + + it('projects an 8 MiB split credential and public payload without clipping or RegExp stack growth', () => { + const body = 'A'.repeat(8 * 1024 * 1024 - 256); + const projected = redactSecretShapedText(`sk-proj-${body}\nBBBBBBBB; public=A`); + // Fail with bounded metadata if a causal removal exposes this entire large credential. + expectCredentialProjection(projected.length).toBe('[redacted]; public=A'.length); + expectCredentialProjection(projected).toBe('[redacted]; public=A'); + const publicText = `${body}\n public-tail-B`; + expectCredentialProjection(redactSecretShapedText(publicText)).toBe(publicText); + }); +}); diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 42fead8a..c714b618 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -120,6 +120,83 @@ function redactInlineMediaForText(value: unknown, seen: WeakSet): unknow } } +// One alternation preserves outer spans when credential families overlap. Bounded lookaheads +// enforce minimum lengths before simple runs; no admitted-size repeated groups use the regex stack. +const STANDALONE_CREDENTIAL = + /\b(?:sk-(?=[\w-]{16})[\w-]+|sk_(?:live|test)_(?=[A-Za-z0-9]{16})[A-Za-z0-9]+|A[KSB]IA[0-9A-Z]{16}|gh[pousr]_(?=[A-Za-z0-9]{20})[A-Za-z0-9]+|github_pat_(?=\w{20})\w+|glpat-(?=[\w-]{16})[\w-]+|xox[baprs]-(?=[A-Za-z0-9-]{10})[A-Za-z0-9-]+|AIza(?=[\w-]{30})[\w-]+|ya29\.(?=[\w-]{20})[\w-]+|hf_(?=[A-Za-z0-9]{20})[A-Za-z0-9]+|npm_(?=[A-Za-z0-9]{20})[A-Za-z0-9]+|eyJ(?=[\w-]{10})[\w-]+\.(?=[\w-]{10})[\w-]+\.(?=[\w-]{6})[\w-]+)/g; // NOSONAR — finite credential families, see the projection policy below +// The joined probe checks its boundary in the ORIGINAL text. Removing LF/TAB must not erase +// a legitimate boundary or manufacture one inside a public word. The source is the literal above. +const JOINED_STANDALONE_CREDENTIAL = new RegExp(STANDALONE_CREDENTIAL.source.slice(2), 'g'); + +const SCHEME_CREDENTIAL = + /\b(b[\n\t]*e[\n\t]*a[\n\t]*r[\n\t]*e[\n\t]*r|b[\n\t]*a[\n\t]*s[\n\t]*i[\n\t]*c|t[\n\t]*o[\n\t]*k[\n\t]*e[\n\t]*n)\s+(?=[\w.~+/-]{8})[\w.~+/-]+={0,2}/gi; + +type SecretSpanVisitor = (start: number, end: number, replacement: string) => void; + +/** + * LF/TAB may split finite credential shapes. Map recognized spans back onto the original text; + * ordinary spaces remain boundaries and public text is never globally normalized for identity. + * Token-character continuations after LF/TAB are intentionally part of a recognized credential. + * The two cursors advance monotonically, with no per-character offset array or recursive matcher. + */ +function redactJoinedCredentialSpans( + text: string, + visit: (joined: string, mask: SecretSpanVisitor) => void, + boundary?: RegExp, +): string { + if (!/[\n\t]/.test(text)) return text; + const joined = text.replace(/[\n\t]+/g, ''); + const parts: string[] = []; + let originalOffset = 0; + let joinedOffset = 0; + let copied = 0; + function advance(target: number): number { + while (joinedOffset < target && originalOffset < text.length) { + const char = text[originalOffset++]; + if (char !== '\n' && char !== '\t') joinedOffset += 1; + } + return originalOffset; + } + visit(joined, (start, end, replacement) => { + let originalStart = advance(start); + while (text[originalStart] === '\n' || text[originalStart] === '\t') originalStart += 1; + const originalEnd = advance(end); + if (boundary?.test(text[originalStart - 1] ?? '')) return; + if (!/[\n\t]/.test(text.slice(originalStart, originalEnd))) return; + parts.push(text.slice(copied, originalStart), replacement); + copied = originalEnd; + }); + if (parts.length === 0) return text; + parts.push(text.slice(copied)); + return parts.join(''); +} + +function redactSplitCredentials(text: string): string { + if (!/[\n\t]/.test(text)) return text; + const urls = redactJoinedCredentialSpans(text, visitUrlUserinfo); + const keys = redactJoinedCredentialSpans( + urls, + (joined, mask) => { + for (const match of joined.matchAll(JOINED_STANDALONE_CREDENTIAL)) { + mask(match.index, match.index + match[0].length, '[redacted]'); + } + }, + /\w/, + ); + const schemes = keys.replace(SCHEME_CREDENTIAL, '$1 [redacted]'); + return redactJoinedCredentialSpans( + schemes, + (joined, mask) => { + // Finite literal header names only. Ordinary spaces delimit diagnostic text and values. + const header = /(?:authorization|(?:x-)?api-key|x-goog-api-key)["']?\s*:/gi; + for (const match of joined.matchAll(header)) { + mask(match.index, match.index + match[0].length, match[0]); + } + }, + /[A-Za-z0-9_-]/, + ); +} + /** * Redact secret-SHAPED substrings from observability and journal projections — NOT the model-facing value * (the model must still see the real tool result @@ -134,7 +211,7 @@ function redactInlineMediaForText(value: unknown, seen: WeakSet): unknow */ export function redactSecretShapedText(text: string): string { return ( - redactOpaqueQueryValues(redactUrlUserinfo(text)) + redactOpaqueQueryValues(redactUrlUserinfo(redactSplitCredentials(text))) // A PEM private-key block (multi-line, space-separated markers the `private_key` key-pattern can't see). // The body span is bounded (`{0,20000}?`, lazy) so an unterminated block can't drive an unbounded scan. .replace( @@ -146,7 +223,7 @@ export function redactSecretShapedText(text: string): string { // For every open-ended token minimum below, a bounded lookahead enforces the same floor before // one simple greedy run. V8 can exhaust its RegExp stack on an admitted-scale `{N,}` run; the // equivalent `(?=class{N})class+` form keeps the whole value instead of clipping a credential. - .replace(/\b(bearer|basic|token)\s+(?=[\w.~+/-]{8})[\w.~+/-]+={0,2}/gi, '$1 [redacted]') + .replace(SCHEME_CREDENTIAL, '$1 [redacted]') // A secret-ish key (bounded wrappers keep this ReDoS-safe) + `=`/`:` + its value. The optional `["']?` // BEFORE the separator catches the JSON `"access_token":"…"` shape (an OAuth/egress response body). The // value has QUOTED and UNQUOTED branches. A quoted value ends at the first UNESCAPED matching quote, @@ -174,7 +251,7 @@ export function redactSecretShapedText(text: string): string { // the tighter `[A-Za-z0-9]` / `[A-Za-z0-9-]` families keep their narrower class (no `_`). .replace( // NOSONAR — the per-regex complexity is the deliberate, documented exception explained above - /\b(?:sk-(?=[A-Za-z0-9]{16})[A-Za-z0-9]+|sk_(?:live|test)_(?=[A-Za-z0-9]{16})[A-Za-z0-9]+|A[KSB]IA[0-9A-Z]{16}|gh[pousr]_(?=[A-Za-z0-9]{20})[A-Za-z0-9]+|github_pat_(?=\w{20})\w+|glpat-(?=[\w-]{16})[\w-]+|xox[baprs]-(?=[A-Za-z0-9-]{10})[A-Za-z0-9-]+|AIza(?=[\w-]{30})[\w-]+|ya29\.(?=[\w-]{20})[\w-]+|hf_(?=[A-Za-z0-9]{20})[A-Za-z0-9]+|npm_(?=[A-Za-z0-9]{20})[A-Za-z0-9]+|eyJ(?=[\w-]{10})[\w-]+\.(?=[\w-]{10})[\w-]+\.(?=[\w-]{6})[\w-]+)/g, + STANDALONE_CREDENTIAL, '[redacted]', ) ); @@ -210,33 +287,52 @@ function redactOpaqueQueryValues(text: string): string { /** * One whole authority, with its last `@` separating userinfo from the host. Prefix-only * matches can mask the first password fragment and leave another `@` plus a credential - * tail in the durable digest. Delimiters stop before paths, queries, fragments or adjacent - * JSON fields; a short colonless vanity handle retains the existing non-secret policy. + * tail in the durable digest. Paths and JSON delimiters stop the scan; queries/fragments + * after an existing userinfo separator remain public. Before that separator, raw ?/# in a + * colon-bearing password are masked, except an explicit public host:port prefix; a short colonless vanity handle retains the existing non-secret policy. */ -function redactUrlUserinfo(text: string): string { - // This necessary delimiter also avoids retrying a scheme prefix across large non-URL values. - if (!text.includes('://')) return text; +function isPublicHostPort(text: string): boolean { + const colon = text.lastIndexOf(':'); + if (colon < 0 || !/^\d{1,5}$/.test(text.slice(colon + 1))) return false; + const hostname = text.slice(0, colon); + return hostname.includes('.') || hostname === 'localhost' || /^\[[\da-f:]+\]$/i.test(hostname); +} + +function visitUrlUserinfo(text: string, mask: SecretSpanVisitor): void { + if (!text.includes('://')) return; const scheme = /[a-z][\w+.-]{0,31}:\/\//gi; - const delimiter = /[/\s?#"',}\]]/g; - const parts: string[] = []; - let cursor = 0; + const delimiter = /[/\s"',}\]]/g; for (let match = scheme.exec(text); match !== null; match = scheme.exec(text)) { - const authorityStart = scheme.lastIndex; - delimiter.lastIndex = authorityStart; + const start = scheme.lastIndex; + delimiter.lastIndex = start; const end = delimiter.exec(text)?.index ?? text.length; - const authority = text.slice(authorityStart, end); - const at = authority.lastIndexOf('@'); + const authority = text.slice(start, end); + const firstQuery = authority.search(/[?#]/); + const authorityAt = firstQuery < 0 ? -1 : authority.lastIndexOf('@', firstQuery - 1); + const at = authorityAt >= 0 ? authorityAt : authority.lastIndexOf('@'); if (at < 0) continue; const userinfo = authority.slice(0, at); const colon = userinfo.indexOf(':'); + const query = userinfo.search(/[?#]/); + // Raw ?/# can belong to a malformed password. An explicit host:port prefix instead + // introduces a public query/fragment; its contact values must not become userinfo. + if (query >= 0 && (colon < 0 || colon > query || isPublicHostPort(userinfo.slice(0, query)))) + continue; const hasPassword = colon >= 0 && colon < userinfo.length - 1; if (!hasPassword && userinfo.length < 16) continue; - parts.push(text.slice(cursor, authorityStart), '[redacted]'); - cursor = authorityStart + at; - // Do not consume the host while examining it: `host;other://...` must leave the next - // scheme available to this scanner, rather than swallowing `other:` at its first slash. - scheme.lastIndex = cursor + 1; + mask(start, start + at, '[redacted]'); + // Skip the recognized userinfo without hiding a later scheme in the public host/tail. + scheme.lastIndex = start + at + 1; } +} + +function redactUrlUserinfo(text: string): string { + const parts: string[] = []; + let cursor = 0; + visitUrlUserinfo(text, (start, end, replacement) => { + parts.push(text.slice(cursor, start), replacement); + cursor = end; + }); if (parts.length === 0) return text; parts.push(text.slice(cursor)); return parts.join(''); @@ -476,10 +572,9 @@ function toText(result: unknown): string { } function makeSummary(text: string): string { - // Normalize the complete materialized text before recognition, preserving the one-line diagnostic - // policy even for quoted multiline values. Clip only after recognition: a closing quote or URL + // Recognize LF/TAB-split shapes in the complete materialized text before one-line normalization. Clip only after recognition: a closing quote or URL // authority separator can be beyond the preview. Raw model/spill text stays unchanged. - const oneLine = redactSecretShapedText(text.replace(/\s+/g, ' ').trim()); + const oneLine = redactSecretShapedText(redactSplitCredentials(text).replace(/\s+/g, ' ').trim()); return oneLine.length <= SUMMARY_MAX ? oneLine : `${oneLine.slice(0, SUMMARY_MAX)}…`; } From e4bb04e3dc8c3dd11d5b8f23c833224a6e716dd5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 11:46:43 +0300 Subject: [PATCH 185/222] fix(llm): preserve split credential evidence before masking Keep every finite diagnostic credential family observable until its complete span is masked. Cover modern key formats, split labels and URL userinfo without exposing a recognized suffix. Refs: ADR-0011, ADR-0110 Co-Authored-By: Codex --- packages/llm/src/llm-error.test.ts | 9 +- packages/llm/src/scrub-secrets.test.ts | 94 ++++++++++++++++++++ packages/llm/src/scrub-secrets.ts | 116 ++++++++++++++++++------- 3 files changed, 183 insertions(+), 36 deletions(-) diff --git a/packages/llm/src/llm-error.test.ts b/packages/llm/src/llm-error.test.ts index d92399bb..b5170ab4 100644 --- a/packages/llm/src/llm-error.test.ts +++ b/packages/llm/src/llm-error.test.ts @@ -301,6 +301,7 @@ describe('finite secret query names (#196)', () => { }); it('SECRETS_CAUSAL_OVERLAP_ORDER preserves prose after every completely masked credential', () => { + // A plain space is a hard boundary; LF/TAB alone can now continue a finite prefixed key. for (const [credential, masked] of [ [['sk-', 'ant-api03-', overlapSuffix].join(''), '[REDACTED]'], [['AI', 'zaSyA', overlapSuffix].join(''), '[REDACTED]'], @@ -311,8 +312,8 @@ describe('finite secret query names (#196)', () => { ['Bearer opaque-header-Z29', 'Bearer [REDACTED]'], ['Basic dXNlcjpvcGFxdWU=', 'Basic [REDACTED]'], ]) { - const text = `${credential}\nrequest failed retry later`; - const expected = `${masked}\nrequest failed retry later`; + const text = `${credential}\n request failed retry later`; + const expected = `${masked}\n request failed retry later`; const error = makeLlmError({ provider: 'openai', kind: 'auth', message: text, code: text }); for (const output of [scrubSecrets(text), error.message, error.code]) { expect(output, 'SECRETS_CAUSAL_OVERLAP_ORDER').toBe(expected); @@ -345,8 +346,8 @@ describe('finite secret query names (#196)', () => { `?${name}=[REDACTED] request failed retry later`, ); } - expect(scrubSecrets(`key ${key}\nrequest failed retry later`)).toBe( - 'key [REDACTED]\nrequest failed retry later', + expect(scrubSecrets(`key ${key}\n request failed retry later`)).toBe( + 'key [REDACTED]\n request failed retry later', ); const alreadyMasked = '?client_secret=[REDACTED]\nrequest failed retry later'; expect(scrubSecrets(alreadyMasked)).toBe(alreadyMasked); diff --git a/packages/llm/src/scrub-secrets.test.ts b/packages/llm/src/scrub-secrets.test.ts index 80699ece..b0682858 100644 --- a/packages/llm/src/scrub-secrets.test.ts +++ b/packages/llm/src/scrub-secrets.test.ts @@ -174,3 +174,97 @@ describe('finite diagnostic credential backstop regressions (PR #95 M2)', () => }, ); }); + +function expectCredentialDiagnostic(value: unknown) { + return expect(value, 'SECRETS_B2_LLM_DIAGNOSTIC'); +} + +describe('PR95 B2 split provider/header and malformed userinfo diagnostics', () => { + it('masks every LF/TAB split including a complete-looking first key fragment', () => { + for (const prefix of ['sk-', 'sk-proj-', 'sk-svcacct-', 'sk-ant-', 'AIza']) { + const credential = `${prefix}${'A'.repeat(32)}_${'B'.repeat(32)}`; + for (const separator of ['\n', '\t']) { + for (let index = 1; index < credential.length; index += 1) { + const input = `provider rejected ${credential.slice(0, index)}${separator}${credential.slice(index)}; retry later`; + expectCredentialDiagnostic(scrubSecrets(input)).toBe('[REDACTED]'); + const error = makeLlmError({ + provider: 'openai', + kind: 'auth', + message: input, + code: input, + }); + expectCredentialDiagnostic(error.message).toBe('[REDACTED]'); + expectCredentialDiagnostic(error.code).toBe('[REDACTED]'); + } + } + } + }); + + it('masks each split position in the finite API header names', () => { + for (const name of ['api-key', 'x-api-key', 'x-goog-api-key']) { + for (const separator of ['\n', '\t']) { + for (let index = 1; index < name.length; index += 1) { + const input = `header ${name.slice(0, index)}${separator}${name.slice(index)}: opaque-Z29; retry later`; + expectCredentialDiagnostic(scrubSecrets(input)).toBe('[REDACTED]'); + } + } + } + }); + + it.each(['?', '#'])( + 'masks raw %s in userinfo while retaining host contact queries', + (separator) => { + expectCredentialDiagnostic( + scrubSecrets(`https://alice:pa${separator}ss@provider.invalid/v1`), + ).toBe('https://[REDACTED]@provider.invalid/v1'); + for (const host of ['provider.invalid', 'provider.invalid:8443', '[::1]:8443']) { + const input = `https://${host}${separator}contact=alice@example.invalid`; + expectCredentialDiagnostic(scrubSecrets(input)).toBe(input); + } + }, + ); + + it('scrubs admitted-scale key and URL values without consuming the RegExp stack', () => { + const body = 'A'.repeat(8 * 1024 * 1024 - 256); + expectCredentialDiagnostic(scrubSecrets(`provider rejected sk-proj-${body}; retry`)).toBe( + 'provider rejected [REDACTED]; retry', + ); + const url = scrubSecrets(`https://alice:${body}?ss@provider.invalid/v1`); + // A removed recognizer must fail without serializing an admitted-size credential into evidence. + expectCredentialDiagnostic(url.length).toBe('https://[REDACTED]@provider.invalid/v1'.length); + expectCredentialDiagnostic(url).toBe('https://[REDACTED]@provider.invalid/v1'); + }); +}); + +describe('PR95 B2 finite joined-view boundaries', () => { + it('does not invent a key boundary in a public word with a separate newline', () => { + const input = `wordsk-proj-${'A'.repeat(32)};\nordinary`; + expectCredentialDiagnostic(scrubSecrets(input)).toBe(input); + const splitEmbedded = `wordsk-proj-${'A'.repeat(32)}\n${'B'.repeat(32)}; retry`; + expectCredentialDiagnostic(scrubSecrets(splitEmbedded)).toBe(splitEmbedded); + expectCredentialDiagnostic( + scrubSecrets(`rejected\nsk-proj-AAA\t${'B'.repeat(32)}; retry`), + ).toBe('[REDACTED]'); + }); + + it.each(['?', '#'])( + 'preserves public %s tails after userinfo and masks raw password assignments', + (separator) => { + expectCredentialDiagnostic( + scrubSecrets(`https://u:password@host.invalid${separator}contact=a@example.invalid`), + ).toBe(`https://[REDACTED]@host.invalid${separator}contact=a@example.invalid`); + expectCredentialDiagnostic( + scrubSecrets(`https://u:pa${separator}ss=value@host.invalid/v1`), + ).toBe('https://[REDACTED]@host.invalid/v1'); + }, + ); + + it('treats LF/TAB token-character continuations as credential material even after a complete first fragment', () => { + expectCredentialDiagnostic( + scrubSecrets(`sk-proj-${'A'.repeat(32)}\nrequest; retry later`), + ).toBe('[REDACTED]'); + expectCredentialDiagnostic( + scrubSecrets(`sk-proj-${'A'.repeat(32)}\n request; retry later`), + ).toBe('[REDACTED]\n request; retry later'); + }); +}); diff --git a/packages/llm/src/scrub-secrets.ts b/packages/llm/src/scrub-secrets.ts index 19d9e27b..0db5121c 100644 --- a/packages/llm/src/scrub-secrets.ts +++ b/packages/llm/src/scrub-secrets.ts @@ -6,9 +6,9 @@ import { stripTerminalControls } from '@relavium/shared/terminal-safe'; * the engine's `deInlineMedia` pass, leak-freedom here is an active transform at one boundary, not * per-adapter discipline. It masks finite common secret **shapes**: prefixed keys, literal secret query * names, URL userinfo and finite API-key/Bearer/Basic headers. Each non-query rule probes the original diagnostic - * independently, including terminal-normalized and whitespace-joined residual views, before any + * independently, including terminal-normalized and LF/TAB-joined views, before any * other rule can erase its identifying context. A match revealed by normalization masks the whole - * diagnostic. The finite patterns retain their word boundaries and already-masked-span limits; + * diagnostic. The finite patterns check boundaries in the original text and preserve already-masked spans; * this is not universal fragment discovery. Query names are excluded from the residual probe. * Pure and total for string input: regex replacements and a * finite residual check, with no parsing or external calls on the error path. @@ -21,26 +21,29 @@ export function scrubSecrets(text: string): string { interface SecretRule { readonly pattern: RegExp; - readonly replacement: string; - readonly joinMaskedLines?: boolean; + readonly replacement: string | ((match: string) => string); + readonly joinedPattern?: RegExp; + readonly boundary?: RegExp; } /** One private authority per existing credential family, shared by probes and output masking. */ const NON_QUERY_SECRET_RULES: readonly SecretRule[] = [ // OpenAI sk-/sk-proj-/sk-svcacct- and Anthropic sk-ant- key prefixes. - { pattern: /\bsk-(?:ant-)?[A-Za-z0-9\-_]{16,}/g, replacement: '[REDACTED]' }, - { pattern: /\bAIza[0-9A-Za-z\-_]{20,}/g, replacement: '[REDACTED]' }, - // Finite authority schemes, not URL validation. The LAST @ before /, ? or # ends userinfo, - // including malformed raw @/colon characters in a password; path/query @ remain public text. + { pattern: /\bsk-(?=[\w-]{16})[\w-]+/g, replacement: '[REDACTED]', boundary: /\w/ }, + { pattern: /\bAIza(?=[\w-]{20})[\w-]+/g, replacement: '[REDACTED]', boundary: /\w/ }, + // Finite authority schemes, not URL validation. Raw ?/# before the first @ may belong + // to a password; after an existing @ they begin public query/fragment text. { - pattern: /\b((?:https?|wss?|ftps?):\/\/)[^/\s?#]*@/gi, - replacement: '$1[REDACTED]@', - joinMaskedLines: true, + pattern: /\b(?:https?|wss?|ftps?):\/\/[^/\s]*@/gi, + replacement: maskUrlUserinfo, + boundary: /\w/, }, // Exact literal header names, also in quoted diagnostic objects. No arbitrary header discovery. { pattern: /(^|[^A-Za-z0-9_-])((?:x-)?api-key|x-goog-api-key)(["']?\s*:\s*["']?)[^\s"',;}]+/gi, replacement: '$1$2$3[REDACTED]', + joinedPattern: /(?:(?:x-)?api-key|x-goog-api-key)["']?\s*:/gi, + boundary: /[A-Za-z0-9_-]/, }, // LF/TAB may split the finite scheme LABEL. Preserve the required label/value separator and // ordinary following prose; opaque value fragments cannot be distinguished from that prose. @@ -56,34 +59,83 @@ function normalizeSecretProbe(text: string): string { return stripTerminalControls(text); } +function isPublicHostPort(text: string): boolean { + const colon = text.lastIndexOf(':'); + if (colon < 0 || !/^\d{1,5}$/.test(text.slice(colon + 1))) return false; + const hostname = text.slice(0, colon); + return hostname.includes('.') || hostname === 'localhost' || /^\[[\da-f:]+\]$/i.test(hostname); +} + +function maskUrlUserinfo(match: string): string { + const start = match.indexOf('://') + 3; + const authority = match.slice(start); + const firstQuery = authority.search(/[?#]/); + const authorityAt = firstQuery < 0 ? -1 : authority.lastIndexOf('@', firstQuery - 1); + const at = authorityAt >= 0 ? authorityAt : authority.length - 1; + const userinfo = authority.slice(0, at); + const query = userinfo.search(/[?#]/); + const colon = userinfo.indexOf(':'); + // Raw ?/# may occur inside a malformed password. Preserve host query/fragment assignments, + // including hosts with ports, rather than interpreting their contact values as credentials. + if (query >= 0 && (colon < 0 || colon > query || isPublicHostPort(userinfo.slice(0, query)))) + return match; + return `${match.slice(0, start)}[REDACTED]${authority.slice(at)}`; +} + +function applyRule(text: string, rule: SecretRule): string { + return typeof rule.replacement === 'string' + ? text.replace(rule.pattern, rule.replacement) + : text.replace(rule.pattern, rule.replacement); +} + +/** + * Probe LF/TAB joins before any masking can destroy the prefix. Ordinary spaces remain boundaries. + * Check the left boundary against the original string: a join must neither erase an existing + * boundary nor invent a match inside a public word. Only finite prefix/header/URL families join; + * opaque Bearer/Basic values and literal query names keep their established delimiter policy. + */ +function hasSplitCredential(text: string, joined: string, rule: SecretRule): boolean { + if (rule.boundary === undefined) return false; + const pattern = + rule.joinedPattern ?? new RegExp(rule.pattern.source.slice(2), rule.pattern.flags); + let originalOffset = 0; + let joinedOffset = 0; + function advance(target: number): number { + while (joinedOffset < target && originalOffset < text.length) { + const char = text[originalOffset++]; + if (char !== '\n' && char !== '\t') joinedOffset += 1; + } + return originalOffset; + } + for (const match of joined.matchAll(pattern)) { + let start = advance(match.index); + while (text[start] === '\n' || text[start] === '\t') start += 1; + const end = advance(match.index + match[0].length); + if (rule.boundary.test(text[start - 1] ?? '')) continue; + if (!/[\n\t]/.test(text.slice(start, end))) continue; + // A malformed URL candidate may actually be a public contact query. + if (typeof rule.replacement === 'function' && rule.replacement(match[0]) === match[0]) continue; + return true; + } + return false; +} + function scrubNonQuerySecrets(text: string): string { const normalized = normalizeSecretProbe(text); + const joined = /[\n\t]/.test(normalized) ? normalized.replace(/[\n\t]+/g, '') : undefined; for (const rule of NON_QUERY_SECRET_RULES) { - const rawMasked = text.replace(rule.pattern, rule.replacement); - const normalizedMasked = normalized.replace(rule.pattern, rule.replacement); - // Keep required header whitespace: joining first would hide a newly revealed Bearer/Basic label. + const rawMasked = applyRule(text, rule); + const normalizedMasked = applyRule(normalized, rule); if (normalizeSecretProbe(rawMasked) !== normalizedMasked) return '[REDACTED]'; - // A raw @ before LF/TAB can create a partial URL mask while another @ still ends the actual - // authority. Compare this rule's full views before treating its masks as complete delimiters. - // Spaces remain boundaries here so separate same-line diagnostics cannot merge authorities. - if (rule.joinMaskedLines) { - const joined = normalized.replace(/[\n\t]+/g, ''); - if ( - joined.replace(rule.pattern, rule.replacement) !== normalizedMasked.replace(/[\n\t]+/g, '') - ) { - return '[REDACTED]'; - } - } - // Other families must not destroy this rule's prefix; only its own complete masks delimit spans. + if (joined !== undefined && hasSplitCredential(normalized, joined, rule)) return '[REDACTED]'; + // Retain the existing broader whitespace probe only within this rule's still-unmasked spans. + // It detects incomplete prefixes separated by spaces without consuming prose after a full mask. for (const remaining of normalizedMasked.split('[REDACTED]')) { - const joined = remaining.replace(/\s+/g, ''); - if (joined.replace(rule.pattern, rule.replacement) !== joined) return '[REDACTED]'; + const residual = remaining.replace(/\s+/g, ''); + if (applyRule(residual, rule) !== residual) return '[REDACTED]'; } } - return NON_QUERY_SECRET_RULES.reduce( - (masked, rule) => masked.replace(rule.pattern, rule.replacement), - text, - ); + return NON_QUERY_SECRET_RULES.reduce((masked, rule) => applyRule(masked, rule), text); } /** Exact literal query names, applied last so a value cannot erase another family's evidence. */ From 87b7a6979cbd4f6fd137a035d81c2f492aa7a03e Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 11:46:43 +0300 Subject: [PATCH 186/222] fix(cli): retain safe native and terminal cleanup ownership Never signal PID-less failed spawns. Bound unacknowledged cleanup, preserve primary failures and seal late publication. Pin every notice forwarding path, recovered close and native lifecycle regression; clarify shared history diagnostics. Refs: ADR-0103, ADR-0110, ADR-0113 Co-Authored-By: Codex --- .../src/chat/session-chat-terminal.test.ts | 22 + apps/cli/src/chat/session-chat-terminal.ts | 8 +- apps/cli/src/chat/session-host.test.ts | 36 +- apps/cli/src/commands/chat-export.test.ts | 20 +- apps/cli/src/commands/chat-export.ts | 4 +- apps/cli/src/commands/descriptors.ts | 14 +- .../dispatch-native-forwarding.test.ts | 8 +- apps/cli/src/commands/drive.ts | 1 + .../commands/fs-workspace-admission.test.ts | 23 +- apps/cli/src/commands/logs.ts | 4 +- .../management-owner-integration.test.ts | 36 +- .../commands/model-discovery-lifetime.test.ts | 6 + .../notice-context-forwarding.test.ts | 47 ++- .../notice-context-runtime-forwarding.test.ts | 186 +++++++++ .../commands/partial-db-acquisition.test.ts | 25 +- .../cli/src/commands/provider-headers.test.ts | 12 +- .../src/commands/provider-keychain.test.ts | 12 +- .../src/commands/workflow-lifetime.test.ts | 42 ++ apps/cli/src/commands/workflow-lifetime.ts | 3 + apps/cli/src/engine/mcp-servers.ts | 34 +- .../engine/mcp-session-preparation.test.ts | 53 +++ .../src/engine/mcp-signal-teardown.test.ts | 383 ------------------ apps/cli/src/engine/mcp-signal-teardown.ts | 222 ---------- .../cli/src/engine/model-catalog-port.test.ts | 5 +- .../engine/model-refresh-admission.test.ts | 5 +- apps/cli/src/engine/model-refresh.ts | 4 + apps/cli/src/engine/tool-host/os.test.ts | 47 ++- apps/cli/src/engine/tool-host/os.ts | 5 +- apps/cli/src/harness/concurrency.e2e.test.ts | 8 +- apps/cli/src/harness/mcp-orphan.e2e.test.ts | 62 +-- .../src/process/background-failure.test.ts | 148 ------- apps/cli/src/process/background-failure.ts | 104 ----- apps/cli/src/process/cli-error.test.ts | 5 +- apps/cli/src/process/cli-error.ts | 2 +- .../src/process/native-process-owner.test.ts | 58 +++ apps/cli/src/process/native-process-owner.ts | 29 +- apps/cli/src/process/native-prompt-io.test.ts | 5 +- apps/cli/src/process/native-prompt-io.ts | 5 +- apps/cli/src/process/options.ts | 25 +- apps/cli/src/process/render-error.test.ts | 65 ++- apps/cli/src/process/render-error.ts | 5 +- apps/cli/src/process/render-user-facing.ts | 19 +- apps/cli/src/render/hatches.test.ts | 15 + apps/cli/src/render/hatches.ts | 16 +- apps/cli/src/render/run-job-control.test.ts | 41 ++ apps/cli/src/render/run-job-control.ts | 24 +- apps/cli/src/render/tui/chat-ink.tsx | 5 +- .../src/render/tui/chat-picker-stop.test.tsx | 57 +++ .../render/tui/home-completion-stop.test.ts | 2 +- .../src/render/tui/home-controller.test.ts | 56 +++ apps/cli/src/render/tui/home-controller.ts | 9 +- .../cli/src/render/tui/run-view-model.test.ts | 11 + apps/cli/src/render/tui/run-view-model.ts | 3 +- packages/shared/src/run-history-errors.ts | 5 +- 54 files changed, 998 insertions(+), 1053 deletions(-) create mode 100644 apps/cli/src/commands/notice-context-runtime-forwarding.test.ts delete mode 100644 apps/cli/src/engine/mcp-signal-teardown.test.ts delete mode 100644 apps/cli/src/engine/mcp-signal-teardown.ts delete mode 100644 apps/cli/src/process/background-failure.test.ts delete mode 100644 apps/cli/src/process/background-failure.ts create mode 100644 apps/cli/src/render/tui/chat-picker-stop.test.tsx diff --git a/apps/cli/src/chat/session-chat-terminal.test.ts b/apps/cli/src/chat/session-chat-terminal.test.ts index 3b5085ce..9ee9ba58 100644 --- a/apps/cli/src/chat/session-chat-terminal.test.ts +++ b/apps/cli/src/chat/session-chat-terminal.test.ts @@ -41,6 +41,28 @@ function fixture(write?: (text: string) => Promise) { } describe('persistent chat terminal and actual child mounts', () => { + it('reports late adopted job disposal refusal without escaping the sealed terminal', async () => { + const f = fixture(); + await f.terminal.close(); + const primary = new Error('PRIVATE_LATE_JOB_DISPOSE'); + const native = gate(); + const dispose = vi.fn(() => { + throw primary; + }); + expect(() => + f.terminal.adoptJob({ + requestSuspend: vi.fn(), + dispose, + finish: () => native.promise, + released: native.promise, + }), + ).not.toThrow(); + expect(dispose).toHaveBeenCalledOnce(); + expect(f.lifetime.surface.work.firstFailure?.cause).toBe(primary); + expect(f.registry.observations.exitCode()).toBe(9); + native.release(); + await turn(); + }); it('retains a partially registered exit fallback without acknowledging terminal cleanup', async () => { const f = invocationFixture(); const lifetime = new SessionChatLifetime(f.invocation, { diff --git a/apps/cli/src/chat/session-chat-terminal.ts b/apps/cli/src/chat/session-chat-terminal.ts index 01c2c0be..714aa5c2 100644 --- a/apps/cli/src/chat/session-chat-terminal.ts +++ b/apps/cli/src/chat/session-chat-terminal.ts @@ -183,7 +183,13 @@ export class SessionChatTerminal { void job.released.then(() => { this.#jobs.delete(job); }, this.#report); - if (this.#sealed) job.dispose(); + if (this.#sealed) { + try { + job.dispose(); + } catch (cause) { + this.#report(cause); + } + } }; readonly close = (): Promise => this.#owner.close(); diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 1542d732..40bab87d 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -1783,13 +1783,19 @@ it('reads the attached initial usage snapshot once across a streamed turn', asyn describe('owned session MCP startup cancellation', () => { it.each( (['fresh', 'resumed'] as const).flatMap((surface) => - (['caller-unlink', 'owner-unlink', 'partial-add'] as const).map((fault) => ({ - surface, - fault, - })), + (['caller-unlink', 'owner-unlink', 'partial-add', 'partial-add-during-abort'] as const).map( + (fault) => ({ + surface, + fault, + obligation: + fault === 'partial-add' + ? 'partial attachment and held close' + : 'discovery cancellation and held close', + }), + ), ), )( - '$surface retains $fault uncertainty without losing discovery cancellation or inventing close ACK', + '$surface retains $fault uncertainty through $obligation without inventing close ACK', async ({ surface, fault }) => { const f = invocationFixture(); const owner = new SessionWorkOwner(f.invocation, 'chat', { @@ -1797,6 +1803,7 @@ describe('owned session MCP startup cancellation', () => { cleanup: () => Promise.resolve(), }); const primary = new Error('foreign caller signal fault'); + const partialAdd = fault === 'partial-add' || fault === 'partial-add-during-abort'; let aborted = false; let allowRemoval = false; const listeners = new Set<() => void>(); @@ -1806,10 +1813,14 @@ describe('owned session MCP startup cancellation', () => { }, addEventListener: (_type, listener) => { listeners.add(listener); - if (fault === 'partial-add') throw primary; + if (fault === 'partial-add-during-abort') { + aborted = true; + for (const attached of [...listeners]) attached(); + } + if (partialAdd) throw primary; }, removeEventListener: (_type, listener) => { - if (!allowRemoval && fault !== 'partial-add') throw primary; + if (!allowRemoval && !partialAdd) throw primary; listeners.delete(listener); }, }; @@ -1817,6 +1828,7 @@ describe('owned session MCP startup cancellation', () => { const rawDiscovery = new Promise<[]>((resolve) => { releaseDiscovery = () => resolve([]); }); + const cancelled = vi.fn(); const listTools = vi.fn((signal?: AbortSignalLike) => raceDeadline( 'fs', @@ -1824,7 +1836,10 @@ describe('owned session MCP startup cancellation', () => { { startedAt: Date.now(), timeoutMs: 15_000 }, () => rawDiscovery, signal, - ), + ).catch((cause: unknown) => { + if (cause instanceof McpAbortedError) cancelled(); + throw cause; + }), ); let acknowledgeClose: () => void = () => undefined; const closeAck = new Promise((resolve) => { @@ -1904,7 +1919,7 @@ describe('owned session MCP startup cancellation', () => { ); try { await vi.waitFor(() => expect(listTools).toHaveBeenCalledOnce()); - if (fault !== 'partial-add') { + if (!partialAdd) { expect(() => { if (fault === 'owner-unlink') owner.stop(); else { @@ -1914,6 +1929,9 @@ describe('owned session MCP startup cancellation', () => { }, 'FOREIGN_UNLINK_CANNOT_SUPPRESS_MCP_CANCELLATION').not.toThrow(); } await vi.waitFor(() => expect(close).toHaveBeenCalledOnce()); + if (fault === 'partial-add-during-abort') + expect(cancelled, 'PARTIAL_ADD_ABORT_REACHES_ENTERED_DISCOVERY').toHaveBeenCalledOnce(); + if (fault === 'partial-add') expect(cancelled).not.toHaveBeenCalled(); expect(settled, 'SUBSCRIPTION_FAULT_IS_NOT_NATIVE_CLOSE_ACK').toBe(false); expect(owner.hasCleanupUncertainty).toBe(true); expect(owner.firstFailure?.cause).toBe(primary); diff --git a/apps/cli/src/commands/chat-export.test.ts b/apps/cli/src/commands/chat-export.test.ts index f1b86e96..f5d72a30 100644 --- a/apps/cli/src/commands/chat-export.test.ts +++ b/apps/cli/src/commands/chat-export.test.ts @@ -7,6 +7,7 @@ import { AgentSchema, type AgentSessionRecord, type SessionMessage } from '@rela import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { OpenedSessionStore } from '../history/session-open.js'; +import { CliError } from '../process/cli-error.js'; import { EXIT_CODES } from '../process/exit-codes.js'; import type { GlobalOptions } from '../process/options.js'; import { captureIo, parseNdjson } from '../test-support.js'; @@ -222,6 +223,23 @@ describe('chatExportCommand (2.P)', () => { expect(existsSync(join(cwd, 'custom/out.yaml'))).toBe(true); }); + it('preserves an unexpected provenance fault after the scaffold has been written', () => { + const primary = new TypeError('PRIVATE_EXPORT_BUG'); + opened = { + ...opened, + store: { + ...opened.store, + updateSession: () => { + throw primary; + }, + }, + }; + const { io, err } = captureIo(); + expect(() => chatExportCommand({ sessionId: 's1', force: false }, deps(io))).toThrow(primary); + expect(existsSync(join(cwd, 's1.relavium.yaml'))).toBe(true); + expect(err()).not.toContain('could not mark the session exported'); + }); + it('degrades a row-mark failure to a stderr warning — the scaffold still lands, exit 0', () => { // updateSession throws (e.g. a locked db): the file write is the durable contract, so the export still // succeeds (exit 0) with a stderr note rather than failing an export already on disk. @@ -230,7 +248,7 @@ describe('chatExportCommand (2.P)', () => { store: { ...opened.store, updateSession: () => { - throw new Error('db locked'); + throw new CliError('storage_unavailable', ''); }, }, }; diff --git a/apps/cli/src/commands/chat-export.ts b/apps/cli/src/commands/chat-export.ts index 2e7da3c8..ec7e9ec4 100644 --- a/apps/cli/src/commands/chat-export.ts +++ b/apps/cli/src/commands/chat-export.ts @@ -1,6 +1,7 @@ import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { acquireCommandResource, type CommandLifetime } from '../process/command-lifetime.js'; import { closeQuietly } from '../process/close-quietly.js'; +import { toUserFacing } from '../process/errors.js'; import { SessionExportedEventSchema } from '@relavium/shared'; import { exportSession, SESSION_EXPORT_REVIEW_NOTICE } from '../chat/export.js'; @@ -89,7 +90,8 @@ export function chatExportCommand( exportedWorkflowPath: result.path, updatedAt: exportedAt, }); - } catch { + } catch (cause) { + if (toUserFacing(cause).code !== 'storage_unavailable') throw cause; deps.io.writeErr('note: scaffold written but could not mark the session exported\n'); } diff --git a/apps/cli/src/commands/descriptors.ts b/apps/cli/src/commands/descriptors.ts index 9ca7bbf2..ab595735 100644 --- a/apps/cli/src/commands/descriptors.ts +++ b/apps/cli/src/commands/descriptors.ts @@ -50,6 +50,18 @@ export interface CommandDescriptor { readonly landsIn?: string; } export interface GlobalOptionDescriptor extends OptionDescriptor { + readonly binding: + | 'json' + | 'color' + | 'cwd' + | 'config' + | 'allowFsRoots' + | 'noAltScreen' + | 'noMouse' + | 'verbose' + | 'quiet' + | 'help' + | 'version'; readonly scope: 'position-independent'; readonly owner: 'extractor' | 'commander'; } @@ -1136,7 +1148,7 @@ export const COMMAND_DESCRIPTORS: readonly CommandDescriptor[] = freezeData([ ] satisfies readonly CommandDescriptor[]); function globalOption( - binding: string, + binding: GlobalOptionDescriptor['binding'], spellings: readonly string[], description: string, details: Partial = {}, diff --git a/apps/cli/src/commands/dispatch-native-forwarding.test.ts b/apps/cli/src/commands/dispatch-native-forwarding.test.ts index 0029c5a4..8cf459f9 100644 --- a/apps/cli/src/commands/dispatch-native-forwarding.test.ts +++ b/apps/cli/src/commands/dispatch-native-forwarding.test.ts @@ -4,16 +4,19 @@ import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import { createCliInvocation } from '../process/invocation.js'; +import { createCliInvocation, type CliInvocation } from '../process/invocation.js'; import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; import { captureIo } from '../test-support.js'; import { executeCommand, type CommandInput, type DispatchContext } from './dispatch.js'; import { witnessNativeToolProcess } from './native-forwarding.test-support.js'; +const receivedInvocations: CliInvocation[] = []; + /** Retain the real production dependency projections; replace only the command body with a native owner. */ async function nativeBoundary(deps: DispatchContext): Promise<0> { expect(deps.invocation, 'NATIVE_DISPATCH_INVOCATION').toBeDefined(); if (deps.invocation === undefined) throw new Error('dispatch omitted the original invocation'); + receivedInvocations.push(deps.invocation); await witnessNativeToolProcess( nativeProcessesForInvocation(deps.invocation), deps.invocation, @@ -52,6 +55,7 @@ describe('production native dispatch dependency forwarding', () => { async (id, input) => { const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-dispatch-native-'))); const invocation = createCliInvocation(); + const before = receivedInvocations.length; try { expect( await executeCommand(id, input, { @@ -60,6 +64,8 @@ describe('production native dispatch dependency forwarding', () => { invocation, }), ).toBe(0); + expect(receivedInvocations.slice(before), 'NATIVE_DISPATCH_ENTERS_ONCE').toHaveLength(1); + expect(receivedInvocations[before], 'NATIVE_DISPATCH_ORIGINAL_INVOCATION').toBe(invocation); expect(invocation.registry.activeOwners).toBe(0); } finally { rmSync(cwd, { recursive: true, force: true }); diff --git a/apps/cli/src/commands/drive.ts b/apps/cli/src/commands/drive.ts index c9db79eb..e15b64fe 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -171,6 +171,7 @@ export async function driveRun(deps: DriveRunDeps): Promise => { + lifetime.cleanupFailed(new CliError('cli_internal', '')); diagnostic( 'cleanup_failed', 'Run cleanup could not be acknowledged; input and run resources remain owned.', diff --git a/apps/cli/src/commands/fs-workspace-admission.test.ts b/apps/cli/src/commands/fs-workspace-admission.test.ts index d1aa9973..acb1f8f1 100644 --- a/apps/cli/src/commands/fs-workspace-admission.test.ts +++ b/apps/cli/src/commands/fs-workspace-admission.test.ts @@ -20,7 +20,7 @@ import { AgentSchema } from '@relavium/shared'; import { buildChatSession, buildResumedChatSession } from '../chat/session-host.js'; import { scriptedResolver } from '../chat/test-support.js'; import { loadResolvedConfig } from '../config/load.js'; -import { createFsPathPolicy } from '../engine/tool-host/fs-policy.js'; +import { createFsPathPolicy, fsPolicyScopeRoots } from '../engine/tool-host/fs-policy.js'; import { openSessionStore } from '../history/session-open.js'; import type { GlobalOptions } from '../process/options.js'; import { run } from '../run.js'; @@ -353,6 +353,11 @@ describe('secret workspace admission before canonical persistence', () => { symlinkSync(home, alias, 'junction'); fixture.trace('alias-retarget-return'); fixture.trace('resume-enter'); + let built: Awaited> | undefined; + const resume = vi.fn(async (options) => { + built = await buildResumedChatSession(options); + return built; + }); expect( await chatResumeCommand( { sessionId: 'workspace-floor-session' }, @@ -361,11 +366,19 @@ describe('secret workspace admission before canonical persistence', () => { io: captureIo().io, global: global(home), providers: scriptedResolver([]), + buildResumedSession: resume, drive: exitDriver, }, ), ).toBe(4); fixture.trace('resume-return'); + expect(resume).toHaveBeenCalledOnce(); + expect(built?.context.workingDir, 'RESUMED_HOST_RETAINS_ORIGINAL_WORKSPACE').toBe(workspace); + expect( + fsPolicyScopeRoots(built?.fsPolicy, workspace, 'project'), + 'RESUMED_FILESYSTEM_AUTHORITY_RETAINS_ORIGINAL_WORKSPACE', + ).toEqual([workspace]); + expect(readSession(fixture)?.session.context.workingDir).toBe(workspace); }); }); @@ -509,7 +522,9 @@ it('runner-abort ownership joins a held resumed builder before fixture removal a expect(existsSync(fixture.base)).toBe(true); })); -it('a real SQLite read failure still closes the manually acquired session store', () => +// These three cases test this file's fixture custody. Production acquisition and close +// behavior is exercised by partial-db-acquisition.test.ts and surface-db-acquisition.test.ts. +it('fixture integrity: a real SQLite read failure closes the manually acquired session store', () => ownCase(async (fixture) => { await seed(fixture, fixture.workspace); let native: ReturnType['sqlite'] | undefined; @@ -532,7 +547,7 @@ it('a real SQLite read failure still closes the manually acquired session store' } })); -it('fixture retirement seals late work and refuses successor setup until raw work and owners join', async () => { +it('fixture integrity: retirement seals late work until raw work and owners join', async () => { const fixture = currentCase; if (fixture === undefined) throw new Error('Missing native workspace fixture.'); let releaseRaw = (): void => {}; @@ -597,7 +612,7 @@ it('fixture retirement seals late work and refuses successor setup until raw wor expect(process.env['HOME']).toBe(next.home); }); -it('repeated teardown shares the actual join and cannot restore a later fixture environment', async () => { +it('fixture integrity: repeated teardown cannot restore a later fixture environment', async () => { const fixture = currentCase; if (fixture === undefined) throw new Error('Missing native workspace fixture.'); let release = (): void => {}; diff --git a/apps/cli/src/commands/logs.ts b/apps/cli/src/commands/logs.ts index 5a14804f..44ae981e 100644 --- a/apps/cli/src/commands/logs.ts +++ b/apps/cli/src/commands/logs.ts @@ -16,7 +16,7 @@ export interface LogsCommandArgs { } /** How many skipped-row sequence numbers the ADR-0074 §5 note names before it elides — same bound-the-diagnostic - * reasoning as `background-failure.ts`'s `MAX_REPORTED`: the COUNT is the signal, the numbers are the lead. */ + * reasoning as the fixed native background-diagnostic bound: the COUNT is the signal, the numbers are the lead. */ const MAX_REPORTED_SKIPS = 8; export interface LogsCommandDeps { @@ -59,7 +59,7 @@ export function logsCommand(args: LogsCommandArgs, deps: LogsCommandDeps): ExitC // `sse-event-schema.md` §Transport tells consumers to diagnose exactly that as data loss. It goes to // STDERR in both modes, so `--json`'s stdout stays a pure NDJSON `RunEvent` stream (the flag's contract). if (skipped.length > 0) { - // BOUNDED, like `background-failure.ts`'s `MAX_REPORTED`: a run replayed under a much newer binary could + // BOUNDED, like the fixed native background-diagnostic bound: a run replayed under a much newer binary could // skip thousands of rows, and one unbounded line of comma-separated integers is not a diagnostic. The count // is the signal; the first few numbers are the lead. const shown = skipped.slice(0, MAX_REPORTED_SKIPS).map((row) => row.sequenceNumber); diff --git a/apps/cli/src/commands/management-owner-integration.test.ts b/apps/cli/src/commands/management-owner-integration.test.ts index 74f76313..3a54d1be 100644 --- a/apps/cli/src/commands/management-owner-integration.test.ts +++ b/apps/cli/src/commands/management-owner-integration.test.ts @@ -113,9 +113,22 @@ describe('actual management dispatch ownership', () => { const f = invocationFixture(); const cwd = mkdtempSync(join(tmpdir(), 'relavium-owned-create-')); mkdirSync(join(cwd, '.relavium')); - const { io } = captureIo(); + const { io, out } = captureIo(); const lifetime = new CommandLifetime(f.invocation, io, () => () => undefined); const answer = gate(); + const entered = gate(); + const gather = vi.fn(async () => { + entered.release(); + await answer.promise; + return { + kind: 'agent', + name: 'Late Answer', + provider: 'anthropic', + model: 'synthetic', + systemPrompt: 'Synthetic', + tools: [], + } as const; + }); const command = lifetime.execute(() => createCommand( { force: false }, @@ -123,33 +136,28 @@ describe('actual management dispatch ownership', () => { io: lifetime.io, global: { ...global, cwd }, commandLifetime: lifetime, - prompter: { - gather: async () => { - await answer.promise; - return { - kind: 'agent', - name: 'Late Answer', - provider: 'anthropic', - model: 'synthetic', - systemPrompt: 'Synthetic', - tools: [], - }; - }, - }, + prompter: { gather }, }, ), ); try { + await entered.promise; + expect(gather, 'CREATE_WIZARD_ENTERED_BEFORE_STOP').toHaveBeenCalledOnce(); + expect(f.registry.activeOwners).toBe(1); f.invocation.finiteExit.signal(15); + await reactions(); + expect(f.registry.activeOwners, 'CREATE_OWNER_RETAINS_PENDING_WIZARD').toBe(1); answer.release(); await command; expect( existsSync(join(cwd, '.relavium', 'agents', 'late-answer.agent.yaml')), 'CREATE_LATE_ANSWER_CANNOT_WRITE', ).toBe(false); + expect(out()).toBe(''); expect(f.registry.activeOwners).toBe(0); } finally { answer.release(); + await command; rmSync(cwd, { recursive: true, force: true }); } }); diff --git a/apps/cli/src/commands/model-discovery-lifetime.test.ts b/apps/cli/src/commands/model-discovery-lifetime.test.ts index fc5d5279..8b902734 100644 --- a/apps/cli/src/commands/model-discovery-lifetime.test.ts +++ b/apps/cli/src/commands/model-discovery-lifetime.test.ts @@ -139,6 +139,12 @@ describe('actual SDK discovery original-owner integration', () => { }); try { await entered.promise; + await vi.advanceTimersByTimeAsync(0); + expect(settled, 'MANAGEMENT_RESPONSE_PENDING_KEEPS_COMMAND_OPEN').toBe(false); + expect(client.sqlite.open, 'MANAGEMENT_SUCCESS_WAITS_FOR_RAW_SDK').toBe(true); + expect(client.sqlite.prepare('SELECT 1 AS alive').get()).toEqual({ alive: 1 }); + expect(close).not.toHaveBeenCalled(); + expect(f.registry.activeOwners).toBe(1); if (end === 'timeout') { await vi.advanceTimersByTimeAsync(15_000); expect(captured.out()).toContain('model list timed out after 15000ms'); diff --git a/apps/cli/src/commands/notice-context-forwarding.test.ts b/apps/cli/src/commands/notice-context-forwarding.test.ts index 7dae5e6b..08d14c26 100644 --- a/apps/cli/src/commands/notice-context-forwarding.test.ts +++ b/apps/cli/src/commands/notice-context-forwarding.test.ts @@ -1,4 +1,4 @@ -import { writeFileSync } from 'node:fs'; +import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { expect, it, vi } from 'vitest'; @@ -7,7 +7,16 @@ import { dbFaultAcquisitionCases } from './db-fault-acquisition.test-support.js' const runCase = dbFaultAcquisitionCases(); -it.each(['run', 'gate', 'logs', 'status', 'chat-list', 'chat-export'] as const)( +it.each([ + 'run', + 'gate', + 'logs', + 'status', + 'chat-list', + 'chat-export', + 'list', + 'gate-list', +] as const)( '%s supplies captured notice I/O and its original invocation to the database hook', (surface) => runCase({ prefix: 'relavium-notice-context-', closeFails: false }, async (fixture) => { @@ -18,13 +27,15 @@ it.each(['run', 'gate', 'logs', 'status', 'chat-list', 'chat-export'] as const)( assertCurrent(); const { CommandLifetime } = await import('../process/command-lifetime.js'); assertCurrent(); - const [run, gate, logs, status, chatList, chatExport] = await Promise.all([ + const [run, gate, logs, status, chatList, chatExport, list, gateList] = await Promise.all([ import('./run.js'), import('./gate.js'), import('./logs.js'), import('./status.js'), import('./chat-list.js'), import('./chat-export.js'), + import('./list.js'), + import('./gate-list.js'), ]); assertCurrent(); const { CliError } = await import('../process/cli-error.js'); @@ -38,15 +49,21 @@ it.each(['run', 'gate', 'logs', 'status', 'chat-list', 'chat-export'] as const)( const io = command?.io ?? captured.io; const refusal = new CliError('invalid_invocation', 'Synthetic notice hook stop'); let seen: LocalDbNoticeContext | undefined; - const observe = vi.fn((context: LocalDbNoticeContext | undefined): never => { - seen = context; - throw refusal; - }); + let seenCapture: ((close: () => void) => void) | undefined; + const close = vi.fn(); + const observe = vi.fn( + (capture: typeof seenCapture, context: LocalDbNoticeContext | undefined): never => { + seenCapture = capture; + seen = context; + capture?.(close); + throw refusal; + }, + ); const openDb = ( _home: string, - _capture?: (close: () => void) => void, + capture?: (close: () => void) => void, context?: LocalDbNoticeContext, - ): never => observe(context); + ): never => observe(capture, context); const common = { io, invocation: f.invocation, @@ -60,7 +77,8 @@ it.each(['run', 'gate', 'logs', 'status', 'chat-list', 'chat-export'] as const)( verbosity: 'normal' as const, }, }; - const workflow = join(root, 'notice.relavium.yaml'); + mkdirSync(join(root, '.relavium', 'workflows'), { recursive: true }); + const workflow = join(root, '.relavium', 'workflows', 'notice.relavium.yaml'); writeFileSync( workflow, `schema_version: '1.0' @@ -80,7 +98,8 @@ workflow: { workflow, input: [], allowMcpStdio: [] }, { ...common, - openRunStore: (_definition, _home, _cwd, _close, context) => observe(context), + openRunStore: (_definition, _home, _cwd, capture, context) => + observe(capture, context), }, ); case 'gate': @@ -99,6 +118,10 @@ workflow: openSessionStore: openDb, }, ); + case 'list': + return list.listCommand({ agents: false }, { ...common, openDb }); + case 'gate-list': + return gateList.gateListCommand({}, { ...common, openDb }); } }; const outcome = await ( @@ -110,6 +133,8 @@ workflow: expect(observe).toHaveBeenCalledOnce(); expect(seen?.io, 'DATABASE_NOTICE_USES_CAPTURED_IO').toBe(io); expect(seen?.invocation, 'DATABASE_NOTICE_USES_ORIGINAL_INVOCATION').toBe(f.invocation); + expect(seenCapture, 'DATABASE_OPENER_CAPTURES_PARTIAL_CLOSE').toBeTypeOf('function'); + expect(close, 'DATABASE_PARTIAL_CLOSE_REMAINS_OWNED').toHaveBeenCalledOnce(); expect(f.registry.activeOwners).toBe(0); expect(f.registry.observations.hasInfrastructureFailure()).toBe(false); expect(f.exit).not.toHaveBeenCalled(); diff --git a/apps/cli/src/commands/notice-context-runtime-forwarding.test.ts b/apps/cli/src/commands/notice-context-runtime-forwarding.test.ts new file mode 100644 index 00000000..42cc1620 --- /dev/null +++ b/apps/cli/src/commands/notice-context-runtime-forwarding.test.ts @@ -0,0 +1,186 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { beforeAll, expect, it, vi } from 'vitest'; + +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; +import { dbFaultAcquisitionCases } from './db-fault-acquisition.test-support.js'; + +const runCase = dbFaultAcquisitionCases(); +beforeAll(async () => { + // Transform the production surfaces before entering the per-case native custody budget. + await Promise.all([ + import('./chat.js'), + import('./agent-run.js'), + import('../home/drive-home.js'), + import('./dispatch.js'), + ]); +}); + +const cases = [ + ...(['chat', 'chat-resume', 'home'] as const).flatMap((surface) => + [false, true].map((owned) => ({ surface, owned })), + ), + ...( + [ + 'agent', + 'pricing', + 'dispatch-resolver', + 'dispatch-models', + 'dispatch-provider', + 'dispatch-pricing', + ] as const + ).map((surface) => ({ surface, owned: true })), +]; + +it.each(cases)( + '$surface forwards its captured notice context and partial-close capability; owned=$owned', + ({ surface, owned }) => + runCase({ prefix: 'relavium-notice-runtime-', closeFails: false }, async (fixture) => { + const { root, assertCurrent } = fixture; + const { invocationFixture } = await import('../process/invocation.test-support.js'); + assertCurrent(); + const { captureIo } = await import('../test-support.js'); + assertCurrent(); + const { scriptedResolver } = await import('../chat/test-support.js'); + assertCurrent(); + const signals = await import('../process/signals.js'); + assertCurrent(); + vi.spyOn(signals, 'defaultSubscribeSignals').mockReturnValue(() => undefined); + const { CliError } = await import('../process/cli-error.js'); + assertCurrent(); + const { CommandLifetime } = await import('../process/command-lifetime.js'); + assertCurrent(); + const pricing = await import('../engine/pricing-overlay.js'); + assertCurrent(); + const local = await import('../db/open.js'); + assertCurrent(); + const session = await import('../history/session-open.js'); + assertCurrent(); + + const f = invocationFixture(); + const captured = captureIo(); + const command = + surface.startsWith('dispatch-') && surface !== 'dispatch-resolver' + ? new CommandLifetime(f.invocation, captured.io, () => () => undefined) + : undefined; + const io = command?.io ?? { ...captured.io, stdin: Readable.from(['synthetic prompt']) }; + const refusal = new CliError('invalid_invocation', 'Synthetic notice hook stop'); + const close = vi.fn(); + let seen: LocalDbNoticeContext | undefined; + let seenCapture: ((close: () => void) => void) | undefined; + const observe = vi.fn( + (capture: typeof seenCapture, context?: LocalDbNoticeContext): never => { + seenCapture = capture; + seen = context; + capture?.(close); + throw refusal; + }, + ); + const openSessionStore = ( + _home: string, + capture?: (close: () => void) => void, + context?: LocalDbNoticeContext, + ): never => observe(capture, context); + // Keep optional pricing acquisition separate from each authoritative session-store call. + const overlay = + surface === 'pricing' + ? undefined + : vi.spyOn(pricing, 'loadUserPricingOverlay').mockReturnValue(undefined); + if (surface === 'agent') + vi.spyOn(session, 'openSessionStore').mockImplementation(openSessionStore); + if (surface === 'pricing' || surface.startsWith('dispatch-')) { + vi.spyOn(local, 'openLocalDb').mockImplementation((_home, _options, capture, context) => + observe(capture, context), + ); + } + const common = { + io, + ...(owned ? { invocation: f.invocation } : {}), + ...(command === undefined ? {} : { commandLifetime: command }), + subscribeSignals: () => () => undefined, + global: { + cwd: root, + configPath: join(root, 'config.toml'), + json: true, + color: false, + verbosity: 'normal' as const, + }, + providers: scriptedResolver([]), + }; + const agent = join(root, 'notice.agent.yaml'); + writeFileSync( + agent, + 'id: notice\nprovider: anthropic\nmodel: claude-sonnet-4-6\nsystem_prompt: Synthetic test.\ntools: []\n', + ); + const invoke = async () => { + switch (surface) { + case 'chat': { + const { chatCommand } = await import('./chat.js'); + assertCurrent(); + return chatCommand({ agent }, { ...common, openSessionStore }); + } + case 'chat-resume': { + const { chatResumeCommand } = await import('./chat.js'); + assertCurrent(); + return chatResumeCommand({ sessionId: 'synthetic' }, { ...common, openSessionStore }); + } + case 'home': { + const { driveHome } = await import('../home/drive-home.js'); + assertCurrent(); + return driveHome({ ...common, openSessionStore }); + } + case 'agent': { + const { agentRunCommand } = await import('./agent-run.js'); + assertCurrent(); + return agentRunCommand({ agent, input: [], allowMcpStdio: [] }, common); + } + case 'pricing': + return pricing.loadUserPricingOverlay(root, io, f.invocation); + default: { + const { executeCommand } = await import('./dispatch.js'); + assertCurrent(); + const route = { + 'dispatch-resolver': { id: 'chat', positionals: [], options: {} }, + 'dispatch-models': { id: 'models', positionals: [], options: {} }, + 'dispatch-provider': { id: 'provider.list', positionals: [], options: {} }, + 'dispatch-pricing': { + id: 'models.pricing', + positionals: ['synthetic-model'], + options: { provider: 'anthropic', clear: true }, + }, + }[surface]; + return executeCommand(route.id, route, common); + } + } + }; + const outcome = await ( + command === undefined + ? invoke() + : command.execute(async () => { + await invoke(); + return 0; + }) + ).catch((cause: unknown) => cause); + expect(outcome).toBe(refusal); + expect(observe).toHaveBeenCalledOnce(); + // Agent output is owned by its surface. Its pricing port witnesses that same captured writer. + const expectedIo = surface === 'agent' ? overlay?.mock.calls[0]?.[1] : io; + expect(expectedIo).toBeDefined(); + expect(seen?.io, 'DATABASE_NOTICE_USES_CAPTURED_IO').toBe(expectedIo); + expect(seen?.invocation, 'DATABASE_NOTICE_USES_ORIGINAL_INVOCATION').toBe( + owned ? f.invocation : undefined, + ); + if (owned) { + expect(seenCapture, 'DATABASE_OPENER_CAPTURES_PARTIAL_CLOSE').toBeTypeOf('function'); + expect(close, 'DATABASE_PARTIAL_CLOSE_REMAINS_OWNED').toHaveBeenCalledOnce(); + } else { + expect(seenCapture).toBeUndefined(); + expect(close).not.toHaveBeenCalled(); + } + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.observations.hasInfrastructureFailure()).toBe(false); + expect(f.exit).not.toHaveBeenCalled(); + expect(captured.out()).toBe(''); + }), +); diff --git a/apps/cli/src/commands/partial-db-acquisition.test.ts b/apps/cli/src/commands/partial-db-acquisition.test.ts index de5abaa0..ced68e1e 100644 --- a/apps/cli/src/commands/partial-db-acquisition.test.ts +++ b/apps/cli/src/commands/partial-db-acquisition.test.ts @@ -27,10 +27,21 @@ const routes = [ ]; describe('management custody before local database setup', () => { - it.each(routes.flatMap((route) => [false, true].map((closeFails) => ({ ...route, closeFails }))))( - '$id joins actual SQLite after setup fails; close failure=$closeFails', - ({ id, positionals, options, closeFails }) => - runCase({ prefix: 'relavium-partial-db-', closeFails }, async (fixture) => { + it.each([ + ...routes.flatMap((route) => + [false, true].map((closeFails) => ({ ...route, closeFails, closeRecovers: false })), + ), + { + id: 'logs', + positionals: ['synthetic-run'], + options: {}, + closeFails: true, + closeRecovers: true, + }, + ])( + '$id joins actual SQLite after setup fails; close failure=$closeFails; retry recovers=$closeRecovers', + ({ id, positionals, options, closeFails, closeRecovers }) => + runCase({ prefix: 'relavium-partial-db-', closeFails, closeRecovers }, async (fixture) => { const { root, assertCurrent } = fixture; mkdirSync(join(root, '.relavium', 'workflows'), { recursive: true }); writeFileSync( @@ -97,7 +108,7 @@ workflow: expect(captured.out()).toBe(''); expect(f.registry.observations.exitCode()).toBe(9); expect(f.registry.budget.deadline()).toBe(3_000); - if (closeFails) { + if (closeFails && !closeRecovers) { expect(settled, 'PARTIAL_DATABASE_FAILURE_RETAINS_ACTUAL_NATIVE_CLOSE').toBe(false); expect(fixture.native()?.sqlite.open).toBe(true); expect(f.registry.activeOwners).toBe(1); @@ -112,7 +123,9 @@ workflow: expect(fixture.native()?.sqlite.open).toBe(false); expect(f.registry.activeOwners).toBe(0); expect(removeSignals).toHaveBeenCalledOnce(); - expect(fixture.closeAttempts()).toBe(1); + expect(fixture.closeAttempts(), 'COMMAND_LIFETIME_RETRIES_EXACT_NATIVE_CLOSE').toBe( + closeRecovers ? 2 : 1, + ); } }), ); diff --git a/apps/cli/src/commands/provider-headers.test.ts b/apps/cli/src/commands/provider-headers.test.ts index 75f0e518..49f2adef 100644 --- a/apps/cli/src/commands/provider-headers.test.ts +++ b/apps/cli/src/commands/provider-headers.test.ts @@ -23,7 +23,7 @@ const BASE_URL = 'https://wave7-command.example/v1'; const POLICY_MESSAGE = 'Provider headers are disabled until keychain custody is implemented.'; const CORRUPT_MESSAGE = 'Stored provider headers are invalid.'; const INTERNAL_MESSAGE = - 'An unexpected CLI failure occurred. Use --verbose for safe diagnostics. Inspect existing work before repeating paid or effectful operations.'; + 'An unexpected CLI failure occurred. Inspect existing work before repeating paid or effectful operations.'; const MALFORMED_HEADERS = '{"x-wave7-malformed-opaque":"cedar-river-legacy",'; const NONSTRING_HEADERS = '{"x-wave7-nonstring-opaque":37}'; const ERROR_SENTINELS = [ @@ -291,14 +291,16 @@ describe('ADR-0108 provider-header command projections', () => { { type: 'error', code: projected.code, message: projected.message }, ]); else { - expect(rendered.err()).toContain(`relavium: ${projected.message}\n`); - if (verbose) { + const ownStack: unknown = Object.getOwnPropertyDescriptor(error, 'stack')?.value; + // Native V8 stacks may be data or accessors; never invoke an accessor for diagnostics. + if (verbose && typeof ownStack === 'string') { + expect(rendered.err()).toContain(`relavium: ${projected.message}\n`); expect(rendered.err()).toContain(`${error.name}: ${error.message}\n`); expect(rendered.err()).toMatch(/\n\s+at /u); } else expect(rendered.err()).toBe(`relavium: ${projected.message}\n`); } - // The causal diagnostic witness must inspect real verbose output before internal error-shape - // checks: a key-bearing parser diagnostic should fail at its public rendering boundary. + // Inspect actual public diagnostic bytes before private error-shape checks. + // Explicit data-stack masking is covered separately by render-error.test.ts. assertNoLegacy(rendered.err(), 'FS_CAUSAL_PROVIDER_HEADER_DIAGNOSTIC'); expect(isProviderHeaderPolicyError(error)).toBe(policy); expect(error.name).toBe(policy ? 'ProviderHeaderPolicyError' : 'TypeError'); diff --git a/apps/cli/src/commands/provider-keychain.test.ts b/apps/cli/src/commands/provider-keychain.test.ts index 44c50f5d..64f85123 100644 --- a/apps/cli/src/commands/provider-keychain.test.ts +++ b/apps/cli/src/commands/provider-keychain.test.ts @@ -177,9 +177,15 @@ describe('actual OS bridge → provider command → diagnostic rendering (Wave 2 expect(envelope).toEqual({ type: 'error', code: 'invalid_invocation', message }); expect(io.err().trimEnd().split('\n')).toHaveLength(1); } else { - expect(io.err()).toContain(`relavium: ${message}\n`); - if (mode.verbose) expect(io.err()).toContain(`CliError: ${message}`); - else expect(io.err()).toBe(`relavium: ${message}\n`); + const ownStack: unknown = Object.getOwnPropertyDescriptor( + bridgeError, + 'stack', + )?.value; + // V8 versions represent native stacks as data or lazy accessors. Only data is eligible. + if (mode.verbose && typeof ownStack === 'string') { + expect(io.err()).toContain(`relavium: ${message}\n`); + expect(io.err()).toContain(`CliError: ${message}`); + } else expect(io.err()).toBe(`relavium: ${message}\n`); } }); } diff --git a/apps/cli/src/commands/workflow-lifetime.test.ts b/apps/cli/src/commands/workflow-lifetime.test.ts index 7794b623..b43bb932 100644 --- a/apps/cli/src/commands/workflow-lifetime.test.ts +++ b/apps/cli/src/commands/workflow-lifetime.test.ts @@ -189,3 +189,45 @@ it('publishes a returned infrastructure exit before owner retirement instead of expect(f.invocation.registry.observations.exitCode()).toBe(9); expect(f.invocation.registry.activeOwners).toBe(0); }); + +it.each(['storage_unavailable', 'internal'] as const)( + 'bounds unacknowledged cleanup even when its original cause is %s', + async (code) => { + let now = 0; + const timers = new Map<() => void, number>(); + const clock = { + now: () => now, + schedule: (milliseconds: number, fire: () => void) => { + timers.set(fire, now + milliseconds); + return () => { + timers.delete(fire); + }; + }, + }; + const registry = new InvocationRegistry(clock); + const exit = vi.fn(); + const finiteExit = createInvocationFiniteExit(registry, clock, { exit, setExitCode: vi.fn() }); + const lifetime = new WorkflowLifetime( + { registry, finiteExit, seedFileCatalogOnce: () => Promise.resolve() }, + captureIo().io, + () => () => undefined, + ); + let settled = false; + void lifetime + .execute(() => { + lifetime.cleanupFailed(new CliError(code, 'synthetic close fault')); + return Promise.resolve(0); + }) + .then(() => { + settled = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(registry.budget.deadline(), 'CLEANUP_FAILURE_ALWAYS_FINITE').toBe(3000); + now = 3000; + for (const [fire, at] of [...timers]) if (at <= now && timers.delete(fire)) fire(); + await new Promise((resolve) => setImmediate(resolve)); + expect(exit).toHaveBeenCalledExactlyOnceWith(9); + expect(settled, 'CLEANUP_FAILURE_NEVER_ACKNOWLEDGES').toBe(false); + expect(registry.activeOwners).toBe(1); + }, +); diff --git a/apps/cli/src/commands/workflow-lifetime.ts b/apps/cli/src/commands/workflow-lifetime.ts index 56490a29..fe8bb9db 100644 --- a/apps/cli/src/commands/workflow-lifetime.ts +++ b/apps/cli/src/commands/workflow-lifetime.ts @@ -159,6 +159,9 @@ export class WorkflowLifetime { cleanupFailed(cause: unknown): void { this.#cleanupAcknowledged = false; this.failure(cause); + // Failure to acknowledge owned cleanup is infrastructure uncertainty regardless + // of the original operation's classification. Retention must always have a bound. + this.invocation.finiteExit.fail('cli_internal'); } async execute(body: () => Promise): Promise { let result: ExitCode = EXIT_CODES.workflowFailed; diff --git a/apps/cli/src/engine/mcp-servers.ts b/apps/cli/src/engine/mcp-servers.ts index 28422537..632d6e20 100644 --- a/apps/cli/src/engine/mcp-servers.ts +++ b/apps/cli/src/engine/mcp-servers.ts @@ -743,7 +743,17 @@ export async function connectAgentMcp( return client; } catch (cause) { opts.onFailure?.(cause); - await closePreparedMcp(client, opts.onCloseError, opts.budget); + try { + await closePreparedMcp(client, opts.onCloseError, opts.budget); + } catch (cleanupCause) { + // Preserve the original post-start refusal; cleanup uncertainty belongs to + // the original owner and must not replace that refusal with a secondary fault. + try { + opts.onCloseError?.('', cleanupCause); + } catch { + // The owner already observed cleanup entry; a reporting fault is not the primary refusal. + } + } throw cause; } } @@ -754,13 +764,29 @@ export async function closePreparedMcp( onCloseError?: (serverId: string, cause: unknown) => void, budget?: RemainingBudget, ): Promise { - if (budget?.remainingMs() === 0) await new Promise(() => {}); + let budgetFailure: { readonly cause: unknown } | undefined; + try { + if (budget?.remainingMs() === 0) await new Promise(() => {}); + } catch (cause) { + // A failed clock is a fault, not proof that native cleanup completed. Still + // release the already-acquired client; preserve the clock fault after that join. + budgetFailure = { cause }; + } try { await client.close(onCloseError); } catch (cause) { - if (onCloseError === undefined) throw cause; - onCloseError('', cause); + if (budgetFailure !== undefined) { + try { + onCloseError?.('', cause); + } catch { + /* Preserve the first cleanup-boundary fault. */ + } + } else { + if (onCloseError === undefined) throw cause; + onCloseError('', cause); + } } + if (budgetFailure !== undefined) throw budgetFailure.cause; } /** diff --git a/apps/cli/src/engine/mcp-session-preparation.test.ts b/apps/cli/src/engine/mcp-session-preparation.test.ts index 2b6acace..fead3bb1 100644 --- a/apps/cli/src/engine/mcp-session-preparation.test.ts +++ b/apps/cli/src/engine/mcp-session-preparation.test.ts @@ -257,6 +257,59 @@ describe('session preparation keeps its original owner across MCP consent and st }, ); + it('joins acquired client close before preserving a thrown remaining-budget fault', async () => { + const primary = new Error('PRIVATE_BUDGET_FAULT'); + const native = gate(); + const close = vi.fn(() => native.promise); + const result = closePreparedMcp(client(close), undefined, { + deadline: () => undefined, + remainingMs: () => { + throw primary; + }, + }); + const rejected = expect(result).rejects.toBe(primary); + expect(close).toHaveBeenCalledOnce(); + let settled = false; + void result.catch(() => { + settled = true; + }); + await reactions(); + expect(settled).toBe(false); + native.release(); + await rejected; + }); + + it('preserves the late-client admission refusal when close also rejects', async () => { + const started = gate(); + const published = gate(); + const primary = new Error('PRIVATE_ADMISSION'); + const cleanup = new Error('PRIVATE_CLOSE'); + let stopped = false; + const report = vi.fn(); + const close = vi.fn(() => Promise.reject(cleanup)); + const opening = connectAgentMcp(refs, { + cwd: '/tmp', + consentGate: () => Promise.resolve(new Map()), + resolveSecret: () => 'SYNTHETIC_SECRET', + assertAdmitting: () => { + if (stopped) throw primary; + }, + onCloseError: report, + startMcpClient: async () => { + started.release(); + await published.promise; + return client(close); + }, + }); + const rejected = expect(opening).rejects.toBe(primary); + await started.promise; + stopped = true; + published.release(); + await rejected; + expect(close).toHaveBeenCalledOnce(); + expect(report).toHaveBeenCalledWith('', cleanup); + }); + it('never starts a new asynchronous manager close after the original budget expires', async () => { const close = vi.fn(() => Promise.resolve()); let settled = false; diff --git a/apps/cli/src/engine/mcp-signal-teardown.test.ts b/apps/cli/src/engine/mcp-signal-teardown.test.ts deleted file mode 100644 index e630365c..00000000 --- a/apps/cli/src/engine/mcp-signal-teardown.test.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { createCliInvocation } from '../process/invocation.js'; - -import { - guardMcpTeardown, - MCP_TEARDOWN_GRACE_MS, - type McpTeardownGuardOptions, -} from './mcp-signal-teardown.js'; - -/** - * `#21` / [ADR-0088](../../../../docs/decisions/0088-the-mcp-boundary-is-hostile.md) §1.3 — a signalled - * one-shot command must not orphan the MCP children it spawned. - * - * **The bug was measured before this was written**, with a host that spawned a child exactly the way the MCP - * SDK does (no `detached`, piped stdio): sending `SIGTERM` printed no teardown line and left the child alive - * with **`ppid 1`**. `run` and `agent run` both tear down in a `finally`, and Node's default signal handling - * terminates without unwinding one — so the teardown they rely on never ran. - * - * A terminal `Ctrl-C` frequently hides this, because the TTY signals the whole foreground group. That is a - * property of how the signal was delivered, not of the code, which is why these tests drive the handler - * directly rather than through a TTY. - * - * The suite covers BOTH halves. The cooperative half awaits `close()`; the synchronous `process.on('exit')` - * half exists because the cooperative one cannot cover an exit nobody awaits — and `driveRun`'s own - * second-Ctrl-C force-quit is exactly such an exit, which a first version of this guard left re-orphaning the - * children it was mid-way through reaping. - */ - -/** A signal source under the test's control, plus a way to fire one. */ -function fakeSignals(): { - subscribe: (onSignal: (signo: number) => void) => () => void; - fire: (signo: number) => void; - subscribed: () => boolean; -} { - let handler: ((signo: number) => void) | undefined; - return { - subscribe: (onSignal) => { - handler = onSignal; - return () => { - handler = undefined; - }; - }, - fire: (signo) => handler?.(signo), - subscribed: () => handler !== undefined, - }; -} - -/** A `process.on('exit')` stand-in the test can fire deliberately. */ -function fakeExitNet(): { - subscribe: (onExit: () => void) => () => void; - fire: () => void; - subscribed: () => boolean; -} { - let handler: (() => void) | undefined; - return { - subscribe: (onExit) => { - handler = onExit; - return () => { - handler = undefined; - }; - }, - fire: () => handler?.(), - subscribed: () => handler !== undefined, - }; -} - -const never = (): Promise => new Promise(() => undefined); -const none = (): readonly number[] => []; - -/** The common wiring: a controllable signal source, exit net, kill capture and exit capture. */ -function harness(overrides: Partial = {}): { - signals: ReturnType; - exitNet: ReturnType; - killed: number[]; - exit: ReturnType; - options: McpTeardownGuardOptions; -} { - const signals = fakeSignals(); - const exitNet = fakeExitNet(); - const killed: number[] = []; - const exit = vi.fn(); - return { - signals, - exitNet, - killed, - exit, - options: { - subscribeSignals: signals.subscribe, - subscribeProcessExit: exitNet.subscribe, - killPid: (pid: number) => { - killed.push(pid); - }, - exit, - delay: never, - ...overrides, - }, - }; -} - -describe('guardMcpTeardown — the cooperative half', () => { - it('closes the MCP client on a signal and exits 128+signo', async () => { - const h = harness(); - const close = vi.fn(() => Promise.resolve()); - guardMcpTeardown(close, none, h.options); - - h.signals.fire(2); // SIGINT - await vi.waitFor(() => { - expect(close).toHaveBeenCalledTimes(1); - expect(h.exit).toHaveBeenCalledWith(130); // 128 + 2 — a shell pipeline still reads the signal - }); - }); - - it.each([ - ['SIGHUP', 1, 129], - ['SIGINT', 2, 130], - ['SIGQUIT', 3, 131], - ['SIGTERM', 15, 143], - ])('reaps on %s and exits %i+128 = %i', async (_name, signo, code) => { - // All four, not just SIGINT: SIGHUP is what a user gets by closing the terminal window, and SIGTERM is - // what a supervisor or an IDE stop button sends — the two deliveries where the process group does NOT - // save us, which is exactly how the orphan was reproduced. - const h = harness(); - const close = vi.fn(() => Promise.resolve()); - guardMcpTeardown(close, none, h.options); - - h.signals.fire(signo); - await vi.waitFor(() => { - expect(h.exit).toHaveBeenCalledWith(code); - expect(close).toHaveBeenCalledTimes(1); - }); - }); - - it('exits anyway when the teardown wedges, rather than hanging the shell', async () => { - // A bound, because the whole point is that a signalled process ends. Exiting on the timer is safe - // precisely BECAUSE the synchronous exit net below still reaps — otherwise this would be the bug again. - const waited: number[] = []; - const h = harness({ - delay: (ms: number) => { - waited.push(ms); - return Promise.resolve(); - }, - }); - guardMcpTeardown(never, none, h.options); - - h.signals.fire(15); - await vi.waitFor(() => { - expect(h.exit).toHaveBeenCalledWith(143); - expect(waited).toContain(MCP_TEARDOWN_GRACE_MS); - }); - }); - - it('does not let a teardown fault stop the exit', async () => { - const h = harness(); - guardMcpTeardown(() => Promise.reject(new Error('transport already gone')), none, h.options); - - h.signals.fire(2); - await vi.waitFor(() => expect(h.exit).toHaveBeenCalledWith(130)); - }); - - it('latches, so a second Ctrl-C does not start a second teardown', async () => { - const h = harness(); - const close = vi.fn(() => Promise.resolve()); - guardMcpTeardown(close, none, h.options); - - h.signals.fire(2); - h.signals.fire(2); - h.signals.fire(15); - await vi.waitFor(() => { - expect(close).toHaveBeenCalledTimes(1); - expect(h.exit).toHaveBeenCalledTimes(1); - }); - }); -}); - -describe('guardMcpTeardown — reapOnly, for a surface that owns its own exit contract', () => { - it('reaps but does NOT exit, leaving the documented run:cancelled + exit 1 to driveRun', async () => { - // **The blocker a review caught.** A first version exited `128+signo` unconditionally. On `relavium run` - // that raced `driveRun`'s cooperative cancel and won whenever MCP was in play — replacing the documented - // exit `1` with a code absent from the CLI's closed 0–7 table, and leaving the run with NO terminal in the - // durable log, so `relavium status` would list it as active forever. - const h = harness({ reapOnly: true }); - const close = vi.fn(() => Promise.resolve()); - guardMcpTeardown(close, none, h.options); - - h.signals.fire(2); - await vi.waitFor(() => expect(close).toHaveBeenCalledTimes(1)); - expect(h.exit).not.toHaveBeenCalled(); - }); -}); - -describe('guardMcpTeardown — the synchronous exit net', () => { - it('SIGKILLs every live child on process exit, covering the paths nobody awaits', () => { - // `driveRun`'s second-Ctrl-C force-quit calls `process.exit` synchronously, abandoning any in-flight - // async teardown. The cooperative half cannot cover that; this half is why the children still die. - const h = harness(); - guardMcpTeardown(never, () => [4242, 4243], h.options); - - h.exitNet.fire(); - expect(h.killed).toEqual([4242, 4243]); - }); - - it('reads the pids LAZILY, so a guard armed before the connect still reaps what it spawns', () => { - // The guard is armed BEFORE `connectWorkflowMcp` — the window where a cold `npx` runs for up to 120 s - // with the terminal silent — so at arming time there are no children yet. A snapshot taken then would - // reap nothing, which is the highest-probability window for the bug. - let pids: readonly number[] = []; - const h = harness(); - guardMcpTeardown(never, () => pids, h.options); - - h.exitNet.fire(); - expect(h.killed).toEqual([]); // nothing spawned yet - pids = [777]; - h.exitNet.fire(); - expect(h.killed).toEqual([777]); - }); - - it('is registered even in reapOnly mode — the exit is the caller’s, the children are not', () => { - const h = harness({ reapOnly: true }); - guardMcpTeardown(never, () => [99], h.options); - - h.exitNet.fire(); - expect(h.killed).toEqual([99]); - }); - - it('survives a kill that throws, because at exit time there is nothing to report to', () => { - // ESRCH (already reaped by the cooperative half — the ordinary case) and EPERM (a recycled pid) both - // throw. Neither may stop the loop reaping the REMAINING children. - const reached: number[] = []; - const h = harness({ - killPid: (pid: number) => { - reached.push(pid); - if (pid === 1) throw new Error('ESRCH'); - }, - }); - guardMcpTeardown(never, () => [1, 2], h.options); - - expect(() => h.exitNet.fire()).not.toThrow(); - expect(reached).toEqual([1, 2]); - }); -}); - -describe('guardMcpTeardown — the production defaults', () => { - it('registers on the REAL process signals and removes them without acquiring a second native exit net', () => { - // Every other test injects `subscribeSignals`, so the default — the four-signal subscription every - // surface shares — was never taken. A review pointed out that with the surface binding also missing, the - // real registration path had zero coverage from either end. - // - // Native ownership is installed at actual creation and outlives this guard until real native close. - const invocation = createCliInvocation(); - const before = { - sigint: process.listenerCount('SIGINT'), - sigterm: process.listenerCount('SIGTERM'), - sighup: process.listenerCount('SIGHUP'), - sigquit: process.listenerCount('SIGQUIT'), - exit: process.listenerCount('exit'), - }; - const unguard = guardMcpTeardown(() => Promise.resolve(), undefined, { invocation }); - expect(process.listenerCount('SIGINT')).toBe(before.sigint + 1); - expect(process.listenerCount('SIGTERM')).toBe(before.sigterm + 1); - expect(process.listenerCount('SIGHUP')).toBe(before.sighup + 1); - expect(process.listenerCount('SIGQUIT')).toBe(before.sigquit + 1); - unguard(); - // The SIGNAL half is released — a normally-exiting process must not keep a handler that changes how a - // later Ctrl-C behaves. - expect(process.listenerCount('SIGINT')).toBe(before.sigint); - expect(process.listenerCount('SIGTERM')).toBe(before.sigterm); - expect(process.listenerCount('SIGHUP')).toBe(before.sighup); - expect(process.listenerCount('SIGQUIT')).toBe(before.sigquit); - expect(process.listenerCount('exit')).toBe(before.exit); - }); - - it('does not accumulate historical exit listeners across invocation-bound guards', () => { - const invocation = createCliInvocation(); - const before = process.listenerCount('exit'); - const guards = [ - guardMcpTeardown(() => Promise.resolve(), undefined, { invocation }), - guardMcpTeardown(() => Promise.resolve(), undefined, { invocation }), - guardMcpTeardown(() => Promise.resolve(), undefined, { invocation }), - ]; - expect(process.listenerCount('exit')).toBe(before); - for (const unguard of guards) unguard(); - expect(process.listenerCount('exit')).toBe(before); - }); - - it('keeps the grace short enough to serve its own stated purpose', () => { - // The other grace test asserts the guard passes the exported constant — which a literal `delay(500)` would - // redden, but which stays green if the constant itself becomes an hour. The bound's whole justification is - // that a wedged transport must not turn Ctrl-C into a hung shell, so the VALUE needs an assertion too. - expect(MCP_TEARDOWN_GRACE_MS).toBeLessThanOrEqual(5_000); - expect(MCP_TEARDOWN_GRACE_MS).toBeGreaterThan(0); - }); -}); - -describe('guardMcpTeardown — invocation native authority', () => { - it('delegates finite exit to the same live registry and ignores legacy PID/kill authority', () => { - const invocation = createCliInvocation(); - let eligible = true; - const finiteExit = vi.fn(); - const stop = vi.fn(); - const join = vi.fn(); - const reap = vi.fn(); - invocation.registry.register({ - kind: 'native-pid', - retired: new Promise(() => undefined), - stop, - join, - reap, - mayReap: () => eligible, - exitReap: finiteExit, - classifyFailure: () => 'cli_internal', - }); - const h = harness({ invocation }); - const unguard = guardMcpTeardown( - never, - () => { - throw new Error('legacy PID observation is not product kill authority'); - }, - h.options, - ); - try { - h.exitNet.fire(); - expect(finiteExit, 'MCP_GUARD_REGISTRY_EXIT').toHaveBeenCalledTimes(1); - expect(h.killed).toEqual([]); - expect(stop).not.toHaveBeenCalled(); - expect(join).not.toHaveBeenCalled(); - expect(reap).not.toHaveBeenCalled(); - expect(invocation.registry.activeOwners).toBe(1); - expect(invocation.registry.termination).toBeUndefined(); - eligible = false; - h.exitNet.fire(); - expect(finiteExit).toHaveBeenCalledTimes(1); - } finally { - unguard(); - } - }); - - it('preserves first-signal cooperative cleanup without invoking whole-registry termination', async () => { - const invocation = createCliInvocation(); - const h = harness({ invocation, reapOnly: true }); - const close = vi.fn(() => Promise.resolve()); - const unguard = guardMcpTeardown(close, undefined, h.options); - try { - h.signals.fire(2); - await vi.waitFor(() => expect(close).toHaveBeenCalledTimes(1)); - expect(invocation.registry.termination).toBeUndefined(); - expect(h.exit).not.toHaveBeenCalled(); - expect(h.killed).toEqual([]); - } finally { - unguard(); - } - }); -}); - -describe('guardMcpTeardown — unsubscribe', () => { - it('removes BOTH subscriptions when the caller finishes normally', () => { - // Leaving either registered would hold a handle on a process exiting cleanly — and the exit net would - // SIGKILL pids the `finally` had already closed, which is harmless but is a listener nobody owns. - const h = harness(); - const close = vi.fn(() => Promise.resolve()); - const unguard = guardMcpTeardown(close, () => [5], h.options); - - expect(h.signals.subscribed()).toBe(true); - expect(h.exitNet.subscribed()).toBe(true); - unguard(); - expect(h.signals.subscribed()).toBe(false); - expect(h.exitNet.subscribed()).toBe(false); - - h.signals.fire(2); - h.exitNet.fire(); - expect(close).not.toHaveBeenCalled(); - expect(h.exit).not.toHaveBeenCalled(); - expect(h.killed).toEqual([]); - }); - - it('is idempotent, so a caller may unguard on more than one path', () => { - const h = harness(); - const unguard = guardMcpTeardown(() => Promise.resolve(), none, h.options); - expect(() => { - unguard(); - unguard(); - }).not.toThrow(); - }); -}); diff --git a/apps/cli/src/engine/mcp-signal-teardown.ts b/apps/cli/src/engine/mcp-signal-teardown.ts deleted file mode 100644 index 8be26842..00000000 --- a/apps/cli/src/engine/mcp-signal-teardown.ts +++ /dev/null @@ -1,222 +0,0 @@ -import { defaultSubscribeSignals } from '../process/signals.js'; -import type { CliInvocation } from '../process/invocation.js'; - -/** - * Reap spawned MCP children when a one-shot command is signalled - * ([ADR-0088](../../../../docs/decisions/0088-the-mcp-boundary-is-hostile.md) §1.3, `#21`). - * - * **The gap this closes was measured, not inferred.** `run` and `agent run` both tear their MCP client down in - * a `finally`, which is correct for every path Node unwinds — and a signal is not one of them. With no - * listener, Node's default SIGINT/SIGTERM handling terminates the process *without* running `finally`. - * Reproduced: a host that spawned a child exactly the way the MCP SDK does, sent `SIGTERM`, printed no - * `FINALLY_RAN`, and left the child alive with **`ppid 1`**. - * - * A terminal `Ctrl-C` often hides this, because the TTY signals the whole foreground process group and the - * child usually dies with the parent. That is a coincidence of delivery, not a guarantee: `kill -TERM `, - * a supervisor, an IDE stop button, and a child that ignores `SIGINT` (many servers do) all reproduce the - * orphan. The interactive Home has had a cooperative teardown since 2.6.F; the one-shot commands never did. - * - * **Two mechanisms, because one cannot cover every exit.** The cooperative half awaits `close()` and is what - * runs on an ordinary signal. The `process.on('exit')` half is **synchronous** and covers the paths that - * cannot await anything — a second Ctrl-C forcing `process.exit`, the outermost crash net, any `process.exit` - * elsewhere in the tree. Without the second half, `driveRun`'s own force-quit re-orphans exactly the children - * the first half was mid-way through reaping. - */ - -/** The conventional `128 + signo` exit code, so a shell pipeline still reads the signal correctly. */ -const signalExitCode = (signo: number): number => 128 + signo; - -/** - * How long the cooperative teardown may take before the process exits anyway. - * - * A bound, because the whole point is that a signalled process ends: a wedged transport must not convert "the - * user pressed Ctrl-C" into "the shell hangs". - * - * **This deliberately does NOT wait for the SDK's own close ladder to finish, and an earlier comment here got - * that ladder wrong.** Measured against `@modelcontextprotocol/sdk` 1.29.0 (`client/stdio.js`): `stdin.end()` - * → race(exit, **2 s**) → `SIGTERM` → race(exit, **2 s**) → `SIGKILL`. So a child that ignores stdin EOF and - * traps `SIGTERM` — the class this module's own header names — is not `SIGKILL`ed until ~4 s, and a 3 s grace - * fires first. That is the right trade anyway, and only because the SECOND mechanism exists: the timer's exit - * runs the synchronous `process.on('exit')` net, which `SIGKILL`s every live pid itself. Waiting the full 4 s - * would buy a tidier teardown at the cost of a shell that sits there after a Ctrl-C, for a child this guard - * kills either way. - */ -export const MCP_TEARDOWN_GRACE_MS = 3_000; - -export interface McpTeardownGuardDeps { - /** Signal source — defaults to the four-signal subscription every surface shares (never a second one). */ - readonly subscribeSignals?: (onSignal: (signo: number) => void) => () => void; - /** Register a synchronous `process.on('exit')` net; returns a remover. */ - readonly subscribeProcessExit?: (onExit: () => void) => () => void; - /** Synchronously SIGKILL a pid (tests inject a capture; production `process.kill`). */ - readonly killPid?: (pid: number) => void; - /** Exit the process (tests inject a capture; production flushes stdout first — see `exitAfterFlush`). */ - readonly exit?: (code: number) => void; - /** Injectable delay so the grace path is testable without waiting for it. */ - readonly delay?: (ms: number) => Promise; -} - -export interface McpTeardownGuardOptions extends McpTeardownGuardDeps { - /** Product native exit cleanup uses this invocation's exact creation-owned registrations. */ - readonly invocation?: CliInvocation; - /** - * Reap the children but do **not** exit — for a surface that already owns a documented signal contract. - * - * `relavium run` is that surface: `driveRun` registers its own SIGINT listener, and - * [commands.md](../../../../docs/reference/cli/commands.md) promises `Ctrl-C` drains the run to - * `run:cancelled` and exits `1`. A first version of this guard exited `128+signo` unconditionally, which - * won that race whenever MCP was in play — replacing a documented exit code with one absent from the CLI's - * closed `0`–`7` table, and leaving the run with **no terminal in the durable log**, so `relavium status` - * would list it as active forever. Reaping is this guard's job; owning the exit code is not, wherever - * something else already does. - */ - readonly reapOnly?: boolean; -} - -/** - * Compatibility fixture PID observation. Product callers pass the explicit invocation instead; this list - * never grants kill authority when invocation-owned native registrations are available. - */ -export type LiveChildPids = () => readonly number[]; - -/** - * Register the signal guard around `close`, returning an unsubscribe the caller MUST call on the normal path - * (its own `finally` already owns the teardown there — running both would close twice). - * - * `close` is expected to be idempotent: `McpClient.close()` is, and the guard also latches so a second signal - * during teardown does not start a second one. A close fault is swallowed rather than surfaced — the process - * is ending on a signal, and a teardown error has no reader. - */ -export function guardMcpTeardown( - close: () => Promise, - livePids: LiveChildPids = () => [], - options: McpTeardownGuardOptions = {}, -): () => void { - const subscribe = options.subscribeSignals ?? defaultSubscribeSignals; - const kill = options.killPid ?? defaultKillPid; - const exit = options.exit ?? exitAfterFlush; - const delay = options.delay ?? defaultDelay; - let released = false; - - // The synchronous net. It runs on EVERY exit — normal or forced — and is a no-op once the cooperative - // teardown has finished, because a reaped child's pid no longer exists. - // - // **Each kill is isolated, and its own test found why.** The guarantee is that EVERY live child is reaped, - // so one throwing kill must not end the loop and strand the rest. The default kill already swallows its - // ESRCH/EPERM, which is exactly what made the bare loop look correct — the isolation belongs to the loop, - // which owns the guarantee, not to one implementation of the thing it calls. - const reap = (): void => { - if (options.invocation !== undefined) { - options.invocation.registry.reapNativeAtExit(); - return; - } - for (const pid of livePids()) { - try { - kill(pid); - } catch { - // Nothing to report to at exit time — and the next child still has to die. - } - } - }; - // Product exit custody is installed/removable by the native controller when an actual owner reserves. - // It outlives this cooperative guard through genuine native close, including failed initialization. - // An explicit test subscription can drive that same registry's finite capability. Legacy direct fixtures - // without an invocation keep a local fallback; no historical process-wide reaper collection remains. - const unsubscribeExit = - options.subscribeProcessExit !== undefined - ? options.subscribeProcessExit(reap) - : options.invocation === undefined - ? subscribeProcessExit(reap) - : () => undefined; - - const releaseEmptyLegacyNet = (): void => { - if (!released || options.invocation !== undefined) return; - try { - if (livePids().length === 0) unsubscribeExit(); - } catch { - // Failed compatibility observation cannot prove the still-installed fallback empty. - } - }; - - let tearingDown = false; - const unsubscribeSignals = subscribe((signo) => { - // Latched: a user who presses Ctrl-C twice must not start a second teardown racing the first. The second - // signal is dropped rather than escalated — the grace timer below, and the `exit` net above, are what - // guarantee the process ends and the children die. - if (tearingDown) return; - tearingDown = true; - // Keep synchronous close refusal from escaping the signal callback or stranding its grace path. - let closing: Promise; - try { - // Entry remains synchronous: the command's close callback also cancels a pending initialization. - closing = close().catch(() => undefined); - } catch { - closing = Promise.resolve(); - } - void closing.then(releaseEmptyLegacyNet); - void Promise.race([closing, delay(MCP_TEARDOWN_GRACE_MS)]).then(() => { - if (options.reapOnly === true) return; // the caller owns the exit code — see `reapOnly` - unsubscribeSignals(); - exit(signalExitCode(signo)); - }); - }); - - return () => { - released = true; - unsubscribeSignals(); - if (options.subscribeProcessExit !== undefined || options.invocation !== undefined) - unsubscribeExit(); - else releaseEmptyLegacyNet(); - }; -} - -function subscribeProcessExit(reap: () => void): () => void { - process.on('exit', reap); - return () => process.off('exit', reap); -} - -/** - * SIGKILL a pid, swallowing the throw. - * - * `SIGKILL` rather than `SIGTERM` because this runs on `process.on('exit')`: there is no event loop left to - * wait for a graceful shutdown, so a signal the child may ignore is a signal that does nothing. The throw to - * swallow is `ESRCH` — the ordinary case, where the cooperative teardown already reaped this child — and - * `EPERM`, where the pid was recycled by a process we do not own. Neither is actionable at exit time. - * - * **The residual a pid-based reap cannot close, stated rather than implied.** If our child exits and the OS - * recycles its pid to an unrelated process owned by the SAME user before this handler runs, the `SIGKILL` - * lands on that process. Distinguishing them needs a mechanism this host does not have (a pid file descriptor, - * or recording the child's start time), and the window is the milliseconds-to-3-seconds between the teardown - * starting and the process exiting. Accepted rather than hidden: ADR-0088 §11's "asserted against the actual - * child-process table" is a statement about what the TEST checks, and this is where the guarantee stops. - */ -function defaultKillPid(pid: number): void { - try { - process.kill(pid, 'SIGKILL'); - } catch { - // Already gone, or not ours. Both are fine; there is nothing left to report to. - } -} - -/** - * Exit after stdout has drained. - * - * `process.exit()` terminates without flushing, and on a **pipe** — the machine-consumer case — stdout is - * asynchronous and buffered. So `relavium run --json | jq` interrupted mid-stream could emit a **half-written - * JSON line**, which breaks the consumer's parser rather than merely ending its stream early - * ([ADR-0049](../../../../docs/decisions/0049-cli-machine-output-contract.md)). The CLI's own entry point sets - * `process.exitCode` and lets Node exit naturally for that reason; a guard that must exit *now* asks for the - * drain explicitly instead. - * - * The callback fires immediately when nothing is buffered, so the ordinary case costs one tick. - */ -function exitAfterFlush(code: number): void { - process.exitCode = code; - process.stdout.write('', () => process.exit(code)); -} - -function defaultDelay(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms).unref?.(); - }); -} diff --git a/apps/cli/src/engine/model-catalog-port.test.ts b/apps/cli/src/engine/model-catalog-port.test.ts index 87ea0969..f5c9b2a3 100644 --- a/apps/cli/src/engine/model-catalog-port.test.ts +++ b/apps/cli/src/engine/model-catalog-port.test.ts @@ -213,6 +213,7 @@ describe('createModelCatalogPort', () => { let refresh: ReturnType | undefined; const drive = home.execute(() => { refresh = port.refresh(); + void refresh.catch(() => undefined); // Observe rejection while actual discovery remains held. return Promise.resolve(0); }); await new Promise((resolve) => setImmediate(resolve)); @@ -220,7 +221,9 @@ describe('createModelCatalogPort', () => { expect(f.registry.activeOwners).toBe(1); finish([{ id: 'synthetic-late-model' }]); await drive; - await refresh; + expect( + home.surface.work.isStoppedFailure(await refresh?.catch((cause: unknown) => cause)), + ).toBe(true); expect(close).toHaveBeenCalledOnce(); expect(client.sqlite.prepare('SELECT COUNT(*) AS count FROM llm_providers').get()).toEqual({ count: 0, diff --git a/apps/cli/src/engine/model-refresh-admission.test.ts b/apps/cli/src/engine/model-refresh-admission.test.ts index 08485fe5..94dea4fe 100644 --- a/apps/cli/src/engine/model-refresh-admission.test.ts +++ b/apps/cli/src/engine/model-refresh-admission.test.ts @@ -95,7 +95,10 @@ describe('catalog refresh original-owner admission', () => { it('admits sibling attempt clocks before entry, preserving already selected keys without starting post-stop work', async () => { const f = fixture(true, true); try { - await f.service.refresh({ signal: f.owner.signal }); + const result = await f.service + .refresh({ signal: f.owner.signal }) + .catch((cause: unknown) => cause); + expect(f.owner.isStoppedFailure(result)).toBe(true); expect(f.keyFor).toHaveBeenCalledTimes(2); // Both keys were selected before the first attempt fault. expect(f.now, 'CATALOG_FATAL_CLOCK_STOPS_SIBLING_ATTEMPT').toHaveBeenCalledOnce(); expect(f.entered.some((event) => event.stopped)).toBe(false); diff --git a/apps/cli/src/engine/model-refresh.ts b/apps/cli/src/engine/model-refresh.ts index c7dd51cd..cb484099 100644 --- a/apps/cli/src/engine/model-refresh.ts +++ b/apps/cli/src/engine/model-refresh.ts @@ -282,6 +282,8 @@ export function createModelRefreshService(deps: ModelRefreshDeps): ModelRefreshS throw cause; } } catch (err) { + // Stopped owner admission is not a provider failure report. + deps.assertAdmitting?.(); return { provider: id, status: 'failed', error: secretFreeReason(err, key) }; } }; @@ -313,6 +315,8 @@ export function createModelRefreshService(deps: ModelRefreshDeps): ModelRefreshS const settled = await Promise.allSettled( connected.map(({ id, key }) => refreshOne(id, key, signal)), ); + // allSettled joins every admitted sibling; it must not turn owner refusal into success. + deps.assertAdmitting?.(); settled.forEach((outcome, index) => { const entry = connected[index]; if (entry === undefined) return; // unreachable — 1:1 with `connected` diff --git a/apps/cli/src/engine/tool-host/os.test.ts b/apps/cli/src/engine/tool-host/os.test.ts index 6e253646..2ae833b4 100644 --- a/apps/cli/src/engine/tool-host/os.test.ts +++ b/apps/cli/src/engine/tool-host/os.test.ts @@ -3,7 +3,10 @@ import { EventEmitter } from 'node:events'; import { describe, expect, it, vi } from 'vitest'; import { EgressCapabilityError, HostDeniedError } from './errors.js'; // sibling check: the os errors live in the same module -import type { NativeProcessController } from '../../process/native-process-owner.js'; +import { + createNativeProcessController, + type NativeProcessController, +} from '../../process/native-process-owner.js'; import { WindowsTeardownUnavailableError, WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE, @@ -73,9 +76,24 @@ function controllableSpawn(): { spawnImpl: OsSpawnFn; calls: RecordedCall[]; killCount: () => number; + nativeProcesses: NativeProcessController; } { const calls: RecordedCall[] = []; let killCount = 0; + const children = new Map(); + const nativeProcesses = createNativeProcessController(undefined, { + platform: 'linux', + now: () => performance.now(), + schedule: (milliseconds, fire) => { + const timer = setTimeout(fire, milliseconds); + return () => clearTimeout(timer); + }, + signal: (pid, signal) => { + if (signal === 'SIGKILL') children.get(pid)?.kill('SIGKILL'); + }, + verifyWindows: () => Promise.reject(new Error('unused Windows fixture')), + subscribeExit: () => () => undefined, + }); const spawnImpl: OsSpawnFn = (executable, args, options) => { calls.push({ executable, args, env: options.env }); const child = new EventEmitter() as EventEmitter & OsSpawnLike; @@ -87,9 +105,12 @@ function controllableSpawn(): { queueMicrotask(() => child.emit('close', null)); // SIGKILL ⇒ death ⇒ close with a null code }, }); + const pid = 1000 + calls.length; + Object.defineProperty(child, 'pid', { value: pid }); + children.set(pid, child); return child; // never emits close on its own — only a timeout/abort kill ends it }; - return { spawnImpl, calls, killCount: () => killCount }; + return { spawnImpl, calls, killCount: () => killCount, nativeProcesses }; } describe('notifyPlan — the per-platform spawn plan (injection-free arg/env construction)', () => { @@ -228,8 +249,13 @@ describe('createNodeOsCapability — notify', () => { describe('createNodeOsCapability — timeout + mid-spawn abort + the bounded buffer (the SIGKILL paths)', () => { it('SIGKILLs a hung clipboard candidate past timeoutMs and rejects with the timeout reason', async () => { - const { spawnImpl, killCount } = controllableSpawn(); - const os = createNodeOsCapability({ platform: 'darwin', spawnImpl, timeoutMs: 10 }); + const { spawnImpl, killCount, nativeProcesses } = controllableSpawn(); + const os = createNodeOsCapability({ + platform: 'darwin', + spawnImpl, + nativeProcesses, + timeoutMs: 10, + }); const err = await os.readClipboard().catch((e: unknown) => e); expect(err).toBeInstanceOf(OsCapabilityError); expect((err as OsCapabilityError).message).toBe('the os command timed out'); // not the misleading default @@ -237,15 +263,20 @@ describe('createNodeOsCapability — timeout + mid-spawn abort + the bounded buf }); it('SIGKILLs a hung notify spawn past timeoutMs and rejects', async () => { - const { spawnImpl, killCount } = controllableSpawn(); - const os = createNodeOsCapability({ platform: 'linux', spawnImpl, timeoutMs: 10 }); + const { spawnImpl, killCount, nativeProcesses } = controllableSpawn(); + const os = createNodeOsCapability({ + platform: 'linux', + spawnImpl, + nativeProcesses, + timeoutMs: 10, + }); await expect(os.notify({ title: 't', body: 'b' })).rejects.toBeInstanceOf(OsCapabilityError); expect(killCount()).toBeGreaterThanOrEqual(1); }); it('aborts an IN-FLIGHT clipboard spawn and does NOT probe the next candidate', async () => { - const { spawnImpl, calls, killCount } = controllableSpawn(); - const os = createNodeOsCapability({ platform: 'linux', spawnImpl }); // 3 candidates (wl-paste, xclip, xsel) + const { spawnImpl, calls, killCount, nativeProcesses } = controllableSpawn(); + const os = createNodeOsCapability({ platform: 'linux', spawnImpl, nativeProcesses }); // 3 candidates (wl-paste, xclip, xsel) const ac = new AbortController(); const pending = os.readClipboard(ac.signal); await vi.waitFor(() => expect(calls).toHaveLength(1)); // actual entry, independent of preparation awaits diff --git a/apps/cli/src/engine/tool-host/os.ts b/apps/cli/src/engine/tool-host/os.ts index 8923f699..22c9cac0 100644 --- a/apps/cli/src/engine/tool-host/os.ts +++ b/apps/cli/src/engine/tool-host/os.ts @@ -135,6 +135,7 @@ export function createNodeOsCapability(config: NodeOsCapabilityConfig = {}): OsC timeoutMs, maxBufferBytes, nativeProcesses, + platform, }); } catch (error) { if (error instanceof HostDeniedError) throw error; @@ -157,6 +158,7 @@ export function createNodeOsCapability(config: NodeOsCapabilityConfig = {}): OsC timeoutMs, maxBufferBytes, nativeProcesses, + platform, }); if (result.exitCode !== 0) { throw new OsCapabilityError('the notification could not be delivered'); @@ -229,6 +231,7 @@ async function spawnCapturing( timeoutMs: number; maxBufferBytes: number; nativeProcesses: NativeProcessController; + platform: NodeJS.Platform; }, ): Promise { const native = await opts.nativeProcesses.reserve('pid').catch((cause: unknown) => { @@ -298,7 +301,7 @@ async function spawnCapturing( reject(new OsCapabilityError('the os command was aborted')); } else if (failed) { reject(new OsCapabilityError('the os command failed to run')); - } else if (timedOut && (code === null || (process.platform === 'win32' && code !== 0))) { + } else if (timedOut && (code === null || (opts.platform === 'win32' && code !== 0))) { reject(new OsTimeoutError('the os command timed out')); } else { resolvePromise({ exitCode: code ?? 1, stdout: stdout.text() }); diff --git a/apps/cli/src/harness/concurrency.e2e.test.ts b/apps/cli/src/harness/concurrency.e2e.test.ts index 197ceddb..0b0a986b 100644 --- a/apps/cli/src/harness/concurrency.e2e.test.ts +++ b/apps/cli/src/harness/concurrency.e2e.test.ts @@ -528,7 +528,9 @@ describe('concurrency e2e (2.5.I S3) — a run and a chat share one history.db', signal, }) => startCoexistenceCase(signal)); - it('setup refusal closes the original native connection and removes its directory', async ({ + // Fixture custody only: production opener/CommandLifetime partial-close evidence lives in + // commands/partial-db-acquisition.test.ts and commands/surface-db-acquisition.test.ts. + it('fixture cleanup: setup refusal closes its native connection and removes its directory', async ({ signal, }) => { const failure = new Error('synthetic migration setup refusal'); @@ -547,7 +549,7 @@ describe('concurrency e2e (2.5.I S3) — a run and a chat share one history.db', }), ).rejects.toBe(failure); expect(clients).toHaveLength(1); - expect(clients[0]?.sqlite.open, 'CONCURRENCY_SETUP_NATIVE_CLOSE').toBe(false); + expect(clients[0]?.sqlite.open, 'CONCURRENCY_FIXTURE_SETUP_NATIVE_CLOSE').toBe(false); expect(directory).toBeDefined(); expect(directory !== undefined && existsSync(directory)).toBe(false); } finally { @@ -629,7 +631,7 @@ describe('concurrency e2e (2.5.I S3) — a run and a chat share one history.db', } }); - it('cleanup failure retains the original setup fault and actual native close outcome', async ({ + it('fixture cleanup: close failure retains its setup fault and actual native close outcome', async ({ signal, }) => { const primary = new Error('synthetic setup fault'); diff --git a/apps/cli/src/harness/mcp-orphan.e2e.test.ts b/apps/cli/src/harness/mcp-orphan.e2e.test.ts index 3a244e62..4ed61deb 100644 --- a/apps/cli/src/harness/mcp-orphan.e2e.test.ts +++ b/apps/cli/src/harness/mcp-orphan.e2e.test.ts @@ -5,7 +5,6 @@ import type { McpServerRef } from '@relavium/shared'; import { describe, expect, it } from 'vitest'; import { connectAgentMcp } from '../engine/mcp-servers.js'; -import { guardMcpTeardown } from '../engine/mcp-signal-teardown.js'; import type { ResolvedStdioSpawn } from '../engine/mcp-consent.js'; import { createCliInvocation } from '../process/invocation.js'; import { createNativeProcessController } from '../process/native-process-owner.js'; @@ -14,10 +13,8 @@ import { createNativeProcessController } from '../process/native-process-owner.j * `#21` / [ADR-0088](../../../../docs/decisions/0088-the-mcp-boundary-is-hostile.md) §1.3 and §11 — **no orphan * process**, asserted against the actual child-process table rather than against a promise. * - * A review of the first implementation made exactly this point: every test in - * `engine/mcp-signal-teardown.test.ts` injects its signal source, its `exit` and its `kill`, so the suite - * proves the guard's *wiring* and nothing about a real child. That is the test that would have stayed green - * against the two defects this file now pins. + * The creation-owner unit tests use explicit simulated events. This suite separately drives the + * production native controller and invocation registry against actual local child lifetimes. * * The fixture is a server that **ignores stdin EOF and traps `SIGTERM`** — the class this guard's own header * names. It matters because the SDK's close ladder is `stdin.end()` → race(exit, 2 s) → `SIGTERM` → @@ -148,31 +145,17 @@ describe('MCP orphan reaping — against the real child-process table', () => { // Drive the net directly rather than exiting this test process: the mechanism under test is the // callback, and a real `process.exit` here would take the runner with it. - let fireExitNet = (): void => undefined; - const unguard = guardMcpTeardown(() => client?.close() ?? Promise.resolve(), undefined, { - invocation, - subscribeSignals: () => () => undefined, // the cooperative half is the other test's subject - subscribeProcessExit: (onExit) => { - fireExitNet = onExit; - return () => undefined; - }, - }); - try { - fireExitNet(); - // `kill` returns immediately; the process leaves the table a moment later. - expect(await until(() => !alive(pid as number))).toBe(true); - } finally { - unguard(); - } + invocation.registry.reapNativeAtExit(); + // Native cancellation is synchronous; process-table removal follows asynchronously. + expect(await until(() => !alive(pid as number))).toBe(true); } finally { await client?.close(); } }, 30_000); - it('the COOPERATIVE half reaps on a signal, and the run’s own teardown is not what did it', async () => { - // The ordinary path: a signal arrives, `close()` runs, the child dies. Asserted with a `reapOnly` guard — - // the shape `relavium run` uses — so a regression that made `reapOnly` skip the teardown as well as the - // exit would redden here. + it('the creation-owned registry reaps on a signal before command finally cleanup', async () => { + // Drive the same registry stop pass used by the finite-exit signal owner. The command's + // finally cleanup runs only after the native owner has proved its actual retirement. const invocation = createCliInvocation(); const client = await connectAgentMcp([stubbornServer()], { cwd: process.cwd(), @@ -183,31 +166,10 @@ describe('MCP orphan reaping — against the real child-process table', () => { const pid = client?.childPids[0]; expect(typeof pid).toBe('number'); - const signalsSeen: number[] = []; - let fireSignal = (signo: number): void => { - signalsSeen.push(signo); - }; - const unguard = guardMcpTeardown(() => client?.close() ?? Promise.resolve(), undefined, { - invocation, - subscribeSignals: (onSignal) => { - fireSignal = onSignal; - return () => undefined; - }, - subscribeProcessExit: () => () => undefined, // the OTHER half must not be what reaps here - reapOnly: true, - exit: () => { - throw new Error('reapOnly must not exit — driveRun owns this surface’s exit code'); - }, - }); - try { - fireSignal(2); - expect(signalsSeen).toEqual([]); // the guard replaced the placeholder, so the guard is what ran - // The SDK ladder takes up to ~4 s against a trapping child (stdin.end → 2 s → SIGTERM → 2 s → - // SIGKILL), which is why the budget is generous and why the guard's own 3 s grace is NOT measured. - expect(await until(() => !alive(pid as number))).toBe(true); - } finally { - unguard(); - } + const stopped = invocation.registry.terminate({ kind: 'signal', signalNumber: 2 }); + expect(await until(() => !alive(pid as number))).toBe(true); + expect(await stopped).toMatchObject({ kind: 'settled', unacknowledgedOwners: 0 }); + expect(invocation.registry.activeOwners).toBe(0); } finally { await client?.close(); } diff --git a/apps/cli/src/process/background-failure.test.ts b/apps/cli/src/process/background-failure.test.ts deleted file mode 100644 index fdfd8920..00000000 --- a/apps/cli/src/process/background-failure.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; - -import { captureIo } from '../test-support.js'; -import { - describeReason, - escalateExitCode, - installBackgroundFailureNet, -} from './background-failure.js'; -import { EXIT_CODES } from './exit-codes.js'; - -/** CSI + OSC introducers, built from escapes so the bytes survive every editor and diff tool. */ -const CSI = '['; -const OSC = ']'; -const BEL = ''; - -/** - * The process-level net (#228). Its whole job is to keep the CLI alive after an unhandled rejection instead of - * letting Node kill it mid-session — so it is exactly the kind of code that silently rots without tests: the - * listener is attached to the real `process`, and nothing else in the CLI suite would notice its absence. - */ -describe('installBackgroundFailureNet (#228)', () => { - const originalExitCode = process.exitCode; - let dispose: (() => void) | undefined; - - afterEach(() => { - dispose?.(); - dispose = undefined; - process.exitCode = originalExitCode; - }); - - /** - * Drive one real unhandled rejection through Node and wait for the listener to see it. Typed `Error` - * because every case here rejects with one; the non-Error reasons `describeReason` must survive are - * covered by calling it directly, which is cheaper and does not need a real rejection. - */ - async function rejectOnce(reason: Error): Promise { - void Promise.reject(reason); - await new Promise((resolve) => setTimeout(resolve, 10)); - } - - it('reports a rejection and does NOT let it kill the process', async () => { - const { io, err } = captureIo(); - const net = installBackgroundFailureNet(io); - dispose = net.dispose; - process.exitCode = EXIT_CODES.success; - - await rejectOnce(new Error('database is locked')); - - expect(net.occurred()).toBe(true); - expect(err()).toContain('a background operation failed: database is locked'); - // Still running — reaching this assertion at all is itself the proof. - expect(process.exitCode).toBe(EXIT_CODES.workflowFailed); - }); - - it('upgrades a clean 0 but NEVER overwrites a more specific outcome', () => { - const { io } = captureIo(); - const net = installBackgroundFailureNet(io); - dispose = net.dispose; - - // chatEnded / gatePaused / invalidInvocation each carry a truth CI and scripts key on. - for (const code of [ - EXIT_CODES.chatEnded, - EXIT_CODES.gatePaused, - EXIT_CODES.invalidInvocation, - ]) { - process.exitCode = code; - escalateExitCode(); - expect(process.exitCode).toBe(code); - } - process.exitCode = EXIT_CODES.success; - escalateExitCode(); - expect(process.exitCode).toBe(EXIT_CODES.workflowFailed); - }); - - it('SANITIZES the reported reason — a rejection reason is untrusted text', async () => { - const { io, err } = captureIo(); - const net = installBackgroundFailureNet(io); - dispose = net.dispose; - // An MCP server's error text, a provider response body and a tool result can all land here, so the same - // ANSI/OSC guard every other terminal boundary applies has to apply here too (G34's class). - await rejectOnce(new Error(`lost ${CSI}31mred${OSC}0;pwned${BEL} write`)); - expect(err()).not.toContain(''); // no CSI/OSC introducer survives - expect(err()).not.toContain(BEL); // nor the OSC terminator - expect(err()).toContain('lost'); // …while the readable text still reaches the user - }); - - it('bounds repeated reports so a repeating fault cannot scroll a session away', async () => { - const { io, err } = captureIo(); - const net = installBackgroundFailureNet(io); - dispose = net.dispose; - for (let i = 0; i < 8; i += 1) { - await rejectOnce(new Error(`boom-${i}`)); - } - expect(err()).toContain('boom-4'); // the 5th, still reported in full - expect(err()).not.toContain('boom-5'); // the 6th, suppressed - expect(err()).toContain('further background failures suppressed'); - }); - - it('RELAVIUM_DEBUG lifts the bound — the suppression line must not promise what it does not deliver', async () => { - const { io, err } = captureIo({ RELAVIUM_DEBUG: '1' }); - const net = installBackgroundFailureNet(io); - dispose = net.dispose; - for (let i = 0; i < 8; i += 1) { - await rejectOnce(new Error(`boom-${i}`)); - } - // Every one reported, and no suppression notice claiming a flag would reveal them. - expect(err()).toContain('boom-7'); - expect(err()).not.toContain('further background failures suppressed'); - }); - - it('stops listening once disposed', async () => { - const { io, err } = captureIo(); - const net = installBackgroundFailureNet(io); - net.dispose(); - // A throwaway absorber, because this is the ONE case with no net installed: without it the rejection is - // genuinely unhandled, vitest counts it as a file-level error, and the run exits NON-ZERO while every - // summary line still says "passed" — the exact failure mode that hid this from me the first time. - const absorb = (): void => undefined; - process.on('unhandledRejection', absorb); - try { - await rejectOnce(new Error('after dispose')); - } finally { - process.off('unhandledRejection', absorb); - } - expect(net.occurred()).toBe(false); - expect(err()).toBe(''); - }); -}); - -describe('describeReason', () => { - it('carries the discriminant, not just the message — a bare message cannot identify a wiring gap', () => { - expect(describeReason(Object.assign(new Error('locked'), { code: 'SQLITE_BUSY' }))).toBe( - 'locked [SQLITE_BUSY]', - ); - }); - - it('handles the realistic non-Error shapes instead of collapsing them to "unknown"', () => { - // A driver-shaped plain object is exactly what `retry.ts` matches structurally, so it is a real case. - expect(describeReason({ code: 'SQLITE_BUSY', message: 'database is locked' })).toBe( - 'database is locked', - ); - expect(describeReason('a bare string')).toBe('a bare string'); - expect(describeReason(new AggregateError([new Error('a'), new Error('b')]))).toContain( - '2 background errors', - ); - expect(describeReason(42)).toBe('unknown reason'); - }); -}); diff --git a/apps/cli/src/process/background-failure.ts b/apps/cli/src/process/background-failure.ts deleted file mode 100644 index bd5879cd..00000000 --- a/apps/cli/src/process/background-failure.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { sanitizeUntrusted, sanitizeUntrustedInline } from '../render/sanitize.js'; -import { EXIT_CODES } from './exit-codes.js'; -import type { CliIo } from './io.js'; - -/** - * The process-level last-resort net for an unhandled promise rejection (#228). - * - * Node's default for one is to **kill the process**. That default is load-bearing in the wrong direction here: - * the engine's `RunEventBus` deliberately re-throws a passive subscriber's fault out-of-band, on a microtask, - * when no `onListenerError` sink is wired — precisely so it is never silently swallowed. The consequence was - * that a transient `history.db` write failure inside the chat persister took the whole CLI down - * **asynchronously, after the user had already seen the reply**, losing a turn they had been billed for. - * - * So: report it loudly and keep the process alive. This is a BACKSTOP, not a replacement for the sinks - * upstream of it — the store's bounded retry absorbs the transient case and the bus's `onListenerError` sink - * reports the genuine one; anything reaching here is a wiring gap worth seeing. Deliberately scoped to - * rejections: an `uncaughtException` is a different class of fault and continuing past one would be unsafe. - * - * Lives in its own module, rather than inline in the `bin` entry, so it is unit-testable — `index.ts` has a - * top-level `await run(...)` and importing it from a test would run the whole CLI. - */ -export interface BackgroundFailureNet { - /** Whether a background failure was reported during this process's lifetime. */ - readonly occurred: () => boolean; - /** Detach the listener (tests; the real process never needs it). */ - readonly dispose: () => void; -} - -/** How many distinct rejections are reported in full before the net starts counting instead of printing. */ -const MAX_REPORTED = 5; - -export function installBackgroundFailureNet(io: CliIo): BackgroundFailureNet { - let count = 0; - // With `RELAVIUM_DEBUG` set the bound is lifted entirely — otherwise the suppression line below would be - // making a promise ("set RELAVIUM_DEBUG to see them") the code does not keep, and a diagnostic flag that - // does not actually reveal the diagnostics is worse than no flag. - const verbose = io.env['RELAVIUM_DEBUG'] !== undefined; - const onRejection = (reason: unknown): void => { - count += 1; - if (verbose || count <= MAX_REPORTED) { - // Sanitized like every other terminal boundary (`process/render-error.ts`): a rejection reason is - // arbitrary `unknown` — realistically an MCP server's error text, a provider response body, or a tool - // result — i.e. exactly the untrusted sources the ANSI/OSC/Trojan-Source guard exists for. - io.writeErr( - `relavium: a background operation failed: ${sanitizeUntrustedInline(describeReason(reason))}\n`, - ); - if (verbose && reason instanceof Error && reason.stack !== undefined) { - io.writeErr(`${sanitizeUntrusted(reason.stack)}\n`); - } - } else if (count === MAX_REPORTED + 1) { - // Bounded: a repeating fault in a long interactive session must not scroll the transcript away. - io.writeErr( - 'relavium: further background failures suppressed (set RELAVIUM_DEBUG to see them).\n', - ); - } - // Fires after `run()` already set the code: only ever upgrade a clean `0`, never overwrite a more - // specific outcome (a `chatEnded` 4 or a `gatePaused` 3 still means exactly what it says). - escalateExitCode(); - }; - process.on('unhandledRejection', onRejection); - return { - occurred: () => count > 0, - dispose: () => { - process.off('unhandledRejection', onRejection); - }, - }; -} - -/** - * Upgrade a clean success to a failure, and nothing else. A run that lost a durable write must not report - * `0` — but a `chatEnded` (4), `gatePaused` (3) or `invalidInvocation` (2) already carries a more specific - * truth that CI and scripts key on, so those survive untouched. - */ -export function escalateExitCode(): void { - if (process.exitCode === undefined || process.exitCode === EXIT_CODES.success) { - process.exitCode = EXIT_CODES.workflowFailed; - } -} - -/** A one-line, user-safe description of a rejection reason — never a stack as primary output. */ -export function describeReason(reason: unknown): string { - if (reason instanceof AggregateError) { - return `${reason.errors.length} background errors: ${reason.errors - .slice(0, 3) - .map((e: unknown) => describeReason(e)) - .join('; ')}`; - } - if (reason instanceof Error) { - // Include the discriminant when there is one — a bare message often cannot identify the wiring gap. - const code = 'code' in reason && typeof reason.code === 'string' ? ` [${reason.code}]` : ''; - return `${reason.message}${code}`; - } - if (typeof reason === 'string') { - return reason; - } - // A driver-shaped plain object (`{ code, message }`) is the realistic non-Error case on this path. - if (typeof reason === 'object' && reason !== null && 'message' in reason) { - const message: unknown = reason.message; - if (typeof message === 'string') { - return message; - } - } - return 'unknown reason'; -} diff --git a/apps/cli/src/process/cli-error.test.ts b/apps/cli/src/process/cli-error.test.ts index b783c80b..fae6b37e 100644 --- a/apps/cli/src/process/cli-error.test.ts +++ b/apps/cli/src/process/cli-error.test.ts @@ -120,7 +120,10 @@ describe('closed CLI diagnostic authority', () => { expect(new CliError('internal', 'known history refusal').exitCode).toBe(1); expect(new CliError('internal', 'known history refusal').message).toBe('known history refusal'); expect(new CliError('cli_internal', 'private detail').exitCode).toBe(9); - expect(new CliError('cli_internal', 'private detail').message).toContain('--verbose'); + expect(new CliError('cli_internal', 'private detail').message).not.toContain('--verbose'); + expect(new CliError('cli_internal', 'private detail').message).toContain( + 'Inspect existing work', + ); }); it('retains an inspection remedy before repeating potentially effectful infrastructure failures', () => { diff --git a/apps/cli/src/process/cli-error.ts b/apps/cli/src/process/cli-error.ts index 6c733e71..be43ed30 100644 --- a/apps/cli/src/process/cli-error.ts +++ b/apps/cli/src/process/cli-error.ts @@ -46,7 +46,7 @@ export const CLI_ERROR_POLICY: Readonly> = origin: 'infrastructure', exitCode: EXIT_CODES.infrastructureFailed, fixedMessage: - 'An unexpected CLI failure occurred. Use --verbose for safe diagnostics. Inspect existing work before repeating paid or effectful operations.', + 'An unexpected CLI failure occurred. Inspect existing work before repeating paid or effectful operations.', }), output_unavailable: Object.freeze({ origin: 'output', diff --git a/apps/cli/src/process/native-process-owner.test.ts b/apps/cli/src/process/native-process-owner.test.ts index 498b6aee..a163ea6a 100644 --- a/apps/cli/src/process/native-process-owner.test.ts +++ b/apps/cli/src/process/native-process-owner.test.ts @@ -707,6 +707,64 @@ describe('creation-owned native processes', () => { }); describe('actual owned native child lifetime', () => { + it.each(['stop', 'terminate', 'exit'] as const)( + 'never kills a PID-less failed native spawn during same-tick %s', + async (boundary) => { + const h = harness(); + const lease = await h.controller.reserve('pid'); + const target = spawn(`${process.execPath}.relavium-missing-binary-${process.pid}`, [], { + stdio: 'ignore', + shell: false, + }); + // Never enter the unsafe native call, including while proving the unfixed code fails. + const kill = vi.spyOn(target, 'kill').mockReturnValue(false); + expect(target.pid, 'FAILED_SPAWN_HAS_NO_PID').toBeUndefined(); + lease.bindChild(target); + let closed = false; + target.once('close', () => { + closed = true; + }); + if (boundary === 'stop') void lease.stop(); + else if (boundary === 'terminate') void h.registry.terminate({ kind: 'normal' }); + else h.exit(); + expect(closed).toBe(false); + expect(h.registry.activeOwners, 'PIDLESS_SPAWN_NOT_CLOSE_ACK').toBe(1); + expect(kill, 'PIDLESS_SPAWN_NEVER_KILL_HANDLE').not.toHaveBeenCalled(); + expect(h.signal).not.toHaveBeenCalled(); + await lease.whenClosed; + expect(closed).toBe(true); + expect(h.registry.activeOwners).toBe(0); + }, + ); + it('never kills a failed PID-less taskkill helper while retaining both real closes', async () => { + const h = harness('win32'); + const lease = await h.controller.reserve('pid'); + const target = child(70); + let helper: ChildProcess | undefined; + let kill = vi.fn(() => false); + h.request.mockImplementation(() => { + helper = spawn(`${process.execPath}.relavium-missing-helper-${process.pid}`, [], { + stdio: 'ignore', + shell: false, + }); + kill = vi.fn(() => false); + vi.spyOn(helper, 'kill').mockImplementation(kill); + expect(helper.pid).toBeUndefined(); + return helper; + }); + lease.bindChild(target); + void lease.stop(); + h.exit(); + expect(kill, 'PIDLESS_HELPER_NEVER_KILL_HANDLE').not.toHaveBeenCalled(); + expect(h.registry.activeOwners).toBe(1); + await new Promise((resolve) => helper?.once('close', () => resolve())); + expect(kill).not.toHaveBeenCalled(); + expect(h.registry.activeOwners, 'PIDLESS_HELPER_NOT_TARGET_ACK').toBe(1); + target.emit('close', null); + await lease.whenClosed; + expect(h.registry.activeOwners).toBe(0); + }); + it('registers before spawn, terminates a real owned child and joins native close', async () => { const invocation = createCliInvocation(); const controller = nativeProcessesForInvocation(invocation); diff --git a/apps/cli/src/process/native-process-owner.ts b/apps/cli/src/process/native-process-owner.ts index 7899d672..85fc7d0a 100644 --- a/apps/cli/src/process/native-process-owner.ts +++ b/apps/cli/src/process/native-process-owner.ts @@ -141,7 +141,6 @@ interface Target { readonly readPid: () => number | undefined; readonly isClosed: () => boolean; readonly isExited: () => boolean; - readonly cancel?: (() => void) | undefined; readonly cooperativeClose?: (() => Promise) | undefined; } @@ -258,9 +257,6 @@ class NativeCreation { readPid: () => pid, isClosed: () => closed, isExited: () => exited || child.exitCode != null || child.signalCode != null, - cancel: () => { - child.kill('SIGKILL'); - }, }); // An error diagnoses the command, not native closure. Callers retain their public error contract. child.on('error', () => undefined); @@ -357,18 +353,9 @@ class NativeCreation { #signalPid(): void { const pid = this.#livePid(); - if (pid === undefined) { - // A synchronously returned child can have no PID when native spawn later emits error/close. - // Its own handle remains creation-owned; never substitute a guessed or historical PID. - if (this.#eligible() && positivePid(this.#target?.readPid()) === undefined) { - try { - this.#target?.cancel?.(); - } catch { - this.#failed(); - } - } - return; - } + // A failed spawn can return a handle without a PID before its error/close events. + // ChildProcess.kill is unsafe in that state; retain ownership until real close. + if (pid === undefined) return; try { this.#ports.signal(pid, 'SIGKILL'); } catch (cause) { @@ -489,7 +476,15 @@ class NativeCreation { } #cancelHelper(): void { - if (this.#helper === undefined || this.#helperClosed || this.#helperCancelling) return; + if ( + this.#helper === undefined || + this.#helperClosed || + this.#helperCancelling || + positivePid(this.#helper.pid) === undefined || + this.#helper.exitCode !== null || + this.#helper.signalCode !== null + ) + return; // ChildProcess.kill can emit error synchronously. Its listener may request cancellation // again; suppress that reentry while allowing a later independent finite request. this.#helperCancelling = true; diff --git a/apps/cli/src/process/native-prompt-io.test.ts b/apps/cli/src/process/native-prompt-io.test.ts index 51904c21..31c23379 100644 --- a/apps/cli/src/process/native-prompt-io.test.ts +++ b/apps/cli/src/process/native-prompt-io.test.ts @@ -56,7 +56,10 @@ describe('captured native prompt ports', () => { try { expect(nativePromptIo(f.io)).toEqual({ input: f.input, output: f.output }); const lineInput = Readable.from([]); - expect(nativePromptIo({ ...f.io, stdin: lineInput }).input).toBe(lineInput); + expect(nativePromptIo({ ...f.io, stdin: lineInput }).input).toBe(f.input); + const { nativeStdin, ...withoutNativeInput } = f.io; + expect(nativeStdin).toBe(f.input); + expect(nativePromptIo({ ...withoutNativeInput, stdin: lineInput }).input).toBe(lineInput); } finally { f.close(); } diff --git a/apps/cli/src/process/native-prompt-io.ts b/apps/cli/src/process/native-prompt-io.ts index 86b0bde2..4d751736 100644 --- a/apps/cli/src/process/native-prompt-io.ts +++ b/apps/cli/src/process/native-prompt-io.ts @@ -7,7 +7,8 @@ export function nativePromptIo(io: CliIo): { readonly input: Readable; readonly output: NodeJS.WriteStream; } { - if (io.nativeStdout === undefined || !(io.stdin instanceof Readable)) + const input = io.nativeStdin ?? io.stdin; + if (io.nativeStdout === undefined || !(input instanceof Readable)) throw new CliError('cli_internal', ''); - return { input: io.stdin, output: io.nativeStdout }; + return { input, output: io.nativeStdout }; } diff --git a/apps/cli/src/process/options.ts b/apps/cli/src/process/options.ts index b6e897b9..cf6c4f10 100644 --- a/apps/cli/src/process/options.ts +++ b/apps/cli/src/process/options.ts @@ -87,8 +87,17 @@ function applyBoolean(raw: RawGlobalOptions, option: GlobalOptionDescriptor): vo case 'noMouse': raw.noMouse = true; break; - default: + case 'cwd': + case 'config': + case 'allowFsRoots': + case 'help': + case 'version': + throw new CliError('cli_internal', ''); + default: { + const exhaustive: never = option.binding; + void exhaustive; throw new CliError('cli_internal', ''); + } } } @@ -188,8 +197,20 @@ function assignValueFlag( case 'config': raw.config = value; return undefined; - default: + case 'json': + case 'color': + case 'verbose': + case 'quiet': + case 'noAltScreen': + case 'noMouse': + case 'help': + case 'version': + throw new CliError('cli_internal', ''); + default: { + const exhaustive: never = option.binding; + void exhaustive; throw new CliError('cli_internal', ''); + } } } diff --git a/apps/cli/src/process/render-error.test.ts b/apps/cli/src/process/render-error.test.ts index 240c2e08..24b790df 100644 --- a/apps/cli/src/process/render-error.test.ts +++ b/apps/cli/src/process/render-error.test.ts @@ -101,7 +101,9 @@ describe('renderError terminal projection', () => { 'invalid_invocation', 'request failed ?client_secret=opaque-value-Q7', ); - error.stack = 'safe outer frame ?refresh_token=opaque-stack-V9'; + Object.defineProperty(error, 'stack', { + value: 'safe outer frame ?refresh_token=opaque-stack-V9', + }); renderError(error, options, capture.io); const output = capture.stderr(); expect(output).not.toContain('opaque-value-Q7'); @@ -117,7 +119,7 @@ describe('renderError terminal projection', () => { it('sanitizes the human diagnostic and its verbose stack without suppressing readable content (G34)', () => { const error = new CliError('invalid_invocation', `message visible${ATTACK}\nforged message`); - error.stack = `stack visible${ATTACK}\nsecond stack line`; + Object.defineProperty(error, 'stack', { value: `stack visible${ATTACK}\nsecond stack line` }); const capture = captureIo(); renderError(error, { json: false, verbose: true }, capture.io); @@ -167,7 +169,7 @@ describe('renderError terminal projection', () => { for (const text of [`${query}; credential: ${split}`, `credential: ${split}; ${query}`]) { const capture = captureIo(); const error = new CliError('invalid_invocation', text); - error.stack = `outer stack ${text}`; + Object.defineProperty(error, 'stack', { value: `outer stack ${text}` }); renderError(error, options, capture.io); const output = capture.stderr(); expect(output, 'SECRETS_CAUSAL_RESIDUAL_SCRUB').not.toContain(secret); @@ -187,7 +189,9 @@ describe('renderError terminal projection', () => { const capture = captureIo(); const error = new CliError('invalid_invocation', 'request failed; retry after 30s'); const split = '?api_\nkey=opaque-residual-query-P8'; - error.stack = `outer stack ?refresh_token=opaque-stack-value&ordinary=kept; key: ${split}`; + Object.defineProperty(error, 'stack', { + value: `outer stack ?refresh_token=opaque-stack-value&ordinary=kept; key: ${split}`, + }); renderError(error, { json: false, verbose: true }, capture.io); const output = capture.stderr(); expect(output, 'SECRETS_CAUSAL_RESIDUAL_SCRUB').not.toContain('opaque-residual-query-P8'); @@ -222,7 +226,7 @@ describe('renderError terminal projection', () => { for (const projection of [text, llmError.message]) { const capture = captureIo(); const error = new CliError('invalid_invocation', projection); - error.stack = `outer stack ${projection}`; + Object.defineProperty(error, 'stack', { value: `outer stack ${projection}` }); renderError(error, options, capture.io); const output = capture.stderr(); expect(output, 'SECRETS_CAUSAL_PREFIX_ORDER').not.toContain(suffix); @@ -240,16 +244,19 @@ describe('renderError terminal projection', () => { it('SECRETS_CAUSAL_PREFIX_ORDER protects a pre-scrubbed overlapping query in a verbose stack', () => { const suffix = 'AbCdEf0123456789xyzUVWXYZ'; - const query = `?refresh_token=${['sk-', 'ant-', 'api03-', '\n', suffix].join('')}`; - const llmError = makeLlmError({ provider: 'openai', kind: 'auth', message: query }); - const error = new CliError('invalid_invocation', 'request failed; retry after 30s'); - error.stack = `outer stack ${llmError.message}`; - const capture = captureIo(); - renderError(error, { json: false, verbose: true }, capture.io); - const output = capture.stderr(); - expect(output, 'SECRETS_CAUSAL_PREFIX_ORDER').not.toContain(suffix); - expect(output).toContain('request failed; retry after 30s'); - expect(output).toContain('[REDACTED]'); + // The broader residual join remains independently load-bearing even with the LF/TAB probe. + for (const separator of ['\n', '\t', ' \r\n\t ']) { + const query = `?refresh_token=${['sk-', 'ant-', 'api03-', separator, suffix].join('')}`; + const llmError = makeLlmError({ provider: 'openai', kind: 'auth', message: query }); + const error = new CliError('invalid_invocation', 'request failed; retry after 30s'); + Object.defineProperty(error, 'stack', { value: `outer stack ${llmError.message}` }); + const capture = captureIo(); + renderError(error, { json: false, verbose: true }, capture.io); + const output = capture.stderr(); + expect(output, 'SECRETS_CAUSAL_PREFIX_ORDER').not.toContain(suffix); + expect(output).toContain('request failed; retry after 30s'); + expect(output).toContain('[REDACTED]'); + } }); it.each( @@ -283,7 +290,7 @@ describe('renderError terminal projection', () => { for (const projection of [text, llmError.message, llmError.code ?? '']) { const capture = captureIo(); const error = new CliError('invalid_invocation', projection); - error.stack = `outer stack ${projection}`; + Object.defineProperty(error, 'stack', { value: `outer stack ${projection}` }); renderError(error, options, capture.io); const output = capture.stderr(); expect(output, 'SECRETS_CAUSAL_OVERLAP_ORDER').not.toContain(secret); @@ -311,7 +318,7 @@ describe('renderError terminal projection', () => { }); for (const projection of [llmError.message, llmError.code ?? '']) { const error = new CliError('invalid_invocation', 'request failed; retry after 30s'); - error.stack = `outer stack ${projection}`; + Object.defineProperty(error, 'stack', { value: `outer stack ${projection}` }); const capture = captureIo(); renderError(error, { json: false, verbose: true }, capture.io); const output = capture.stderr(); @@ -325,7 +332,7 @@ describe('renderError terminal projection', () => { it('sanitizes JSON diagnostic values and never appends a raw verbose stack', () => { const error = new CliError('invalid_invocation', `message visible${ATTACK}\nforged message`); - error.stack = `stack visible${ATTACK}`; + Object.defineProperty(error, 'stack', { value: `stack visible${ATTACK}` }); const capture = captureIo(); renderError(error, { json: true, verbose: true }, capture.io); @@ -340,3 +347,25 @@ describe('renderError terminal projection', () => { expect(message).toContain('message visible'); }); }); + +it.each(['prototype', 'stack'] as const)( + 'keeps a fixed diagnostic when foreign %s inspection traps', + (trap) => { + const error = + trap === 'prototype' + ? new Proxy(new Error('PRIVATE_TRAP'), { + getPrototypeOf: () => { + throw new Error('PRIVATE_PROTOTYPE'); + }, + }) + : Object.defineProperty(new Error('PRIVATE_TRAP'), 'stack', { + get: () => { + throw new Error('PRIVATE_STACK'); + }, + }); + const capture = captureIo(); + expect(() => renderError(error, { json: false, verbose: true }, capture.io)).not.toThrow(); + expect(capture.stderr()).not.toContain('PRIVATE_'); + expect(capture.stderr()).toContain('relavium:'); + }, +); diff --git a/apps/cli/src/process/render-error.ts b/apps/cli/src/process/render-error.ts index a7619f05..f3b6462d 100644 --- a/apps/cli/src/process/render-error.ts +++ b/apps/cli/src/process/render-error.ts @@ -9,8 +9,9 @@ import { renderUserFacing } from './render-user-facing.js'; * ([ADR-0049](../../../../docs/decisions/0049-cli-machine-output-contract.md)): under `--json` it is a * structured `{ type: 'error', code, message }` envelope (a CLI fault, distinct from the run-stream's * `run:failed`/`node:failed`); otherwise a `relavium: ` human line. So under `--json` a fault - * leaves stdout empty and the exit code is the primary signal. The original stack is written to stderr - * **only** under `--verbose`, never as primary output. + * leaves stdout empty and the exit code is the primary signal. This legacy renderer accepts only an own + * data-string stack under `--verbose`; foreign/native accessors are never invoked. Production fatal + * diagnostics use the separate fixed-message projection. */ export function renderError( value: unknown, diff --git a/apps/cli/src/process/render-user-facing.ts b/apps/cli/src/process/render-user-facing.ts index 20da30e7..c60ffe7b 100644 --- a/apps/cli/src/process/render-user-facing.ts +++ b/apps/cli/src/process/render-user-facing.ts @@ -27,8 +27,21 @@ export function renderUserFacing( // The raw stack is a human-mode debugging affordance only — never under `--json`, where it would // mix non-JSON text into the structured stderr diagnostic (error-handling.md: a stack is not machine // output). Under `--json` the `{ type: 'error', … }` envelope is the whole stderr diagnostic. - if (opts.verbose && !opts.json && value instanceof Error && value.stack !== undefined) { - // A stack embeds the message, and often argument values with it — this is the likeliest leak of the four. - io.writeErr(sanitizeUntrusted(value.stack) + '\n'); + if (opts.verbose && !opts.json) { + const stack = ownErrorStack(value); + if (stack !== undefined) io.writeErr(sanitizeUntrusted(stack) + '\n'); + } +} + +/** A foreign throwable can trap prototype or descriptor inspection; never invoke a stack accessor. */ +function ownErrorStack(value: unknown): string | undefined { + try { + if (!(value instanceof Error)) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, 'stack'); + return descriptor !== undefined && 'value' in descriptor && typeof descriptor.value === 'string' + ? descriptor.value + : undefined; + } catch { + return undefined; } } diff --git a/apps/cli/src/render/hatches.test.ts b/apps/cli/src/render/hatches.test.ts index b97831bb..7e7fb3d2 100644 --- a/apps/cli/src/render/hatches.test.ts +++ b/apps/cli/src/render/hatches.test.ts @@ -103,6 +103,20 @@ const harness = ( }; describe('/scrollback', () => { + it('does not let a throwing observer replace the hatch fault or skip the notice', async () => { + const primary = new CliError('output_unavailable', 'PRIVATE_PRIMARY'); + const failure = vi.fn(() => { + throw new Error('PRIVATE_OBSERVER'); + }); + const { deps, notes } = harness({ + onFailure: failure, + dump: { writeOut: () => Promise.reject(primary), waitForContinue: () => Promise.resolve() }, + }); + await expect(createHatches(deps).dumpScrollback()).resolves.toBeUndefined(); + expect(failure).toHaveBeenCalledExactlyOnceWith(primary); + expect(notes.join('')).toContain('CLI output could not be delivered'); + expect(notes.join('')).not.toContain('PRIVATE_'); + }); it('observes failed transcript delivery before a held native reclaim can hide the failure', async () => { let finishNative: () => void = () => undefined; const native = new Promise((resolve) => { @@ -136,6 +150,7 @@ describe('/scrollback', () => { finishNative(); await dump; expect(notes.join('')).not.toContain('PRIVATE_OUTPUT_FAULT'); + expect(failure).toHaveBeenCalledExactlyOnceWith(primary); }); it('suspends the renderer, dumps, waits, and restores every terminal mode — in order', async () => { diff --git a/apps/cli/src/render/hatches.ts b/apps/cli/src/render/hatches.ts index 26e58b87..96204071 100644 --- a/apps/cli/src/render/hatches.ts +++ b/apps/cli/src/render/hatches.ts @@ -162,6 +162,16 @@ export function createHatches(deps: HatchDeps): Hatches { if (busy) return false; // a suspension is already in flight; ink would throw busy = true; const term = deps.terminal(); + const observed = new Set(); + const reportFailure = (cause: unknown): void => { + if (observed.has(cause)) return; + observed.add(cause); + try { + deps.onFailure?.(cause); + } catch { + // The owner observation cannot replace the primary operation fault or skip its notice. + } + }; try { await suspendFullScreen( { @@ -170,7 +180,7 @@ export function createHatches(deps: HatchDeps): Hatches { try { await deps.writeControl(sequence); } catch (cause) { - deps.onFailure?.(cause); + reportFailure(cause); throw cause; } }, @@ -182,14 +192,14 @@ export function createHatches(deps: HatchDeps): Hatches { try { await body(); } catch (cause) { - deps.onFailure?.(cause); + reportFailure(cause); throw cause; } }, ); return true; } catch (error) { - deps.onFailure?.(error); + reportFailure(error); // Preserve the owned failure before its notice. Native release/ACK remains separate from presentation. const message = deps.onFailure === undefined diff --git a/apps/cli/src/render/run-job-control.test.ts b/apps/cli/src/render/run-job-control.test.ts index 4cf4fecd..6f2cbfe6 100644 --- a/apps/cli/src/render/run-job-control.test.ts +++ b/apps/cli/src/render/run-job-control.test.ts @@ -48,6 +48,47 @@ function harness(supported = true) { } describe('wireRunJobControl (G0, the run/gate path)', () => { + it('reports suspend-listener removal faults at the signal owner instead of throwing', async () => { + const h = harness(); + const primary = new Error('PRIVATE_SIGNAL_REMOVE'); + const failure = vi.fn(); + const control = wireRunJobControl({ + write: h.write, + lifecycle: h.lifecycle, + onFailure: failure, + }); + h.removeSuspend.mockImplementationOnce(() => { + throw primary; + }); + expect(() => h.fireSuspend()).not.toThrow(); + expect(failure).toHaveBeenCalledExactlyOnceWith(primary); + expect(h.suspendSelf).not.toHaveBeenCalled(); + control.dispose(); + await control.released; + }); + + it('reports a failed reattach from SIGCONT without throwing from its native callback', () => { + const h = harness(); + const primary = new Error('PRIVATE_SIGNAL_ATTACH'); + const failure = vi.fn(); + let entries = 0; + const control = wireRunJobControl({ + write: h.write, + lifecycle: { + ...h.lifecycle, + onSuspend: (listener) => { + if (++entries === 2) throw primary; + return h.lifecycle.onSuspend(listener); + }, + }, + onFailure: failure, + }); + h.fireSuspend(); + expect(() => h.fireContinue()).not.toThrow(); + expect(failure).toHaveBeenCalledExactlyOnceWith(primary); + control.dispose(); + }); + it.each(['suspend', 'continue'] as const)( 'reports a native %s cursor write failure while retaining actual terminal restoration', async (phase) => { diff --git a/apps/cli/src/render/run-job-control.ts b/apps/cli/src/render/run-job-control.ts index b1d58b4b..6111c94e 100644 --- a/apps/cli/src/render/run-job-control.ts +++ b/apps/cli/src/render/run-job-control.ts @@ -118,13 +118,18 @@ export function wireRunJobControl(opts: WireRunJobControlOptions): RunJobControl if (disposed) return; if (!writeCursor(SHOW_CURSOR)) return; if (disposed) return; - detached(); - if (disposed) return; try { - lifecycle.suspendSelf(); + detached(); + if (disposed) return; + try { + lifecycle.suspendSelf(); + } catch (cause) { + opts.onFailure?.(cause); + attach(); + } } catch (cause) { - opts.onFailure?.(cause); - attach(); + if (opts.onFailure === undefined) throw cause; + opts.onFailure(cause); } }; const attach = (): void => { @@ -156,8 +161,13 @@ export function wireRunJobControl(opts: WireRunJobControlOptions): RunJobControl try { removeContinue = lifecycle.onContinue(() => { if (disposed) return; - attach(); - if (!disposed) writeCursor(HIDE_CURSOR); + try { + attach(); + if (!disposed) writeCursor(HIDE_CURSOR); + } catch (cause) { + if (opts.onFailure === undefined) throw cause; + opts.onFailure(cause); + } }); continueRegistered = true; } catch (cause) { diff --git a/apps/cli/src/render/tui/chat-ink.tsx b/apps/cli/src/render/tui/chat-ink.tsx index 14095585..91773cf0 100644 --- a/apps/cli/src/render/tui/chat-ink.tsx +++ b/apps/cli/src/render/tui/chat-ink.tsx @@ -915,10 +915,9 @@ export function ChatApp(props: Readonly): ReactElement { void refresh().then( (report) => applyPickerRefresh(epoch, report), () => { - // refresh()/refreshIfStale() never reject (per-provider isolation), but stay defensive: drop the spinner - // only when this is still the same open picker generation. + // Owner refusal rejects; never publish after input admission has sealed. const cur = modelPickerRef.current; - if (epoch === pickerEpochRef.current && cur !== undefined) + if (acceptsInput() && epoch === pickerEpochRef.current && cur !== undefined) applyModelPicker({ ...cur, loading: false }); }, ); diff --git a/apps/cli/src/render/tui/chat-picker-stop.test.tsx b/apps/cli/src/render/tui/chat-picker-stop.test.tsx new file mode 100644 index 00000000..67f6296c --- /dev/null +++ b/apps/cli/src/render/tui/chat-picker-stop.test.tsx @@ -0,0 +1,57 @@ +import type { ReactElement } from 'react'; +import { useStdout } from 'ink'; +import { cleanup, render } from 'ink-testing-library'; +import { afterEach, expect, it, vi } from 'vitest'; +import type { ChatModelsPort } from '../../commands/chat.js'; +import type { RefreshReport } from '../../engine/model-refresh.js'; +import { ChatApp } from './chat-ink.js'; +import { createChatStore } from './chat-store.js'; +import { INLINE_TRANSCRIPT_BOUND } from './session-view-model.js'; +import { waitFor } from './harness-util.js'; + +function WritableCapture(props: { readonly children: ReactElement }): ReactElement { + const { stdout } = useStdout(); + Object.defineProperty(stdout, 'writable', { value: true }); + return props.children; +} +afterEach(cleanup); + +it('does not publish a picker refresh refusal after chat input admission seals', async () => { + let rejectRefresh: (cause: Error) => void = () => undefined; + const pending = new Promise((_resolve, reject) => { + rejectRefresh = reject; + }); + const load = vi.fn(() => ({ entries: [], refreshedAt: undefined })); + const modelPicker: ChatModelsPort = { + boundModel: 'offline-model', + load, + refresh: () => pending, + refreshIfStale: () => pending, + }; + let admitted = true; + const view = render( + + {}} + shouldStop={() => false} + onExit={() => {}} + onError={() => {}} + onModeChange={() => {}} + inputEnabled={() => admitted} + modelPicker={modelPicker} + /> + , + ); + view.stdin.write('/models'); + view.stdin.write('\r'); + await waitFor(() => view.lastFrame()?.includes('refreshing') === true); + expect(load, 'PICKER_REFRESH_ACTUALLY_ENTERED').toHaveBeenCalledOnce(); + expect(view.lastFrame()).toContain('refreshing'); + admitted = false; + const frames = view.frames.length; + rejectRefresh(new Error('owner stopped')); + await waitFor(() => view.frames.length !== frames, 20); + expect(view.frames.length, 'CHAT_REFRESH_REFUSAL_AFTER_SEAL').toBe(frames); + expect(view.lastFrame()).toContain('refreshing'); +}); diff --git a/apps/cli/src/render/tui/home-completion-stop.test.ts b/apps/cli/src/render/tui/home-completion-stop.test.ts index 7a7ed243..1f31427f 100644 --- a/apps/cli/src/render/tui/home-completion-stop.test.ts +++ b/apps/cli/src/render/tui/home-completion-stop.test.ts @@ -239,7 +239,7 @@ describe('Home deferred results respect permanent controller stop', () => { await tick(); expect(f.startChat, 'HOME_STOPPED_CLEAR_REBUILD').toHaveBeenCalledTimes(stopped ? 1 : 2); if (!stopped) expect(f.controller.getSnapshot().session).toBe(f.fresh); - expect(f.controller.getSnapshot().submitBusy).toBe(false); + expect(f.controller.getSnapshot().submitBusy).toBe(stopped); expect(f.onError).not.toHaveBeenCalled(); } finally { raw.resolve(undefined); diff --git a/apps/cli/src/render/tui/home-controller.test.ts b/apps/cli/src/render/tui/home-controller.test.ts index a3fa720e..830a00ed 100644 --- a/apps/cli/src/render/tui/home-controller.test.ts +++ b/apps/cli/src/render/tui/home-controller.test.ts @@ -125,6 +125,41 @@ describe('createHomeController (2.5.B lifecycle / ADR-0054)', () => { vi.restoreAllMocks(); }); + it.each(['resolve', 'reject'] as const)( + 'drops a late turn %s after Home teardown seals publication', + async (outcome) => { + let finish: () => void = () => undefined; + let fail: (cause: Error) => void = () => undefined; + const held = new Promise((resolve, reject) => { + finish = resolve; + fail = reject; + }); + const made = makeSession({ onProcess: () => held }); + const c = createHomeController({ + doctorProbes: STUB_DOCTOR_PROBES, + startChat: () => Promise.resolve(made.session), + homeStore, + onExit: vi.fn(), + onError: vi.fn(), + }); + type(c, 'hello'); + c.handleKey('', ENTER); + await flush(); + expect(made.lines).toEqual(['hello']); + expect(c.getSnapshot().submitBusy).toBe(true); + await c.teardownActive(); + const sealed = c.getSnapshot(); + const changed = vi.fn(); + const unsubscribe = c.subscribe(changed); + if (outcome === 'resolve') finish(); + else fail(new Error('late turn fault')); + await flush(); + expect(changed, 'HOME_TURN_RESULT_AFTER_SEAL').not.toHaveBeenCalled(); + expect(c.getSnapshot()).toBe(sealed); + unsubscribe(); + }, + ); + it('reports an unexpected snapshot failure after chat teardown instead of returning a healthy Home', async () => { let stopped = false; const made = makeSession({ stop: () => stopped }); @@ -1878,6 +1913,27 @@ describe('the /models picker in the bare Home (2.5.G S7 / ADR-0064 §10)', () => return c; } + it('drops a model refresh rejection after Home teardown sealed publication', async () => { + let rejectRefresh: (cause: Error) => void = () => undefined; + const { port } = makeModelsPort({ + refreshIfStale: () => + new Promise((_resolve, reject) => { + rejectRefresh = reject; + }), + }); + const c = openPicker(port); + const changed = vi.fn(); + const unsubscribe = c.subscribe(changed); + await c.teardownActive(); + changed.mockClear(); + const sealed = c.getSnapshot(); + rejectRefresh(new Error('owner stopped')); + await flush(); + expect(changed, 'HOME_REJECTED_REFRESH_AFTER_SEAL').not.toHaveBeenCalled(); + expect(c.getSnapshot()).toBe(sealed); + unsubscribe(); + }); + it('opens over the merged catalog and kicks a TTL-bounded background refresh', async () => { const { port, load, refreshIfStale } = makeModelsPort({ refreshedAt: 1000 }); const c = openPicker(port); diff --git a/apps/cli/src/render/tui/home-controller.ts b/apps/cli/src/render/tui/home-controller.ts index aaa98d76..3119e17c 100644 --- a/apps/cli/src/render/tui/home-controller.ts +++ b/apps/cli/src/render/tui/home-controller.ts @@ -615,7 +615,7 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { set({ submitBusy: true }); void active.processLine(line, display).then( () => { - if (state.session === active) set({ submitBusy: false }); + if (!exiting && state.session === active) set({ submitBusy: false }); if (!isCurrentChat(active)) return; if (active.shouldStop()) { // `/clear` (ADR-0062 §7) swaps in a FRESH session, staying in chat; `/exit`/`/cancel` end to the bare Home. @@ -624,7 +624,7 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { } }, (err: unknown) => { - if (state.session === active) set({ submitBusy: false }); + if (!exiting && state.session === active) set({ submitBusy: false }); tearingDown = active; // align with endChat's single-shot guard (the session closure is idempotent) const td = active.teardown(); activeTeardown = td; // a concurrent signal awaits this teardown too @@ -730,10 +730,9 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { void refresh().then( (report) => applyRefreshResult(epoch, report), () => { - // refresh()/refreshIfStale() never reject (per-provider isolation), but stay defensive: drop the spinner - // only when this is still the same open picker generation. + // Owner refusal rejects; never publish it after exit or into a later picker generation. const cur = state.modelPicker; - if (epoch === pickerEpoch && cur !== undefined) + if (!exiting && epoch === pickerEpoch && cur !== undefined) set({ modelPicker: { ...cur, loading: false } }); }, ); diff --git a/apps/cli/src/render/tui/run-view-model.test.ts b/apps/cli/src/render/tui/run-view-model.test.ts index 112c5ce9..d0d99a62 100644 --- a/apps/cli/src/render/tui/run-view-model.test.ts +++ b/apps/cli/src/render/tui/run-view-model.test.ts @@ -20,6 +20,17 @@ function reduceAll(events: readonly RunEvent[]): RunViewState { } describe('reduceRunEvent', () => { + it('preserves current view state for a newer external runtime event', () => { + const state = initialRunViewState(); + const result = reduceRunEvent(state, { + // @ts-expect-error Runtime compatibility control: a newer event is outside today's closed type. + type: 'future:event', + runId: RUN, + timestamp: TS, + sequenceNumber: 1, + }); + expect(result).toEqual({ ...state, lastSequenceNumber: 1 }); + }); it('starts empty', () => { const s = initialRunViewState(); expect(s.nodeOrder).toEqual([]); diff --git a/apps/cli/src/render/tui/run-view-model.ts b/apps/cli/src/render/tui/run-view-model.ts index 6c768e90..ca0ab4e0 100644 --- a/apps/cli/src/render/tui/run-view-model.ts +++ b/apps/cli/src/render/tui/run-view-model.ts @@ -533,7 +533,8 @@ export function reduceRunEvent(state: RunViewState, event: RunEvent): RunViewSta return base; default: { const exhaustive: never = event; - return exhaustive; + void exhaustive; // Keep compile-time exhaustiveness and tolerate newer external runtime events. + return base; } } } diff --git a/packages/shared/src/run-history-errors.ts b/packages/shared/src/run-history-errors.ts index 47c06fae..55792efa 100644 --- a/packages/shared/src/run-history-errors.ts +++ b/packages/shared/src/run-history-errors.ts @@ -16,7 +16,8 @@ export class CorruptRunEventError extends RelaviumError { * The row's `event_type` column. Our own writes put a union literal here, but the column carries no CHECK * (database-schema.md), so a hand-edited DB could hold arbitrary text — hence the length bound in the message below. * Terminal/bidi control bytes are stripped one layer up, where every user-facing error passes through - * `sanitizeInline` (`apps/cli/src/process/render-error.ts`); this field is not a second sanitization seam. + * the CLI classification/presentation boundary (`apps/cli/src/process/errors.ts` and + * `apps/cli/src/render/sanitize.ts`); this field is not a second sanitization seam. */ readonly eventType: string, cause: unknown, @@ -24,7 +25,7 @@ export class CorruptRunEventError extends RelaviumError { const shownType = eventType.length > 64 ? `${eventType.slice(0, 64)}…` : eventType; super( `run ${runId} has a damaged event row at seq ${sequenceNumber} (type ${shownType})`, - // Preserved so `--verbose` can still show the underlying ZodError/SyntaxError detail. + // Preserve the private cause for trusted debugging; public diagnostics never promise to echo it. { cause }, ); } From 508115b4d26c90b0b7ba91cc4e586c3127031a80 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 11:46:43 +0300 Subject: [PATCH 187/222] fix(repo): verify installed commands and corrective controls Require actual installed command shims and independently pinned public metadata. Restrict cache credentials, strengthen bootstrap boundaries and update load-bearing credential controls with runtime-error receipts. Refs: ADR-0067, ADR-0110, ADR-0111 Co-Authored-By: Codex --- .github/workflows/ci.yml | 12 +- docs/reference/cli/packaging.md | 16 ++- tools/causal-controls/provenance.test.mjs | 42 +++++- tools/ci/require-packed-cli.test.mjs | 58 +++++++- tools/cli-bootstrap/README.md | 11 +- tools/cli-bootstrap/check.mjs | 18 ++- tools/cli-bootstrap/child.mjs | 16 ++- tools/cli-bootstrap/inventory.mjs | 10 +- tools/cli-bootstrap/preload.mjs | 29 ++-- tools/cli-bootstrap/witness.test.mjs | 107 ++++++++++++++- tools/cli-package/README.md | 10 ++ tools/cli-package/archive.mjs | 7 + tools/cli-package/archive.test.mjs | 30 ++++ tools/cli-package/binding.mjs | 8 ++ tools/cli-package/binding.test.mjs | 18 +++ tools/cli-package/consume.mjs | 4 +- tools/cli-package/process.mjs | 29 +++- tools/cli-package/process.test.mjs | 56 +++++++- tools/cli-package/seal.mjs | 13 +- tools/cli-package/seal.test.mjs | 15 +- tools/cli-package/smoke/README.md | 6 +- tools/cli-package/smoke/native-process.mjs | 12 +- .../cli-package/smoke/native-process.test.mjs | 4 +- tools/secrets-controls/README.md | 48 +++++-- tools/secrets-controls/mutations.mjs | 129 +++++++++++++++++- 25 files changed, 650 insertions(+), 58 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a48c85a8..d2c046fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,7 +281,7 @@ jobs: secrets-mutations: name: Wave 2 secrets causal controls # The historical 35-control, three-phase capture exceeded the former aggregate budget. - # The current cohort is 41 controls (2026-10-15); this comment does not certify its execution. + # The current cohort is 51 controls (2026-10-18); this comment does not certify its execution. # Per-phase, case/hook, output and retained-evidence bounds remain unchanged. timeout-minutes: 30 runs-on: ubuntu-latest @@ -648,6 +648,9 @@ jobs: timeout-minutes: 20 permissions: contents: read + env: + TURBO_TOKEN: '' + TURBO_TEAM: '' steps: # Only the test runner and fixtures use this checkout. No workspace dependencies or build. - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -694,6 +697,9 @@ jobs: timeout-minutes: 5 permissions: contents: read + env: + TURBO_TOKEN: '' + TURBO_TEAM: '' steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: @@ -702,6 +708,10 @@ jobs: uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .nvmrc + - name: Require successful pack and every consumer before artifact downloads + env: + RELAVIUM_PACKAGE_JOB_RESULTS: ${{ toJSON(needs) }} + run: node --input-type=module -e "import { requirePackageJobResults } from './tools/cli-package/binding.mjs'; requirePackageJobResults(JSON.parse(process.env.RELAVIUM_PACKAGE_JOB_RESULTS ?? 'null'));" - name: Download the exact packed CLI uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index 8c9aa8cc..3df41142 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -28,7 +28,10 @@ or escaping tar entries, unbundled internal imports, undeclared external package and unanalyzable runtime loading unless explicitly reviewed/allowlisted. Chunk layout must preserve real installed `../drizzle` resolution. Runtime files, migrations, package metadata and existing documented license/changelog assets -form an explicit public allowlist. Only the entry has its source hashbang and executable mode 0755; runtime chunks +form an explicit public allowlist. Public package validation independently pins +`bin.relavium` to `./dist/index.js` and `engines.node` to the [ADR-0067](../../decisions/0067-node-supported-floor-22-reaffirm-better-sqlite3.md) +public floor `>=22`; copying the source manifest into a receipt is insufficient. Archive +validation checks those fields against the same fixed contract. Only the entry has its source hashbang and executable mode 0755; runtime chunks have no hashbang and mode 0644. Remove the global tsup banner before emission, not a first-line rewrite after mapping. Validate tar modes and every output. No source TS/tree, map, private inventory, @@ -125,9 +128,20 @@ the finalized JS and canonical migrations. Its exact public allowlist and native tar-header checks apply on every consumer platform. The consumer installs those verified bytes outside the workspace with pinned pnpm and the existing lifecycle allowlist, verifies installed public files, and records actual dependency versions. +It invokes the installed `node_modules/.bin/relavium` command for `--help` and +`--version` (the platform command wrapper on Windows), checking successful output. +These checks exercise the package's declared executable installation. The deeper +untouched-entry smoke remains a distinct behavioral control. A consumer success +receipt requires `installedCommand.help` and `installedCommand.version` both true. Dependency installation uses the public registry; provider transport and keychain API controls use synthetic data and refusals. +The second PR #95 review adds these executable/engine pins and installed-command +controls. Prior receipts, including `a5a5d45e`-era evidence, retain their historical +source and schema attribution; they do not prove the new controls. Corrected-source +same-archive platform execution and independent acceptance remain pending in the +[second correction register](../../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-second-systematic-review-corrections--2026-10-18). + Installed native smoke and SQLite reads run in an owned bounded child. The installation cleanup owner joins actual child closure before removing the tree; a connection close alone does not unload a Windows native module. Required diff --git a/tools/causal-controls/provenance.test.mjs b/tools/causal-controls/provenance.test.mjs index ef92b24e..ed09115e 100644 --- a/tools/causal-controls/provenance.test.mjs +++ b/tools/causal-controls/provenance.test.mjs @@ -267,21 +267,59 @@ test('every declared filesystem and secrets anchor matches current production so }); test('secrets workload corrections retain independent complete-count and reader-witness pins', () => { - assert.equal(secretsControls.length, 41); + assert.equal(secretsControls.length, 51); assert.equal( secretsControls.reduce((count, control) => count + 1 + (control.witnesses ?? []).length, 0), - 277, + 302, ); for (const [id, file, expectedTests] of [ ['run-default-secret-reader', 'apps/cli/src/commands/run.test.ts', 84], ['snapshot-run-warning', 'apps/cli/src/commands/run.test.ts', 84], ['gate-consumed-stdin-prompt-authority', 'apps/cli/src/commands/gate.test.ts', 73], + ['export-command-review-notice', 'apps/cli/src/commands/chat-export.test.ts', 17], ]) { const control = secretsControls.find((entry) => entry.id === id); assert.ok(control, `${id}: missing control`); assert.deepEqual(control.files, [file]); assert.equal(control.expectedTests, expectedTests, `${id}: complete workload drift`); } + for (const [id, files, expectedTests] of [ + [ + 'diagnostic-residual-scrub', + ['apps/cli/src/render/sanitize.test.ts', 'apps/cli/src/process/render-error.test.ts'], + 163, + ], + ...['prefix-before-redaction', 'normalized-secret-probe', 'non-query-before-query'].map( + (id) => [ + id, + [ + 'packages/llm/src/llm-error.test.ts', + 'apps/cli/src/render/sanitize.test.ts', + 'apps/cli/src/process/render-error.test.ts', + ], + 261, + ], + ), + ...[ + 'core-modern-key-alphabet', + 'core-joined-credential-spans', + 'core-joined-original-boundary', + 'core-split-scheme-label', + 'core-split-header-label', + 'core-raw-password-userinfo', + ].map((id) => [id, ['packages/core/src/tools/bounding.test.ts'], 129]), + ...[ + 'diagnostic-joined-before-masking', + 'diagnostic-joined-original-boundary', + 'diagnostic-split-header-label', + 'diagnostic-raw-password-userinfo', + ].map((id) => [id, ['packages/llm/src/scrub-secrets.test.ts'], 79]), + ]) { + const selected = secretsControls.find((entry) => entry.id === id); + assert.ok(selected, `${id}: missing redaction control`); + assert.deepEqual(selected.files, files); + assert.equal(selected.expectedTests, expectedTests, `${id}: complete workload drift`); + } const reader = secretsControls.find((entry) => entry.id === 'run-default-secret-reader'); assert.equal( reader.target, diff --git a/tools/ci/require-packed-cli.test.mjs b/tools/ci/require-packed-cli.test.mjs index ac0007c2..94272154 100644 --- a/tools/ci/require-packed-cli.test.mjs +++ b/tools/ci/require-packed-cli.test.mjs @@ -13,6 +13,7 @@ const reportsDirectory = '${{ runner.temp }}/packed-cli-reports'; const packCommand = `node tools/cli-package/pack.mjs "${artifactDirectory}"`; const consumeCommand = `node tools/cli-package/consume.mjs "${artifactDirectory}" "${reportDirectory}"`; const aggregateCommand = `node tools/cli-package/aggregate.mjs "${artifactDirectory}" "${reportsDirectory}"`; +const outcomesCommand = `node --input-type=module -e "import { requirePackageJobResults } from './tools/cli-package/binding.mjs'; requirePackageJobResults(JSON.parse(process.env.RELAVIUM_PACKAGE_JOB_RESULTS ?? 'null'));"`; const actions = { checkout: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5', pnpm: 'pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320', @@ -97,7 +98,7 @@ function checkAggregate(job, packId, consumerId) { }); assert.deepEqual( job.steps.filter((step) => step.run).map((step) => step.run), - [aggregateCommand], + packId === 'pack-cli' ? [outcomesCommand, aggregateCommand] : [aggregateCommand], ); const validator = job.steps.find((step) => step.run === aggregateCommand); assert.deepEqual(validator.env, { @@ -117,8 +118,63 @@ function checkAggregate(job, packId, consumerId) { ], ); assert.ok(downloads.every((step) => job.steps.indexOf(step) < job.steps.indexOf(validator))); + if (packId === 'pack-cli') { + const outcomes = job.steps.find((step) => step.run === outcomesCommand); + assert.deepEqual(outcomes.env, { RELAVIUM_PACKAGE_JOB_RESULTS: '${{ toJSON(needs) }}' }); + assert.ok(downloads.every((step) => job.steps.indexOf(outcomes) < job.steps.indexOf(step))); + } +} + +function checkUnprivilegedPackageJobs(workflow) { + for (const id of ['pack-cli', 'packed-cli-consumer', 'packed-cli']) { + const job = workflow.jobs[id]; + for (const key of ['TURBO_TOKEN', 'TURBO_TEAM']) { + assert.equal(job.env?.[key], '', `${id} must clear inherited ${key}`); + for (const step of job.steps) + if (Object.hasOwn(step.env ?? {}, key)) + assert.equal(step.env[key], '', `${id} step reintroduced ${key}`); + } + } } +test('all PR-controlled installed-package jobs clear inherited remote-cache credentials', () => { + checkUnprivilegedPackageJobs(readWorkflow('ci')); +}); + +for (const id of ['pack-cli', 'packed-cli-consumer', 'packed-cli']) + for (const key of ['TURBO_TOKEN', 'TURBO_TEAM']) + for (const value of [undefined, '${{ secrets.REMOTE_CACHE_CREDENTIAL }}']) + test(`package privilege guard refuses ${id} ${key}: ${String(value)}`, () => { + const workflow = readWorkflow('ci'); + workflow.jobs[id].env[key] = value; + assert.throws(() => checkUnprivilegedPackageJobs(workflow)); + }); + +for (const [name, mutate] of [ + ['removed outcome gate', (job, gate) => job.steps.splice(job.steps.indexOf(gate), 1)], + [ + 'outcome gate after downloads', + (job, gate) => { + job.steps.splice(job.steps.indexOf(gate), 1); + job.steps.push(gate); + }, + ], + [ + 'disabled outcome gate', + (_job, gate) => { + gate.if = '${{ false }}'; + }, + ], +]) + test(`aggregate refuses ${name}`, () => { + const job = readWorkflow('ci').jobs['packed-cli']; + mutate( + job, + job.steps.find((step) => step.run === outcomesCommand), + ); + assert.throws(() => checkAggregate(job, 'pack-cli', 'packed-cli-consumer')); + }); + for (const [name, packId, consumerId] of [ ['ci', 'pack-cli', 'packed-cli-consumer'], ['release', 'pack', 'smoke'], diff --git a/tools/cli-bootstrap/README.md b/tools/cli-bootstrap/README.md index 77eded1a..6a04a8d1 100644 --- a/tools/cli-bootstrap/README.md +++ b/tools/cli-bootstrap/README.md @@ -27,7 +27,10 @@ comparison; this is not a measurement of a previous revision. The native `--import` preload installs Node 22.0-compatible `module.register` hooks before importing the entry. It records and refuses native/provider/MCP/TUI/ schema/config-parser imports, protects poisoned temporary HOME/workspace/grant -paths, and refuses network/subprocess dispatch. Node's module/package loader FS +paths, and refuses network/subprocess dispatch, including global WebSocket. +Copy, hard-link and symlink APIs guard both operands in callback, synchronous and +promise forms, including Buffer/file-URL paths and relative symlink targets. +Node's module/package loader FS reads outside those application roots remain allowed and recorded. The main witness writer retains an open descriptor and primitive write function, avoiding the patched FS wrappers. Native `realpath[Sync].native` is preserved and guarded. @@ -36,12 +39,14 @@ Positive controls execute the real compiled chat grant-admission path and refuse real deferred entry/dispatcher chunk loads. Import failures must reach the fixed diagnostics without the arbitrary synthetic error canary or stack. Their current native exit 9 and single fixed human/JSON diagnostic are pinned. The complete -177-control inventory is shared with installed-package binding. +177-control inventory is shared with installed-package binding. Every variant has +a unique stable name, including nested commands and retained single-command aliases. Each scenario uses a fresh sanitized child environment and a finite deadline, with a combined UTF-8 output-byte cap. POSIX children own a process group which is killed and joined; product subprocess dispatch is forbidden on every OS. -Fixture snapshots prove poisoned application files/directories remain unchanged, +All three temporary-directory environment selectors point into an owned, guarded +fixture directory. Fixture snapshots prove temporary and poisoned application files/directories remain unchanged, including on passing controls. Cleanup removes only the exact owned mkdtemp root. These controls establish selected-artifact behavior on the runtime/OS that diff --git a/tools/cli-bootstrap/check.mjs b/tools/cli-bootstrap/check.mjs index 45ae164d..c04a23b8 100644 --- a/tools/cli-bootstrap/check.mjs +++ b/tools/cli-bootstrap/check.mjs @@ -5,7 +5,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { performance } from 'node:perf_hooks'; import { createHash } from 'node:crypto'; import { withFixture, toEntryUrl } from './child.mjs'; -import { metaCases, grammarCases, BOOTSTRAP_CONTROL_COUNT } from './inventory.mjs'; +import { metaCases, grammarCases, controlName, BOOTSTRAP_CONTROL_COUNT } from './inventory.mjs'; import { assertWitness, importFailureCanary } from './policy.mjs'; const repository = fileURLToPath(new URL('../../', import.meta.url)); @@ -79,7 +79,7 @@ export async function runBootstrap(packageRoot = join(repository, 'apps/cli')) { `${name}: global flags changed existing human meta bytes`, ); records.push({ - name, + name: controlName(name, json, rootGrant), json, absentAbsoluteRoot: rootGrant, exitCode: result.code, @@ -99,7 +99,7 @@ export async function runBootstrap(packageRoot = join(repository, 'apps/cli')) { assert.notEqual(result.stderr, '', name); if (nonEcho !== undefined) assert.ok(!result.stderr.includes(nonEcho), `${name}: unsafe argument echoed`); - records.push({ name, json, exitCode: result.code }); + records.push({ name: controlName(name, json), json, exitCode: result.code }); } } @@ -157,7 +157,12 @@ export async function runBootstrap(packageRoot = join(repository, 'apps/cli')) { 0, name, ); - records.push({ name, json, exitCode: result.code, refusedUrl: failRuntimeUrl }); + records.push({ + name: controlName(name, json), + json, + exitCode: result.code, + refusedUrl: failRuntimeUrl, + }); } } }); @@ -167,6 +172,11 @@ export async function runBootstrap(packageRoot = join(repository, 'apps/cli')) { 'compiled graph changed during bootstrap controls', ); assert.equal(records.length, BOOTSTRAP_CONTROL_COUNT, 'complete bootstrap control inventory'); + assert.equal( + new Set(records.map(({ name }) => name)).size, + BOOTSTRAP_CONTROL_COUNT, + 'unique bootstrap control names', + ); return { kind: 'compiled-cli-bootstrap', node: process.version, diff --git a/tools/cli-bootstrap/child.mjs b/tools/cli-bootstrap/child.mjs index 86dbb97e..66cb2703 100644 --- a/tools/cli-bootstrap/child.mjs +++ b/tools/cli-bootstrap/child.mjs @@ -39,7 +39,9 @@ export async function withFixture(run) { const root = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-bootstrap-'))); try { const home = join(root, 'home'), - workspace = join(root, 'workspace'); + workspace = join(root, 'workspace'), + temporary = join(root, 'temporary'); + mkdirSync(temporary); for (const dir of [join(home, '.relavium', 'catalog'), join(workspace, '.relavium')]) mkdirSync(dir, { recursive: true }); const poison = 'synthetic-bootstrap-file-canary-5631\n[[invalid TOML JSON YAML'; @@ -52,12 +54,13 @@ export async function withFixture(run) { ]) writeFileSync(path, poison); const absentRoot = join(root, 'absent-grant'); - const protectedRoots = [home, workspace, absentRoot]; - const before = protectedRoots.slice(0, 2).map(snapshot); + const protectedRoots = [home, workspace, temporary, absentRoot]; + const before = protectedRoots.slice(0, 3).map(snapshot); const fixture = { root, home, workspace, + temporary, absentRoot, async child(args, { failRuntimeUrl, debug = false, bounds } = {}) { const witness = join(root, `witness-${fixture.count++}.ndjson`); @@ -67,6 +70,9 @@ export async function withFixture(run) { USERPROFILE: home, XDG_CONFIG_HOME: home, XDG_CACHE_HOME: home, + TMPDIR: temporary, + TMP: temporary, + TEMP: temporary, NO_COLOR: '1', RELAVIUM_BOOTSTRAP_WITNESS_OPTIONS: JSON.stringify({ witness, @@ -74,7 +80,7 @@ export async function withFixture(run) { failRuntimeUrl, }), }; - for (const key of ['PATH', 'SystemRoot', 'WINDIR', 'TEMP', 'TMP']) + for (const key of ['PATH', 'SystemRoot', 'WINDIR']) if (process.env[key] !== undefined) env[key] = process.env[key]; if (debug) env.RELAVIUM_DEBUG = '1'; const result = await spawnBounded( @@ -100,7 +106,7 @@ export async function withFixture(run) { }; const result = await run(fixture); assert.deepEqual( - protectedRoots.slice(0, 2).map(snapshot), + protectedRoots.slice(0, 3).map(snapshot), before, 'application files or directories changed', ); diff --git a/tools/cli-bootstrap/inventory.mjs b/tools/cli-bootstrap/inventory.mjs index f52b5295..6ef5e584 100644 --- a/tools/cli-bootstrap/inventory.mjs +++ b/tools/cli-bootstrap/inventory.mjs @@ -41,11 +41,19 @@ export const metaCases = Object.freeze([ ['logs'], ['status'], ['init'], - ].map((path) => [`${path.join('-')}-help`, [...path, '--help']]), + ].map((path) => [ + `${path.join('-')}${path.length > 1 ? '-subcommand' : ''}-help`, + [...path, '--help'], + ]), ['help-command-subtree', ['help', 'provider']], ['help-command-root', ['help']], ]); +/** Each variant has a distinct stable name, including nested commands and retained aliases. */ +export function controlName(name, json, rootGrant) { + return `${name}-${json ? 'json' : 'human'}${rootGrant === undefined ? '' : rootGrant ? '-root' : '-no-root'}`; +} + export function grammarCases(absentRoot) { return [ [ diff --git a/tools/cli-bootstrap/preload.mjs b/tools/cli-bootstrap/preload.mjs index d6c67cf9..7ddd68ca 100644 --- a/tools/cli-bootstrap/preload.mjs +++ b/tools/cli-bootstrap/preload.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import Module, { register, syncBuiltinESMExports } from 'node:module'; -import { resolve, relative, isAbsolute, sep } from 'node:path'; +import { dirname, resolve, relative, isAbsolute, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Buffer } from 'node:buffer'; import net from 'node:net'; @@ -32,10 +32,10 @@ function record(event) { // Node 22.0 supports this async loader API; registerHooks is deliberately not used. register(new URL('./loader.mjs', import.meta.url), { data: options }); -function pathValue(value) { +function pathValue(value, base = process.cwd()) { if (value instanceof URL && value.protocol === 'file:') return fileURLToPath(value); - if (Buffer.isBuffer(value)) return value.toString(); - return typeof value === 'string' ? resolve(value) : null; + if (Buffer.isBuffer(value)) return resolve(base, value.toString()); + return typeof value === 'string' ? resolve(base, value) : null; } function inside(root, candidate) { const suffix = relative(root, candidate); @@ -45,11 +45,16 @@ function inside(root, candidate) { } function guard(method, args) { const paths = args - .slice(0, method.startsWith('rename') || method.startsWith('copyFile') ? 2 : 1) - .map(pathValue) - .filter((path) => path !== null); - const denied = paths.some((path) => options.protectedRoots.some((root) => inside(root, path))); - record({ kind: 'fs', method, paths, denied }); + .slice(0, /^(?:rename|copyFile|cp|symlink|link)(?:Sync)?$/u.test(method) ? 2 : 1) + .map((value) => pathValue(value)); + // A relative symlink target is interpreted from the link's parent, not cwd. + if (method.startsWith('symlink') && paths[1] !== null && paths[1] !== undefined) + paths[0] = pathValue(args[0], dirname(paths[1])); + const observedPaths = paths.filter((path) => path !== null); + const denied = observedPaths.some((path) => + options.protectedRoots.some((root) => inside(root, path)), + ); + record({ kind: 'fs', method, paths: observedPaths, denied }); if (denied) throw new Error('synthetic bootstrap application filesystem access refused'); } // Only protected application paths are refused. Loader/module/package reads elsewhere are @@ -63,6 +68,7 @@ const operations = [ 'cp', 'exists', 'lstat', + 'link', 'mkdir', 'mkdtemp', 'open', @@ -75,6 +81,7 @@ const operations = [ 'rm', 'rmdir', 'stat', + 'symlink', 'truncate', 'unlink', 'watch', @@ -121,6 +128,10 @@ globalThis.fetch = async () => { record({ kind: 'fetch', denied: true }); throw new Error('synthetic bootstrap fetch refused'); }; +globalThis.WebSocket = function () { + record({ kind: 'service', category: 'websocket', method: 'WebSocket', denied: true }); + throw new Error('synthetic bootstrap WebSocket refused'); +}; for (const [owner, names, category] of [ [net, ['connect', 'createConnection', 'createServer'], 'net'], [net.Socket.prototype, ['connect'], 'socket'], diff --git a/tools/cli-bootstrap/witness.test.mjs b/tools/cli-bootstrap/witness.test.mjs index eb03a9ee..e859489e 100644 --- a/tools/cli-bootstrap/witness.test.mjs +++ b/tools/cli-bootstrap/witness.test.mjs @@ -2,12 +2,12 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; import { pathToFileURL } from 'node:url'; import { withFixture, toEntryUrl } from './child.mjs'; import { assertWitness, importFailureCanary, servicePackages } from './policy.mjs'; import { spawnBounded } from './process.mjs'; -import { BOOTSTRAP_CONTROL_COUNT } from './inventory.mjs'; +import { BOOTSTRAP_CONTROL_COUNT, controlName, grammarCases, metaCases } from './inventory.mjs'; import ts from 'typescript'; // Pinned independently of the policy so deleting a boundary cannot delete its own control. @@ -91,6 +91,20 @@ for (const [path, markers] of [ test('package binding expects the complete human/JSON native-failure cohort', () => { assert.equal(BOOTSTRAP_CONTROL_COUNT, 177); + const names = metaCases.flatMap(([name]) => + [false, true].flatMap((json) => [false, true].map((root) => controlName(name, json, root))), + ); + names.push( + ...grammarCases('/synthetic-absent').flatMap(([name]) => + [false, true].map((json) => controlName(name, json)), + ), + 'real-execution-fs-before-service', + ...['entry-runtime-load-failure', 'dispatcher-load-failure'].flatMap((name) => + [false, true].map((json) => controlName(name, json)), + ), + ); + assert.equal(names.length, 177); + assert.equal(new Set(names).size, 177); }); function fixtureModule(fixture, body, name = 'fixture.mjs') { @@ -167,6 +181,95 @@ test('CommonJS require cannot bypass the native ESM service witness', async () = assert.throws(() => assertWitness(result.events, toEntryUrl(entry)), /guarded boundaries/); }); }); + +test('copy/link APIs protect both operands in callback, sync and promise forms, including Buffer paths', async () => { + await withFixture(async (fixture) => { + const protectedPath = join(fixture.home, '.relavium', 'config.toml'); + const siblingPath = join(fixture.root, 'sibling-config.toml'); + writeFileSync(siblingPath, 'synthetic-sibling'); + const scenarios = ['cp', 'symlink', 'link'].flatMap((method) => + ['callback', 'sync', 'promise'].flatMap((form) => + ['string', 'buffer', 'url'].flatMap((format) => [ + { method, form, format, paths: [siblingPath, protectedPath] }, + { method, form, format, paths: [protectedPath, siblingPath] }, + ]), + ), + ); + const entry = fixtureModule( + fixture, + ` + import fs from 'node:fs'; + import promises from 'node:fs/promises'; + import { Buffer } from 'node:buffer'; + import { pathToFileURL } from 'node:url'; + for (const {method, form, format, paths} of ${JSON.stringify(scenarios)}) { + const args = paths.map(path => format === 'buffer' ? Buffer.from(path) : format === 'url' ? pathToFileURL(path) : path); + try { + if (form === 'sync') fs[method + 'Sync'](...args); + else if (form === 'promise') await promises[method](...args); + else await new Promise((resolve, reject) => fs[method](...args, error => error ? reject(error) : resolve())); + } catch {} + } + try { fs.readFileSync(Buffer.from(${JSON.stringify(relative(fixture.workspace, protectedPath))})); } catch {} + try { fs.symlinkSync('../home/.relavium/config.toml', ${JSON.stringify(join(fixture.root, 'links', 'ref'))}); } catch {} + `, + ); + const result = await fixture.child([entry]); + assert.equal(result.code, 0, result.stderr); + const refused = result.events.filter((event) => event.kind === 'fs' && event.denied); + assert.equal(refused.length, scenarios.length + 2); + assert.deepEqual( + refused.slice(0, scenarios.length).map((event) => event.paths), + scenarios.map(({ paths }) => paths), + ); + assert.deepEqual(refused.at(-2).paths, [protectedPath]); + assert.equal(refused.at(-1).paths[0], protectedPath); + assert.throws(() => assertWitness(result.events, toEntryUrl(entry)), /guarded boundaries/); + }); +}); + +test('bootstrap denies WebSocket construction even when the refusal is swallowed', async () => { + await withFixture(async (fixture) => { + const entry = fixtureModule( + fixture, + "try { new WebSocket('wss://synthetic.invalid'); } catch {}", + ); + const result = await fixture.child([entry]); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual( + result.events.filter((event) => event.kind === 'service'), + [{ kind: 'service', category: 'websocket', method: 'WebSocket', denied: true }], + ); + assert.throws(() => assertWitness(result.events, toEntryUrl(entry)), /guarded boundaries/); + }); +}); + +test('all child temporary-directory selectors stay in a protected owned fixture', async () => { + await withFixture(async (fixture) => { + const entry = fixtureModule( + fixture, + ` + import { tmpdir } from 'node:os'; + import { writeFileSync } from 'node:fs'; + import { join } from 'node:path'; + console.log(JSON.stringify([tmpdir(), process.env.TMPDIR, process.env.TMP, process.env.TEMP])); + try { writeFileSync(join(tmpdir(), 'forbidden-bootstrap-temporary'), 'synthetic'); } catch {} + `, + ); + const result = await fixture.child([entry]); + assert.equal(result.code, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), Array(4).fill(fixture.temporary)); + assert.ok( + result.events.some( + (event) => + event.kind === 'fs' && + event.denied && + event.paths[0] === join(fixture.temporary, 'forbidden-bootstrap-temporary'), + ), + ); + assert.throws(() => assertWitness(result.events, toEntryUrl(entry)), /guarded boundaries/); + }); +}); test('sync, promises, named builtin imports, streams and root-stat calls remain observable after swallowed failure', async () => { await withFixture(async (fixture) => { const config = join(fixture.home, '.relavium', 'config.toml'); diff --git a/tools/cli-package/README.md b/tools/cli-package/README.md index f73e21c8..b6520200 100644 --- a/tools/cli-package/README.md +++ b/tools/cli-package/README.md @@ -20,6 +20,9 @@ observes one actual prepack build-start record, checks the emitted graph, and independently inspects the produced gzip/USTAR bytes. The inspector never extracts the archive. Every JS and migration byte must match the finalized build and canonical migrations; package metadata must match pnpm's publication transform. +Both receipt and archive validation independently pin the public `bin.relavium` +entry to `dist/index.js` and the ADR-0067 published `engines.node` floor to `>=22`. +The producer's own current Node major follows `.nvmrc`. An exact public allowlist, checksums, modes, hashbangs, byte ceilings and negative controls exclude links, traversal, duplicates, extra files, maps and private build inventories. This deliberately supports the pinned packer's dialect, not general tar. @@ -37,6 +40,10 @@ public registry packages; runtime provider responses are synthetic and keychain access is refused. No workspace install, product build, repack, provider request or credential is used by a consumer. Actual installed dependency versions and metadata digests are recorded; a workspace resolution is never substituted. +The consumer also executes the actual installed command shim through `pnpm exec` +for `--version` and `--help`, including the generated `.cmd` entry on Windows. +Missing or broken shims and wrong output refuse; both passes are required in the +public consumer receipt independently of the direct-entry behavior cases. Each consumer repeats the 177 untouched-entry bootstrap controls and the ten [installed behavior cases](smoke/README.md). Detailed local reports remain in the @@ -87,6 +94,9 @@ Required PR/push consumers use current `.nvmrc` Linux/macOS/Windows and exact No matching receipts plus successful pack/matrix jobs. Missing, failed, skipped, cancelled, duplicate, wrong-runtime or mismatched evidence refuses. The required `ci` job also checks the aggregate's success under `always()`. +CI pack, consumer and aggregate jobs clear inherited remote-cache credentials. +The aggregate explicitly checks pack and matrix outcomes before artifact downloads, +so a failed pack cannot turn an absent artifact name into an unrelated layout error. Artifacts are named by originating run and pack attempt. A failed-consumer retry reuses that original tarball and preserves successful same-artifact receipts; it diff --git a/tools/cli-package/archive.mjs b/tools/cli-package/archive.mjs index 015880da..6b2afa0c 100644 --- a/tools/cli-package/archive.mjs +++ b/tools/cli-package/archive.mjs @@ -164,6 +164,13 @@ function manifest(bytes, expected) { ) { refuse('MANIFEST_RUNTIME'); } + if ( + !isRecord(parsed.bin) || + Object.keys(parsed.bin).length !== 1 || + (parsed.bin.relavium !== 'dist/index.js' && parsed.bin.relavium !== './dist/index.js') + ) + refuse('MANIFEST_BIN'); + if (!isRecord(parsed.engines) || parsed.engines.node !== '>=22') refuse('MANIFEST_NODE_FLOOR'); for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) { const dependencies = parsed[field]; if (dependencies === undefined) continue; diff --git a/tools/cli-package/archive.test.mjs b/tools/cli-package/archive.test.mjs index 2ceaee4f..fd381714 100644 --- a/tools/cli-package/archive.test.mjs +++ b/tools/cli-package/archive.test.mjs @@ -10,6 +10,7 @@ const expectedPackage = { version: '0.1.1', type: 'module', bin: { relavium: 'dist/index.js' }, + engines: { node: '>=22' }, dependencies: { commander: '^14.0.0' }, devDependencies: { '@relavium/core': '0.0.0' }, }; @@ -340,6 +341,35 @@ test('manifest rejects runtime drift and unresolved/private runtime dependencies } }); +for (const [changed, code] of [ + ...[ + undefined, + {}, + 'dist/index.js', + { relavium: './dist/missing.js' }, + { relavium: '../dist/index.js' }, + { relavium: './dist/../dist/index.js' }, + { relavium: './dist/index.js', other: './dist/index.js' }, + ].map((bin) => [{ bin }, 'MANIFEST_BIN']), + ...[undefined, {}, { node: '>=20' }, { node: '>=24' }, { node: '>=22.1' }].map((engines) => [ + { engines }, + 'MANIFEST_NODE_FLOOR', + ]), +]) + test(`archive independently refuses public command/floor drift: ${JSON.stringify(changed)}`, () => { + const badPackage = JSON.parse(JSON.stringify({ ...expectedPackage, ...changed })); + const files = inventory(); + files.set('package/package.json', Buffer.from(JSON.stringify(badPackage))); + assert.throws( + () => + inspectPackageArchive(gzipSync(tar(files)), { + expectedFiles: files, + expectedPackage: badPackage, + }), + (error) => error instanceof PackageArchiveError && error.code === code, + ); + }); + test('portable install paths refuse Windows device names and case aliases', () => { const aliases = inventory(); aliases.set('package/dist/Chunk-ABC.js', Buffer.from('export {};')); diff --git a/tools/cli-package/binding.mjs b/tools/cli-package/binding.mjs index 565031d7..d2236b4c 100644 --- a/tools/cli-package/binding.mjs +++ b/tools/cli-package/binding.mjs @@ -192,6 +192,10 @@ function publicPackage(value) { ) refuse('PACKAGE'); version(manifest.version); + const bin = exact(manifest.bin, ['relavium']); + if (bin.relavium !== 'dist/index.js' && bin.relavium !== './dist/index.js') refuse('PACKAGE_BIN'); + // ADR-0067's public floor is independent of the source manifest and producer runtime. + if (record(manifest.engines).node !== '>=22') refuse('PACKAGE_NODE_FLOOR'); const dependencies = record(manifest.dependencies); if (Object.keys(dependencies).length < 1 || Object.keys(dependencies).length > 100) refuse('PACKAGE'); @@ -283,6 +287,7 @@ export function validateConsumerRecord(value, receiptValue, expected) { 'arch', 'success', 'bootstrap', + 'installedCommand', 'installedFiles', 'smoke', 'packageVersion', @@ -348,6 +353,9 @@ export function validateConsumerRecord(value, receiptValue, expected) { const entry = receipt.files.find((file) => file.path === 'package/dist/index.js'); if (bootstrap.controls !== BOOTSTRAP_CONTROL_COUNT || bootstrap.entrySha256 !== entry?.sha256) refuse('BOOTSTRAP'); + const installedCommand = exact(consumer.installedCommand, ['help', 'version']); + if (installedCommand.help !== true || installedCommand.version !== true) + refuse('INSTALLED_COMMAND'); const smoke = exact(consumer.smoke, ['success', 'cases']); if ( smoke.success !== true || diff --git a/tools/cli-package/binding.test.mjs b/tools/cli-package/binding.test.mjs index 97fed5f3..804f615c 100644 --- a/tools/cli-package/binding.test.mjs +++ b/tools/cli-package/binding.test.mjs @@ -41,6 +41,8 @@ function receipt() { name: 'relavium', version: '0.1.1', type: 'module', + bin: { relavium: './dist/index.js' }, + engines: { node: '>=22' }, dependencies: { commander: '^14.0.0' }, devDependencies: { '@relavium/core': '0.0.0' }, }, @@ -63,6 +65,7 @@ function consumer(leg = 'current-linux') { arch: 'x64', success: true, bootstrap: { controls: 177, entrySha256: 'c'.repeat(64) }, + installedCommand: { help: true, version: true }, installedFiles: pack.files.map(({ path, bytes, sha256 }) => ({ path, bytes, sha256 })), smoke: { success: true, cases: REQUIRED_SMOKE_CASES.map((id) => ({ id, passed: true })) }, packageVersion: '0.1.1', @@ -160,6 +163,16 @@ test('package metadata is public-only and keeps private packages out of runtime' { version: '0.2.0' }, { private: true }, { sourceInventory: [] }, + { bin: undefined }, + { bin: 'dist/index.js' }, + { bin: {} }, + { bin: { relavium: './dist/missing.js' } }, + { bin: { relavium: '../dist/index.js' } }, + { bin: { relavium: './dist/../dist/index.js' } }, + { bin: { relavium: './dist/index.js', other: './dist/index.js' } }, + { engines: undefined }, + { engines: {} }, + ...['>=20', '>=22.1', '>=24', '*', '^22 || >=24'].map((node) => ({ engines: { node } })), { dependencies: { '@relavium/core': '0.0.0' } }, { dependencies: { commander: 'workspace:*' } }, { dependencies: { commander: 'catalog:' } }, @@ -219,6 +232,11 @@ test('consumer refuses replay, mismatched files, failed/skipped/incomplete contr { node: 'v24.0.0-preview' }, { bootstrap: { controls: 176, entrySha256: 'c'.repeat(64) } }, { bootstrap: { controls: 177, entrySha256: '0'.repeat(64) } }, + { installedCommand: { version: true } }, + { installedCommand: { help: true } }, + { installedCommand: { help: false, version: true } }, + { installedCommand: { help: true, version: false } }, + { installedCommand: { help: true, version: true, skipped: false } }, { installedFiles: report.installedFiles.slice(1) }, { installedFiles: report.installedFiles.map((item) => ({ ...item, mode: 0o644 })) }, { installedFiles: report.installedFiles.map((item) => ({ ...item, sha256: '0'.repeat(64) })) }, diff --git a/tools/cli-package/consume.mjs b/tools/cli-package/consume.mjs index 94e2878b..581e0e2c 100644 --- a/tools/cli-package/consume.mjs +++ b/tools/cli-package/consume.mjs @@ -22,7 +22,7 @@ import { validateConsumerRecord, } from './binding.mjs'; import { installedInventory, oneTarball, readOrdinaryFile, sha256 } from './files.mjs'; -import { isolatedEnvironment, runCommand, runPnpm } from './process.mjs'; +import { exerciseInstalledCommand, isolatedEnvironment, runCommand, runPnpm } from './process.mjs'; import { runInstalledSmoke } from './smoke/index.mjs'; import { completeConsumer } from './consumer-lifetime.mjs'; @@ -143,6 +143,7 @@ await completeConsumer({ join(reportDirectory, 'bootstrap-details.json'), JSON.stringify(bootstrap, null, 2), ); + const installedCommand = await exerciseInstalledCommand(home, receipt.packageVersion); const smoke = await runInstalledSmoke({ packageRoot, supportRoot: reportDirectory, @@ -166,6 +167,7 @@ await completeConsumer({ arch: process.arch, success: true, bootstrap: { controls: bootstrap.controls, entrySha256: bootstrap.entrySha256 }, + installedCommand, installedFiles: before, dependencies, smoke: { success: true, cases: smoke.cases.map(({ id, passed }) => ({ id, passed })) }, diff --git a/tools/cli-package/process.mjs b/tools/cli-package/process.mjs index a6bd8112..89127b3e 100644 --- a/tools/cli-package/process.mjs +++ b/tools/cli-package/process.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { Buffer } from 'node:buffer'; -import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'; +import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs'; import { delimiter, extname, isAbsolute, join } from 'node:path'; import { clearTimeout, setTimeout } from 'node:timers'; @@ -142,3 +142,30 @@ export async function runPnpm(args, options) { const pnpm = resolvePnpm(); return runCommand(pnpm.command, [...pnpm.prefix, ...args], options); } + +/** Exercise the package manager's actual POSIX/Windows command shim in the owned installation. */ +export async function exerciseInstalledCommand(home, version) { + const shim = join( + home, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'relavium.cmd' : 'relavium', + ); + const stat = lstatSync(shim); + assert.ok(stat.isFile() || stat.isSymbolicLink(), 'PACKED_CLI_INSTALLED_COMMAND_MISSING'); + const options = { + cwd: home, + env: isolatedEnvironment(home), + timeoutMs: 30_000, + maxBytes: 128 * 1024, + }; + // pnpm exec supports the actual POSIX/Windows shim without substituting node . + // The absolute shim path prevents an ambient PATH command from filling a broken installation. + const actualVersion = await runPnpm(['exec', shim, '--version'], options); + assert.equal(actualVersion.stdout, `${version}\n`, 'PACKED_CLI_INSTALLED_COMMAND_VERSION'); + assert.equal(actualVersion.stderr, '', 'PACKED_CLI_INSTALLED_COMMAND_VERSION_STDERR'); + const help = await runPnpm(['exec', shim, '--help'], options); + assert.match(help.stdout, /^Usage: relavium/u, 'PACKED_CLI_INSTALLED_COMMAND_HELP'); + assert.equal(help.stderr, '', 'PACKED_CLI_INSTALLED_COMMAND_HELP_STDERR'); + return { help: true, version: true }; +} diff --git a/tools/cli-package/process.test.mjs b/tools/cli-package/process.test.mjs index bd08babd..318db0f3 100644 --- a/tools/cli-package/process.test.mjs +++ b/tools/cli-package/process.test.mjs @@ -3,7 +3,13 @@ import { test } from 'node:test'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { isolatedEnvironment, resolvePnpm, runCommand } from './process.mjs'; +import { + exerciseInstalledCommand, + isolatedEnvironment, + resolvePnpm, + runCommand, + runPnpm, +} from './process.mjs'; test('installer receives an explicit credential/config/preload-free environment', () => { const home = join(tmpdir(), 'synthetic-installer-home'); @@ -101,3 +107,51 @@ test('installer rejects and joins a real deadline violation', async () => { /CHILD_TIMEOUT/, ); }); + +test('actual pnpm install command shim runs help/version and a broken or absent shim refuses', async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-installed-command-test-'))); + try { + const home = join(root, 'consumer'), + source = join(root, 'package'); + mkdirSync(home); + mkdirSync(source); + writeFileSync( + join(home, 'package.json'), + JSON.stringify({ private: true, packageManager: 'pnpm@9.12.3' }), + ); + writeFileSync( + join(source, 'package.json'), + JSON.stringify({ + name: 'relavium', + version: '0.0.0', + bin: { relavium: './index.cjs' }, + }), + ); + writeFileSync( + join(source, 'index.cjs'), + '#!/usr/bin/env node\nconsole.log(process.argv[2] === "--version" ? "0.0.0" : "Usage: relavium synthetic");\n', + ); + await runPnpm( + ['add', '--offline', '--ignore-scripts', '--ignore-workspace', `file:${source}`], + { + cwd: home, + env: isolatedEnvironment(home), + timeoutMs: 30_000, + }, + ); + assert.deepEqual(await exerciseInstalledCommand(home, '0.0.0'), { help: true, version: true }); + await assert.rejects(exerciseInstalledCommand(home, '0.0.1'), /INSTALLED_COMMAND_VERSION/); + const shim = join( + home, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'relavium.cmd' : 'relavium', + ); + writeFileSync(shim, process.platform === 'win32' ? '@exit /b 17\r\n' : '#!/bin/sh\nexit 17\n'); + await assert.rejects(exerciseInstalledCommand(home, '0.0.0'), /CHILD_FAILED/); + rmSync(shim); + await assert.rejects(exerciseInstalledCommand(home, '0.0.0'), { code: 'ENOENT' }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tools/cli-package/seal.mjs b/tools/cli-package/seal.mjs index 4f5c6b5b..1af581b2 100644 --- a/tools/cli-package/seal.mjs +++ b/tools/cli-package/seal.mjs @@ -11,6 +11,17 @@ import { isolatedEnvironment, runCommand } from './process.mjs'; const repository = fileURLToPath(new URL('../../', import.meta.url)); +export function producerNodeMajor(root = repository) { + const declared = readOrdinaryFile(join(root, '.nvmrc'), 20).toString('utf8').trim(); + assert.match(declared, /^[1-9]\d*$/u, 'PACKED_CLI_INVALID_PRODUCER_NODE'); + const major = Number(declared); + assert.ok( + Number.isSafeInteger(major) && major >= 22 && major <= 100, + 'PACKED_CLI_INVALID_PRODUCER_NODE', + ); + return major; +} + export function expectedPackManifest(root = repository) { const require = createRequire(join(root, 'apps/cli/package.json')); const { parse } = require('yaml'); @@ -102,7 +113,7 @@ export async function sealPackage(artifactDirectory) { validatePackageReceipt(receipt, { source, origin, - nodeMajor: 24, + nodeMajor: producerNodeMajor(), allowLocal: origin.kind === 'local', }); writeFileSync( diff --git a/tools/cli-package/seal.test.mjs b/tools/cli-package/seal.test.mjs index c5586937..822d181f 100644 --- a/tools/cli-package/seal.test.mjs +++ b/tools/cli-package/seal.test.mjs @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { test } from 'node:test'; -import { finalizedPublicFiles } from './seal.mjs'; +import { finalizedPublicFiles, producerNodeMajor } from './seal.mjs'; function withFinalizedFixture(body) { const root = mkdtempSync(join(tmpdir(), 'relavium-seal-fixture-')); @@ -29,6 +29,19 @@ function withFinalizedFixture(body) { } } +test('producer runtime follows the declared toolchain independently of the fixed public Node floor', () => { + withFinalizedFixture(({ root, write }) => { + for (const major of [22, 24, 26]) { + write('.nvmrc', `${major}\n`); + assert.equal(producerNodeMajor(root), major); + } + for (const invalid of ['', '20', 'v24', '24.0.0', '24\n26', '101']) { + write('.nvmrc', invalid); + assert.throws(() => producerNodeMajor(root), /INVALID_PRODUCER_NODE/); + } + }); +}); + test('finalized seal inventory contains exact graph bytes, canonical migrations and public documents', () => { withFinalizedFixture(({ root }) => { const files = finalizedPublicFiles(root); diff --git a/tools/cli-package/smoke/README.md b/tools/cli-package/smoke/README.md index be12c5d1..11fe2333 100644 --- a/tools/cli-package/smoke/README.md +++ b/tools/cli-package/smoke/README.md @@ -106,8 +106,10 @@ watchdog, actual close and exact-PID cleanup evidence. These are plain one-shot activation/ACK-order controls: interactive terminal restoration and absolute budget nonrenewal retain their separately attributed source controls. [Step 8d and whole Step 8 acceptance](../../../docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) -retains its reviewed-source attribution. PR #95's corrected ten-case source still requires -fresh same-artifact platform evidence. Original-TS maps/custody remain Step 9. The current ten +retains its reviewed-source attribution. PR #95's earlier ten-case platform evidence +retains the source attribution in the [corrective review completion register](../../../docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-corrective-review-completion--2026-10-18). +The second systematic-review correction changes these controls and requires fresh +same-artifact platform evidence for its changed source. Original-TS maps/custody remain Step 9. The current ten behavior cases and 177 bootstrap controls are required. Windows bootstrap modes require five actual target/helper creations (two fatal, one background, two output); the other platform/cohort combinations require three. diff --git a/tools/cli-package/smoke/native-process.mjs b/tools/cli-package/smoke/native-process.mjs index b9dac9c7..f526bd6c 100644 --- a/tools/cli-package/smoke/native-process.mjs +++ b/tools/cli-package/smoke/native-process.mjs @@ -140,7 +140,7 @@ export function assertObservedGroupRefusal({ platform, mode, result, observed, r type: 'error', code: 'cli_internal', message: - 'An unexpected CLI failure occurred. Use --verbose for safe diagnostics. Inspect existing work before repeating paid or effectful operations.', + 'An unexpected CLI failure occurred. Inspect existing work before repeating paid or effectful operations.', }, ]); assert.equal( @@ -267,7 +267,7 @@ export function assertNativeBootstrapDiagnostic(diagnostics, mode) { type: 'error', code: 'cli_internal', message: - 'An unexpected CLI failure occurred. Use --verbose for safe diagnostics. Inspect existing work before repeating paid or effectful operations.', + 'An unexpected CLI failure occurred. Inspect existing work before repeating paid or effectful operations.', }, 'bootstrap-output': { type: 'error', @@ -594,7 +594,8 @@ export async function assertInstalledNativeProcesses(context, { bootstrap = fals ); const closed = observed.find((item) => item.operation === 'native-target-actual-close'); assert.ok(closed); - assert.equal(nativePidGone(started[0].pid), true); + assert.equal(closed.pid, started[0].pid); + // Actual close joins this creation. Probing the retired PID number could sample a reused PID. if (mode === 'leader-exit') assert.equal( closed.code, @@ -634,7 +635,10 @@ export async function assertInstalledNativeProcesses(context, { bootstrap = fals 0, 'The actual verified taskkill request failed', ); - assert.equal(nativePidGone(helpers[0].pid), true); + assert.equal( + observed.find((item) => item.operation === 'native-helper-actual-close').pid, + helpers[0].pid, + ); } else assert.equal(helpers.length, 0, 'Finite exit cleanup must not start a helper'); } Object.assign(outcome, { diff --git a/tools/cli-package/smoke/native-process.test.mjs b/tools/cli-package/smoke/native-process.test.mjs index d668f410..5ceab75e 100644 --- a/tools/cli-package/smoke/native-process.test.mjs +++ b/tools/cli-package/smoke/native-process.test.mjs @@ -37,7 +37,7 @@ function observedGroupRefusal() { signal: null, stdout: '{"type":"session:cancelled"}\n', stderr: - '{"type":"error","code":"cli_internal","message":"An unexpected CLI failure occurred. Use --verbose for safe diagnostics. Inspect existing work before repeating paid or effectful operations."}\n', + '{"type":"error","code":"cli_internal","message":"An unexpected CLI failure occurred. Inspect existing work before repeating paid or effectful operations."}\n', }, observed: [ { operation: 'native-target-spawn', pid: 40, detached: true, exitCallbacksBeforeEntry: 1 }, @@ -374,7 +374,7 @@ for (const [mode, code, message] of [ [ 'bootstrap-fatal', 'cli_internal', - 'An unexpected CLI failure occurred. Use --verbose for safe diagnostics. Inspect existing work before repeating paid or effectful operations.', + 'An unexpected CLI failure occurred. Inspect existing work before repeating paid or effectful operations.', ], [ 'bootstrap-output', diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index 371d118b..2e05704c 100644 --- a/tools/secrets-controls/README.md +++ b/tools/secrets-controls/README.md @@ -1,10 +1,10 @@ # Secrets causal controls `pnpm check:secrets-mutations` runs the declared Step 3 secrets foundation, -Step 4 first-run input matrix, Step 5 snapshot/export and Step 7 provider-header controls on physical source +Step 4 first-run input matrix, Step 5 snapshot/export, Step 7 provider-header and PR #95 B2 redaction controls on physical source copies. `--mechanism ` supports diagnosis only; selected runs retain `complete: false`. The current manifest -declares **41 independent controls and 277 required named assertion witnesses**. +declares **51 independent controls and 302 required named assertion witnesses**. These counts describe the workload; a declaration is not evidence that its removal passed. Actual fixed/removal/restored results belong to the coordinator's summary. @@ -28,8 +28,11 @@ Ordinary diagnostics without a residual credential retain their in-place redacti and benign text. The prefix-before-redaction control removes the shared scrubber's whitespace join -from each rule's original diagnostic probe, leaving split key and URL-userinfo -evidence vulnerable to overlapping query redaction. It requires 58 named assertion +from each rule's remaining diagnostic spans, leaving space-separated incomplete key +and URL-userinfo evidence vulnerable to overlapping query redaction. The separate +LF/TAB probe stays active. Its pre-scrubbed verbose-stack witness exercises LF, TAB +and space/CR/LF/TAB forms so the broader residual mechanism remains independently +load-bearing. It requires 58 named assertion witnesses: eight LLM query-family cases, both CLI sanitizer functions for all eight families, four actual human/JSON renderer modes and an isolated verbose stack already passed through the shared scrubber, plus 29 @@ -45,10 +48,10 @@ other replacement erases its evidence, followed by the finite query replacement. The views include the canonical terminal-normalized text and a whitespace-joined residual view. The split literal query name remains an independent CLI obligation. Across the thirteen Step 3 controls, 191 named assertion witnesses are required. The shared -overlap controls collect 259 tests across the LLM, CLI sanitizer and renderer files. -When a long prefix was already completely masked, its following suffix remains an -existing classification ambiguity; this adds no universal secret-detection claim or -new deferral. +overlap controls collect 261 tests across the LLM, CLI sanitizer and renderer files. +The B2 controls below pin the conservative LF/TAB continuation rule when a first +fragment already meets a key-length floor. Broader whitespace residual checks retain +their finite scope; neither mechanism claims universal secret detection. The eleven Step 4 controls remove separate production mechanisms: @@ -113,7 +116,7 @@ transliteration or uniqueness of generated IDs. Two independent CLI notice controls remove the successful standalone command and live `/export` notice sites. Each requires named human and JSON witnesses in -the complete 16-case command-export or 133-case chat file. The live control also +the complete 17-case command-export or 133-case chat file. The live control also requires its actual interactive transcript/Ink-frame witness. A third control restores raw stderr delivery only for the live notice: human/JSON positive cases still pass, while the interactive witness must fail with its named assertion marker. @@ -168,6 +171,33 @@ command core and rendering, not full top-level parser/native composition. Legacy plaintext remains sensitive in SQLite/WAL/backups; logical empty-map clearing proves neither physical erasure nor functional keychain-reference headers. +The ten PR #95 B2 controls require 25 additional named assertions and retain all +41 previous control identities and their required witnesses. Six core controls remove +modern `sk-` body characters, joined-span application, the original-text left-boundary +check, split scheme labels, split API-header labels and raw `?`/`#` password recognition +independently. Four LLM controls remove the before-masking LF/TAB probe, its original +left-boundary check, split API-header recognition and raw-password recognition. +Every core phase executes the complete 129-case bounding file; every new LLM phase +executes the complete 79-case scrubber file. Independent ordinary tooling tests pin +these inventories, the 261-case overlap cohort, 163-case CLI diagnostic cohort and +17-case command-export cohort. Actual collection still has to match in every phase. + +The boundary controls preserve an embedded public-word key-shaped run containing an +internal LF as well as recognizing a real key after an LF boundary. This makes the +original-text boundary check load-bearing rather than relying on a no-split case that +would survive its removal. Other witnesses require complete recognized spans and +retain public tails, host contacts and the full-value identity policy. The finite +LF/TAB continuation and malformed-userinfo policy is documented in the canonical +[effect-journal contract](../../docs/reference/shared-core/effect-journal.md#11-secrets-what-a-row-may-hold). +An eight-MiB permanent fixture checks completion and whole-span behavior without a +machine-speed threshold; these controls do not certify arbitrary obfuscation. + +The current 51-control / 153-phase full declaration projects 925 retained files +(51 × 18 + 7) under the unchanged 2,000-file / 64-MiB export bounds. This is a capacity +calculation, not a measured full capture. Historical 41-control captures retain their +original source and cohort attribution; selected B2 captures are not full-family +acceptance. + The thin entry point uses the same coordinator, outcome and bounded process join as the filesystem family in [causal-controls](../causal-controls/README.md). Source manifests physically capture both the shared tooling and this family, diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index a7d95349..3387dd02 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -48,6 +48,51 @@ const nativeModes = ['human', '--verbose', '--json', '--json --verbose']; const nativeSuccess = (mode) => `delivers one verbatim default-stdin read through real engine start and persists only masks (${mode})`; +const coreProjection = 'packages/core/src/tools/bounding.ts'; +const coreProjectionCases = ['packages/core/src/tools/bounding.test.ts']; +const coreProjectionMarker = 'SECRETS_B2_CORE_PROJECTION'; +const diagnosticMarker = 'SECRETS_B2_LLM_DIAGNOSTIC'; +const splitCoreHeaders = ['\n', '\t'].map( + (separator) => + `masks split schemes, finite header names and URL userinfo (${JSON.stringify(separator)})`, +); +const corePasswords = ['?', '#'].map( + (separator) => `masks raw ${separator} within password userinfo and preserves contact queries`, +); +const diagnosticPasswords = ['?', '#'].map( + (separator) => `masks raw ${separator} in userinfo while retaining host contact queries`, +); +const rawPasswordAssignments = ['?', '#'].map( + (separator) => + `preserves public ${separator} tails after userinfo and masks raw password assignments`, +); +function coreProjectionControl(id, before, after, targets) { + return control( + id, + coreProjection, + before, + after, + coreProjectionCases, + 129, + targets[0], + coreProjectionMarker, + targets.slice(1).map((target) => ({ target, marker: coreProjectionMarker })), + ); +} +function diagnosticControl(id, before, after, targets) { + return control( + id, + scrubSecretsLeaf, + before, + after, + ['packages/llm/src/scrub-secrets.test.ts'], + 79, + targets[0], + diagnosticMarker, + targets.slice(1).map((target) => ({ target, marker: diagnosticMarker })), + ); +} + export const mutations = [ control( 'stdin-cap', @@ -163,7 +208,7 @@ export const mutations = [ ` const scrubbed = scrubSecrets(normalized); if (scrubbed !== normalized) return scrubbed;`, ['apps/cli/src/render/sanitize.test.ts', 'apps/cli/src/process/render-error.test.ts'], - 161, + 163, "SECRETS_CAUSAL_RESIDUAL_SCRUB protects 'client_secret' beside a split credential: 'multiline'", 'SECRETS_CAUSAL_RESIDUAL_SCRUB', [ @@ -198,14 +243,14 @@ export const mutations = [ control( 'prefix-before-redaction', scrubSecretsLeaf, - " const joined = remaining.replace(/\\s+/g, '');", - ' const joined = remaining;', + " const residual = remaining.replace(/\\s+/g, '');", + ' const residual = remaining;', [ 'packages/llm/src/llm-error.test.ts', 'apps/cli/src/render/sanitize.test.ts', 'apps/cli/src/process/render-error.test.ts', ], - 259, + 261, 'SECRETS_CAUSAL_PREFIX_ORDER protects client_secret before overlapping query redaction', 'SECRETS_CAUSAL_PREFIX_ORDER', [ @@ -254,7 +299,7 @@ export const mutations = [ ' return stripTerminalControls(text);', ' return text;', overlapFiles, - 259, + 261, `${overlapMarker} protects client_secret normalized key before query redaction`, overlapMarker, overlapWitnesses('normalized key').filter( @@ -269,7 +314,7 @@ export const mutations = [ ' return scrubQuerySecrets(scrubNonQuerySecrets(text));', ' return scrubNonQuerySecrets(scrubQuerySecrets(text));', overlapFiles, - 259, + 261, `${overlapMarker} protects client_secret Bearer before query redaction`, overlapMarker, [ @@ -510,7 +555,7 @@ export const mutations = [ ' deps.io.writeErr(SESSION_EXPORT_REVIEW_NOTICE);\n', '', ['apps/cli/src/commands/chat-export.test.ts'], - 16, + 17, "reports fixed continuity and review notice on stderr after successful 'human' export", 'W2_EXPORT_COMMAND_NOTICE', [ @@ -709,4 +754,74 @@ export const mutations = [ }, ], ), + // PR #95 B2: independent modern-prefix, joined-view, boundary and malformed-userinfo sites. + coreProjectionControl( + 'core-modern-key-alphabet', + String.raw`sk-(?=[\w-]{16})[\w-]+`, + String.raw`sk-(?=[A-Za-z0-9]{16})[A-Za-z0-9]+`, + ['sk-proj-', 'sk-svcacct-', 'sk-ant-', 'sk-AAAA'].map( + (prefix) => `masks the complete modern provider shape ${prefix}`, + ), + ), + coreProjectionControl( + 'core-joined-credential-spans', + ' visit(joined, (start, end, replacement) => {', + " visit('', (start, end, replacement) => {", + [ + 'masks every LF/TAB split without losing the surrounding public identity', + ...splitCoreHeaders, + ], + ), + coreProjectionControl( + 'core-joined-original-boundary', + " if (boundary?.test(text[originalStart - 1] ?? '')) return;", + '', + ['keeps the original left boundary when LF/TAB occurs elsewhere or immediately before a key'], + ), + coreProjectionControl( + 'core-split-scheme-label', + String.raw`/\b(b[\n\t]*e[\n\t]*a[\n\t]*r[\n\t]*e[\n\t]*r|b[\n\t]*a[\n\t]*s[\n\t]*i[\n\t]*c|t[\n\t]*o[\n\t]*k[\n\t]*e[\n\t]*n)\s+(?=[\w.~+/-]{8})[\w.~+/-]+={0,2}/gi`, + String.raw`/\b(bearer|basic|token)\s+(?=[\w.~+/-]{8})[\w.~+/-]+={0,2}/gi`, + splitCoreHeaders, + ), + coreProjectionControl( + 'core-split-header-label', + ' mask(match.index, match.index + match[0].length, match[0]);', + ' void match;', + splitCoreHeaders, + ), + coreProjectionControl( + 'core-raw-password-userinfo', + String.raw`const delimiter = /[/\s"',}\]]/g;`, + String.raw`const delimiter = /[/\s?#"',}\]]/g;`, + [...corePasswords, ...rawPasswordAssignments], + ), + diagnosticControl( + 'diagnostic-joined-before-masking', + " if (joined !== undefined && hasSplitCredential(normalized, joined, rule)) return '[REDACTED]';", + '', + [ + 'masks every LF/TAB split including a complete-looking first key fragment', + 'masks each split position in the finite API header names', + 'treats LF/TAB token-character continuations as credential material even after a complete first fragment', + ], + ), + diagnosticControl( + 'diagnostic-joined-original-boundary', + " if (rule.boundary.test(text[start - 1] ?? '')) continue;", + '', + ['does not invent a key boundary in a public word with a separate newline'], + ), + diagnosticControl( + 'diagnostic-split-header-label', + String.raw`joinedPattern: /(?:(?:x-)?api-key|x-goog-api-key)["']?\s*:/gi,`, + 'joinedPattern: /a^/g,', + ['masks each split position in the finite API header names'], + ), + diagnosticControl( + 'diagnostic-raw-password-userinfo', + String.raw`pattern: /\b(?:https?|wss?|ftps?):\/\/[^/\s]*@/gi,`, + String.raw`pattern: /\b(?:https?|wss?|ftps?):\/\/[^/\s?#]*@/gi,`, + [...diagnosticPasswords, ...rawPasswordAssignments], + ), ]; From 02b34e4da38c3076ec6ff3f69ccfbf633be0b01d Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 11:46:43 +0300 Subject: [PATCH 188/222] docs(docs): reconcile corrective status and canonical contracts Keep historical ADR decisions append-only while correcting live status, sandbox contracts and source-attributed review records. New correction acceptance and publication remain pending. Refs: ADR-0110, ADR-0111, ADR-0112, ADR-0113 Co-Authored-By: Codex --- ...d-discovery-have-explicit-compatibility.md | 18 + ...-operations-use-a-lazy-packaged-runtime.md | 6 + ...and-onboarding-state-are-operator-owned.md | 6 + ...ontext-keeps-provenance-at-public-sinks.md | 6 + docs/reference/cli/commands.md | 6 +- .../shared-core/expression-sandbox-spec.md | 24 +- docs/reference/shared-core/tool-registry.md | 14 +- docs/roadmap/current.md | 328 ++---------------- .../phase-2.5.5-hardening-and-remediation.md | 76 +++- docs/standards/security-review.md | 34 +- 10 files changed, 191 insertions(+), 327 deletions(-) diff --git a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md index b8418195..1ae4a864 100644 --- a/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -33,6 +33,12 @@ These choices remain Proposed. Exact shapes, categories and exits belong in [CLI commands](../reference/cli/commands.md#proposed-wave-3-cli-compatibility). No engine ErrorCode or runtime dependency is added. +> Amended 2026-10-18: The preceding “remain Proposed” sentence records the +> proposal stage and is not the current decision status. The maintainer accepted +> this ADR on 2026-10-16; its dated acceptance and implementation notes below +> retain their exact scopes. The [current correction register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-second-systematic-review-corrections--2026-10-18) +> records later PR #95 work without granting new acceptance. + ## Decision ### Diagnostics and ownership @@ -327,3 +333,15 @@ failed-review checkpoints retain their historical scopes. This lands native owner/session/terminal/fatal integration; it does not claim Step 10's unshipped CLI compatibility work or whole-Wave-3 acceptance. No new decision or deferral is added. + + +## Fixed infrastructure diagnostic clarification — 2026-10-18 + +> Amended 2026-10-18: Infrastructure and unclassifiable fault paths intentionally +> emit the same fixed diagnostic at every verbosity. Their fixed message does +> not suggest `--verbose` provides extra detail. The existing bounded owned +> metadata/frame policy still governs categories that expose such detail; no +> raw arbitrary message, cause tree or stack is admitted by this clarification. +> The [canonical diagnostic policy](../reference/cli/commands.md#diagnostic-policy) +> owns this distinction. This corrects misleading advice and reconciles the +> implementation without reversing the decision or claiming new review acceptance. diff --git a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md index bcf6b131..af9058c2 100644 --- a/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md +++ b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md @@ -30,6 +30,12 @@ in [CLI packaging](../reference/cli/packaging.md), with release procedures in th [release runbook](../runbooks/release-a-surface.md). No runtime dependency/version bump, production recipient, upload or publication is authorized. +> Amended 2026-10-18: The preceding “remain Proposed” sentence records the +> proposal stage and is not the current decision status. The maintainer accepted +> this ADR on 2026-10-16; its dated acceptance and implementation notes below +> retain their exact scopes. The [current correction register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-second-systematic-review-corrections--2026-10-18) +> records later PR #95 work without granting new acceptance. + ## Decision ### Lazy graph and catalog authority diff --git a/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md b/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md index e7922241..809d89d9 100644 --- a/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md +++ b/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md @@ -34,6 +34,12 @@ All choices remain Proposed. Exact keys, limits, diagnostics, transition and rollback rules have one home in [config-spec.md](../reference/contracts/config-spec.md#proposed-wave-3-chat-policy-and-preferences). +> Amended 2026-10-18: The preceding “remain Proposed” sentence records the +> proposal stage and is not the current decision status. The maintainer accepted +> this ADR on 2026-10-16; its dated acceptance and implementation notes below +> retain their exact scopes. The [current correction register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-second-systematic-review-corrections--2026-10-18) +> records later PR #95 work without granting new acceptance. + ## Decision ### Chat HTTP authority diff --git a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md index 6bd36ec4..7024603d 100644 --- a/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md +++ b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md @@ -35,6 +35,12 @@ All decisions remain Proposed. Exact interfaces and error behaviour belong in and its linked runner/tool contracts. No new dependency, engine ErrorCode, credential sink or tool-mapping feature is authorized. +> Amended 2026-10-18: The preceding “remain Proposed” sentence records the +> proposal stage and is not the current decision status. The maintainer accepted +> this ADR on 2026-10-16; its dated acceptance and implementation notes below +> retain their exact scopes. The [current correction register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-second-systematic-review-corrections--2026-10-18) +> records later PR #95 work without granting new acceptance. + ## Decision ### Qualify once at the engine boundary diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index f8d4e1da..cacb73ce 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -698,8 +698,10 @@ filter IO or implicitly approve a prompt. Reasoning follows its existing content control. Verbose exposes only owned fixed classifications and bounded validated frame locations, with no stack first-line message, arbitrary cause/detail, argv, request or credential text. Unknown throws, projection/getter faults and broken -sinks fall back to a fixed message; do not coerce arbitrary objects. The ordinary -generic failure points to `--verbose`. +sinks fall back to a fixed message; do not coerce arbitrary objects. Infrastructure +and unclassifiable faults use the same fixed diagnostic at every verbosity; their +message does not suggest that `--verbose` exposes additional detail. The bounded +safe-detail policy still applies to diagnostic categories that provide owned metadata. Frame syntax alone is not provenance: Error.stack is writable. Emit only an exact owned shipped-artifact/source-file identity from a pure static/build-generated diff --git a/docs/reference/shared-core/expression-sandbox-spec.md b/docs/reference/shared-core/expression-sandbox-spec.md index 75e26e14..11da09c3 100644 --- a/docs/reference/shared-core/expression-sandbox-spec.md +++ b/docs/reference/shared-core/expression-sandbox-spec.md @@ -53,10 +53,11 @@ Rules: remain absent. Key-only enumeration is allowed; see the [finite denial contract](#private-evaluation-and-finite-denial). This is declared-source provenance, not value scanning: arbitrary literals and historical `run.outputs` are outside that guarantee. -- **The scope is data-only and deeply immutable.** It is crossed into the VM as plain JSON (see - [Marshaling & isolation](#marshaling--isolation)); each binding is installed as a non-writable, - non-configurable property over a deep-frozen value. An expression cannot mutate the scope, and a - mutation attempt has no cross-evaluation effect. +- **The scope uses immutable lexical bindings.** It crosses into the VM as JSON-only transport + (see [Marshaling & isolation](#marshaling--isolation)); the VM rebuilds frozen null-prototype + records, installs denied accessors and gives authored code `const` bindings in a separate + strict lexical function. Scope bindings are never VM-global properties. An expression cannot + mutate the scope, and a mutation attempt has no cross-evaluation effect. - **Determinism of `merge_fn`.** Because `branches` is ordered by static declaration (not arrival), a parallel fan-in merges to the same result regardless of which branch finishes first. @@ -201,13 +202,14 @@ retryable/fatal split (owned by [error-handling.md](../../standards/error-handli | Observed denied input/context accessor | private denied transport arm, never an authored error name/message | `secret_context` | **fatal**, fixed diagnostic without detail/cause | | Wall-clock timeout (the genuine interrupt) | `{ name: "InternalError", message: "interrupted" }` **and** the host deadline passed | `timeout` | **retryable** — non-idempotent; bounded by the node retry budget (1.S) | -> **Classification is by error `name` + the host-side deadline — never an author-controlled message.** -> The only retryable reason, `timeout`, requires BOTH the engine-emitted `InternalError: interrupted` -> marker AND the host deadline having passed; a user can forge neither in combination (running long -> enough to pass the deadline trips the real interrupt first, and anything thrown earlier has -> `deadlinePassed === false`). So a thrown `Error("…interrupted…")` is a fatal `runtime`, and a -> deterministic error that merely outlasts the deadline is fatal, not retryable. The exact quickjs -> strings are implementation-dependent; the perf spike records what the pinned variant emits. +> **Private transport selects success, failure or observed denial before ordinary error classification.** +> A VM-owned denied arm takes precedence over authored catches and error names; authored values cannot +> select that arm. Only ordinary failures use the existing error-name/engine-marker and host-deadline +> classifier. The retryable `timeout` requires BOTH the genuine `InternalError: interrupted` marker +> AND the host deadline having passed. An authored `Error("…interrupted…")` is a fatal `runtime`; +> merely outlasting the deadline does not make a deterministic failure retryable. The pinned VM's +> exact marker strings are implementation-dependent. See [private transport](#primitive-transport-and-result-parity) +> and the [finite denial limit](#private-evaluation-and-finite-denial). **Message scrubbing (binding).** The user-facing `sandbox_error` message is the code plus a generic, secret-free string — it **never** echoes the expression source, a variable name, a scope value, an diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index daf1f412..b00a6230 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -422,12 +422,14 @@ clean sibling avoids incorrectly treating distinct selections as a replay. This projection a lossless canonical encoding or claim detection of arbitrary sensitive text. Diagnostic truncation follows complete recognized-span redaction. `toolInput` applies -secret/media projection before clipping its string leaves; `outputSummary` normalizes -the complete materialized text to one line, recognizes secret-shaped spans, then bounds -length. A closing quote or URL authority separator beyond a preview boundary must not -make a supported credential shape unrecognizable. This does not redact the raw model -result, full spill or arbitrary unlabelled sensitive text; those retain their existing -content-sensitive handling. +secret/media projection before clipping its string leaves; `outputSummary` recognizes +supported LF/TAB-split shapes before normalizing complete materialized text to one line, +then recognizes ordinary spans and bounds length. The +[finite credential masking contract](built-in-tools.md#finite-diagnostic-credential-masking) +owns supported spans, ambiguous continuation and public-prose boundaries. A closing +quote or URL authority separator beyond a preview boundary must not make a supported +credential shape unrecognizable. Raw model results, full spills and arbitrary unlabelled +sensitive text retain their existing content-sensitive handling. Recorded replay results retain the [existing journal contract](effect-journal.md); this diagnostic correction does not rewrite historical content. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 9e9ffec2..940bdf68 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1,7 +1,7 @@ # Current state > Status: Living -> Last updated: 2026-10-17 +> Last updated: 2026-10-18 - **Related**: [README.md](README.md), [phases/phase-2.5-cli-consolidation.md](phases/phase-2.5-cli-consolidation.md), [phases/phase-2.5.5-hardening-and-remediation.md](phases/phase-2.5.5-hardening-and-remediation.md), [phases/phase-2-cli.md](phases/phase-2-cli.md), [deferred-tasks.md](deferred-tasks.md), [../project-structure.md](../project-structure.md), [../tech-stack.md](../tech-stack.md) @@ -136,12 +136,14 @@ The [deferred register](deferred-tasks.md) retains its existing limits; closure ## Next work — Wave 3 — Clear the ground -The next engineering work is [Wave 3 — Clear the ground](#wave-3--clear-the-ground); -its implementation has not started in this closure update. -Its serialized multi-file queue starts with shared close handling, safe path -presentation and exhaustiveness guards, before hot-file decomposition and the -remaining CLI/process work. Start with current documents/code and its own decision -preflight; Wave 2 does not silently approve the next wave's unresolved decisions. +Wave 3 implementation is in progress on `development`; Steps 1–8 have scoped +independent acceptance. The initial publication checkpoint is +[PR #95](https://github.com/HodeTech/Relavium/pull/95). A second maintainer systematic +review at `a5a5d45e` reopened corrective work; its +[current correction register](phases/phase-2.5.5-hardening-and-remediation.md#pr-95-second-systematic-review-corrections--2026-10-18) +owns dispositions and remaining gates. New corrections are pending independent +review and publication checks. Steps 9–19 have not started; check the PR status +before extending its scope with Step 9. See the [Wave 3 queue](#wave-3--clear-the-ground). ## Historical W7 implementation checkpoints @@ -1106,296 +1108,26 @@ an ESLint selector rather than a list. ### Wave 3 — Clear the ground -**Approved for implementation — 2026-10-16.** The maintainer approved -ADR-0110–0113, their linked unshipped contracts and the nineteen-increment plan. -The [approval checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-maintainer-approval-and-implementation-checkpoint--2026-10-16) -records completed decision gates and staged implementation. -[Step 1 is accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-1-acceptance--2026-10-16) -after two NEW independent complete clean reviews. -[Step 2 is also accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-2-acceptance--2026-10-16) -with two NEW complete clean cumulative rounds after its historical-reader corrections; -[Step 3 is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-3-acceptance--2026-10-16) -after two NEW complete cumulative clean rounds and a separate clean security sitting. -[Step 4 is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-4-acceptance--2026-10-16) -after two NEW complete cumulative clean rounds. -[Step 5 is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-5-acceptance--2026-10-16) -after two NEW complete cumulative clean rounds following its registry-documentation -correction and a separate clean security sitting. -[Step 6 is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-6-acceptance--2026-10-16) -after two NEW complete cumulative clean rounds and a separate clean security sitting. -[Step 7a is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-7a-acceptance--2026-10-16) -after two NEW complete cumulative clean rounds and a separate clean security sitting. -[Step 7b and whole Step 7 are independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-7b-and-whole-step-7-acceptance--2026-10-16) -at `c836ffe6` after two NEW complete cumulative clean rounds, a separate clean -security sitting and successful same-tarball current Linux/macOS/Windows and -exact Node 22.0.0 Linux consumers. [The accepted c836 full CI run subsequently completed successfully](phases/phase-2.5.5-hardening-and-remediation.md#step-7-completed-full-ci-follow-up--2026-10-16), -with its executed source and CI totals recorded separately from the historical -acceptance observation. [Step 8a implements origin adapters and an inert native -foundation](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-implementation-checkpoint--2026-10-16); -[its independent acceptance](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8a-acceptance--2026-10-16) -at `e0a5ae19` includes two NEW complete cumulative clean rounds and a separate -clean security sitting. [The same reviewed source's full CI subsequently completed successfully](phases/phase-2.5.5-hardening-and-remediation.md#step-8a-completed-full-ci-follow-up--2026-10-16), -with remote totals attributed separately. [Step 8b is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8b-acceptance--2026-10-16) -at `e16a7659` after two NEW complete cumulative clean rounds and a separate clean -security sitting following the canonical-reference correction. Full CI and all -four required same-tarball consumers pass at `ab50be11`; independent equivalence -checks preserve that execution attribution. [Step 8c workflow/terminal integration is independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8c-acceptance--2026-10-16) -at `dbab25d8` after two NEW complete cumulative clean rounds, a separate clean -security sitting and successful full CI plus all four same-tarball platform -consumers. [Step 8d and whole Step 8 are independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) -at `c83c8575` after two NEW complete cumulative CLEAN rounds, a NEW separate -clean security sitting and successful full CI plus all four actual same-tarball -consumers. Steps 1–8 are accepted on `development`; Steps 9–19 have NOT STARTED. -Following the private handoff, the maintainer authorized an initial PR for completed -Steps 1–8. Steps 9–19 and whole-wave acceptance remain separate. Check that PR's -status before later commits on `development`, which would extend its review scope. -The checkpoints below are historical observations; -their then-open gates are resolved only by the final acceptance linked above. The earlier -[2026-10-17 chat/Home implementation checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-chat-and-home-integration-checkpoint--2026-10-17) -records partial session/native-terminal integration and the remaining activation, -review/security/platform gates without claiming acceptance. The subsequent -[management-owner checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-management-ownership-checkpoint--2026-10-17) -records remaining command, prompt, doctor and transient-resource integration; -[native activation checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-native-activation-implementation-checkpoint--2026-10-17) -records product bootstrap activation at `a2acba8e` and the eighth required -installed case at `2928d8ef`. At that checkpoint, two NEW complete cumulative -clean reviews, separate security and current full CI/platform acceptance were -still required. Subsequent historical rounds retain their exact source scopes. The -[first cumulative round and correction register](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-first-cumulative-review-and-corrections--2026-10-17) -records complete NONCLEAN coverage of all 255 paths and the verified corrections; -no clean-round acceptance is inferred. The [second cumulative round and native-input corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-second-cumulative-review-and-native-input-corrections--2026-10-17) -record complete NONCLEAN coverage of all 262 paths, the supplemental native-input -finding and subsequent fixes. Fresh complete reviews/security/full-CI/platform -acceptance remain open. The [third cumulative review and partial-acquisition corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-third-cumulative-review-and-partial-acquisition-corrections--2026-10-17) -record complete NONCLEAN coverage of 269 paths at `ec8ba368`, source-only collection, -shared consumed-input custody and pre-setup database ownership across the actual -shipping surfaces. Earlier CI failures retain their exact attribution; the macOS -packed failure is not cleared by local reproduction passes. Two NEW complete clean -rounds, a NEW separate clean security sitting and current CI/platform acceptance -remain open. The [fourth cumulative review and native-resource corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-fourth-cumulative-review-and-native-resource-corrections--2026-10-17) -records complete NONCLEAN coverage of 279 paths at `db825377`: three verified High -findings cover consumed one-shot input, late workflow MCP close admission and the -separate native migration-lock connection. All four same-artifact package consumers -pass at that source; this does not grant review acceptance or erase the earlier -unexplained macOS failure. Two NEW complete cumulative clean rounds after the last -material fix, a NEW separate clean security sitting and fresh full CI/platform -acceptance remain required. The [fifth cumulative review and deferred-close correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-fifth-cumulative-review-and-deferred-close-correction--2026-10-17) -records complete NONCLEAN coverage of 285 paths / 1,526 independently hash-checked -hunks at `ad743ba5`, with one corroborated manager microtask-expiry finding. -All four exact-package consumers pass there and their physical artifacts are -independently inspected, but broader CI exposes an obsolete causal workload pin -and an unexplained Windows advisory timeout. The implementation correction -preserves the live budget through actual transport entry and updates strict -workload counts; new reviews/security/full CI and Windows evidence remain open. -The [sixth cumulative review and first-fault correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-sixth-cumulative-review-and-first-fault-correction--2026-10-17) -records full NONCLEAN coverage of the same 285 paths / 1,526 hunks at `4b7ba216`. -Successful MCP close failures must reach their owner before a held sibling joins; -chat/Home references also require lifecycle reconciliation. The correction keeps -actual close joins and the original deadline, with permanent adverse controls. -New Windows evidence passes; required macOS packaging remains failed and its -initiating fault unresolved. Passive test-only native diagnostics preserve all -original outcome/cleanup assertions. New clean reviews, security and exact-source -CI/platform gates remain required. The -[seventh cumulative review and native-outcome correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-seventh-cumulative-review-and-native-outcome-correction--2026-10-17) -records complete NONCLEAN review of 288 paths / 1,534 verified hunks at `86f0422a`. -Its one tooling finding concerns a directly observed Darwin group EPERM: the -product correctly retains infrastructure 9, but the installed smoke assumes 0/1. -The narrow validator correction requires exact fault/closure evidence and no -completed turn; it does not normalize EPERM or waive a historical failed run. -Separate Windows/local-suite triage identifies fixture close and late-case -ownership defects. Corrections are implemented with original deadlines and -assertions; fresh author, cumulative-review, security and required CI/platform -gates remain staged. The -[eighth cumulative review and coexistence-fixture correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-eighth-cumulative-review-and-coexistence-fixture-correction--2026-10-17) -records complete NONCLEAN coverage of 293 paths / 1,578 independently verified -hunks at `09740575`. One current one-shot summary contradicts the implemented -exit 9/finite cleanup contract; its correction changes no runtime policy. -Separate actual-SQLite triage reproduces setup and timeout ownership gaps in the -coexistence fixture, with the original five-second deadline preserved. All four -same-artifact consumers pass at that reviewed source; its separate Windows -advisory timeout remains failed and its exact initiating delay unclassified. -The [ninth cumulative review and caught-fault correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-ninth-cumulative-review-and-caught-fault-correction--2026-10-17) -records complete NONCLEAN coverage of 293 paths / 1,588 independently verified -hunks at `40086e71`: two High runtime findings in one-shot passive output and owned -Home default writing, plus a Medium signal-summary inconsistency. Corrections -observe original invocation failures before local recovery, seal further entry and -retain actual cleanup joins. New tests fail precisely against the old runtime; -known config refusals and ordinary failed turns retain their classified outcomes. -All four package consumers and the Windows advisory pass at the reviewed source, -which precedes these runtime corrections. The old unexplained Windows failure is -not retrospectively passed. Two NEW clean complete cumulative rounds, a NEW clean -security sitting and fresh final-source CI/platform evidence remain required. -The [tenth cumulative review and original-owner corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-tenth-cumulative-review-and-original-owner-corrections--2026-10-17) -records complete NONCLEAN coverage of 293 paths / 1,600 independently verified hunks -at `4c0fa5b7`. Four confirmed gaps affect passive Home/chat consumers, export timestamps, -native editor preparation and checkpoint error classification. Corrections preserve -known refusals and real cleanup joins. Separate fixture triage proves raw-work and -manual SQLite-read cleanup gaps without claiming the initiating remote Windows cause. -All four same-artifact consumers pass at that reviewed source; its Windows advisory -remains failed. Correction author gates pass (341 CLI files / 5,475 passes / ten -established skips); two NEW clean cumulative rounds, NEW separate security and fresh -final-source CI/platform evidence remain pending. -The [eleventh cumulative review and advisory-owner corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-eleventh-cumulative-review-and-advisory-owner-corrections--2026-10-17) -records full NONCLEAN review of 302 paths / 1,650 independently verified hunks at -`b9bc7118`. Two original-owner/classification gaps and two stale required causal -workload declarations are corrected at `d8e6b4d5`. Healthy warnings, successful -passive reporting, known refusals and actual cleanup joins remain intact. The test's -manual native snapshot avoids redundant migration maintenance while preserving -real default fresh/resume entry and original five-second bounds. Four installed -consumers pass at the reviewed source; required CI and Windows advisory fail -separately, with the latter's initiating phase still unknown. Author gates pass -(344 CLI files / 5,496 passes / ten established skips). Complete causal republication, -two NEW clean cumulative rounds, separate security and final-source CI/platforms -remain required. No user decision is pending. -The [twelfth cumulative review and cooperative-terminal correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-twelfth-cumulative-review-and-cooperative-terminal-correction--2026-10-17) -records complete NONCLEAN coverage of 306 paths / 1,672 independently rehashed -hunks at `518082b1`. Two High gaps in normal JSON finalization and workflow advisory -ownership are corrected at `3d937244`, with 24 precise old-runtime failures and six -healthy controls. Cooperative terminal delivery, existing fatal admission, actual -workflow health and cleanup joins remain separate. The clear/reseat fixture retains -actual raw-work/root custody and avoids redundant observer maintenance under its -original five-second bound. Both complete causal families pass at the reviewed old -source (110 filesystem / 41 secrets controls), with independent retained-evidence -audits; its four package consumers pass. Its different Windows clear/reseat failure -remains failed and unexplained. Final author lint/typecheck/test gates pass -(347 CLI files / 5,533 passing tests / ten established skips). Two NEW clean cumulative -rounds, NEW separate security and fresh final-source CI/platforms remain required. -Steps 1–7 and 8a–8c are accepted; 8d remains open and Steps 9–19 have not started. -No user decision is pending. -The [thirteenth cumulative review and checkpoint-notice correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-thirteenth-cumulative-review-and-checkpoint-notice-correction--2026-10-17) -records complete NONCLEAN coverage of 310 paths / 1,706 independently rehashed -hunks at `c15aa314`. The confirmed default-notice fault/custody gap is corrected -at `afb3b932`: explicit captured IO, immediate original-owner observation and -actual pending-delivery ownership preserve successful DB acquisition without -inventing a write acknowledgement. Identical regressions fail on the old source; -fixed author checks pass (349 CLI files / 5,566 passes / ten established skips). -The older source's four installed consumers and Windows advisory pass; its full -CI was still running when recorded. These do not certify the correction. R1–R13 -remain NONCLEAN; two NEW complete cumulative clean rounds, separate security and -fresh final-source CI/platforms are required. No user decision is pending. -The [fourteenth cumulative review and disclosure-retention corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-fourteenth-cumulative-review-and-disclosure-retention-corrections--2026-10-17) -record complete NONCLEAN coverage of 314 paths / 1,715 independently rehashed -hunks at `dcc23cfb`. Two High disclosure instances share a common ownership gap; -`d6d2fbdd` fixes it across chat/Home/run/gate and fixes workspace fixture isolation. -`e218ba0e` keeps arbitrary installed-test error text in its private report. Actual -old-source/removal controls break the corresponding assertions; author gates pass -(352 CLI files / 5,590 passes / ten established skips; 549 acceptance-tool tests). -A separate joined post-build CLI run reports zero unhandled errors. At the older -reviewed source, all four installed consumers and Windows advisory pass; full CI -was pending at the filesystem causal job when recorded. R1–R14 remain NONCLEAN; -two NEW complete clean cumulative rounds, separate security and fresh final-source -CI/platforms remain required. Steps 9–19 have not started. No user decision is pending. -The [fifteenth cumulative review and Home doctor correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-fifteenth-cumulative-review-and-home-doctor-correction--2026-10-17) -records complete NONCLEAN coverage of 318 paths / 1,737 independently rehashed -hunks at `eb5d61af`. `151abaf5` fixes the High Home doctor publication/work gap; -`1ed15ab8` fixes the required-CI workload blocker without weakening the 12-case -coordinator. Fixed/restored runs pass 12/12 and removal breaks seven assertions. -Author gates pass (353 CLI files / 5,598 passes / ten established skips; 549 -acceptance-tool tests), with a joined post-build CLI run and explicit zero unhandled -errors. The reviewed source's four installed consumers and Windows advisory pass, -but its full CI fails the stale workload and downstream dependency gate. Those -results do not accept the correction. R1–R15 remain NONCLEAN; two NEW complete -cumulative clean rounds, separate security and fresh final-source CI/platforms -remain required. Steps 9–19 have not started. No user decision is pending. -The [sixteenth cumulative review and compaction-delivery correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-sixteenth-cumulative-review-and-compaction-delivery-correction--2026-10-17) -records complete NONCLEAN coverage of 319 paths / 1,744 rehashed hunks at `71a91042`. -`ea996d6c` retains the original compaction publication/flush callback through actual -settlement after core cancellation. Ten actual fresh/resumed chat/SQLite controls -pass, including a subsequent turn after abort; predecessor code breaks six precise -ownership assertions with four positive controls. Applied workspace and separately -joined post-build CLI checks pass (354 files / 5,608 passes / ten established skips, -zero unhandled errors). R1–R16 remain NONCLEAN. Two NEW complete cumulative clean -rounds, NEW separate security and fresh final-source CI/platforms remain required. -Steps 9–19 have not started; no user decision is pending. -The [seventeenth cumulative review and onboarding correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-seventeenth-cumulative-review-and-onboarding-correction--2026-10-17) -records complete NONCLEAN coverage of 320 paths / 1,746 rehashed hunks at `2891e8fd`. -`1dec7a8d` observes original Home raw-mode release/reclaim faults before reversible -recovery consumes them. Nine actual Home/SQLite controls pass; removal produces -four precise failures with five positive controls. Applied workspace, build, -separate joined post-build CLI (355 files / 5,617 passes / ten established skips, -zero unhandled errors), 549 acceptance-tool tests and compiled smokes pass. -R1–R17 remain NONCLEAN. Two NEW complete cumulative clean rounds, NEW separate -security and fresh final-source CI/platforms remain required. Steps 9–19 have not -started; no user decision is pending. -The [eighteenth cumulative review and self-stop/CI corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-eighteenth-cumulative-review-and-self-stopci-corrections--2026-10-17) -records complete NONCLEAN coverage of 321 paths / 1,749 rehashed hunks at `1f3cce50`. -`f23e0da7` observes caught workflow self-stop faults on the original invocation; -`df6e6631` fixes the positively confirmed main-CI watchdog expiry while retaining -all inner bounds and required gates. Two independent mechanism removals break -the new ownership regressions; fixed/restored runs pass all 75 cases. Applied -workspace/build and joined post-build CLI checks pass (356 files / 5,622 passes / -ten established skips, zero unhandled errors), together with 549 acceptance-tool -tests and compiled smokes. R1–R18 remain NONCLEAN. Two NEW complete cumulative -clean rounds, NEW separate security and fresh corrected-source CI/platforms remain -required. Steps 9–19 have not started; no user decision is pending. -The [nineteenth cumulative review and late-publication correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-nineteenth-cumulative-review-and-late-publication-correction--2026-10-17) -records complete NONCLEAN coverage of 324 paths / 1,754 rehashed hunks at `261bb82d`. -Two Medium gaps let shell refusal notices and directory completion results publish -after owned stop. `6cc8bc53` seals both results while retaining live warnings and -actual native cleanup. Four installed-Ink controls and two independent guard -removals pin the behaviour; fixed/restored five-file runs pass all 27 cases with -zero unhandled errors and actual process joins. Applied workspace/build and joined -post-build CLI gates pass (357 files / 5,626 passes / ten established skips, zero -unhandled errors), with static and compiled smokes. R1–R19 remain NONCLEAN; two -NEW complete cumulative clean rounds, NEW separate security and corrected-source -full CI/platforms remain required. Steps 9–19 have not started; no user decision -is pending. -The [twentieth cumulative review and stopped-result/maintenance corrections](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-twentieth-cumulative-review-and-stopped-resultmaintenance-corrections--2026-10-17) -records complete NONCLEAN coverage of 325 paths / 1,759 rehashed hunks at `4eee7c68`. -Two Medium findings are corrected at `50d7309f`: Home drops deferred business -results after permanent stop, and optional workflow effect retention/media GC -refuse fresh entry at the original zero remaining budget. Actual native and -already-entered work joins stay owed. Six installed-Home, ten controller and -run/gate/budget live/expired controls pass; two Home and five budget mechanism -removals fail their precise assertions and every restoration passes. Author -workspace/build and joined post-build CLI gates pass (360 files / 5,649 passes / -ten established skips, zero unhandled errors), with static/compiled smokes. -R1–R20 remain NONCLEAN; two NEW complete cumulative clean rounds, NEW separate -security and fresh corrected-source full CI/platforms remain required. Steps 9–19 -have not started; no user decision is pending. -The [R21–R22 discovery ownership checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-r21r22-discovery-ownership-correction--2026-10-17) -records complete coverage of 330 paths / 1,789 cumulative hunks at `c491836a`. -R21 is CLEAN; R22 confirms one Medium discovery-ownership gap, independently -reproduced with actual installed SDK/SQLite and a held synthetic transport. -The correction uses existing invocation-local work, preserving bounded reports, -actual cleanup joins and all existing deadlines. The separate clean security -sitting, full CI and all four physically inspected package consumers belong to -`c491836a`; none accepts the later correction. Two NEW complete cumulative clean -rounds, NEW separate security and corrected-source full CI/platforms remain -required. Steps 9–19 have not started; no user decision is pending. -The [R23 joined-maintenance checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-r23-joined-maintenance-correction--2026-10-17) -records complete NONCLEAN review of 340 paths / 1,834 cumulative hunks at -`e7fe93c5`. Two confirmed Medium gaps release workflow/database ownership before -entered sibling media deletions or shard enumeration settle. Root independently -reproduces both; the correction preserves existing best-effort results and budgets -while joining entered siblings through failure. Ten new permanent cases and three -independent causal removals cover the correction. This author evidence does not -replace two NEW complete cumulative clean rounds, NEW separate security or fresh -corrected-source full CI and all four same-artifact consumers. Steps 9–19 have not -started; no user decision is pending. Step 8d proceeds automatically. -The [R24 direct-builder fixture correction](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-r24-builder-fixture-correction--2026-10-17) -records complete NONCLEAN review of 342 paths / 1,842 cumulative hunks at -`cef520d4`, with one Medium test-preparation finding. The native Windows advisory -exceeds the original five-second case bound during unrelated disk-backed chat -seeding; required CI is separately attributed. Only the direct-builder fixture now -uses a validated real in-memory SQLite SessionStore roundtrip. All guard/no-service -assertions, fixture abort ownership, five-second bounds and surrounding on-disk -lifecycle cases remain. CI-mode focus passes 228 cases; two independent entry-guard -removals fail the exact case and every restoration passes. Two NEW complete -cumulative clean rounds, NEW separate security and fresh corrected-source full -CI/all four same-artifact consumers remain required. No user decision is pending. - -**Latest status — 2026-10-17:** the [final Step 8d / whole Step 8 acceptance](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) -closes those gates at `c83c8575`: R25/R26 each cover all 342 paths / 1,842 hunks -and are CLEAN; separate security is CLEAN; full CI completes 15/15 jobs and all -four actual same-tarball consumers succeed. Earlier failed/partial records remain -historical and are not relabelled. Steps 9–19 remain unstarted for the next agent; -no new decision, credential, billing or release operation is needed to begin Step 9. - -The [final proposal reviews](phases/phase-2.5.5-hardening-and-remediation.md#second-maintainer-rereview-final-proposal-readiness--2026-10-16) -remain proposal evidence, not implementation review. The selected encrypted archive -method persists; production recipient/recovery belongs to Wave 4b release setup. -No provider capture, credential, billing or production upload is needed for this implementation. +**Implementation in progress — 2026-10-18.** ADR-0110–0113, their linked +contracts and the nineteen-increment plan were approved on 2026-10-16. +[Steps 1–8 are independently accepted](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-step-8d-and-whole-step-8-acceptance--2026-10-17) +on `development`, with whole Step 8 reviewed at `c83c8575`. The phase register +owns each increment's source, tests, cumulative review rounds, security sittings +and platform/publication evidence, including historical NONCLEAN R1–R24 records. +This live page links that history instead of repeating its superseded pending states. + +The maintainer authorized the initial completed-Steps-1–8 publication checkpoint, +[PR #95](https://github.com/HodeTech/Relavium/pull/95). The +[previous corrective review completion](phases/phase-2.5.5-hardening-and-remediation.md#pr-95-corrective-review-completion--2026-10-18) +remains attributed to `ba14b1bd`; it does not certify later changes. The second +maintainer systematic report reviews `a5a5d45e` and opens further corrections. +The [second correction register](phases/phase-2.5.5-hardening-and-remediation.md#pr-95-second-systematic-review-corrections--2026-10-18) +is canonical for that work. New independent complete cumulative clean rounds, +a separate security sitting and corrected-source publication checks are pending; +no new acceptance is claimed. Steps 9–19 have **NOT STARTED** and whole-wave +acceptance remains Step 19. Check PR #95 before later implementation commits, +which extend its review scope. No new decision, credential, billing or provider +capture gate is introduced. Decompose, guard and normalize every hot file a 2.6 workstream is about to land on. **The multi-file queue is Step 1 and runs first.** The approved @@ -1436,7 +1168,7 @@ on `home-controller.ts`/`chat-ink.tsx` (the decomposition moves already-guarded - **`packages/shared` (strict)** — the `.code` ruling (G50, D10) → `findDuplicates`/`superRefine` implementation or preservation at the schema dispositions (#89, #90, G16, G17, G18, #210, #211, #212) → `BaseEventSchema` disposition (#213) → the `RelaviumError` base class (#219). *The ruling must precede the base class, or it is baked in permanently.* -- **`registry.ts` / `builtins.ts` / `engine.ts`** — declared-source context repair and honest unsupported-mapping disposition (#92/Proposed ADR-0113) → `ctx.fsScope` (#93) → +- **`registry.ts` / `builtins.ts` / `engine.ts`** — declared-source context repair and honest unsupported-mapping disposition (#92/Accepted ADR-0113) → `ctx.fsScope` (#93) → `ok`/`success` (#216, #221) → identifier-sort `localeCompare` pinning (#258, #259) → the `git_commit` denial copy (#308); then the four schema length caps (#95), the `llmVisibleParams` rewrite (#96, #98), stale doc comments (#78, #97, #214); then `#armRunTimeout` (#75), `#dispatch` dead branch (#79), diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index f9e9ca27..3bf5d71a 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -207,15 +207,15 @@ reached. - ✅ **DONE ([Wave 3 Step 2](#wave-3-step-2-acceptance--2026-10-16)).** **Apply consistent duplicate/cross-field admission; certify existing input and session guards.** Shared leaf/full admission now enforces the approved duplicate, human-gate timeout and logical Run status matrices. Existing input defaults/enums and session compaction-role rules are certified. Historical session/workflow readers retain their former contracts; no rows are migrated or silently repaired. *(M · `packages/shared/src/{node,agent,workflow,session,run,common}.ts` · #89, #90, G18, #212, G16, G17, #210, #211)* - **Extend `analyzeSecretTaint`'s leak-reporting loop to cover context-value sites.** `analyzeSecretTaint` (`packages/core/src/interpolation/analyze.ts`) deliberately skips context-value sites in its leak-reporting loop, so a direct `{{secrets.*}}` reference never read elsewhere is not reported at parse time — the workflow only fails later, differently-shaped, at run start. Not an actual secret leak, but a real completeness gap in the "reject before a run starts, uniformly" contract. *(S · `packages/core/src/interpolation/analyze.ts` · G22)* - ✅ **DONE ([Wave 3 Step 4](#wave-3-step-4-acceptance--2026-10-16)).** **Retain and validate the public event envelope without stripping full payloads.** `BaseEventSchema` shares the full run family's parsed exactly-one-correlation check; `BaseEvent` and `HumanGateEvent` remain exported. Complete payload, strict accounting, lenient opposite-key and stored forward-reader contracts are preserved. *(S · `packages/shared/src/run-event.ts` · #213)* -- **Correct `ctx.fsScope`'s wiring, blocked on a maintainer call.** `ctx.fsScope` is threaded through every dispatch context in `packages/core/src/tools/{types,registry,builtins}.ts` and `packages/core/src/engine/agent-session.ts`, but `enforcePolicy()` (`packages/core/src/tools/registry.ts`) has no `fsScoped` branch and no `FsCapability` method signature accepts it. **Blocked on a maintainer decision**: is this a deliberate "one host instance = one immutable scope" design, in which case only the doc comment needs correcting, or should it be threaded into `FsCapability`'s method signatures to support future per-turn scope escalation? *(S · `packages/core/src/tools/registry.ts` · #93)* +- ✅ **DONE ([Wave 3 Step 5](#wave-3-step-5-acceptance--2026-10-16)).** **Clarify descriptive dispatch scope and retain immutable host filesystem authority.** Accepted [ADR-0105](../../decisions/0105-filesystem-roots-are-invocation-grants-not-project-authority.md) decides the host boundary; `ctx.fsScope` describes dispatch context and cannot widen the host's grant. No per-turn `FsCapability` escalation is introduced. *(S · `packages/core/src/tools/{types,registry,builtins}.ts` · #93)* - **Correct three stale engine doc comments to match current code.** `checkpoint.ts`'s module doc (`packages/core/src/engine/checkpoint.ts`) claims a "runId+nodeId+retryCount idempotency key" bounds resumed side effects; no such key exists. `builtins.ts`'s top comment still says egress is "not wired yet"; it shipped. `workflow-yaml-spec.md` documents `workflow.version` as Semver-format-enforced; `WorkflowSpecSchema` enforces no format. One sweep correcting each. *(S · `packages/core/src/engine/checkpoint.ts`, `packages/core/src/tools/builtins.ts`, `docs/reference/*/workflow-yaml-spec.md` · #78, #97, #214)* - **Remove the dead `RunEventBusOptions.validate` escape hatch and replace `kahnOrder`'s full re-sort with a priority structure.** `RunEventBusOptions.validate` (`packages/core/src/engine/event-bus.ts`) is documented as a hot-path escape hatch but no caller ever sets it — dead config, negligible cost today. `kahnOrder` (`packages/core/src/dag.ts`) re-sorts the entire ready-list on every pop instead of using a priority queue, also negligible at today's node counts. Both essentially-free cleanups; batch into one pass and revisit priority-queueing only if 2.6.P's subworkflows push node counts up. *(S · `packages/core/src/engine/event-bus.ts`, `packages/core/src/dag.ts` · #274, #275)* - ✅ **DONE ([Wave 3 Step 4](#wave-3-step-4-acceptance--2026-10-16)).** **Introduce minimal shared native error ancestry while retaining each family's contract.** The current export-scoped inventory replaces the historical 15/17 estimate: 46 roots across 37 files reach 83 module-exported production classes, with the additional unchanged private timeout descendant recorded separately. Constructors, names, discriminants, context/cause and structural/private-authority guards are preserved. *(M · `packages/shared/src/relavium-error.ts` and existing package families · #219)* - **Chat-to-workflow export hardening: continuity caveat and non-ASCII slug fallback; certify existing tool-content exclusion.** W7/PR #90 and Accepted [ADR-0095](../../decisions/0095-what-an-agent-session-remembers-across-turns.md) structurally exclude model-issued tool arguments/results from durable session rows and exported scaffolds. The earlier raw-tool-result export claim and its proposed opt-in/redaction decision are superseded by that contract; do not add a raw-content flag. Wave 2 still adds a clear continuity/review notice and deterministic ASCII title normalization with a session-identity fallback. Retained user/assistant text, `@` attachments and `!` output can remain sensitive: this is not universal transcript masking. Certify producer→SQLite→export rejection fixtures with #115; the [Wave 2 decision package](#decisions-presented-together-before-implementation) records D14/D15. *(M · `packages/core/src/export/serializer.ts` · #80, #81, #83)* -- **Give `BudgetExceededError`/`BudgetPauseError` a `.code` field, blocked on a maintainer call.** These two errors (`packages/core/src/engine/budget-governor.ts`) expose no `.code`, unlike `EngineStateError`/`RunLoopInvariantError` in the same directory that `docs/standards/error-handling.md` names as the canonical model. Not a functional bug today. **Blocked on a maintainer decision**: should these errors adopt the `.code` convention, or is `instanceof`-narrowing an accepted exception for this pair? *(S · `packages/core/src/engine/budget-governor.ts` · G50)* +- ✅ **DONE ([Wave 3 Step 4](#wave-3-step-4-acceptance--2026-10-16)).** **Preserve typed budget-error discriminants with shared native ancestry.** `BudgetExceededError` and `BudgetPauseError` retain their `.code` fields and `instanceof` identity; the accepted D10 ruling and error-substrate increment resolve the former maintainer question. *(S · `packages/core/src/engine/budget-governor.ts` · G50)* - **Collapse two always-co-occurring branches in `engine.ts`'s `#dispatch` retry-sleep handling.** Purely cosmetic dead-code/maintenance-trap: two branches that always co-occur, one unreachable dead weight. No behavioral bug, but real, trivially-scheduled cleanup. *(S · `packages/core/src/engine/engine.ts` · #79)* -**Acceptance:** the CRITICAL `previewFor()`/approval-preview secret leak is closed and verified against a `run_command`/`write_file` fixture containing a fake credential; `BudgetGovernor` rejects a concurrent-branch overspend under `max_parallel` in a new test exercising the reservation ledger; `#armRunTimeout` computes remaining budget on every resume rather than re-arming full duration; cancel-signal propagation reaches `human-gate.ts` and `resolvePrompt`, and backpressure propagation reaches `SessionHandle`; `parser.ts` and `agent-parser.ts` share one `messageFor`-derived diagnostic helper; `findDuplicates`/`superRefine` are applied at every one of the seven named remaining schema sites across `node.ts`, `agent.ts`, `workflow.ts`, `session.ts`, and `run.ts`; the cycle-guard `WeakSet` leak in `bounding.ts` is fixed with a regression test; no engine-core doc comment contradicts the code it describes; the two blocked items (`BudgetExceededError`/`BudgetPauseError`'s `.code` field, and the export-serializer's redaction/warning scope for embedded `tool_result` content) have a maintainer ruling recorded before either lands. +**Acceptance:** the CRITICAL `previewFor()`/approval-preview secret leak is closed and verified against a `run_command`/`write_file` fixture containing a fake credential; `BudgetGovernor` rejects a concurrent-branch overspend under `max_parallel` in a new test exercising the reservation ledger; `#armRunTimeout` computes remaining budget on every resume rather than re-arming full duration; cancel-signal propagation reaches `human-gate.ts` and `resolvePrompt`, and backpressure propagation reaches `SessionHandle`; `parser.ts` and `agent-parser.ts` share one `messageFor`-derived diagnostic helper; the approved duplicate/cross-field schema inventory is implemented or certified with its historical-reader limits in [Wave 3 Step 2](#wave-3-step-2-acceptance--2026-10-16); the cycle-guard `WeakSet` leak in `bounding.ts` is fixed with a regression test; no engine-core doc comment contradicts the code it describes; the budget-error `.code` ruling is resolved by D10 and [Wave 3 Step 4](#wave-3-step-4-acceptance--2026-10-16), while the export privacy/warning scope is resolved by the accepted D14/D15 Wave 2 contract. These are resolved decision gates, not current blockers; whole sub-stream A acceptance remains Wave 3 Step 19. ### 2.5.5.B — LLM adapter & catalog money-safety hardening @@ -625,7 +625,7 @@ The review report itself is not tracked in git (it lives under the gitignored `d | `#80` | `low` | `packages/core/src/export/serializer.ts:198` | Exported multi-turn session has no cross-turn conversation memory | Chat-to-workflow export (serializer.ts) has three independent robustness/safety gaps: no… | | `#81` | `low` | `packages/core/src/export/serializer.ts:213` | Exported YAML embeds the full transcript with no scrub of tool-result content | Chat-to-workflow export (serializer.ts) has three independent robustness/safety gaps: no… | | `#83` | `low` | `packages/core/src/export/serializer.ts:159` | Non-ASCII session titles collapse to a generic workflow id | Chat-to-workflow export (serializer.ts) has three independent robustness/safety gaps: no… | -| `#93` | `low` | `packages/core/src/tools/types.ts:340` | ctx.fsScope is threaded through every dispatch but never read by the registry or forwarded to FsCapability | ctx.fsScope is threaded through every dispatch context but enforcePolicy() has no fsScoped… | +| `#93` | `low` | `packages/core/src/tools/types.ts:340` | Historical descriptive-scope ambiguity | DONE — immutable host authority under Accepted ADR-0105; descriptive dispatch contract clarified and accepted in [Wave 3 Step 5](#wave-3-step-5-acceptance--2026-10-16). | | `#97` | `low` | `packages/core/src/tools/builtins.ts:6` | Top-of-file comment claims the egress host capability is "not wired yet", but it now is | Three stale doc comments in the engine assert a behavior/format the code no longer (or never… | | `#212` | `low` | `packages/shared/src/node.ts:142` | Duplicate `when` values in a condition node's branches[], and duplicate ids in parallel_of, are not rejected | Two established packages/shared validation conventions (a duplicate-detection helper, and… | | `#213` | `low` | `packages/shared/src/run-event.ts:57` | BaseEventSchema / BaseEvent and HumanGateEvent are public exports with zero consumers anywhere in the monorepo | BaseEventSchema/BaseEvent and HumanGateEvent are public exports with zero consumers anywhere in… | @@ -639,7 +639,7 @@ The review report itself is not tracked in git (it lives under the gitignored `d | `#98` | `polish` | `packages/core/src/tools/builtins.ts:248` | Tool descriptions read as engineer-facing implementation notes, not model-facing guidance | No built-in tool's llmVisibleParams schema has per-property descriptions, and several leak… | | `#275` | `polish` | `packages/core/src/dag.ts:500` | Kahn topo sort re-sorts the whole ready-list every pop (O(n^2 log n)), not a priority queue | RunEventBusOptions.validate is dead config, and kahnOrder re-sorts the whole ready-list on… | | `G18` | `polish` | `packages/shared/src/agent.ts:245` | FallbackChainEntrySchema has no duplicate-entry check | Two established packages/shared validation conventions (a duplicate-detection helper, and… | -| `G50` | `polish` | `packages/core/src/engine/budget-governor.ts:26` | Budget*Error carries no .code, breaking the directory's own error-taxonomy convention | BudgetExceededError/BudgetPauseError expose no .code field, unlike every other engine/ error… | +| `G50` | `polish` | `packages/core/src/engine/budget-governor.ts:26` | Historical budget-error discriminant question | DONE — D10 resolved; `.code` and identity preserved by accepted [Wave 3 Step 4](#wave-3-step-4-acceptance--2026-10-16). | #### 2.5.5.B — LLM adapter & catalog money-safety hardening @@ -8659,3 +8659,69 @@ downloaded archive/report inspection is distinct from executing another platform Earlier pending checkpoints are historical, not outstanding requests to repeat their old-source reviews. No merge, release, new decision or deferral is added. Steps 9–19 remain unstarted and outside this PR. + + +### PR #95 second systematic-review corrections — 2026-10-18 + +**Corrective work is in progress; independent review and publication acceptance +are pending.** The second maintainer report reviews `ab08209e..a5a5d45e` and +reopens PR #95 readiness. Earlier increment acceptance and the prior `ba14b1bd` +corrective reviews retain their exact source scopes; neither accepts these new +corrections. This is a scoped publication checkpoint for Steps 1–8, not whole-wave +acceptance. Steps 9–19 remain unstarted. + +The report's Blockers B1/B2, High H1/H2 and Medium 1–12 are assigned for correction +and regression verification. B1 concerns pid-less native cancellation; B2 concerns +finite credential masking. H1 concerns installed executable/bin validation and H2 +notice-context/partial-close forwarding across every changed caller. Medium +findings cover cleanup deadlines, stopped refresh/publication, diagnostic +contracts and hostile values, fixture-test attribution, recovered native close, +package engine admission, CI credentials, stale status and superseded process +modules. The supplementary Low findings retain individual concrete dispositions +in the corrective handoff; no blanket acceptance, deferral or dismissal follows +from this inventory. + +For H2, direct and dispatch tests enter the production command paths and observe +the opener's context and capture-close capability, including list/gate-list, +fresh/resumed chat, Home, one-shot agent, pricing and resolver/models/provider/ +pricing dispatch. For Medium 6 the coexistence setup/cleanup tests are explicitly +labelled **fixture cleanup**; they do not certify production acquisition. Medium 7 +adds a real `logs` dispatch through `CommandLifetime` with failed initial close, +successful retry on the exact SQLite handle, actual closed state, removed signal +custody and zero active owners. Local author verification passes five complete +files / 62 cases: both forwarding files, partial and surface DB acquisition, and +the coexistence harness. CLI typecheck and focused lint/format checks pass. These +are working-tree author checks, not final-source CI, coverage or platform acceptance. + +Four temporary forwarding probes independently remove context at fourteen new +call sites, capture-close at eleven owned call sites, and context/capture at the +production models adapter. They produce respectively 14/11/1/1 named semantic +failures with 6/9/19/19 positive controls, zero runtime-error suites and all twenty +forwarding cases passing after **each byte-exact restoration**. An initial runner +preparation rejects two valid custody assertion names; its record is retained, +the validator is corrected, and the complete probes are rerun. No failed or +incomplete preparation supplies this author evidence. These controls do not +supply independent acceptance. + +Supplementary Low test corrections retain actual production evidence: the benign +workspace-alias retarget case checks the real resumed host context, immutable +filesystem roots and persisted original workspace; three helper-custody cases +are labelled fixture integrity. The fresh/resumed MCP partial-add cases now +separate attachment failure from actual cancellation during partial attachment, +with a discovery-abort witness and held native-close acknowledgement. Both +complete files pass 98 cases locally (12 workspace / 86 session-host), with the +final workspace assertion-placement revision separately passing all twelve. +These author tests add no production claim or review acceptance. + +Documentation corrections reconcile live Wave 3 progress, resolved #93/G50 and +schema acceptance wording, the ADR-0113 lexical scope/private denial transport, +fixed infrastructure diagnostics, the finite LF/TAB credential masker and the +historical proposal-stage ADR sentences. The installed executable/bin/Node-floor +contract is corrected without attributing new controls to older consumer receipts. +Detailed R1–R24 history stays in this owning register rather than a duplicate live +progress narrative. The security index records only the previous `ba14b1bd` +static sitting as completed; a **NEW separate sitting for this correction is +pending**. Two NEW independent complete cumulative clean rounds after the final +material correction, current author gates and corrected-source CI/coverage/Sonar/ +same-archive platform controls remain required. No merge, release, new ADR, +credential capture, billing step or new deferral is claimed. diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index 763c13c4..3bb0b721 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -333,7 +333,12 @@ change all of them between two calls. Recorded after the wave merged (2026-09-01 | The model is TOLD a tool description came from a server, so a description cannot impersonate the operator | `agent-session.test.ts` — "prefixes an MCP tool's description with a provenance line" and "states the provenance even when the server supplied NO description" | | A cross-layer or same-layer tool-name collision is refused rather than resolved silently | `resolve.test.ts` | | A connect that never settles is bounded, and the cancellation reaches the server | `deadlines.test.ts` — a transport whose `start()` never settles; `network-adapters.test.ts` — a real `McpServer` observing its own cancellation | -| A spawned local server cannot outlive the CLI that spawned it | `mcp-orphan.e2e.test.ts` **and** `tools/cli-smoke/check.mjs` — a real signalled `agent run` subprocess asserted against the process table | +| A creation-owned local MCP root is stopped and its actual close is joined on termination | `mcp-orphan.e2e.test.ts` exercises the production native creation-owner/invocation finite-exit path; `tools/cli-smoke/check.mjs` drives a real signalled `agent run` subprocess. PID-root custody is not universal descendant containment. | + +**Current control pointer correction — 2026-10-18:** the MCP-orphan harness now +uses the production native creation owner and original invocation termination +path. The superseded standalone signal guard is removed. This updates the current +control inventory; it does not re-execute or widen the historical W4 sitting. **What the sitting produced**, recorded because a sitting that finds nothing is usually a sitting that did not look: the wave's own review found **five merge blockers** in `W4`, all reproduced and fixed before it merged, @@ -731,6 +736,24 @@ verbose-stack secrecy or complete vulnerability-database audit is certified. Actual native close is not a receipt ACK; expiry never grants DB-safe-close permission. Steps 9–19 and whole-Wave-3 acceptance remain open. +### Sitting: PR #95 prior corrective secret and ownership boundaries (2026-10-18) + +The [prior corrective completion register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-corrective-review-completion--2026-10-18) +records a NEW separate **COMPLETE CLEAN static** security sitting at +`ba14b1bd179270742534fc460501ec72dba85da1`, against review base `05061d55`, +covering 40 paths / 100 ordered default-context hunks. Root independently +verified source bindings, coverage and substantive assessments. The sitting +executes **no tests** and inherits no earlier execution result. Finite credential +masking, historical-content, native-close-versus-ACK and trusted-host limits retain +their canonical scope; this entry records the completed earlier sitting rather +than recertifying those mechanisms. + +The second maintainer report reviews later source `a5a5d45e` and opens new +corrections. A **NEW separate corrective sitting is pending** in the +[second correction register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-second-systematic-review-corrections--2026-10-18). +The `ba14b1bd` sitting does not accept those later changes, establish a new +whole-PR security result or imply current publication acceptance. + ## Sandbox and tool policy (`run_command`, node tools, secret inputs) This is the **binding home** for the tool-policy rules; the rationale is @@ -829,10 +852,11 @@ security invariants** a review must confirm are: exist *inside* the VM but are contained — they reach no host reference and no forbidden capability, so they are harmless; the guarantee rests on the isolation + capability removal, not on deleting every reflective handle to `Function`. -- **JSON-only marshaling, immutable scope.** Scope crosses the boundary as plain JSON (host - `stringify` → VM `parse`), so no live host object/getter/function leaks in and a `{"__proto__":…}` - in model/tool-derived `run.outputs` cannot poison the prototype chain; each binding is installed - non-writable/non-configurable over a deep-frozen value. +- **JSON-only marshaling, immutable lexical scope.** Scope crosses through JSON-only transport, + so no live host reference crosses the boundary. The VM rebuilds frozen null-prototype records + and denied accessors, then supplies `const` bindings in a separate strict lexical function; + scope bindings are never VM-global properties. The [canonical marshaling contract](../reference/shared-core/expression-sandbox-spec.md#marshaling--isolation) + owns prototype, accessor and isolation details. - **Deterministic within caps; caps are non-idempotent safety nets.** No clock/RNG/async is supplied. Uncaught resource failures surface as typed `sandbox_error`; a caught pre-accessor stack failure can return a clean fallback without an observed denial. The From ad5a506a0aa123783a731de935cc67b3b6134b05 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 12:00:12 +0300 Subject: [PATCH 189/222] fix(cli): bound unacknowledged terminal cleanup faults Carry terminal cleanup uncertainty independently of the original error classification. Keep terminal release acknowledgements separate from workflow retirement and preserve positively native-free constructor refusals. Cover prompt, view, job-control and final-release faults with the actual lifetime. The bound removal fails nine named assertions; byte-exact restoration passes all 25 lifetime cases. Refs: ADR-0103, ADR-0110 Co-Authored-By: Codex --- .../src/commands/workflow-lifetime.test.ts | 138 ++++++++++++++++++ apps/cli/src/commands/workflow-lifetime.ts | 8 +- .../src/commands/workflow-terminal.test.ts | 2 +- apps/cli/src/commands/workflow-terminal.ts | 36 +++-- docs/reference/cli/commands.md | 4 + 5 files changed, 172 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/commands/workflow-lifetime.test.ts b/apps/cli/src/commands/workflow-lifetime.test.ts index b43bb932..a2f45580 100644 --- a/apps/cli/src/commands/workflow-lifetime.test.ts +++ b/apps/cli/src/commands/workflow-lifetime.test.ts @@ -7,6 +7,7 @@ import { WorkflowLifetime } from './workflow-lifetime.js'; import { InvocationRegistry } from '../process/invocation-registry.js'; import { createInvocationFiniteExit } from '../process/invocation-finite-exit.js'; import { createNativeProcessController } from '../process/native-process-owner.js'; +import { invocationFixture } from '../process/invocation.test-support.js'; function fixture() { const captured = captureIo(); @@ -231,3 +232,140 @@ it.each(['storage_unavailable', 'internal'] as const)( expect(registry.activeOwners).toBe(1); }, ); + +it.each([ + 'prompt-rejection', + 'prompt-construction', + 'input-release', + 'late-input-release', + 'renderer-construction', + 'job-construction', + 'job-release', + 'job-dispose', + 'view-operation', +] as const)('bounds actual retained terminal cleanup after a classified %s fault', async (path) => { + const f = invocationFixture(); + const lifetime = new WorkflowLifetime(f.invocation, captureIo().io, () => () => undefined); + const cause = new CliError('storage_unavailable', 'synthetic terminal fault'); + let releaseViewOperation: () => void = () => undefined; + const viewOperation = new Promise((resolve) => { + releaseViewOperation = resolve; + }); + let settled = false; + let escaped: unknown; + const work = lifetime.execute(async () => { + if (path === 'prompt-rejection') await lifetime.terminal.runPrompt(() => Promise.reject(cause)); + else if (path === 'prompt-construction') + await lifetime.terminal.runPrompt(() => { + throw cause; + }); + else if (path === 'renderer-construction') + lifetime.terminal.createRenderer(() => { + throw cause; + }); + else if (path === 'job-construction') + lifetime.terminal.createJobControl(() => { + throw cause; + }); + else if (path === 'job-release') { + const released = Promise.reject(cause); + lifetime.terminal.createJobControl(() => ({ dispose: () => undefined, released })); + } else if (path === 'job-dispose') + lifetime.terminal.createJobControl(() => ({ + dispose: () => { + throw cause; + }, + })); + else { + let releases = 0; + lifetime.terminal.createRenderer(() => ({ + onEvent: () => undefined, + releaseInput: () => { + releases += 1; + return path === 'late-input-release' && releases === 1 + ? Promise.resolve() + : Promise.reject(cause); + }, + })); + if (path === 'view-operation') + await lifetime.terminal.runViewOperation(() => Promise.reject(cause)); + else if (path === 'late-input-release') { + void lifetime.terminal.runViewOperation(() => viewOperation); + // Restoration has to join the entered operation before its final release. + setImmediate(releaseViewOperation); + } + } + return 0; + }); + void work.then( + () => { + settled = true; + }, + (failure: unknown) => { + settled = true; + escaped = failure; + }, + ); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + expect(f.registry.budget.deadline(), 'TERMINAL_RETAINED_FAILURE_FINITE').toBe(3000); + expect(f.registry.activeOwners, 'TERMINAL_RETAINED_FAILURE_NO_ACK').toBe(1); + expect(settled).toBe(false); + f.advance(2999); + await new Promise((resolve) => setImmediate(resolve)); + expect(f.exit).not.toHaveBeenCalled(); + f.advance(3000); + await new Promise((resolve) => setImmediate(resolve)); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); + expect(f.registry.activeOwners, 'TERMINAL_EXPIRY_NEVER_ACKNOWLEDGES').toBe(1); + expect(settled).toBe(false); + expect(escaped).toBeUndefined(); +}); + +it('preserves a classified constructor refusal after positive native-free acknowledgement', async () => { + const f = invocationFixture(); + const lifetime = new WorkflowLifetime(f.invocation, captureIo().io, () => () => undefined); + const cause = new CliError('storage_unavailable', 'synthetic native-free refusal'); + await expect( + lifetime.execute(() => { + lifetime.terminal.createRenderer((creation) => { + creation.acknowledgeNoAcquisition(); + throw cause; + }); + return Promise.resolve(0); + }), + ).rejects.toBe(cause); + expect(f.registry.activeOwners).toBe(0); + expect(f.registry.budget.deadline()).toBeUndefined(); + expect(f.registry.observations.exitCode()).toBe(1); + expect(f.exit).not.toHaveBeenCalled(); +}); + +it('allows an adopted terminal rollback to retire after its actual release despite a classified fault', async () => { + const f = invocationFixture(); + const lifetime = new WorkflowLifetime(f.invocation, captureIo().io, () => () => undefined); + const cause = new CliError('storage_unavailable', 'synthetic adopted-constructor fault'); + let release: () => void = () => undefined; + const ack = new Promise((resolve) => { + release = resolve; + }); + let settled = false; + const work = lifetime.execute(() => { + lifetime.terminal.createRenderer((creation) => { + creation.adopt({ releaseInput: () => ack }); + throw cause; + }); + return Promise.resolve(0); + }); + const observed = work.catch((failure: unknown) => { + settled = true; + return failure; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(f.registry.budget.deadline()).toBe(3000); + expect(f.registry.activeOwners).toBe(1); + expect(settled).toBe(false); + release(); + expect(await observed).toMatchObject({ code: 'cli_internal', cause }); + expect(f.registry.activeOwners, 'TERMINAL_REAL_ACK_CAN_RETIRE').toBe(0); +}); diff --git a/apps/cli/src/commands/workflow-lifetime.ts b/apps/cli/src/commands/workflow-lifetime.ts index fe8bb9db..a445ad78 100644 --- a/apps/cli/src/commands/workflow-lifetime.ts +++ b/apps/cli/src/commands/workflow-lifetime.ts @@ -57,7 +57,13 @@ export class WorkflowLifetime { this.invocation.registry, io, () => this.checkpoint(), - (cause) => this.failure(cause), + (cause, cleanupUnacknowledged) => { + this.failure(cause); + // Terminal cleanup uncertainty itself needs the common bound even when + // the operation's cause is classified (for example unavailable storage). + // The terminal keeps its own ACK: do not taint the workflow's retirement. + if (cleanupUnacknowledged) this.invocation.finiteExit.fail('cli_internal'); + }, ); this.effectNoticeOwner = Object.freeze({ signal: this.signal, diff --git a/apps/cli/src/commands/workflow-terminal.test.ts b/apps/cli/src/commands/workflow-terminal.test.ts index cf66693f..934438e8 100644 --- a/apps/cli/src/commands/workflow-terminal.test.ts +++ b/apps/cli/src/commands/workflow-terminal.test.ts @@ -118,7 +118,7 @@ describe('reserved workflow terminal creation and actual restoration', () => { await turn(); expect(finished).toBe(false); expect(f.registry.activeOwners).toBe(1); - expect(f.fail).toHaveBeenCalledWith(cause); + expect(f.fail).toHaveBeenCalledWith(cause, true); }); it('adopts late job-control return after a reentrant seal and disposes it before retirement', async () => { const f = fixture(); diff --git a/apps/cli/src/commands/workflow-terminal.ts b/apps/cli/src/commands/workflow-terminal.ts index 9471b3f2..b6b9a648 100644 --- a/apps/cli/src/commands/workflow-terminal.ts +++ b/apps/cli/src/commands/workflow-terminal.ts @@ -15,7 +15,7 @@ export interface RendererCreationOwner { export class WorkflowTerminal { readonly #registry: InvocationRegistry; readonly #io: CliIo; - readonly #fail: (cause: unknown) => void; + readonly #fail: (cause: unknown, cleanupUnacknowledged: boolean) => void; readonly #assertEntering: () => void; readonly #prompts = new Set>(); readonly #viewOperations = new Set>(); @@ -36,7 +36,7 @@ export class WorkflowTerminal { registry: InvocationRegistry, io: CliIo, assertEntering: () => void, - fail: (cause: unknown) => void, + fail: (cause: unknown, cleanupUnacknowledged: boolean) => void, ) { this.#registry = registry; this.#io = io; @@ -75,7 +75,7 @@ export class WorkflowTerminal { this.#promptUnacknowledged = true; this.#prompts.delete(settled); settle(); - this.#fail(cause); + this.#fail(cause, true); throw cause; }; let raw: Promise; @@ -85,7 +85,7 @@ export class WorkflowTerminal { this.#promptUnacknowledged = true; this.#prompts.delete(settled); settle(); - this.#fail(cause); + this.#fail(cause, true); // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- Preserve the opaque constructor failure without forging terminal release. return Promise.reject(cause); } @@ -115,6 +115,7 @@ export class WorkflowTerminal { raw = factory(); } catch (cause) { finish(); + this.#fail(cause, mayAcquire || this.#view !== undefined); return Promise.resolve().then(() => { throw cause; }); @@ -126,6 +127,7 @@ export class WorkflowTerminal { }, (cause: unknown) => { finish(); + this.#fail(cause, mayAcquire || this.#view !== undefined); throw cause; }, ); @@ -139,8 +141,9 @@ export class WorkflowTerminal { return this.#view?.releaseInput?.(); }); this.#inputWork = pending; - void pending.catch(() => { + void pending.catch((cause: unknown) => { if (this.#inputWork === pending) this.#inputWork = undefined; + this.#fail(cause, true); }); return pending; } @@ -173,7 +176,9 @@ export class WorkflowTerminal { if (this.#sealed) renderer.seal?.(); return renderer; } catch (cause) { - this.#fail(cause); + // A positive native-free declaration needs no cleanup bound. An adopted view + // still owns its real release, including constructor rollback after adoption. + this.#fail(cause, this.#viewUnacknowledged || this.#view !== undefined); throw cause; } } @@ -193,9 +198,12 @@ export class WorkflowTerminal { this.#jobReleased = control.released; this.#jobUnacknowledged = false; if (control.released !== undefined) - void control.released.then(() => { - if (this.#disposeJobControl === control.dispose) this.#disposeJobControl = undefined; - }, this.#fail); + void control.released.then( + () => { + if (this.#disposeJobControl === control.dispose) this.#disposeJobControl = undefined; + }, + (cause: unknown) => this.#fail(cause, true), + ); if (this.#sealed) this.#releaseJobControl(); }; try { @@ -203,7 +211,7 @@ export class WorkflowTerminal { adopt(control); return control; } catch (cause) { - this.#fail(cause); + this.#fail(cause, true); throw cause; } } @@ -222,12 +230,12 @@ export class WorkflowTerminal { try { this.#view?.seal?.(); } catch (cause) { - this.#fail(cause); + this.#fail(cause, true); } try { this.#releaseJobControl(); } catch (cause) { - this.#fail(cause); + this.#fail(cause, true); } } @@ -255,7 +263,7 @@ export class WorkflowTerminal { // Attempt and observe independent operations before awaiting a held prompt/view/stdout callback. const guarded = [earlyCursor, view, job].map((work) => work.catch((cause: unknown) => { - this.#fail(cause); + this.#fail(cause, true); throw cause; }), ); @@ -285,7 +293,7 @@ export class WorkflowTerminal { /** Seal only at the last preparation/driver boundary, then join actual restoration. */ async finish(): Promise { this.seal(); - void this.#restore().catch(this.#fail); + void this.#restore().catch((cause: unknown) => this.#fail(cause, true)); await this.#retired; } } diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index cacb73ce..7e9a1acd 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1100,6 +1100,10 @@ their classifier; unknown cleanup failures do not silently become success. The first owned infrastructure fault starts the common three-second backstop immediately, including when cleanup holds the rejection from the outer catch. +Retained terminal cleanup starts that bound even when the original operation has +a known operational classification. The terminal retains its own release ACK; +this does not mark an already-safe workflow as unacknowledged. A positively +native-free constructor refusal keeps its ordinary classification. The native bootstrap takes the original observer, deadline and physical-exit latch before runtime entry, invalidating any queued fallback completion. Actual terminal restoration and the sole fixed final diagnostic require native ACK From 64774b644a2d58e0a78ce96df8f5412e8ae52d05 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 12:05:38 +0300 Subject: [PATCH 190/222] fix(core): select original credential boundaries before matching Prevent an embedded public prefix from hiding a later split credential. Use the canonical matcher with bounded previews and monotonic token-run lookahead so rejected JWT candidates cannot rescan admitted-size suffixes. Cover full projections, every existing family and dense eight-MiB inputs while retaining the current finite masking policy. Refs: ADR-0080, ADR-0113 Co-Authored-By: Codex --- packages/core/src/tools/bounding.test.ts | 84 ++++++++++++++++++++++++ packages/core/src/tools/bounding.ts | 73 ++++++++++++++++++-- 2 files changed, 153 insertions(+), 4 deletions(-) diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index 3814834d..d7de9bd1 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -971,6 +971,90 @@ describe('PR95 B2 finite split-credential projections', () => { }); describe('PR95 B2 projection boundaries and admitted-scale controls', () => { + it.each([ + ['sk-proj-', '\n'], + ['sk-proj-', '\t'], + ['AIza', '\n'], + ['AIza', '\t'], + ])( + 'keeps the original boundary after an embedded greedy prefix %s (%j)', + async (prefix, separator) => { + const publicPrefix = `word${prefix}${'A'.repeat(32)}${separator}`; + for (const publicTail of ['A', 'B']) { + const input = `${publicPrefix}${prefix}${'B'.repeat(32)}${separator}${'C'.repeat(32)}; public=${publicTail}`; + const expected = `${publicPrefix}[redacted]; public=${publicTail}`; + expect(redactSecretShapedText(input), 'SECRETS_B2_ORIGINAL_BOUNDARY').toBe(expected); + expect(redactSecretShapedValue({ body: input }), 'SECRETS_B2_ORIGINAL_BOUNDARY').toEqual({ + body: expected, + }); + expect((await boundForModel(input, BIG, {})).summary, 'SECRETS_B2_ORIGINAL_BOUNDARY').toBe( + expected.replace(/\s+/g, ' '), + ); + } + }, + ); + + it('preserves canonical family acceptance through bounded joined probes', () => { + const credentials = [ + `sk-${'A'.repeat(16)}`, + `sk_live_${'A'.repeat(16)}`, + `sk_test_${'A'.repeat(16)}`, + ...['AKIA', 'ASIA', 'ABIA'].map((prefix) => `${prefix}${'A'.repeat(16)}`), + ...['ghp_', 'gho_', 'ghu_', 'ghs_', 'ghr_', 'github_pat_', 'hf_', 'npm_'].map( + (prefix) => `${prefix}${'A'.repeat(20)}`, + ), + `glpat-${'A'.repeat(16)}`, + ...['xoxb-', 'xoxa-', 'xoxp-', 'xoxr-', 'xoxs-'].map( + (prefix) => `${prefix}${'A'.repeat(10)}`, + ), + `AIza${'A'.repeat(30)}`, + `ya29.${'A'.repeat(20)}`, + `eyJ${'A'.repeat(10)}.${'B'.repeat(10)}.${'C'.repeat(6)}`, + ]; + for (const credential of credentials) { + for (const padding of [0, 63, 64, 65, 128]) { + const raw = `${credential}${'D'.repeat(padding)}; public=A`; + for (const separator of ['\n', '\t']) { + const split = raw.slice(0, 1) + separator + raw.slice(1); + // The unchanged raw canonical matcher is the preservation oracle, not a second grammar. + expect(redactSecretShapedText(split), 'SECRETS_B2_BOUNDED_PROBE').toBe( + redactSecretShapedText(raw), + ); + } + } + } + for (const length of [63, 64, 65, 128]) { + const jwt = `eyJ${'A'.repeat(length)}\n.${'B'.repeat(length)}\t.${'C'.repeat(length)}-sk-${'D'.repeat(32)}\nTAIL`; + expect(redactSecretShapedText(`${jwt}; public=A`), 'SECRETS_B2_BOUNDED_PROBE').toBe( + '[redacted]; public=A', + ); + } + }); + + it('bounds admitted-size repeated invalid JWT starts without losing later valid spans', () => { + const fragment = `eyJ${'A'.repeat(61)}\n`; + const publicPrefix = fragment.repeat(Math.floor((8 * 1024 * 1024 - 256) / fragment.length)); + const started = performance.now(); + // No dot, or a huge second run with no second dot: both full JWT attempts would scan to EOF. + for (const suffix of ['', `.${'B'.repeat(128)}`]) { + const input = `${publicPrefix}${suffix}; sk-proj-${'C'.repeat(32)}\t${'D'.repeat(32)}; public=A`; + const expected = `${publicPrefix}${suffix}; [redacted]; public=A`; + const output = redactSecretShapedText(input); + expect(output.length, 'SECRETS_B2_BOUNDED_PROBE').toBe(expected.length); + expect(output === expected, 'SECRETS_B2_BOUNDED_PROBE').toBe(true); + } + expect(performance.now() - started, 'SECRETS_B2_BOUNDED_PROBE').toBeLessThan(5_000); + }); + + it('preserves dense admitted-size public boundaries with bounded preparation', () => { + const started = performance.now(); + for (const fragment of ['a\n', 'a ']) { + const input = `${fragment.repeat(4 * 1024 * 1024 - 1)}\n`; + expect(redactSecretShapedText(input) === input, 'SECRETS_B2_BOUNDED_PROBE').toBe(true); + } + expect(performance.now() - started, 'SECRETS_B2_BOUNDED_PROBE').toBeLessThan(5_000); + }); + it('retains prefix evidence inside a header and preserves the same public tail after multiple masks', () => { const credential = `sk-proj-${'A'.repeat(24)}\n${'B'.repeat(24)}`; const result = redactSecretShapedText( diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index c714b618..08aaf092 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -126,13 +126,80 @@ const STANDALONE_CREDENTIAL = /\b(?:sk-(?=[\w-]{16})[\w-]+|sk_(?:live|test)_(?=[A-Za-z0-9]{16})[A-Za-z0-9]+|A[KSB]IA[0-9A-Z]{16}|gh[pousr]_(?=[A-Za-z0-9]{20})[A-Za-z0-9]+|github_pat_(?=\w{20})\w+|glpat-(?=[\w-]{16})[\w-]+|xox[baprs]-(?=[A-Za-z0-9-]{10})[A-Za-z0-9-]+|AIza(?=[\w-]{30})[\w-]+|ya29\.(?=[\w-]{20})[\w-]+|hf_(?=[A-Za-z0-9]{20})[A-Za-z0-9]+|npm_(?=[A-Za-z0-9]{20})[A-Za-z0-9]+|eyJ(?=[\w-]{10})[\w-]+\.(?=[\w-]{10})[\w-]+\.(?=[\w-]{6})[\w-]+)/g; // NOSONAR — finite credential families, see the projection policy below // The joined probe checks its boundary in the ORIGINAL text. Removing LF/TAB must not erase // a legitimate boundary or manufacture one inside a public word. The source is the literal above. -const JOINED_STANDALONE_CREDENTIAL = new RegExp(STANDALONE_CREDENTIAL.source.slice(2), 'g'); +const JOINED_STANDALONE_CREDENTIAL = new RegExp(STANDALONE_CREDENTIAL.source.slice(2), 'y'); const SCHEME_CREDENTIAL = /\b(b[\n\t]*e[\n\t]*a[\n\t]*r[\n\t]*e[\n\t]*r|b[\n\t]*a[\n\t]*s[\n\t]*i[\n\t]*c|t[\n\t]*o[\n\t]*k[\n\t]*e[\n\t]*n)\s+(?=[\w.~+/-]{8})[\w.~+/-]+={0,2}/gi; type SecretSpanVisitor = (start: number, end: number, replacement: string) => void; +/** + * A failed JWT probe must not rescan an admitted-size token run at every original LF/TAB boundary. + * Probe the SAME canonical matcher with at most three dot-separated runs (JWT's maximum), keeping + * the first 64 characters of each run. The largest minimum acceptance window in the literal is + * 34 characters (AIza + 30); all other prefixes/minima fit, including JWT's 13/10/6 and ya29's two + * runs. Truncating a longer [\w-] run therefore preserves acceptance for every current family. + * Only accepted probes run the full greedy matcher, whose consumed span is then skipped. + * + * Candidate starts increase monotonically. Cache the three lookahead run ends so many original + * boundaries inside one joined run share one delimiter scan; no per-character index is retained. + */ +function joinedCredentialProbe(joined: string): (start: number) => string { + const delimiter = /[^\w-]/g; + const runs: { start: number; end: number }[] = []; + function runEnd(start: number): number { + const cached = runs.find((run) => run.start <= start && start < run.end); + if (cached !== undefined) return cached.end; + delimiter.lastIndex = start; + const end = delimiter.exec(joined)?.index ?? joined.length; + if (end > start) { + runs.push({ start, end }); + if (runs.length > 3) runs.shift(); + } + return end; + } + return (start) => { + let preview = ''; + for (let index = 0; index < 3; index += 1) { + const end = runEnd(start); + preview += joined.slice(start, Math.min(end, start + 64)); + if (end === start || joined[end] !== '.') break; + preview += '.'; + start = end + 1; + } + return preview; + }; +} + +/** + * Select valid ORIGINAL starts before matching: a rejected embedded prefix cannot consume them. + * Every prefix in the canonical literal starts with an ASCII letter; the prefilter only omits + * impossible starts. It does not decide which family matches or where that credential ends. + */ +function visitJoinedStandaloneCredentials( + original: string, + joined: string, + mask: SecretSpanVisitor, +): void { + const preview = joinedCredentialProbe(joined); + let joinedOffset = 0; + let matchedUntil = 0; + for (let originalOffset = 0; originalOffset < original.length; originalOffset += 1) { + const char = original[originalOffset]; + if (char === '\n' || char === '\t') continue; + const start = joinedOffset++; + if (start < matchedUntil || !/[A-Za-z]/.test(char ?? '')) continue; + if (/\w/.test(original[originalOffset - 1] ?? '')) continue; + JOINED_STANDALONE_CREDENTIAL.lastIndex = 0; + if (!JOINED_STANDALONE_CREDENTIAL.test(preview(start))) continue; + JOINED_STANDALONE_CREDENTIAL.lastIndex = start; + const match = JOINED_STANDALONE_CREDENTIAL.exec(joined); + if (match === null) continue; + matchedUntil = JOINED_STANDALONE_CREDENTIAL.lastIndex; + mask(start, matchedUntil, '[redacted]'); + } +} + /** * LF/TAB may split finite credential shapes. Map recognized spans back onto the original text; * ordinary spaces remain boundaries and public text is never globally normalized for identity. @@ -177,9 +244,7 @@ function redactSplitCredentials(text: string): string { const keys = redactJoinedCredentialSpans( urls, (joined, mask) => { - for (const match of joined.matchAll(JOINED_STANDALONE_CREDENTIAL)) { - mask(match.index, match.index + match[0].length, '[redacted]'); - } + visitJoinedStandaloneCredentials(urls, joined, mask); }, /\w/, ); From 5393f6b70bc633218f7adee1f0fcd6712faa3009 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 12:05:38 +0300 Subject: [PATCH 191/222] fix(llm): retain eligible split credential starts Select original-text token boundaries before a greedy joined match can skip them. Preserve the one canonical family rule and mask complete diagnostics when LF or TAB reveals a supported credential. Pin the rejected-public-prefix interaction through scrubSecrets and both LlmError message and code, including admitted-size preparation. Refs: ADR-0011, ADR-0080 Co-Authored-By: Codex --- packages/llm/src/scrub-secrets.test.ts | 22 +++++++++++ packages/llm/src/scrub-secrets.ts | 53 +++++++++++++++++++++++++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/llm/src/scrub-secrets.test.ts b/packages/llm/src/scrub-secrets.test.ts index b0682858..24f925cd 100644 --- a/packages/llm/src/scrub-secrets.test.ts +++ b/packages/llm/src/scrub-secrets.test.ts @@ -237,6 +237,28 @@ describe('PR95 B2 split provider/header and malformed userinfo diagnostics', () }); describe('PR95 B2 finite joined-view boundaries', () => { + it.each([ + ['sk-proj-', '\n'], + ['sk-proj-', '\t'], + ['AIza', '\n'], + ['AIza', '\t'], + ])('keeps the original boundary after an embedded greedy prefix %s (%j)', (prefix, separator) => { + const input = `word${prefix}${'A'.repeat(32)}${separator}${prefix}${'B'.repeat(32)}${separator}${'C'.repeat(32)}; public=A`; + expect(scrubSecrets(input), 'SECRETS_B2_ORIGINAL_BOUNDARY').toBe('[REDACTED]'); + const error = makeLlmError({ provider: 'openai', kind: 'auth', message: input, code: input }); + expect(error.message, 'SECRETS_B2_ORIGINAL_BOUNDARY').toBe('[REDACTED]'); + expect(error.code, 'SECRETS_B2_ORIGINAL_BOUNDARY').toBe('[REDACTED]'); + }); + + it('bounds admitted-size embedded prefixes before a valid original boundary', () => { + const fragment = `wordsk-proj-${'A'.repeat(52)}\n`; + const publicPrefix = fragment.repeat(Math.floor((8 * 1024 * 1024 - 256) / fragment.length)); + const input = `${publicPrefix}sk-proj-${'B'.repeat(32)}\t${'C'.repeat(32)}; public=A`; + const started = performance.now(); + expect(scrubSecrets(input), 'SECRETS_B2_ORIGINAL_BOUNDARY').toBe('[REDACTED]'); + expect(performance.now() - started, 'SECRETS_B2_ORIGINAL_BOUNDARY').toBeLessThan(5_000); + }); + it('does not invent a key boundary in a public word with a separate newline', () => { const input = `wordsk-proj-${'A'.repeat(32)};\nordinary`; expectCredentialDiagnostic(scrubSecrets(input)).toBe(input); diff --git a/packages/llm/src/scrub-secrets.ts b/packages/llm/src/scrub-secrets.ts index 0db5121c..6c4440a5 100644 --- a/packages/llm/src/scrub-secrets.ts +++ b/packages/llm/src/scrub-secrets.ts @@ -24,13 +24,25 @@ interface SecretRule { readonly replacement: string | ((match: string) => string); readonly joinedPattern?: RegExp; readonly boundary?: RegExp; + /** A prefixed single token run: select original boundaries before its greedy joined match. */ + readonly joinedToken?: boolean; } /** One private authority per existing credential family, shared by probes and output masking. */ const NON_QUERY_SECRET_RULES: readonly SecretRule[] = [ // OpenAI sk-/sk-proj-/sk-svcacct- and Anthropic sk-ant- key prefixes. - { pattern: /\bsk-(?=[\w-]{16})[\w-]+/g, replacement: '[REDACTED]', boundary: /\w/ }, - { pattern: /\bAIza(?=[\w-]{20})[\w-]+/g, replacement: '[REDACTED]', boundary: /\w/ }, + { + pattern: /\bsk-(?=[\w-]{16})[\w-]+/g, + replacement: '[REDACTED]', + boundary: /\w/, + joinedToken: true, + }, + { + pattern: /\bAIza(?=[\w-]{20})[\w-]+/g, + replacement: '[REDACTED]', + boundary: /\w/, + joinedToken: true, + }, // Finite authority schemes, not URL validation. Raw ?/# before the first @ may belong // to a password; after an existing @ they begin public query/fragment text. { @@ -88,6 +100,42 @@ function applyRule(text: string, rule: SecretRule): string { : text.replace(rule.pattern, rule.replacement); } +/** + * Probe only eligible original starts, so an embedded public prefix cannot greedily hide a later + * LF/TAB start. These rules have one token run after a bounded prefix/minimum: failures are bounded, + * and successful matches advance both cursors over the whole run. No rejected full-run rescans. + * Their canonical prefixes start with ASCII letters; that prefilter skips only impossible starts. + */ +function hasSplitToken(text: string, joined: string, rule: SecretRule): boolean { + const pattern = new RegExp(rule.pattern.source.slice(2), rule.pattern.flags.replace('g', 'y')); + let originalOffset = 0; + let joinedOffset = 0; + while (originalOffset < text.length) { + const char = text[originalOffset]; + if (char === '\n' || char === '\t') { + originalOffset += 1; + continue; + } + if (/[A-Za-z]/.test(char ?? '') && !rule.boundary?.test(text[originalOffset - 1] ?? '')) { + pattern.lastIndex = joinedOffset; + const match = pattern.exec(joined); + if (match !== null) { + const start = originalOffset; + const joinedStart = joinedOffset; + while (joinedOffset < pattern.lastIndex) { + const next = text[originalOffset++]; + if (next !== '\n' && next !== '\t') joinedOffset += 1; + } + if (originalOffset - start > joinedOffset - joinedStart) return true; + continue; + } + } + originalOffset += 1; + joinedOffset += 1; + } + return false; +} + /** * Probe LF/TAB joins before any masking can destroy the prefix. Ordinary spaces remain boundaries. * Check the left boundary against the original string: a join must neither erase an existing @@ -96,6 +144,7 @@ function applyRule(text: string, rule: SecretRule): string { */ function hasSplitCredential(text: string, joined: string, rule: SecretRule): boolean { if (rule.boundary === undefined) return false; + if (rule.joinedToken === true) return hasSplitToken(text, joined, rule); const pattern = rule.joinedPattern ?? new RegExp(rule.pattern.source.slice(2), rule.pattern.flags); let originalOffset = 0; From b19af1af6b8900912d89fc78a720b31c02daa5db Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 12:08:07 +0300 Subject: [PATCH 192/222] test(repo): pin split credential boundary recovery controls Retain the 51 prior control identities and witnesses while updating complete workload counts. Add separate core and diagnostic recovery removals requiring all eight poisoned-prefix assertions, without removing bounded JWT preparation. Declare 53 controls and 310 witnesses; declarations remain separate from physical fixed/removal/restored acceptance. Refs: ADR-0080, ADR-0113 Co-Authored-By: Codex --- .github/workflows/ci.yml | 2 +- tools/causal-controls/provenance.test.mjs | 28 ++++++- tools/secrets-controls/README.md | 27 +++++-- tools/secrets-controls/mutations.mjs | 91 ++++++++++++++++++++--- 4 files changed, 125 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2c046fd..23b19e8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,7 +281,7 @@ jobs: secrets-mutations: name: Wave 2 secrets causal controls # The historical 35-control, three-phase capture exceeded the former aggregate budget. - # The current cohort is 51 controls (2026-10-18); this comment does not certify its execution. + # The current cohort is 53 controls (2026-10-18); this comment does not certify its execution. # Per-phase, case/hook, output and retained-evidence bounds remain unchanged. timeout-minutes: 30 runs-on: ubuntu-latest diff --git a/tools/causal-controls/provenance.test.mjs b/tools/causal-controls/provenance.test.mjs index ed09115e..78615a95 100644 --- a/tools/causal-controls/provenance.test.mjs +++ b/tools/causal-controls/provenance.test.mjs @@ -267,10 +267,10 @@ test('every declared filesystem and secrets anchor matches current production so }); test('secrets workload corrections retain independent complete-count and reader-witness pins', () => { - assert.equal(secretsControls.length, 51); + assert.equal(secretsControls.length, 53); assert.equal( secretsControls.reduce((count, control) => count + 1 + (control.witnesses ?? []).length, 0), - 302, + 310, ); for (const [id, file, expectedTests] of [ ['run-default-secret-reader', 'apps/cli/src/commands/run.test.ts', 84], @@ -307,19 +307,39 @@ test('secrets workload corrections retain independent complete-count and reader- 'core-split-scheme-label', 'core-split-header-label', 'core-raw-password-userinfo', - ].map((id) => [id, ['packages/core/src/tools/bounding.test.ts'], 129]), + 'core-joined-original-boundary-recovery', + ].map((id) => [id, ['packages/core/src/tools/bounding.test.ts'], 136]), ...[ 'diagnostic-joined-before-masking', 'diagnostic-joined-original-boundary', 'diagnostic-split-header-label', 'diagnostic-raw-password-userinfo', - ].map((id) => [id, ['packages/llm/src/scrub-secrets.test.ts'], 79]), + 'diagnostic-joined-original-boundary-recovery', + ].map((id) => [id, ['packages/llm/src/scrub-secrets.test.ts'], 84]), ]) { const selected = secretsControls.find((entry) => entry.id === id); assert.ok(selected, `${id}: missing redaction control`); assert.deepEqual(selected.files, files); assert.equal(selected.expectedTests, expectedTests, `${id}: complete workload drift`); } + for (const id of [ + 'core-joined-original-boundary-recovery', + 'diagnostic-joined-original-boundary-recovery', + ]) { + const recovery = secretsControls.find((entry) => entry.id === id); + assert.ok(recovery, `${id}: missing original-boundary recovery`); + assert.equal(recovery.marker, 'SECRETS_B2_ORIGINAL_BOUNDARY'); + assert.deepEqual( + [recovery.target, ...recovery.witnesses.map((witness) => witness.target)], + ['sk-proj-', 'AIza'].flatMap((prefix) => + ['\n', '\t'].map( + (separator) => + `keeps the original boundary after an embedded greedy prefix ${prefix} (${JSON.stringify(separator)})`, + ), + ), + ); + assert.ok(recovery.witnesses.every(({ marker }) => marker === 'SECRETS_B2_ORIGINAL_BOUNDARY')); + } const reader = secretsControls.find((entry) => entry.id === 'run-default-secret-reader'); assert.equal( reader.target, diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index 2e05704c..722fbabe 100644 --- a/tools/secrets-controls/README.md +++ b/tools/secrets-controls/README.md @@ -4,7 +4,7 @@ Step 4 first-run input matrix, Step 5 snapshot/export, Step 7 provider-header and PR #95 B2 redaction controls on physical source copies. `--mechanism ` supports diagnosis only; selected runs retain `complete: false`. The current manifest -declares **51 independent controls and 302 required named assertion witnesses**. +declares **53 independent controls and 310 required named assertion witnesses**. These counts describe the workload; a declaration is not evidence that its removal passed. Actual fixed/removal/restored results belong to the coordinator's summary. @@ -177,8 +177,8 @@ modern `sk-` body characters, joined-span application, the original-text left-bo check, split scheme labels, split API-header labels and raw `?`/`#` password recognition independently. Four LLM controls remove the before-masking LF/TAB probe, its original left-boundary check, split API-header recognition and raw-password recognition. -Every core phase executes the complete 129-case bounding file; every new LLM phase -executes the complete 79-case scrubber file. Independent ordinary tooling tests pin +Every core phase executes the complete 136-case bounding file; every new LLM phase +executes the complete 84-case scrubber file. Independent ordinary tooling tests pin these inventories, the 261-case overlap cohort, 163-case CLI diagnostic cohort and 17-case command-export cohort. Actual collection still has to match in every phase. @@ -192,10 +192,25 @@ LF/TAB continuation and malformed-userinfo policy is documented in the canonical An eight-MiB permanent fixture checks completion and whole-span behavior without a machine-speed threshold; these controls do not certify arbitrary obfuscation. -The current 51-control / 153-phase full declaration projects 925 retained files -(51 × 18 + 7) under the unchanged 2,000-file / 64-MiB export bounds. This is a capacity +Two R1 original-boundary recovery controls add eight named assertions, retaining all +51 prior identities and required witnesses. The core removal admits embedded-word +starts while retaining the mapper's late refusal and bounded probe; the rejected +candidate consumes the later valid start. The LLM removal restores the old +non-overlapping discovery for its bounded single-run token families. All four +OpenAI/Google × LF/TAB poisoned-prefix cases must then fail with +`SECRETS_B2_ORIGINAL_BOUNDARY`; +public embedded-word preservation and the later complete split credential are +asserted together. The earlier core boundary control removes both scanner and +mapper boundary checks as one policy; removing the late mapper check alone leaves +the new scanner prefilter active. The LLM boundary control removes its actual early +token-boundary predicate. Their existing named witnesses remain required. These +are declared controls; actual final-source fixed/removal/restored evidence remains +separate from the older eleven selected captures and independent acceptance. + +The current 53-control / 159-phase full declaration projects 961 retained files +(53 × 18 + 7) under the unchanged 2,000-file / 64-MiB export bounds. This is a capacity calculation, not a measured full capture. Historical 41-control captures retain their -original source and cohort attribution; selected B2 captures are not full-family +original source and cohort attribution; selected B2 or original-boundary recovery captures are not full-family acceptance. The thin entry point uses the same coordinator, outcome and bounded process join diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index 3387dd02..1e00bf2b 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -66,30 +66,82 @@ const rawPasswordAssignments = ['?', '#'].map( (separator) => `preserves public ${separator} tails after userinfo and masks raw password assignments`, ); -function coreProjectionControl(id, before, after, targets) { +const originalBoundaryMarker = 'SECRETS_B2_ORIGINAL_BOUNDARY'; +const originalBoundaryCases = ['sk-proj-', 'AIza'].flatMap((prefix) => + ['\n', '\t'].map( + (separator) => + `keeps the original boundary after an embedded greedy prefix ${prefix} (${JSON.stringify(separator)})`, + ), +); +// Both scanner admission and mapper admission enforce one original-boundary law. Remove both +// exact checks together; removing only the late mapper guard would leave its new prefilter intact. +const coreOriginalBoundary = String.raw` if (/\w/.test(original[originalOffset - 1] ?? '')) continue; + JOINED_STANDALONE_CREDENTIAL.lastIndex = 0; + if (!JOINED_STANDALONE_CREDENTIAL.test(preview(start))) continue; + JOINED_STANDALONE_CREDENTIAL.lastIndex = start; + const match = JOINED_STANDALONE_CREDENTIAL.exec(joined); + if (match === null) continue; + matchedUntil = JOINED_STANDALONE_CREDENTIAL.lastIndex; + mask(start, matchedUntil, '[redacted]'); + } +} + +/** + * LF/TAB may split finite credential shapes. Map recognized spans back onto the original text; + * ordinary spaces remain boundaries and public text is never globally normalized for identity. + * Token-character continuations after LF/TAB are intentionally part of a recognized credential. + * The two cursors advance monotonically, with no per-character offset array or recursive matcher. + */ +function redactJoinedCredentialSpans( + text: string, + visit: (joined: string, mask: SecretSpanVisitor) => void, + boundary?: RegExp, +): string { + if (!/[\n\t]/.test(text)) return text; + const joined = text.replace(/[\n\t]+/g, ''); + const parts: string[] = []; + let originalOffset = 0; + let joinedOffset = 0; + let copied = 0; + function advance(target: number): number { + while (joinedOffset < target && originalOffset < text.length) { + const char = text[originalOffset++]; + if (char !== '\n' && char !== '\t') joinedOffset += 1; + } + return originalOffset; + } + visit(joined, (start, end, replacement) => { + let originalStart = advance(start); + while (text[originalStart] === '\n' || text[originalStart] === '\t') originalStart += 1; + const originalEnd = advance(end); + if (boundary?.test(text[originalStart - 1] ?? '')) return;`; +const withoutCoreOriginalBoundary = coreOriginalBoundary + .replace(" if (/\\w/.test(original[originalOffset - 1] ?? '')) continue;\n", '') + .replace(" if (boundary?.test(text[originalStart - 1] ?? '')) return;", ''); +function coreProjectionControl(id, before, after, targets, marker = coreProjectionMarker) { return control( id, coreProjection, before, after, coreProjectionCases, - 129, + 136, targets[0], - coreProjectionMarker, - targets.slice(1).map((target) => ({ target, marker: coreProjectionMarker })), + marker, + targets.slice(1).map((target) => ({ target, marker })), ); } -function diagnosticControl(id, before, after, targets) { +function diagnosticControl(id, before, after, targets, marker = diagnosticMarker) { return control( id, scrubSecretsLeaf, before, after, ['packages/llm/src/scrub-secrets.test.ts'], - 79, + 84, targets[0], - diagnosticMarker, - targets.slice(1).map((target) => ({ target, marker: diagnosticMarker })), + marker, + targets.slice(1).map((target) => ({ target, marker })), ); } @@ -774,8 +826,8 @@ export const mutations = [ ), coreProjectionControl( 'core-joined-original-boundary', - " if (boundary?.test(text[originalStart - 1] ?? '')) return;", - '', + coreOriginalBoundary, + withoutCoreOriginalBoundary, ['keeps the original left boundary when LF/TAB occurs elsewhere or immediately before a key'], ), coreProjectionControl( @@ -808,8 +860,8 @@ export const mutations = [ ), diagnosticControl( 'diagnostic-joined-original-boundary', - " if (rule.boundary.test(text[start - 1] ?? '')) continue;", - '', + "if (/[A-Za-z]/.test(char ?? '') && !rule.boundary?.test(text[originalOffset - 1] ?? '')) {", + "if (/[A-Za-z]/.test(char ?? '')) {", ['does not invent a key boundary in a public word with a separate newline'], ), diagnosticControl( @@ -824,4 +876,19 @@ export const mutations = [ String.raw`pattern: /\b(?:https?|wss?|ftps?):\/\/[^/\s?#]*@/gi,`, [...diagnosticPasswords, ...rawPasswordAssignments], ), + // R1-LN-1: recover a valid original start hidden inside a rejected greedy joined candidate. + coreProjectionControl( + 'core-joined-original-boundary-recovery', + " if (/\\w/.test(original[originalOffset - 1] ?? '')) continue;\n", + '', + originalBoundaryCases, + originalBoundaryMarker, + ), + diagnosticControl( + 'diagnostic-joined-original-boundary-recovery', + ' if (rule.joinedToken === true) return hasSplitToken(text, joined, rule);\n', + '', + originalBoundaryCases, + originalBoundaryMarker, + ), ]; From dd1c45ad3151fa910063cf28f14267c367662bce Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 12:08:08 +0300 Subject: [PATCH 193/222] docs(docs): record nonclean review and corrective verification Retain exact attribution for the complete first corrective review and its two independently reproduced residuals. Record current author corrections and required fresh acceptance gates without implying merge readiness. Refs: ADR-0110, ADR-0113 Co-Authored-By: Codex --- .../phase-2.5.5-hardening-and-remediation.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 3bf5d71a..d7db9d5e 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8725,3 +8725,27 @@ pending**. Two NEW independent complete cumulative clean rounds after the final material correction, current author gates and corrected-source CI/coverage/Sonar/ same-archive platform controls remain required. No merge, release, new ADR, credential capture, billing step or new deferral is claimed. + +The first new cumulative round at `02b34e4d` is **COMPLETE NONCLEAN**: three +independent lanes cover all 96 corrective paths / 234 ordered hunks, with no +unassigned or repeated paths. The tooling/documentation lane is clean; the +libraries/native lane reproduces a skipped original-boundary credential suffix, +and the CLI lane reproduces classified terminal cleanup retained without a +finite bound. These are defects in the first corrections, not accepted limits. +The terminal correction in `ad5a506a` carries actual terminal cleanup uncertainty +to the original finite-exit owner without marking workflow cleanup unacknowledged. +Positive native-free constructor refusals retain their ordinary classification; +adopted rollback can retire only after its actual input-release ACK. Author +checks pass 61 focused cases plus CLI typecheck/lint. Removing the bound fails +nine named assertions; byte-exact restoration passes all 25 lifetime cases. +This author control does not replace the committed coordinator's runtime-error +receipt or independent review. Credential recovery and its causal controls remain +under final verification. Corrections `64774b64` / `5393f6b7` select eligible +original-text starts before a rejected greedy candidate can skip them. Author +core/LLM checks pass 318 focused cases, typechecks and lint, including admitted-size +public boundaries and failed JWT probes. The manifest declares 53 controls / 310 +required witnesses; all 51 preceding identities and witnesses remain, and fresh +collection confirms 25 complete workload files / 1,292 cases. These declarations +and author checks are not final-source physical execution or independent acceptance. +Two NEW complete cumulative clean rounds after the final +material correction and the separate security/publication gates are still required. From ce6df2681dca4df8db6025dcdd03e4ceef951fee Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 12:09:08 +0300 Subject: [PATCH 194/222] test(llm): bound admitted-size failure diagnostics Assert the scrubbed length before comparing the whole value so a deliberate recovery removal cannot inflate causal evidence with eight MiB of diagnostic text. Keep the same behavior, marker and complete case count. Refs: ADR-0080, ADR-0113 Co-Authored-By: Codex --- packages/llm/src/scrub-secrets.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/llm/src/scrub-secrets.test.ts b/packages/llm/src/scrub-secrets.test.ts index 24f925cd..4e42946e 100644 --- a/packages/llm/src/scrub-secrets.test.ts +++ b/packages/llm/src/scrub-secrets.test.ts @@ -255,7 +255,10 @@ describe('PR95 B2 finite joined-view boundaries', () => { const publicPrefix = fragment.repeat(Math.floor((8 * 1024 * 1024 - 256) / fragment.length)); const input = `${publicPrefix}sk-proj-${'B'.repeat(32)}\t${'C'.repeat(32)}; public=A`; const started = performance.now(); - expect(scrubSecrets(input), 'SECRETS_B2_ORIGINAL_BOUNDARY').toBe('[REDACTED]'); + const output = scrubSecrets(input); + // Keep removal evidence bounded: a regression must not serialize eight MiB into its report. + expect(output.length, 'SECRETS_B2_ORIGINAL_BOUNDARY').toBe('[REDACTED]'.length); + expect(output, 'SECRETS_B2_ORIGINAL_BOUNDARY').toBe('[REDACTED]'); expect(performance.now() - started, 'SECRETS_B2_ORIGINAL_BOUNDARY').toBeLessThan(5_000); }); From 8e7b9a38e7332ab85bc3b9fc19966e72f2c8eb46 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 12:31:06 +0300 Subject: [PATCH 195/222] fix(core): bound raw and split credential matching Select original boundaries before finite header matches and reuse the bounded canonical standalone scanner for raw text. Prevent nested header candidates from skipping a valid label and failed raw JWT candidates from repeatedly scanning an admitted-size suffix. Preserve full-value projections, original public bytes and outer overlapping credential spans. Keep all existing resource bounds and cover raw eight-MiB work, exact canonical families, summaries and structured values. Refs: ADR-0080, ADR-0113 Co-Authored-By: Codex --- packages/core/src/tools/bounding.test.ts | 57 +++++++++- packages/core/src/tools/bounding.ts | 132 ++++++++++++++--------- 2 files changed, 136 insertions(+), 53 deletions(-) diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index d7de9bd1..05c28d8e 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -971,6 +971,46 @@ describe('PR95 B2 finite split-credential projections', () => { }); describe('PR95 B2 projection boundaries and admitted-scale controls', () => { + it.each([ + ['x-', '\n', '\t'], + ['x-', '\t', '\n'], + ['x-goog-', '\n', '\t'], + ['x-goog-', '\t', '\n'], + ])( + 'recovers the original finite header boundary after embedded %s (%j)', + async (prefix, boundary, split) => { + for (const publicTail of ['A', 'B']) { + const input = `word${prefix}${boundary}api-${split}key: opaque-Z29; public=${publicTail}`; + const expected = `word${prefix}${boundary}[redacted]; public=${publicTail}`; + expect(redactSecretShapedText(input), 'SECRETS_B2_HEADER_ORIGINAL_BOUNDARY').toBe(expected); + expect( + redactSecretShapedValue({ body: input }), + 'SECRETS_B2_HEADER_ORIGINAL_BOUNDARY', + ).toEqual({ + body: expected, + }); + expect( + (await boundForModel(input, BIG, {})).summary, + 'SECRETS_B2_HEADER_ORIGINAL_BOUNDARY', + ).toBe(expected.replace(/\s+/g, ' ')); + const embedded = `word${prefix}api-${split}key: opaque-Z29; public=${publicTail}`; + expect(redactSecretShapedText(embedded), 'SECRETS_B2_HEADER_ORIGINAL_BOUNDARY').toBe( + embedded, + ); + } + }, + ); + + it('bounds admitted-size failed finite header preparation', () => { + const fragment = 'wordx-\tapi-\nkey '; + const input = fragment.repeat(Math.floor((8 * 1024 * 1024 - 256) / fragment.length)); + const started = performance.now(); + const output = redactSecretShapedText(input); + expect(output.length, 'SECRETS_B2_HEADER_PREPARATION').toBe(input.length); + expect(output === input, 'SECRETS_B2_HEADER_PREPARATION').toBe(true); + expect(performance.now() - started, 'SECRETS_B2_HEADER_PREPARATION').toBeLessThan(5_000); + }); + it.each([ ['sk-proj-', '\n'], ['sk-proj-', '\t'], @@ -1012,11 +1052,14 @@ describe('PR95 B2 projection boundaries and admitted-scale controls', () => { `eyJ${'A'.repeat(10)}.${'B'.repeat(10)}.${'C'.repeat(6)}`, ]; for (const credential of credentials) { + expect(redactSecretShapedText(`${credential}; public=A`), 'SECRETS_B2_BOUNDED_PROBE').toBe( + '[redacted]; public=A', + ); for (const padding of [0, 63, 64, 65, 128]) { const raw = `${credential}${'D'.repeat(padding)}; public=A`; for (const separator of ['\n', '\t']) { const split = raw.slice(0, 1) + separator + raw.slice(1); - // The unchanged raw canonical matcher is the preservation oracle, not a second grammar. + // The raw canonical projection is the preservation oracle, not a second grammar. expect(redactSecretShapedText(split), 'SECRETS_B2_BOUNDED_PROBE').toBe( redactSecretShapedText(raw), ); @@ -1046,6 +1089,18 @@ describe('PR95 B2 projection boundaries and admitted-scale controls', () => { expect(performance.now() - started, 'SECRETS_B2_BOUNDED_PROBE').toBeLessThan(5_000); }); + it('bounds admitted-size raw hyphen-separated invalid JWT starts and masks a later whole key', () => { + const fragment = 'eyJAAAAAAAAAA-'; + const publicPrefix = fragment.repeat(Math.floor((8 * 1024 * 1024 - 256) / fragment.length)); + const input = `${publicPrefix}sk-proj-${'C'.repeat(32)}-${'D'.repeat(32)}; public=A`; + const expected = `${publicPrefix}[redacted]; public=A`; + const started = performance.now(); + const output = redactSecretShapedText(input); + expect(output.length, 'SECRETS_B2_RAW_BOUNDED_PROBE').toBe(expected.length); + expect(output === expected, 'SECRETS_B2_RAW_BOUNDED_PROBE').toBe(true); + expect(performance.now() - started, 'SECRETS_B2_RAW_BOUNDED_PROBE').toBeLessThan(5_000); + }); + it('preserves dense admitted-size public boundaries with bounded preparation', () => { const started = performance.now(); for (const fragment of ['a\n', 'a ']) { diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 08aaf092..119c94de 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -134,7 +134,8 @@ const SCHEME_CREDENTIAL = type SecretSpanVisitor = (start: number, end: number, replacement: string) => void; /** - * A failed JWT probe must not rescan an admitted-size token run at every original LF/TAB boundary. + * A failed JWT probe must not rescan an admitted-size token run at every original boundary, + * including LF/TAB joins and raw hyphen-separated words. * Probe the SAME canonical matcher with at most three dot-separated runs (JWT's maximum), keeping * the first 64 characters of each run. The largest minimum acceptance window in the literal is * 34 characters (AIza + 30); all other prefixes/minima fit, including JWT's 13/10/6 and ya29's two @@ -175,6 +176,7 @@ function joinedCredentialProbe(joined: string): (start: number) => string { * Select valid ORIGINAL starts before matching: a rejected embedded prefix cannot consume them. * Every prefix in the canonical literal starts with an ASCII letter; the prefilter only omits * impossible starts. It does not decide which family matches or where that credential ends. + * The views can also be the same raw text; only an actual joined view skips original LF/TAB. */ function visitJoinedStandaloneCredentials( original: string, @@ -182,11 +184,12 @@ function visitJoinedStandaloneCredentials( mask: SecretSpanVisitor, ): void { const preview = joinedCredentialProbe(joined); + const joining = original !== joined; let joinedOffset = 0; let matchedUntil = 0; for (let originalOffset = 0; originalOffset < original.length; originalOffset += 1) { const char = original[originalOffset]; - if (char === '\n' || char === '\t') continue; + if (joining && (char === '\n' || char === '\t')) continue; const start = joinedOffset++; if (start < matchedUntil || !/[A-Za-z]/.test(char ?? '')) continue; if (/\w/.test(original[originalOffset - 1] ?? '')) continue; @@ -200,6 +203,41 @@ function visitJoinedStandaloneCredentials( } } +function redactStandaloneCredentials(text: string): string { + const parts: string[] = []; + let copied = 0; + visitJoinedStandaloneCredentials(text, text, (start, end, replacement) => { + parts.push(text.slice(copied, start), replacement); + copied = end; + }); + if (parts.length === 0) return text; + parts.push(text.slice(copied)); + return parts.join(''); +} + +/** + * The finite header-label literal is the sole joined-header authority. Check ORIGINAL boundaries + * before matching, so an embedded x-api-key/x-goog-api-key cannot consume an inner api-key start. + * Labels are bounded; trailing whitespace cannot contain another label, so failed scans are linear. + */ +function visitJoinedHeaderLabels(original: string, joined: string, mask: SecretSpanVisitor): void { + const header = /(?:authorization|(?:x-)?api-key|x-goog-api-key)["']?\s*:/iy; + let joinedOffset = 0; + let matchedUntil = 0; + for (let originalOffset = 0; originalOffset < original.length; originalOffset += 1) { + const char = original[originalOffset]; + if (char === '\n' || char === '\t') continue; + const start = joinedOffset++; + if (start < matchedUntil || !/[A-Za-z]/.test(char ?? '')) continue; + if (/[A-Za-z0-9_-]/.test(original[originalOffset - 1] ?? '')) continue; + header.lastIndex = start; + const match = header.exec(joined); + if (match === null) continue; + matchedUntil = header.lastIndex; + mask(start, matchedUntil, match[0]); + } +} + /** * LF/TAB may split finite credential shapes. Map recognized spans back onto the original text; * ordinary spaces remain boundaries and public text is never globally normalized for identity. @@ -252,11 +290,7 @@ function redactSplitCredentials(text: string): string { return redactJoinedCredentialSpans( schemes, (joined, mask) => { - // Finite literal header names only. Ordinary spaces delimit diagnostic text and values. - const header = /(?:authorization|(?:x-)?api-key|x-goog-api-key)["']?\s*:/gi; - for (const match of joined.matchAll(header)) { - mask(match.index, match.index + match[0].length, match[0]); - } + visitJoinedHeaderLabels(schemes, joined, mask); }, /[A-Za-z0-9_-]/, ); @@ -275,51 +309,45 @@ function redactSplitCredentials(text: string): string { * no-backtracking-RegExp / no-ReDoS posture. */ export function redactSecretShapedText(text: string): string { - return ( - redactOpaqueQueryValues(redactUrlUserinfo(redactSplitCredentials(text))) - // A PEM private-key block (multi-line, space-separated markers the `private_key` key-pattern can't see). - // The body span is bounded (`{0,20000}?`, lazy) so an unterminated block can't drive an unbounded scan. - .replace( - /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----[\s\S]{0,20000}?-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----/g, - '[redacted]', - ) - // An `Authorization`-style scheme + its token: `Bearer ` / `Basic ` / `Token `. The token class - // `[\w.~+/-]` is base64url + padding (`\w` folds `[A-Za-z0-9_]`). - // For every open-ended token minimum below, a bounded lookahead enforces the same floor before - // one simple greedy run. V8 can exhaust its RegExp stack on an admitted-scale `{N,}` run; the - // equivalent `(?=class{N})class+` form keeps the whole value instead of clipping a credential. - .replace(SCHEME_CREDENTIAL, '$1 [redacted]') - // A secret-ish key (bounded wrappers keep this ReDoS-safe) + `=`/`:` + its value. The optional `["']?` - // BEFORE the separator catches the JSON `"access_token":"…"` shape (an OAuth/egress response body). The - // value has QUOTED and UNQUOTED branches. A quoted value ends at the first UNESCAPED matching quote, - // including JSON's escaped quotes/backslashes; otherwise a valid password such as `a\"secret-tail` - // left its tail exposed. The two alternatives inside each repetition are disjoint: a backslash - // consumes exactly the following non-newline character, while the ordinary class excludes it. - // No ambiguous nested repetition/backref is needed. Unquoted values retain their delimiter and - // minimum-length policy. Kept a single analyzable LITERAL (not composed via `new RegExp`) so Sonar's static - // super-linear-runtime (S5852) check still covers this security-critical pattern; its keyword-alternation - // breadth is deliberate (and `apikey` is dropped — `api[_-]?key` already subsumes it). - .replace( - /\b[\w-]{0,32}(?:password|passwd|secret|token|api[_-]?key|authorization|access[_-]?key|private[_-]?key|client[_-]?secret)[\w-]{0,16}["']?\s*[=:]\s*(?:"(?:[^"\\\r\n]|\\[^\r\n])*"|'(?:[^'\\\r\n]|\\[^\r\n])*'|(?=[^\s"',;&]{6})[^\s"',;&]+)/gi, - '[redacted]', - ) - // Well-known standalone credential shapes (OpenAI, Stripe, AWS incl. STS ASIA/ABIA, GitHub token + PAT, - // GitLab PAT, Slack, Google API + OAuth, HuggingFace, npm, JWT). Kept as ONE alternation ON PURPOSE: a - // single leftmost-longest scan is required so two ADJACENT / EMBEDDED credential shapes (e.g. a JWT whose - // signature segment contains an `sk-` run, or a `xox…-glpat-…` run) are covered by the outer greedy match - // as a single span. A per-family MULTI-PASS split is NOT equivalent — an earlier pass inserting `[redacted]` - // (a non-word `[`) truncates a later family's greedy match and can leave a trailing secret-shaped substring - // EXPOSED (a real redaction regression, pinned in bounding.test.ts). So this stays a single analyzable - // LITERAL and its Sonar per-regex complexity is a deliberate, documented exception — the same trade-off as - // the key=value pattern above: correctness of a security redactor + Sonar's static ReDoS (S5852) coverage - // outrank the metric. `\w` / `[\w-]` fold only the classes that are EXACTLY `[A-Za-z0-9_]` / `[A-Za-z0-9_-]`; - // the tighter `[A-Za-z0-9]` / `[A-Za-z0-9-]` families keep their narrower class (no `_`). - .replace( - // NOSONAR — the per-regex complexity is the deliberate, documented exception explained above - STANDALONE_CREDENTIAL, - '[redacted]', - ) - ); + const projected = redactOpaqueQueryValues(redactUrlUserinfo(redactSplitCredentials(text))) + // A PEM private-key block (multi-line, space-separated markers the `private_key` key-pattern can't see). + // The body span is bounded (`{0,20000}?`, lazy) so an unterminated block can't drive an unbounded scan. + .replace( + /-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY-----[\s\S]{0,20000}?-----END [A-Z0-9 ]{0,40}PRIVATE KEY-----/g, + '[redacted]', + ) + // An `Authorization`-style scheme + its token: `Bearer ` / `Basic ` / `Token `. The token class + // `[\w.~+/-]` is base64url + padding (`\w` folds `[A-Za-z0-9_]`). + // For every open-ended token minimum below, a bounded lookahead enforces the same floor before + // one simple greedy run. V8 can exhaust its RegExp stack on an admitted-scale `{N,}` run; the + // equivalent `(?=class{N})class+` form keeps the whole value instead of clipping a credential. + .replace(SCHEME_CREDENTIAL, '$1 [redacted]') + // A secret-ish key (bounded wrappers keep this ReDoS-safe) + `=`/`:` + its value. The optional `["']?` + // BEFORE the separator catches the JSON `"access_token":"…"` shape (an OAuth/egress response body). The + // value has QUOTED and UNQUOTED branches. A quoted value ends at the first UNESCAPED matching quote, + // including JSON's escaped quotes/backslashes; otherwise a valid password such as `a\"secret-tail` + // left its tail exposed. The two alternatives inside each repetition are disjoint: a backslash + // consumes exactly the following non-newline character, while the ordinary class excludes it. + // No ambiguous nested repetition/backref is needed. Unquoted values retain their delimiter and + // minimum-length policy. Kept a single analyzable LITERAL (not composed via `new RegExp`) so Sonar's static + // super-linear-runtime (S5852) check still covers this security-critical pattern; its keyword-alternation + // breadth is deliberate (and `apikey` is dropped — `api[_-]?key` already subsumes it). + .replace( + /\b[\w-]{0,32}(?:password|passwd|secret|token|api[_-]?key|authorization|access[_-]?key|private[_-]?key|client[_-]?secret)[\w-]{0,16}["']?\s*[=:]\s*(?:"(?:[^"\\\r\n]|\\[^\r\n])*"|'(?:[^'\\\r\n]|\\[^\r\n])*'|(?=[^\s"',;&]{6})[^\s"',;&]+)/gi, + '[redacted]', + ); + // Well-known standalone credential shapes (OpenAI, Stripe, AWS incl. STS ASIA/ABIA, GitHub token + PAT, + // GitLab PAT, Slack, Google API + OAuth, HuggingFace, npm, JWT). Kept as ONE alternation ON PURPOSE: a + // single leftmost-longest scan is required so two ADJACENT / EMBEDDED credential shapes (e.g. a JWT whose + // signature segment contains an `sk-` run, or a `xox…-glpat-…` run) are covered by the outer greedy match + // as a single span. A per-family MULTI-PASS split is NOT equivalent — an earlier pass inserting `[redacted]` + // (a non-word `[`) truncates a later family's greedy match and can leave a trailing secret-shaped substring + // EXPOSED (a real redaction regression, pinned in bounding.test.ts). So this stays a single analyzable + // LITERAL and its Sonar per-regex complexity is a deliberate, documented exception — the same trade-off as + // the key=value pattern above: correctness of a security redactor + Sonar's static ReDoS (S5852) coverage + // outrank the metric. `\w` / `[\w-]` fold only the classes that are EXACTLY `[A-Za-z0-9_]` / `[A-Za-z0-9_-]`; + // the tighter `[A-Za-z0-9]` / `[A-Za-z0-9-]` families keep their narrower class (no `_`). + return redactStandaloneCredentials(projected); } /** From d18c7f899897a6d6dde97883e1dd2ee5d2043eee Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 12:31:06 +0300 Subject: [PATCH 196/222] fix(llm): check original starts for every joined credential rule Use one original-boundary scanner for the finite prefix, header and URL rules so an embedded larger label cannot hide a valid inner header. Preserve public URL contacts and bounded failure preparation. Forward only matched text to function replacements while retaining string replacement semantics and strict overload narrowing; remove the duplicated branch reported by Sonar. Refs: ADR-0011, ADR-0080 Co-Authored-By: Codex --- packages/llm/src/scrub-secrets.test.ts | 40 ++++++++++++++ packages/llm/src/scrub-secrets.ts | 72 +++++++++----------------- 2 files changed, 64 insertions(+), 48 deletions(-) diff --git a/packages/llm/src/scrub-secrets.test.ts b/packages/llm/src/scrub-secrets.test.ts index 4e42946e..59c6d814 100644 --- a/packages/llm/src/scrub-secrets.test.ts +++ b/packages/llm/src/scrub-secrets.test.ts @@ -237,6 +237,46 @@ describe('PR95 B2 split provider/header and malformed userinfo diagnostics', () }); describe('PR95 B2 finite joined-view boundaries', () => { + it.each([ + ['x-', '\n', '\t'], + ['x-', '\t', '\n'], + ['x-goog-', '\n', '\t'], + ['x-goog-', '\t', '\n'], + ])( + 'recovers the original finite header boundary after embedded %s (%j)', + (prefix, boundary, split) => { + for (const publicTail of ['A', 'B']) { + const input = `word${prefix}${boundary}api-${split}key: opaque-Z29; public=${publicTail}`; + expect(scrubSecrets(input), 'SECRETS_B2_HEADER_ORIGINAL_BOUNDARY').toBe('[REDACTED]'); + const error = makeLlmError({ + provider: 'openai', + kind: 'auth', + message: input, + code: input, + }); + expect(error.message, 'SECRETS_B2_HEADER_ORIGINAL_BOUNDARY').toBe('[REDACTED]'); + expect(error.code, 'SECRETS_B2_HEADER_ORIGINAL_BOUNDARY').toBe('[REDACTED]'); + const embedded = `word${prefix}api-${split}key: opaque-Z29; public=${publicTail}`; + expect(scrubSecrets(embedded), 'SECRETS_B2_HEADER_ORIGINAL_BOUNDARY').toBe(embedded); + expect( + scrubSecrets(`api-key: opaque-Z29; public=${publicTail}`), + 'SECRETS_B2_HEADER_ORIGINAL_BOUNDARY', + ).toBe(`api-key: [REDACTED]; public=${publicTail}`); + } + }, + ); + + it('bounds admitted-size failed header and URL preparation', () => { + const started = performance.now(); + for (const fragment of ['wordx-\tapi-\nkey ', 'https:\n//provider.invalid/']) { + const input = fragment.repeat(Math.floor((8 * 1024 * 1024 - 256) / fragment.length)); + const output = scrubSecrets(input); + expect(output.length, 'SECRETS_B2_HEADER_PREPARATION').toBe(input.length); + expect(output === input, 'SECRETS_B2_HEADER_PREPARATION').toBe(true); + } + expect(performance.now() - started, 'SECRETS_B2_HEADER_PREPARATION').toBeLessThan(5_000); + }); + it.each([ ['sk-proj-', '\n'], ['sk-proj-', '\t'], diff --git a/packages/llm/src/scrub-secrets.ts b/packages/llm/src/scrub-secrets.ts index 6c4440a5..76b09b7a 100644 --- a/packages/llm/src/scrub-secrets.ts +++ b/packages/llm/src/scrub-secrets.ts @@ -24,8 +24,6 @@ interface SecretRule { readonly replacement: string | ((match: string) => string); readonly joinedPattern?: RegExp; readonly boundary?: RegExp; - /** A prefixed single token run: select original boundaries before its greedy joined match. */ - readonly joinedToken?: boolean; } /** One private authority per existing credential family, shared by probes and output masking. */ @@ -35,13 +33,11 @@ const NON_QUERY_SECRET_RULES: readonly SecretRule[] = [ pattern: /\bsk-(?=[\w-]{16})[\w-]+/g, replacement: '[REDACTED]', boundary: /\w/, - joinedToken: true, }, { pattern: /\bAIza(?=[\w-]{20})[\w-]+/g, replacement: '[REDACTED]', boundary: /\w/, - joinedToken: true, }, // Finite authority schemes, not URL validation. Raw ?/# before the first @ may belong // to a password; after an existing @ they begin public query/fragment text. @@ -95,19 +91,27 @@ function maskUrlUserinfo(match: string): string { } function applyRule(text: string, rule: SecretRule): string { - return typeof rule.replacement === 'string' - ? text.replace(rule.pattern, rule.replacement) - : text.replace(rule.pattern, rule.replacement); + const replacement = rule.replacement; + return typeof replacement === 'string' + ? text.replace(rule.pattern, replacement) + : text.replace(rule.pattern, (match) => replacement(match)); } /** - * Probe only eligible original starts, so an embedded public prefix cannot greedily hide a later - * LF/TAB start. These rules have one token run after a bounded prefix/minimum: failures are bounded, - * and successful matches advance both cursors over the whole run. No rejected full-run rescans. - * Their canonical prefixes start with ASCII letters; that prefilter skips only impossible starts. + * Probe LF/TAB joins at eligible ORIGINAL starts before a greedy match can hide a later boundary. + * All boundary-bearing rules use this scanner and their canonical matcher (or finite header-label + * override). Prefixes start with ASCII letters; that prefilter skips only impossible starts. + * Keys have one token run after a bounded prefix/minimum. Header labels are bounded and their + * trailing whitespace cannot contain another label. URL authority excludes slash, so a later URL + * prefix delimits a failed authority scan. Successful matches advance both cursors over their span; + * no failed candidate rescans an admitted-size run at every LF/TAB boundary. + * Opaque Bearer/Basic values and literal query names keep their established delimiter policy. */ -function hasSplitToken(text: string, joined: string, rule: SecretRule): boolean { - const pattern = new RegExp(rule.pattern.source.slice(2), rule.pattern.flags.replace('g', 'y')); +function hasSplitCredential(text: string, joined: string, rule: SecretRule): boolean { + if (rule.boundary === undefined) return false; + const canonical = rule.joinedPattern ?? rule.pattern; + const source = rule.joinedPattern === undefined ? canonical.source.slice(2) : canonical.source; + const pattern = new RegExp(source, canonical.flags.replace('g', 'y')); let originalOffset = 0; let joinedOffset = 0; while (originalOffset < text.length) { @@ -116,7 +120,7 @@ function hasSplitToken(text: string, joined: string, rule: SecretRule): boolean originalOffset += 1; continue; } - if (/[A-Za-z]/.test(char ?? '') && !rule.boundary?.test(text[originalOffset - 1] ?? '')) { + if (/[A-Za-z]/.test(char ?? '') && !rule.boundary.test(text[originalOffset - 1] ?? '')) { pattern.lastIndex = joinedOffset; const match = pattern.exec(joined); if (match !== null) { @@ -126,7 +130,12 @@ function hasSplitToken(text: string, joined: string, rule: SecretRule): boolean const next = text[originalOffset++]; if (next !== '\n' && next !== '\t') joinedOffset += 1; } - if (originalOffset - start > joinedOffset - joinedStart) return true; + if ( + originalOffset - start > joinedOffset - joinedStart && + // A malformed URL candidate may actually be a public contact query. + (typeof rule.replacement !== 'function' || rule.replacement(match[0]) !== match[0]) + ) + return true; continue; } } @@ -136,39 +145,6 @@ function hasSplitToken(text: string, joined: string, rule: SecretRule): boolean return false; } -/** - * Probe LF/TAB joins before any masking can destroy the prefix. Ordinary spaces remain boundaries. - * Check the left boundary against the original string: a join must neither erase an existing - * boundary nor invent a match inside a public word. Only finite prefix/header/URL families join; - * opaque Bearer/Basic values and literal query names keep their established delimiter policy. - */ -function hasSplitCredential(text: string, joined: string, rule: SecretRule): boolean { - if (rule.boundary === undefined) return false; - if (rule.joinedToken === true) return hasSplitToken(text, joined, rule); - const pattern = - rule.joinedPattern ?? new RegExp(rule.pattern.source.slice(2), rule.pattern.flags); - let originalOffset = 0; - let joinedOffset = 0; - function advance(target: number): number { - while (joinedOffset < target && originalOffset < text.length) { - const char = text[originalOffset++]; - if (char !== '\n' && char !== '\t') joinedOffset += 1; - } - return originalOffset; - } - for (const match of joined.matchAll(pattern)) { - let start = advance(match.index); - while (text[start] === '\n' || text[start] === '\t') start += 1; - const end = advance(match.index + match[0].length); - if (rule.boundary.test(text[start - 1] ?? '')) continue; - if (!/[\n\t]/.test(text.slice(start, end))) continue; - // A malformed URL candidate may actually be a public contact query. - if (typeof rule.replacement === 'function' && rule.replacement(match[0]) === match[0]) continue; - return true; - } - return false; -} - function scrubNonQuerySecrets(text: string): string { const normalized = normalizeSecretProbe(text); const joined = /[\n\t]/.test(normalized) ? normalized.replace(/[\n\t]+/g, '') : undefined; From 8773434c016636a9038a05fa1e6e30d9a93184a5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 12:31:26 +0300 Subject: [PATCH 197/222] test(repo): preserve nested header recovery evidence Keep all 53 preceding control identities and required witnesses. Add independent core and diagnostic nested-header recovery removals, update moved boundary anchors and verify complete 142-case core and 89-case scrubber workloads. Declare 55 controls and 318 witnesses without treating declarations, timeouts or older captures as current acceptance. Every removal retains bounded canonical JWT preparation. Refs: ADR-0080, ADR-0113 Co-Authored-By: Codex --- .github/workflows/ci.yml | 2 +- tools/causal-controls/provenance.test.mjs | 30 ++++++++-- tools/secrets-controls/README.md | 40 ++++++++++--- tools/secrets-controls/mutations.mjs | 71 ++++++++++++++++++++--- 4 files changed, 123 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23b19e8d..a9bcc7c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,7 +281,7 @@ jobs: secrets-mutations: name: Wave 2 secrets causal controls # The historical 35-control, three-phase capture exceeded the former aggregate budget. - # The current cohort is 53 controls (2026-10-18); this comment does not certify its execution. + # The current cohort is 55 controls (2026-10-18); this comment does not certify its execution. # Per-phase, case/hook, output and retained-evidence bounds remain unchanged. timeout-minutes: 30 runs-on: ubuntu-latest diff --git a/tools/causal-controls/provenance.test.mjs b/tools/causal-controls/provenance.test.mjs index 78615a95..f6d7743f 100644 --- a/tools/causal-controls/provenance.test.mjs +++ b/tools/causal-controls/provenance.test.mjs @@ -267,10 +267,10 @@ test('every declared filesystem and secrets anchor matches current production so }); test('secrets workload corrections retain independent complete-count and reader-witness pins', () => { - assert.equal(secretsControls.length, 53); + assert.equal(secretsControls.length, 55); assert.equal( secretsControls.reduce((count, control) => count + 1 + (control.witnesses ?? []).length, 0), - 310, + 318, ); for (const [id, file, expectedTests] of [ ['run-default-secret-reader', 'apps/cli/src/commands/run.test.ts', 84], @@ -308,14 +308,16 @@ test('secrets workload corrections retain independent complete-count and reader- 'core-split-header-label', 'core-raw-password-userinfo', 'core-joined-original-boundary-recovery', - ].map((id) => [id, ['packages/core/src/tools/bounding.test.ts'], 136]), + 'core-header-original-boundary-recovery', + ].map((id) => [id, ['packages/core/src/tools/bounding.test.ts'], 142]), ...[ 'diagnostic-joined-before-masking', 'diagnostic-joined-original-boundary', 'diagnostic-split-header-label', 'diagnostic-raw-password-userinfo', 'diagnostic-joined-original-boundary-recovery', - ].map((id) => [id, ['packages/llm/src/scrub-secrets.test.ts'], 84]), + 'diagnostic-header-original-boundary-recovery', + ].map((id) => [id, ['packages/llm/src/scrub-secrets.test.ts'], 89]), ]) { const selected = secretsControls.find((entry) => entry.id === id); assert.ok(selected, `${id}: missing redaction control`); @@ -340,6 +342,26 @@ test('secrets workload corrections retain independent complete-count and reader- ); assert.ok(recovery.witnesses.every(({ marker }) => marker === 'SECRETS_B2_ORIGINAL_BOUNDARY')); } + for (const id of [ + 'core-header-original-boundary-recovery', + 'diagnostic-header-original-boundary-recovery', + ]) { + const recovery = secretsControls.find((entry) => entry.id === id); + assert.ok(recovery, `${id}: missing original finite-header boundary recovery`); + assert.equal(recovery.marker, 'SECRETS_B2_HEADER_ORIGINAL_BOUNDARY'); + assert.deepEqual( + [recovery.target, ...recovery.witnesses.map((witness) => witness.target)], + ['x-', 'x-goog-'].flatMap((prefix) => + ['\n', '\t'].map( + (separator) => + `recovers the original finite header boundary after embedded ${prefix} (${JSON.stringify(separator)})`, + ), + ), + ); + assert.ok( + recovery.witnesses.every(({ marker }) => marker === 'SECRETS_B2_HEADER_ORIGINAL_BOUNDARY'), + ); + } const reader = secretsControls.find((entry) => entry.id === 'run-default-secret-reader'); assert.equal( reader.target, diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index 722fbabe..009868db 100644 --- a/tools/secrets-controls/README.md +++ b/tools/secrets-controls/README.md @@ -4,7 +4,7 @@ Step 4 first-run input matrix, Step 5 snapshot/export, Step 7 provider-header and PR #95 B2 redaction controls on physical source copies. `--mechanism ` supports diagnosis only; selected runs retain `complete: false`. The current manifest -declares **53 independent controls and 310 required named assertion witnesses**. +declares **55 independent controls and 318 required named assertion witnesses**. These counts describe the workload; a declaration is not evidence that its removal passed. Actual fixed/removal/restored results belong to the coordinator's summary. @@ -177,8 +177,8 @@ modern `sk-` body characters, joined-span application, the original-text left-bo check, split scheme labels, split API-header labels and raw `?`/`#` password recognition independently. Four LLM controls remove the before-masking LF/TAB probe, its original left-boundary check, split API-header recognition and raw-password recognition. -Every core phase executes the complete 136-case bounding file; every new LLM phase -executes the complete 84-case scrubber file. Independent ordinary tooling tests pin +Every core phase executes the complete 142-case bounding file; every new LLM phase +executes the complete 89-case scrubber file. Independent ordinary tooling tests pin these inventories, the 261-case overlap cohort, 163-case CLI diagnostic cohort and 17-case command-export cohort. Actual collection still has to match in every phase. @@ -195,20 +195,44 @@ machine-speed threshold; these controls do not certify arbitrary obfuscation. Two R1 original-boundary recovery controls add eight named assertions, retaining all 51 prior identities and required witnesses. The core removal admits embedded-word starts while retaining the mapper's late refusal and bounded probe; the rejected -candidate consumes the later valid start. The LLM removal restores the old -non-overlapping discovery for its bounded single-run token families. All four +candidate consumes the later valid start. The LLM removal replaces original-boundary checks with joined-boundary checks only +for the non-header rules in its unified scanner, preserving the finite-header path. All four OpenAI/Google × LF/TAB poisoned-prefix cases must then fail with `SECRETS_B2_ORIGINAL_BOUNDARY`; public embedded-word preservation and the later complete split credential are asserted together. The earlier core boundary control removes both scanner and mapper boundary checks as one policy; removing the late mapper check alone leaves the new scanner prefilter active. The LLM boundary control removes its actual early -token-boundary predicate. Their existing named witnesses remain required. These +original-boundary predicate. Their existing named witnesses remain required. These are declared controls; actual final-source fixed/removal/restored evidence remains separate from the older eleven selected captures and independent acceptance. -The current 53-control / 159-phase full declaration projects 961 retained files -(53 × 18 + 7) under the unchanged 2,000-file / 64-MiB export bounds. This is a capacity +Two R2 finite-header recovery controls add eight named assertions while preserving +all 53 prior control identities, paths, targets, markers and witness arrays. Four +`x-`/`x-goog-` × swapped LF/TAB witnesses per package require +`SECRETS_B2_HEADER_ORIGINAL_BOUNDARY`. The core removal admits an embedded outer +label but retains the mapper's late boundary refusal, reproducing consumption of +the valid inner label. The LLM removal substitutes joined-boundary checks only for +its finite-header rule; other rules still use original boundaries. Neither removal +disables bounded JWT probing or replaces canonical matcher authority. The earlier +split-header-label control now removes the header helper's actual mask delivery. +Core and LLM stress cases check bounded preparation with length/Boolean assertions, +so a regression cannot serialize an admitted-size value into failure metadata. + +A separately confirmed raw-path failed-JWT preparation defect is covered by a +permanent admitted-size raw-path regression. All declared removals retain the +bounded canonical visitor used by both raw and joined standalone-key passes. +Reverting that protection would make timeout an invalid substitute for semantic +failure; there is no declared raw-scanner timeout control or retrospective R2 +review claim for this later author correction. + +Earlier eleven-control author captures and the four selected `ce6df268` captures +retain their own source/workload attribution. Declaration maintenance and fresh +collection do not recertify them as current-source or full-55 execution. Actual +fixed/removal/restored evidence and independent acceptance remain separate. + +The current 55-control / 165-phase full declaration projects 997 retained files +(55 × 18 + 7) under the unchanged 2,000-file / 64-MiB export bounds. This is a capacity calculation, not a measured full capture. Historical 41-control captures retain their original source and cohort attribution; selected B2 or original-boundary recovery captures are not full-family acceptance. diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index 1e00bf2b..8a8f13b1 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -73,6 +73,13 @@ const originalBoundaryCases = ['sk-proj-', 'AIza'].flatMap((prefix) => `keeps the original boundary after an embedded greedy prefix ${prefix} (${JSON.stringify(separator)})`, ), ); +const headerOriginalBoundaryMarker = 'SECRETS_B2_HEADER_ORIGINAL_BOUNDARY'; +const headerOriginalBoundaryCases = ['x-', 'x-goog-'].flatMap((prefix) => + ['\n', '\t'].map( + (separator) => + `recovers the original finite header boundary after embedded ${prefix} (${JSON.stringify(separator)})`, + ), +); // Both scanner admission and mapper admission enforce one original-boundary law. Remove both // exact checks together; removing only the late mapper guard would leave its new prefilter intact. const coreOriginalBoundary = String.raw` if (/\w/.test(original[originalOffset - 1] ?? '')) continue; @@ -86,6 +93,41 @@ const coreOriginalBoundary = String.raw` if (/\w/.test(original[originalOffse } } +function redactStandaloneCredentials(text: string): string { + const parts: string[] = []; + let copied = 0; + visitJoinedStandaloneCredentials(text, text, (start, end, replacement) => { + parts.push(text.slice(copied, start), replacement); + copied = end; + }); + if (parts.length === 0) return text; + parts.push(text.slice(copied)); + return parts.join(''); +} + +/** + * The finite header-label literal is the sole joined-header authority. Check ORIGINAL boundaries + * before matching, so an embedded x-api-key/x-goog-api-key cannot consume an inner api-key start. + * Labels are bounded; trailing whitespace cannot contain another label, so failed scans are linear. + */ +function visitJoinedHeaderLabels(original: string, joined: string, mask: SecretSpanVisitor): void { + const header = /(?:authorization|(?:x-)?api-key|x-goog-api-key)["']?\s*:/iy; + let joinedOffset = 0; + let matchedUntil = 0; + for (let originalOffset = 0; originalOffset < original.length; originalOffset += 1) { + const char = original[originalOffset]; + if (char === '\n' || char === '\t') continue; + const start = joinedOffset++; + if (start < matchedUntil || !/[A-Za-z]/.test(char ?? '')) continue; + if (/[A-Za-z0-9_-]/.test(original[originalOffset - 1] ?? '')) continue; + header.lastIndex = start; + const match = header.exec(joined); + if (match === null) continue; + matchedUntil = header.lastIndex; + mask(start, matchedUntil, match[0]); + } +} + /** * LF/TAB may split finite credential shapes. Map recognized spans back onto the original text; * ordinary spaces remain boundaries and public text is never globally normalized for identity. @@ -125,7 +167,7 @@ function coreProjectionControl(id, before, after, targets, marker = coreProjecti before, after, coreProjectionCases, - 136, + 142, targets[0], marker, targets.slice(1).map((target) => ({ target, marker })), @@ -138,7 +180,7 @@ function diagnosticControl(id, before, after, targets, marker = diagnosticMarker before, after, ['packages/llm/src/scrub-secrets.test.ts'], - 84, + 89, targets[0], marker, targets.slice(1).map((target) => ({ target, marker })), @@ -838,8 +880,8 @@ export const mutations = [ ), coreProjectionControl( 'core-split-header-label', - ' mask(match.index, match.index + match[0].length, match[0]);', - ' void match;', + ' mask(start, matchedUntil, match[0]);', + ' void match;', splitCoreHeaders, ), coreProjectionControl( @@ -860,7 +902,7 @@ export const mutations = [ ), diagnosticControl( 'diagnostic-joined-original-boundary', - "if (/[A-Za-z]/.test(char ?? '') && !rule.boundary?.test(text[originalOffset - 1] ?? '')) {", + "if (/[A-Za-z]/.test(char ?? '') && !rule.boundary.test(text[originalOffset - 1] ?? '')) {", "if (/[A-Za-z]/.test(char ?? '')) {", ['does not invent a key boundary in a public word with a separate newline'], ), @@ -886,9 +928,24 @@ export const mutations = [ ), diagnosticControl( 'diagnostic-joined-original-boundary-recovery', - ' if (rule.joinedToken === true) return hasSplitToken(text, joined, rule);\n', - '', + "!rule.boundary.test(text[originalOffset - 1] ?? '')", + "!rule.boundary.test((rule.joinedPattern === undefined ? joined[joinedOffset - 1] : text[originalOffset - 1]) ?? '')", originalBoundaryCases, originalBoundaryMarker, ), + // R2-LN-F1: retain a valid inner header start after a rejected embedded outer label. + coreProjectionControl( + 'core-header-original-boundary-recovery', + " if (/[A-Za-z0-9_-]/.test(original[originalOffset - 1] ?? '')) continue;\n", + '', + headerOriginalBoundaryCases, + headerOriginalBoundaryMarker, + ), + diagnosticControl( + 'diagnostic-header-original-boundary-recovery', + "!rule.boundary.test(text[originalOffset - 1] ?? '')", + "!rule.boundary.test((rule.joinedPattern !== undefined ? joined[joinedOffset - 1] : text[originalOffset - 1]) ?? '')", + headerOriginalBoundaryCases, + headerOriginalBoundaryMarker, + ), ]; From 7e9096a828737727b2c0f0aaa67354d206e7a71d Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 12:31:26 +0300 Subject: [PATCH 198/222] docs(docs): record second review and bounded matching corrections Preserve source attribution for the complete nonclean review, independently inspected selected evidence and the separately reproduced raw JWT scaling failure. Keep fresh review, security and publication gates explicitly open. Refs: ADR-0080, ADR-0110, ADR-0113 Co-Authored-By: Codex --- .../phase-2.5.5-hardening-and-remediation.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index d7db9d5e..ff82ea9b 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8749,3 +8749,28 @@ collection confirms 25 complete workload files / 1,292 cases. These declarations and author checks are not final-source physical execution or independent acceptance. Two NEW complete cumulative clean rounds after the final material correction and the separate security/publication gates are still required. + +The second new cumulative round at `ce6df268` is also **COMPLETE NONCLEAN**: +98 corrective paths / 251 ordered hunks are assigned exactly once and their +body/hunk identities are independently reconciled. CLI and tooling/documentation +lanes are clean. The libraries/native lane reproduces an overlapping finite +header-label case: an invalid embedded `x-api-key` or `x-goog-api-key` candidate +can still hide a valid original `api-key` boundary. This is the same admitted +boundary class and remains a correction obligation, not a new accepted limitation. +All four selected boundary controls at that source have independently inspected +physical evidence: 12 phases / 1,320 executions / ten required witnesses / fifteen +marked removal failures, zero unhandled errors and exact restoration. Selected +captures remain incomplete-family evidence. + +The author separately reproduces quadratic work in the raw, non-joined standalone +JWT pass: manufactured invalid hyphen-separated candidates take approximately +5/17/66/255/994 ms at 8/16/32/64/128 KiB on the recorded host. An owned-child +8-MiB reproduction reaches its three-second parent watchdog. That timeout is a +reproduction limit, **not** acceptable causal-removal evidence. The correction +shares the existing bounded canonical scanner across raw and joined standalone +paths; finite header recognition checks original boundaries before matching. +The LLM boundary path is unified across its finite families. The duplicated +typed-replacement branch behind the source-bound Sonar reliability failure is +also corrected without waiving the issue. Fresh author checks, two NEW complete +cumulative clean rounds, a NEW separate security sitting and corrected-source +publication gates remain required after these material corrections. From 5d8b058eb9499dfaba999ba2343416b9a76ecd4a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 12:57:26 +0300 Subject: [PATCH 199/222] perf(llm): skip redundant work in finite credential probes Preserve the canonical family and original-boundary matchers while rejecting impossible ASCII starts numerically and reusing identical pure projections. The real Node 22.13 preparation exceeded its unchanged five-second bound; the same admitted-size workload now completes without relaxing that bound. Refs: ADR-0110, PR #95 Co-Authored-By: Codex --- packages/llm/src/scrub-secrets.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/llm/src/scrub-secrets.ts b/packages/llm/src/scrub-secrets.ts index 76b09b7a..81cc4e1c 100644 --- a/packages/llm/src/scrub-secrets.ts +++ b/packages/llm/src/scrub-secrets.ts @@ -120,7 +120,18 @@ function hasSplitCredential(text: string, joined: string, rule: SecretRule): boo originalOffset += 1; continue; } - if (/[A-Za-z]/.test(char ?? '') && !rule.boundary.test(text[originalOffset - 1] ?? '')) { + // Both finite boundary classes reject ASCII word characters. Skip those impossible + // starts without allocating/testing RegExp objects for every admitted-size character; + // the rule's own boundary and canonical matcher still decide every eligible start. + const code = text.charCodeAt(originalOffset); + const previous = text.charCodeAt(originalOffset - 1); + const letter = (code >= 65 && code <= 90) || (code >= 97 && code <= 122); + const previousWord = + (previous >= 48 && previous <= 57) || + (previous >= 65 && previous <= 90) || + (previous >= 97 && previous <= 122) || + previous === 95; + if (letter && !previousWord && !rule.boundary.test(text[originalOffset - 1] ?? '')) { pattern.lastIndex = joinedOffset; const match = pattern.exec(joined); if (match !== null) { @@ -150,8 +161,11 @@ function scrubNonQuerySecrets(text: string): string { const joined = /[\n\t]/.test(normalized) ? normalized.replace(/[\n\t]+/g, '') : undefined; for (const rule of NON_QUERY_SECRET_RULES) { const rawMasked = applyRule(text, rule); - const normalizedMasked = applyRule(normalized, rule); - if (normalizeSecretProbe(rawMasked) !== normalizedMasked) return '[REDACTED]'; + // Identical inputs have identical pure projections; unchanged raw text already has + // its normalized view. Reuse these results rather than rescan large public diagnostics. + const normalizedMasked = normalized === text ? rawMasked : applyRule(normalized, rule); + if ((rawMasked === text ? normalized : normalizeSecretProbe(rawMasked)) !== normalizedMasked) + return '[REDACTED]'; if (joined !== undefined && hasSplitCredential(normalized, joined, rule)) return '[REDACTED]'; // Retain the existing broader whitespace probe only within this rule's still-unmasked spans. // It detects incomplete prefixes separated by spaces without consuming prose after a full mask. From db71d5a6d05a395d900644110b13462b841205ec Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 12:57:26 +0300 Subject: [PATCH 200/222] fix(cli): preserve raw credential evidence before display formatting Run the shared raw and normalized finite secret projection before stripping terminal controls, then retain the CLI query residual policy and collapse one-line output last. This closes actual human and JSON diagnostic leaks. Pin both original-boundary and complete-fragment continuations, preserving the independent pre-clipping positive control for declared input reasons. Refs: ADR-0110, PR #95 Co-Authored-By: Codex --- .../input-admission-diagnostics.test.ts | 7 +++- apps/cli/src/process/render-error.test.ts | 35 +++++++++++++++++ apps/cli/src/render/sanitize.test.ts | 39 +++++++++++++++++++ apps/cli/src/render/sanitize.ts | 38 +++++++----------- 4 files changed, 92 insertions(+), 27 deletions(-) diff --git a/apps/cli/src/commands/input-admission-diagnostics.test.ts b/apps/cli/src/commands/input-admission-diagnostics.test.ts index 029b1c00..7fd46f6e 100644 --- a/apps/cli/src/commands/input-admission-diagnostics.test.ts +++ b/apps/cli/src/commands/input-admission-diagnostics.test.ts @@ -62,12 +62,15 @@ describe('declared input-admission diagnostics', () => { const message = inputAdmissionMessage([ { name: credential, message: 'value does not match the declared pattern' }, { name: 'declared', message: 'r'.repeat(249) + ' ' + splitCredential }, + { name: 'raw', message: 'r'.repeat(249) + ' ' + credential }, ]); expect(message).toContain("input '[REDACTED]': value does not match the declared pattern"); expect(message).not.toContain('sk-proj-'); expect(message).not.toContain('x'.repeat(20)); - // The complete credential spans the display limit, so truncating before redaction would leak it. - expect(message).toContain(`input 'declared': ${'r'.repeat(249)} [REDAC`); + // Normalization reveals a different finite match, so the original diagnostic is wholly masked. + expect(message).toContain("input 'declared': [REDACTED]"); + // A raw credential still crosses the display limit; clipping before redaction would leak it. + expect(message).toContain(`input 'raw': ${'r'.repeat(249)} [REDAC`); }); it('keeps terminal controls, bidi direction changes and extra rows out of a displayed reason', () => { diff --git a/apps/cli/src/process/render-error.test.ts b/apps/cli/src/process/render-error.test.ts index 24b790df..9fbdddd6 100644 --- a/apps/cli/src/process/render-error.test.ts +++ b/apps/cli/src/process/render-error.test.ts @@ -90,6 +90,41 @@ function messageFromJson(value: unknown): string { } describe('renderError terminal projection', () => { + it.each([ + { label: 'human', json: false, verbose: false }, + { label: 'human verbose', json: false, verbose: true }, + { label: 'JSON', json: true, verbose: false }, + { label: 'JSON verbose', json: true, verbose: true }, + ])('SECRETS_B2_DISPLAY_ORDER protects original credential evidence in $label', (options) => { + for (const prefix of ['sk-proj-', 'AIza']) { + for (const separator of ['\n', '\t', '\u0001', '\u007f', '\u0085', '\u202e']) { + const marker = + separator === '\n' || separator === '\t' + ? 'SECRETS_B2_DISPLAY_ORDER' + : 'SECRETS_B2_DISPLAY_BOUNDARY'; + const input = + separator === '\n' || separator === '\t' + ? `provider rejected ${prefix}${'A'.repeat(32)}${separator}${'B'.repeat(32)}; public=Q` + : `word${separator}${prefix}${'A'.repeat(32)}; public=Q`; + const error = new CliError('invalid_invocation', input); + Object.defineProperty(error, 'stack', { value: `outer stack ${input}` }); + const capture = captureIo(); + const stdout: string[] = []; + renderError(error, options, { ...capture.io, writeOut: (text) => stdout.push(text) }); + expect(stdout, marker).toEqual([]); + const output = capture.stderr(); + expect(output, marker).not.toContain('A'.repeat(32)); + expect(output, marker).not.toContain('B'.repeat(32)); + if (options.json) { + expect(messageFromJson(JSON.parse(output)), marker).toBe('[REDACTED]'); + expect(output).not.toContain('outer stack'); + } else { + expect(output, marker).toContain('relavium: [REDACTED]\n'); + } + } + } + }); + it.each([ { json: false, verbose: false }, { json: false, verbose: true }, diff --git a/apps/cli/src/render/sanitize.test.ts b/apps/cli/src/render/sanitize.test.ts index c0493148..cb267423 100644 --- a/apps/cli/src/render/sanitize.test.ts +++ b/apps/cli/src/render/sanitize.test.ts @@ -251,6 +251,45 @@ describe('sanitizeUntrusted — the failure paths carry arbitrary text (#W15-8)' }); }); +describe('PR95 B2 complete diagnostic evidence before display normalization', () => { + const variants = [ + { label: 'multiline', sanitize: sanitizeUntrusted }, + { label: 'inline', sanitize: sanitizeUntrustedInline }, + ]; + + it.each( + variants.flatMap((variant) => ['\n', '\t'].map((separator) => ({ ...variant, separator }))), + )( + 'SECRETS_B2_DISPLAY_ORDER protects a complete first fragment in $label with $separator', + ({ sanitize, separator }) => { + for (const prefix of ['sk-proj-', 'AIza']) { + const input = `provider rejected ${prefix}${'A'.repeat(32)}${separator}${'B'.repeat(32)}; public=Q`; + expect(sanitize(input), 'SECRETS_B2_DISPLAY_ORDER').toBe('[REDACTED]'); + expect(sanitize('provider rejected public-label; public=Q')).toBe( + 'provider rejected public-label; public=Q', + ); + } + }, + ); + + it.each( + variants.flatMap((variant) => + ['\u0001', '\u007f', '\u0085', '\u202e'].map((separator) => ({ ...variant, separator })), + ), + )( + 'SECRETS_B2_DISPLAY_BOUNDARY preserves the original boundary in $label with $separator', + ({ sanitize, separator }) => { + for (const prefix of ['sk-proj-', 'AIza']) { + const input = `word${separator}${prefix}${'A'.repeat(32)}; public=Q`; + expect(sanitize(input), 'SECRETS_B2_DISPLAY_BOUNDARY').toBe('[REDACTED]'); + expect(sanitize(`word${separator}public-label; public=Q`)).toBe( + 'wordpublic-label; public=Q', + ); + } + }, + ); +}); + /** * `--json` is machine output that is read on a terminal as often as it is piped (#W15-10). `JSON.stringify` * escapes `ESC` but leaves DEL, the C1 controls and the Trojan-Source bidi family raw, so the human path's diff --git a/apps/cli/src/render/sanitize.ts b/apps/cli/src/render/sanitize.ts index 61de430b..80fd1f66 100644 --- a/apps/cli/src/render/sanitize.ts +++ b/apps/cli/src/render/sanitize.ts @@ -29,45 +29,33 @@ export function sanitizeInline(text: string): string { * The sanitizer for text this process did NOT author — a rejection message, a provider response body, an MCP * error, a tool result, a stack trace. * - * Two passes, and the ORDER is load-bearing: **normalize first, redact second.** - * - * This was the other way round, on the theory that a credential split by a control byte would rejoin once that - * byte was removed — and the docblock admitted it could not construct a case where the order mattered. There - * is one, and it is a real leak: a key split by a byte `stripTerminalControls` REMOVES (say `U+0001`) defeats - * the pattern while the byte is still there, and then the strip rejoins the halves — printing the whole key. - * Redacting after the strip sees the contiguous key and replaces it. - * - * A NEWLINE is different: `sanitizeInline` collapses it to a SPACE rather than removing it, so the halves - * never become contiguous. The in-place pass cannot protect that suffix on its own; the whitespace-joined - * residual check below supplies that protection. Only the REMOVED bytes rejoin during normalization. - * - * `scrubSecrets` rather than the tool-input detector `redactSecretShapedText`: the two catch different things, - * and this path needs the KEY-PREFIX patterns (`sk-*`, `AIza*`, `Bearer`/`Basic` headers, URL userinfo, secret - * query params) that an error message actually carries. The tool detector is tuned for authored paths and - * commands and does not match a bare API key. + * Give the shared scrubber the ORIGINAL text before display normalization erases evidence. + * `scrubSecrets` independently checks raw and terminal-normalized views, including keys joined by + * removed controls and recognized LF/TAB continuations after a complete-looking first fragment. + * Stripping first can erase a legitimate original word boundary; collapsing LF/TAB first can expose + * the continuation after masking only the first fragment. Strip only after that complete projection, + * then apply the CLI's normalized/residual query-name policy before any one-line collapse. * * `sanitizeInline`/`stripTerminalControls` deliberately do NOT redact: they run on text this codebase composed, * where redaction would mangle legitimate output. This is the variant for the failure paths, where the content * is arbitrary and the cost of a false positive is a `[redacted]` in a diagnostic rather than a leaked key. */ export function sanitizeUntrusted(text: string): string { - return redactIncludingAcrossSeparators(stripTerminalControls(text)); + return redactIncludingAcrossSeparators(stripTerminalControls(scrubSecrets(text))); } -/** {@link sanitizeUntrusted}, collapsed to one row — for a one-line notice. Same normalize-then-redact order: - * `sanitizeInline` is the normalization (strip + collapse), so the scrub sees the final text. */ +/** {@link sanitizeUntrusted}, collapsed to one row only AFTER its complete secret projection. */ export function sanitizeUntrustedInline(text: string): string { - return redactIncludingAcrossSeparators(sanitizeInline(text)); + return sanitizeInline(sanitizeUntrusted(text)); } /** - * Redact `normalized`, INCLUDING a credential split across whitespace. + * Redact the display-normalized diagnostic, INCLUDING the CLI's residual split query-name policy. * * The ordinary pass replaces a contiguous match in place, which is what almost every case is. What it cannot - * see is a key broken by a newline or a tab: unlike the control bytes {@link stripTerminalControls} removes, - * those are display-significant, so normalization COLLAPSES them to a space rather than deleting them — and - * the halves never become contiguous for the matcher. The prefix (`sk-ant-…`) is public, so what leaked was - * the part that matters: the secret suffix. + * classify universally is arbitrary opaque fragments separated by whitespace. The shared scrubber owns + * its finite original/normalized/LF-TAB rules; this additional CLI check joins still-unmasked spans to + * recognize split literal query names without joining through already-redacted material. * * After every in-place pass, this checks the remaining unredacted segments — is there a credential once * their separators are gone? — and if so redacts the WHOLE text. A successful independent redaction must From daa00a1bf6658d19a1ec55666ce11ed7fc32ce22 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 12:57:27 +0300 Subject: [PATCH 201/222] test(repo): pin diagnostic display boundary regressions Retain every earlier causal control and named witness, update actual file inventories, and independently remove the inline and original-control ordering protections. Keep bounded scanners and ordinary case limits in all removals, including the CLI split-query residual control. Refs: ADR-0107, ADR-0110, PR #95 Co-Authored-By: Codex --- .github/workflows/ci.yml | 2 +- tools/causal-controls/provenance.test.mjs | 57 +++++++++++++++++-- tools/secrets-controls/README.md | 38 +++++++++++-- tools/secrets-controls/mutations.mjs | 68 ++++++++++++++++++++--- 4 files changed, 145 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9bcc7c6..4fc93a55 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,7 +281,7 @@ jobs: secrets-mutations: name: Wave 2 secrets causal controls # The historical 35-control, three-phase capture exceeded the former aggregate budget. - # The current cohort is 55 controls (2026-10-18); this comment does not certify its execution. + # The current cohort is 57 controls (2026-10-18); this comment does not certify its execution. # Per-phase, case/hook, output and retained-evidence bounds remain unchanged. timeout-minutes: 30 runs-on: ubuntu-latest diff --git a/tools/causal-controls/provenance.test.mjs b/tools/causal-controls/provenance.test.mjs index f6d7743f..1b969bc7 100644 --- a/tools/causal-controls/provenance.test.mjs +++ b/tools/causal-controls/provenance.test.mjs @@ -267,10 +267,10 @@ test('every declared filesystem and secrets anchor matches current production so }); test('secrets workload corrections retain independent complete-count and reader-witness pins', () => { - assert.equal(secretsControls.length, 55); + assert.equal(secretsControls.length, 57); assert.equal( secretsControls.reduce((count, control) => count + 1 + (control.witnesses ?? []).length, 0), - 318, + 336, ); for (const [id, file, expectedTests] of [ ['run-default-secret-reader', 'apps/cli/src/commands/run.test.ts', 84], @@ -287,7 +287,7 @@ test('secrets workload corrections retain independent complete-count and reader- [ 'diagnostic-residual-scrub', ['apps/cli/src/render/sanitize.test.ts', 'apps/cli/src/process/render-error.test.ts'], - 163, + 179, ], ...['prefix-before-redaction', 'normalized-secret-probe', 'non-query-before-query'].map( (id) => [ @@ -297,7 +297,7 @@ test('secrets workload corrections retain independent complete-count and reader- 'apps/cli/src/render/sanitize.test.ts', 'apps/cli/src/process/render-error.test.ts', ], - 261, + 277, ], ), ...[ @@ -318,12 +318,61 @@ test('secrets workload corrections retain independent complete-count and reader- 'diagnostic-joined-original-boundary-recovery', 'diagnostic-header-original-boundary-recovery', ].map((id) => [id, ['packages/llm/src/scrub-secrets.test.ts'], 89]), + ...['diagnostic-inline-projection-order', 'diagnostic-original-control-boundary'].map((id) => [ + id, + ['apps/cli/src/render/sanitize.test.ts', 'apps/cli/src/process/render-error.test.ts'], + 179, + ]), ]) { const selected = secretsControls.find((entry) => entry.id === id); assert.ok(selected, `${id}: missing redaction control`); assert.deepEqual(selected.files, files); assert.equal(selected.expectedTests, expectedTests, `${id}: complete workload drift`); } + const llmBoundary = secretsControls.find( + (entry) => entry.id === 'diagnostic-joined-original-boundary', + ); + assert.ok(llmBoundary); + assert.ok(llmBoundary.before.includes('!previousWord && !rule.boundary.test(')); + assert.equal(llmBoundary.after, 'if (letter) {'); + const cliResidual = secretsControls.find((entry) => entry.id === 'diagnostic-residual-scrub'); + assert.ok(cliResidual); + assert.equal(cliResidual.before, " for (const remaining of scrubbed.split('[REDACTED]')) {"); + assert.equal(cliResidual.after, ' for (const remaining of []) {'); + for (const [id, marker, sanitizerTargets] of [ + [ + 'diagnostic-inline-projection-order', + 'SECRETS_B2_DISPLAY_ORDER', + ['\\n', '\\t'].map( + (separator) => + `SECRETS_B2_DISPLAY_ORDER protects a complete first fragment in 'inline' with '${separator}'`, + ), + ], + [ + 'diagnostic-original-control-boundary', + 'SECRETS_B2_DISPLAY_BOUNDARY', + ['multiline', 'inline'].flatMap((label) => + ['\\u0001', '\\u007f', '\\u0085', '\\u202e'].map( + (separator) => + `SECRETS_B2_DISPLAY_BOUNDARY preserves the original boundary in '${label}' with '${separator}'`, + ), + ), + ], + ]) { + const display = secretsControls.find((entry) => entry.id === id); + assert.ok(display, `${id}: missing display projection control`); + assert.equal(display.marker, marker); + assert.deepEqual( + [display.target, ...display.witnesses.map((witness) => witness.target)], + [ + ...sanitizerTargets, + ...['human', 'human verbose', 'JSON', 'JSON verbose'].map( + (label) => `SECRETS_B2_DISPLAY_ORDER protects original credential evidence in '${label}'`, + ), + ], + ); + assert.ok(display.witnesses.every((witness) => witness.marker === marker)); + } for (const id of [ 'core-joined-original-boundary-recovery', 'diagnostic-joined-original-boundary-recovery', diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index 009868db..d66af3e0 100644 --- a/tools/secrets-controls/README.md +++ b/tools/secrets-controls/README.md @@ -4,7 +4,7 @@ Step 4 first-run input matrix, Step 5 snapshot/export, Step 7 provider-header and PR #95 B2 redaction controls on physical source copies. `--mechanism ` supports diagnosis only; selected runs retain `complete: false`. The current manifest -declares **55 independent controls and 318 required named assertion witnesses**. +declares **57 independent controls and 336 required named assertion witnesses**. These counts describe the workload; a declaration is not evidence that its removal passed. Actual fixed/removal/restored results belong to the coordinator's summary. @@ -17,7 +17,7 @@ including exact-cap, Unicode/whitespace and benign query-name positive cases. Th requires all three aggregate and all three single-chunk witnesses. Source-error normalization requires all nine declared ownership boundaries. Each native operation requires both constructor and method witnesses with an opaque Error. -The diagnostic residual control restores the unsafe early return after an in-place match +The diagnostic residual control skips the CLI's still-unmasked-span residual scan and requires all 21 named completed assertion witnesses: both sanitizer functions for each of the eight query families, human/JSON diagnostics in normal/verbose mode and an isolated verbose-stack case with a benign message. Each pairs a newly @@ -48,7 +48,7 @@ other replacement erases its evidence, followed by the finite query replacement. The views include the canonical terminal-normalized text and a whitespace-joined residual view. The split literal query name remains an independent CLI obligation. Across the thirteen Step 3 controls, 191 named assertion witnesses are required. The shared -overlap controls collect 261 tests across the LLM, CLI sanitizer and renderer files. +overlap controls collect 277 tests across the LLM, CLI sanitizer and renderer files. The B2 controls below pin the conservative LF/TAB continuation rule when a first fragment already meets a key-length floor. Broader whitespace residual checks retain their finite scope; neither mechanism claims universal secret detection. @@ -179,7 +179,7 @@ independently. Four LLM controls remove the before-masking LF/TAB probe, its ori left-boundary check, split API-header recognition and raw-password recognition. Every core phase executes the complete 142-case bounding file; every new LLM phase executes the complete 89-case scrubber file. Independent ordinary tooling tests pin -these inventories, the 261-case overlap cohort, 163-case CLI diagnostic cohort and +these inventories, the 277-case overlap cohort, 179-case CLI diagnostic cohort and 17-case command-export cohort. Actual collection still has to match in every phase. The boundary controls preserve an embedded public-word key-shaped run containing an @@ -231,8 +231,34 @@ retain their own source/workload attribution. Declaration maintenance and fresh collection do not recertify them as current-source or full-55 execution. Actual fixed/removal/restored evidence and independent acceptance remain separate. -The current 55-control / 165-phase full declaration projects 997 retained files -(55 × 18 + 7) under the unchanged 2,000-file / 64-MiB export bounds. This is a capacity +Two R3 display-projection controls add eighteen named assertion witnesses while +retaining all 55 prior identities, targets, markers and witness arrays. The +`diagnostic-inline-projection-order` removal collapses LF/TAB before the complete +untrusted-text projection. Its two inline sanitizer cases and four actual +human/JSON normal/verbose renderer cases must fail `SECRETS_B2_DISPLAY_ORDER`. +The independent `diagnostic-original-control-boundary` removal strips terminal +controls before the initial shared scrubber can observe their original credential +boundary. Eight multiline/inline sanitizer cases cover removed C0, DEL, C1 and +bidi units; the same four real renderer cases must fail their distinct +`SECRETS_B2_DISPLAY_BOUNDARY` assertion. Both phases execute the complete 179-case +sanitizer/renderer cohort. The earlier CLI residual-query control remains +independent and retains its required witnesses. Its removal now skips the actual +remaining-span loop: the new outer raw scrubber can already mask ordinary matches, +so the former conditional early-return mutation would leave this policy active. + +The LLM original-boundary removal now removes both the numeric coarse +previous-word prefilter and the rule's own boundary predicate; retaining either +would leave the tested policy active. Header/key recovery removals still change +their original-rule boundary only, and all removals retain the same canonical +matchers and bounded raw/joined standalone visitors. Current collection is 25 +complete files / 1,319 cases, with each control's exact workload and named target +checked separately. These are updated declarations and author checks, not new +physical causal captures or a clean review round. R3 remains NONCLEAN until its +corrections receive new independent acceptance; prior selected `7e9096a8` captures +retain their older source/workload attribution. + +The current 57-control / 171-phase full declaration projects 1,033 retained files +(57 × 18 + 7) under the unchanged 2,000-file / 64-MiB export bounds. This is a capacity calculation, not a measured full capture. Historical 41-control captures retain their original source and cohort attribution; selected B2 or original-boundary recovery captures are not full-family acceptance. diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index 8a8f13b1..2f605748 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -80,6 +80,28 @@ const headerOriginalBoundaryCases = ['x-', 'x-goog-'].flatMap((prefix) => `recovers the original finite header boundary after embedded ${prefix} (${JSON.stringify(separator)})`, ), ); +const displayProjectionCases = [ + 'apps/cli/src/render/sanitize.test.ts', + 'apps/cli/src/process/render-error.test.ts', +]; +const displayOrderMarker = 'SECRETS_B2_DISPLAY_ORDER'; +const displayBoundaryMarker = 'SECRETS_B2_DISPLAY_BOUNDARY'; +const displayOrderCases = ['\\n', '\\t'].map( + (separator) => + `${displayOrderMarker} protects a complete first fragment in 'inline' with '${separator}'`, +); +const displayBoundaryCases = ['multiline', 'inline'].flatMap((label) => + ['\\u0001', '\\u007f', '\\u0085', '\\u202e'].map( + (separator) => + `${displayBoundaryMarker} preserves the original boundary in '${label}' with '${separator}'`, + ), +); +function displayRendererWitnesses(marker) { + return ['human', 'human verbose', 'JSON', 'JSON verbose'].map((label) => ({ + target: `${displayOrderMarker} protects original credential evidence in '${label}'`, + marker, + })); +} // Both scanner admission and mapper admission enforce one original-boundary law. Remove both // exact checks together; removing only the late mapper guard would leave its new prefilter intact. const coreOriginalBoundary = String.raw` if (/\w/.test(original[originalOffset - 1] ?? '')) continue; @@ -298,11 +320,10 @@ export const mutations = [ control( 'diagnostic-residual-scrub', 'apps/cli/src/render/sanitize.ts', - ' const scrubbed = scrubSecrets(normalized);', - ` const scrubbed = scrubSecrets(normalized); - if (scrubbed !== normalized) return scrubbed;`, + " for (const remaining of scrubbed.split('[REDACTED]')) {", + ' for (const remaining of []) {', ['apps/cli/src/render/sanitize.test.ts', 'apps/cli/src/process/render-error.test.ts'], - 163, + 179, "SECRETS_CAUSAL_RESIDUAL_SCRUB protects 'client_secret' beside a split credential: 'multiline'", 'SECRETS_CAUSAL_RESIDUAL_SCRUB', [ @@ -344,7 +365,7 @@ export const mutations = [ 'apps/cli/src/render/sanitize.test.ts', 'apps/cli/src/process/render-error.test.ts', ], - 261, + 277, 'SECRETS_CAUSAL_PREFIX_ORDER protects client_secret before overlapping query redaction', 'SECRETS_CAUSAL_PREFIX_ORDER', [ @@ -393,7 +414,7 @@ export const mutations = [ ' return stripTerminalControls(text);', ' return text;', overlapFiles, - 261, + 277, `${overlapMarker} protects client_secret normalized key before query redaction`, overlapMarker, overlapWitnesses('normalized key').filter( @@ -408,7 +429,7 @@ export const mutations = [ ' return scrubQuerySecrets(scrubNonQuerySecrets(text));', ' return scrubNonQuerySecrets(scrubQuerySecrets(text));', overlapFiles, - 261, + 277, `${overlapMarker} protects client_secret Bearer before query redaction`, overlapMarker, [ @@ -902,8 +923,8 @@ export const mutations = [ ), diagnosticControl( 'diagnostic-joined-original-boundary', - "if (/[A-Za-z]/.test(char ?? '') && !rule.boundary.test(text[originalOffset - 1] ?? '')) {", - "if (/[A-Za-z]/.test(char ?? '')) {", + "if (letter && !previousWord && !rule.boundary.test(text[originalOffset - 1] ?? '')) {", + 'if (letter) {', ['does not invent a key boundary in a public word with a separate newline'], ), diagnosticControl( @@ -948,4 +969,33 @@ export const mutations = [ headerOriginalBoundaryCases, headerOriginalBoundaryMarker, ), + // R3: retain original credential evidence before independent inline/terminal display transforms. + control( + 'diagnostic-inline-projection-order', + 'apps/cli/src/render/sanitize.ts', + ' return sanitizeInline(sanitizeUntrusted(text));', + ' return sanitizeUntrusted(sanitizeInline(text));', + displayProjectionCases, + 179, + displayOrderCases[0], + displayOrderMarker, + [ + ...displayOrderCases.slice(1).map((target) => ({ target, marker: displayOrderMarker })), + ...displayRendererWitnesses(displayOrderMarker), + ], + ), + control( + 'diagnostic-original-control-boundary', + 'apps/cli/src/render/sanitize.ts', + ' return redactIncludingAcrossSeparators(stripTerminalControls(scrubSecrets(text)));', + ' return redactIncludingAcrossSeparators(stripTerminalControls(text));', + displayProjectionCases, + 179, + displayBoundaryCases[0], + displayBoundaryMarker, + [ + ...displayBoundaryCases.slice(1).map((target) => ({ target, marker: displayBoundaryMarker })), + ...displayRendererWitnesses(displayBoundaryMarker), + ], + ), ]; From 82ab41cb9bf6cef40831ec861f7c3ce7fc5e0ab0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 12:57:27 +0300 Subject: [PATCH 202/222] docs(docs): retain third review findings and corrected author evidence Record the complete nonclean source scope, actual diagnostic disclosures and Node-floor preparation failure without promoting author corrections or older green gates into new independent acceptance. Refs: PR #95, ADR-0110 Co-Authored-By: Codex --- .../phase-2.5.5-hardening-and-remediation.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index ff82ea9b..5e242244 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8774,3 +8774,29 @@ typed-replacement branch behind the source-bound Sonar reliability failure is also corrected without waiving the issue. Fresh author checks, two NEW complete cumulative clean rounds, a NEW separate security sitting and corrected-source publication gates remain required after these material corrections. + +The third new cumulative round at `7e9096a8` is **COMPLETE NONCLEAN**: +all 98 corrective paths / 249 ordered hunks have independently reconciled body +and hunk identities. Two independent lanes reproduce a diagnostic boundary gap: +CLI one-line normalization can expose a recognized LF/TAB continuation, and +terminal stripping can erase a legitimate original credential boundary. Actual +sanitizer, human/JSON renderer and `run` error routes reproduce the disclosure. +The correction gives the original diagnostic to the shared finite projection +before terminal stripping, retains the CLI's normalized/residual query policy, +and collapses to one line last. Twelve sanitizer cases and four real renderer +modes pin both boundaries, public preservation and JSON stdout purity. An initial +author preparation breaks one existing normalized query-name positive control; +that failure is retained and corrected before publication. + +A separate source-bound Node 22.13 CI job at that head exceeds the unchanged +five-second admitted-size header/URL preparation bound (7,573 ms). Numerical +rejection of universally impossible ASCII starts and reuse of identical pure +projections remove redundant work without changing the canonical matchers, +workload or bound. Author execution on the actual official Node 22.13.0 runtime +passes 187 focused cases; the preparation takes 1,413 ms. The four current focused +files pass 366 cases, and all 22 independent disclosure expectations pass after +correction. The earlier head's ordinary coverage passes 748 files / 15,953 tests / +22 established skips; its SonarCloud gate is green. Neither accepts these newer +corrections. Two NEW complete cumulative clean rounds, a NEW separate security +sitting and corrected-source publication evidence remain required. Steps 9–19 +remain unstarted; no merge, release or whole-wave acceptance is claimed. From aec2d79373f4c98ce1caff4c66898fe5bf9083c6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 13:18:05 +0300 Subject: [PATCH 203/222] ci(repo): isolate compatibility test file workers The exact Node floor job already serializes workspaces, but file workers still contend for the unchanged admitted-size preparation bound. Pass the one-worker limit to every existing test script and pin that forwarding in the scheduling guard. Preserve all cases, phase bounds, cache isolation, explicit native concurrency and ordinary required-CI workers. Refs: PR #95, ADR-0111 Co-Authored-By: Codex --- .github/workflows/ci.yml | 6 +++++- tools/ci/workspace-scheduling.test.mjs | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fc93a55..14531698 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -432,9 +432,13 @@ jobs: # `--force` = no cache READ (genuine Node-22 run, not a Node-24 replay); the job-level blanked # TURBO_TOKEN/TURBO_TEAM above = no cache WRITE (so `ci` never replays this Node-22 result). Both are # needed — see the job-level comment. + # In this compatibility job, serialize file workers as well as workspaces + # so unrelated files cannot consume the admitted-size cases' unchanged elapsed-time budget. + # Every case still runs, including its explicit native concurrency. Required CI retains its + # ordinary file workers; this job verifies the exact older runtime rather than cross-file load. run: | pnpm turbo run typecheck --force - pnpm turbo run test --concurrency=1 --force + pnpm turbo run test --concurrency=1 --force -- --maxWorkers=1 pnpm turbo run build --force # CI-only strict peer-dependency gate (.npmrc keeps this OFF for local installs so fresh diff --git a/tools/ci/workspace-scheduling.test.mjs b/tools/ci/workspace-scheduling.test.mjs index ef0518fc..97a81ecf 100644 --- a/tools/ci/workspace-scheduling.test.mjs +++ b/tools/ci/workspace-scheduling.test.mjs @@ -151,7 +151,7 @@ test('required and local CI share all workspace tasks and uncached per-invocatio ); assert.deepEqual(floorCommand.run.trim().split('\n'), [ 'pnpm turbo run typecheck --force', - 'pnpm turbo run test --concurrency=1 --force', + 'pnpm turbo run test --concurrency=1 --force -- --maxWorkers=1', 'pnpm turbo run build --force', ]); assert.equal(floorCommand['continue-on-error'], undefined); From 4efb42c08811d369a6a77db69804765799fc0a34 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 13:41:42 +0300 Subject: [PATCH 204/222] fix(cli): seal default-write recovery after fatal shutdown Recheck the permanent Home seal before publishing a failed or successful default-write result. Pin the actual native writer path with a post-seal subscriber witness while preserving the live classified-refusal hint and original finite exit. Refs: PR #95, ADR-0110, ADR-0103 Co-Authored-By: Codex --- apps/cli/src/home/drive-home.test.ts | 9 +++++++++ apps/cli/src/render/tui/home-controller.ts | 3 +++ 2 files changed, 12 insertions(+) diff --git a/apps/cli/src/home/drive-home.test.ts b/apps/cli/src/home/drive-home.test.ts index 848e3cd3..1f794e50 100644 --- a/apps/cli/src/home/drive-home.test.ts +++ b/apps/cli/src/home/drive-home.test.ts @@ -724,6 +724,11 @@ describe('driveHome (2.5.B / ADR-0054)', () => { props.controller.handleKey('', ENTER); await flush(); expect(props.controller.getSnapshot().modelPicker?.entries.length).toBeGreaterThan(0); + const postSealHints: Array = []; + const unsubscribe = props.controller.subscribe(() => { + if (props.inputEnabled?.() === false) + postSealHints.push(props.controller.getSnapshot().modelPicker?.hint); + }); if (mode === 'unexpected') { // This native entry is before the real writer's filesystem catch; no errno or arbitrary // error message is granted configuration-refusal provenance. @@ -742,7 +747,11 @@ describe('driveHome (2.5.B / ADR-0054)', () => { ).toBe(9); expect(f.registry.budget.deadline()).toBe(3000); expect(await drive).toBe(9); + unsubscribe(); + expect(postSealHints, 'HOME_DEFAULT_WRITE_NO_POSTSEAL_PUBLICATION').toEqual([]); } else { + unsubscribe(); + expect(postSealHints).toEqual([]); expect(props.controller.getSnapshot().modelPicker?.hint).toContain('could not save'); expect(f.registry.termination).toBeUndefined(); props.controller.handleKey('', { escape: true }); diff --git a/apps/cli/src/render/tui/home-controller.ts b/apps/cli/src/render/tui/home-controller.ts index 3119e17c..0283c12b 100644 --- a/apps/cli/src/render/tui/home-controller.ts +++ b/apps/cli/src/render/tui/home-controller.ts @@ -830,6 +830,8 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { try { port.writeDefault(modelId, provider, reasoningEffort); } catch { + // An owned unexpected writer fault may have synchronously sealed Home before rethrowing. + if (exiting) return; // A generic save-failure hint — the actual write target may be a `--config` override, not the canonical // `~/.relavium/config.toml`, so don't name a path the user may not be using. const open = state.modelPicker; @@ -843,6 +845,7 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { } return; } + if (exiting) return; set({ modelPicker: undefined, notice: defaultWriteNotice(port.currentDefault(), modelId, displayName, reasoningEffort), From 498103cb8e8628bfd487d676f9f854025cee954b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 13:47:16 +0300 Subject: [PATCH 205/222] perf(repo): reduce credential preparation without narrowing its evidence Enumerate original-boundary candidates and separator runs natively, retaining the canonical standalone matcher and bounded JWT preview. Derive the diagnostic absence preflight from its existing canonical rule. Retarget boundary controls to their actual guards and name the unchanged stored-header message assertion on accessor-backed native errors. Refs: PR #95, ADR-0080, ADR-0110 Co-Authored-By: Codex --- .../cli/src/commands/provider-headers.test.ts | 6 +- packages/core/src/tools/bounding.ts | 69 +++++++++++------ packages/llm/src/scrub-secrets.ts | 4 + tools/secrets-controls/README.md | 17 ++++- tools/secrets-controls/mutations.mjs | 76 +++++++++++++------ 5 files changed, 122 insertions(+), 50 deletions(-) diff --git a/apps/cli/src/commands/provider-headers.test.ts b/apps/cli/src/commands/provider-headers.test.ts index 49f2adef..fce51ac0 100644 --- a/apps/cli/src/commands/provider-headers.test.ts +++ b/apps/cli/src/commands/provider-headers.test.ts @@ -304,7 +304,11 @@ describe('ADR-0108 provider-header command projections', () => { assertNoLegacy(rendered.err(), 'FS_CAUSAL_PROVIDER_HEADER_DIAGNOSTIC'); expect(isProviderHeaderPolicyError(error)).toBe(policy); expect(error.name).toBe(policy ? 'ProviderHeaderPolicyError' : 'TypeError'); - expect(error.message).toBe(policy ? POLICY_MESSAGE : CORRUPT_MESSAGE); + // Pin the store boundary even when native Error.stack is an accessor and the + // safe fixed renderer correctly declines to read it. Both boundaries must hold. + expect(error.message, 'FS_CAUSAL_PROVIDER_HEADER_DIAGNOSTIC').toBe( + policy ? POLICY_MESSAGE : CORRUPT_MESSAGE, + ); expect(Object.hasOwn(error, 'cause')).toBe(false); const ownProperties = Object.getOwnPropertyNames(error).map((key) => { const value: unknown = Object.getOwnPropertyDescriptor(error, key)?.value; diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 119c94de..a6fd014c 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -184,15 +184,23 @@ function visitJoinedStandaloneCredentials( mask: SecretSpanVisitor, ): void { const preview = joinedCredentialProbe(joined); - const joining = original !== joined; - let joinedOffset = 0; + const candidates = /(^|[^\w])([A-Za-z])/g; + const separators = /[\n\t]+/g; + let next = original === joined ? null : separators.exec(original); + let removed = 0; let matchedUntil = 0; - for (let originalOffset = 0; originalOffset < original.length; originalOffset += 1) { - const char = original[originalOffset]; - if (joining && (char === '\n' || char === '\t')) continue; - const start = joinedOffset++; - if (start < matchedUntil || !/[A-Za-z]/.test(char ?? '')) continue; - if (/\w/.test(original[originalOffset - 1] ?? '')) continue; + for ( + let candidate = candidates.exec(original); + candidate !== null; + candidate = candidates.exec(original) + ) { + const originalStart = candidate.index + candidate[0].length - 1; + while (next !== null && next.index < originalStart) { + removed += next[0].length; + next = separators.exec(original); + } + const start = originalStart - removed; + if (start < matchedUntil) continue; JOINED_STANDALONE_CREDENTIAL.lastIndex = 0; if (!JOINED_STANDALONE_CREDENTIAL.test(preview(start))) continue; JOINED_STANDALONE_CREDENTIAL.lastIndex = start; @@ -221,15 +229,25 @@ function redactStandaloneCredentials(text: string): string { * Labels are bounded; trailing whitespace cannot contain another label, so failed scans are linear. */ function visitJoinedHeaderLabels(original: string, joined: string, mask: SecretSpanVisitor): void { + if (!joined.includes(':')) return; const header = /(?:authorization|(?:x-)?api-key|x-goog-api-key)["']?\s*:/iy; - let joinedOffset = 0; + const candidates = /(^|[^A-Za-z0-9_-])([A-Za-z])/g; + const separators = /[\n\t]+/g; + let next = separators.exec(original); + let removed = 0; let matchedUntil = 0; - for (let originalOffset = 0; originalOffset < original.length; originalOffset += 1) { - const char = original[originalOffset]; - if (char === '\n' || char === '\t') continue; - const start = joinedOffset++; - if (start < matchedUntil || !/[A-Za-z]/.test(char ?? '')) continue; - if (/[A-Za-z0-9_-]/.test(original[originalOffset - 1] ?? '')) continue; + for ( + let candidate = candidates.exec(original); + candidate !== null; + candidate = candidates.exec(original) + ) { + const originalStart = candidate.index + candidate[0].length - 1; + while (next !== null && next.index < originalStart) { + removed += next[0].length; + next = separators.exec(original); + } + const start = originalStart - removed; + if (start < matchedUntil) continue; header.lastIndex = start; const match = header.exec(joined); if (match === null) continue; @@ -252,19 +270,22 @@ function redactJoinedCredentialSpans( if (!/[\n\t]/.test(text)) return text; const joined = text.replace(/[\n\t]+/g, ''); const parts: string[] = []; - let originalOffset = 0; - let joinedOffset = 0; + const separators = /[\n\t]+/g; + let next = separators.exec(text); + let removed = 0; let copied = 0; - function advance(target: number): number { - while (joinedOffset < target && originalOffset < text.length) { - const char = text[originalOffset++]; - if (char !== '\n' && char !== '\t') joinedOffset += 1; + function advance(target: number, includeAtBoundary = false): number { + while ( + next !== null && + (next.index - removed < target || (includeAtBoundary && next.index - removed === target)) + ) { + removed += next[0].length; + next = separators.exec(text); } - return originalOffset; + return target + removed; } visit(joined, (start, end, replacement) => { - let originalStart = advance(start); - while (text[originalStart] === '\n' || text[originalStart] === '\t') originalStart += 1; + const originalStart = advance(start, true); const originalEnd = advance(end); if (boundary?.test(text[originalStart - 1] ?? '')) return; if (!/[\n\t]/.test(text.slice(originalStart, originalEnd))) return; diff --git a/packages/llm/src/scrub-secrets.ts b/packages/llm/src/scrub-secrets.ts index 81cc4e1c..974fd510 100644 --- a/packages/llm/src/scrub-secrets.ts +++ b/packages/llm/src/scrub-secrets.ts @@ -111,6 +111,10 @@ function hasSplitCredential(text: string, joined: string, rule: SecretRule): boo if (rule.boundary === undefined) return false; const canonical = rule.joinedPattern ?? rule.pattern; const source = rule.joinedPattern === undefined ? canonical.source.slice(2) : canonical.source; + // Absence under the same boundary-free canonical matcher proves there is no joined + // candidate. Presence still requires the original-boundary and split checks below. + // Native scanning avoids a JS/coverage branch for every byte of unrelated large text. + if (!new RegExp(source, canonical.flags).test(joined)) return false; const pattern = new RegExp(source, canonical.flags.replace('g', 'y')); let originalOffset = 0; let joinedOffset = 0; diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index d66af3e0..f90a38ab 100644 --- a/tools/secrets-controls/README.md +++ b/tools/secrets-controls/README.md @@ -246,6 +246,12 @@ independent and retains its required witnesses. Its removal now skips the actual remaining-span loop: the new outer raw scrubber can already mask ordinary matches, so the former conditional early-return mutation would leave this policy active. +Core candidate and separator runs now use native scans while retaining the same +canonical matcher and bounded JWT preview. The original-boundary removals target +the actual native candidate guards; the combined removal also drops the mapper +guard. An LLM native absence preflight derives from the existing canonical rule, +so it adds no second credential grammar or acceptance authority. + The LLM original-boundary removal now removes both the numeric coarse previous-word prefilter and the rule's own boundary predicate; retaining either would leave the tested policy active. Header/key recovery removals still change @@ -253,9 +259,9 @@ their original-rule boundary only, and all removals retain the same canonical matchers and bounded raw/joined standalone visitors. Current collection is 25 complete files / 1,319 cases, with each control's exact workload and named target checked separately. These are updated declarations and author checks, not new -physical causal captures or a clean review round. R3 remains NONCLEAN until its -corrections receive new independent acceptance; prior selected `7e9096a8` captures -retain their older source/workload attribution. +physical causal captures or a clean review round. R3 is preserved as NONCLEAN; +later corrective acceptance belongs to the owning phase register. Prior selected +`7e9096a8` captures retain their older source/workload attribution. The current 57-control / 171-phase full declaration projects 1,033 retained files (57 × 18 + 7) under the unchanged 2,000-file / 64-MiB export bounds. This is a capacity @@ -265,6 +271,11 @@ acceptance. The thin entry point uses the same coordinator, outcome and bounded process join as the filesystem family in [causal-controls](../causal-controls/README.md). +The provider-header diagnostic control pins the store's fixed throwable message +as well as the separately sanitized rendered bytes. Its named assertion also +owns the store-message check: a native accessor-backed `Error.stack` is correctly +unread by the safe renderer, so public masking alone cannot certify the store's +content-free failure boundary. The control identity, cases and witnesses remain. Source manifests physically capture both the shared tooling and this family, including helpers and declarations. Exact file membership/hashes, installed dependencies, HEAD/index, target body evaluations, inventories and restoration are diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index 2f605748..7445414a 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -104,7 +104,23 @@ function displayRendererWitnesses(marker) { } // Both scanner admission and mapper admission enforce one original-boundary law. Remove both // exact checks together; removing only the late mapper guard would leave its new prefilter intact. -const coreOriginalBoundary = String.raw` if (/\w/.test(original[originalOffset - 1] ?? '')) continue; +const coreOriginalBoundary = String.raw` const candidates = /(^|[^\w])([A-Za-z])/g; + const separators = /[\n\t]+/g; + let next = original === joined ? null : separators.exec(original); + let removed = 0; + let matchedUntil = 0; + for ( + let candidate = candidates.exec(original); + candidate !== null; + candidate = candidates.exec(original) + ) { + const originalStart = candidate.index + candidate[0].length - 1; + while (next !== null && next.index < originalStart) { + removed += next[0].length; + next = separators.exec(original); + } + const start = originalStart - removed; + if (start < matchedUntil) continue; JOINED_STANDALONE_CREDENTIAL.lastIndex = 0; if (!JOINED_STANDALONE_CREDENTIAL.test(preview(start))) continue; JOINED_STANDALONE_CREDENTIAL.lastIndex = start; @@ -133,15 +149,25 @@ function redactStandaloneCredentials(text: string): string { * Labels are bounded; trailing whitespace cannot contain another label, so failed scans are linear. */ function visitJoinedHeaderLabels(original: string, joined: string, mask: SecretSpanVisitor): void { + if (!joined.includes(':')) return; const header = /(?:authorization|(?:x-)?api-key|x-goog-api-key)["']?\s*:/iy; - let joinedOffset = 0; + const candidates = /(^|[^A-Za-z0-9_-])([A-Za-z])/g; + const separators = /[\n\t]+/g; + let next = separators.exec(original); + let removed = 0; let matchedUntil = 0; - for (let originalOffset = 0; originalOffset < original.length; originalOffset += 1) { - const char = original[originalOffset]; - if (char === '\n' || char === '\t') continue; - const start = joinedOffset++; - if (start < matchedUntil || !/[A-Za-z]/.test(char ?? '')) continue; - if (/[A-Za-z0-9_-]/.test(original[originalOffset - 1] ?? '')) continue; + for ( + let candidate = candidates.exec(original); + candidate !== null; + candidate = candidates.exec(original) + ) { + const originalStart = candidate.index + candidate[0].length - 1; + while (next !== null && next.index < originalStart) { + removed += next[0].length; + next = separators.exec(original); + } + const start = originalStart - removed; + if (start < matchedUntil) continue; header.lastIndex = start; const match = header.exec(joined); if (match === null) continue; @@ -164,23 +190,29 @@ function redactJoinedCredentialSpans( if (!/[\n\t]/.test(text)) return text; const joined = text.replace(/[\n\t]+/g, ''); const parts: string[] = []; - let originalOffset = 0; - let joinedOffset = 0; + const separators = /[\n\t]+/g; + let next = separators.exec(text); + let removed = 0; let copied = 0; - function advance(target: number): number { - while (joinedOffset < target && originalOffset < text.length) { - const char = text[originalOffset++]; - if (char !== '\n' && char !== '\t') joinedOffset += 1; + function advance(target: number, includeAtBoundary = false): number { + while ( + next !== null && + (next.index - removed < target || (includeAtBoundary && next.index - removed === target)) + ) { + removed += next[0].length; + next = separators.exec(text); } - return originalOffset; + return target + removed; } visit(joined, (start, end, replacement) => { - let originalStart = advance(start); - while (text[originalStart] === '\n' || text[originalStart] === '\t') originalStart += 1; + const originalStart = advance(start, true); const originalEnd = advance(end); if (boundary?.test(text[originalStart - 1] ?? '')) return;`; const withoutCoreOriginalBoundary = coreOriginalBoundary - .replace(" if (/\\w/.test(original[originalOffset - 1] ?? '')) continue;\n", '') + .replace( + String.raw` const candidates = /(^|[^\w])([A-Za-z])/g;`, + String.raw` const candidates = /([A-Za-z])/g;`, + ) .replace(" if (boundary?.test(text[originalStart - 1] ?? '')) return;", ''); function coreProjectionControl(id, before, after, targets, marker = coreProjectionMarker) { return control( @@ -942,8 +974,8 @@ export const mutations = [ // R1-LN-1: recover a valid original start hidden inside a rejected greedy joined candidate. coreProjectionControl( 'core-joined-original-boundary-recovery', - " if (/\\w/.test(original[originalOffset - 1] ?? '')) continue;\n", - '', + String.raw` const candidates = /(^|[^\w])([A-Za-z])/g;`, + String.raw` const candidates = /([A-Za-z])/g;`, originalBoundaryCases, originalBoundaryMarker, ), @@ -957,8 +989,8 @@ export const mutations = [ // R2-LN-F1: retain a valid inner header start after a rejected embedded outer label. coreProjectionControl( 'core-header-original-boundary-recovery', - " if (/[A-Za-z0-9_-]/.test(original[originalOffset - 1] ?? '')) continue;\n", - '', + String.raw` const candidates = /(^|[^A-Za-z0-9_-])([A-Za-z])/g;`, + String.raw` const candidates = /([A-Za-z])/g;`, headerOriginalBoundaryCases, headerOriginalBoundaryMarker, ), From 122e81890361f0a5e1116834a21be39632831c09 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 13:47:16 +0300 Subject: [PATCH 206/222] ci(repo): isolate complete coverage and compatibility workloads Run enforced coverage in one file worker, retaining every case, threshold and native concurrency assertion. Give both complete serialized suites a finite 25-minute job budget; per-case bounds remain unchanged. Exercise the actual coverage entry with a fixture child and pin exact arguments, enforcement and nonzero propagation. Refs: PR #95, ADR-0111 Co-Authored-By: Codex --- .github/workflows/ci.yml | 8 +++-- tools/ci/workspace-scheduling.test.mjs | 50 ++++++++++++++++++++++++-- tools/coverage-gate/run.mjs | 4 ++- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14531698..ea2e635d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -407,7 +407,9 @@ jobs: floor-check: name: node 22-line floor (22.13.0) · typecheck · test · build (advisory) runs-on: ubuntu-latest - timeout-minutes: 15 + # The complete serialized compatibility suite includes native DB waits; the former + # fifteen-minute budget expired before CLI completion. Per-case limits stay unchanged. + timeout-minutes: 25 # Fully isolate this job from the shared Turbo remote cache. Turbo's task hash does NOT include the # runtime Node version, so floor-check (Node 22) and the required `ci` job (Node 24) share a hash — # either could replay the other's cached result and mask a version-specific failure. `--force` (below) @@ -468,7 +470,9 @@ jobs: coverage: name: engine coverage floor (llm, mcp) runs-on: ubuntu-latest - timeout-minutes: 15 + # One file worker isolates unchanged admitted-size case budgets from instrumentation + # contention. Keep a finite whole-suite budget for all files and native DB waits. + timeout-minutes: 25 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: diff --git a/tools/ci/workspace-scheduling.test.mjs b/tools/ci/workspace-scheduling.test.mjs index 97a81ecf..8ad0fe05 100644 --- a/tools/ci/workspace-scheduling.test.mjs +++ b/tools/ci/workspace-scheduling.test.mjs @@ -1,6 +1,14 @@ import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -11,6 +19,39 @@ const root = new URL('../../', import.meta.url); const packageJson = JSON.parse(readFileSync(new URL('package.json', root), 'utf8')); const command = packageJson.scripts['ci:workspaces']; +test('actual enforced coverage entry keeps all cases in one file worker and propagates failure', () => { + const directory = mkdtempSync(join(tmpdir(), 'relavium-coverage-scheduling-')); + try { + const entry = join(directory, 'tools', 'coverage-gate', 'run.mjs'); + const dependency = join(directory, 'node_modules', 'vitest'); + mkdirSync(join(directory, 'tools', 'coverage-gate'), { recursive: true }); + mkdirSync(dependency, { recursive: true }); + writeFileSync(join(directory, 'package.json'), '{}'); + writeFileSync(entry, readFileSync(new URL('tools/coverage-gate/run.mjs', root))); + writeFileSync( + join(dependency, 'package.json'), + JSON.stringify({ name: 'vitest', bin: { vitest: 'entry.cjs' } }), + ); + writeFileSync( + join(dependency, 'entry.cjs'), + `console.log(JSON.stringify({args:process.argv.slice(2),enforced:process.env.RELAVIUM_COVERAGE_ENFORCED_ONLY}));process.exit(37);`, + ); + const result = spawnSync(process.execPath, [entry], { + encoding: 'utf8', + timeout: 10_000, + }); + assert.equal(result.error, undefined); + assert.equal(result.status, 37); + assert.equal(result.stderr, ''); + assert.deepEqual(JSON.parse(result.stdout), { + args: ['run', '--coverage', '--maxWorkers=1'], + enforced: '1', + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + for (const [lintCode, testCode, expectedCode, calls] of [ [0, 0, 0, ['lint typecheck', 'test --concurrency=1']], [31, 0, 31, ['lint typecheck']], @@ -144,7 +185,12 @@ test('required and local CI share all workspace tasks and uncached per-invocatio rmSync(temporary, { recursive: true, force: true }); } const floor = workflow.jobs['floor-check']; - assert.equal(floor['timeout-minutes'], 15); + assert.equal(floor['timeout-minutes'], 25); + assert.equal(workflow.jobs.coverage['timeout-minutes'], 25); + assert.equal( + workflow.jobs.coverage.steps.find((step) => step.run === 'pnpm coverage:enforced')?.name, + 'Engine coverage floor (>=90% line+branch — llm, mcp enforced; core measured)', + ); assert.deepEqual(floor.env, { TURBO_TOKEN: '', TURBO_TEAM: '' }); const floorCommand = floor.steps.find( (step) => step.name === 'Typecheck, test, build on the exact floor', diff --git a/tools/coverage-gate/run.mjs b/tools/coverage-gate/run.mjs index 516612f0..535949d6 100644 --- a/tools/coverage-gate/run.mjs +++ b/tools/coverage-gate/run.mjs @@ -28,7 +28,9 @@ try { process.exit(1); } -const result = spawnSync(process.execPath, [vitestEntry, 'run', '--coverage'], { +// Coverage instrumentation and unrelated file workers must not compete for the admitted-size +// controls' unchanged case budgets. All files, thresholds and explicit native concurrency remain. +const result = spawnSync(process.execPath, [vitestEntry, 'run', '--coverage', '--maxWorkers=1'], { cwd: repoRoot, stdio: 'inherit', env: { ...process.env, RELAVIUM_COVERAGE_ENFORCED_ONLY: '1' }, From 9af49d3058ab85b3b969db8dad4c781dee82b8fa Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 13:47:16 +0300 Subject: [PATCH 207/222] docs(docs): retain sixth corrective review and failed gate evidence Record the complete nonclean round, reproduced Home publication fault, source-bound coverage and compatibility failures, and refused missing diagnostic witness. Keep subsequent clean reviews and publication evidence explicitly outstanding. Refs: PR #95, ADR-0110, ADR-0111 Co-Authored-By: Codex --- .../phase-2.5.5-hardening-and-remediation.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 5e242244..49edd1b3 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8800,3 +8800,37 @@ correction. The earlier head's ordinary coverage passes 748 files / 15,953 tests corrections. Two NEW complete cumulative clean rounds, a NEW separate security sitting and corrected-source publication evidence remain required. Steps 9–19 remain unstarted; no merge, release or whole-wave acceptance is claimed. + +The sixth cumulative round at `aec2d793` is **COMPLETE NONCLEAN**: independent +lanes read all 102 corrective paths / 256 ordered hunks, with root-reconciled +body/hunk identities and no missing or repeated assignment. Libraries/native and +tooling/documentation are clean. The CLI lane reproduces a real Home preference +writer fault that synchronously seals the invocation, followed by a new picker +hint from its recovery catch. `4efb42c0` checks the permanent seal before failed +or successful write-result publication. The production writer regression records +post-seal subscriber notifications; the original faulty source fails while the +classified live-refusal control passes. All three affected Home files pass 174 +cases after correction. This is author evidence, not a clean cumulative round. + +The source-bound PR coverage run at that head fails five admitted-size preparation +checks under instrumentation; the unchanged five-second bounds remain enforced. +The complete serialized Node 22.13 compatibility job separately exhausts its +fifteen-minute whole-job budget before CLI completion. The correction uses native +candidate/separator-run scans in core and a necessary-evidence preflight derived +from the LLM's **existing canonical matcher**. Neither changes the credential +families, original-boundary checks, full-value projection or bounded JWT preview. +The coverage runner serializes file workers; its cases, thresholds and explicit +native concurrency remain. Whole coverage and compatibility jobs have finite +25-minute budgets for the complete suites; per-case bounds are unchanged. + +The full secrets capture also correctly refuses acceptance when a declared +provider-header diagnostic witness lacks its named assertion. On the recorded +Node 24 runtime, the safe renderer declines to invoke the native stack accessor; +eight removal cases instead fail the store's existing fixed-message assertion. +That unchanged assertion now carries the diagnostic marker as well. Public-output +checks, all 57 control identities / 336 required witnesses and the complete +workloads remain; no missing witness is waived or inferred from a generic failure. +The native core boundary controls are retargeted to their actual candidate guards, +with both scanner and mapping checks removed for the combined boundary control. +Fresh complete causal execution, two NEW complete cumulative clean rounds, a NEW +separate security sitting and corrected-source publication gates remain required. From 0c0956d44dedc5f5f293881bca323f52908cf2e0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 14:20:17 +0300 Subject: [PATCH 208/222] perf(core): skip impossible credential probes from canonical prefixes Build the unchanged standalone matcher and its bounded necessary-prefix preflight from one fragment definition. Absence bypasses dense public candidate loops; present prefixes still use original boundaries and the bounded canonical JWT probe. Keep existing admitted-size controls and five-second limits. Refs: ADR-0110, PR #95 Co-Authored-By: Codex --- packages/core/src/tools/bounding.ts | 41 ++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index a6fd014c..2086e613 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -120,12 +120,43 @@ function redactInlineMediaForText(value: unknown, seen: WeakSet): unknow } } -// One alternation preserves outer spans when credential families overlap. Bounded lookaheads +// One definition builds both the complete matcher and its necessary-prefix preflight. Prefixes +// have bounded width: testing their absence must never run the unbounded JWT/body alternatives. +// One alternation still preserves outer spans when credential families overlap. Bounded lookaheads // enforce minimum lengths before simple runs; no admitted-size repeated groups use the regex stack. -const STANDALONE_CREDENTIAL = - /\b(?:sk-(?=[\w-]{16})[\w-]+|sk_(?:live|test)_(?=[A-Za-z0-9]{16})[A-Za-z0-9]+|A[KSB]IA[0-9A-Z]{16}|gh[pousr]_(?=[A-Za-z0-9]{20})[A-Za-z0-9]+|github_pat_(?=\w{20})\w+|glpat-(?=[\w-]{16})[\w-]+|xox[baprs]-(?=[A-Za-z0-9-]{10})[A-Za-z0-9-]+|AIza(?=[\w-]{30})[\w-]+|ya29\.(?=[\w-]{20})[\w-]+|hf_(?=[A-Za-z0-9]{20})[A-Za-z0-9]+|npm_(?=[A-Za-z0-9]{20})[A-Za-z0-9]+|eyJ(?=[\w-]{10})[\w-]+\.(?=[\w-]{10})[\w-]+\.(?=[\w-]{6})[\w-]+)/g; // NOSONAR — finite credential families, see the projection policy below +const STANDALONE_CREDENTIAL_FRAGMENTS: readonly { + readonly prefix: RegExp; + readonly body: RegExp; +}[] = [ + { prefix: /sk-/, body: /(?=[\w-]{16})[\w-]+/ }, + { prefix: /sk_(?:live|test)_/, body: /(?=[A-Za-z0-9]{16})[A-Za-z0-9]+/ }, + { prefix: /A[KSB]IA/, body: /[0-9A-Z]{16}/ }, + { prefix: /gh[pousr]_/, body: /(?=[A-Za-z0-9]{20})[A-Za-z0-9]+/ }, + { prefix: /github_pat_/, body: /(?=\w{20})\w+/ }, + { prefix: /glpat-/, body: /(?=[\w-]{16})[\w-]+/ }, + { prefix: /xox[baprs]-/, body: /(?=[A-Za-z0-9-]{10})[A-Za-z0-9-]+/ }, + { prefix: /AIza/, body: /(?=[\w-]{30})[\w-]+/ }, + { prefix: /ya29\./, body: /(?=[\w-]{20})[\w-]+/ }, + { prefix: /hf_/, body: /(?=[A-Za-z0-9]{20})[A-Za-z0-9]+/ }, + { prefix: /npm_/, body: /(?=[A-Za-z0-9]{20})[A-Za-z0-9]+/ }, + { + prefix: /eyJ/, + body: /(?=[\w-]{10})[\w-]+\.(?=[\w-]{10})[\w-]+\.(?=[\w-]{6})[\w-]+/, + }, +]; +const STANDALONE_CREDENTIAL = new RegExp( + '\\b(?:' + + STANDALONE_CREDENTIAL_FRAGMENTS.map( + (fragment) => fragment.prefix.source + fragment.body.source, + ).join('|') + + ')', + 'g', +); +const STANDALONE_CREDENTIAL_PREFIX = new RegExp( + STANDALONE_CREDENTIAL_FRAGMENTS.map((fragment) => fragment.prefix.source).join('|'), +); // The joined probe checks its boundary in the ORIGINAL text. Removing LF/TAB must not erase -// a legitimate boundary or manufacture one inside a public word. The source is the literal above. +// a legitimate boundary or manufacture one inside a public word. The source uses the same fragments. const JOINED_STANDALONE_CREDENTIAL = new RegExp(STANDALONE_CREDENTIAL.source.slice(2), 'y'); const SCHEME_CREDENTIAL = @@ -183,6 +214,8 @@ function visitJoinedStandaloneCredentials( joined: string, mask: SecretSpanVisitor, ): void { + // Absence only: a present prefix still needs the original boundary and bounded canonical probe. + if (!STANDALONE_CREDENTIAL_PREFIX.test(joined)) return; const preview = joinedCredentialProbe(joined); const candidates = /(^|[^\w])([A-Za-z])/g; const separators = /[\n\t]+/g; From 7b39a5ef6508013b601e3cbaa1f4693bcfabf52f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 14:20:33 +0300 Subject: [PATCH 209/222] docs(docs): retain final-source instrumented preparation failure Attribute completed reviews and physical evidence to their exact source while retaining the failed publication gate. Record the canonical-prefix correction and renewed complete review, security and publication obligations. Refs: ADR-0110, PR #95 Co-Authored-By: Codex --- .../phase-2.5.5-hardening-and-remediation.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 49edd1b3..8b92f7c1 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8834,3 +8834,29 @@ The native core boundary controls are retargeted to their actual candidate guard with both scanner and mapping checks removed for the combined boundary control. Fresh complete causal execution, two NEW complete cumulative clean rounds, a NEW separate security sitting and corrected-source publication gates remain required. + +The seventh and eighth ordinary cumulative reviews inspect `9af49d30`: each +accounts for all 104 corrective paths / 265 ordered hunks, with independently +reconciled body/hunk identities and no missing or repeated assignment. No new +source finding is confirmed. The NEW separate security sitting at that source is +clean for its explicit security scope, with 480 independently executed passing +cases; its partial reading modes, selected tests and platform limits are retained. +Neither review nor that sitting establishes successful publication. Required PR +coverage subsequently fails the existing dense-public preparation control at +8,487 ms against its unchanged five-second bound. All other cases pass +(748 files / 15,968 passing cases / one failure / 22 established CI skips). +The push Windows advisory separately records a cold argv-admission case timeout; +the same-source PR Windows job passes. Neither result is hidden or relabeled. + +`0c0956d4` derives a bounded necessary-prefix preflight from the **same canonical +fragment definition** that builds the complete core matcher. Its absence avoids +millions of impossible public-text probes. Prefix presence grants no matching +authority: original boundaries, bounded JWT previews and full canonical matching +remain required. The composed canonical matcher source is unchanged; a private +prototype agrees on 5,155 differential cases and eight admitted-size comparisons. +Author execution passes all 142 complete bounding cases, including the unchanged +dense-public control at 483 ms, plus core lint/typecheck. These local measurements +are not Linux instrumentation acceptance. Two NEW complete cumulative clean +rounds, a NEW separate security sitting and current-source publication gates +remain required after this material performance correction. Steps 9–19 remain +unstarted; no merge or whole-wave acceptance is claimed. From 3d276489e710209b85e8a59adeefda905f58da92 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 14:23:50 +0300 Subject: [PATCH 210/222] test(repo): retarget the canonical modern-key removal anchor Apply the same alphabet narrowing to its single fragment-table declaration without changing control identity, workload or required witnesses. Preserve the superseded review preparation and source-scoped evidence in the owning register. Refs: ADR-0110, PR #95 Co-Authored-By: Codex --- .../phases/phase-2.5.5-hardening-and-remediation.md | 10 ++++++++++ tools/secrets-controls/mutations.mjs | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 8b92f7c1..3ffd1e01 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8860,3 +8860,13 @@ are not Linux instrumentation acceptance. Two NEW complete cumulative clean rounds, a NEW separate security sitting and current-source publication gates remain required after this material performance correction. Steps 9–19 remain unstarted; no merge or whole-wave acceptance is claimed. + +The fragment-table rewrite also moves the existing modern-key-alphabet removal +anchor. The author anchor preflight catches this before a new review can be +completed; the ninth review preparation is superseded, not a clean round. +The control now narrows the exact current `sk-` body declaration. Its identity, +four semantic witnesses, complete workload and expected refusal are unchanged. +All 57 anchors occur exactly once and every declared mutant syntax-parses; +336 named witnesses remain required. An anchor check is not physical causal +execution, and the prior source's passing full capture is not promoted to the +new source. Current-source review, security and publication gates remain open. diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index 7445414a..dfed5153 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -904,8 +904,8 @@ export const mutations = [ // PR #95 B2: independent modern-prefix, joined-view, boundary and malformed-userinfo sites. coreProjectionControl( 'core-modern-key-alphabet', - String.raw`sk-(?=[\w-]{16})[\w-]+`, - String.raw`sk-(?=[A-Za-z0-9]{16})[A-Za-z0-9]+`, + String.raw`{ prefix: /sk-/, body: /(?=[\w-]{16})[\w-]+/ }`, + String.raw`{ prefix: /sk-/, body: /(?=[A-Za-z0-9]{16})[A-Za-z0-9]+/ }`, ['sk-proj-', 'sk-svcacct-', 'sk-ant-', 'sk-AAAA'].map( (prefix) => `masks the complete modern provider shape ${prefix}`, ), From cd1a0b225514474c506cde69b01b200657280d24 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 14:35:48 +0300 Subject: [PATCH 211/222] docs(core): describe the actual canonical matcher construction Replace the stale literal/static-analyzer claim with the composed alternation, prefix absence preflight and bounded runtime proof. Preserve the complete R10 low finding and source-scoped author evidence in the owning register. Compiler output with comments removed is identical. Refs: ADR-0110, PR #95 Co-Authored-By: Codex --- .../phase-2.5.5-hardening-and-remediation.md | 19 +++++++++++++++++++ packages/core/src/tools/bounding.ts | 8 ++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 3ffd1e01..ef75d1f0 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8870,3 +8870,22 @@ All 57 anchors occur exactly once and every declared mutant syntax-parses; 336 named witnesses remain required. An anchor check is not physical causal execution, and the prior source's passing full capture is not promoted to the new source. Current-source review, security and publication gates remain open. + +The tenth cumulative round at `3d276489` is **COMPLETE NONCLEAN, LOW ONLY**: +all 104 corrective paths / 265 ordered hunks are independently reconciled and read +within their explicit ordinary-review modes. CLI and tooling/documentation are +clean. Libraries/native finds one stale implementation comment that still calls +the now-composed matcher a literal and claims static-analysis coverage. The +comment is corrected to describe actual construction and bounded runtime evidence; +no executable policy changes. Two NEW complete cumulative clean rounds and the +NEW separate security/publication gates remain required. + +Fresh local ordinary bare coverage passes 748 files / 15,970 cases / 21 established +local skips in 714.41 seconds, including all three engine line/branch floors. +The dense-public control passes at 768 ms under instrumentation. Package, test +and configuration bytes are unchanged during the control-anchor/history-only +`7b39a5ef` → `3d276489` delta; these gates do not certify control-family execution. +The current-source selected modern-alphabet coordinator separately passes three +phases / 426 executions / four required witnesses / twelve marked removal +failures, zero recorded unhandled errors and exact restoration. Its bounded +physical evidence is independently inspected; it is not a full-family capture. diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 2086e613..aa82048d 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -396,10 +396,10 @@ export function redactSecretShapedText(text: string): string { // signature segment contains an `sk-` run, or a `xox…-glpat-…` run) are covered by the outer greedy match // as a single span. A per-family MULTI-PASS split is NOT equivalent — an earlier pass inserting `[redacted]` // (a non-word `[`) truncates a later family's greedy match and can leave a trailing secret-shaped substring - // EXPOSED (a real redaction regression, pinned in bounding.test.ts). So this stays a single analyzable - // LITERAL and its Sonar per-regex complexity is a deliberate, documented exception — the same trade-off as - // the key=value pattern above: correctness of a security redactor + Sonar's static ReDoS (S5852) coverage - // outrank the metric. `\w` / `[\w-]` fold only the classes that are EXACTLY `[A-Za-z0-9_]` / `[A-Za-z0-9_-]`; + // EXPOSED (a real redaction regression, pinned in bounding.test.ts). The canonical fragments therefore + // construct one complete alternation, with a separate bounded prefix-absence preflight. Failed probes + // use the bounded preview before the full greedy matcher; overlap and admitted-size tests pin that path. + // `\w` / `[\w-]` fold only the classes that are EXACTLY `[A-Za-z0-9_]` / `[A-Za-z0-9_-]`; // the tighter `[A-Za-z0-9]` / `[A-Za-z0-9-]` families keep their narrower class (no `_`). return redactStandaloneCredentials(projected); } From cec30b9e12b2ebd3c3d2296db9cae3a72f993520 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 14:40:35 +0300 Subject: [PATCH 212/222] ci(repo): reserve measured full-coverage scheduling headroom The recorded push check exceeded its 25-minute job maximum after 1481 seconds of complete Vitest work plus setup. Give only coverage a finite 30-minute whole-job budget; retain every case, single-worker isolation, case deadlines, thresholds and required dependencies. Pin the updated bound and preserve independent diagnosis. Refs: ADR-0110, PR #95 Co-Authored-By: Codex --- .github/workflows/ci.yml | 5 +++-- .../phases/phase-2.5.5-hardening-and-remediation.md | 12 ++++++++++++ tools/ci/workspace-scheduling.test.mjs | 2 +- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea2e635d..4a50a68e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -471,8 +471,9 @@ jobs: name: engine coverage floor (llm, mcp) runs-on: ubuntu-latest # One file worker isolates unchanged admitted-size case budgets from instrumentation - # contention. Keep a finite whole-suite budget for all files and native DB waits. - timeout-minutes: 25 + # contention. A measured 1,481-second complete run plus setup exhausted 25 minutes; + # reserve finite whole-suite headroom without changing case bounds or coverage thresholds. + timeout-minutes: 30 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index ef75d1f0..dd628ad8 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8889,3 +8889,15 @@ The current-source selected modern-alphabet coordinator separately passes three phases / 426 executions / four required witnesses / twelve marked removal failures, zero recorded unhandled errors and exact restoration. Its bounded physical evidence is independently inspected; it is not a full-family capture. + +The eleventh review preparation is **INCOMPLETE / SUPERSEDED**, not a clean +round. A separately inspected annotation for the earlier push coverage job +`117655454340` explicitly reports that its 25-minute maximum was exceeded: +Vitest takes 1,481.25 seconds, before setup/cleanup, and also records the already +known dense-public case failure. The tooling reviewer independently confirms +this measured outer-budget finding. Coverage alone receives a finite 30-minute +whole-job budget and its structural pin is updated. Node-floor, required-CI and +Windows budgets are unchanged; all cases, instrumentation, per-case bounds, +coverage thresholds and required-job dependencies remain. No current-source +failure or success is inferred from that historical job. Final-source clean +cumulative reviews, security and publication gates remain required. diff --git a/tools/ci/workspace-scheduling.test.mjs b/tools/ci/workspace-scheduling.test.mjs index 8ad0fe05..071ab632 100644 --- a/tools/ci/workspace-scheduling.test.mjs +++ b/tools/ci/workspace-scheduling.test.mjs @@ -186,7 +186,7 @@ test('required and local CI share all workspace tasks and uncached per-invocatio } const floor = workflow.jobs['floor-check']; assert.equal(floor['timeout-minutes'], 25); - assert.equal(workflow.jobs.coverage['timeout-minutes'], 25); + assert.equal(workflow.jobs.coverage['timeout-minutes'], 30); assert.equal( workflow.jobs.coverage.steps.find((step) => step.run === 'pnpm coverage:enforced')?.name, 'Engine coverage floor (>=90% line+branch — llm, mcp enforced; core measured)', From a1fca06cf376599c39095f363d2a5ba149f9c55f Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 15:27:48 +0300 Subject: [PATCH 213/222] fix(db): classify owned native session metadata writes Preserve native storage origin for the fixed session update transaction while validating caller data outside that authority boundary. Actual readonly recovery and foreign-client/accessor refusals cover the export regression. Refs: ADR-0110 Co-Authored-By: Codex --- packages/db/src/session-store.ts | 19 ++++--- packages/db/src/storage-origin.test.ts | 70 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 7 deletions(-) diff --git a/packages/db/src/session-store.ts b/packages/db/src/session-store.ts index 9f56e8e6..4bdf1408 100644 --- a/packages/db/src/session-store.ts +++ b/packages/db/src/session-store.ts @@ -21,6 +21,7 @@ import { import { readSessionCoverage, type SessionUsageSnapshot } from './session-usage-read.js'; import { withBusyRetry } from './retry.js'; import { withSessionUsageWrite } from './session-usage-write.js'; +import { hasOwnedClient, withOwnedSqlite } from './storage-failure-facts.js'; import { agentSessions, sessionMessages, @@ -592,13 +593,17 @@ export function createSessionStore(db: Db): SessionStore { }, updateSession: (record) => { - withSessionUsageWrite(db, [record.id], (tx) => - tx - .update(agentSessions) - .set(mutableSessionColumns(record)) - .where(eq(agentSessions.id, record.id)) - .run(), - ); + // Validate caller data outside the native-origin boundary: even a getter throwing a + // real SqliteError must not acquire storage authority. Only our owned write grants it. + const sessionId = record.id; + const columns = mutableSessionColumns(record); + const write = (): void => { + withSessionUsageWrite(db, [sessionId], (tx) => + tx.update(agentSessions).set(columns).where(eq(agentSessions.id, sessionId)).run(), + ); + }; + if (hasOwnedClient(db)) withOwnedSqlite(write); + else write(); }, recordSessionCost: (entry) => { diff --git a/packages/db/src/storage-origin.test.ts b/packages/db/src/storage-origin.test.ts index 6f10eb0b..fc51b3ba 100644 --- a/packages/db/src/storage-origin.test.ts +++ b/packages/db/src/storage-origin.test.ts @@ -6,12 +6,15 @@ import { DrizzleError } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/better-sqlite3'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AgentSessionRecord } from '@relavium/shared'; + import { createClient, DbOpenError, runMigrations, type DbClient } from './client.js'; import { isOwnedStorageFailure } from './index.js'; import { FilesystemMediaStore } from './media-store.js'; import { MediaStoreFilesystemError, MediaStorePaths } from './media-store-paths.js'; import { migrateWithOwnedStorageFacts } from './migration-storage-origin.js'; import * as schema from './schema.js'; +import { createSessionStore } from './session-store.js'; import { checkpointSessionEffectPrivacy, SessionEffectPrivacyError, @@ -55,7 +58,74 @@ function migrations(sqlText?: string): string { return folder; } +function sessionRecord(): AgentSessionRecord { + return { + id: 'storage-origin-session', + agentSlug: 'synthetic', + context: { workingDir: root, fsScopeTier: 'sandboxed' }, + status: 'active', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: '2026-10-18T00:00:00.000Z', + updatedAt: '2026-10-18T00:00:00.000Z', + }; +} + describe('owned storage origin (ADR-0110)', () => { + it('marks a real readonly session update and permits recovery on the same native client', () => { + const value = client(); + runMigrations(value.db); + const store = createSessionStore(value.db); + const record = sessionRecord(); + store.createSession(record); + value.sqlite.pragma('query_only = ON'); + const failure = caught(() => store.updateSession({ ...record, status: 'exported' })); + expect(failure).toBeInstanceOf(Database.SqliteError); + expect(failure).toMatchObject({ code: 'SQLITE_READONLY' }); + expect(isOwnedStorageFailure(failure), 'DB_ORIGIN_SESSION_UPDATE_READONLY').toBe(true); + expect(store.loadSession(record.id)?.status).toBe('active'); + value.sqlite.pragma('query_only = OFF'); + store.updateSession({ ...record, status: 'exported' }); + expect(store.loadSession(record.id)?.status).toBe('exported'); + }); + + it('does not grant session-update storage origin to a foreign native client', () => { + const sqlite = new Database(':memory:'); + try { + const db = drizzle(sqlite, { schema }); + runMigrations(db); + const store = createSessionStore(db); + const record = sessionRecord(); + store.createSession(record); + sqlite.pragma('query_only = ON'); + const failure = caught(() => store.updateSession({ ...record, status: 'exported' })); + expect(failure).toBeInstanceOf(Database.SqliteError); + expect(failure).toMatchObject({ code: 'SQLITE_READONLY' }); + expect(isOwnedStorageFailure(failure), 'DB_ORIGIN_FOREIGN_SESSION_UPDATE').toBe(false); + } finally { + sqlite.close(); + } + }); + + it('does not grant storage origin to a native-shaped error thrown by record validation', () => { + const value = client(); + runMigrations(value.db); + const store = createSessionStore(value.db); + const record = sessionRecord(); + store.createSession(record); + const primary = new Database.SqliteError('PRIVATE_RECORD_ACCESSOR', 'SQLITE_READONLY'); + Object.defineProperty(record, 'title', { + get: () => { + throw primary; + }, + }); + expect(caught(() => store.updateSession(record))).toBe(primary); + expect(isOwnedStorageFailure(primary), 'DB_ORIGIN_RECORD_VALIDATION_REFUSAL').toBe(false); + expect(store.loadSession(record.id)?.status).toBe('active'); + }); + it('marks an actual corrupt database open while retaining family, cause and path-free message', () => { const path = join(root, 'corrupt.db'); writeFileSync(path, 'not a SQLite file'); From 21fdfbb7811568e6a7845014678ee5903c24ab6b Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 15:27:48 +0300 Subject: [PATCH 214/222] test(cli): exercise actual readonly export metadata failures Replace the preclassified store-error fixture with real SQLite refusals through human and JSON export, preserving successful publication and the unchanged row. Clarify existing refresh, probe and export comments without changing their runtime policy. Refs: ADR-0026, ADR-0110 Co-Authored-By: Codex --- apps/cli/src/commands/chat-export.test.ts | 45 +++++++++++++--------- apps/cli/src/commands/chat-export.ts | 4 +- apps/cli/src/engine/providers.ts | 6 +-- apps/cli/src/onboarding/wizard.ts | 11 +++--- apps/cli/src/render/tui/home-controller.ts | 2 +- 5 files changed, 38 insertions(+), 30 deletions(-) diff --git a/apps/cli/src/commands/chat-export.test.ts b/apps/cli/src/commands/chat-export.test.ts index f5d72a30..70746055 100644 --- a/apps/cli/src/commands/chat-export.test.ts +++ b/apps/cli/src/commands/chat-export.test.ts @@ -7,7 +7,6 @@ import { AgentSchema, type AgentSessionRecord, type SessionMessage } from '@rela import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { OpenedSessionStore } from '../history/session-open.js'; -import { CliError } from '../process/cli-error.js'; import { EXIT_CODES } from '../process/exit-codes.js'; import type { GlobalOptions } from '../process/options.js'; import { captureIo, parseNdjson } from '../test-support.js'; @@ -240,23 +239,33 @@ describe('chatExportCommand (2.P)', () => { expect(err()).not.toContain('could not mark the session exported'); }); - it('degrades a row-mark failure to a stderr warning — the scaffold still lands, exit 0', () => { - // updateSession throws (e.g. a locked db): the file write is the durable contract, so the export still - // succeeds (exit 0) with a stderr note rather than failing an export already on disk. - opened = { - ...opened, - store: { - ...opened.store, - updateSession: () => { - throw new CliError('storage_unavailable', ''); - }, - }, - }; - const { io, err } = captureIo(); - expect(chatExportCommand({ sessionId: 's1', force: false }, deps(io))).toBe(EXIT_CODES.success); - expect(err()).toContain('could not mark the session exported'); - expect(existsSync(join(cwd, 's1.relavium.yaml'))).toBe(true); - }); + it.each([false, true])( + 'preserves the written scaffold and success on actual SQLite readonly metadata refusal (json=%s)', + (json) => { + client.sqlite.pragma('query_only = ON'); + const { io, err, out } = captureIo(); + expect( + chatExportCommand({ sessionId: 's1', force: false }, deps(io, json)), + 'EXPORT_NATIVE_READONLY_SUCCESS', + ).toBe(EXIT_CODES.success); + expect(err(), 'EXPORT_NATIVE_READONLY_WARNING').toContain( + 'could not mark the session exported', + ); + expect(existsSync(join(cwd, 's1.relavium.yaml'))).toBe(true); + expect(opened.store.loadSession('s1')?.status).toBe('active'); + if (json) { + expect(parseNdjson(out())).toEqual([ + expect.objectContaining({ + type: 'session:exported', + sessionId: 's1', + workflowPath: join(cwd, 's1.relavium.yaml'), + }), + ]); + } else { + expect(out()).toBe(`Exported session s1 to ${join(cwd, 's1.relavium.yaml')}\n`); + } + }, + ); it('refuses to overwrite an existing target without --force (exit-2 fault) and closes the store', () => { const { io } = captureIo(); diff --git a/apps/cli/src/commands/chat-export.ts b/apps/cli/src/commands/chat-export.ts index ec7e9ec4..6bde3bb7 100644 --- a/apps/cli/src/commands/chat-export.ts +++ b/apps/cli/src/commands/chat-export.ts @@ -80,8 +80,8 @@ export function chatExportCommand( // Mark the session `exported` + record the path (provenance, ADR-0026). Safe here — this is a NON-live // session (no concurrent persister), unlike the in-REPL `/export` which deliberately does not mark the row. - // The file write is the durable contract, so a provenance-mark fault degrades to a warning (stderr, so - // stdout stays pure under --json) rather than failing an export whose scaffold already landed on disk. + // The file write is durable: a classified storage refusal degrades to a stderr warning + // (keeping --json stdout pure). Unexpected metadata faults still propagate; the scaffold remains. const exportedAt = new Date(now()).toISOString(); try { opened.store.updateSession({ diff --git a/apps/cli/src/engine/providers.ts b/apps/cli/src/engine/providers.ts index ce7b3ce5..81e0007d 100644 --- a/apps/cli/src/engine/providers.ts +++ b/apps/cli/src/engine/providers.ts @@ -205,9 +205,9 @@ export type ProviderKeyValidation = * defensively redact any occurrence (`raw.split(key).join(keyHint(key))`) before surfacing AND never attach * `err` as a cause (it could carry the key in a nested field a `--verbose` render might expose). * - * The request is BOUNDED here — by an `AbortController` (cancels the in-flight request) AND a hard `Promise.race` - * timeout (settles the call even if an adapter ignores the signal) — so BOTH callers (`provider test` and the - * `/doctor --deep` probe) are bounded by construction; a stalled provider can never hang the CLI. + * Abort and the timeout bound the caller's probe observation. With a lifecycle owner, entered producer + * work remains retained until its actual settlement; a UX timeout never acknowledges cleanup. + * Ownership/admission refusals can reject instead of producing a validation result. */ export async function validateProviderKey( provider: LlmProvider, diff --git a/apps/cli/src/onboarding/wizard.ts b/apps/cli/src/onboarding/wizard.ts index 5bfb39eb..00bbb736 100644 --- a/apps/cli/src/onboarding/wizard.ts +++ b/apps/cli/src/onboarding/wizard.ts @@ -337,9 +337,9 @@ export async function runOnboardingWizard(deps: OnboardingDeps): Promise { /** * Live-validate a key with a retry loop (2.5.G S8). Returns `{ keyToStore, verified }` once the user has a key to * persist — verified (probe ok), or consciously accepted despite a failure ("save it anyway"). Returns `null` when - * the user skips (a note/outro is already shown, mirroring the top-level flow). Never throws for a bad key; only a - * clack-prompt fault propagates (caught by the Home's cleanup). Secret-free: the key is never echoed — only the - * redacted `detail`. A bad key never reaches the keychain (storage happens only after this resolves). + * the user skips (a note/outro is already shown, mirroring the top-level flow). Rejected keys produce retry + * choices; prompt, lifecycle and unexpected probe faults can propagate through Home cleanup. The key is never + * echoed, only redacted `detail`. Storage follows this result and its explicit verification/continue decision. */ async function validateWithRetry( p: ClackOnboardingDeps, @@ -375,9 +375,8 @@ async function validateWithRetry( p.note(`Checking your ${KNOWN_PROVIDERS[provider].displayName} key…`, 'Verification'); const sp = p.spinner?.(); sp?.start(`Checking your ${KNOWN_PROVIDERS[provider].displayName} key…`); - // The spinner MUST stop even if the probe rejects unexpectedly (validateProviderKey never rejects, but a future - // injected `validate` might) — otherwise a runaway spinner interval would sit over the ink-hosted Home while the - // throw propagates to the Home's cleanup. The `finally` stops it; the throw then surfaces cleanly there. + // Always stop the spinner before a probe, lifecycle or injected-validator fault propagates + // through Home cleanup; an unresolved presentation interval does not own the real probe. let res: ProviderKeyValidation; try { res = await validate(provider, keyToStore); diff --git a/apps/cli/src/render/tui/home-controller.ts b/apps/cli/src/render/tui/home-controller.ts index 0283c12b..dcaa0ebd 100644 --- a/apps/cli/src/render/tui/home-controller.ts +++ b/apps/cli/src/render/tui/home-controller.ts @@ -204,7 +204,7 @@ export interface HomeModelsPort { }; /** TTL-bounded background refresh (ADR-0064 §5c) — refreshes empty/stale providers; `undefined` when none were. */ refreshIfStale: () => Promise; - /** Unbounded, user-initiated refresh (Ctrl+R) — every connected provider, per-provider-isolated. Never rejects. */ + /** User-initiated refresh (Ctrl+R), regardless of TTL. Provider faults are isolated; owned stop can reject. */ refresh: () => Promise; /** The current `[preferences].default_model` (the picker's `✓` marker), or `undefined` when none is set. */ currentDefault: () => string | undefined; From 88235c193dd23c37f27142650ee288deb3816828 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 15:27:48 +0300 Subject: [PATCH 215/222] test(repo): retain the complete native export causal workload Pin the full eighteen-case command cohort after adding real native human/JSON metadata-refusal controls. Keep every control identity and required witness; update the independent workload guard and living declarations. Refs: ADR-0110 Co-Authored-By: Codex --- tools/causal-controls/provenance.test.mjs | 2 +- tools/secrets-controls/README.md | 6 +++--- tools/secrets-controls/mutations.mjs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/causal-controls/provenance.test.mjs b/tools/causal-controls/provenance.test.mjs index 1b969bc7..36c953dd 100644 --- a/tools/causal-controls/provenance.test.mjs +++ b/tools/causal-controls/provenance.test.mjs @@ -276,7 +276,7 @@ test('secrets workload corrections retain independent complete-count and reader- ['run-default-secret-reader', 'apps/cli/src/commands/run.test.ts', 84], ['snapshot-run-warning', 'apps/cli/src/commands/run.test.ts', 84], ['gate-consumed-stdin-prompt-authority', 'apps/cli/src/commands/gate.test.ts', 73], - ['export-command-review-notice', 'apps/cli/src/commands/chat-export.test.ts', 17], + ['export-command-review-notice', 'apps/cli/src/commands/chat-export.test.ts', 18], ]) { const control = secretsControls.find((entry) => entry.id === id); assert.ok(control, `${id}: missing control`); diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index f90a38ab..bce9595c 100644 --- a/tools/secrets-controls/README.md +++ b/tools/secrets-controls/README.md @@ -116,7 +116,7 @@ transliteration or uniqueness of generated IDs. Two independent CLI notice controls remove the successful standalone command and live `/export` notice sites. Each requires named human and JSON witnesses in -the complete 17-case command-export or 133-case chat file. The live control also +the complete 18-case command-export or 133-case chat file. The live control also requires its actual interactive transcript/Ink-frame witness. A third control restores raw stderr delivery only for the live notice: human/JSON positive cases still pass, while the interactive witness must fail with its named assertion marker. @@ -180,7 +180,7 @@ left-boundary check, split API-header recognition and raw-password recognition. Every core phase executes the complete 142-case bounding file; every new LLM phase executes the complete 89-case scrubber file. Independent ordinary tooling tests pin these inventories, the 277-case overlap cohort, 179-case CLI diagnostic cohort and -17-case command-export cohort. Actual collection still has to match in every phase. +18-case command-export cohort. Actual collection still has to match in every phase. The boundary controls preserve an embedded public-word key-shaped run containing an internal LF as well as recognizing a real key after an LF boundary. This makes the @@ -257,7 +257,7 @@ previous-word prefilter and the rule's own boundary predicate; retaining either would leave the tested policy active. Header/key recovery removals still change their original-rule boundary only, and all removals retain the same canonical matchers and bounded raw/joined standalone visitors. Current collection is 25 -complete files / 1,319 cases, with each control's exact workload and named target +complete files / 1,320 cases, with each control's exact workload and named target checked separately. These are updated declarations and author checks, not new physical causal captures or a clean review round. R3 is preserved as NONCLEAN; later corrective acceptance belongs to the owning phase register. Prior selected diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index dfed5153..61514032 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -702,7 +702,7 @@ export const mutations = [ ' deps.io.writeErr(SESSION_EXPORT_REVIEW_NOTICE);\n', '', ['apps/cli/src/commands/chat-export.test.ts'], - 17, + 18, "reports fixed continuity and review notice on stderr after successful 'human' export", 'W2_EXPORT_COMMAND_NOTICE', [ From ec0f3e10d2a2ec2e4f89e17b22e35ebd64623347 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 15:27:48 +0300 Subject: [PATCH 216/222] docs(docs): record the native export corrective review Retain R12 CLEAN and R13 NONCLEAN source scopes, the actual SQLite regression, red/green and author gate evidence, preparation failures and the requirement for two new cumulative rounds plus a separate security sitting. Refs: ADR-0110 Co-Authored-By: Codex --- .../phase-2.5.5-hardening-and-remediation.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index dd628ad8..c442aadf 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8901,3 +8901,50 @@ Windows budgets are unchanged; all cases, instrumentation, per-case bounds, coverage thresholds and required-job dependencies remain. No current-source failure or success is inferred from that historical job. Final-source clean cumulative reviews, security and publication gates remain required. + +The twelfth cumulative ordinary round at `cec30b9e` is **COMPLETE CLEAN**; +the thirteenth at that same source is **COMPLETE NONCLEAN**. Both independently +account for all 104 corrective paths / 265 ordered hunks, with root-verified +body/hunk identities and no gaps or duplicate assignments. The thirteenth CLI +lane enters the actual session store with native `query_only = ON`: export +writes its scaffold, but the metadata update's raw `SQLITE_READONLY` lacks owned +storage provenance. The new storage-only warning branch therefore propagates +`cli_internal` instead of publishing the already-written export's successful +result. Its positive permanent test had injected a preclassified `CliError`, +which did not exercise that native path. This is a verified defect in the +correction, not a retained limitation. + +The repair validates and snapshots session-update input outside the native-origin +boundary, then grants storage facts only around the fixed transaction/write on +an actually owned SQLite client. No message heuristic, public constructor, +arbitrary callback or cause-tree classification is introduced. Real readonly +failure/recovery, foreign-client and throwing-record negative controls pin that +boundary. Human and JSON exports now use real SQLite refusal tests; the unknown +metadata-fault control still propagates its original error and preserves the +written scaffold. Before the repair, the three positive native cases fail; both negative origin +controls pass within the run's 28 passing cases. +After the repair and actual DB rebuild, all three complete affected files pass +92 cases. An initial author typecheck catches a callback return annotation; +its failed preparation is retained and corrected before acceptance. + +The thirteenth round's separate comment advisory is also corrected: Home refresh +and validation comments describe owned rejection and retained real work, and +export distinguishes classified storage degradation from an unexpected fault. +Those edits change comments only. The previous clean corrective security sitting +at `cec30b9e` retains its explicit reading modes, 814 distinct executed Vitest +assertions, 195 tooling assertions, 177 existing-dist bootstrap controls and +Corepack-path qualifications; it does not accept this later storage-boundary +repair. Two NEW complete cumulative clean ordinary rounds, a NEW separate +security sitting and current-source publication checks remain required after +the final material fix. Steps 9–19 remain unstarted. + +The real native export matrix adds one command-file case (18 total). Its existing +secrets control and independent tooling pin both retain the complete workload; +all 57 control identities / 336 witness obligations remain unchanged. The first +acceptance-tool preparation correctly rejects the stale independent 17-case pin; +that failed run is retained, the pin and live cohort documentation are reconciled, +and no missing case or witness is waived. Historical 17-case captures retain +their source attribution. Fresh full workspace lint/typecheck/test/build passes +24/24 forced tasks with zero cache hits: 748 files / 15,974 passing tests / +21 established local skips. These author results are not independent review or +final-source remote CI acceptance. From d001f9c0b1ac674fd42894ec176378d9a588ff94 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 16:01:23 +0300 Subject: [PATCH 217/222] docs(docs): record accepted pr95 corrective source reviews Join the two new complete cumulative clean rounds and separate security sitting after the actual native export repair. Preserve historical failed and source-bound evidence, distinguish live publication criteria, and keep Wave 3 steps 9-19 unstarted. Refs: ADR-0110, ADR-0113, PR #95 Co-Authored-By: Codex --- docs/roadmap/current.md | 12 ++-- .../phase-2.5.5-hardening-and-remediation.md | 64 ++++++++++++++++++- docs/standards/security-review.md | 42 ++++++++++-- 3 files changed, 107 insertions(+), 11 deletions(-) diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 940bdf68..1695c4fa 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1122,10 +1122,14 @@ The maintainer authorized the initial completed-Steps-1–8 publication checkpoi remains attributed to `ba14b1bd`; it does not certify later changes. The second maintainer systematic report reviews `a5a5d45e` and opens further corrections. The [second correction register](phases/phase-2.5.5-hardening-and-remediation.md#pr-95-second-systematic-review-corrections--2026-10-18) -is canonical for that work. New independent complete cumulative clean rounds, -a separate security sitting and corrected-source publication checks are pending; -no new acceptance is claimed. Steps 9–19 have **NOT STARTED** and whole-wave -acceptance remains Step 19. Check PR #95 before later implementation commits, +is canonical for that work. The +[second corrective source-review completion](phases/phase-2.5.5-hardening-and-remediation.md#pr-95-second-corrective-source-review-completion--2026-10-18) +accepts corrections at `ec0f3e10` after two NEW complete cumulative clean rounds +and a NEW separate clean security sitting. Current source-bound PR/push CI, +coverage, SonarCloud, same-archive consumers and physical control artifacts remain +required publication criteria; PR #95 owns their live result. Source review does +not imply merge or whole-wave acceptance. Steps 9–19 have **NOT STARTED** and +whole-wave acceptance remains Step 19. Check PR #95 before later implementation commits, which extend its review scope. No new decision, credential, billing or provider capture gate is introduced. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index c442aadf..f85a58ad 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -8663,9 +8663,13 @@ Steps 9–19 remain unstarted and outside this PR. ### PR #95 second systematic-review corrections — 2026-10-18 -**Corrective work is in progress; independent review and publication acceptance -are pending.** The second maintainer report reviews `ab08209e..a5a5d45e` and -reopens PR #95 readiness. Earlier increment acceptance and the prior `ba14b1bd` +**Source corrections are independently accepted at `ec0f3e10`.** The +[second corrective source-review completion](#pr-95-second-corrective-source-review-completion--2026-10-18) +records the final clean rounds and separate security sitting. Current publication +status belongs to PR #95's source-bound checks, separately from source review. +The chronological entries below retain earlier pending, NONCLEAN and superseded +states rather than retrospectively relabeling them. The second maintainer report +reviews `ab08209e..a5a5d45e` and reopened PR #95 readiness. Earlier increment acceptance and the prior `ba14b1bd` corrective reviews retain their exact source scopes; neither accepts these new corrections. This is a scoped publication checkpoint for Steps 1–8, not whole-wave acceptance. Steps 9–19 remain unstarted. @@ -8948,3 +8952,57 @@ their source attribution. Fresh full workspace lint/typecheck/test/build passes 24/24 forced tasks with zero cache hits: 748 files / 15,974 passing tests / 21 established local skips. These author results are not independent review or final-source remote CI acceptance. + +### PR #95 second corrective source-review completion — 2026-10-18 + +The final material repair and cumulative second-report corrections are +**independently accepted at `ec0f3e10d2a2ec2e4f89e17b22e35ebd64623347`**, +against report source `a5a5d45e`. Fourteenth and fifteenth ordinary rounds are +NEW, independent and **COMPLETE CLEAN** after the native SQLite export repair. +Each accounts for all **108 corrective paths / 272 ordered U3 hunks**, with +root-reconciled body and hunk hashes, no missing or repeated assignments and +substantive per-path assessments. Code, test and tooling bodies are read in full; +historical documents use explicit complete-structure and all-affected +semantic-clause modes. Unrelated historical narrative is not recertified. Earlier +NONCLEAN rounds and failed/superseded preparation remain attributed above. +These are cumulative corrective reviews, not a new whole-PR review. + +A NEW separate security sitting at the same source is **CLEAN** within its +explicit changed-mechanism, caller/test and historical-clause modes. Root verifies +all 108 body identities / 272 hunks and retained execution-log hashes. Independent +execution passes **764 source tests across 18 files and 148 tooling tests across +seven files**, zero skips. The initial private alias-order load failure remains +retained and receives no acceptance credit. Report SHA-256: +`156c7947b1564ca601668d6e97069822853acf0da2533759499c7ad6391f2b1f`. +The [security index](../../standards/security-review.md#sitting-pr-95-second-corrective-native-and-storage-boundaries-2026-10-18) +owns its scope and exclusions; no real provider, paid request, keychain or GPG +operation was performed. Detailed reviews remain local and Git-ignored. + +Author validation at this source passes **24/24 forced workspace tasks** with +zero cache hits, **748 files / 15,974 tests / 21 established local skips**, +**633 acceptance-tool tests**, tool lint/typecheck and format/diff checks. +The selected real-native export coordinator passes one control / three phases / +54 executions / two required witnesses / four marked removal failures, zero +recorded runner-unhandled errors and byte-exact restoration. Its physical evidence +is independently inspected, including the actual 18/18 fixed and restored results; +`complete:false` explicitly excludes full-family acceptance. All 57 secrets +control identities and 336 required witness obligations remain. Native close, +receipt ACK and safe store close retain distinct meanings; finite credential +recognition does not promise arbitrary secret detection. + +Earlier `3d276489` PR and push CI each finish **15/15 successful jobs**. Root +independently inspects their same-archive four-platform consumers and full +filesystem, secrets, usage and wire artifacts, including archive digests and +retained bytes. Those results remain **historical evidence at that source**; +they do not accept the later native storage repair. Current PR/push CI, coverage, +SonarCloud, all four same-archive consumers and complete physical control artifacts +are required publication criteria at the final pushed head. Their live result is +owned by [PR #95](https://github.com/HodeTech/Relavium/pull/95), not inferred from +this earlier source-review record. A metadata-only closing delta is reviewed +separately; it does not create a new full-source review or execution claim. + +This checkpoint remains **Wave 3 Steps 1–8 only**. Steps **9–19 have NOT STARTED**; +whole-wave acceptance and core coverage promotion remain Step 19. No merge, +release, new ADR, user decision, paid capture or new deferral is claimed. Existing +manual `git_commit` review and historical session-export limits retain their +Accepted contracts. The approved execution plan remains the sole later-work order. diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index 3bb0b721..3a2e5427 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -749,10 +749,44 @@ their canonical scope; this entry records the completed earlier sitting rather than recertifying those mechanisms. The second maintainer report reviews later source `a5a5d45e` and opens new -corrections. A **NEW separate corrective sitting is pending** in the -[second correction register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-second-systematic-review-corrections--2026-10-18). -The `ba14b1bd` sitting does not accept those later changes, establish a new -whole-PR security result or imply current publication acceptance. +corrections. The NEW separate sitting below accepts its later scoped security +work; the [second correction register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-second-systematic-review-corrections--2026-10-18) +retains all intermediate source scopes and findings. The `ba14b1bd` sitting does +not accept those later changes, establish a new whole-PR security result or imply +current publication acceptance. + +### Sitting: PR #95 second corrective native and storage boundaries (2026-10-18) + +A NEW separate sitting is **CLEAN** at +`ec0f3e10d2a2ec2e4f89e17b22e35ebd64623347`, against `a5a5d45e`, after the +actual SQLite export repair. Its **108 paths / 272 ordered U3 hunks** have +root-verified body, diff, hunk and retained log hashes. Reading modes cover +complete changed security mechanisms and relevant callers, changed test/fixture +expectations and affected historical clauses; full body identity does not claim +reading every unchanged ordinary line. This is separate from the two NEW +complete cumulative ordinary rounds, and not a new whole-PR security review. +Report SHA-256: `156c7947b1564ca601668d6e97069822853acf0da2533759499c7ad6391f2b1f`. + +Independent execution passes **764 source tests in 18 files and 148 Node tooling +tests in seven files**, zero skips. A failed private alias-order preparation is +retained without acceptance credit. The sitting checks private SQLite origin, +actual read-only export/recovery and foreign-client/getter negative controls; +creation-owned positive PID/native-close obligations; finite raw-before-normalized +credential projection, complete journal arguments and hostile diagnostics; sealed +UI/refresh, installed executable/Node-floor pins and credential-free CI consumers. +No actionable finding or new deferral remains within this scope. + +The author-selected export capture is physically inspected, not rerun by this +reviewer: three source segments, 1,392 fixed-source hashes, bound runtime receipts +and actual 18/18 fixed, four expected removal failures, 18/18 restored agree. +Its `complete:false` result is not full-family acceptance. Retained dependency +metadata is checked; absent dependency bytes and source/dist/native-platform +limits remain explicit. No live provider, paid call, keychain, GPG, complete CI, +coverage, actual Windows/Linux/minimum-Node or operator TTY is exercised here. +Finite redaction is not universal detection; timeout does not mint settlement, +receipt, native-close or safe-store-close ACK. The +[source-review completion register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-second-corrective-source-review-completion--2026-10-18) +separately owns publication criteria and Steps 9–19 remaining unstarted. ## Sandbox and tool policy (`run_command`, node tools, secret inputs) From 154d832df70838e519b87264d2022d68fdfad7f9 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 16:20:50 +0300 Subject: [PATCH 218/222] test(cli): own lazy home preparation before admission cases Prepare the real Home source graph under the existing captured fixture and pending-work owner. Keep the default argv route, service observations, every admission assertion, five-second case bound and actual cleanup joins. Retain the Windows failure and separate source-preparation scope. Refs: ADR-0105, ADR-0110, PR #95 Co-Authored-By: Codex --- .../commands/fs-workspace-admission.test.ts | 8 +++- docs/roadmap/current.md | 7 +++- .../phase-2.5.5-hardening-and-remediation.md | 38 +++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/commands/fs-workspace-admission.test.ts b/apps/cli/src/commands/fs-workspace-admission.test.ts index acb1f8f1..8d07cc88 100644 --- a/apps/cli/src/commands/fs-workspace-admission.test.ts +++ b/apps/cli/src/commands/fs-workspace-admission.test.ts @@ -138,8 +138,14 @@ function beginWorkspaceCase(context: { readonly signal: AbortSignal }): Workspac Object.assign(effects, { history: 0, keys: 0 }); return fixture; } -beforeEach((context) => { +beforeEach(async (context) => { beginWorkspaceCase(context); + // Compile the real lazy Home graph as owned fixture preparation, outside the case clock. + // Keep its service observations inside this fixture; run() still uses its default Home loader. + await ownCase(async (fixture) => { + await import('../home/drive-home.js'); + fixture.assertCurrent(); + }); }); function retireWorkspaceCase(fixture: WorkspaceCase): Promise { fixture.sealed = true; diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 1695c4fa..1f76652c 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1128,8 +1128,11 @@ accepts corrections at `ec0f3e10` after two NEW complete cumulative clean rounds and a NEW separate clean security sitting. Current source-bound PR/push CI, coverage, SonarCloud, same-archive consumers and physical control artifacts remain required publication criteria; PR #95 owns their live result. Source review does -not imply merge or whole-wave acceptance. Steps 9–19 have **NOT STARTED** and -whole-wave acceptance remains Step 19. Check PR #95 before later implementation commits, +not imply merge or whole-wave acceptance. The later +[native Windows admission preparation](phases/phase-2.5.5-hardening-and-remediation.md#pr-95-native-windows-admission-preparation--2026-10-18) +retains the failed advisory attempt and separate test-preparation review scope; +production runtime is unchanged and PR #95 owns final publication results. +Steps 9–19 have **NOT STARTED** and whole-wave acceptance remains Step 19. Check PR #95 before later implementation commits, which extend its review scope. No new decision, credential, billing or provider capture gate is introduced. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index f85a58ad..cc05e270 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -9006,3 +9006,41 @@ whole-wave acceptance and core coverage promotion remain Step 19. No merge, release, new ADR, user decision, paid capture or new deferral is claimed. Existing manual `git_commit` review and historical session-export limits retain their Accepted contracts. The approved execution plan remains the sole later-work order. + +### PR #95 native Windows admission preparation — 2026-10-18 + +The first publication attempt at `d001f9c0` retains a failed push-side Windows +source-native advisory: the first bare-Home argv case exceeds Vitest's default +five-second case bound. The same-source PR Windows job passes, as do all four +actual installed consumers. The failed native trace records only runner abort +at 5,007 ms, with no native-open, seed or read phase. This does not conclusively +attribute the Windows delay to infrastructure or establish successful publication. + +Independent private local instrumentation measures 320 ms of a 327-ms bare case +inside the real lazy Home import; preparation reduces that import to 0.018 ms +and the same case to 7 ms. Those local values do not reproduce the actual Windows +delay. The Accepted packaging contract has independent fresh-process compiled +controls; this source-level admission case has no five-second cold TypeScript +compilation SLA. Its purpose remains refusal before history, key, provider or MCP +composition through the actual default `run` route. + +The correction prepares the **real Home module inside the existing owned native +fixture's before-each hook**, after its HOME and service observations are captured. +The import promise is registered with the existing case owner; preparation checks +admission again after the await, and cleanup joins it before restoring the fixture. +Unlike collection-time preparation, module acquisition is not hidden by resetting +service observations afterward. No replacement Home implementation is injected. +All original cases, assertions, causal markers, the unchanged five-second case +bound, cancellation and real native joins remain. Product, build, dependency and +CI bytes are unchanged. This is a separate test-preparation increment after the +accepted `ec0f3e10` runtime/source corrections, not a new runtime or security policy. + +Author execution passes all twelve complete admission cases, plus CLI typecheck, +focused lint and format/diff checks. These local results do not certify Windows +or a complete causal family. Two NEW independent complete reviews of this +preparation increment, a scoped security-witness check and final-head publication +checks are required separately. +Earlier R14/R15 and their metadata checks retain their exact sources and do not +claim to read this later test change. The final PR record owns actual current +results, including the failed attempt; no green retry is inferred here. Steps +9–19 remain unstarted and the PR remains open for maintainer review. From 1c7aec25437678e398a28d23f9bc8c1c8a966944 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 16:51:07 +0300 Subject: [PATCH 219/222] test(cli): bound and retain native concurrency fixture work Use the neighboring native lifecycle suite budget after Windows migration alone exceeded the default case bound. Join runner and scenario cancellation, retain actual closure and add a safely spied real PID-less spawn regression. Product deadlines and native SQLite busy policy are unchanged. Refs: ADR-0103, ADR-0110, PR #95 Co-Authored-By: Codex --- apps/cli/src/harness/concurrency.e2e.test.ts | 45 ++++++++++++---- .../phase-2.5.5-hardening-and-remediation.md | 51 +++++++++++++++++++ 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/harness/concurrency.e2e.test.ts b/apps/cli/src/harness/concurrency.e2e.test.ts index 0b0a986b..2c1e0ee5 100644 --- a/apps/cli/src/harness/concurrency.e2e.test.ts +++ b/apps/cli/src/harness/concurrency.e2e.test.ts @@ -1,4 +1,4 @@ -import { spawn } from 'node:child_process'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import { existsSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -15,7 +15,7 @@ import { type DbClient, } from '@relavium/db'; import { RunEventSchema, type AgentSessionRecord, type SessionMessage } from '@relavium/shared'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { runCommand } from '../commands/run.js'; import { buildEngine } from '../engine/build-engine.js'; @@ -87,6 +87,7 @@ const message = (sessionId: string, seq: number): SessionMessage => ({ }); interface SpawnedChild { + readonly native: ChildProcessWithoutNullStreams; /** Positive initialization ACK; the parent must not hold the write lock during child startup. */ readonly opened: Promise; /** Positive first-write handshake. Early exit rejects rather than impersonating readiness. */ @@ -99,10 +100,11 @@ interface SpawnedChild { } /** Spawn once and retain its actual close even if setup, the test body or readiness fails. */ -function runChild(args: readonly string[]): SpawnedChild { - const child = spawn(process.execPath, [CHILD_SCRIPT, ...args], { +function runChild(args: readonly string[], executable = process.execPath): SpawnedChild { + const child = spawn(executable, [CHILD_SCRIPT, ...args], { stdio: ['pipe', 'pipe', 'pipe'], }); + const pid = child.pid; let stdout = ''; let stderr = ''; let closed = false; @@ -151,6 +153,7 @@ function runChild(args: readonly string[]): SpawnedChild { void observedOpened.catch(() => undefined); void observedReady.catch(() => undefined); return { + native: child, opened: observedOpened, ready: observedReady, done, @@ -158,7 +161,7 @@ function runChild(args: readonly string[]): SpawnedChild { child.stdin.end('START\n'); }, stop: () => { - if (!closed) child.kill(); + if (!closed && pid !== undefined && Number.isSafeInteger(pid) && pid > 0) child.kill(); }, isClosed: () => closed, }; @@ -513,6 +516,26 @@ async function crossProcessCase(signal: AbortSignal, ports: CrossProcessPorts): } describe('concurrency e2e (2.5.I S3) — a run and a chat share one history.db', () => { + it('fixture integrity: a PID-less failed native spawn never receives stop signalling', async () => { + const child = runChild([], join(tmpdir(), `relavium-missing-executable-${randomUUID()}`)); + rawCase = child.done.then(() => undefined); + const kill = vi.spyOn(child.native, 'kill').mockReturnValue(false); + try { + expect(child.native.pid).toBeUndefined(); + expect(child.isClosed()).toBe(false); + child.stop(); + expect(kill, 'NATIVE_FIXTURE_PIDLESS_STOP').not.toHaveBeenCalled(); + const result = await child.done; + expect(result.spawnError).toMatchObject({ code: 'ENOENT' }); + expect(child.isClosed()).toBe(true); + await expect(child.opened).rejects.toThrow('Concurrent writer closed before its handshake'); + await expect(child.ready).rejects.toThrow('Concurrent writer closed before its handshake'); + } finally { + await child.done; + kill.mockRestore(); + } + }); + it('an already-aborted fixture wait still observes the raw child-phase rejection', async () => { const controller = new AbortController(); const reason = new Error('synthetic case already stopped'); @@ -560,7 +583,9 @@ describe('concurrency e2e (2.5.I S3) — a run and a chat share one history.db', } }); - it('case abort seals a held real build and joins writers before native cleanup', async () => { + it('case abort seals a held real build and joins writers before native cleanup', async ({ + signal, + }) => { const controller = new AbortController(); const reason = new Error('synthetic case abort'); const clients: DbClient[] = []; @@ -577,7 +602,7 @@ describe('concurrency e2e (2.5.I S3) — a run and a chat share one history.db', | { readonly code: number; readonly stdout: string; readonly stderr: string } | undefined; let settled = false; - const work = startCoexistenceCase(controller.signal, { + const work = startCoexistenceCase(AbortSignal.any([signal, controller.signal]), { onClient: (client) => clients.push(client), onDirectory: (path) => { directory = path; @@ -696,13 +721,13 @@ describe('concurrency e2e (2.5.I S3) — a run and a chat share one history.db', it.skipIf(!existsSync(DB_DIST_PATH))( 'case abort after child initialization joins both native closes before releasing fixture custody', - async () => { + async ({ signal }) => { const controller = new AbortController(); const reason = new Error('synthetic cross-process abort'); const children: SpawnedChild[] = []; let directory: string | undefined; await expect( - startCrossProcessCase(controller.signal, { + startCrossProcessCase(AbortSignal.any([signal, controller.signal]), { onChild: (child) => children.push(child), onDirectory: (path) => { directory = path; @@ -719,4 +744,4 @@ describe('concurrency e2e (2.5.I S3) — a run and a chat share one history.db', expect(directory !== undefined && existsSync(directory)).toBe(false); }, ); -}); +}, 30_000); // Functional native-file lifecycle, matching session-chain; not a five-second latency contract. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index cc05e270..63d0ad86 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -9044,3 +9044,54 @@ Earlier R14/R15 and their metadata checks retain their exact sources and do not claim to read this later test change. The final PR record owns actual current results, including the failed attempt; no green retry is inferred here. Steps 9–19 remain unstarted and the PR remains open for maintainer review. + +### PR #95 native concurrency fixture budget — 2026-10-18 + +At `154d832d`, the PR-side Windows native advisory passes the twelve prepared +workspace-admission cases, including the original bare-Home failure. It fails a +**different** functional cross-process cancellation case at Vitest's default +five-second case bound. The retained trace shows parent migration completing at +5,795 ms, children opened at 6,477 ms, both actual closes joined at 6,494 ms and +fixture removal at 6,495 ms, with zero cleanup faults. Those timings identify +pre-child native preparation consuming the case budget; they do not establish +why Windows delayed the migration or prove a product runtime defect. + +The native concurrency suite receives the existing neighboring session-chain +suite's **finite 30-second functional native-file case budget**. This covers +migration, two actual OS children and their real closure/removal, rather than +claiming a five-second disk/process latency guarantee. The eight existing cases +retain their assertions and handshake semantics. The existing native SQLite +five-second busy handler and its independent deterministic retry/contention controls remain. +No product or benchmark deadline changes, and no timeout supplies a native-close, +receipt or fixture-cleanup acknowledgement. + +The two synthetic-cancellation scenarios now combine their runner signal with +the scenario controller. An already-delivered abort is observed by the existing +post-migration check before child creation. JavaScript timers do not preempt a +synchronous native migration; a later delivered abort still stops the awaited +child phase. Pending raw work and actual cleanup remain joined by the fixture's +existing after-each owner. Both signals can stop admission, while an ordinary scenario still preserves its +original synthetic reason and held-native assertions. This is a separate native +test-harness correction after the accepted preparation increment; all production, +build, dependency and CI bytes remain unchanged. + +The touched native fixture's stop path also now requires its captured positive +PID. A real missing-executable regression spies on the actual child's kill method +before same-tick stop, retains its actual close, and confirms failed readiness; +no dangerous OS signal is delivered by the negative test. This repairs a private +fixture safeguard, not a newly reported production caller. The eight existing +cases remain and this explicit fixture-integrity case is additive. + +Author execution passes all nine complete concurrency cases, then both affected +native/admission files together: 21 passes, zero skips. CLI typecheck, focused lint, +format and diff checks pass. A temporary PID-guard removal fails the exact marked +negative case while the actual kill method is safely spied; eight other cases +are deliberately unselected in that probe. Byte-exact restoration passes the full +nine-case file. This author probe is not a committed-coordinator runtime receipt +or a claim of independent zero-unhandled-error acceptance. + +Two NEW complete independent reviews of this increment and a NEW scoped security +witness check are required, alongside actual final-head PR/push/native/platform/ +physical publication results. The two earlier preparation reviews retain their +exact `154d832d` scope. The failed Windows attempt remains retained; neither a +successful retry nor full publication is inferred. Steps 9–19 remain unstarted. From a1e956f1c7ce74202b0531a74dddce06d5b60f2d Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 17:03:49 +0300 Subject: [PATCH 220/222] docs(docs): record scoped pr95 native fixture acceptance Record independent preparation and concurrency fixture reviews, retain failed Windows attempts and separate final-source publication criteria. Refs: ADR-0103, ADR-0110, Wave 3 Steps 1-8 Co-Authored-By: Codex --- docs/roadmap/current.md | 12 ++++-- .../phase-2.5.5-hardening-and-remediation.md | 38 +++++++++++++++++++ docs/standards/security-review.md | 31 +++++++++++++++ 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 1f76652c..4e3cec4d 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -141,9 +141,11 @@ independent acceptance. The initial publication checkpoint is [PR #95](https://github.com/HodeTech/Relavium/pull/95). A second maintainer systematic review at `a5a5d45e` reopened corrective work; its [current correction register](phases/phase-2.5.5-hardening-and-remediation.md#pr-95-second-systematic-review-corrections--2026-10-18) -owns dispositions and remaining gates. New corrections are pending independent -review and publication checks. Steps 9–19 have not started; check the PR status -before extending its scope with Step 9. See the [Wave 3 queue](#wave-3--clear-the-ground). +owns dispositions and remaining gates. Corrective source reviews are complete; +the two later native test-fixture increments have [separate scoped acceptance](phases/phase-2.5.5-hardening-and-remediation.md#pr-95-supplemental-native-fixture-source-acceptance--2026-10-18). +Final pushed-head publication checks remain required, with their live results on +PR #95. Steps 9–19 have not started; check the PR status before extending its scope +with Step 9. See the [Wave 3 queue](#wave-3--clear-the-ground). ## Historical W7 implementation checkpoints @@ -1131,7 +1133,9 @@ required publication criteria; PR #95 owns their live result. Source review does not imply merge or whole-wave acceptance. The later [native Windows admission preparation](phases/phase-2.5.5-hardening-and-remediation.md#pr-95-native-windows-admission-preparation--2026-10-18) retains the failed advisory attempt and separate test-preparation review scope; -production runtime is unchanged and PR #95 owns final publication results. +production runtime is unchanged. The later [supplemental fixture acceptance](phases/phase-2.5.5-hardening-and-remediation.md#pr-95-supplemental-native-fixture-source-acceptance--2026-10-18) +records both owned-preparation and native-concurrency review scopes, including +both failed Windows attempts. PR #95 owns final pushed-head publication results. Steps 9–19 have **NOT STARTED** and whole-wave acceptance remains Step 19. Check PR #95 before later implementation commits, which extend its review scope. No new decision, credential, billing or provider capture gate is introduced. diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 63d0ad86..1b3f8c86 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -9095,3 +9095,41 @@ witness check are required, alongside actual final-head PR/push/native/platform/ physical publication results. The two earlier preparation reviews retain their exact `154d832d` scope. The failed Windows attempt remains retained; neither a successful retry nor full publication is inferred. Steps 9–19 remain unstarted. + +### PR #95 supplemental native fixture source acceptance — 2026-10-18 + +The two later test-only increments now have their own independent acceptance. +The owned Home-preparation increment at `154d832d` has two NEW complete clean +ordinary reviews of all **three paths / three U3 hunks**, plus a NEW separate +scoped clean security-witness sitting. Its twelve complete admission cases pass +independently. The native-concurrency increment at `1c7aec25` has two NEW complete +clean ordinary reviews of all **two paths / twelve U3 hunks** (eleven test hunks +and one phase-record hunk), plus a NEW separate scoped clean security sitting. +Each native reviewer independently executes all nine cases, with zero skips. +Root verifies complete body/diff/ordered-hunk and retained-log identities; code +bodies are read in full and historical documents use explicit neighboring and +all-affected semantic-clause modes. No earlier verdict or read flags replace either review. + +The native functional runner allowance is finite at 30 seconds per case; all +eight original native cases remain and the real failed-spawn safeguard is additive. +Runner/scenario signal composition and actual raw/native joins preserve the +synchronous-migration qualification recorded above. This is not a product deadline, +performance guarantee, new acknowledgement, whole-PR review or new full 108-path +review. The [supplemental security index](../../standards/security-review.md#sitting-pr-95-supplemental-native-test-fixtures-2026-10-18) +owns the separate witness scopes and exclusions. Detailed ledgers remain local +and Git-ignored. + +All production, configuration, dependency, build and CI bytes remain identical to +accepted corrective source `ec0f3e10`. The exact differences are the two private +test files and three documentation files; all other **1,764 tracked entries** +remain identical. R14/R15 and their security sitting retain their exact earlier +source. The final documentation-only closing delta is checked separately and +does not inherit a full-source review or execution claim. + +Both failed Windows attempts remain retained, with their distinct actual traces. +Final pushed-head PR/push CI, coverage, SonarCloud, all four same-archive consumers +and physical filesystem/secrets/usage/wire checks are required publication +criteria; [PR #95](https://github.com/HodeTech/Relavium/pull/95) owns their live +result. Source acceptance does not assert a green retry or completed publication. +Steps **9–19 have NOT STARTED**. No merge, release, new user decision or deferral +is claimed. diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index 3a2e5427..70a006db 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -788,6 +788,37 @@ receipt, native-close or safe-store-close ACK. The [source-review completion register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-second-corrective-source-review-completion--2026-10-18) separately owns publication criteria and Steps 9–19 remaining unstarted. +### Sitting: PR #95 supplemental native test fixtures (2026-10-18) + +Two NEW separate scoped witness sittings are **CLEAN**: owned Home preparation +at `154d832d` (three paths / three U3 hunks), and native concurrency at `1c7aec25` +(two paths / twelve U3 hunks). They cover their complete changed fixture bodies, +actual native/runner callers and all affected historical clauses. Root verifies +body, diff, ordered-hunk and retained-log identities. Preparation report SHA-256: +`f7d66bba60fa2cfbf22c2f999a826c75ba965e9e0010e1c2fd7a5e9b5766ec37`; +native report SHA-256: +`0f6d1b46035dcebe8a55fc91cb618d85bfde9dd658b65d03a48e57cde55a253b`. +These are separate from the earlier 108-path security sitting and from each +increment's two NEW complete ordinary reviews; no whole-PR review is inferred. + +Independent execution passes twelve preparation cases and nine native cases, +respectively, with zero skips. The sittings verify captured fixture observations, +retained real Home import and post-await admission; creation-owned positive PID +signalling; the real ENOENT safely-spied negative; runner/scenario cancellation +and actual raw/native-close joins. The finite 30-second native functional case +budget does not change product/native-busy policy or mint an acknowledgement. +JavaScript timers do not preempt synchronous migration. The selected preparation +coordinator is inspected with `complete:false`; the manual native PID-guard probe +is not a committed runtime-reporter receipt. Ordinary Vitest output is not +promoted to a bound zero-unhandled-error certificate. + +No product, configuration, dependency, build or CI byte changes from `ec0f3e10`. +No live provider, keychain, paid call, GPG, full CI/coverage, operator TTY, other-OS +execution or complete physical control family is performed by these sittings. +The [supplemental source register](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#pr-95-supplemental-native-fixture-source-acceptance--2026-10-18) +retains both actual failed Windows attempts and final-head publication criteria. +Steps 9–19 remain unstarted; the PR owns the final live results. + ## Sandbox and tool policy (`run_command`, node tools, secret inputs) This is the **binding home** for the tool-policy rules; the rationale is From 19c999683ca00f6c760f8d4f52c96b0afdd83581 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 18 Oct 2026 17:08:26 +0300 Subject: [PATCH 221/222] docs(docs): attribute native preparation evidence to its executor Correct the supplemental security index and retain NONCLEAN metadata rounds plus the mutable progress-snapshot qualification. Refs: ADR-0103, ADR-0110, PR #95 Co-Authored-By: Codex --- .../phases/phase-2.5.5-hardening-and-remediation.md | 10 +++++++++- docs/standards/security-review.md | 6 ++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index 1b3f8c86..91f78042 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -9124,7 +9124,15 @@ accepted corrective source `ec0f3e10`. The exact differences are the two private test files and three documentation files; all other **1,764 tracked entries** remain identical. R14/R15 and their security sitting retain their exact earlier source. The final documentation-only closing delta is checked separately and -does not inherit a full-source review or execution claim. +does not inherit a full-source review or execution claim. Its first two metadata +checks at `a1e956f1` are NONCLEAN: both verify that the index incorrectly attributes +the twelve preparation tests to the security sitting. The correction explicitly +names ordinary reviewer R2 as executor and the preparation sitting as inspection +only; the native security sitting retains its actual nine-case execution. Fresh +limited checks own the corrected metadata source. An older mutable progress-state +JSON no longer matches its review-time hash; it is not treated as an immutable +snapshot or reconstructed. Actual raw logs and package receipts remain separate +historical evidence. Both failed Windows attempts remain retained, with their distinct actual traces. Final pushed-head PR/push CI, coverage, SonarCloud, all four same-archive consumers diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index 70a006db..c24c769a 100644 --- a/docs/standards/security-review.md +++ b/docs/standards/security-review.md @@ -801,8 +801,10 @@ native report SHA-256: These are separate from the earlier 108-path security sitting and from each increment's two NEW complete ordinary reviews; no whole-PR review is inferred. -Independent execution passes twelve preparation cases and nine native cases, -respectively, with zero skips. The sittings verify captured fixture observations, +The ordinary preparation reviewer R2 independently passes twelve cases, with +zero skips; the preparation security sitting inspects that evidence and executes +no tests. The native security sitting independently passes nine cases, zero +skips. The scoped security checks verify captured fixture observations, retained real Home import and post-await admission; creation-owned positive PID signalling; the real ENOENT safely-spied negative; runner/scenario cancellation and actual raw/native-close joins. The finite 30-second native functional case From a3e164b5608d386fc3db7fa3b8483d0adf2ed0fd Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Mon, 19 Oct 2026 00:51:18 +0300 Subject: [PATCH 222/222] refactor(tests): enhance URL userinfo masking and improve child process handling --- .../native-forwarding.test-support.ts | 13 +- .../engine/tool-host/process-close.test.ts | 41 ++++ apps/cli/src/harness/mcp-orphan.e2e.test.ts | 215 +++++++++--------- apps/cli/src/render/sanitize.test.ts | 12 + packages/core/src/agent-parser.ts | 2 +- packages/core/src/parser.ts | 2 +- packages/llm/src/scrub-secrets.test.ts | 16 ++ packages/llm/src/scrub-secrets.ts | 10 +- tools/causal-controls/provenance.test.mjs | 8 +- tools/secrets-controls/mutations.mjs | 18 +- 10 files changed, 214 insertions(+), 123 deletions(-) diff --git a/apps/cli/src/commands/native-forwarding.test-support.ts b/apps/cli/src/commands/native-forwarding.test-support.ts index e58f100b..e80f460f 100644 --- a/apps/cli/src/commands/native-forwarding.test-support.ts +++ b/apps/cli/src/commands/native-forwarding.test-support.ts @@ -10,6 +10,13 @@ import { type NativeProcessController, } from '../process/native-process-owner.js'; +/** + * These waits observe a REAL spawned child (or the retirement reaction of one). vitest's default + * `waitFor` timeout is 1 s, which a cold or loaded worker can exceed before a child has booted. + * Kept under the 5 s default test timeout so a genuine failure surfaces as its own assertion. + */ +const REAL_CHILD_WAIT_MS = 4_000; + /** A physical child witness for host-only forwarding, with an independent tool timeout. */ export async function witnessNativeToolProcess( nativeProcesses: NativeProcessController | undefined, @@ -31,6 +38,7 @@ export async function witnessNativeToolProcess( // queued. Require its actual removal before measuring the stable native-parent baseline. await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(expectedParentOwners), { interval: 1, + timeout: REAL_CHILD_WAIT_MS, }); const pending = host.process.spawn( process.execPath, @@ -43,11 +51,12 @@ export async function witnessNativeToolProcess( expect(invocation.registry.activeOwners, 'NATIVE_PROCESS_ASSEMBLY_OWNERSHIP').toBe( expectedParentOwners + 1, ), - { interval: 1 }, + { interval: 1, timeout: REAL_CHILD_WAIT_MS }, ); expect(await pending).toMatchObject({ exitCode: 0, stdout: 'native-witness' }); await vi.waitFor(() => expect(invocation.registry.activeOwners).toBe(expectedParentOwners), { interval: 1, + timeout: REAL_CHILD_WAIT_MS, }); } @@ -94,7 +103,7 @@ export async function holdNativeToolProcess( expect(existsSync(marker)).toBe(true); // physical native entry, not only a reserved field expect(invocation.registry.activeOwners).toBe(before + 1); }, - { interval: 1 }, + { interval: 1, timeout: REAL_CHILD_WAIT_MS }, ); return { close }; } catch (error) { diff --git a/apps/cli/src/engine/tool-host/process-close.test.ts b/apps/cli/src/engine/tool-host/process-close.test.ts index 5ea84b1b..140d7510 100644 --- a/apps/cli/src/engine/tool-host/process-close.test.ts +++ b/apps/cli/src/engine/tool-host/process-close.test.ts @@ -5,6 +5,11 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createCliInvocation } from '../../process/invocation.js'; +import { + nativeProcessesForInvocation, + type NativeProcessController, +} from '../../process/native-process-owner.js'; import { createNodeProcessCapability, ProcessCapabilityError } from './process.js'; vi.mock('node:child_process', { spy: true }); @@ -121,4 +126,40 @@ describe('native child resource lifetime after an error (ADR-0103)', () => { expect(error).toMatchObject({ message: 'the command failed to run' }); expect(closed).toBe(true); }); + + it('withholds the failed-spawn result until the owner reservation joins close', async () => { + // The child's own `close` event cannot pin this: the capability's handler only runs after it, so a + // rejection that skipped `reservation.whenClosed` would still see a closed child (and, on POSIX with no + // helper, an already-retired owner). Gate the reservation's join itself and observe the publication. + const real = nativeProcessesForInvocation(createCliInvocation()); + if (real === undefined) throw new Error('explicit invocation missing'); + const gate = deferred(); + const gated: NativeProcessController = { + ...real, + reserve: async (kind) => ({ ...(await real.reserve(kind)), whenClosed: gate.promise }), + }; + let closed = false; + vi.mocked(spawn).mockImplementationOnce((command, args, options) => { + const child = native.spawn(command, args, options); + child.once('close', () => { + closed = true; + }); + return child; + }); + let settled = false; + const call = createNodeProcessCapability({ workspaceDir: workspace, nativeProcesses: gated }) + .spawn(join(workspace, 'absent-executable'), [], {}, {}) + .catch((cause: unknown) => cause) + .finally(() => { + settled = true; + }); + await vi.waitFor(() => expect(closed).toBe(true)); + // Microtasks queued by the close dispatch have run; give any ungated publication one more turn. + await new Promise((resolve) => setImmediate(resolve)); + expect(settled, 'PROCESS_OWNER_CLOSE_JOIN').toBe(false); + gate.resolve(); + const error = await call; + expect(error).toBeInstanceOf(ProcessCapabilityError); + expect(error).toMatchObject({ message: 'the command failed to run' }); + }); }); diff --git a/apps/cli/src/harness/mcp-orphan.e2e.test.ts b/apps/cli/src/harness/mcp-orphan.e2e.test.ts index 4ed61deb..2e8d23b0 100644 --- a/apps/cli/src/harness/mcp-orphan.e2e.test.ts +++ b/apps/cli/src/harness/mcp-orphan.e2e.test.ts @@ -105,108 +105,113 @@ async function until(predicate: () => boolean, budgetMs = 10_000): Promise { - it('surfaces the spawned child pid, so a host has something to reap', async () => { - // The precondition for everything below, and a claim worth pinning on its own: before ADR-0088 the pid - // never left `@relavium/mcp`, so a synchronous reap was not expressible at all. - const client = await connectAgentMcp([stubbornServer()], { - cwd: process.cwd(), - consentGate: allowAll, - }); - expect(client).toBeDefined(); - try { - expect(client?.childPids).toHaveLength(1); - const pid = client?.childPids[0]; - expect(typeof pid).toBe('number'); - expect(alive(pid as number)).toBe(true); - } finally { - await client?.close(); - } - }, 30_000); - - it('the SYNCHRONOUS exit net kills a SIGTERM-trapping child — the path nobody awaits', async () => { - // **The assertion the guard exists for.** `driveRun`'s second-Ctrl-C force-quit, the outermost crash net, - // and any `process.exit` in the tree all terminate without awaiting `close()`. The cooperative half cannot - // cover them; this one runs on `process.on('exit')` and must be synchronous, which is why it SIGKILLs a - // pid rather than awaiting a transport. - const invocation = createCliInvocation(); - const client = await connectAgentMcp([stubbornServer()], { - cwd: process.cwd(), - consentGate: allowAll, - nativeProcesses: createNativeProcessController(invocation.registry), - }); - // **Everything after the connect runs under a `finally`.** A failing assertion here used to leave a live - // `SIGTERM`-trapping child behind, and this file's own later test filters the process table by fixture - // name — so one red run seeded orphans that made the next run red for a different reason. The first test - // in this file already had the guard; these two did not. - try { - const pid = client?.childPids[0]; - expect(typeof pid).toBe('number'); - - // Drive the net directly rather than exiting this test process: the mechanism under test is the - // callback, and a real `process.exit` here would take the runner with it. - invocation.registry.reapNativeAtExit(); - // Native cancellation is synchronous; process-table removal follows asynchronously. - expect(await until(() => !alive(pid as number))).toBe(true); - } finally { - await client?.close(); - } - }, 30_000); - - it('the creation-owned registry reaps on a signal before command finally cleanup', async () => { - // Drive the same registry stop pass used by the finite-exit signal owner. The command's - // finally cleanup runs only after the native owner has proved its actual retirement. - const invocation = createCliInvocation(); - const client = await connectAgentMcp([stubbornServer()], { - cwd: process.cwd(), - consentGate: allowAll, - nativeProcesses: createNativeProcessController(invocation.registry), - }); - try { - const pid = client?.childPids[0]; - expect(typeof pid).toBe('number'); - - const stopped = invocation.registry.terminate({ kind: 'signal', signalNumber: 2 }); - expect(await until(() => !alive(pid as number))).toBe(true); - expect(await stopped).toMatchObject({ kind: 'settled', unacknowledgedOwners: 0 }); - expect(invocation.registry.activeOwners).toBe(0); - } finally { - await client?.close(); - } - }, 30_000); - - it('cancels an IN-FLIGHT connect and reaps the child spawned inside it', async () => { - // **The branch a review found uncovered, and it is the one the whole ordering exists for.** The SDK spawns - // the child inside `transport.start()`, so between the spawn and a completed `initialize` there is a live - // process and no client — `McpClient.childPids` is still empty, so neither reaper has a pid, and the only - // thing that can stop it is the connect signal itself. - // - // It was broken in a way that type-checked: the adapters took a `signal`, `startMcpClient` passed one, and - // the CLI's own `open` closure was written `open: () => …`, which accepts the call and drops the argument. - // A Ctrl-C during a cold `npx` therefore stopped nothing. The unit test asserts the signal's IDENTITY - // reaches the opener; this one asserts what that buys — a real child, gone. - const before = childPidsOf(SILENT); - const cancel = new AbortController(); - const connecting = connectAgentMcp([silentServer()], { - cwd: process.cwd(), - consentGate: allowAll, - connectSignal: cancel.signal, - }); - // Wait for the spawn rather than assuming it — cancelling before it would prove nothing, and a fixed - // 500 ms sleep is a bet on the runner. A cold `npx` on a loaded CI box loses that bet, and the failure - // reads as "the cancel did not reap" when in fact nothing had been spawned yet. - // - // Only the pids THIS test spawned. Scanning the whole table for the fixture name also picks up orphans a - // previous failing run left behind, which nothing will ever reap — a self-poisoning assertion that fails - // for the wrong reason and then keeps failing. - const newPids = (): number[] => childPidsOf(SILENT).filter((pid) => !before.includes(pid)); - expect(await until(() => newPids().length > 0)).toBe(true); - const spawned = newPids(); - - cancel.abort(); - await expect(connecting).rejects.toThrow(); - // The connect's own teardown reaps what it spawned; nothing else can, because no client was ever returned - // and `childPids` is therefore empty. Budgeted well above the SDK's ~4.6 s ladder against a trapping child. - expect(await until(() => spawned.every((pid) => !alive(pid)), 20_000)).toBe(true); - }, 30_000); -}); +// The process table is read with POSIX `ps -A -o pid=,args=`, which does not exist on Windows: skip rather +// than fail at the first `execFileSync`. Windows native reaping is covered by the portable owner units. +describe.skipIf(process.platform === 'win32')( + 'MCP orphan reaping — against the real child-process table', + () => { + it('surfaces the spawned child pid, so a host has something to reap', async () => { + // The precondition for everything below, and a claim worth pinning on its own: before ADR-0088 the pid + // never left `@relavium/mcp`, so a synchronous reap was not expressible at all. + const client = await connectAgentMcp([stubbornServer()], { + cwd: process.cwd(), + consentGate: allowAll, + }); + expect(client).toBeDefined(); + try { + expect(client?.childPids).toHaveLength(1); + const pid = client?.childPids[0]; + expect(typeof pid).toBe('number'); + expect(alive(pid as number)).toBe(true); + } finally { + await client?.close(); + } + }, 30_000); + + it('the SYNCHRONOUS exit net kills a SIGTERM-trapping child — the path nobody awaits', async () => { + // **The assertion the guard exists for.** `driveRun`'s second-Ctrl-C force-quit, the outermost crash net, + // and any `process.exit` in the tree all terminate without awaiting `close()`. The cooperative half cannot + // cover them; this one runs on `process.on('exit')` and must be synchronous, which is why it SIGKILLs a + // pid rather than awaiting a transport. + const invocation = createCliInvocation(); + const client = await connectAgentMcp([stubbornServer()], { + cwd: process.cwd(), + consentGate: allowAll, + nativeProcesses: createNativeProcessController(invocation.registry), + }); + // **Everything after the connect runs under a `finally`.** A failing assertion here used to leave a live + // `SIGTERM`-trapping child behind, and this file's own later test filters the process table by fixture + // name — so one red run seeded orphans that made the next run red for a different reason. The first test + // in this file already had the guard; these two did not. + try { + const pid = client?.childPids[0]; + expect(typeof pid).toBe('number'); + + // Drive the net directly rather than exiting this test process: the mechanism under test is the + // callback, and a real `process.exit` here would take the runner with it. + invocation.registry.reapNativeAtExit(); + // Native cancellation is synchronous; process-table removal follows asynchronously. + expect(await until(() => !alive(pid as number))).toBe(true); + } finally { + await client?.close(); + } + }, 30_000); + + it('the creation-owned registry reaps on a signal before command finally cleanup', async () => { + // Drive the same registry stop pass used by the finite-exit signal owner. The command's + // finally cleanup runs only after the native owner has proved its actual retirement. + const invocation = createCliInvocation(); + const client = await connectAgentMcp([stubbornServer()], { + cwd: process.cwd(), + consentGate: allowAll, + nativeProcesses: createNativeProcessController(invocation.registry), + }); + try { + const pid = client?.childPids[0]; + expect(typeof pid).toBe('number'); + + const stopped = invocation.registry.terminate({ kind: 'signal', signalNumber: 2 }); + expect(await until(() => !alive(pid as number))).toBe(true); + expect(await stopped).toMatchObject({ kind: 'settled', unacknowledgedOwners: 0 }); + expect(invocation.registry.activeOwners).toBe(0); + } finally { + await client?.close(); + } + }, 30_000); + + it('cancels an IN-FLIGHT connect and reaps the child spawned inside it', async () => { + // **The branch a review found uncovered, and it is the one the whole ordering exists for.** The SDK spawns + // the child inside `transport.start()`, so between the spawn and a completed `initialize` there is a live + // process and no client — `McpClient.childPids` is still empty, so neither reaper has a pid, and the only + // thing that can stop it is the connect signal itself. + // + // It was broken in a way that type-checked: the adapters took a `signal`, `startMcpClient` passed one, and + // the CLI's own `open` closure was written `open: () => …`, which accepts the call and drops the argument. + // A Ctrl-C during a cold `npx` therefore stopped nothing. The unit test asserts the signal's IDENTITY + // reaches the opener; this one asserts what that buys — a real child, gone. + const before = childPidsOf(SILENT); + const cancel = new AbortController(); + const connecting = connectAgentMcp([silentServer()], { + cwd: process.cwd(), + consentGate: allowAll, + connectSignal: cancel.signal, + }); + // Wait for the spawn rather than assuming it — cancelling before it would prove nothing, and a fixed + // 500 ms sleep is a bet on the runner. A cold `npx` on a loaded CI box loses that bet, and the failure + // reads as "the cancel did not reap" when in fact nothing had been spawned yet. + // + // Only the pids THIS test spawned. Scanning the whole table for the fixture name also picks up orphans a + // previous failing run left behind, which nothing will ever reap — a self-poisoning assertion that fails + // for the wrong reason and then keeps failing. + const newPids = (): number[] => childPidsOf(SILENT).filter((pid) => !before.includes(pid)); + expect(await until(() => newPids().length > 0)).toBe(true); + const spawned = newPids(); + + cancel.abort(); + await expect(connecting).rejects.toThrow(); + // The connect's own teardown reaps what it spawned; nothing else can, because no client was ever returned + // and `childPids` is therefore empty. Budgeted well above the SDK's ~4.6 s ladder against a trapping child. + expect(await until(() => spawned.every((pid) => !alive(pid)), 20_000)).toBe(true); + }, 30_000); + }, +); diff --git a/apps/cli/src/render/sanitize.test.ts b/apps/cli/src/render/sanitize.test.ts index cb267423..00a18161 100644 --- a/apps/cli/src/render/sanitize.test.ts +++ b/apps/cli/src/render/sanitize.test.ts @@ -249,6 +249,18 @@ describe('sanitizeUntrusted — the failure paths carry arbitrary text (#W15-8)' } } }); + + it('masks URL userinfo whose scheme is glued to a preceding word character (PR #95 MEDIUM-1)', () => { + for (const sanitize of [sanitizeUntrusted, sanitizeUntrustedInline]) { + expect(sanitize('xhttps://user:opaque-value@host/x')).toBe('xhttps://[REDACTED]@host/x'); + expect(sanitize('1wss://opaque-user@host/x retry after 30s')).toBe( + '1wss://[REDACTED]@host/x retry after 30s', + ); + for (const separator of ['\n', '\t']) { + expect(sanitize(`xhttps://us${separator}er:opaque-value@host/x`)).toBe('[REDACTED]'); + } + } + }); }); describe('PR95 B2 complete diagnostic evidence before display normalization', () => { diff --git a/packages/core/src/agent-parser.ts b/packages/core/src/agent-parser.ts index f2f921b9..7a435da8 100644 --- a/packages/core/src/agent-parser.ts +++ b/packages/core/src/agent-parser.ts @@ -1,4 +1,3 @@ -import { schemaIssueReason } from './schema-issue.js'; /** * `parseAgent` — the standalone `.agent.yaml` parser, the agent sibling of {@link ./parser.ts}'s * `parseWorkflow`. It loads an agent-document **string**, decodes it through the one hardened YAML profile @@ -18,6 +17,7 @@ import { RelaviumError, AgentSchema, type Agent } from '@relavium/shared'; import { MAX_SOURCE_CHARS } from './parser.js'; import { outputSchemaRefusals } from './output-schema.js'; +import { schemaIssueReason } from './schema-issue.js'; import { decodeHardenedYaml } from './yaml-decode.js'; /** The validated agent document — `@relavium/shared`'s `Agent`, under a parser-local alias (mirrors `WorkflowDefinition`). */ diff --git a/packages/core/src/parser.ts b/packages/core/src/parser.ts index f76ba565..cf508fbb 100644 --- a/packages/core/src/parser.ts +++ b/packages/core/src/parser.ts @@ -1,4 +1,3 @@ -import { schemaIssueReason } from './schema-issue.js'; /** * `WorkflowYAMLParser` (1.L / 1.L2) — the engine's entry point. Loads a `.relavium.yaml` **string**, * validates it against the strict `@relavium/shared` `WorkflowSchema` (ADR-0023), and runs the static @@ -31,6 +30,7 @@ import { } from './errors.js'; import { analyzePreRunReferences, analyzeSecretTaint } from './interpolation/analyze.js'; import { outputSchemaRefusals } from './output-schema.js'; +import { schemaIssueReason } from './schema-issue.js'; import { decodeHardenedYaml } from './yaml-decode.js'; /** The validated workflow document — `@relavium/shared`'s `Workflow`, under a parser-local alias. */ diff --git a/packages/llm/src/scrub-secrets.test.ts b/packages/llm/src/scrub-secrets.test.ts index 59c6d814..31aac2bb 100644 --- a/packages/llm/src/scrub-secrets.test.ts +++ b/packages/llm/src/scrub-secrets.test.ts @@ -83,6 +83,22 @@ describe('finite diagnostic credential backstop regressions (PR #95 M2)', () => expected: `connect ${scheme.toUpperCase()}://[REDACTED]@provider.invalid/v1 failed`, }, ]), + // A scheme glued to a preceding word character was masked before this PR; a `\b` on the rule + // let it through (PR #95 review, MEDIUM-1). Adjacent and LF/TAB-split forms are both pinned. + ...['http', 'https', 'ws', 'wss', 'ftp', 'ftps'].flatMap((scheme) => [ + { + input: `x${scheme}://alice:opaque-password@provider.invalid/v1`, + expected: `x${scheme}://[REDACTED]@provider.invalid/v1`, + }, + { + input: `1${scheme.toUpperCase()}://opaque-user@provider.invalid/v1`, + expected: `1${scheme.toUpperCase()}://[REDACTED]@provider.invalid/v1`, + }, + ]), + ...['\n', '\t'].map((separator) => ({ + input: `xhttps://ali${separator}ce:opaque-password@provider.invalid/v1`, + expected: '[REDACTED]', + })), ...['api-key', 'x-api-key', 'x-goog-api-key'].flatMap((name) => [ { input: `header ${name}: opaque-header-Z29 rejected`, diff --git a/packages/llm/src/scrub-secrets.ts b/packages/llm/src/scrub-secrets.ts index 974fd510..9d9921a3 100644 --- a/packages/llm/src/scrub-secrets.ts +++ b/packages/llm/src/scrub-secrets.ts @@ -26,6 +26,13 @@ interface SecretRule { readonly boundary?: RegExp; } +/** + * Deliberately NOT word-bounded: the pre-PR matcher masked `xhttps://user:pass@host`, and a `\b` here + * would let any scheme glued to a preceding word character through unmasked. The rule has no `\b` + * prefix, so it also supplies its own joined form rather than letting the scanner strip one. + */ +const URL_USERINFO_PATTERN = /(?:https?|wss?|ftps?):\/\/[^/\s]*@/gi; + /** One private authority per existing credential family, shared by probes and output masking. */ const NON_QUERY_SECRET_RULES: readonly SecretRule[] = [ // OpenAI sk-/sk-proj-/sk-svcacct- and Anthropic sk-ant- key prefixes. @@ -42,7 +49,8 @@ const NON_QUERY_SECRET_RULES: readonly SecretRule[] = [ // Finite authority schemes, not URL validation. Raw ?/# before the first @ may belong // to a password; after an existing @ they begin public query/fragment text. { - pattern: /\b(?:https?|wss?|ftps?):\/\/[^/\s]*@/gi, + pattern: URL_USERINFO_PATTERN, + joinedPattern: URL_USERINFO_PATTERN, replacement: maskUrlUserinfo, boundary: /\w/, }, diff --git a/tools/causal-controls/provenance.test.mjs b/tools/causal-controls/provenance.test.mjs index 36c953dd..d1f19400 100644 --- a/tools/causal-controls/provenance.test.mjs +++ b/tools/causal-controls/provenance.test.mjs @@ -287,7 +287,7 @@ test('secrets workload corrections retain independent complete-count and reader- [ 'diagnostic-residual-scrub', ['apps/cli/src/render/sanitize.test.ts', 'apps/cli/src/process/render-error.test.ts'], - 179, + 180, ], ...['prefix-before-redaction', 'normalized-secret-probe', 'non-query-before-query'].map( (id) => [ @@ -297,7 +297,7 @@ test('secrets workload corrections retain independent complete-count and reader- 'apps/cli/src/render/sanitize.test.ts', 'apps/cli/src/process/render-error.test.ts', ], - 277, + 278, ], ), ...[ @@ -317,11 +317,11 @@ test('secrets workload corrections retain independent complete-count and reader- 'diagnostic-raw-password-userinfo', 'diagnostic-joined-original-boundary-recovery', 'diagnostic-header-original-boundary-recovery', - ].map((id) => [id, ['packages/llm/src/scrub-secrets.test.ts'], 89]), + ].map((id) => [id, ['packages/llm/src/scrub-secrets.test.ts'], 103]), ...['diagnostic-inline-projection-order', 'diagnostic-original-control-boundary'].map((id) => [ id, ['apps/cli/src/render/sanitize.test.ts', 'apps/cli/src/process/render-error.test.ts'], - 179, + 180, ]), ]) { const selected = secretsControls.find((entry) => entry.id === id); diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index 61514032..96d47e90 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -234,7 +234,7 @@ function diagnosticControl(id, before, after, targets, marker = diagnosticMarker before, after, ['packages/llm/src/scrub-secrets.test.ts'], - 89, + 103, targets[0], marker, targets.slice(1).map((target) => ({ target, marker })), @@ -355,7 +355,7 @@ export const mutations = [ " for (const remaining of scrubbed.split('[REDACTED]')) {", ' for (const remaining of []) {', ['apps/cli/src/render/sanitize.test.ts', 'apps/cli/src/process/render-error.test.ts'], - 179, + 180, "SECRETS_CAUSAL_RESIDUAL_SCRUB protects 'client_secret' beside a split credential: 'multiline'", 'SECRETS_CAUSAL_RESIDUAL_SCRUB', [ @@ -397,7 +397,7 @@ export const mutations = [ 'apps/cli/src/render/sanitize.test.ts', 'apps/cli/src/process/render-error.test.ts', ], - 277, + 278, 'SECRETS_CAUSAL_PREFIX_ORDER protects client_secret before overlapping query redaction', 'SECRETS_CAUSAL_PREFIX_ORDER', [ @@ -446,7 +446,7 @@ export const mutations = [ ' return stripTerminalControls(text);', ' return text;', overlapFiles, - 277, + 278, `${overlapMarker} protects client_secret normalized key before query redaction`, overlapMarker, overlapWitnesses('normalized key').filter( @@ -461,7 +461,7 @@ export const mutations = [ ' return scrubQuerySecrets(scrubNonQuerySecrets(text));', ' return scrubNonQuerySecrets(scrubQuerySecrets(text));', overlapFiles, - 277, + 278, `${overlapMarker} protects client_secret Bearer before query redaction`, overlapMarker, [ @@ -967,8 +967,8 @@ export const mutations = [ ), diagnosticControl( 'diagnostic-raw-password-userinfo', - String.raw`pattern: /\b(?:https?|wss?|ftps?):\/\/[^/\s]*@/gi,`, - String.raw`pattern: /\b(?:https?|wss?|ftps?):\/\/[^/\s?#]*@/gi,`, + String.raw`const URL_USERINFO_PATTERN = /(?:https?|wss?|ftps?):\/\/[^/\s]*@/gi;`, + String.raw`const URL_USERINFO_PATTERN = /(?:https?|wss?|ftps?):\/\/[^/\s?#]*@/gi;`, [...diagnosticPasswords, ...rawPasswordAssignments], ), // R1-LN-1: recover a valid original start hidden inside a rejected greedy joined candidate. @@ -1008,7 +1008,7 @@ export const mutations = [ ' return sanitizeInline(sanitizeUntrusted(text));', ' return sanitizeUntrusted(sanitizeInline(text));', displayProjectionCases, - 179, + 180, displayOrderCases[0], displayOrderMarker, [ @@ -1022,7 +1022,7 @@ export const mutations = [ ' return redactIncludingAcrossSeparators(stripTerminalControls(scrubSecrets(text)));', ' return redactIncludingAcrossSeparators(stripTerminalControls(text));', displayProjectionCases, - 179, + 180, displayBoundaryCases[0], displayBoundaryMarker, [