diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 341fa2cf7..4a50a68e8 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,11 +57,14 @@ 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 - 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: @@ -94,6 +97,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 @@ -169,6 +177,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 @@ -270,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 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 @@ -396,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) @@ -421,9 +434,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 @@ -453,7 +470,10 @@ 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. 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: @@ -574,3 +594,146 @@ 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 + 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 + 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 + env: + TURBO_TOKEN: '' + TURBO_TEAM: '' + 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: 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: + 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 bf309b864..83c0ea3ef 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/AGENTS.md b/AGENTS.md index ed5228078..8d7dc760d 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,67 @@ 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), +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 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 independently +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 independently accepted after +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 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; +it is independently accepted at `e0a5ae19` after two NEW complete cumulative +clean rounds and a separate clean security sitting. +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. 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. +**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. 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. -**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 eb9b21ca3..8c828f2a9 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,64 @@ 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), +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 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 independently +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 independently accepted after +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 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; +it is independently accepted at `e0a5ae19` after two NEW complete cumulative +clean rounds and a separate clean security sitting. +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. 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. +**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. 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); @@ -190,17 +217,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 73fefc38d..c28b08d6a 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/apps/cli/package.json b/apps/cli/package.json index 0b1f78f9f..101ab6791 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/authoring/create-prompter-native.test.ts b/apps/cli/src/authoring/create-prompter-native.test.ts new file mode 100644 index 000000000..4f6240511 --- /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 30e5068fa..4467adb08 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/agent-source.test.ts b/apps/cli/src/chat/agent-source.test.ts index 52866c23a..b19a89ddf 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/doctor-deep.test.ts b/apps/cli/src/chat/doctor-deep.test.ts index 8327511ef..61862ac56 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 ed0341346..6fdb76b6e 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 b87b03ee8..74104a12c 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/export.test.ts b/apps/cli/src/chat/export.test.ts index a78da30b9..05c81b9e7 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,55 @@ describe('exportSession (2.P)', () => { expect(yaml).toContain('type: output'); expect(yaml).not.toContain('type: agent'); // no completed turn ⇒ no agent node }); + 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(() => { + 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('cli_internal'); + expect(error.exitCode).toBe(9); + expect(error.cause).toBe(cause); + 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 199739bc4..e0464f94c 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,12 @@ 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'; /** * Session → workflow export (2.P / [ADR-0026](../../../../docs/decisions/0026-session-export-to-workflow.md)) — @@ -24,7 +29,7 @@ import { CliError } from '../process/errors.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; @@ -72,33 +77,33 @@ 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 }); + 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 = - err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).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 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 storageBoundaryError(err); } // The session is append-only; the next event/seq is one past the durable MAX (a fold, not a spread). @@ -106,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/chat/mcp-connect-signal.test.ts b/apps/cli/src/chat/mcp-connect-signal.test.ts new file mode 100644 index 000000000..62eb29d23 --- /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 000000000..c0cf89961 --- /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/native-removal.test-support.ts b/apps/cli/src/chat/native-removal.test-support.ts new file mode 100644 index 000000000..5078ca597 --- /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/persister.test.ts b/apps/cli/src/chat/persister.test.ts index e4686c2f7..e7e064f0a 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 5332c33a4..576331098 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 @@ -458,11 +463,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 @@ -478,8 +481,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/chat/session-chat-lifetime.test.ts b/apps/cli/src/chat/session-chat-lifetime.test.ts new file mode 100644 index 000000000..420af52f2 --- /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 000000000..cc401fee3 --- /dev/null +++ b/apps/cli/src/chat/session-chat-lifetime.ts @@ -0,0 +1,127 @@ +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'; +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; + }; + }; + + /** 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; + 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 000000000..9ee9ba587 --- /dev/null +++ b/apps/cli/src/chat/session-chat-terminal.test.ts @@ -0,0 +1,263 @@ +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('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, { + 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', ''))); + 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 000000000..714aa5c2e --- /dev/null +++ b/apps/cli/src/chat/session-chat-terminal.ts @@ -0,0 +1,196 @@ +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) { + try { + job.dispose(); + } catch (cause) { + this.#report(cause); + } + } + }; + + 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 000000000..03552a009 --- /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 000000000..f64f446c7 --- /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 000000000..6657833b3 --- /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.test.ts b/apps/cli/src/chat/session-host-owner.test.ts new file mode 100644 index 000000000..fc5071315 --- /dev/null +++ b/apps/cli/src/chat/session-host-owner.test.ts @@ -0,0 +1,364 @@ +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.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 { + 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.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); + }); + 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(); + 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.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; + 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 000000000..23ef5acbc --- /dev/null +++ b/apps/cli/src/chat/session-host-owner.ts @@ -0,0 +1,129 @@ +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; + #mcpClosing: Promise | undefined; + + constructor(invocation: CliInvocation, surface: SessionOwner['surface']) { + this.work = new SessionWorkOwner(invocation, surface, { + cancel: () => this.#cancel(), + beforeWorkJoin: () => this.#closeMcp(), + 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, hostOwner: this }; + }); + } + + /** 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; + } + + /** 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; + 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); + } + } + + #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; + // 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); + } + } + await this.#mcpClosing; + } +} diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 2715171b6..40bab87d0 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -1,3 +1,11 @@ +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 { 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'; import { execFileSync } from 'node:child_process'; @@ -14,11 +22,13 @@ import { import type { SessionStreamHandleEvent } from '@relavium/core'; import { buildServerToolDefs, + McpAbortedError, + raceDeadline, 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'; @@ -327,7 +337,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 +368,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 +834,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 +1779,416 @@ it('reads the attached initial usage snapshot once across a streamed turn', asyn await built.whenHostSafeToClose(); expect(initialCoverage).toHaveBeenCalledTimes(1); }); + +describe('owned session MCP startup cancellation', () => { + it.each( + (['fresh', 'resumed'] as const).flatMap((surface) => + (['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 through $obligation without 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'); + const partialAdd = fault === 'partial-add' || fault === 'partial-add-during-abort'; + 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-during-abort') { + aborted = true; + for (const attached of [...listeners]) attached(); + } + if (partialAdd) throw primary; + }, + removeEventListener: (_type, listener) => { + if (!allowRemoval && !partialAdd) throw primary; + listeners.delete(listener); + }, + }; + let releaseDiscovery: () => void = () => undefined; + const rawDiscovery = new Promise<[]>((resolve) => { + releaseDiscovery = () => resolve([]); + }); + const cancelled = vi.fn(); + const listTools = vi.fn((signal?: AbortSignalLike) => + raceDeadline( + 'fs', + 'discovery', + { 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) => { + 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 (!partialAdd) { + 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()); + 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); + 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 })), + ), + )('$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', + 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 e63bb124f..f2c8e2081 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -1,3 +1,5 @@ +import type { NativeProcessController } from '../process/native-process-owner.js'; +import { combineMcpConnectSignals } from './mcp-connect-signal.js'; import { type AgentDefinition, AgentSession, @@ -33,7 +35,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, @@ -50,6 +52,7 @@ import type { import type { ResolvedChatConfig } from '../config/resolve.js'; import { + closePreparedMcp, connectAgentMcp, type ConnectAgentMcpOptions, type StdioConsentGate, @@ -72,6 +75,9 @@ 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'; +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'; @@ -88,6 +94,10 @@ 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). */ readonly chat: ResolvedChatConfig; readonly maxTokensEstimate?: number; @@ -119,7 +129,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 @@ -165,7 +175,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; /** @@ -188,8 +199,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; /** @@ -225,6 +237,9 @@ 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; /** Host-authoritative base-model window; present undefined for custom/unknown metadata. */ @@ -316,6 +331,8 @@ type SessionRuntimeOptions = Pick< | 'providers' | 'toolHost' | 'fsPolicy' + | 'nativeProcesses' + | 'workOwner' | 'afterTurnCompaction' | 'onCompactionStart' | 'onBudgetWarning' @@ -368,8 +385,38 @@ 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); + // 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 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 @@ -377,7 +424,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. @@ -406,6 +453,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; @@ -445,10 +493,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 @@ -473,8 +521,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; @@ -500,11 +563,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({ @@ -518,7 +582,7 @@ function buildSessionRuntime( return invocation; }, emitUsageDisposition: async (draft) => { - await requiredUsageWriter().disposition(draft); + await observeAccounting(async () => requiredUsageWriter().disposition(draft)); publishAcknowledged(() => { usageCoverage?.disposition( draft.model, @@ -534,7 +598,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({ @@ -547,12 +611,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) @@ -599,11 +686,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, @@ -644,7 +731,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 ? {} @@ -656,7 +743,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) @@ -674,7 +764,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 @@ -690,8 +780,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, @@ -753,12 +845,18 @@ 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' + | 'workOwner' >; /** @@ -774,8 +872,27 @@ function mcpOptionsFor( mcpArtifact: string | undefined, cwd: string, ): ConnectAgentMcpOptions { + const owner = opts.workOwner; + const ownerSignal = owner?.signal; + const callerSignal = opts.mcpConnectSignal; + const connectSignal: AbortSignalLike | undefined = + owner === undefined || ownerSignal === undefined + ? callerSignal + : callerSignal === undefined || callerSignal === ownerSignal + ? ownerSignal + : combineMcpConnectSignals(ownerSignal, callerSignal, owner.cleanupUncertain); 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 // actually read from — §7's "declared in " for the imported-artifact case. @@ -783,7 +900,7 @@ 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 }), + ...(connectSignal === undefined ? {} : { connectSignal }), }; } @@ -813,6 +930,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); @@ -838,6 +956,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; } } @@ -980,7 +1113,7 @@ export interface BuiltResumedChatSession extends BuiltChatSession { export interface BuildResumedChatSessionOptions extends Pick< BuildChatSessionOptions, - 'afterTurnCompaction' | 'onCompactionStart' | 'fsPolicy' + 'afterTurnCompaction' | 'onCompactionStart' | 'fsPolicy' | 'nativeProcesses' | 'workOwner' > { /** The resolved `[chat]` block (turn cap, cost cap) — applied to the resumed session's deps. */ readonly chat: ResolvedChatConfig; @@ -1022,10 +1155,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 @@ -1064,6 +1194,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; @@ -1098,6 +1229,7 @@ export async function buildResumedChatSession( ); try { + opts.workOwner?.assertAdmitting(); const { attachUsageWriter, whenHostSafeToClose, @@ -1147,6 +1279,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, @@ -1170,7 +1303,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-hosts.test.ts b/apps/cli/src/chat/session-hosts.test.ts new file mode 100644 index 000000000..c98d78023 --- /dev/null +++ b/apps/cli/src/chat/session-hosts.test.ts @@ -0,0 +1,158 @@ +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'); + let settled = false; + const opening = f.hosts + .construct(async (owner) => { + await owner.build(async (workOwner) => ({ + ...(await buildChatSession({ ...options(), workOwner })), + closeMcp: close, + })); + throw cause; + }) + .catch((error: unknown) => { + settled = true; + return 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); + await reactions(); + expect(settled, 'FAILED_CONSTRUCTION_AWAITS_ACTUAL_HOST_CLOSE').toBe(false); + } 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 000000000..74fe6215d --- /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 000000000..56700e52c --- /dev/null +++ b/apps/cli/src/chat/session-ink-input.test.ts @@ -0,0 +1,221 @@ +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'; +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, afterRemoval }; +} + +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); + 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, + ]); + 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 afterRemoval = injectPostRemoval(input); + 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); + afterRemoval((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.afterRemoval((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.afterRemoval((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.afterRemoval((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.afterRemoval((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 000000000..dc891c36f --- /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(); + readonly #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 000000000..7300907be --- /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 000000000..558ff261c --- /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 000000000..84ddbf988 --- /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-mcp-close-owner.test.ts b/apps/cli/src/chat/session-mcp-close-owner.test.ts new file mode 100644 index 000000000..df77cef08 --- /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 new file mode 100644 index 000000000..56f9643b2 --- /dev/null +++ b/apps/cli/src/chat/session-presentation.test.ts @@ -0,0 +1,231 @@ +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 { + ownSessionNotification, + 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.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 = () => {}; + 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 = () => {}; + 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 000000000..3d90d0476 --- /dev/null +++ b/apps/cli/src/chat/session-presentation.ts @@ -0,0 +1,57 @@ +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'; + +/** 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 { + 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 { + return ownNotification(owner, consume); +} + +/** 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/chat/session-prompt.test.ts b/apps/cli/src/chat/session-prompt.test.ts new file mode 100644 index 000000000..4c8cee266 --- /dev/null +++ b/apps/cli/src/chat/session-prompt.test.ts @@ -0,0 +1,108 @@ +import { PassThrough } from 'node:stream'; + +import { confirm } from '@clack/prompts'; +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(); + const output = new PassThrough(); + 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, + { + ...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, + }), + 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', + ).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 000000000..9c76a3c46 --- /dev/null +++ b/apps/cli/src/chat/session-prompt.ts @@ -0,0 +1,57 @@ +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, + 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({ + // 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 () => { + 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) { + if (invocation.registry.budget.remainingMs() === 0) await new Promise(() => {}); + await io.writeOutAcknowledged(SHOW_CURSOR); + } + }, + }); + 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; + fulfilled = true; + return answer; + } finally { + await owner.close(); + } +} 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 000000000..78a378ea6 --- /dev/null +++ b/apps/cli/src/chat/session-readline.test.ts @@ -0,0 +1,202 @@ +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 () => { + entered.resolve(); + await release.promise; + // 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'; + }, + ), + ); + 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 000000000..4c121a197 --- /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/chat/session-surface-lifetime.test.ts b/apps/cli/src/chat/session-surface-lifetime.test.ts new file mode 100644 index 000000000..37d369a3c --- /dev/null +++ b/apps/cli/src/chat/session-surface-lifetime.test.ts @@ -0,0 +1,234 @@ +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('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) => { + 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 000000000..f93f04c39 --- /dev/null +++ b/apps/cli/src/chat/session-surface-lifetime.ts @@ -0,0 +1,79 @@ +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 { subscribeWithSignalCustody } from '../process/signal-custody.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, + cleanup?: () => Promise, + ) { + this.invocation = invocation ?? createCliInvocation(); + this.work = new SessionWorkOwner(this.invocation, surface, { + cancel: () => undefined, + 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?.(); + if (this.work.hasCleanupUncertainty) await new Promise(() => {}); + this.#removeSignals(); + }, + }); + try { + this.#removeSignals = subscribeWithSignalCustody( + this.invocation.signalCustody, + 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 000000000..1fac6f501 --- /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 000000000..423d0c3b3 --- /dev/null +++ b/apps/cli/src/chat/session-terminal-owner.ts @@ -0,0 +1,164 @@ +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', ''); + } + + get retired(): Promise { + return this.#retired; + } + + #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 afe418493..5cbf9de97 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-view-lifetime.test.ts b/apps/cli/src/chat/session-view-lifetime.test.ts new file mode 100644 index 000000000..7fd90f05f --- /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 000000000..f82c99982 --- /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.test.ts b/apps/cli/src/chat/session-work-owner.test.ts new file mode 100644 index 000000000..358f09401 --- /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 000000000..45ca2fb54 --- /dev/null +++ b/apps/cli/src/chat/session-work-owner.ts @@ -0,0 +1,19 @@ +import type { CliInvocation } from '../process/invocation.js'; +import type { SessionOwner } from '../process/invocation-registry.js'; +import { + InvocationWorkOwner, + type InvocationWorkOwnerPorts, +} from '../process/invocation-work-owner.js'; + +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, + ) { + super(invocation, { kind: 'session', surface }, ports); + } +} 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 000000000..e96e6bae1 --- /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/agent-native-input.e2e.test.ts b/apps/cli/src/commands/agent-native-input.e2e.test.ts new file mode 100644 index 000000000..30a5aafc1 --- /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.test.ts b/apps/cli/src/commands/agent-run.test.ts index 3b00c0c4b..e222df261 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'; @@ -13,11 +15,18 @@ 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 { 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 { @@ -47,11 +56,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; @@ -62,13 +73,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 () => { @@ -142,10 +168,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) => { @@ -185,6 +219,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(); } @@ -201,21 +239,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); @@ -225,6 +315,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 }, ]); @@ -234,11 +327,199 @@ 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])( + '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`, + ); + // 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 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 { + 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(); + } + await reactions(); + expect(control.registry.activeOwners).toBe(0); + expect(control.registry.budget.deadline()).toBeUndefined(); + 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) { @@ -248,6 +529,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]; @@ -265,10 +550,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, @@ -286,11 +578,168 @@ describe('agentRunCommand (2.Q)', () => { }, out, err, + control, }; } 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) => { + 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, 2); + 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 05966cce1..38be940c7 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -1,5 +1,13 @@ +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'; +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'; +import { CapturedNativeInput } from '../process/native-input.js'; import type { SessionStreamHandleEvent } from '@relavium/core'; @@ -9,11 +17,8 @@ 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'; -import { createConsentPrompter } 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'; @@ -25,11 +30,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'; @@ -43,8 +49,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 { @@ -64,6 +71,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). */ @@ -86,280 +95,298 @@ export async function agentRunCommand( args: AgentRunCommandArgs, deps: AgentRunCommandDeps, ): Promise { - 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, + 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, + { + ...deps, + invocation: lifetime.invocation, + io: ownSessionOutput(deps.io, lifetime.work), + }, + lifetime, + (close) => { + closeJournal = close; + }, + ), + ); +} - const fsPolicy = admitChatFsPolicy(grants, deps.global.cwd, config.chat.extraRoots); +async function agentRunOwned( + args: AgentRunCommandArgs, + deps: AgentRunCommandDeps, + lifetime: SessionSurfaceLifetime, + captureJournalClose: (close: () => void) => void, +): Promise { + const hostOwner = new SessionHostOwner(lifetime.invocation, 'agent'); + 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); + // 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: 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 - * 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?.(); - }, - // **`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(), - ); + // 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, deps.invocation); - const built = await (async (): Promise => { - const b = await (deps.buildSession ?? buildChatSession)({ - 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({ + // 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, + }), + // 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( - { - 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, - }); - if (!accepted) throw new Error('One-shot actual cost acknowledgement refused'); - receipts.delete(receipt.invocation); - }); + // **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 = acquireNativeResource( + (captureClose) => + openSessionStore(homeDir, captureClose, { + io: deps.io, + invocation: deps.invocation, + }), + { + assertAdmitting: lifetime.work.assertAdmitting, + captureClose: captureJournalClose, + cleanupUncertain: lifetime.work.cleanupUncertain, + isStoppedFailure: lifetime.work.isStoppedFailure, }, - }); - 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(); + let ownedEffectSessionId: string | undefined; + const usageStore = createSessionUsageStore(journalStore.db); + const receipts = new Map(); try { - if (!handedOff) await built.closeMcp?.(); - } finally { - try { - if (ownedEffectSessionId !== undefined) - sweepOneShotSessionEffects(deps.io, journalStore.db, ownedEffectSessionId); - } finally { + // 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 { - await built.whenHostSafeToClose(); - } finally { - try { - for (const receipt of receipts.values()) usageStore.retire(receipt); - receipts.clear(); - } finally { - journalStore.close(); - } + 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, + 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); + }); + }, + }); + 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, 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: + // 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, lifetime.work); + 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(); } } @@ -367,6 +394,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 @@ -379,7 +407,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', @@ -391,16 +424,18 @@ 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, 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 @@ -410,10 +445,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 @@ -430,57 +474,54 @@ 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); - } 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`); - } + owner.assertAdmitting(); + } catch (cause) { + owner.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) { + owner.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 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); + } + 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 === input.cancelled) signal?.throwIfAborted(); + options?.onFailure(cause); + throw cause; + } finally { + await input.close(); } - data += decoder.end(); - return data; } diff --git a/apps/cli/src/commands/budget.test.ts b/apps/cli/src/commands/budget.test.ts index 4b656bb4f..e36d413c6 100644 --- a/apps/cli/src/commands/budget.test.ts +++ b/apps/cli/src/commands/budget.test.ts @@ -1,3 +1,7 @@ +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'; import { spawn } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; @@ -69,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. */ @@ -216,6 +220,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, @@ -240,9 +245,34 @@ function deps(io: ReturnType['io']): GateCommandDeps { }, }; } +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, queuedExpiry); + 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(); + const invocation = createCliInvocation(); + const exitListeners = process.listenerCount('exit'); const { io } = captureIo(); expect( await budgetCommand( @@ -250,9 +280,24 @@ 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, + 2, + ); + 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', @@ -842,7 +887,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); @@ -861,10 +906,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: () => { @@ -875,15 +922,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({ @@ -891,7 +948,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(); @@ -1192,7 +1249,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), @@ -1223,17 +1280,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/chat-advisory-failure.test.ts b/apps/cli/src/commands/chat-advisory-failure.test.ts new file mode 100644 index 000000000..c42e24259 --- /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/chat-compaction-disclosure-owner.test.ts b/apps/cli/src/commands/chat-compaction-disclosure-owner.test.ts new file mode 100644 index 000000000..8a9348902 --- /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/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 000000000..bacae769f --- /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-effect-disclosure-owner.test.ts b/apps/cli/src/commands/chat-effect-disclosure-owner.test.ts new file mode 100644 index 000000000..6de035b61 --- /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-export-lifetime.test.ts b/apps/cli/src/commands/chat-export-lifetime.test.ts new file mode 100644 index 000000000..dbc416c1f --- /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 b5e526b0e..707460557 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'; @@ -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', @@ -136,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); @@ -206,24 +222,51 @@ describe('chatExportCommand (2.P)', () => { expect(existsSync(join(cwd, 'custom/out.yaml'))).toBe(true); }); - 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. + 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 new Error('db locked'); + throw primary; }, }, }; 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(() => 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.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(); chatExportCommand({ sessionId: 's1', force: false }, deps(io)); // first export creates the file diff --git a/apps/cli/src/commands/chat-export.ts b/apps/cli/src/commands/chat-export.ts index f3ef52e32..6bde3bb78 100644 --- a/apps/cli/src/commands/chat-export.ts +++ b/apps/cli/src/commands/chat-export.ts @@ -1,3 +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'; @@ -26,10 +30,15 @@ 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`. */ - readonly openSessionStore?: (homeDir: string) => OpenedSessionStore; + 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; } @@ -43,7 +52,12 @@ export function chatExportCommand( cwd: deps.global.cwd, configPath: deps.global.configPath, }); - const opened = (deps.openSessionStore ?? openSessionStore)(homeDir); + const opened = acquireCommandResource(deps.commandLifetime, (captureClose) => + (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose, { + io: deps.io, + invocation: deps.commandLifetime?.invocation, + }), + ); try { const result = exportSession({ store: opened.store, @@ -66,19 +80,19 @@ 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({ ...result.record, status: 'exported', exportedWorkflowPath: result.path, - updatedAt: new Date(now()).toISOString(), + updatedAt: exportedAt, }); - } catch (err) { - deps.io.writeErr( - `note: scaffold written but could not mark the session exported: ${err instanceof Error ? err.message : String(err)}\n`, - ); + } catch (cause) { + if (toUserFacing(cause).code !== 'storage_unavailable') throw cause; + deps.io.writeErr('note: scaffold written but could not mark the session exported\n'); } if (deps.global.json) { @@ -86,7 +100,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, }); @@ -96,6 +110,7 @@ export function chatExportCommand( } return EXIT_CODES.success; } finally { - opened.close(); + if (deps.commandLifetime === undefined) + closeQuietly(deps.io, 'session store', () => opened.close()); } } 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 000000000..040ea854c --- /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-list.ts b/apps/cli/src/commands/chat-list.ts index 7e24b1e26..6b4d93e55 100644 --- a/apps/cli/src/commands/chat-list.ts +++ b/apps/cli/src/commands/chat-list.ts @@ -1,3 +1,6 @@ +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'; import { loadResolvedConfig } from '../config/load.js'; @@ -9,10 +12,15 @@ 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`. */ - readonly openSessionStore?: (homeDir: string) => OpenedSessionStore; + readonly openSessionStore?: ( + homeDir: string, + captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, + ) => OpenedSessionStore; } /** @@ -28,7 +36,12 @@ export function chatListCommand(deps: ChatListCommandDeps): ExitCode { cwd: deps.global.cwd, configPath: deps.global.configPath, }); - const opened = (deps.openSessionStore ?? openSessionStore)(homeDir); + const opened = acquireCommandResource(deps.commandLifetime, (captureClose) => + (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose, { + io: deps.io, + invocation: deps.commandLifetime?.invocation, + }), + ); try { if (deps.global.json) { opened.store.visitSessionSummaries((session) => { @@ -54,7 +67,8 @@ export function chatListCommand(deps: ChatListCommandDeps): ExitCode { } return EXIT_CODES.success; } finally { - opened.close(); + if (deps.commandLifetime === undefined) + closeQuietly(deps.io, 'session store', () => opened.close()); } } 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 000000000..df21e20b1 --- /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-readline-custody.test.ts b/apps/cli/src/commands/chat-readline-custody.test.ts new file mode 100644 index 000000000..aceefd248 --- /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.test.ts b/apps/cli/src/commands/chat.test.ts index a2e756635..6169b3cc2 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -1,8 +1,22 @@ +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'; 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'; @@ -266,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[] = []; @@ -414,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, @@ -1863,6 +2092,154 @@ describe('chatCommand', () => { return p; } + it('binds the default standalone editor to the original native registry', async () => { + 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)); + 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 { + await vi.waitFor( + () => { + expect(invocation.registry.activeOwners, 'NATIVE_STANDALONE_EDITOR_FORWARDING').toBe(4); + expect(readFileSync(marker, 'utf8')).toBe('ready\n'); + }, + { interval: 5, timeout: 3000 }, + ); + } finally { + writeFileSync(release, 'release'); + await editing; + ctx.suspendPort.attach(undefined); + } + 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() }; + }; + expect( + await chatCommand( + { agent: undefined }, + { + ...d, + ...INERT_HOIST, + invocation, + 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'], + ['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({ exit: vi.fn(), setExitCode: vi.fn() }); + 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, + drives === 0 ? 2 : 3, + ); + nativeCreations++; + return buildChatSession({ ...options, startMcpClient: connect }); + }, + buildResumedSession: async (options) => { + await witnessNativeToolProcess( + options.nativeProcesses, + invocation, + cwd, + drives === 0 ? 2 : 3, + ); + 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 +2687,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({ exit: vi.fn(), setExitCode: vi.fn() }); const resumedEstimates: (number | undefined)[] = []; const freshEstimates: (number | undefined)[] = []; const store = createSessionStore(client.db); @@ -2334,12 +2712,20 @@ describe('chatResumeCommand (2.N)', () => { { sessionId: 'id-0' }, { ...d, + invocation, drive: clearThenExit, - buildSession: (options) => { + buildSession: async (options) => { + await witnessNativeToolProcess(options.nativeProcesses, invocation, options.cwd, 2); freshEstimates.push(options.maxTokensEstimate); return buildChatSession(options); }, - buildResumedSession: (options) => { + buildResumedSession: async (options) => { + await witnessNativeToolProcess( + options.nativeProcesses, + invocation, + options.record.context.workingDir, + 2, + ); resumedEstimates.push(options.maxTokensEstimate); return buildResumedChatSession(options); }, @@ -2360,6 +2746,7 @@ describe('chatResumeCommand (2.N)', () => { it('preserves the configured estimate through a resumed-session model reseat', async () => { writeOutputEstimate(cwd); + 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'); @@ -2382,10 +2769,17 @@ 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.length === 0 ? 2 : 3, + ); estimates.push(options.maxTokensEstimate); return buildResumedChatSession(options); }, @@ -2644,9 +3038,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(); diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 7a6bf1089..12865b47b 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -1,7 +1,19 @@ +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'; +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, +} from '../process/native-process-owner.js'; 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, @@ -20,6 +32,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 { @@ -81,13 +94,14 @@ 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'; 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, @@ -144,24 +158,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 @@ -207,12 +203,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. @@ -350,6 +362,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; @@ -359,7 +373,11 @@ 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, + 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; readonly resolveKeys?: ( @@ -387,6 +405,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; @@ -396,7 +416,11 @@ 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, + noticeContext?: LocalDbNoticeContext, + ) => OpenedSessionStore; /** The MCP named-secret resolver (2.R Step 4) — production injects the keychain-backed one; default env-only. */ readonly mcpSecretResolver?: McpSecretResolver; readonly resolveKeys?: ( @@ -419,6 +443,10 @@ 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; readonly global: GlobalOptions; readonly drive?: ChatDriver; @@ -432,6 +460,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, @@ -558,7 +588,18 @@ 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; const uuid = deps.uuid ?? randomUUID; @@ -569,6 +610,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); @@ -581,171 +628,230 @@ 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, 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 // fail-loud exit-2 CliError, cause stripped) before the session is live. - const built = await (deps.buildSession ?? buildChatSession)({ - chat: config.chat, - fsPolicy, - ...(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: createConsentGate({ - io: deps.io, - global: deps.global, - homeDir, - prompt: createConsentPrompter(), - }), - 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 = + ownership === undefined + ? (deps.openSessionStore ?? openSessionStore)(homeDir, undefined, { + io: deps.io, + invocation: deps.invocation, + }) + : ownership.acquireStore((captureClose) => + (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose, { + io: deps.io, + invocation: deps.invocation, + }), + ); + } 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({ + ...(built.workOwner === undefined ? {} : { workOwner: built.workOwner }), + 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, - ...(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, - ...(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, ); } @@ -757,242 +863,313 @@ 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; const uuid = deps.uuid ?? randomUUID; 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 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; + const opened = + ownership === undefined + ? (deps.openSessionStore ?? openSessionStore)(homeDir, undefined, { + io: deps.io, + invocation: deps.invocation, + }) + : ownership.acquireStore((captureClose) => + (deps.openSessionStore ?? openSessionStore)(homeDir, captureClose, { + io: deps.io, + invocation: deps.invocation, + }), + ); + 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); - const resumed = await (deps.buildResumedSession ?? buildResumedChatSession)({ + + 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, + // 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, + ...(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: createConsentGate({ - io: deps.io, - global: deps.global, - homeDir, - prompt: createConsentPrompter(), - }), - 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((e: unknown) => warnTeardown(deps.io, 'MCP', e)); - 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, - ...(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, - ...(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, + ...(built.workOwner === undefined ? {} : { owner: built.workOwner }), + 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, ); } @@ -1209,6 +1386,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); @@ -1224,7 +1402,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.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 @@ -1237,7 +1416,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 { @@ -1257,6 +1442,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 @@ -1287,6 +1473,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 @@ -1297,7 +1484,15 @@ 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); + if (noticesStopped()) return; + 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. @@ -1323,6 +1518,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'; @@ -1360,9 +1556,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'}`, ); @@ -1376,7 +1572,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'); @@ -1488,9 +1685,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. @@ -1598,7 +1796,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 @@ -1628,8 +1829,11 @@ 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; + readonly consentGate?: BuildChatSessionOptions['consentGate']; readonly maxTokensEstimate?: number; readonly agent: AgentDefinition; readonly cwd: string; @@ -1656,32 +1860,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.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. @@ -1701,26 +1922,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 @@ -1735,10 +1959,12 @@ 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({ + ...(built.workOwner === undefined ? {} : { workOwner: built.workOwner }), cwd: deps.cwd, ...(deps.configPath === undefined ? {} : { configPath: deps.configPath }), resolver: deps.providers, @@ -1759,6 +1985,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 }), @@ -1773,8 +2000,12 @@ 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; + readonly consentGate?: BuildChatSessionOptions['consentGate']; readonly maxTokensEstimate?: number; readonly agent: AgentDefinition; readonly projectConfigDir: string | undefined; @@ -1791,8 +2022,11 @@ 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 }), + ...(params.consentGate === undefined ? {} : { consentGate: params.consentGate }), ...(params.maxTokensEstimate === undefined ? {} : { maxTokensEstimate: params.maxTokensEstimate }), @@ -1816,7 +2050,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), + ); } /** @@ -1860,26 +2099,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. @@ -1896,8 +2138,11 @@ 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; + readonly consentGate?: BuildChatSessionOptions['consentGate']; readonly maxTokensEstimate?: number; readonly now: () => number; readonly uuid: () => string; @@ -1938,6 +2183,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) { @@ -1963,23 +2209,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.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( @@ -1994,13 +2253,15 @@ 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 // 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, @@ -2019,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, @@ -2037,6 +2299,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 }), @@ -2050,8 +2313,12 @@ 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; + readonly consentGate?: BuildChatSessionOptions['consentGate']; readonly maxTokensEstimate?: number; readonly now: () => number; readonly uuid: () => string; @@ -2070,8 +2337,11 @@ 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 }), + ...(params.consentGate === undefined ? {} : { consentGate: params.consentGate }), ...(params.maxTokensEstimate === undefined ? {} : { maxTokensEstimate: params.maxTokensEstimate }), @@ -2093,7 +2363,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 @@ -2103,7 +2377,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)); } /** @@ -2173,6 +2450,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) @@ -2185,23 +2478,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, @@ -2246,13 +2535,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 @@ -2260,14 +2557,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((e: unknown) => warnTeardown(deps.io, 'MCP', e)); - } + 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')); + } } } @@ -2340,8 +2645,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: @@ -2351,15 +2656,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`. @@ -2369,13 +2678,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 @@ -2383,7 +2694,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?.(); @@ -2397,19 +2708,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 @@ -2417,13 +2729,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); } } @@ -2478,46 +2793,101 @@ 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: nodeSpawnEditor, - 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), + 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; + 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; @@ -2531,7 +2901,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( @@ -2540,48 +2915,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; @@ -2605,18 +3015,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; } }, @@ -2624,7 +3044,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; @@ -2635,46 +3056,76 @@ 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 onSigint = (): void => { - active = false; - interrupt(); - rl.close(); - }; - process.once('SIGINT', onSigint); - try { - ctx.io.writeOut(`${ctx.intro ?? 'relavium chat — type a message, or /exit to quit.'}\n`); - 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; + const io = ownSessionOutput(ctx.io, ctx.workOwner); + return withSessionReadline( + ctx.io.stdin, + ctx.workOwner, + () => + ctx.handle.subscribe( + 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. + // 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(); + 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; - process.removeListener('SIGINT', onSigint); - 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() }; + }, + ); } /** @@ -2686,46 +3137,75 @@ 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 onSigint = (): void => { - active = false; - interrupt(); - rl.close(); - }; - process.once('SIGINT', onSigint); - try { - 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; + const io = ownSessionOutput(ctx.io, ctx.workOwner); + return withSessionReadline( + ctx.io.stdin, + ctx.workOwner, + () => + ctx.handle.subscribe( + ownSessionSubscriber(ctx.workOwner, (event) => + 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; - process.removeListener('SIGINT', onSigint); - 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/create.ts b/apps/cli/src/commands/create.ts index 99e6ef816..5e56c1563 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/db-fault-acquisition.test-support.ts b/apps/cli/src/commands/db-fault-acquisition.test-support.ts new file mode 100644 index 000000000..9735871e5 --- /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/departure-native.test.ts b/apps/cli/src/commands/departure-native.test.ts index 6f9b1b12a..569c10b6c 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 new file mode 100644 index 000000000..281a0b268 --- /dev/null +++ b/apps/cli/src/commands/descriptor-actions.test.ts @@ -0,0 +1,486 @@ +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); + }, + writeOutAcknowledged: (text) => { + out.push(text); + return Promise.resolve(); + }, + 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 000000000..cf4d4da00 --- /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 000000000..ab5957355 --- /dev/null +++ b/apps/cli/src/commands/descriptors.ts @@ -0,0 +1,1320 @@ +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 binding: + | 'json' + | 'color' + | 'cwd' + | 'config' + | 'allowFsRoots' + | 'noAltScreen' + | 'noMouse' + | 'verbose' + | 'quiet' + | 'help' + | 'version'; + 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: GlobalOptionDescriptor['binding'], + 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 000000000..0b12b017c --- /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 000000000..5544a1096 --- /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-native-forwarding.test.ts b/apps/cli/src/commands/dispatch-native-forwarding.test.ts new file mode 100644 index 000000000..8cf459f94 --- /dev/null +++ b/apps/cli/src/commands/dispatch-native-forwarding.test.ts @@ -0,0 +1,75 @@ +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, 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, + 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(); + const before = receivedInvocations.length; + try { + expect( + await executeCommand(id, input, { + io: captureIo().io, + global: { json: false, color: false, cwd, configPath: undefined, verbosity: 'normal' }, + 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/dispatch.ts b/apps/cli/src/commands/dispatch.ts index 1a533f127..4c8c3ca7d 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 { @@ -22,15 +23,17 @@ 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 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'; import { createOsKeychainStore } from '../secrets/os-keychain.js'; import { readSecretFromStdin } from '../secrets/read-secret.js'; +import { Readable } from 'node:stream'; +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'; import { chatExportCommand, type ChatExportCommandArgs } from './chat-export.js'; @@ -57,43 +60,32 @@ 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), - * 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) ───────────────────────────────────────────────── @@ -392,12 +384,12 @@ 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, + (captureClose) => + openLocalDb(homeDir, undefined, captureClose, { io: ctx.io, invocation: ctx.invocation }), + ({ db }) => fromDb(db), + ); } /** One native keychain accessor, shared by the key resolver (2.C) + the MCP named-secret resolver (2.R §6). */ @@ -419,6 +411,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), @@ -428,6 +421,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, @@ -439,6 +433,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, @@ -446,19 +441,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 })); @@ -470,6 +464,7 @@ const executeAgentRun: CommandExecutor = (input, ctx) => agentRunCommand(buildAgentRunArgs(input), { io: ctx.io, global: ctx.global, + ...(ctx.invocation === undefined ? {} : { invocation: ctx.invocation }), resolveKeys: () => keyResolvers(ctx), }); @@ -477,6 +472,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), }); @@ -484,28 +480,22 @@ 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), }); 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 @@ -532,7 +522,11 @@ 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, + 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: ( @@ -548,15 +542,17 @@ export interface ModelsDbPorts { readonly refreshCatalog: ( homeDir: string, persist?: (catalog: Readonly>) => void, + lifecycle?: { readonly signal: AbortSignal; readonly assertAdmitting: () => void }, ) => Promise; } const PRODUCTION_MODELS_PORTS: ModelsDbPorts = { - openDb: openLocalDb, + openDb: (homeDir, captureClose, noticeContext) => + openLocalDb(homeDir, undefined, captureClose, noticeContext), 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 }), }; /** @@ -570,11 +566,23 @@ 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); + const { db, close } = acquireCommandResource(ctx.commandLifetime, (captureClose) => + ports.openDb(homeDir, captureClose, { io: ctx.io, invocation: ctx.invocation }), + ); 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 @@ -586,12 +594,25 @@ 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, + invocationOptions: owner.work, + }), resolveProvider: resolver.resolveProvider, keyFor: resolver.keyFor, providerStore, @@ -600,6 +621,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, @@ -608,13 +639,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(); } } @@ -648,7 +690,9 @@ const executeModelsPricing: CommandExecutor = (input, ctx) => { cwd: ctx.global.cwd, configPath: ctx.global.configPath, }); - const { db, close } = openLocalDb(homeDir); + const { db, close } = acquireCommandResource(ctx.commandLifetime, (captureClose) => + openLocalDb(homeDir, undefined, captureClose, { io: ctx.io, invocation: ctx.invocation }), + ); try { const storeDeps = { uuid: () => randomUUID(), now: () => Date.now() }; return Promise.resolve( @@ -660,7 +704,7 @@ const executeModelsPricing: CommandExecutor = (input, ctx) => { }), ); } finally { - close(); + if (ctx.commandLifetime === undefined) close(); } }; @@ -669,11 +713,22 @@ 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); + const { db, close } = acquireCommandResource(ctx.commandLifetime, (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 const store = createProviderStore(db, { uuid: () => randomUUID(), now: () => Date.now() }); @@ -683,12 +738,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(); } } @@ -702,19 +768,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 ─────────────────────────────────────────────────────── @@ -772,6 +826,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) { @@ -786,5 +852,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/drive-budget.test.ts b/apps/cli/src/commands/drive-budget.test.ts index f2bf920c8..ea8784c14 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 000000000..5b361c214 --- /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-gate-lifecycle.test.ts b/apps/cli/src/commands/drive-gate-lifecycle.test.ts index c4cccb3ab..f3f9384bd 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-startup-ownership.test.ts b/apps/cli/src/commands/drive-startup-ownership.test.ts index dfa793e20..6dede2455 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 73999b929..d912b29e1 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 { invocationFixture } from '../process/invocation.test-support.js'; import { EngineStateError, createInMemoryHost, @@ -64,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); @@ -86,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' }); @@ -103,20 +119,27 @@ 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'); }); - 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 +148,34 @@ describe('driveRun — interactive gate', () => { const { io } = captureIo(); const cancelSpy = vi.spyOn(handle, 'cancel'); + const lifetime = new WorkflowLifetime( + invocationFixture().invocation, + 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,17 +190,44 @@ describe('driveRun — interactive gate', () => { }; const { io, err } = captureIo(); + const lifetime = new WorkflowLifetime( + invocationFixture().invocation, + 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 () => { 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'); }); @@ -166,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". @@ -182,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 14629adcc..e15b64fe6 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -19,10 +19,13 @@ 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'; import type { RunRenderer } from '../render/renderer.js'; +import type { RendererCreationOwner } from './workflow-terminal.js'; +import type { 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; + 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 => { @@ -152,25 +171,25 @@ 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.', ); return await new 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 +197,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 +233,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 +322,31 @@ 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, + 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); + } } catch (error) { - driveError = error; + recordFault(error); requestCancellation(); } for (;;) { @@ -304,6 +375,10 @@ export async function driveRun(deps: DriveRunDeps): Promise terminal.runPrompt(invoke), + runViewOperation: (invoke, mayAcquire) => + terminal.runViewOperation(invoke, mayAcquire), + failed: recordFault, }, renderer, gatePrompter, @@ -317,7 +392,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,44 +450,48 @@ 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 +543,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 +551,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/active-agent-signal-child.ts b/apps/cli/src/commands/fixtures/active-agent-signal-child.ts new file mode 100644 index 000000000..1b3a3e166 --- /dev/null +++ b/apps/cli/src/commands/fixtures/active-agent-signal-child.ts @@ -0,0 +1,213 @@ +/** Isolated OS-signal probe: shipping commands/host/SQLite and a real offline stdio MCP child. */ +import { ChildProcess } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { LlmProvider, StreamChunk } from '@relavium/llm'; +import type { StdioNativeLifetime } from '@relavium/mcp'; +import { AgentSchema } from '@relavium/shared'; +import { buildChatSession, buildResumedChatSession } from '../../chat/session-host.js'; +import { openSessionStore } from '../../history/session-open.js'; +import { createCliInvocation } from '../../process/invocation.js'; +import type { NativeProcessController } from '../../process/native-process-owner.js'; +import { captureIo, CHAT_TEXT_CAPABILITY_FLAGS } from '../../test-support.js'; +import { agentRunCommand } from '../agent-run.js'; +import { chatCommand, chatResumeCommand, driveJson, drivePlain } from '../chat.js'; + +const mode = process.argv[2]; +const root = process.argv[3]; +if (mode === undefined || root === undefined) + throw new Error('Isolated signal arguments required.'); +const agentRun = mode.startsWith('agent-'); +const resumed = mode.startsWith('resume-'); +const json = mode.endsWith('-json'); +const eof = mode.includes('-eof-'); +const server = fileURLToPath( + new URL('../../../../../packages/mcp/test-fixtures/echo-mcp-server.mjs', import.meta.url), +); +const agent = AgentSchema.parse({ + id: 'active-signal', + provider: 'anthropic', + model: 'claude-sonnet-4-6', + system_prompt: 'offline signal regression', + mcp_servers: [{ id: 'offline', transport: 'stdio', command: process.execPath, args: [server] }], +}); +const agentPath = join(root, 'active.agent.yaml'); +writeFileSync(agentPath, JSON.stringify(agent)); +writeFileSync(join(root, 'config.toml'), ''); +const invocation = createCliInvocation(); +const captured = captureIo(); +let lifetime: StdioNativeLifetime | undefined; +let nativePid: number | undefined; +let providerAborted = false; +let providerAcknowledged = false; +let nativeCloseHeld = false; +let nativeCloseDelivered = false; +let settled = false; +const outcome: { result?: number } = {}; +let releaseProvider: () => 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/fixtures/legacy-command-manifest.json b/apps/cli/src/commands/fixtures/legacy-command-manifest.json new file mode 100644 index 000000000..2bee3d9a5 --- /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/fixtures/workflow-cleanup-child.ts b/apps/cli/src/commands/fixtures/workflow-cleanup-child.ts new file mode 100644 index 000000000..28e71b380 --- /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 000000000..87baa3b1c --- /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-secret-child.ts b/apps/cli/src/commands/fixtures/workflow-secret-child.ts new file mode 100644 index 000000000..4ba95ebd2 --- /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/fixtures/workflow-tsx-hook.mjs b/apps/cli/src/commands/fixtures/workflow-tsx-hook.mjs new file mode 100644 index 000000000..3af62e5ed --- /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/fs-invocation.test.ts b/apps/cli/src/commands/fs-invocation.test.ts index 7532513a6..71d7cb7d1 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, @@ -26,6 +27,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'; @@ -33,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'); + } }, }; }); @@ -74,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'); @@ -82,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' }; @@ -159,6 +226,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,11 +242,19 @@ 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(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); + expect(f.exit).not.toHaveBeenCalled(); + f.advance(3000); + expect(await f.invocation.finiteExit.exited).toBe(9); + expect(f.exit).toHaveBeenCalledExactlyOnceWith(9); }, ); @@ -260,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); }); @@ -397,8 +538,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); - expect(code, 'FS_CAUSAL_INVOCATION_FORWARD').toBe(1); + 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/commands/fs-workspace-admission.test.ts b/apps/cli/src/commands/fs-workspace-admission.test.ts index 96f4cbd27..8d07cc889 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 { 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, @@ -11,12 +14,13 @@ 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, runMigrations } from '@relavium/db'; +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'; @@ -24,13 +28,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'); + } }, }; }); @@ -41,26 +70,136 @@ 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; + readonly trace: (phase: WorkspacePhase) => void; + readonly abortSnapshot: () => readonly PhaseEntry[] | undefined; + sealed: boolean; + retired: boolean; + closing: Promise | undefined; +} +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'); 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 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 }); + const fixture: WorkspaceCase = { + base, + home, + workspace, + sensitiveAlias, + 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(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(); + }); }); -afterEach(() => { - vi.unstubAllEnvs(); - rmSync(base, { recursive: true, force: true }); +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 () => { + 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); + }); + fixture.pending.add(raw); + return raw; +} +function readSession( + fixture: WorkspaceCase, + open: (path: string) => ReturnType = createClient, +) { + fixture.assertCurrent(); + 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 createSessionStore(client.db).loadFull('workspace-floor-session'); + } finally { + client.sqlite.close(); + fixture.trace('read-closed'); + } +} const global = (cwd: string): GlobalOptions => ({ cwd, json: false, @@ -73,10 +212,13 @@ 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(); + fixture.trace('seed-enter'); await chatCommand( { agent: undefined }, { + invocation: fixture.invocation, io: captureIo().io, global: global(cwd), providers: scriptedResolver([]), @@ -84,156 +226,425 @@ async function seed(cwd: string): Promise { drive: exitDriver, }, ); + fixture.trace('seed-return'); + 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', 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); + fixture.trace('alias-retarget-enter'); + rmSync(alias); + 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' }, + { + invocation: fixture.invocation, + 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); + }); }); 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; + const loaded = loadBuilderSession(fixture); + 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, noticeContext) => { + const opened = openSessionStore(home, captureClose, noticeContext); + 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); + })); + +// 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; + const open = (path: string) => { + const client = createClient(path); + native = client.sqlite; + try { + native.prepare('DROP TABLE agent_sessions').run(); + } catch (cause) { + native.close(); + throw cause; + } + return client; + }; + try { + expect(() => readSession(fixture, open)).toThrow(/no such table/); + expect(native?.open).toBe(false); + } finally { + if (native?.open === true) native.close(); + } + })); + +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 => {}; + 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('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 => {}; + 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-checkpoint-fold.test.ts b/apps/cli/src/commands/gate-checkpoint-fold.test.ts new file mode 100644 index 000000000..3522123c1 --- /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 000000000..7a67be5a1 --- /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-list.ts b/apps/cli/src/commands/gate-list.ts index 3b168ce7c..d703e0e04 100644 --- a/apps/cli/src/commands/gate-list.ts +++ b/apps/cli/src/commands/gate-list.ts @@ -1,3 +1,5 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; +import { acquireCommandResource, type CommandLifetime } from '../process/command-lifetime.js'; import type { Db, RunHistoryReader } from '@relavium/db'; import { loadResolvedConfig } from '../config/load.js'; @@ -24,9 +26,14 @@ export interface GateListCommandArgs { } 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, + noticeContext?: LocalDbNoticeContext, + ) => { db: Db; close: () => void }; } /** @@ -46,7 +53,12 @@ export function gateListCommand(args: GateListCommandArgs, deps: GateListCommand cwd: deps.global.cwd, configPath: deps.global.configPath, }); - const { reader, close } = openHistoryReader(homeDir, deps.openDb); + const { reader, close } = acquireCommandResource(deps.commandLifetime, (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 // whole listing. With an explicit `runId` there is only one run, so degrading would silently answer "no @@ -82,7 +94,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/gate.test.ts b/apps/cli/src/commands/gate.test.ts index 7a63d3631..c6d4a4b4f 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -1,7 +1,12 @@ +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'; 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, @@ -9,6 +14,7 @@ import { parseWorkflow, type CheckpointState, type RunHandle, + type WorkflowDefinition, type WorkflowEngine, } from '@relavium/core'; import { @@ -25,7 +31,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'; @@ -51,7 +57,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 @@ -230,12 +239,64 @@ describe('gateCommand', () => { db = client.db; }); afterEach(() => { - client.sqlite.close(); + 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'); + 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' }); + 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; + }, + }, + ).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); }); /** 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). */ @@ -254,6 +315,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 +348,188 @@ describe('gateCommand', () => { return { runId, gateIds }; } + 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: + - 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, queuedExpiry); + 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(); + 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, + 2, + ); + 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', + 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[] }> => @@ -366,14 +618,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 }); } }); @@ -382,7 +633,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'); @@ -402,17 +653,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, @@ -434,7 +682,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(); } }, ); @@ -444,26 +692,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); @@ -479,7 +724,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(); } }, ); @@ -662,21 +907,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: () => {} }), }, @@ -695,7 +940,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(); } }); @@ -727,7 +972,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(); @@ -1026,6 +1274,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) => @@ -1093,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(); @@ -1180,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/gate.ts b/apps/cli/src/commands/gate.ts index 33da0b3d1..7abae0490 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -1,3 +1,11 @@ +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'; +import { warnTeardown } from '../process/close-quietly.js'; +import { nativeProcessesForInvocation } from '../process/native-process-owner.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'; @@ -17,18 +25,16 @@ import { createEffectJournalStore, createRunHistoryStore, createRunLeasePort, - isCorruptRunEventError, - isUnreadableRunEventLogError, loadRunSnapshot, type Db, } from '@relavium/db'; import { MaskedSecretSchema, - WorkflowSchema, + WorkflowSnapshotSchema, type GateDecision, + type MediaBilledModality, type RunStatus, } from '@relavium/shared'; -import { liveMcpChildPids } from '@relavium/mcp'; import { loadResolvedConfig } from '../config/load.js'; import { openLocalDb } from '../db/open.js'; @@ -58,21 +64,22 @@ import { } from '../gate/budget.js'; import { connectWorkflowMcp, + closePreparedMcp, workflowDeclaresMcp, surfaceMcpSkipped, type StdioConsentGate, 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'; 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'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; @@ -137,6 +144,9 @@ 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 subscribeSignals?: typeof defaultSubscribeSignals; readonly io: CliIo; readonly global: GlobalOptions; /** Injectable so tests drive a stub provider + the in-memory host engine. */ @@ -144,8 +154,16 @@ 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 selectRenderer?: (io: CliIo, global: GlobalOptions) => RunRenderer; + readonly openDb?: ( + homeDir: string, + captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, + ) => { db: Db; close: () => void }; + 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; @@ -265,28 +283,33 @@ async function resumeGateCommand( kind: 'human' | 'budget', decision: GateDecision, ): Promise { + 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, }); - let opened: { db: Db; close: () => void }; - 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 }, - ); - } + let closeHistory: (() => void) | 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(); - 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', @@ -294,6 +317,27 @@ async function resumeGateCommand( return EXIT_CODES.workflowFailed; }; try { + lifetime.checkpoint(); + const opened = acquireNativeResource( + (captureClose) => + deps.openDb === undefined + ? 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) => { + 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}`); @@ -316,12 +360,8 @@ async function resumeGateCommand( }, }); 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. throw new CliError('invalid_invocation', `run ${args.runId} has no resumable state`); @@ -369,7 +409,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 }; @@ -377,6 +419,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 @@ -384,43 +427,30 @@ async function resumeGateCommand( // 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 // 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(); - }, - () => liveMcpChildPids(), - { reapOnly: true }, - ); 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, + budget: lifetime.invocation.registry.budget, work: mcpWork, registrations: config.mcpServers, resolveSecret: @@ -433,18 +463,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 ? {} @@ -472,28 +511,36 @@ async function resumeGateCommand( // 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 // 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, @@ -512,13 +559,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', @@ -528,12 +574,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 @@ -577,6 +625,8 @@ async function resumeGateCommand( 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). @@ -584,8 +634,8 @@ async function resumeGateCommand( // 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)) { - sweepCommittedEffects(deps.io, opened.db, args.runId); + if (isTerminalOutcome(outcome) && lifetime.invocation.registry.budget.remainingMs() !== 0) { + sweepCommittedEffects(deps.io, opened.db, args.runId, lifetime.effectNoticeOwner); } await sweepMediaAtTerminal({ sweep: deps.sweepMedia ?? defaultSweepMedia, @@ -594,6 +644,7 @@ async function resumeGateCommand( 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 @@ -605,23 +656,36 @@ 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(() => { - deps.io.writeErr('warning: the MCP client could not close cleanly\n'); - }); - } finally { - mcpWork.seal(); - await mcpWork.done; - opened.close(); + if (mcpRuntime !== undefined) { + await closePreparedMcp( + mcpRuntime.client, + reportMcpCloseFailure, + lifetime.invocation.registry.budget, + ); } - } finally { - unguardMcp(); - unguardResume(); + } 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 { + closeHistory?.(); + } catch (cause) { + lifetime.cleanupFailed(storageBoundaryError(cause)); + warnTeardown(deps.io, 'history database'); } } } @@ -682,10 +746,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', @@ -780,6 +844,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, @@ -802,9 +867,20 @@ async function resolveSecretInputs( } const read = deps.readSecretInput ?? - ((): Promise => - readSecretFromStdin(SECRET_STDIN_CONTEXT(args.runId, resumeInvocation))); - const supplied = parseSecretLines(await read(), `run ${args.runId}`); + ((): 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}`); const missing = masked.filter((name) => !Object.hasOwn(supplied, name)); if (missing.length > 0) { throw new CliError( @@ -827,35 +903,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/commands/input-admission-diagnostics.test.ts b/apps/cli/src/commands/input-admission-diagnostics.test.ts index 029b1c009..7fd46f6e5 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/commands/input.ts b/apps/cli/src/commands/input.ts new file mode 100644 index 000000000..7eccf9c4f --- /dev/null +++ b/apps/cli/src/commands/input.ts @@ -0,0 +1,28 @@ +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; + +export interface CommandInput { + readonly positionals: readonly string[]; + readonly options: Readonly>; +} + +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; + 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/lazy-dispatch.test.ts b/apps/cli/src/commands/lazy-dispatch.test.ts new file mode 100644 index 000000000..aa66fa507 --- /dev/null +++ b/apps/cli/src/commands/lazy-dispatch.test.ts @@ -0,0 +1,227 @@ +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, + writeOutAcknowledged: () => Promise.resolve(), + 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 000000000..c32011109 --- /dev/null +++ b/apps/cli/src/commands/lazy-dispatch.ts @@ -0,0 +1,91 @@ +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('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); +}); + +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) => { + if (ctx.invocation?.registry.termination !== undefined) return 0; + 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(); + if (ctx.invocation?.registry.termination !== undefined) return 0; + return route(id, input, ctx); +}; 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 000000000..0ddcda713 --- /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 408531374..0a3067550 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -1,4 +1,7 @@ +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'; import { loadResolvedConfig } from '../config/load.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; @@ -15,10 +18,15 @@ 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`. */ - readonly openDb?: (homeDir: string) => { db: Db; close: () => void }; + 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; } @@ -63,13 +71,18 @@ 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); + const { reader, close } = acquireCommandResource(deps.commandLifetime, (captureClose) => + openHistoryReader(homeDir, deps.openDb, captureClose, { + io: deps.io, + invocation: deps.commandLifetime?.invocation, + }), + ); 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). @@ -148,7 +161,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/commands/logs.ts b/apps/cli/src/commands/logs.ts index 1c8b633a9..44ae981e5 100644 --- a/apps/cli/src/commands/logs.ts +++ b/apps/cli/src/commands/logs.ts @@ -1,3 +1,5 @@ +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'; @@ -14,13 +16,18 @@ 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 { + 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, + noticeContext?: LocalDbNoticeContext, + ) => { db: Db; close: () => void }; } /** @@ -34,7 +41,12 @@ export function logsCommand(args: LogsCommandArgs, deps: LogsCommandDeps): ExitC cwd: deps.global.cwd, configPath: deps.global.configPath, }); - const { reader, close } = openHistoryReader(homeDir, deps.openDb); + const { reader, close } = acquireCommandResource(deps.commandLifetime, (captureClose) => + openHistoryReader(homeDir, deps.openDb, captureClose, { + io: deps.io, + invocation: deps.commandLifetime?.invocation, + }), + ); try { const run = reader.loadRun(args.runId); if (run === undefined) { @@ -47,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); @@ -72,7 +84,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 000000000..fbae97793 --- /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 000000000..3a54d1bea --- /dev/null +++ b/apps/cli/src/commands/management-owner-integration.test.ts @@ -0,0 +1,164 @@ +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, 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 }, + { + io: lifetime.io, + global: { ...global, cwd }, + commandLifetime: lifetime, + 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/manifest.test.ts b/apps/cli/src/commands/manifest.test.ts index a4943da78..0b8fee160 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 39fb52a5f..8501986c7 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/media-input.ts b/apps/cli/src/commands/media-input.ts new file mode 100644 index 000000000..5b552e25c --- /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/commands/media.test.ts b/apps/cli/src/commands/media.test.ts new file mode 100644 index 000000000..bfc6b7448 --- /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 8e920b095..74114ff15 100644 --- a/apps/cli/src/commands/media.ts +++ b/apps/cli/src/commands/media.ts @@ -2,10 +2,13 @@ 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'; 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; @@ -15,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( @@ -23,23 +32,35 @@ 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')); - 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); + } + deps.commandLifetime?.work.assertAdmitting(); + 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/model-discovery-lifetime.test.ts b/apps/cli/src/commands/model-discovery-lifetime.test.ts new file mode 100644 index 000000000..8b9027348 --- /dev/null +++ b/apps/cli/src/commands/model-discovery-lifetime.test.ts @@ -0,0 +1,289 @@ +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; + 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'); + 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/commands/native-forwarding.test-support.ts b/apps/cli/src/commands/native-forwarding.test-support.ts new file mode 100644 index 000000000..e80f460f6 --- /dev/null +++ b/apps/cli/src/commands/native-forwarding.test-support.ts @@ -0,0 +1,113 @@ +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'; + +/** + * 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, + invocation: CliInvocation, + workspaceDir: string, + expectedParentOwners = 0, +): 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'); + // 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, + timeout: REAL_CHILD_WAIT_MS, + }); + 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( + expectedParentOwners + 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, + }); +} + +/** 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, timeout: REAL_CHILD_WAIT_MS }, + ); + return { close }; + } catch (error) { + await close(); + throw error; + } +} 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 000000000..08d14c268 --- /dev/null +++ b/apps/cli/src/commands/notice-context-forwarding.test.ts @@ -0,0 +1,143 @@ +import { mkdirSync, 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', + '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) => { + 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, 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'); + 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; + 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, + context?: LocalDbNoticeContext, + ): never => observe(capture, 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, + }, + }; + mkdirSync(join(root, '.relavium', 'workflows'), { recursive: true }); + const workflow = join(root, '.relavium', 'workflows', '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, capture, context) => + observe(capture, 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, + }, + ); + case 'list': + return list.listCommand({ agents: false }, { ...common, openDb }); + case 'gate-list': + return gateList.gateListCommand({}, { ...common, 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(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(); + expect(captured.out()).toBe(''); + }), +); 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 000000000..42cc1620b --- /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 new file mode 100644 index 000000000..ced68e1e5 --- /dev/null +++ b/apps/cli/src/commands/partial-db-acquisition.test.ts @@ -0,0 +1,132 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { dbFaultAcquisitionCases } from './db-fault-acquisition.test-support.js'; + +const runCase = dbFaultAcquisitionCases(); +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: {} }, + { 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, 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( + 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 } +`, + ); + 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; + }, + ); + 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 && !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); + 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(), '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 995eddb90..fce51ac0d 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. 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 = [ @@ -290,18 +291,24 @@ 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'); - 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; @@ -309,9 +316,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/provider-keychain.test.ts b/apps/cli/src/commands/provider-keychain.test.ts index 44c50f5d6..64f851235 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/provider.test.ts b/apps/cli/src/commands/provider.test.ts index 4f4646fdb..96646c9f7 100644 --- a/apps/cli/src/commands/provider.test.ts +++ b/apps/cli/src/commands/provider.test.ts @@ -110,6 +110,27 @@ describe('relavium provider commands (2.C)', () => { 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 2f2e65689..8b5aefbea 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,9 @@ 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; readonly store: ProviderStore; readonly keychain: KeychainStore; @@ -116,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) }; @@ -276,6 +287,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`. @@ -312,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/run-secret-consent.test.ts b/apps/cli/src/commands/run-secret-consent.test.ts index 31ca68a91..e4b5503ad 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 702071d8c..f004b8893 100644 --- a/apps/cli/src/commands/run.test.ts +++ b/apps/cli/src/commands/run.test.ts @@ -1,7 +1,12 @@ +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'; 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, @@ -235,6 +240,7 @@ function deps( return { io, global, + invocation: createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }), buildEngine: () => buildEngine({ host: createInMemoryHost(), @@ -327,6 +333,42 @@ 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, + 2, + ); + 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'; @@ -885,18 +927,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'); }); @@ -930,6 +974,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(); @@ -1080,34 +1164,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 () => { @@ -1122,7 +1204,8 @@ describe('runCommand', () => { { workflow: path, input: [], allowMcpStdio: [] }, deps(io, globalOptions(), { buildEngine: buildStalling, - selectRenderer: () => { + selectRenderer: (_io, _global, creation) => { + creation?.acknowledgeNoAcquisition(); throw new Error('render boom'); }, }), @@ -1429,6 +1512,7 @@ workflow: close: () => Promise.resolve(), }; const baselineSigint = process.listenerCount('SIGINT'); + const baselineExit = process.listenerCount('exit'); let sigintDuringConnect = -1; let exitDuringConnect = -1; @@ -1457,13 +1541,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); }); @@ -1480,6 +1560,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 = { @@ -1514,9 +1595,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); }); @@ -1582,18 +1662,22 @@ 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. - const path = writeWorkflow('mcp-dbfault.relavium.yaml', MCP_WF); + 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: [{ type: 'text', text: 'x' }], isError: false }), + callTool: () => Promise.resolve({ content: [], isError: false }), close: () => { closed += 1; return Promise.resolve(); @@ -1613,17 +1697,72 @@ workflow: { id: 'fs', toolsAllowlist: ['read'], open: () => Promise.resolve(conn) }, ]), openRunStore: () => { - throw new Error('EACCES: permission denied'); + throw cause; }, }, ); } 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(closed).toBe(1); // the spawned connection did not leak past the fault + 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; + const conn: McpConnection = { + listTools: () => Promise.resolve([{ name: 'read', inputSchema: { type: 'object' } }]), + callTool: () => Promise.resolve({ content: [{ type: 'text', text: 'x' }], isError: false }), + close: () => { + closed += 1; + return Promise.resolve(); + }, + }; + 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; + }, + }, + ).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 () => { @@ -1707,6 +1846,41 @@ workflow: expect(engineBuilt).toBe(false); // the connect failed before the engine was ever built (no leak) }); + 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, queuedExpiry); + 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(); @@ -2007,69 +2181,86 @@ 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 descriptor = Object.getOwnPropertyDescriptor(process, 'stdin'); - 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 }, - }); - 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, 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 { - if (descriptor !== undefined) Object.defineProperty(process, 'stdin', descriptor); - } - }); + 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)', 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({}), @@ -2079,13 +2270,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 62fa3b255..3466340f9 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -1,3 +1,11 @@ +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'; +import { warnTeardown } from '../process/close-quietly.js'; +import { nativeProcessesForInvocation } from '../process/native-process-owner.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'; @@ -9,7 +17,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'; @@ -30,7 +37,7 @@ import { sweepCommittedEffects } from '../engine/effect-retention.js'; import { createCliHost } from '../engine/host.js'; import { connectWorkflowMcp, - workflowDeclaresMcp, + closePreparedMcp, type StdioConsentGate, surfaceMcpSkipped, type WorkflowMcpRuntime, @@ -50,9 +57,9 @@ 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'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; @@ -61,7 +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 { + readSecretFromStdin, + type StdinSecretContext, + type StdinSecretSource, +} from '../secrets/read-secret.js'; import { resolveWorkflowSource } from '../workflows/resolve.js'; import { assertWorkflowCatalogValid, @@ -94,6 +105,10 @@ 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. */ @@ -115,12 +130,18 @@ export interface RunCommandDeps { workflow: WorkflowDefinition, homeDir: string, projectRoot: string, + captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ) => OpenedHistory; /** * 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 @@ -170,6 +191,17 @@ function parseOrRefuse(yaml: string, source: string): WorkflowDefinition { } export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Promise { + 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', @@ -191,7 +223,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), @@ -202,7 +235,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; @@ -215,53 +250,37 @@ 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); } // 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 => { + 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: the pids are read LAZILY (there are none yet at this point), 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 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 }, - ) - : (): 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 // 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, + connectSignal: lifetime.signal, + assertAdmitting: () => lifetime.checkpoint(), + onFailure: (cause) => { + 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), @@ -277,10 +296,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; @@ -297,17 +323,29 @@ 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); + const openRunStore = deps.openRunStore; + if (openRunStore !== undefined) { + opened = acquireNativeResource( + (captureClose) => + openRunStore(runWorkflow, homeDir, deps.global.cwd, captureClose, { + io: deps.io, + invocation: lifetime.invocation, + }), + { + assertAdmitting: () => lifetime.checkpoint(), + captureClose: (close) => { + closeHistory = close; + }, + cleanupUncertain: (cause) => lifetime.cleanupFailed(cause), + isStoppedFailure: (cause) => lifetime.isStoppedFailure(cause), + }, + ); + } } 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 = @@ -329,6 +367,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', }; @@ -338,19 +377,17 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr // 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, @@ -363,8 +400,12 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr // 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` @@ -421,7 +462,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 @@ -435,6 +479,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', @@ -442,15 +487,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) @@ -470,6 +519,8 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr 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 @@ -479,8 +530,12 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr // 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) { - sweepCommittedEffects(deps.io, opened.db, handle.runId); + if ( + isTerminalOutcome(outcome) && + opened !== undefined && + lifetime.invocation.registry.budget.remainingMs() !== 0 + ) { + sweepCommittedEffects(deps.io, opened.db, handle.runId, lifetime.effectNoticeOwner); } await sweepMediaAtTerminal({ sweep: deps.sweepMedia ?? defaultSweepMedia, @@ -489,6 +544,7 @@ export async function runCommand(args: RunCommandArgs, deps: RunCommandDeps): Pr 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 @@ -505,30 +561,39 @@ 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 { - try { - await mcpRuntime?.client.close(); - } finally { - mcpWork.seal(); - await mcpWork.done; - opened?.close(); + if (mcpRuntime !== undefined) { + await closePreparedMcp( + mcpRuntime.client, + reportMcpCloseFailure, + lifetime.invocation.registry.budget, + ); } - } 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(); + } 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 { + closeHistory?.(); + } catch (cause) { + lifetime.cleanupFailed(storageBoundaryError(cause)); + warnTeardown(deps.io, 'history database'); } } } @@ -545,6 +610,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')) { @@ -553,6 +619,31 @@ 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 (!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), + }); + }); + const raw = await read(); + 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 new file mode 100644 index 000000000..5e428acc1 --- /dev/null +++ b/apps/cli/src/commands/safe-close.test.ts @@ -0,0 +1,447 @@ +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 { 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'; +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: + 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'); + } + + 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', + 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 its durable successful result while an opaque native close fault withholds retirement', async () => { + const { io, out, err } = captureIo(); + 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), + }, + ), + invocation, + ); + 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 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, + writeErr: () => { + throw new Error('stderr closed'); + }, + }; + 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), + }, + ), + invocation, + ); + expect(observedFailure.mock.calls[0]?.[0], 'PRIMARY_FAILURE_PRECEDES_OPAQUE_CLEANUP').toBe( + primary, + ); + expect(observedFailure.mock.calls.length).toBeGreaterThan(1); + assertClosed(captured.err()); + }); + + it('gate cannot certify release from a native close throw after missing-run refusal', async () => { + const { io, out, err } = captureIo(); + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); + await assertWorkflowCloseUnacknowledged( + gateCommand( + { runId: 'absent', approve: true }, + { + io, + invocation, + subscribeSignals: () => () => undefined, + global: global(true), + openDb: () => ({ db: client.db, close }), + }, + ), + invocation, + ); + expect(out()).toBe(''); + expect(err()).toBe('warning: history database teardown failed\n'); + assertClosed(err()); + }); + + it('gate preserves a genuinely completed run while retaining opaque native close uncertainty', 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(); + const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); + await assertWorkflowCloseUnacknowledged( + gateCommand( + { runId: started.runId, approve: true }, + { + io, + invocation, + subscribeSignals: () => () => undefined, + global: global(true), + openDb: () => ({ db: client.db, close }), + }, + ), + invocation, + ); + expect(out()).toBe(''); + expect(err()).toContain('warning: history database teardown failed\n'); + assertClosed(err()); + }); + + it.each([false, true])( + '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; + 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 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')]), + buildSession: async (options) => { + const built = await buildChatSession(options); + const joinHost = built.whenHostSafeToClose; + return { + ...built, + whenHostSafeToClose: async () => { + await joinHost(); + joined = true; + if (verdictFails) throw primary; + }, + }; + }, + }, + ); + 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' })]), + ); + } + 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/commands/specs-forwarding.test.ts b/apps/cli/src/commands/specs-forwarding.test.ts index 8fdf81085..4bf87d121 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 8beaf7cd5..c0e13ca8f 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/commands/status.ts b/apps/cli/src/commands/status.ts index b09550e87..8f15b7f06 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -1,3 +1,5 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.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'; @@ -22,9 +24,14 @@ 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 }; + 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; } @@ -81,7 +88,12 @@ export async function statusCommand(deps: StatusCommandDeps): Promise cwd: deps.global.cwd, configPath: deps.global.configPath, }); - const { reader, close } = openHistoryReader(homeDir, deps.openDb); + const { reader, close } = acquireCommandResource(deps.commandLifetime, (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. // // A run in that state is still `running` in the derived projection, so it appears here as an ordinary @@ -140,7 +152,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/commands/surface-db-acquisition.test.ts b/apps/cli/src/commands/surface-db-acquisition.test.ts new file mode 100644 index 000000000..a76236024 --- /dev/null +++ b/apps/cli/src/commands/surface-db-acquisition.test.ts @@ -0,0 +1,190 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { dbFaultAcquisitionCases } from './db-fault-acquisition.test-support.js'; + +const runCase = dbFaultAcquisitionCases(); +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; + +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', + ({ 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, + '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 } +`, + ); + 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, noticeContext) => { + fixture.enableFault(); + return nativeOpen(home, captureClose, noticeContext); + }, + ); + 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); + } + } + }; + let settled = false; + const result = drive().then( + (value) => { + settled = true; + return value; + }, + (cause: unknown) => { + settled = true; + return cause; + }, + ); + 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 (!fixture.native()?.sqlite.open) await result; + }); + await vi.waitFor(() => + 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(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. + 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(fixture.native()?.sqlite.open).toBe(true); + expect(f.registry.activeOwners).toBe(1); + } else { + expect(await result).toMatchObject({ code: 'cli_internal', exitCode: 9 }); + 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); + } + }, + ), + ); +}); 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 000000000..12baf0b33 --- /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-cleanup-native.test.ts b/apps/cli/src/commands/workflow-cleanup-native.test.ts new file mode 100644 index 000000000..264a8fbc0 --- /dev/null +++ b/apps/cli/src/commands/workflow-cleanup-native.test.ts @@ -0,0 +1,106 @@ +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 })), + ); + let reportPremature = (): void => undefined; + const premature = new Promise((resolve) => { + reportPremature = resolve; + }); + 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 === '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. + // 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 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. + // 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({ + 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 000000000..8dc820e71 --- /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-effect-retention-owner.test.ts b/apps/cli/src/commands/workflow-effect-retention-owner.test.ts new file mode 100644 index 000000000..e9b5ce4af --- /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-expired-cleanup.test-support.ts b/apps/cli/src/commands/workflow-expired-cleanup.test-support.ts new file mode 100644 index 000000000..bc7724b55 --- /dev/null +++ b/apps/cli/src/commands/workflow-expired-cleanup.test-support.ts @@ -0,0 +1,133 @@ +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'], + queuedExpiry = false, +) { + 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, + ).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); + 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 || 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 || queuedExpiry) { + 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/commands/workflow-job-control-failure.test.ts b/apps/cli/src/commands/workflow-job-control-failure.test.ts new file mode 100644 index 000000000..2a1ec8c06 --- /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/commands/workflow-lifetime.test.ts b/apps/cli/src/commands/workflow-lifetime.test.ts new file mode 100644 index 000000000..a2f455802 --- /dev/null +++ b/apps/cli/src/commands/workflow-lifetime.test.ts @@ -0,0 +1,371 @@ +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'; +import { invocationFixture } from '../process/invocation.test-support.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('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); + 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); +}); + +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); + }, +); + +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 new file mode 100644 index 000000000..a445ad78f --- /dev/null +++ b/apps/cli/src/commands/workflow-lifetime.ts @@ -0,0 +1,232 @@ +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 { 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; + #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.invocation.finiteExit.activateFallback(); + 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, 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, + 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, + (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; + } + /** 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); + 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); + // 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; + 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(): Promise { + if (!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-maintenance-budget.test.ts b/apps/cli/src/commands/workflow-maintenance-budget.test.ts new file mode 100644 index 000000000..50ed8f0bc --- /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/commands/workflow-maintenance-siblings.test.ts b/apps/cli/src/commands/workflow-maintenance-siblings.test.ts new file mode 100644 index 000000000..28a00bfa0 --- /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/commands/workflow-mcp-close-fault.test.ts b/apps/cli/src/commands/workflow-mcp-close-fault.test.ts new file mode 100644 index 000000000..8dc490d2c --- /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/apps/cli/src/commands/workflow-preparation.test.ts b/apps/cli/src/commands/workflow-preparation.test.ts new file mode 100644 index 000000000..c344e8ffb --- /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-secret-native.test.ts b/apps/cli/src/commands/workflow-secret-native.test.ts new file mode 100644 index 000000000..d5af8db91 --- /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/commands/workflow-terminal.test.ts b/apps/cli/src/commands/workflow-terminal.test.ts new file mode 100644 index 000000000..934438e89 --- /dev/null +++ b/apps/cli/src/commands/workflow-terminal.test.ts @@ -0,0 +1,286 @@ +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.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); + 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, true); + }); + 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 000000000..b6b9a6488 --- /dev/null +++ b/apps/cli/src/commands/workflow-terminal.ts @@ -0,0 +1,299 @@ +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, cleanupUnacknowledged: boolean) => 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, cleanupUnacknowledged: boolean) => 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, true); + throw cause; + }; + let raw: Promise; + try { + raw = factory(); + } catch (cause) { + this.#promptUnacknowledged = true; + this.#prompts.delete(settled); + settle(); + 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); + } + 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(); + this.#fail(cause, mayAcquire || this.#view !== undefined); + return Promise.resolve().then(() => { + throw cause; + }); + } + return Promise.resolve(raw).then( + (value) => { + finish(); + return value; + }, + (cause: unknown) => { + finish(); + this.#fail(cause, mayAcquire || this.#view !== undefined); + 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((cause: unknown) => { + if (this.#inputWork === pending) this.#inputWork = undefined; + this.#fail(cause, true); + }); + 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) { + // 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; + } + } + + 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; + }, + (cause: unknown) => this.#fail(cause, true), + ); + if (this.#sealed) this.#releaseJobControl(); + }; + try { + const control = factory(adopt); + adopt(control); + return control; + } catch (cause) { + this.#fail(cause, true); + 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, true); + } + try { + this.#releaseJobControl(); + } catch (cause) { + this.#fail(cause, true); + } + } + + #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, true); + 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((cause: unknown) => this.#fail(cause, true)); + await this.#retired; + } +} diff --git a/apps/cli/src/config/load.test.ts b/apps/cli/src/config/load.test.ts index 65b44b177..2df0eb5c5 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 9a422481b..6fedf6be7 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/apps/cli/src/db/checkpoint-notice-lifetime.test.ts b/apps/cli/src/db/checkpoint-notice-lifetime.test.ts new file mode 100644 index 000000000..b86c4b36e --- /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 000000000..dca9668cb --- /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 000000000..f4f1e786c --- /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/migration-lock-custody.test.ts b/apps/cli/src/db/migration-lock-custody.test.ts new file mode 100644 index 000000000..91414f715 --- /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 0cad3c3b1..4bbf6c043 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, { errors: [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'); @@ -62,7 +107,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 a9ac76b78..1b70bc5c3 100644 --- a/apps/cli/src/db/open.ts +++ b/apps/cli/src/db/open.ts @@ -1,13 +1,19 @@ -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'; +import { CliError } from '../process/cli-error.js'; +import { deliverCheckpointNotice, type LocalDbNoticeContext } from './checkpoint-notice.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; @@ -21,15 +27,57 @@ 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 { - 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; + 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; // 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 (!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 @@ -38,17 +86,34 @@ 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) { - client.sqlite.close(); - throw err; + 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') { // 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. @@ -58,11 +123,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, }; } @@ -75,10 +136,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 +150,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/engine/build-engine.test.ts b/apps/cli/src/engine/build-engine.test.ts index 2520408a6..cd52de3be 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 4a95df073..c7e00d139 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 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); } } @@ -260,7 +269,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 000000000..3846a0e65 --- /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/effect-retention.ts b/apps/cli/src/engine/effect-retention.ts index bedd7474d..45aea0529 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/engine/file-catalog-cutover.test.ts b/apps/cli/src/engine/file-catalog-cutover.test.ts new file mode 100644 index 000000000..cb8bf6a16 --- /dev/null +++ b/apps/cli/src/engine/file-catalog-cutover.test.ts @@ -0,0 +1,224 @@ +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); + }, + writeErrAcknowledged: (text) => { + errors.push(text); + return Promise.resolve(); + }, + }); + 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/fixtures/media-prune-producer.ts b/apps/cli/src/engine/fixtures/media-prune-producer.ts index d4ad31951..7b9a9e533 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 b3daa8c09..99b11d2ea 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/engine/fixtures/usage-coverage-crash-worker.ts b/apps/cli/src/engine/fixtures/usage-coverage-crash-worker.ts index 3effa9a0f..6a0a63522 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-consent-cancellation.test.ts b/apps/cli/src/engine/mcp-consent-cancellation.test.ts new file mode 100644 index 000000000..82a30191b --- /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 28fab0aad..cb5b7165f 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-consent-lifetime.test.ts b/apps/cli/src/engine/mcp-consent-lifetime.test.ts new file mode 100644 index 000000000..c61010dae --- /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-consent.ts b/apps/cli/src/engine/mcp-consent.ts index e6e318fb8..c4235e6d7 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/mcp-native-ownership.test.ts b/apps/cli/src/engine/mcp-native-ownership.test.ts new file mode 100644 index 000000000..6e4aa0ce0 --- /dev/null +++ b/apps/cli/src/engine/mcp-native-ownership.test.ts @@ -0,0 +1,256 @@ +import { fileURLToPath } from 'node:url'; +import { parseWorkflow, type WorkflowDefinition } from '@relavium/core'; +import type { McpServerRef } from '@relavium/shared'; +import type { StdioNativeLifetime } from '@relavium/mcp'; +import { describe, expect, it } from 'vitest'; +import { createCliInvocation } from '../process/invocation.js'; +import { + createNativeProcessController, + type NativeProcessController, +} from '../process/native-process-owner.js'; +import { + WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE, + WindowsTeardownUnavailableError, +} from '../process/windows-teardown.js'; +import { CliError } from '../process/cli-error.js'; +import { EgressWorkScope } from './egress-work.js'; +import { connectAgentMcp, connectWorkflowMcp, resolveServerConfigs } from './mcp-servers.js'; +import type { ResolvedStdioSpawn } from './mcp-consent.js'; + +const silentServer = fileURLToPath( + new URL('../../../../packages/mcp/test-fixtures/silent-mcp-server.mjs', import.meta.url), +); +const ref: McpServerRef = { + id: 'native-owner', + transport: 'stdio', + command: process.execPath, + args: [silentServer], + connect_timeout_ms: 10000, +}; +const tick = () => 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-server-identifier-order.test.ts b/apps/cli/src/engine/mcp-server-identifier-order.test.ts new file mode 100644 index 000000000..b72b1550f --- /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.test.ts b/apps/cli/src/engine/mcp-servers.test.ts index 1738a6b55..9c1e08b54 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 58d2e4c4e..632d6e209 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, @@ -13,17 +14,20 @@ import { openStdioConnection, openWebSocketConnection, startMcpClient as defaultStartMcpClient, + setMcpClientVersion, type HttpServerSpec, type ManagerSkippedTool, type McpClient, type McpConnection, type McpServerConfig, + type McpStartupLifecycle, type SseServerSpec, type StdioServerSpec, type WebSocketServerSpec, } from '@relavium/mcp'; import { nodeEgressDeps, type LocalEndpoint } from '@relavium/db'; import { + byCodeUnit, extractEgressAuthority, isForbiddenDeclaredEnvKey, isMetadataOrLinkLocal, @@ -37,13 +41,23 @@ 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'; 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 { + 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 @@ -64,11 +78,20 @@ import type { McpSecretResolver } from '../secrets/mcp-secret.js'; 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). @@ -80,6 +103,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). @@ -288,6 +313,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; @@ -308,7 +334,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), ); } @@ -330,6 +364,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). @@ -357,6 +392,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), @@ -366,21 +404,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(); + } + }, }; } @@ -645,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', ''); } /** @@ -664,6 +707,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) => @@ -673,9 +717,76 @@ 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); + opts.assertAdmitting?.(); + if (opts.connectSignal?.aborted) throw new CliError('internal', 'MCP connection was cancelled.'); + 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); + 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); + 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; + } +} + +/** 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 { + 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 (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; } /** @@ -690,15 +801,31 @@ async function startMcpClientFailLoud( custom: ConnectAgentMcpOptions['startMcpClient'], 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; + // 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); + } if (err instanceof McpError) { throw new CliError( 'invalid_invocation', @@ -711,6 +838,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. * @@ -789,15 +924,25 @@ 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 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; /** 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}. */ @@ -870,23 +1015,32 @@ 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, opts.resolveSecret, {}, consented, + opts.nativeProcesses, ); + opts.assertAdmitting?.(); const client = await startMcpClientFailLoud( configs, opts.startMcpClient, opts.connectSignal, opts.work, + opts.assertAdmitting, + opts.onFailure, + startupLifecycle(opts), ); 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) @@ -902,11 +1056,27 @@ 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 closePreparedMcp(client, opts.onCloseError, opts.budget); + } catch (cause) { + if (opts.onCloseError !== undefined) opts.onCloseError('', cause); + else opts.onFailure?.(cause); + } throw err; } } +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; @@ -961,11 +1131,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/mcp-session-preparation.test.ts b/apps/cli/src/engine/mcp-session-preparation.test.ts new file mode 100644 index 000000000..fead3bb12 --- /dev/null +++ b/apps/cli/src/engine/mcp-session-preparation.test.ts @@ -0,0 +1,393 @@ +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('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; + 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/engine/mcp-signal-teardown.test.ts b/apps/cli/src/engine/mcp-signal-teardown.test.ts deleted file mode 100644 index fba240a5d..000000000 --- a/apps/cli/src/engine/mcp-signal-teardown.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -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; the exit reaper is process-scoped', () => { - // 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. - 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(), none); - 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); - }); - - 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. - const before = process.listenerCount('exit'); - const guards = [ - guardMcpTeardown(() => Promise.resolve(), none), - guardMcpTeardown(() => Promise.resolve(), none), - guardMcpTeardown(() => Promise.resolve(), none), - ]; - expect(process.listenerCount('exit')).toBeLessThanOrEqual(before + 1); - for (const unguard of guards) unguard(); - expect(process.listenerCount('exit')).toBeLessThanOrEqual(before + 1); - }); - - 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 — 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 8af75978e..000000000 --- a/apps/cli/src/engine/mcp-signal-teardown.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { defaultSubscribeSignals } from '../process/signals.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 { - /** - * 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; -} - -/** - * 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. - */ -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; - - // 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 => { - for (const pid of livePids()) { - try { - kill(pid); - } catch { - // Nothing to report to at exit time — and the next child still has to die. - } - } - }; - // **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. - const unsubscribeExit = - options.subscribeProcessExit === undefined - ? installProcessExitReaper(reap) - : options.subscribeProcessExit(reap); - - 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; - void Promise.race([close().catch(() => undefined), delay(MCP_TEARDOWN_GRACE_MS)]).then(() => { - if (options.reapOnly === true) return; // the caller owns the exit code — see `reapOnly` - unsubscribeSignals(); - exit(signalExitCode(signo)); - }); - }); - - return () => { - 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(); - }; -} - -/** - * 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 -} - -/** - * 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/mcp-workflow-preparation.test.ts b/apps/cli/src/engine/mcp-workflow-preparation.test.ts new file mode 100644 index 000000000..87d71ac17 --- /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/apps/cli/src/engine/media-gc.test.ts b/apps/cli/src/engine/media-gc.test.ts index 49d41a057..6215a4d2e 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( @@ -411,6 +455,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 ff2a0985d..e69642ae5 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. */ @@ -62,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: @@ -102,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. @@ -116,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; } @@ -171,7 +197,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 +207,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 +215,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/engine/model-catalog-port.test.ts b/apps/cli/src/engine/model-catalog-port.test.ts index 6fcaf16c5..f5c9b2a38 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,203 @@ 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); + 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(); + void refresh.catch(() => undefined); // Observe rejection while actual discovery remains held. + 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; + 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, + }); + 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 829d4e84e..202aea417 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,28 @@ 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, + 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 // 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 = async (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 +80,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-admission.test.ts b/apps/cli/src/engine/model-refresh-admission.test.ts new file mode 100644 index 000000000..94dea4fe9 --- /dev/null +++ b/apps/cli/src/engine/model-refresh-admission.test.ts @@ -0,0 +1,111 @@ +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 { + 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); + 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-identifier-order.test.ts b/apps/cli/src/engine/model-refresh-identifier-order.test.ts new file mode 100644 index 000000000..69d9f739b --- /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 cfba3a27f..cb4840996 100644 --- a/apps/cli/src/engine/model-refresh.ts +++ b/apps/cli/src/engine/model-refresh.ts @@ -1,5 +1,12 @@ import type { ModelCatalogLiveModel, ReplaceProviderModelsResult } from '@relavium/db'; -import type { AbortSignalLike, LlmProvider, ModelListing, ProviderId } from '@relavium/llm'; +import { byCodeUnit } from '@relavium/shared'; +import type { + AbortSignalLike, + LlmInvocationOptions, + LlmProvider, + ModelListing, + ProviderId, +} from '@relavium/llm'; import { keyHint } from './providers.js'; @@ -88,6 +95,12 @@ 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. */ + 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`). */ @@ -103,7 +116,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; @@ -183,50 +197,99 @@ 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). - 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; + } + 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' }; } // 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 // 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; + deps.assertAdmitting?.(); - 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) { + // Stopped owner admission is not a provider failure report. + deps.assertAdmitting?.(); 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; @@ -235,19 +298,25 @@ 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 { + } catch (cause) { + deps.onHostFailure?.(cause); + deps.assertAdmitting?.(); results.push({ provider: id, status: 'skipped-no-key' }); continue; } + deps.assertAdmitting?.(); connected.push({ id, key }); } 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` @@ -260,21 +329,32 @@ 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 }; }; 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) { + deps.assertAdmitting?.(); // 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); + 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/engine/pricing-overlay.test.ts b/apps/cli/src/engine/pricing-overlay.test.ts index 0279df314..547fc233f 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'; @@ -14,6 +14,8 @@ import { 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 { @@ -153,16 +155,28 @@ 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); }); - 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)).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 0a9eab708..869e3b06f 100644 --- a/apps/cli/src/engine/pricing-overlay.ts +++ b/apps/cli/src/engine/pricing-overlay.ts @@ -1,3 +1,8 @@ +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 { @@ -86,28 +91,43 @@ 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(homeDir: string): 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 { - opened.close(); - } +export function loadUserPricingOverlay( + homeDir: string, + io: Pick, + invocation?: CliInvocation, +): PricingOverlay | undefined { + return withSynchronousResource( + invocation, + (captureClose) => { + let opened: { db: Db; close: () => void }; + try { + 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; + 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/engine/providers.test.ts b/apps/cli/src/engine/providers.test.ts index 2634df7e5..bd8462521 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 79c9de1b6..81e0007d5 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, @@ -8,6 +8,7 @@ import { type EndpointKind, LlmProviderError, type LlmProvider, + type LlmRequest, type ProviderId, } from '@relavium/llm'; import type { Agent } from '@relavium/shared'; @@ -204,16 +205,22 @@ 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, 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 +228,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,17 +248,18 @@ export async function validateProviderKey( }); }, timeoutMs); }); - const probe = (async (): Promise => { + 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); @@ -254,11 +271,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/engine/secret-context-history.test.ts b/apps/cli/src/engine/secret-context-history.test.ts new file mode 100644 index 000000000..b0e0e2988 --- /dev/null +++ b/apps/cli/src/engine/secret-context-history.test.ts @@ -0,0 +1,470 @@ +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('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("")]'], + ['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); + }, + ); +}); 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 000000000..179bb8cbf --- /dev/null +++ b/apps/cli/src/engine/tool-alias-journal-digest.test.ts @@ -0,0 +1,904 @@ +import { createHash } from 'node:crypto'; + +import { + BUILTIN_TOOLS, + createToolRegistry, + type ToolDef, + type ToolDispatchContext, + type ToolHost, +} 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 `v5:${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']) }, +]; + +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', + 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 }) => { + 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(/^v5:[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(); + } + }); +}); + +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(); + } + }, + ); +}); + +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.each(['v2:', 'v3:', 'v4:'])( + '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); + let ids = 0; + const store = createEffectJournalStore(client.db, { + uuid: () => `structured-${++ids}`, + now: () => 6, + }); + 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(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, + }); + 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/apps/cli/src/engine/tool-host/assemble.test.ts b/apps/cli/src/engine/tool-host/assemble.test.ts index 027682ab5..ee4069fa8 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 3df6ca5ec..c7b316981 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/errors.ts b/apps/cli/src/engine/tool-host/errors.ts index 134df8de0..eecadd509 100644 --- a/apps/cli/src/engine/tool-host/errors.ts +++ b/apps/cli/src/engine/tool-host/errors.ts @@ -1,4 +1,6 @@ -import { ToolDispatchError } from '@relavium/core'; +import { RelaviumError } from '@relavium/shared/relavium-error'; + +import { ToolDispatchError } from '@relavium/core/tool-dispatch-error'; 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/engine/tool-host/fs-admission.ts b/apps/cli/src/engine/tool-host/fs-admission.ts index 7bafc33e5..b22946321 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 000000000..e5e09ede5 --- /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 91711a304..0269c0a2f 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/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 000000000..1adbf8894 --- /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 59bae6ff9..2ae833b48 100644 --- a/apps/cli/src/engine/tool-host/os.test.ts +++ b/apps/cli/src/engine/tool-host/os.test.ts @@ -1,8 +1,16 @@ 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 { + createNativeProcessController, + type NativeProcessController, +} from '../../process/native-process-owner.js'; +import { + WindowsTeardownUnavailableError, + WINDOWS_TEARDOWN_UNAVAILABLE_MESSAGE, +} from '../../process/windows-teardown.js'; import { clipboardReadPlans, createNodeOsCapability, @@ -45,6 +53,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) { @@ -67,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; @@ -81,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)', () => { @@ -222,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 @@ -231,18 +263,23 @@ 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 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 +293,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 a9304a915..22c9cac08 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,15 @@ 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, + platform, + }); } 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 +157,8 @@ export function createNodeOsCapability(config: NodeOsCapabilityConfig = {}): OsC signal, timeoutMs, maxBufferBytes, + nativeProcesses, + platform, }); if (result.exitCode !== 0) { throw new OsCapabilityError('the notification could not be delivered'); @@ -194,11 +223,29 @@ 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; + platform: NodeJS.Platform; + }, ): 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 +255,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 +288,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 || (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/engine/tool-host/process-close.test.ts b/apps/cli/src/engine/tool-host/process-close.test.ts index 5ea84b1b0..140d75107 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/engine/tool-host/process.ts b/apps/cli/src/engine/tool-host/process.ts index 63d982154..6b7c3e693 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/gate/clack-prompter.ts b/apps/cli/src/gate/clack-prompter.ts index 891313068..72f37e821 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 ed3622aa6..98fa25334 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 0e0007816..98734d013 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/harness/concurrency.e2e.test.ts b/apps/cli/src/harness/concurrency.e2e.test.ts index bd9b14ac4..2c1e0ee50 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'; @@ -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, vi } 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'; @@ -85,190 +87,661 @@ 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. */ + 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. */ 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. */ -function runChild(args: readonly string[]): SpawnedChild { - const child = spawn(process.execPath, [CHILD_SCRIPT, ...args], { - stdio: ['ignore', 'pipe', 'pipe'], +/** Spawn once and retain its actual close even if setup, the test body or readiness fails. */ +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; + 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 { + native: child, + opened: observedOpened, + ready: observedReady, + done, + start: () => { + child.stdin.end('START\n'); + }, + stop: () => { + if (!closed && pid !== undefined && Number.isSafeInteger(pid) && pid > 0) child.kill(); + }, + isClosed: () => closed, + }; } -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 +/** 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 { + 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; - try { - const sessions = createSessionStore(chatClient.db); - const sessionId = 'sess-coexist'; - sessions.createSession(session(sessionId)); + const sessions = createSessionStore(chatClient.db); + const sessionId = 'sess-coexist'; + sessions.createSession(session(sessionId)); - // 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(); - } - })(); + // 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 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), - }, - }), - 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'), - close: () => {}, - }); - const runIo = captureIo(); - const runPromise = runCommand( - { workflow: join(FIXTURES_DIR, 'sequential.relavium.yaml'), input: [], allowMcpStdio: [] }, - { io: runIo.io, global: globalOptions(), openRunStore }, + // 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 { + 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.'); +} - const [runCode] = await Promise.all([runPromise, chatWrites]); +interface CrossProcessPorts { + readonly firstMode?: 'fail-before-ready'; + readonly onChild?: (child: SpawnedChild) => void; + readonly onDirectory?: (directory: string) => void; + readonly onOpened?: () => void; +} - // 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'); +function startCrossProcessCase(signal: AbortSignal, ports: CrossProcessPorts = {}): Promise { + rawCase = Promise.resolve().then(() => crossProcessCase(signal, ports)); + return rawCase; +} - // 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); - } finally { - try { - runClient.sqlite.close(); - } finally { +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 { - chatClient.sqlite.close(); - } finally { - rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + 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('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'); + 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)); + + // 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'); + const clients: DbClient[] = []; + let directory: string | undefined; + try { + await expect( + startCoexistenceCase(signal, { + onClient: (client) => clients.push(client), + onDirectory: (path) => { + directory = path; + }, + migrate: () => { + throw failure; + }, + }), + ).rejects.toBe(failure); + expect(clients).toHaveLength(1); + expect(clients[0]?.sqlite.open, 'CONCURRENCY_FIXTURE_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 }); + } + }); + + 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[] = []; + 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(AbortSignal.any([signal, 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; + } + }); + + it('fixture cleanup: close failure retains its 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 { + 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 }); + } }); 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 ({ signal }) => { + const controller = new AbortController(); + const reason = new Error('synthetic cross-process abort'); + const children: SpawnedChild[] = []; + let directory: string | undefined; + await expect( + startCrossProcessCase(AbortSignal.any([signal, 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); }, ); -}); +}, 30_000); // Functional native-file lifecycle, matching session-chain; not a five-second latency contract. diff --git a/apps/cli/src/harness/fixtures/concurrent-writer.mjs b/apps/cli/src/harness/fixtures/concurrent-writer.mjs index 8783b40fa..161a163f6 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(); + } } diff --git a/apps/cli/src/harness/mcp-orphan.e2e.test.ts b/apps/cli/src/harness/mcp-orphan.e2e.test.ts index 7dae92240..2e8d23b01 100644 --- a/apps/cli/src/harness/mcp-orphan.e2e.test.ts +++ b/apps/cli/src/harness/mcp-orphan.e2e.test.ts @@ -5,17 +5,16 @@ 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'; /** * `#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` → @@ -106,145 +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 client = await connectAgentMcp([stubbornServer()], { - cwd: process.cwd(), - consentGate: allowAll, - }); - // **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. - 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; - }, - }, - ); +// 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 { - fireExitNet(); - // `kill` returns immediately; the process leaves the table a moment later. + 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 { - unguard(); + await client?.close(); } - } 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. - const client = await connectAgentMcp([stubbornServer()], { - cwd: process.cwd(), - consentGate: allowAll, - }); - try { - 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(), - () => 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'); - }, - }, - ); + }, 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 { - 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. + 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 { - unguard(); + await client?.close(); } - } 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); -}); + }, 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/history/open.ts b/apps/cli/src/history/open.ts index 40c0acae9..8ef53f395 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'; @@ -48,8 +49,10 @@ export function openHistoryStore( workflow: WorkflowDefinition, homeDir: string, projectRoot: string, + captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, ): OpenedHistory { - const { db, close } = openLocalDb(homeDir); + 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.test.ts b/apps/cli/src/history/reader.test.ts new file mode 100644 index 000000000..15928701c --- /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 162bbd7d8..741e309ff 100644 --- a/apps/cli/src/history/reader.ts +++ b/apps/cli/src/history/reader.ts @@ -1,7 +1,8 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; 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,22 +13,35 @@ 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, - openDb: (homeDir: string) => { db: Db; close: () => void } = openLocalDb, + 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; + const capture = (close: () => void): void => { + nativeClose = close; + captureClose?.(close); + }; try { - opened = openDb(homeDir); + opened = + openDb === undefined + ? openLocalDb(homeDir, undefined, capture, noticeContext) + : openDb(homeDir, capture, noticeContext); } 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 }; + // 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 59694a084..5583eefff 100644 --- a/apps/cli/src/history/session-open.ts +++ b/apps/cli/src/history/session-open.ts @@ -1,7 +1,8 @@ +import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; 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 +20,19 @@ 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 { +export function openSessionStore( + homeDir: string, + captureClose?: (close: () => void) => void, + noticeContext?: LocalDbNoticeContext, +): OpenedSessionStore { let opened: { db: Db; close: () => void }; try { - opened = openLocalDb(homeDir); + opened = openLocalDb(homeDir, undefined, captureClose, noticeContext); } 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/home/drive-home-advisory.test.ts b/apps/cli/src/home/drive-home-advisory.test.ts new file mode 100644 index 000000000..61492ee24 --- /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/apps/cli/src/home/drive-home.test.ts b/apps/cli/src/home/drive-home.test.ts index aad0fb9c1..1f794e503 100644 --- a/apps/cli/src/home/drive-home.test.ts +++ b/apps/cli/src/home/drive-home.test.ts @@ -1,7 +1,15 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { createCliInvocation } from '../process/invocation.js'; +import { randomUUID } from 'node:crypto'; +import { invocationFixture } from '../process/invocation.test-support.js'; +import { + holdNativeToolProcess, + witnessNativeToolProcess, +} from '../commands/native-forwarding.test-support.js'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, 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, @@ -44,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. @@ -114,6 +123,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { const io: CliIo = { writeOut: () => undefined, writeErr: () => undefined, + writeOutAcknowledged: () => Promise.resolve(), writeErrAcknowledged: () => Promise.resolve(), env: {}, stdoutIsTty: true, @@ -129,7 +139,8 @@ describe('driveHome (2.5.B / ADR-0054)', () => { cwd = mkdtempSync(join(tmpdir(), 'relavium-home-')); }); afterEach(() => { - client.sqlite.close(); + if (client.sqlite.open) client.sqlite.close(); + vi.restoreAllMocks(); }); const global: GlobalOptions = { @@ -159,7 +170,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 = { @@ -177,7 +192,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { return { unmount, waitUntilRenderFlush: () => Promise.resolve(), - waitUntilExit: () => new Promise(() => {}), + waitUntilExit: () => exited, }; }, getSize: () => ({ cols: 120, rows: 40 }), @@ -205,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 @@ -218,6 +312,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, { @@ -260,6 +397,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 @@ -355,12 +541,20 @@ 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({ 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)[] = []; let captured: RootAppProps | undefined; const { deps } = makeDeps((p) => (captured = p), { - buildSession: (options) => { + invocation, + buildSession: async (options) => { + await witnessNativeToolProcess( + options.nativeProcesses, + invocation, + options.cwd, + estimates.length === 0 ? 4 : 5, + ); estimates.push(options.maxTokensEstimate); return buildChatSession(options); }, @@ -371,8 +565,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(5), + ); + 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 @@ -383,6 +580,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(6), + ); await vi.waitFor(() => expect(estimates).toEqual([17, 17])); expect(props.controller.getSnapshot().mode).toBe('chat'); expect(estimates).toEqual([17, 17]); @@ -395,6 +595,184 @@ 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({ exit: vi.fn(), setExitCode: vi.fn() }); + 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(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(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(7); + permits[0]?.(); // stale completion may close only its own native creation + 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(3)); + 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({ 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)); + let captured: RootAppProps | undefined; + const { deps } = makeDeps((props) => (captured = props), { + invocation, + 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()); + 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(5); + expect(readFileSync(marker, 'utf8')).toBe('ready\n'); + }, + { interval: 5, timeout: 3000 }, + ); + } finally { + writeFileSync(release, 'release'); + 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); + } + 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.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); + 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. + 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); + 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 }); + 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". @@ -503,6 +881,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({ 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)[] = []; @@ -511,12 +890,20 @@ 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, 4); freshEstimates.push(options.maxTokensEstimate); return buildChatSession(options); }, - buildResumedSession: (options) => { + buildResumedSession: async (options) => { + await witnessNativeToolProcess( + options.nativeProcesses, + invocation, + options.record.context.workingDir, + 5, + ); resumedEstimates.push(options.maxTokensEstimate); return buildResumedChatSession(options); }, @@ -528,7 +915,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 ?? ''; @@ -550,7 +937,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(6), + ); + 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 c5cb4b150..94121fde9 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -1,3 +1,17 @@ +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'; +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'; import { COMPACTION_UNKNOWN_WINDOW_NOTICE } from '../chat/repl-info.js'; import { randomUUID } from 'node:crypto'; import { Buffer } from 'node:buffer'; @@ -46,7 +60,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, @@ -69,7 +83,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'; @@ -103,6 +117,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; @@ -111,7 +127,11 @@ 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, + noticeContext?: LocalDbNoticeContext, + ) => OpenedSessionStore; readonly mcpSecretResolver?: McpSecretResolver; /** The `/doctor` probes (2.5.C S5) — production assembles the real probes; a test injects a fake. */ readonly doctorProbes?: DoctorProbes; @@ -190,6 +210,19 @@ export const defaultOnboardingTerminalLifecycle: OnboardingTerminalLifecycle = { }; 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); + 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 { + const nativeProcesses = nativeProcessesForInvocation(deps.invocation); const grants = admitFsInvocation(deps.global); const now = deps.now ?? Date.now; const uuid = deps.uuid ?? randomUUID; @@ -208,7 +241,18 @@ export async function driveHome(deps: HomeDeps): Promise { const keychain = createOsKeychainStore(); const mcpSecretResolver = deps.mcpSecretResolver ?? createMcpSecretResolver(deps.io.env, keychain); - const opened = (deps.openSessionStore ?? openSessionStore)(homeDir); + const opened = + ownership === undefined + ? (deps.openSessionStore ?? openSessionStore)(homeDir, undefined, { + io: deps.io, + invocation: deps.invocation, + }) + : ownership.acquireStore((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 // still injects `deps.providers` / `deps.doctorProbes` to bypass the keychain/network. @@ -219,6 +263,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; @@ -230,25 +280,59 @@ 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 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); }; - const writeControl = - deps.writeControl ?? - ((sequence: string) => { - 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. @@ -256,13 +340,14 @@ export async function driveHome(deps: HomeDeps): Promise { try { onboardingTerminal.setRawMode(true); onboardingRawReleased = false; - } catch { + } catch (cause) { + ownership?.surface.work.failure(cause); // A future recovery opportunity gets another exact retry. } } if (onboardingCursorReleased) { try { - writeControl(HIDE_CURSOR); + void writeControl(HIDE_CURSOR); onboardingCursorReleased = false; } catch { // A future recovery opportunity gets another exact retry. @@ -291,9 +376,10 @@ 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 { + } 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; @@ -313,7 +399,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. @@ -351,7 +437,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 @@ -359,7 +445,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 @@ -371,7 +457,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 @@ -380,7 +466,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, @@ -390,18 +484,48 @@ 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: nodeSpawnEditor, - 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); + 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) @@ -410,6 +534,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({ @@ -431,6 +558,7 @@ export async function driveHome(deps: HomeDeps): Promise { 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, @@ -464,22 +592,37 @@ 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` // 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, @@ -501,6 +644,7 @@ export async function driveHome(deps: HomeDeps): Promise { 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, @@ -512,29 +656,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. @@ -559,9 +707,22 @@ 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( + ownSessionSubscriber(built.hostOwner?.work, (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. @@ -581,9 +742,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' @@ -593,9 +764,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 } } }; @@ -609,20 +780,28 @@ 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, + ...(built.workOwner === undefined ? {} : { owner: built.workOwner }), + 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, @@ -632,99 +811,120 @@ 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 { - persister?.close(); + closeQuietly(deps.io, 'persister', () => persister?.close()); } finally { - await built.closeMcp?.().catch(() => undefined); + await built.closeMcp?.().catch(() => warnTeardown(deps.io, 'MCP')); } throw err; } }; // 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)({ - // 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 @@ -734,118 +934,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)({ - 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)); @@ -895,37 +1112,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 @@ -938,11 +1226,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, @@ -958,6 +1251,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 @@ -967,6 +1261,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. @@ -989,19 +1284,41 @@ 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({ + ...(ownership === undefined ? {} : { workOwner: ownership.surface.work }), startChat, reseatChat, homeStore, 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'; @@ -1019,16 +1336,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, @@ -1036,10 +1399,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 @@ -1047,8 +1412,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); }, } : {}), @@ -1057,9 +1423,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)), @@ -1071,16 +1444,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: @@ -1088,19 +1472,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/home-completion-stop.test.ts b/apps/cli/src/home/home-completion-stop.test.ts new file mode 100644 index 000000000..40cfcae76 --- /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/home/home-doctor-ownership.test.ts b/apps/cli/src/home/home-doctor-ownership.test.ts new file mode 100644 index 000000000..cb7c2f13e --- /dev/null +++ b/apps/cli/src/home/home-doctor-ownership.test.ts @@ -0,0 +1,222 @@ +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 { 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'; +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 } : {}), + // 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, + 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); + 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(); + 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/home/home-effect-disclosure-owner.test.ts b/apps/cli/src/home/home-effect-disclosure-owner.test.ts new file mode 100644 index 000000000..c3bddae9d --- /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 }); + } +}); 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 000000000..be6b17cfe --- /dev/null +++ b/apps/cli/src/home/home-onboarding-raw-owner.test.ts @@ -0,0 +1,236 @@ +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 { 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'; +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 } : {}), + // 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, + 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); + 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; + 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/apps/cli/src/home/session-home-lifetime.test.ts b/apps/cli/src/home/session-home-lifetime.test.ts new file mode 100644 index 000000000..b3aa09d12 --- /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 000000000..f2456d405 --- /dev/null +++ b/apps/cli/src/home/session-home-lifetime.ts @@ -0,0 +1,90 @@ +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'; +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'); + } + + /** 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; + 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/index.ts b/apps/cli/src/index.ts index f0f6756af..1b51edc4c 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -1,43 +1,6 @@ -import { escalateExitCode, installBackgroundFailureNet } from './process/background-failure.js'; -import { EXIT_CODES } from './process/exit-codes.js'; +#!/usr/bin/env node import { processIo } from './process/io.js'; -import { setMcpClientVersion } from '@relavium/mcp'; +import { startNativeCli } from './process/native-bootstrap.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. -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 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. - 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(); -} +// 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/mcp/consent-prompt.ts b/apps/cli/src/mcp/consent-prompt.ts index ac83405cf..229f556f5 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-native.test.ts b/apps/cli/src/onboarding/wizard-native.test.ts new file mode 100644 index 000000000..dc44b28f6 --- /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 437a79350..f34cf5ac4 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'); @@ -113,6 +115,7 @@ const stubResolver: ProviderResolver = { const io: CliIo = { writeOut: () => undefined, writeErr: () => undefined, + writeOutAcknowledged: () => Promise.resolve(), writeErrAcknowledged: () => Promise.resolve(), env: {}, stdoutIsTty: true, @@ -155,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 878ff13df..00bbb7369 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,13 +230,23 @@ 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; // 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 }, @@ -185,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: { @@ -196,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 @@ -239,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."); @@ -253,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, @@ -272,19 +356,31 @@ 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 - // 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); + deps.workOwner?.assertAdmitting(); } finally { sp?.stop('Key check finished.'); } 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 000000000..e50a34bb2 --- /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 000000000..c43fb54a2 --- /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/background-failure.test.ts b/apps/cli/src/process/background-failure.test.ts deleted file mode 100644 index fdfd89206..000000000 --- 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 bd5879cda..000000000 --- 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/background-observer.test.ts b/apps/cli/src/process/background-observer.test.ts new file mode 100644 index 000000000..4ae7f6ef5 --- /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 000000000..d93a11797 --- /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/cli-error.test.ts b/apps/cli/src/process/cli-error.test.ts new file mode 100644 index 000000000..fae6b37eb --- /dev/null +++ b/apps/cli/src/process/cli-error.test.ts @@ -0,0 +1,147 @@ +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).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', () => { + 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 000000000..be43ed30b --- /dev/null +++ b/apps/cli/src/process/cli-error.ts @@ -0,0 +1,130 @@ +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. 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; +} + +/** 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: EXIT_CODES.infrastructureFailed, + }; +} 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 000000000..45fdb9bc6 --- /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 000000000..cbcd6f699 --- /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/process/command-lifetime.test.ts b/apps/cli/src/process/command-lifetime.test.ts new file mode 100644 index 000000000..553ff339b --- /dev/null +++ b/apps/cli/src/process/command-lifetime.test.ts @@ -0,0 +1,243 @@ +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('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) => { + 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 000000000..700ff3945 --- /dev/null +++ b/apps/cli/src/process/command-lifetime.ts @@ -0,0 +1,172 @@ +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'; +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 { + 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 = subscribeWithSignalCustody( + invocation.signalCustody, + 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; + } + }); + }; + + /** 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; + 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 })); +} + +/** 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/diagnostic-policy.test.ts b/apps/cli/src/process/diagnostic-policy.test.ts new file mode 100644 index 000000000..eb8611cf1 --- /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 000000000..a36f8c6ad --- /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/error-base-compatibility.test.ts b/apps/cli/src/process/error-base-compatibility.test.ts new file mode 100644 index 000000000..d3434e788 --- /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.test.ts b/apps/cli/src/process/errors.test.ts index 8a4339633..48ee33945 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, @@ -8,13 +9,23 @@ 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 { + CliError as LightweightCliError, + isCliError as isLightweightCliError, +} from './cli-error.js'; import { EXIT_CODES } from './exit-codes.js'; describe('EXIT_CODES', () => { @@ -34,11 +45,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); @@ -66,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) => { @@ -99,12 +155,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, }); }); @@ -181,8 +237,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'); }); }); @@ -228,10 +284,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); }); }); @@ -248,3 +304,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 d5b395851..1ea86bb6c 100644 --- a/apps/cli/src/process/errors.ts +++ b/apps/cli/src/process/errors.ts @@ -1,89 +1,55 @@ import { AgentParseError, EngineStateError, - isTransientEngineStateError, + TRANSIENT_ENGINE_STATE_CODES, WorkflowParseError, } from '@relavium/core'; import { - isCorruptRunEventError, + DbOpenError, + isOwnedStorageFailure, isProviderHeaderPolicyError, - isUnreadableRunEventLogError, ProviderHeaderPolicyError, SessionMessageBoundaryError, } from '@relavium/db'; +import { RunSuspensionCorruptionError } from '@relavium/shared'; -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, -}; - -/** - * 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 Error { - readonly code: CliErrorCode; - readonly exitCode: ExitCode; +import { EXIT_CODES } from './exit-codes.js'; +import { + CLI_ERROR_POLICY, + cliErrorExitCode, + CliError, + isCliError, + projectCliError, + type UserFacingError, +} from './cli-error.js'; +export { CliError, isCliError } from './cli-error.js'; +export type { CliErrorCode, UserFacingError } from './cli-error.js'; - 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]; +/** 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'); } } -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; -} - -/** - * 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`). - */ -export function toUserFacing(value: unknown): UserFacingError { - if (isCliError(value)) { - return { code: value.code, message: value.message, exitCode: value.exitCode }; +function projectUserFacing(value: unknown): UserFacingError { + 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'); + if (value instanceof DbOpenError && ownData(value, 'code') === 'uri_unsupported') { + return { + 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', @@ -91,9 +57,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', @@ -102,91 +66,85 @@ 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)) { + if (value instanceof RunSuspensionCorruptionError) { 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.`, + message: + 'The stored gate history is inconsistent. Inspect its history before resuming or repeating the run.', 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: EXIT_CODE_BY_ERROR[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: EXIT_CODE_BY_ERROR['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: EXIT_CODE_BY_ERROR['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 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), }; } + +/** 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/exit-codes.ts b/apps/cli/src/process/exit-codes.ts index 889eb76a3..34921188d 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 000000000..ec857841e --- /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 000000000..feabeaed4 --- /dev/null +++ b/apps/cli/src/process/exit-observations.ts @@ -0,0 +1,119 @@ +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; + #cooperative = 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; + } + + /** 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; + } + + 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; + } + + /** 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/file-catalog-seed.test.ts b/apps/cli/src/process/file-catalog-seed.test.ts new file mode 100644 index 000000000..88bf2d7a9 --- /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 000000000..db4a83222 --- /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/fixed-diagnostic.test.ts b/apps/cli/src/process/fixed-diagnostic.test.ts new file mode 100644 index 000000000..5289ef6f8 --- /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 000000000..4f058034f --- /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 000000000..950e4c286 --- /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-finite-exit.test.ts b/apps/cli/src/process/invocation-finite-exit.test.ts new file mode 100644 index 000000000..5d3b4dbb5 --- /dev/null +++ b/apps/cli/src/process/invocation-finite-exit.test.ts @@ -0,0 +1,319 @@ +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('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); + 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('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) => { + 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 000000000..2c27027ec --- /dev/null +++ b/apps/cli/src/process/invocation-finite-exit.ts @@ -0,0 +1,192 @@ +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 { + /** 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; + /** 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; + 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 || + (!registry.observations.hasInfrastructureFailure() && + !registry.observations.hasTerminatingSignal() && + !registry.observations.hasCooperativeCancel()) + ) + 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.hasTerminatingSignal() || + registry.observations.hasCooperativeCancel()) && + !registry.observations.hasInfrastructureFailure() + ) + exitNow(); + }); + }; + const observe = (): void => { + if (observing) return; + 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; + 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 }); + }, + signal: (signalNumber: number) => { + assertTerminatingSignalNumber(signalNumber); + if (exited || mode === 'disposed') return; + activateFallback(); + 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 }); + }, + 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', ''); + 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-registry.test.ts b/apps/cli/src/process/invocation-registry.test.ts new file mode 100644 index 000000000..65b4b841b --- /dev/null +++ b/apps/cli/src/process/invocation-registry.test.ts @@ -0,0 +1,1531 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + InvocationRegistry, + type InvocationClock, + type InvocationCloseResult, + type NativeOwner, + 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, + }; +} + +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 +// 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('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(); + 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.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); + 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); + }); +}); + +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 new file mode 100644 index 000000000..51ab9cb62 --- /dev/null +++ b/apps/cli/src/process/invocation-registry.ts @@ -0,0 +1,704 @@ +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'; +} + +/** 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; +} + +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; + /** 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 + | CommandOwner + | TerminalOwner + | NativeOwner; +export type OwnerStage = + | 'stop' + | 'join' + | 'restore' + | 'reap' + | 'retirement' + | 'health' + | 'budget' + | 'exit'; + +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: 'cooperative_cancel'; readonly outcome: 1 | 4; readonly deadline: number } + | { 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; + budgetObserved: boolean; + budgetDeadline: number | undefined; + budgetExpired: boolean; + budgetReentered: 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; + #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.#remainingMs(), + }); + } + + 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; + } + + /** 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', ''); + } + + register(owner: InvocationOwner): OwnerLease { + 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; + }); + const entry: Entry = { + owner, + retirement, + resolveRetirement, + stages: new Map(), + closing: false, + 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. + if ( + !this.#observe( + owner.retired, + (result) => this.#retire(entry, result), + (cause) => this.#failed(entry, 'retirement', cause), + ) + ) { + 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(); + 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 === '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); + 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); + this.#notifyTermination(); + 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, + }), + ); + this.#notifyTermination(); + }); + return this.#termination; + } + + /** Actual workflow facts may strengthen disposition while another owner remains pending. */ + sampleWorkflowHealth(): void { + 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; + 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 (isNativeOwner(owner)) { + 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') { + // 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); + try { + this.#tighten(this.#now() + FATAL_TERMINATION_MS); + } catch { + 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) { + try { + this.#tighten(request.deadline); + } catch { + this.observations.observeFailure('cli_internal'); + this.#expire(); + } + } + } + this.#sampleHealth(); + this.#notifyTermination(); + } + + #now(): number { + const value = this.#clock.now(); + if (!Number.isFinite(value)) throw new CliError('cli_internal', ''); + 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; + 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 { + const disarm = this.#disarm; + this.#disarm = undefined; + try { + 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 { + 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 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' || + value === 'storage_unavailable' || + value === 'cli_internal' || + value === 'output_unavailable' + ); +} 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 000000000..4c29f3450 --- /dev/null +++ b/apps/cli/src/process/invocation-work-owner.ts @@ -0,0 +1,171 @@ +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; + /** 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; +} + +/** + * 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; + } + + /** 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; + } + + 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; + await this.#ports.beforeWorkJoin?.(); + 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/invocation.test-support.ts b/apps/cli/src/process/invocation.test-support.ts new file mode 100644 index 000000000..6132d6a36 --- /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/apps/cli/src/process/invocation.ts b/apps/cli/src/process/invocation.ts new file mode 100644 index 000000000..0e2635f06 --- /dev/null +++ b/apps/cli/src/process/invocation.ts @@ -0,0 +1,47 @@ +import { createFileCatalogSeed } from './file-catalog-seed.js'; +import { SignalCustody } from './signal-custody.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; + readonly signalCustody?: SignalCustody; +} + +/** Created once before guarded imports; authority is explicitly threaded, never module-global. */ +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, + signalCustody: new SignalCustody(), + finiteExit: createInvocationFiniteExit( + registry, + clock, + finitePorts ?? { + setExitCode: (code) => { + process.exitCode = code; + }, + exit: (code) => process.exit(code), + }, + ), + 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-stdout-acknowledgement.test.ts b/apps/cli/src/process/io-stdout-acknowledgement.test.ts new file mode 100644 index 000000000..dcb57fe2f --- /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.test.ts b/apps/cli/src/process/io.test.ts index fde081aa4..4375a844a 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; @@ -74,8 +94,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 +127,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 +143,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 8cb2df289..595668d4d 100644 --- a/apps/cli/src/process/io.ts +++ b/apps/cli/src/process/io.ts @@ -1,12 +1,20 @@ -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 * 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; + /** 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; 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,23 +33,34 @@ export interface CliIo { export function processIo(): CliIo { const { stdout, stderr, stdin } = process; return { + nativeStdout: stdout, + 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), env: process.env, - stdoutIsTty: process.stdout.isTTY === true, - stdinIsTty: process.stdin.isTTY === true, + stdoutIsTty: stdout.isTTY === true, + stdinIsTty: stdin.isTTY === true, stdin, }; } -const STDERR_FAILED = 'The diagnostic output could not be acknowledged.'; +/** 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 }); + } +} -function canWrite(stream: NodeJS.WriteStream): boolean { +export function canWrite(stream: NodeJS.WriteStream): boolean { return ( stream.writable && !stream.destroyed && @@ -52,7 +71,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 +91,7 @@ function acknowledgeWrite(stream: NodeJS.WriteStream, text: string): Promise finish(false); stream.on('error', failed); 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 000000000..bd4632c96 --- /dev/null +++ b/apps/cli/src/process/native-bootstrap.test.ts @@ -0,0 +1,316 @@ +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'; +import { CliError } from './cli-error.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('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) => { + 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([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 }) => { + 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 000000000..0bbd7c72e --- /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-input.test.ts b/apps/cli/src/process/native-input.test.ts new file mode 100644 index 000000000..30f4493ff --- /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 000000000..cf7b8fa1f --- /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/native-invocation-native.test.ts b/apps/cli/src/process/native-invocation-native.test.ts new file mode 100644 index 000000000..abc67dccd --- /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 000000000..f55dee830 --- /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 000000000..f46634b40 --- /dev/null +++ b/apps/cli/src/process/native-invocation.ts @@ -0,0 +1,245 @@ +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'; + +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), + }; +} + +/** + * 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( + registry: InvocationRegistry, + policy: DiagnosticPolicy, + ports: NativeInvocationPorts = processPorts(), + sharedExit?: InvocationFiniteExit, +): 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 hasExited = (): boolean => exited || sharedExit?.hasExited() === true; + if (sharedExit !== undefined) void sharedExit.exited.then((code) => finish(code)); + const exitNow = (): void => { + if (hasExited() || disposed) return; + if (sharedExit !== undefined) { + sharedExit.exitNow(); + return; + } + exited = true; + clearTimer(); + registry.sampleWorkflowHealth(); + const code = registry.observations.exitCode(); + ports.setExitCode(code); + finish(code); + ports.exit(code); + }; + const scheduleBackstop = (): void => { + if (sharedExit !== undefined) return; + const deadline = registry.budget.deadline(); + if (hasExited() || 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 (hasExited() || disposed) return; + void registry.terminate({ kind: 'infrastructure', code }); + }; + const finalize = async (): Promise => { + const termination = registry.termination; + if (termination === undefined || hasExited() || disposed) return; + const result = await termination; + if (hasExited() || 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 (hasExited() || disposed) return; + registry.sampleWorkflowHealth(); + if ( + registry.observations.hasInfrastructureFailure() || + registry.observations.hasTerminatingSignal() || + registry.observations.hasCooperativeCancel() + ) { + exitNow(); + return; + } + clearTimer(); + const code = registry.observations.exitCode(); + ports.setExitCode(code); + finish(code); + }; + const changed = (): void => { + scheduleBackstop(); + if (hasExited() || 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 && + !hasExited() && + !disposed && + (registry.observations.hasInfrastructureFailure() || + registry.observations.hasTerminatingSignal() || + registry.observations.hasCooperativeCancel()) + ) + changed(); + }); + }); + }; + const removeObserver = + sharedExit === undefined + ? registry.observeTermination(changed) + : sharedExit.handoff(registry, 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/native-process-owner.test.ts b/apps/cli/src/process/native-process-owner.test.ts new file mode 100644 index 000000000..a163ea6ad --- /dev/null +++ b/apps/cli/src/process/native-process-owner.test.ts @@ -0,0 +1,850 @@ +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('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'); + 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.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'); + 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('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) => ({ + 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(); + 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.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); + 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 000000000..85fc7d0a2 --- /dev/null +++ b/apps/cli/src/process/native-process-owner.ts @@ -0,0 +1,565 @@ +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 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; + #helperCancelling = 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, + }); + // 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.#remaining() !== 0 + ) { + 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(); + // 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) { + 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 || + 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; + try { + this.#helper.kill('SIGKILL'); + } catch { + this.#failed(); + } finally { + this.#helperCancelling = false; + } + } + + #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/native-prompt-io.test.ts b/apps/cli/src/process/native-prompt-io.test.ts new file mode 100644 index 000000000..31c233794 --- /dev/null +++ b/apps/cli/src/process/native-prompt-io.test.ts @@ -0,0 +1,140 @@ +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(f.input); + const { nativeStdin, ...withoutNativeInput } = f.io; + expect(nativeStdin).toBe(f.input); + expect(nativePromptIo({ ...withoutNativeInput, 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 000000000..4d7517367 --- /dev/null +++ b/apps/cli/src/process/native-prompt-io.ts @@ -0,0 +1,14 @@ +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; +} { + const input = io.nativeStdin ?? io.stdin; + if (io.nativeStdout === undefined || !(input instanceof Readable)) + throw new CliError('cli_internal', ''); + return { input, output: io.nativeStdout }; +} 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 000000000..5d678f96c --- /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 95159af30..cf6c4f10f 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,40 @@ 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; + 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', ''); + } + } +} type ValueFlagResult = | { readonly kind: 'consumed'; readonly advance: number } @@ -122,9 +130,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 +149,78 @@ 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 }; - } - 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 }; + 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) }; } - 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.', - ); +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; + 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', ''); } - (raw.allowFsRoots ??= []).push(value); - return undefined; - } - if (flag === '--cwd') { - raw.cwd = value; - } else { - raw.config = value; } - 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 +251,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/owned-notification.test.ts b/apps/cli/src/process/owned-notification.test.ts new file mode 100644 index 000000000..c20c97819 --- /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 000000000..e9a05d494 --- /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); + } + }; +} 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 000000000..68fd74be7 --- /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 000000000..327b6311f --- /dev/null +++ b/apps/cli/src/process/owned-storage.ts @@ -0,0 +1,72 @@ +import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; +import { mkdtemp, writeFile } from 'node:fs/promises'; + +import { CliError, toCliError } from './errors.js'; + +/** Project an escaping open/maintenance failure without inventing storage provenance. */ +export function storageBoundaryError(value: unknown): CliError { + return toCliError(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); + } +} + +/** 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 { + 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 new file mode 100644 index 000000000..2d5ce2f38 --- /dev/null +++ b/apps/cli/src/process/pure-substrate-graph.test.ts @@ -0,0 +1,290 @@ +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/exit-codes.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', + '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', + ); + }); +}); + +// 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/render-error.test.ts b/apps/cli/src/process/render-error.test.ts index 8f3ef6559..9fbdddd63 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(); @@ -89,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 }, @@ -100,7 +136,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'); @@ -116,7 +154,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); @@ -166,7 +204,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); @@ -186,7 +224,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'); @@ -221,7 +261,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); @@ -239,16 +279,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( @@ -282,7 +325,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); @@ -310,7 +353,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(); @@ -324,7 +367,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); @@ -339,3 +382,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 887b08e8a..f3b6462d2 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 → @@ -9,30 +9,14 @@ import { sanitizeInline, sanitizeUntrusted, sanitizeUntrustedInline } from '../r * ([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, 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 000000000..c60ffe7b4 --- /dev/null +++ b/apps/cli/src/process/render-user-facing.ts @@ -0,0 +1,47 @@ +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) { + 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/process/signal-custody.test.ts b/apps/cli/src/process/signal-custody.test.ts new file mode 100644 index 000000000..7943c8c6a --- /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 000000000..555b0bd41 --- /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/process/supported-leaf-exports.test.ts b/apps/cli/src/process/supported-leaf-exports.test.ts new file mode 100644 index 000000000..57da3b71a --- /dev/null +++ b/apps/cli/src/process/supported-leaf-exports.test.ts @@ -0,0 +1,103 @@ +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(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 () => { + 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/apps/cli/src/process/synchronous-resource.test.ts b/apps/cli/src/process/synchronous-resource.test.ts new file mode 100644 index 000000000..21b17aa81 --- /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 000000000..2371d7c13 --- /dev/null +++ b/apps/cli/src/process/synchronous-resource.ts @@ -0,0 +1,104 @@ +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 + * next open. A failed close retains the exact resource capability, rather than a warning ACK. + */ +export function withSynchronousResource void }, Result>( + invocation: CliInvocation | undefined, + acquire: (captureClose: (close: () => void) => void) => 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; + const closes: (() => void)[] = []; + let active = true; + let attempted = false; + let uncertain = false; + const release = (): void => { + if (active || attempted || uncertain) return; + attempted = true; + try { + 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) { + 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 = 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. + if ( + toUserFacing(cause).code === 'cli_internal' || + toUserFacing(cause).code === 'output_unavailable' + ) { + 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/process/windows-teardown.test.ts b/apps/cli/src/process/windows-teardown.test.ts new file mode 100644 index 000000000..bb8ee7bb0 --- /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 000000000..570659d38 --- /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(/(? 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 +68,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/render/editor-native-lifetime.test.ts b/apps/cli/src/render/editor-native-lifetime.test.ts new file mode 100644 index 000000000..b4dc987fb --- /dev/null +++ b/apps/cli/src/render/editor-native-lifetime.test.ts @@ -0,0 +1,148 @@ +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 { nativeProcessesForInvocation } from '../process/native-process-owner.js'; +import { createNodeSpawnEditor } from './editor.js'; + +vi.mock('node:child_process', { spy: true }); +const native = await vi.importActual('node:child_process'); +afterEach(() => { + vi.mocked(spawn).mockReset(); + vi.mocked(spawn).mockImplementation(native.spawn); +}); + +describe('foreground editor actual native lifetime', () => { + 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(), + }); + 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, 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 1eb9afd8c..0c468648f 100644 --- a/apps/cli/src/render/editor.test.ts +++ b/apps/cli/src/render/editor.test.ts @@ -1,23 +1,38 @@ +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'; 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(() => ({ 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); }, @@ -169,6 +184,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({ @@ -443,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 bec1d9045..0eaa6165f 100644 --- a/apps/cli/src/render/editor.ts +++ b/apps/cli/src/render/editor.ts @@ -1,9 +1,19 @@ 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'; +import { + createNativeProcessController, + type NativeProcessController, +} from '../process/native-process-owner.js'; +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 * private temp file and hand the terminal to the user's editor, so they can search, select, and copy it — the @@ -153,7 +163,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}` }; @@ -224,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 @@ -241,7 +267,8 @@ 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(), + onFailure?: (cause: unknown) => void, +): (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(); + } 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 }), + ); + return; + } + 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); + }); + 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 })); - }); + }; +} + +/** 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/fixtures/native-editor.mjs b/apps/cli/src/render/fixtures/native-editor.mjs new file mode 100644 index 000000000..36a6f4df9 --- /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); diff --git a/apps/cli/src/render/hatches.test.ts b/apps/cli/src/render/hatches.test.ts index 3d19c5fbe..7e7fb3d24 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,56 @@ 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) => { + 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'); + expect(failure).toHaveBeenCalledExactlyOnceWith(primary); + }); + 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 983b7d917..962040712 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`). */ @@ -159,21 +162,52 @@ 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( { suspendTerminal: suspend, - writeControl: deps.writeControl, + writeControl: async (sequence) => { + try { + await deps.writeControl(sequence); + } catch (cause) { + reportFailure(cause); + throw cause; + } + }, inkOwnsAltScreen: term.inkOwnsAltScreen, altActive: term.altActive, mouseActive: term.mouseActive, }, - body, + async () => { + try { + await body(); + } catch (cause) { + reportFailure(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)}`); + reportFailure(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/renderer.ts b/apps/cli/src/render/renderer.ts index e171988b0..e0382eb3f 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 000000000..3d3a35464 --- /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 e4eec036e..6f2cbfe60 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,107 @@ 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) => { + 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 }); @@ -71,6 +175,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 @@ -157,7 +308,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 1fe918019..6111c94e5 100644 --- a/apps/cli/src/render/run-job-control.ts +++ b/apps/cli/src/render/run-job-control.ts @@ -22,9 +22,14 @@ 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; + /** 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). */ @@ -33,101 +38,161 @@ 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 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(); + const dispose = (): void => { + disposed = true; // Callback and future-registration admission closes before either native remover. + let failed: { readonly cause: unknown } | undefined; 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(); + 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(); }; - 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 writeCursor = (text: string): boolean => { try { - detach(); - } catch (cleanupError) { - throw new CliError('internal', 'Run job-control setup failed.', { - cause: { registrationError: error, cleanupError }, - }); + 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; } - 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; + }; + const onSuspend = (): void => { + if (disposed) return; + if (!writeCursor(SHOW_CURSOR)) return; + if (disposed) return; + try { + detached(); + if (disposed) return; try { - detach(); + lifecycle.suspendSelf(); } catch (cause) { - failure = { cause }; + opts.onFailure?.(cause); + attach(); } - try { - if (!continueRemoved) { - removeContinue(); - continueRemoved = true; + } catch (cause) { + if (opts.onFailure === undefined) throw cause; + opts.onFailure(cause); + } + }; + 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 { + removeSuspend = lifecycle.onSuspend(onSuspend); + acquired = true; + attached = true; + if (disposed) detached(); + } catch (cause) { + if (!acquired) unknownRegistration = true; + throw cause; + } finally { + registrations -= 1; + } + 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; + try { + attach(); + if (!disposed) writeCursor(HIDE_CURSOR); + } catch (cause) { + if (opts.onFailure === undefined) throw cause; + opts.onFailure(cause); + } + }); + 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 { + 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/sanitize.test.ts b/apps/cli/src/render/sanitize.test.ts index c04931487..00a181611 100644 --- a/apps/cli/src/render/sanitize.test.ts +++ b/apps/cli/src/render/sanitize.test.ts @@ -249,6 +249,57 @@ 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', () => { + 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', + ); + } + }, + ); }); /** diff --git a/apps/cli/src/render/sanitize.ts b/apps/cli/src/render/sanitize.ts index b770989dc..80fd1f663 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). @@ -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 diff --git a/apps/cli/src/render/scrollback.test.ts b/apps/cli/src/render/scrollback.test.ts index 5aa792264..fd0959a77 100644 --- a/apps/cli/src/render/scrollback.test.ts +++ b/apps/cli/src/render/scrollback.test.ts @@ -1,8 +1,9 @@ import { Writable } from 'node:stream'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { PassThrough } from 'node:stream'; +import { injectPostRemoval } from '../chat/native-removal.test-support.js'; import { DUMP_FOOTER, @@ -26,7 +27,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 +40,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 +124,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'); + injectPostRemoval(stdin)((event) => { + 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 450eac696..d6ae23496 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/select.test.ts b/apps/cli/src/render/select.test.ts index d88267cbf..caa222067 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 a59fbbca4..7c441e3ff 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/suspend.test.ts b/apps/cli/src/render/suspend.test.ts index b25e76cf8..92e9e705f 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 a1df1ef79..87148ea4d 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/RunApp.test.tsx b/apps/cli/src/render/tui/RunApp.test.tsx index 2b71010d2..1bde57e86 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 d41676ae9..29b6c1499 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-completion-stop.test.ts b/apps/cli/src/render/tui/chat-completion-stop.test.ts new file mode 100644 index 000000000..e0aed1a05 --- /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 59f29f037..91773cf0b 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, @@ -12,6 +13,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 +212,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 +599,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)); @@ -773,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 }); @@ -795,12 +805,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 +821,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 +836,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 +861,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; @@ -863,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'); }, ); }; @@ -872,6 +885,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 +907,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; @@ -900,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 }); }, ); @@ -956,6 +970,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 = @@ -1006,6 +1021,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 +1060,10 @@ export function ChatApp(props: Readonly): ReactElement { case 'state': applyEffortPicker(step.state); return; + default: { + const exhaustive: never = step; + return exhaustive; + } } }; @@ -1059,6 +1082,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`). @@ -1108,6 +1132,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; @@ -1198,6 +1223,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')) { @@ -1655,7 +1681,7 @@ export function ChatApp(props: Readonly): ReactElement { export function finalizeInkExit( exited: Promise, ops: { - readonly teardown: () => void; + readonly teardown: () => unknown; readonly outcome: () => ChatDriveOutcome; }, ): Promise { @@ -1674,7 +1700,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, @@ -1715,7 +1742,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(); @@ -1729,7 +1758,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, + ); }; }); @@ -1747,6 +1784,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 @@ -1772,7 +1810,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 @@ -1805,68 +1851,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 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 + // '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(); @@ -1875,7 +1986,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` @@ -1886,12 +1998,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/chat-picker-stop.test.tsx b/apps/cli/src/render/tui/chat-picker-stop.test.tsx new file mode 100644 index 000000000..67f6296c1 --- /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-app.test.tsx b/apps/cli/src/render/tui/home-app.test.tsx index d5447bf0b..48964b4d6 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 54844040f..a4af2161e 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/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 000000000..1f31427f4 --- /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(stopped); + expect(f.onError).not.toHaveBeenCalled(); + } finally { + raw.resolve(undefined); + await f.controller.teardownActive(); + } + }, + ); +}); diff --git a/apps/cli/src/render/tui/home-controller.test.ts b/apps/cli/src/render/tui/home-controller.test.ts index bc496a26a..830a00eda 100644 --- a/apps/cli/src/render/tui/home-controller.test.ts +++ b/apps/cli/src/render/tui/home-controller.test.ts @@ -125,6 +125,105 @@ 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 }); + 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, @@ -1814,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 8bb99b451..dcaa0ebdb 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, @@ -83,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. @@ -202,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; @@ -236,6 +238,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. */ @@ -295,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 @@ -366,7 +372,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 => @@ -439,7 +451,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 @@ -449,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(); @@ -520,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) @@ -598,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 (!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. if (active.stopReason() === 'clear') clearChat(active); @@ -610,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 @@ -626,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) @@ -684,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; @@ -707,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; @@ -714,15 +730,15 @@ 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 } }); }, ); }; 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 @@ -801,7 +817,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, @@ -813,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; @@ -826,6 +845,7 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { } return; } + if (exiting) return; set({ modelPicker: undefined, notice: defaultWriteNotice(port.currentDefault(), modelId, displayName, reasoningEffort), @@ -942,6 +962,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 +1009,10 @@ export function createHomeController(deps: HomeControllerDeps): HomeController { case 'state': set({ effortPicker: step.state }); break; + default: { + const exhaustive: never = step; + return exhaustive; + } } return true; }; @@ -1033,27 +1061,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); }, }; @@ -1104,9 +1137,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( @@ -1126,8 +1159,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 @@ -1167,7 +1199,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). @@ -1198,6 +1230,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; @@ -1216,7 +1249,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'); } }, @@ -1556,9 +1589,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/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 000000000..fbfad24a2 --- /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 0c56803c3..09992267e 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/render/tui/ink-terminal-visibility.test.ts b/apps/cli/src/render/tui/ink-terminal-visibility.test.ts index 7ffef0a80..0d149c0ad 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]); 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 000000000..1cc3cb31b --- /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 fedc3cf6b..81e82f17a 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/render/tui/run-view-model.test.ts b/apps/cli/src/render/tui/run-view-model.test.ts index f0330f8b9..d0d99a627 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([]); @@ -28,6 +39,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 a6bd7d496..ca0ab4e08 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,17 @@ 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; + void exhaustive; // Keep compile-time exhaustiveness and tolerate newer external runtime events. + return base; + } } } 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 000000000..59152798e --- /dev/null +++ b/apps/cli/src/run-lazy-boundary.test.ts @@ -0,0 +1,141 @@ +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; + }, + writeOutAcknowledged: (text) => { + out += text; + return Promise.resolve(); + }, + 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, + finiteExit: base.finiteExit, + 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-media-prune.test.ts b/apps/cli/src/run-media-prune.test.ts index 5f3b84d6d..fe6dd187a 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,8 +321,30 @@ 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', - ).toBe(kind === 'root' ? 0 : 1); + `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'); expect(io.out() + io.err()).not.toContain(isolated.home); diff --git a/apps/cli/src/run-secret-inputs.test.ts b/apps/cli/src/run-secret-inputs.test.ts index 68d48e7c8..779155675 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 7344ce75b..9ee31a5e1 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/apps/cli/src/run.test.ts b/apps/cli/src/run.test.ts index ac4ac3003..6f48d374b 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'); diff --git a/apps/cli/src/run.ts b/apps/cli/src/run.ts index 8ee432507..7694b98b7 100644 --- a/apps/cli/src/run.ts +++ b/apps/cli/src/run.ts @@ -1,160 +1,168 @@ 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, 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, 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('cli_internal', '', { cause }); + } + if (deps.invocation?.registry.termination !== undefined) return EXIT_CODES.success; + 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(), + native?: Pick, ): 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, native); } - - // 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 }, - }); - - // Bare invocation — no subcommand after extraction (only `[node, script]`, optionally a lone `--`). - if (isBareInvocation(rest)) { - return await handleBareInvocation({ io, global, program, openHome, renderCtx }); - } - + 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, assertPreparing); + assertPreparing(); + 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, 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) { - // 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, native); } } -/** 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, + assertPreparing: () => void, +): Promise { + assertPreparing(); + 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('cli_internal', '', { cause }); } + assertPreparing(); + 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 { - try { - loadCachedCatalog(resolveHomeDir({})); - } 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. +function renderFailure( + value: unknown, + opts: { json: boolean; verbose: boolean }, + io: CliIo, + 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); } + 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/secrets/fixtures/native-close-child.ts b/apps/cli/src/secrets/fixtures/native-close-child.ts new file mode 100644 index 000000000..48c3ea45a --- /dev/null +++ b/apps/cli/src/secrets/fixtures/native-close-child.ts @@ -0,0 +1,133 @@ +/** 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'; +import { agentRunCommand } from '../../commands/agent-run.js'; +import { join } from 'node:path'; + +const mode = process.argv[2]; +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 = () => {}; +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 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 (action === 'predestroyed') { + input.destroy(); + await heldClose; +} +if (action === 'factory') + input[Symbol.asyncIterator] = () => { + throw new Error('synthetic native iterator factory refusal'); + }; +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 (action === 'cancel' || (agent && action === 'factory')) await new Promise(() => {}); +process.send?.({ phase: 'settled', ...snapshot() }, () => process.disconnect()); diff --git a/apps/cli/src/secrets/keychain.ts b/apps/cli/src/secrets/keychain.ts index 674f6916a..7584faf9b 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'; 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 000000000..5923b609e --- /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 new file mode 100644 index 000000000..b2a9f9fec --- /dev/null +++ b/apps/cli/src/secrets/read-secret-lifetime.test.ts @@ -0,0 +1,190 @@ +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.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(); + 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 5da31d47f..ce912f6b1 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 { 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 @@ -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 { @@ -63,16 +66,20 @@ 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 captured = new CapturedNativeInput(input, lifecycle, 'Secret input stopped.'); const chunks: Buffer[] = []; let totalBytes = 0; try { + try { + captured.open(); + 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; @@ -93,6 +100,7 @@ export async function readSecretFromStdin( } else if (typeof chunk === 'string') size = chunk.length; } } catch (cause) { + if (cause === captured.cancelled) lifecycle?.signal.throwIfAborted(); throw readFailure(cause); } if (done) { @@ -121,14 +129,19 @@ 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. + captured.requestClose(); + if (!finished && iterator !== undefined) { try { await iterator.return?.(); } catch { // Best-effort release must not replace the primary refusal with foreign cleanup text. } } + await captured.close(); } + 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); diff --git a/apps/cli/src/test-support.ts b/apps/cli/src/test-support.ts index 3e7419528..11f9f3f5d 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/apps/cli/src/version.ts b/apps/cli/src/version.ts new file mode 100644 index 000000000..1ea9877bb --- /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/src/workflows/catalog-identifier-order.test.ts b/apps/cli/src/workflows/catalog-identifier-order.test.ts new file mode 100644 index 000000000..d71a2c2d2 --- /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 b77f8f26e..fa57e3259 100644 --- a/apps/cli/src/workflows/catalog.ts +++ b/apps/cli/src/workflows/catalog.ts @@ -8,7 +8,8 @@ import { realpathSync, statSync, } from 'node:fs'; -import { basename, join, relative } from 'node:path'; +import { basename, join } from 'node:path'; +import { byCodeUnit } from '@relavium/shared'; import { AgentParseError, @@ -19,6 +20,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'; /** @@ -101,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. */ @@ -118,7 +120,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 858afc910..194709ac2 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 99002cfbf..c92822670 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/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts index 6476d3a20..92e89c717 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 }); diff --git a/docs/decisions/0006-os-keychain-for-api-keys.md b/docs/decisions/0006-os-keychain-for-api-keys.md index b35896621..f1913d3e1 100644 --- a/docs/decisions/0006-os-keychain-for-api-keys.md +++ b/docs/decisions/0006-os-keychain-for-api-keys.md @@ -66,3 +66,16 @@ 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. + +## 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/0019-cli-node-keychain-library.md b/docs/decisions/0019-cli-node-keychain-library.md index 270f227be..b1a76d024 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 a9989ae42..db70b5423 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/0027-expression-sandbox.md b/docs/decisions/0027-expression-sandbox.md index daddd6fc7..1c4bae15c 100644 --- a/docs/decisions/0027-expression-sandbox.md +++ b/docs/decisions/0027-expression-sandbox.md @@ -195,3 +195,19 @@ 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. + + +## 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 18e85cd61..959772eac 100644 --- a/docs/decisions/0029-tool-policy-hardening.md +++ b/docs/decisions/0029-tool-policy-hardening.md @@ -119,3 +119,30 @@ 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. + +## 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. + + +## 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/0031-llm-seam-shape-amendment-multimodal-io.md b/docs/decisions/0031-llm-seam-shape-amendment-multimodal-io.md index d88c7166d..dbdb60221 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/0033-strict-config-files-amends-0023.md b/docs/decisions/0033-strict-config-files-amends-0023.md index 3fd46ec47..4b27a7bf2 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 ed85198fc..ffe945c59 100644 --- a/docs/decisions/0037-engine-tool-execution-boundary.md +++ b/docs/decisions/0037-engine-tool-execution-boundary.md @@ -81,3 +81,30 @@ 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. + +## 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. + + +## 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/0042-engine-media-storage-substrate-mediastore-deinline-retention.md b/docs/decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md index 24c685659..5c7b7fa3d 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 fc79d74d0..2b2c0c917 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 5e7b5a801..bea8cc9a7 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/0047-cli-framework-commander-ink-clack.md b/docs/decisions/0047-cli-framework-commander-ink-clack.md index 5aff644f3..a7d9569b5 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 5c365860a..2489fce7d 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 23ef66aa1..9598ab208 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,16 @@ 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. + +## 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 c694aa6e1..1df8188f7 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 35aa01b77..47a431853 100644 --- a/docs/decisions/0054-cli-bare-invocation-interactive-home.md +++ b/docs/decisions/0054-cli-bare-invocation-interactive-home.md @@ -76,3 +76,16 @@ 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. + +## 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 ad7ffe130..7886a0dbd 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,16 @@ 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. + +## 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 80e69d7e3..494c343ff 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 dfef5f3a7..9095dded0 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,16 @@ 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. + +## 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 2ec2ea48f..938aeb1b6 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/0061-cli-input-layer-file-injection-and-shell-escape.md b/docs/decisions/0061-cli-input-layer-file-injection-and-shell-escape.md index ceaf7cad2..230afaf0b 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 4e350cb75..19059b584 100644 --- a/docs/decisions/0063-cli-config-write-contract.md +++ b/docs/decisions/0063-cli-config-write-contract.md @@ -140,3 +140,16 @@ 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. + +## 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/0064-live-model-catalog.md b/docs/decisions/0064-live-model-catalog.md index 179cca902..c335b767e 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/0065-provider-economics-and-extensibility.md b/docs/decisions/0065-provider-economics-and-extensibility.md index f2818ff6c..7bfbf29aa 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/0071-models-dev-as-the-model-metadata-source.md b/docs/decisions/0071-models-dev-as-the-model-metadata-source.md index 0fdf3882c..1be40bd8b 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 55da06aba..33f130f47 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/0080-durable-effect-journal-and-the-tiered-effect-contract.md b/docs/decisions/0080-durable-effect-journal-and-the-tiered-effect-contract.md index c38dd60b5..b86f80b1a 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,113 @@ 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. + +### 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. + +### 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. + +### 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. + +### 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. + +### 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. + +### 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. + + +### 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/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 497f1055c..ca4d6e494 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,28 @@ 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. + +## 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/0084-consent-before-a-local-mcp-spawn.md b/docs/decisions/0084-consent-before-a-local-mcp-spawn.md index 43647d5f6..00c458c0d 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/0088-the-mcp-boundary-is-hostile.md b/docs/decisions/0088-the-mcp-boundary-is-hostile.md index 00d135f38..3b6492660 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/0089-media-correctness-four-boundaries.md b/docs/decisions/0089-media-correctness-four-boundaries.md index eaeaebb0a..531df8880 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/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 054f90d13..beb007fc1 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,19 @@ 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. + + +## 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/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 3a1f24bdd..6f76b0a26 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,38 @@ 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. + +## 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. + +### 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/0105-filesystem-roots-are-invocation-grants-not-project-authority.md b/docs/decisions/0105-filesystem-roots-are-invocation-grants-not-project-authority.md index 8c0e556c4..4e6d623cd 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,16 @@ 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. + +## 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/0106-media-publication-requires-bounded-verified-bytes.md b/docs/decisions/0106-media-publication-requires-bounded-verified-bytes.md index eb3d9f9e1..c3d44b2f5 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 ac5674d93..2b26e3b63 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 67a7c20ca..456c7276f 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 2924fefcd..a6ed2babc 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,16 @@ 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. + +## 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 new file mode 100644 index 000000000..1ae4a864f --- /dev/null +++ b/docs/decisions/0110-cli-diagnostics-and-discovery-have-explicit-compatibility.md @@ -0,0 +1,347 @@ +# ADR-0110: CLI diagnostics and discovery have explicit compatibility + +- **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) + +## 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 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 +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. + +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. + +> 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 + +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 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. + +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 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. 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 + +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**, record ADR-0049's clause-specific partial replacement in its +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. +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 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 + +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. + 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, + 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 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 + +- 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 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. + +## 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. + +## 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. + +## 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. + +## 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. + +## 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. + + +## 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. + +## 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. + +## 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. + +## 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. + +## 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. + +## 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. + +## 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. + + +## 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 new file mode 100644 index 000000000..af9058c2b --- /dev/null +++ b/docs/decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md @@ -0,0 +1,232 @@ +# ADR-0111: CLI meta-operations use a lazy packaged runtime + +- **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) + +## Context + +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. + +> 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 + +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. 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, 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 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; +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 + +Emit external upstream TypeScript maps only in a fresh isolated, uncached +candidate build, without embedded sources. Ordinary workspace builds remain +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. + +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. + +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. + +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. 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, +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 +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. 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 + +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. + 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 + 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, 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, 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 + +### Positive + +- Meta operations avoid unrelated services by construction and retain one grammar. +- Exact published bytes can be symbolicated to original public-source revisions. + +### Negative + +- 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. + +## 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. + +## 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. + +## 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/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 000000000..809d89d94 --- /dev/null +++ b/docs/decisions/0112-chat-network-grants-and-onboarding-state-are-operator-owned.md @@ -0,0 +1,191 @@ +# ADR-0112: Chat network grants and onboarding state are operator-owned + +- **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) + +## Context + +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. 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. + +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). + +> 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 + +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. + +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: +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 + +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. +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. 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 +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. 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 +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 +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 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 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 + 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 + +- Existing chat HTTP becomes usable under explicit, bounded operator authority. +- Rebuilds revoke grants monotonically; setup and intentional config links stay honest. + +### Negative + +- Strict new keys break old-binary config reads until removed, even on unrelated + commands. The canonical rollback instructions make the recovery explicit. +- 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. + +## 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 new file mode 100644 index 000000000..7024603d0 --- /dev/null +++ b/docs/decisions/0113-secret-derived-context-keeps-provenance-at-public-sinks.md @@ -0,0 +1,253 @@ +# ADR-0113: Secret-derived context keeps provenance at public sinks + +- **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) + +## Context + +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; 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) +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 + +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 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 +budget preparation. The one owned projection contains only clean context strings +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 +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. 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. + +### 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 +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. + +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 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 +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 + +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-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 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 + +1. Actual WorkflowEngine + createStandardNodeExecutor + QuickJS controls cover + 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. + 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, exact current primitive/result parity and + 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 + 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 (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, 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. 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. +- 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 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. + +## 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. + + +## 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. + +### 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. + +### 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/decisions/README.md b/docs/decisions/README.md index a8eaa0b6d..8b9136a29 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,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) | 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 ae76a5336..bb56d98f0 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) | 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 bbabae4fa..59947821c 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`) @@ -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/commands.md b/docs/reference/cli/commands.md index fbe711b03..7e9a1acda 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-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 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 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. @@ -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) | @@ -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)). @@ -151,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 { @@ -164,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. @@ -231,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. @@ -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` @@ -458,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` @@ -571,23 +580,24 @@ 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). ## 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 | |------|---------| | `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) | @@ -595,6 +605,12 @@ 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 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. @@ -644,3 +660,682 @@ 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 + +**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 +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. Step 8a +implements minimal storage-origin adapters and defensive runtime projections, +and adds an inert native invocation foundation. Product fatal/native/terminal +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 + +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. 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 +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 + +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 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: 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 | +| 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. 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. + +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. `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. + +#### 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, 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. + +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, 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. + +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. +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 + +**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 +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. 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. +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 | +| 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 | + +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. + +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 +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. + +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. + +### Session ownership implementation checkpoint + +**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 +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. + +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 +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 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 +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. 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 historical implementation scopes; the final acceptance above closes whole Step 8. + +### Management ownership implementation checkpoint + +**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 +signal and admission checks prevent credential, catalog and output publication +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. + +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; 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 +from guarded business output. No deadline certifies input, terminal or database +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 + +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, +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) +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 +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 +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 +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. + +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 +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. +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 +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 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 + +**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 +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. 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; 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 | +|---|---| +| 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. +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 +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 + +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, `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, 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 +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 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; +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. + + +#### 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. + +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. + + +### 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. + +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. + +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. + +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. + +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. + +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. diff --git a/docs/reference/cli/home.md b/docs/reference/cli/home.md index 02ae78415..049f22274 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-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) @@ -23,7 +23,16 @@ 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. Step 8d +implements caught Home/session fault integration and the complete fatal coordinator; +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) @@ -135,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/reference/cli/packaging.md b/docs/reference/cli/packaging.md new file mode 100644 index 000000000..3df411422 --- /dev/null +++ b/docs/reference/cli/packaging.md @@ -0,0 +1,403 @@ +# CLI Packaging Contract + +- **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) + +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 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 + +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. 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, +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. + +### 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. 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 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. 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 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 +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 + +### 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. +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 +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, +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 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 + +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. + 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. + 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. +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. + +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. +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. +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 **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 +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. + +## 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. 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. +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 + +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. + +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 | +|---|---| +| 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 | + +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. + +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. 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 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 approved design contract into implemented reference status and remove stale proposal +banners only for actually accepted mechanisms. diff --git a/docs/reference/contracts/agent-session-spec.md b/docs/reference/contracts/agent-session-spec.md index f9de93bbf..efefa2198 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/contracts/agent-yaml-spec.md b/docs/reference/contracts/agent-yaml-spec.md index 0a17a9ca8..2eafad6b2 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/config-spec.md b/docs/reference/contracts/config-spec.md index 1e62f66fe..336e28c5b 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 @@ -259,3 +259,108 @@ 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 + +**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. + 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. 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; 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. + +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. + +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, +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. 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. + +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. +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 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, 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/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index 090eb6cc2..24115ab5c 100644 --- a/docs/reference/contracts/sse-event-schema.md +++ b/docs/reference/contracts/sse-event-schema.md @@ -38,6 +38,23 @@ 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. + +`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 48242a793..157619ed3 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`. | @@ -180,11 +180,23 @@ 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 -`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: @@ -194,7 +206,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: @@ -202,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. @@ -306,6 +322,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 +340,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 +372,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 +676,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/desktop/keychain-and-secrets.md b/docs/reference/desktop/keychain-and-secrets.md index 37e264b62..b5a22d4cc 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) @@ -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 10b9f267b..65b299446 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,12 +58,19 @@ 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`. ## 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: @@ -369,3 +376,51 @@ 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. + + + +## Qualified context consumers + +**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). + +`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. +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 +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. +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 +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/built-in-tools.md b/docs/reference/shared-core/built-in-tools.md index b65b8495b..09662fbb3 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). @@ -58,13 +82,36 @@ 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). ## 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/database-schema.md b/docs/reference/shared-core/database-schema.md index 68b86fc8d..d75762a13 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: @@ -311,9 +348,12 @@ 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 -corruption errors remain unexpected CLI `internal` failures (exit 1), not policy -refusals or silent empty maps. +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 +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 @@ -484,9 +524,18 @@ 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. +> **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 | |--------|------|-------------| @@ -681,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 `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) | @@ -723,6 +772,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 | |--------|------|-------------| @@ -1278,6 +1332,26 @@ 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). + +`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`. @@ -1318,6 +1392,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/effect-journal.md b/docs/reference/shared-core/effect-journal.md index ad660e6e0..f1c3a8db3 100644 --- a/docs/reference/shared-core/effect-journal.md +++ b/docs/reference/shared-core/effect-journal.md @@ -351,6 +351,41 @@ 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 `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, `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. +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, 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 +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 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. @@ -427,8 +462,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/expression-sandbox-spec.md b/docs/reference/shared-core/expression-sandbox-spec.md index aa2130ed3..11da09c39 100644 --- a/docs/reference/shared-core/expression-sandbox-spec.md +++ b/docs/reference/shared-core/expression-sandbox-spec.md @@ -33,28 +33,31 @@ 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. -- **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. +- **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 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. @@ -151,20 +154,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,28 +191,31 @@ 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.** -> 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 -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 +230,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 @@ -256,3 +266,153 @@ 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). + + + +## Secret context boundary + +**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 + +`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. + +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` +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 + +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 + +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. + +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 +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 +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 +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 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` | + +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/llm-provider-seam.md b/docs/reference/shared-core/llm-provider-seam.md index b4f4f1331..2ac78921d 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/reference/shared-core/mcp-integration.md b/docs/reference/shared-core/mcp-integration.md index 7c9b43b5f..0730fdccb 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 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 +`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 @@ -173,11 +196,68 @@ 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 acceptance is recorded in [current.md](../../roadmap/current.md). +### Failed-start cleanup lifecycle + +**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 +`budget.remainingMs()` uses the invocation's existing remaining budget. The +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 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. + +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 +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 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/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index 6db10131c..b00a62307 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -273,16 +273,17 @@ 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 // 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 @@ -389,24 +390,49 @@ 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 | `secret`-typed values reach only credential/header fields, rejected at parse from tool text (transitive taint, 0029(c)); re-checked on the **top-level** effective-arg keys (step 3) — by contract the 1.O/1.V taint producer flattens a tainted *nested* path to a top-level deny before it reaches `secretArgKeys`; the actual secret never travels as an arg value (it is a host-resolved `credentialRef`) | host attaches a resolved `credentialRef` inside its trust boundary | -| FS scope tier | tool flagged `fsScoped` | `fs.*` jails to the tier + rejects path traversal | +| 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 | 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 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 [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. + +Diagnostic truncation follows complete recognized-span redaction. `toolInput` applies +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. + ## 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 @@ -430,7 +456,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) | @@ -498,3 +524,54 @@ 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 + +**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 +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. + +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. + +## 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/current.md b/docs/roadmap/current.md index 925cf052a..4e3cec4da 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-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) @@ -101,55 +101,51 @@ 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). -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. 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 @@ -524,7 +520,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 +1076,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 +1090,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; @@ -1114,15 +1110,53 @@ an ESLint selector rather than a list. ### Wave 3 — Clear the ground -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. +**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. 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. 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. 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. + +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 +[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** 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) → @@ -1131,29 +1165,34 @@ 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 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. **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) → 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 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.* -- **`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/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), `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**; - 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 @@ -1281,9 +1320,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 | `--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 | +| 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? | **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 | | 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 1b5fcc3c3..436ff9b0b 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-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 > review added the deliberately-unscheduled block at the end, Phase 2.6.5's `W1` and `W2` residual @@ -2167,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 c93fa45a8..91f78042a 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 @@ -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 @@ -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,18 +204,18 @@ 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)* +- ✅ **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)* +- ✅ **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)* -- **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)* +- ✅ **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 @@ -309,24 +309,24 @@ 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)* -- **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)* +- **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. +- ✅ **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. **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)* +- **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)* -- **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)* @@ -335,11 +335,11 @@ 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)* -**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 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 @@ -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. @@ -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). @@ -623,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… | @@ -637,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 @@ -1041,7 +1043,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 +2904,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 +3485,5659 @@ 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. + +### 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 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/ +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/#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. | +| 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; 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; 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. | +| #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 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 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, 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. | +| 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. | +| 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. 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/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. 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 +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. + +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. 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. + +#### 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. + +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. 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. + +#### 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 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. + +**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. + +#### 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. + +#### 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. + +#### 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 +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. + +#### 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; 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. +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. + +#### 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. + +#### 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. + +#### 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. + +#### 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. + +#### 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. + +### 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. + +### 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 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 +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. + +**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. + +### 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. + + +### 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. + + +### 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. + + +### 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. + + +### 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. + + +### 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. + + +### 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. + + +### 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. + + +### 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 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 +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. + + +### 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 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 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 | 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` | +| `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** 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. + +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. + + +### 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. + + +### 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. + +### 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-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. 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 +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. + + +### 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. + + +### 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. + +### 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. + + +### 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. + + +### 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. + + +### 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. + +## 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. + +### 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. + +### 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. + +### 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. + +## 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. + +### 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. + +## 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. + +### 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. + + +## 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. + +### 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. + +### 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. + + +## 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. + +### 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. + +### 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. + +## 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. + +## 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. + +## 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. + +## 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. + +## 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. + +## 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. + + +## 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. + + +## 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. + +### 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. + +### 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. + + +### 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. + +### 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. + + +### 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. + +### 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. + + +### 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. + + +### 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. + + +### 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. + +### 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. + +### 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`. + +### 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`. + + +### 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`. + +### 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. + +### 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. + +### 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. + + +### 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. + + +### 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. + +### 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. + +### 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. + +### 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. + +### 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. + +### 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. + +### 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. + +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. + +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. + +### 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. + +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. + +### 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. + +### 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. + +### 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. + +### 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. + + +### PR #95 second systematic-review corrections — 2026-10-18 + +**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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +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. + +### 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. + +### 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. + +### 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. + +### 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. 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 +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/roadmap/phases/phase-2.6-conversational-authoring.md b/docs/roadmap/phases/phase-2.6-conversational-authoring.md index 1578d2c75..9ec2254d9 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) | diff --git a/docs/standards/error-handling.md b/docs/standards/error-handling.md index 15105b400..abaf37f54 100644 --- a/docs/standards/error-handling.md +++ b/docs/standards/error-handling.md @@ -11,6 +11,25 @@ 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. + +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 stable discriminant (a `kind` / `code`) callers narrow on — not `error.message`, which @@ -28,6 +47,15 @@ and cost accounting that are product features. `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 diff --git a/docs/standards/security-review.md b/docs/standards/security-review.md index 63ec2481a..c24c769a8 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, @@ -515,6 +520,307 @@ 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). + +### 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. + +### 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. + +### 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. + +### 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. + +### 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. + +### 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. + +### 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. + +### 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. + +### 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. 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. + +### 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. + +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 +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 @@ -613,16 +919,22 @@ 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 - `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. +- **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 + [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 diff --git a/package.json b/package.json index 0c20e6f4b..efa79ef47 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-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,9 +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", + "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" + "check:secrets-mutations": "node tools/secrets-controls/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/packages/core/package.json b/packages/core/package.json index 9d19a8151..6fbcefecd 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/agent-parser.test.ts b/packages/core/src/agent-parser.test.ts index 82a62126e..65224dd93 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 180f21c37..7a435da8d 100644 --- a/packages/core/src/agent-parser.ts +++ b/packages/core/src/agent-parser.ts @@ -7,16 +7,17 @@ * 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'; 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'; +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`). */ @@ -33,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). */ @@ -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/dag-priority-equivalence.test.ts b/packages/core/src/dag-priority-equivalence.test.ts new file mode 100644 index 000000000..f801b3491 --- /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 f13bc17b9..10160d1b6 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-allowance-settlement.test.ts b/packages/core/src/engine/agent-runner-allowance-settlement.test.ts index c2f633fdd..85f9094ed 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-literal-system-append.test.ts b/packages/core/src/engine/agent-runner-literal-system-append.test.ts new file mode 100644 index 000000000..00f2793c4 --- /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 000000000..c5bb862cc --- /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 3fa90905e..7c2224a34 100644 --- a/packages/core/src/engine/agent-runner.test.ts +++ b/packages/core/src/engine/agent-runner.test.ts @@ -14,10 +14,22 @@ 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'; -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, @@ -31,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'; @@ -251,8 +263,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: { @@ -307,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 b29b2938c..f547e44a8 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'; @@ -485,10 +485,9 @@ 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 (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 @@ -1577,20 +1576,26 @@ 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 ?? {}, + 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 e67976808..82ed5de1f 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/agent-session.ts b/packages/core/src/engine/agent-session.ts index 381247bf4..b0dd867e4 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 dcd738da9..e2977e156 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 3dbbb61f9..34c5b7f79 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/context-definition-ownership.test.ts b/packages/core/src/engine/context-definition-ownership.test.ts new file mode 100644 index 000000000..59087114e --- /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/dispatch-allowance.ts b/packages/core/src/engine/dispatch-allowance.ts index eb7090ed2..e04f1e58a 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/empty-template-provenance.test.ts b/packages/core/src/engine/empty-template-provenance.test.ts new file mode 100644 index 000000000..5c72f0dbc --- /dev/null +++ b/packages/core/src/engine/empty-template-provenance.test.ts @@ -0,0 +1,351 @@ +/** 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.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(); + 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.test.ts b/packages/core/src/engine/engine.test.ts index a9dcf8765..999513e8a 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 83dddd2b6..ab0671142 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( - this.#workflow, + const resolved = await resolveContext( + this.#provenance.contextSource, 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, @@ -2921,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); @@ -3036,7 +3041,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 +6204,8 @@ export class WorkflowEngine { | { readonly gateId: string; readonly preparation: BudgetDispatchPreparation; - readonly context: Readonly>; + readonly context: WorkflowContextProjection; + readonly provenance: WorkflowProvenance; } | undefined > { @@ -6244,12 +6250,14 @@ export class WorkflowEngine { { runId: input.runId, gateId: input.gateId }, ); try { - const context = await resolveContext( - input.workflow, + const provenance = createWorkflowProvenance(input.workflow); + const resolved = await resolveContext( + provenance.contextSource, 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 +6266,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 +6280,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/error-base-compatibility.test.ts b/packages/core/src/engine/error-base-compatibility.test.ts new file mode 100644 index 000000000..8829acbd9 --- /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.test.ts b/packages/core/src/engine/errors.test.ts index 3272a2f1a..4bc8cbf9f 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 d05ad1d51..0598b9156 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`. */ @@ -53,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 { @@ -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/event-bus-validation-compatibility.test.ts b/packages/core/src/engine/event-bus-validation-compatibility.test.ts new file mode 100644 index 000000000..df44dc3d5 --- /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/expression-conversion-order.test.ts b/packages/core/src/engine/expression-conversion-order.test.ts new file mode 100644 index 000000000..1fb45f2cd --- /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/engine/human-gate-tail-race.test.ts b/packages/core/src/engine/human-gate-tail-race.test.ts new file mode 100644 index 000000000..c7e6b015c --- /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/invariant-error.ts b/packages/core/src/engine/invariant-error.ts index e825be4af..33dfb18ca 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 0189d611d..486bee061 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 b2c946493..22f10d57b 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/engine/node-executor.ts b/packages/core/src/engine/node-executor.ts index 1df6775dc..d8121c6e0 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-tail-cancellation.test.ts b/packages/core/src/engine/node-handlers/human-gate-tail-cancellation.test.ts new file mode 100644 index 000000000..15fa7025a --- /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.test.ts b/packages/core/src/engine/node-handlers/human-gate.test.ts index 200c52b81..ccbd718c5 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 be7a93d42..599094275 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,26 +34,23 @@ 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 ?? {}; let message: string; let assignee: string | undefined; try { - message = - node.message_template === undefined - ? '' - : await resolveTemplate(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 - : 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. @@ -73,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/engine/node-handlers/node-handlers.test.ts b/packages/core/src/engine/node-handlers/node-handlers.test.ts index efb131bd4..de129aa90 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 01e157b69..c053a80ee 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/resume-identity.test.ts b/packages/core/src/engine/resume-identity.test.ts index daceefba4..986218a1f 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 ed281f8f4..0385366a9 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/engine/secret-provenance-producers.test.ts b/packages/core/src/engine/secret-provenance-producers.test.ts new file mode 100644 index 000000000..e24701a61 --- /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 26b37b40f..0bd376818 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -5,15 +5,17 @@ * 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 * 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; @@ -201,6 +203,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 @@ -218,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; @@ -237,6 +240,16 @@ export class InterpolationError extends Error { } } +/** Missing/malformed explicit provenance at a public execution boundary (ADR-0113). */ +export class WorkflowContextError extends RelaviumError { + 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 +265,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,11 +274,12 @@ 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`. */ -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. */ @@ -272,9 +287,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 +304,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/export/serializer.test.ts b/packages/core/src/export/serializer.test.ts index b380c4ddd..4c0d5e5d0 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 dd17d4124..c1b639f7e 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. */ 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 000000000..4f0a18a74 --- /dev/null +++ b/packages/core/src/expression/error-parity.test.ts @@ -0,0 +1,537 @@ +/** 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: '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")', + 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 ( + 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' && + 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: '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: + '(() => {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: + '{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' }, + ); + }); +}); + +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(); + }); +}); + +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 new file mode 100644 index 000000000..87fec1156 --- /dev/null +++ b/packages/core/src/expression/program.ts @@ -0,0 +1,147 @@ +/** 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, setPrototypeOf=Object.setPrototypeOf;', + ' 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");}', + ' }', + ' 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=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.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'); +} + +/** Same iterative depth admission as the original sandbox; scope data never recurses on the host. */ +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 }); + } +} diff --git a/packages/core/src/expression/sandbox.perf.test.ts b/packages/core/src/expression/sandbox.perf.test.ts index eefbcdc7a..f1cbb3568 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 e9a936539..e50a9b8c7 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 4d70e9b38..1df526cd6 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,29 @@ 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, + decodeThrownPayload, +} 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 +138,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 +163,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 +193,7 @@ function runProgram( module: QuickJSWASMModule, program: string, limits: SandboxLimits, + kind: ExpressionKind, ): EvalOutcome { const runtime = module.newRuntime({ memoryLimitBytes: limits.memoryBytes, @@ -299,19 +221,63 @@ 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 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; + } 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(decodeThrownPayload(transport.payloadJson), 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 +288,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 +315,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 +356,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 +432,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 644e51b8d..bd831ec54 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 000000000..3ee3e0e04 --- /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 000000000..44f7df93d --- /dev/null +++ b/packages/core/src/expression/transport.test.ts @@ -0,0 +1,266 @@ +/** 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, + decodeThrownPayload, + 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<[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', + payloadJson: '{"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 000000000..2f8761a5c --- /dev/null +++ b/packages/core/src/expression/transport.ts @@ -0,0 +1,121 @@ +/** 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'), + payloadJson: 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; +} + +/** 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 166568d98..4a21698bd 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, ContextResolutionSource } 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. @@ -190,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. diff --git a/packages/core/src/interpolation/analyze.ts b/packages/core/src/interpolation/analyze.ts index 03a172f31..479f262f5 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 000000000..32a86c685 --- /dev/null +++ b/packages/core/src/interpolation/provenance.test.ts @@ -0,0 +1,323 @@ +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('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); + 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 000000000..811016205 --- /dev/null +++ b/packages/core/src/interpolation/provenance.ts @@ -0,0 +1,144 @@ +/** 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'; +import type { ContextResolutionSource } from './resolve.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 { + /** 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 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) }), + }); + const names = ownSecretInputNames(taint.inputs.keys()); + return Object.freeze({ + ...names, + contextSource, + 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 (!declaredContextKeys.has(key)) throw new WorkflowContextError(); + 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 000000000..457eeef11 --- /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/references-quoted-escapes.test.ts b/packages/core/src/interpolation/references-quoted-escapes.test.ts new file mode 100644 index 000000000..e4e6f6027 --- /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 b1f1fe088..cbbee89d1 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 14d231084..692790fd0 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/interpolation/resolve.ts b/packages/core/src/interpolation/resolve.ts index 93a47a474..d00c092e7 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 @@ -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'; @@ -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,52 @@ 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, + ); +} + +/** 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- @@ -64,16 +115,13 @@ export async function resolveTemplate( * @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); diff --git a/packages/core/src/limits.test.ts b/packages/core/src/limits.test.ts index b2912f1b6..692cfb12c 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 aa10a72fd..1701ef3cc 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 e55efed8e..cf508fbb9 100644 --- a/packages/core/src/parser.ts +++ b/packages/core/src/parser.ts @@ -30,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. */ @@ -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 000000000..87b95bafb --- /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 000000000..c54e492ab --- /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'; + } +} 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 000000000..a79bf4452 --- /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.test.ts b/packages/core/src/tools/bounding.test.ts index 18cd01915..05c28d8e2 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); @@ -31,6 +56,33 @@ 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: '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`, + 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()); @@ -246,6 +298,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 +393,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-----', @@ -335,6 +505,27 @@ 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. + // 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]'], + ['-----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. @@ -343,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 @@ -406,6 +597,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 @@ -448,6 +682,16 @@ 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( + `${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. @@ -540,6 +784,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. @@ -555,14 +821,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. @@ -578,3 +892,265 @@ 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.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'], + ['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) { + 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 raw canonical projection 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('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 ']) { + 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( + `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 1bd188dab..aa82048d0 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -88,102 +88,401 @@ function redactInlineMediaForText(value: unknown, seen: WeakSet): unknow return '[cyclic]'; } seen.add(value); - if (Array.isArray(value)) { - return value.map((item) => 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)) { + // 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 { + // Only an ancestor is cyclic; a completed sibling can legitimately share this object. + seen.delete(value); } - if (isBinaryBuffer(value)) { - return '[binary buffer omitted]'; // a raw Uint8Array/ArrayBuffer — never JSON the decimal byte values +} + +// 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_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 uses the same fragments. +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 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 + * 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; } - if (!isPlainObject(value)) { - return value; // Date/RegExp/Map/Set/class instance — leave for JSON.stringify's native handling + 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. + * The views can also be the same raw text; only an actual joined view skips original LF/TAB. + */ +function visitJoinedStandaloneCredentials( + original: string, + 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; + 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; + const match = JOINED_STANDALONE_CREDENTIAL.exec(joined); + if (match === null) continue; + matchedUntil = JOINED_STANDALONE_CREDENTIAL.lastIndex; + mask(start, matchedUntil, '[redacted]'); } - 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 }; +} + +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 { + if (!joined.includes(':')) return; + const header = /(?:authorization|(?:x-)?api-key|x-goog-api-key)["']?\s*:/iy; + 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 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; + matchedUntil = header.lastIndex; + mask(start, matchedUntil, match[0]); } - const out: Record = {}; - for (const [key, item] of Object.entries(value)) { - out[key] = redactInlineMediaForText(item, seen); +} + +/** + * 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[] = []; + const separators = /[\n\t]+/g; + let next = separators.exec(text); + let removed = 0; + let copied = 0; + 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 target + removed; } - return out; + visit(joined, (start, end, replacement) => { + 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; + 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) => { + visitJoinedStandaloneCredentials(urls, joined, mask); + }, + /\w/, + ); + const schemes = keys.replace(SCHEME_CREDENTIAL, '$1 [redacted]'); + return redactJoinedCredentialSpans( + schemes, + (joined, mask) => { + visitJoinedHeaderLabels(schemes, joined, mask); + }, + /[A-Za-z0-9_-]/, + ); } /** - * 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 { - return ( - 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]@') - // …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]') - // 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_]`). - .replace(/\b(bearer|basic|token)\s+[\w.~+/-]{8,}={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 - // 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, - '[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 - /\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, - '[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). 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); +} + +/** + * 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. 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 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; + for (let match = scheme.exec(text); match !== null; match = scheme.exec(text)) { + const start = scheme.lastIndex; + delimiter.lastIndex = start; + const end = delimiter.exec(text)?.index ?? text.length; + 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; + 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(''); } /** @@ -192,7 +491,7 @@ export function redactSecretShapedText(text: string): string { * 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()); @@ -281,29 +580,46 @@ 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) { + out.push(redactCredentialArgument(redactNext, item)); + 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}=${redactCredentialArgument(flag, value)}`); + continue; + } + if (isSecretishFlag(item)) redactNext = item; + } out.push(redactSecretShapedWalk(item, seen)); } 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`. (`--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. @@ -319,7 +635,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); } @@ -327,17 +643,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]'; } @@ -346,19 +671,28 @@ 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 + // 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 + // 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. + Object.defineProperty(out, redactSecretShapedText(key), { + value: redactUnderKey(key, item, seen), + enumerable: true, + configurable: true, + writable: true, + }); + } + 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 @@ -385,12 +719,9 @@ 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 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(redactSplitCredentials(text).replace(/\s+/g, ' ').trim()); return oneLine.length <= SUMMARY_MAX ? oneLine : `${oneLine.slice(0, SUMMARY_MAX)}…`; } 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 000000000..b83a8edd7 --- /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 000000000..f995c4b71 --- /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 000000000..597b79058 --- /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 000000000..6aed1b208 --- /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 15ef17282..67e4f5506 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); @@ -392,7 +418,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 24a22876e..611b5ed40 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,21 @@ const gitCommitTool = defineBuiltin({ llmVisibleParams: { type: 'object', properties: { - message: { type: 'string' }, - files: { type: 'array', items: { type: 'string' } }, + message: { + type: 'string', + minLength: 1, + 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', + minLength: 1, + description: 'A non-empty file pathspec that does not begin with a hyphen.', + }, + }, }, required: ['message'], additionalProperties: false, @@ -374,28 +504,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 +578,18 @@ const webSearchTool = defineBuiltin({ .strict(), llmVisibleParams: { type: 'object', - properties: { query: { type: 'string' }, maxResults: { type: 'integer', minimum: 1 } }, + properties: { + query: { + type: 'string', + minLength: 1, + 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 +609,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 +660,14 @@ 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', + 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'], additionalProperties: false, }, @@ -530,7 +701,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 +800,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 +857,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/dispatch-error.test.ts b/packages/core/src/tools/dispatch-error.test.ts new file mode 100644 index 000000000..b5cc5e7a7 --- /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 000000000..bb5a9f399 --- /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 eac36b200..d8058942f 100644 --- a/packages/core/src/tools/errors.ts +++ b/packages/core/src/tools/errors.ts @@ -8,58 +8,16 @@ * [tool-registry.md](../../../../docs/reference/shared-core/tool-registry.md#error-taxonomy) and ADR-0037. */ +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 Error { - /** The tool-error discriminant. */ - abstract readonly code: ToolErrorCode; - /** The closed run `ErrorCode` this maps to (sse-event-schema.md). */ - abstract readonly runErrorCode: ErrorCode; - /** A pure function of the fault class — owned by error-handling.md. Only `execution_failed` retries. */ - abstract readonly retryable: boolean; - /** The tool id involved, when one is known (a name, never a secret). */ - readonly toolId?: ToolId; - /** - * 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 { @@ -69,7 +27,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 000000000..076a92b5b --- /dev/null +++ b/packages/core/src/tools/registry-alias-projection.test.ts @@ -0,0 +1,364 @@ +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('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); + 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 }) => { + 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 }) }; + 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 000000000..56a0f584b --- /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 dcecea164..4671b5742 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 fe8f0c97c..51454c0b0 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), }; } @@ -342,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) @@ -614,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 }, }, }; @@ -688,7 +692,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 +732,7 @@ function zodIssuePaths(cause: unknown): readonly string[] { } } } - return [...paths].sort((a, b) => a.localeCompare(b)); + return [...paths].sort(byCodeUnit); } /* ------------------------------------------------------------------------------------------------ * @@ -740,7 +744,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.`, ); } @@ -1055,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 ?? []) { @@ -1063,7 +1068,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); } @@ -1076,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 @@ -1085,32 +1091,31 @@ 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. + // 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; } /** - * 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 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 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 @@ -1119,12 +1124,23 @@ 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) => 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)) { + Object.defineProperty(out, key, { + value: boundArgsForDisplay(item, seen), + enumerable: true, + configurable: true, + writable: true, + }); + } + return out; + } finally { + seen.delete(value); + } } /** Narrow the walk's `unknown` result back to a record, without an unsafe cast. */ @@ -1144,19 +1160,28 @@ 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; + Object.defineProperty(out, key, { + value: dropSecretKeysDeep(item, secretArgKeys, seen), + enumerable: true, + configurable: true, + writable: true, + }); + } + 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 abcac4fe9..d52e50f82 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; @@ -391,7 +394,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; diff --git a/packages/db/src/client-acquisition.test.ts b/packages/db/src/client-acquisition.test.ts new file mode 100644 index 000000000..d01f9c5b3 --- /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-native-origin.test.ts b/packages/db/src/client-native-origin.test.ts new file mode 100644 index 000000000..b1c453dce --- /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 caf7cf84a..975b84ef3 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,10 +1,19 @@ +import { RelaviumError } from '@relavium/shared'; + import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; 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'; @@ -73,7 +82,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; @@ -102,7 +111,10 @@ export class DbOpenError extends Error { * - `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. @@ -113,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:') { - 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); - sqlite = opened; + const opened = withOwnedSqlite(() => new Database(path)); + 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 @@ -141,27 +155,38 @@ 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. + 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. - throw new DbOpenError('open_failed', 'could not open the SQLite database', path, { - cause: err, + const failure = new DbOpenError('open_failed', 'could not open the SQLite database', path, { + cause: + cleanupFailure === undefined + ? err + : new AggregateError([err, cleanupFailure.cause], 'SQLite startup cleanup failed.', { + cause: 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; } } @@ -181,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; } /** @@ -194,9 +221,17 @@ export interface RunMigrationsOptions { */ export function runMigrations(db: Db, options: RunMigrationsOptions = {}): SessionEffectCheckpoint { requireSessionEffectTransactionOwnership(db); - return withMigrationLock(options.dbPath, () => { - migrate(db, { migrationsFolder: MIGRATIONS_DIR }); - 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/effect-journal-store.test.ts b/packages/db/src/effect-journal-store.test.ts index 039ef29ea..55e10d93c 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,20 @@ describe('the effect journal store', () => { const port = (): ReturnType => createEffectJournalPort(store, RUN, ATTEMPT); + it.each(['', 'v2:', 'v3:', 'v4:'])( + '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 // 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 accbb84f1..947d3dac9 100644 --- a/packages/db/src/effect-journal-store.ts +++ b/packages/db/src/effect-journal-store.ts @@ -641,5 +641,8 @@ 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'); + // Unversioned digests may describe a lossy display preview; v2/v3 may include a partially redacted + // 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')}`; } 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 000000000..13116517a --- /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/fixtures/media-temporary-register.ts b/packages/db/src/fixtures/media-temporary-register.ts index 99c87350d..64cdec057 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 1b60cd025..7c850ef2a 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/index.ts b/packages/db/src/index.ts index 90977a693..6839664e8 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 eb5e2f8ab..f9dd7bac5 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 4794d3096..7fb5c534c 100644 --- a/packages/db/src/media-store-paths.ts +++ b/packages/db/src/media-store-paths.ts @@ -1,8 +1,11 @@ +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 Error { +export class MediaStoreFilesystemError extends RelaviumError { constructor(readonly reason: 'missing' | 'unsafe_path' | 'io') { super( reason === 'missing' @@ -49,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, @@ -95,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 ( @@ -113,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'); @@ -167,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; @@ -212,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.test.ts b/packages/db/src/media-store-temporaries.test.ts index a57396cc4..1d01da05a 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', diff --git a/packages/db/src/media-store-temporaries.ts b/packages/db/src/media-store-temporaries.ts index fdb71f0ca..2af51f626 100644 --- a/packages/db/src/media-store-temporaries.ts +++ b/packages/db/src/media-store-temporaries.ts @@ -1,9 +1,12 @@ +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'; 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' @@ -22,7 +25,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', ) { @@ -252,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) { @@ -273,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); }); @@ -289,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); } @@ -324,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'); @@ -345,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; @@ -356,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() || @@ -378,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 || @@ -393,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 ( @@ -408,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)); } } @@ -470,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'); @@ -486,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; })(); @@ -530,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, @@ -544,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/media-store.ts b/packages/db/src/media-store.ts index 5ff422192..0a6d7655a 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(); } } diff --git a/packages/db/src/media-write.ts b/packages/db/src/media-write.ts index 36ece0ea7..bda586028 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/migrate-lock.test.ts b/packages/db/src/migrate-lock.test.ts index 1c179c143..aeb396cef 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 fd29da07a..e2bdc2b96 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 @@ -64,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:') { @@ -77,8 +80,15 @@ export function withMigrationLock( // The lock file cannot be opened at all (a read-only directory). Reconciling beats refusing to start. return runReconciling(run); } - try { - lock.pragma(`busy_timeout = ${deps.timeoutMs ?? LOCK_TIMEOUT_MS}`); + 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); + else configureTimeout(); try { // The acquisition. `BEGIN EXCLUSIVE` is the only statement here that can block or fail on contention. lock.exec('BEGIN EXCLUSIVE'); @@ -90,14 +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 { - 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/packages/db/src/migration-storage-origin.test.ts b/packages/db/src/migration-storage-origin.test.ts new file mode 100644 index 000000000..ec2a02796 --- /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 000000000..665c761bc --- /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/model-catalog-store.ts b/packages/db/src/model-catalog-store.ts index 00ec93e6d..e8e7b40cf 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 33d193480..27810845b 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 5f90f3ab1..d4c2227c9 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; @@ -1434,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). */ diff --git a/packages/db/src/safe-egress.ts b/packages/db/src/safe-egress.ts index 7603aace2..2366de90c 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 3931b8487..d9bf6679d 100644 --- a/packages/db/src/session-effect-privacy.ts +++ b/packages/db/src/session-effect-privacy.ts @@ -1,14 +1,21 @@ +import { RelaviumError } from '@relavium/shared'; + 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. */ 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' @@ -38,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/session-effect-turns.ts b/packages/db/src/session-effect-turns.ts index d8c636bcc..f4ed14b8b 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.test.ts b/packages/db/src/session-store.test.ts index 7a39dd41e..4816ccdd4 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'; diff --git a/packages/db/src/session-store.ts b/packages/db/src/session-store.ts index 17d237b2a..4bdf14086 100644 --- a/packages/db/src/session-store.ts +++ b/packages/db/src/session-store.ts @@ -1,4 +1,5 @@ import { + RelaviumError, AgentSessionSchema, canonicalJson, SessionMessageSchema, @@ -20,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, @@ -71,7 +73,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 @@ -591,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/session-usage-identifier-order.test.ts b/packages/db/src/session-usage-identifier-order.test.ts new file mode 100644 index 000000000..dd730afc6 --- /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 8550b0607..9239e8320 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 })), }; } diff --git a/packages/db/src/session-usage-write.ts b/packages/db/src/session-usage-write.ts index 500918287..b294d149f 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' diff --git a/packages/db/src/storage-failure-facts.ts b/packages/db/src/storage-failure-facts.ts new file mode 100644 index 000000000..6a2007bee --- /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 000000000..fc51b3baa --- /dev/null +++ b/packages/db/src/storage-origin.test.ts @@ -0,0 +1,286 @@ +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 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, +} 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; +} + +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'); + 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); + }); +}); diff --git a/packages/llm/package.json b/packages/llm/package.json index 744c9d11a..8ce301629 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/adapters/anthropic.ts b/packages/llm/src/adapters/anthropic.ts index 10f51995e..aec5b8b88 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 000000000..3f6171e06 --- /dev/null +++ b/packages/llm/src/adapters/bounded-list-models-lifetime.test.ts @@ -0,0 +1,274 @@ +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.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'); + 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 58a04832d..0469b8ea4 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 000000000..f21a41cca --- /dev/null +++ b/packages/llm/src/adapters/list-models-lifetime.test.ts @@ -0,0 +1,239 @@ +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 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 }) => { + const parent = owner(); + const fetch = vi.fn(() => Promise.resolve(jsonResponse({}))); + const stop = new AbortController(); + stop.abort(); + 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/openai.ts b/packages/llm/src/adapters/openai.ts index 0535cb9ba..678d31a5a 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 96fba49c2..07a6db63c 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 @@ -34,8 +36,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 +48,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; @@ -240,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. */ @@ -248,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, @@ -259,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; } @@ -277,59 +283,95 @@ 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(); - const onAbort = (): void => controller.abort(); - if (signal !== undefined) { - if (signal.aborted) { - controller.abort(); - } else { - signal.addEventListener('abort', onAbort); - } - } + // 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); + let cancelled = false; + const onAbort = (): void => { + cancelled = true; + controller.abort(); + }; 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 }); - throw new LlmProviderError( - makeLlmError({ + 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) onAbort(); + } + 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, - kind: base.kind, - message: redactKey(base.message, key), - ...(base.code !== undefined ? { code: redactKey(base.code, key) } : {}), - ...(base.status !== undefined ? { status: base.status } : {}), - }), - ); + err, + timedOut, + cancelled, + 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); + if (timer !== undefined) clearTimeout(timer); + try { + if (listenerAttempted) signal?.removeEventListener('abort', onAbort); + } catch { + cleanupFailed = true; } - if (signal !== undefined) { - signal.removeEventListener('abort', onAbort); + 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: 'unknown', + message: 'model list cleanup could not be completed', + }), + ); + return result; } /** 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 000000000..3a8ec8ba2 --- /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 4c38c8c41..0f24ae23e 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.test.ts b/packages/llm/src/llm-error.test.ts index d92399bbb..b5170ab42 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/llm-error.ts b/packages/llm/src/llm-error.ts index e4fd5cef0..79f13f8d9 100644 --- a/packages/llm/src/llm-error.ts +++ b/packages/llm/src/llm-error.ts @@ -1,11 +1,15 @@ 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. */ @@ -76,7 +80,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) { @@ -102,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/output-cap.ts b/packages/llm/src/output-cap.ts index 962553fb2..706fdcaaa 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 d25e7852f..69478e948 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; } diff --git a/packages/llm/src/scrub-secrets.test.ts b/packages/llm/src/scrub-secrets.test.ts new file mode 100644 index 000000000..31aac2bbe --- /dev/null +++ b/packages/llm/src/scrub-secrets.test.ts @@ -0,0 +1,351 @@ +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); + }); +}); + +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`, + }, + ]), + // 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`, + 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); + }, + ); +}); + +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.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'], + ['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(); + 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); + }); + + 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 new file mode 100644 index 000000000..9d9921a3c --- /dev/null +++ b/packages/llm/src/scrub-secrets.ts @@ -0,0 +1,198 @@ +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 finite API-key/Bearer/Basic headers. Each non-query rule probes the original diagnostic + * 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 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. + * 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 | ((match: string) => string); + readonly joinedPattern?: RegExp; + 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. + { + 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: URL_USERINFO_PATTERN, + joinedPattern: URL_USERINFO_PATTERN, + 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. + { + 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. */ +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 { + const replacement = rule.replacement; + return typeof replacement === 'string' + ? text.replace(rule.pattern, replacement) + : text.replace(rule.pattern, (match) => replacement(match)); +} + +/** + * 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 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; + // 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; + while (originalOffset < text.length) { + const char = text[originalOffset]; + if (char === '\n' || char === '\t') { + originalOffset += 1; + continue; + } + // 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) { + 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 && + // A malformed URL candidate may actually be a public contact query. + (typeof rule.replacement !== 'function' || rule.replacement(match[0]) !== match[0]) + ) + return true; + continue; + } + } + originalOffset += 1; + joinedOffset += 1; + } + 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 = applyRule(text, rule); + // 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. + for (const remaining of normalizedMasked.split('[REDACTED]')) { + const residual = remaining.replace(/\s+/g, ''); + if (applyRule(residual, rule) !== residual) return '[REDACTED]'; + } + } + 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. */ +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. +} diff --git a/packages/llm/src/types.ts b/packages/llm/src/types.ts index 535544b20..f139b0415 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; } 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 000000000..cf52fbcec --- /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 d07d33ec0..38f727b45 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'; diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 0bb0a63de..b39da5edf 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'; @@ -76,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 000000000..bd9f3c0c7 --- /dev/null +++ b/packages/mcp/src/manager-startup-cleanup.test.ts @@ -0,0 +1,416 @@ +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)); + +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('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) => { + 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'], + 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 fd4e7d5b6..2843a0481 100644 --- a/packages/mcp/src/manager.ts +++ b/packages/mcp/src/manager.ts @@ -65,9 +65,13 @@ 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 - * a semantic receipt or proof that a failed transport acknowledged native resource closure. + * 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. 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; /** @@ -80,26 +84,30 @@ export interface McpClient { readonly childPids: readonly number[]; } +/** 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; + /** Applies to every actual close, including successful startup and deferred manager entry. */ + 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); - }); - }; + 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[] = []; @@ -121,6 +129,85 @@ 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 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 ? {} : { budget: lifecycle.budget }), + onCloseError: reportCloseFailure, + }, + (cause) => + rejectObserver( + new Error( + failedStartup + ? 'MCP startup cleanup observation failed.' + : 'MCP 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 Promise.race([closing, observerFailure]).then(() => { + if (state?.failure !== undefined) throw state.failure.cause; + }); + }; const settled = await Promise.allSettled( servers.map(async (server) => { const work = startup.fork(); @@ -139,9 +226,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 +297,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); + } } }), ); 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 000000000..2940b6537 --- /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 0078ca8b1..159f69c46 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 d5ef36048..52b9a8d59 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; } diff --git a/packages/shared/package.json b/packages/shared/package.json index c1bb8efe7..de29219e2 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -9,6 +9,21 @@ "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" + }, + "./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/agent.test.ts b/packages/shared/src/agent.test.ts index 95232517e..47dcf30a9 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 07aa99f75..f70a0e408 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(), @@ -324,9 +328,24 @@ 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'], }); } }); +/** 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/canonical.ts b/packages/shared/src/canonical.ts index de169a848..4f37c78bf 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/config.ts b/packages/shared/src/config.ts index 4cd96cf9e..33bcaaa0f 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/event-envelope-compatibility.test.ts b/packages/shared/src/event-envelope-compatibility.test.ts new file mode 100644 index 000000000..d76593419 --- /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 e699a0b20..371fff011 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/fs-root-limits.test.ts b/packages/shared/src/fs-root-limits.test.ts new file mode 100644 index 000000000..885c14b0b --- /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 000000000..01d1cc0ed --- /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 }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 952fc3eb5..7fb682758 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 d1496f0b5..88c60da85 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/node.test.ts b/packages/shared/src/node.test.ts index ef890b63c..51b49f342 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 dd8f20b67..827feb8be 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({ @@ -162,7 +179,7 @@ export const ConditionBranchSchema = z }) .strict(); -export const ConditionNodeSchema = z +const ConditionNodeFields = z .object({ id: kebabIdSchema, type: z.literal('condition'), @@ -176,6 +193,23 @@ export const ConditionNodeSchema = z }) .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, @@ -192,7 +226,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(); @@ -217,15 +256,24 @@ export const OutputNodeSchema = z }) .strict(); -/** The authored node discriminated union. */ -export const NodeSchema = z.discriminatedUnion('type', [ +/** + * 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, - HumanGateNodeSchema, - ConditionNodeSchema, + 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/relavium-error.test.ts b/packages/shared/src/relavium-error.test.ts new file mode 100644 index 000000000..ebc709ce9 --- /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 000000000..b543040b9 --- /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 8e5092be9..29cc32ebc 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 -------------------------------------------------------------------- @@ -201,8 +206,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'), @@ -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 7ea4318fd..55792efab 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; @@ -14,7 +16,8 @@ export class CorruptRunEventError extends Error { * 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, @@ -22,7 +25,7 @@ export class CorruptRunEventError extends Error { 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 }, ); } diff --git a/packages/shared/src/run-suspension.ts b/packages/shared/src/run-suspension.ts index 941914405..a2d750439 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.test.ts b/packages/shared/src/run.test.ts index cf0ec6f92..aa56f1d9e 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 5a4962502..6f4ef9796 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'; @@ -70,6 +72,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 && @@ -162,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; @@ -204,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; @@ -483,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; @@ -508,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/session.test.ts b/packages/shared/src/session.test.ts index f902d44e6..2897b5b8d 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 2687a757e..59fb7e14b 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`. */ 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 000000000..7a34b02f1 --- /dev/null +++ b/packages/shared/src/terminal-safe-leaf.test.ts @@ -0,0 +1,42 @@ +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', () => { + expect(rootStripTerminalControls).toBe(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', + ); + } + }); +}); diff --git a/packages/shared/src/usage-evidence.ts b/packages/shared/src/usage-evidence.ts index 0e3939841..b3f73a5be 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'); diff --git a/packages/shared/src/workflow.test.ts b/packages/shared/src/workflow.test.ts index ac8582c82..d5d736865 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'; /** @@ -271,6 +272,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); @@ -840,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 ec1682f83..1e7f38921 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,103 +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(); - // 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', - ]); - } - }); +/** 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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69c81e2f0..cd5ce6a41 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 diff --git a/tools/budget-replay-compat/README.md b/tools/budget-replay-compat/README.md index 446bd5b68..364ab4c3c 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 c118ee797..b211d00d8 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 d446e88b9..c7cb466d2 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 214aa3bd9..37ef2df93 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 92bb774ef..d9300ee98 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; diff --git a/tools/bundle-closure/check.mjs b/tools/bundle-closure/check.mjs index 65bae7e40..2aa9bb819 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 000000000..95e1ccf60 --- /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 000000000..404bb0a4b --- /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);', + ); + assert.doesNotThrow(() => 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/causal-controls/outcome.test.mjs b/tools/causal-controls/outcome.test.mjs index 22ed063d9..348fcd986 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 1a9cb0584..d1f194009 100644 --- a/tools/causal-controls/provenance.test.mjs +++ b/tools/causal-controls/provenance.test.mjs @@ -265,3 +265,163 @@ 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, 57); + assert.equal( + secretsControls.reduce((count, control) => count + 1 + (control.witnesses ?? []).length, 0), + 336, + ); + 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', 18], + ]) { + 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'], + 180, + ], + ...['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', + ], + 278, + ], + ), + ...[ + 'core-modern-key-alphabet', + 'core-joined-credential-spans', + 'core-joined-original-boundary', + 'core-split-scheme-label', + 'core-split-header-label', + 'core-raw-password-userinfo', + 'core-joined-original-boundary-recovery', + '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', + 'diagnostic-header-original-boundary-recovery', + ].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'], + 180, + ]), + ]) { + 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', + ]) { + 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')); + } + 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, + '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/causal-controls/run.test.mjs b/tools/causal-controls/run.test.mjs index ad5499174..dd39a8d78 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/ci/require-packed-cli.test.mjs b/tools/ci/require-packed-cli.test.mjs new file mode 100644 index 000000000..942721544 --- /dev/null +++ b/tools/ci/require-packed-cli.test.mjs @@ -0,0 +1,402 @@ +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 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', + 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), + packId === 'pack-cli' ? [outcomesCommand, aggregateCommand] : [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))); + 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'], +]) { + 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')); + }); +} + +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/ci/require-w8-acceptance.test.mjs b/tools/ci/require-w8-acceptance.test.mjs index 364fe5b72..e2bbd452e 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/ci/workspace-scheduling.test.mjs b/tools/ci/workspace-scheduling.test.mjs index 737b5990f..071ab6322 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']], @@ -77,7 +118,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, [ @@ -144,14 +185,19 @@ 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'], 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)', + ); assert.deepEqual(floor.env, { TURBO_TOKEN: '', TURBO_TEAM: '' }); const floorCommand = floor.steps.find( (step) => step.name === 'Typecheck, test, build on the exact floor', ); 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); diff --git a/tools/cli-bootstrap/README.md b/tools/cli-bootstrap/README.md new file mode 100644 index 000000000..6a04a8d1e --- /dev/null +++ b/tools/cli-bootstrap/README.md @@ -0,0 +1,56 @@ +# 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 ``. + +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 +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, 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. + +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. 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. +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 +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 new file mode 100644 index 000000000..c04a23b86 --- /dev/null +++ b/tools/cli-bootstrap/check.mjs @@ -0,0 +1,196 @@ +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { resolve, join } from 'node:path'; +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, controlName, BOOTSTRAP_CONTROL_COUNT } from './inventory.mjs'; +import { assertWitness, importFailureCanary } from './policy.mjs'; + +const repository = fileURLToPath(new URL('../../', import.meta.url)); +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'), + ); + 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: controlName(name, json, rootGrant), + 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: controlName(name, json), 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.includes(fixture.workspace)); + records.push({ + name: 'real-execution-fs-before-service', + exitCode: admission.code, + witness: firstDenied.method, + }); + + // 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']], + ]) { + 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[\t ]+at |file:\/\/|Error: synthetic/, name); + assert.equal( + result.events.filter((event) => event.denied && !event.runtimeFailure).length, + 0, + name, + ); + records.push({ + name: controlName(name, json), + json, + exitCode: result.code, + refusedUrl: failRuntimeUrl, + }); + } + } + }); + assert.deepEqual( + artifactInventory(), + artifact, + '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, + platform: process.platform, + entrySha256: createHash('sha256').update(readFileSync(entry)).digest('hex'), + artifact, + 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-bootstrap/child.mjs b/tools/cli-bootstrap/child.mjs new file mode 100644 index 000000000..66cb27030 --- /dev/null +++ b/tools/cli-bootstrap/child.mjs @@ -0,0 +1,125 @@ +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 } 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'), + 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'; + 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, 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`); + writeFileSync(witness, ''); + const env = { + HOME: home, + 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, + protectedRoots, + failRuntimeUrl, + }), + }; + 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( + ['--import', new URL('./preload.mjs', import.meta.url).href, ...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, 3).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 000000000..6ef5e5844 --- /dev/null +++ b/tools/cli-bootstrap/inventory.mjs @@ -0,0 +1,78 @@ +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']], + ['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('-')}${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 [ + [ + '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 000000000..43fd4631d --- /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 000000000..249f4f219 --- /dev/null +++ b/tools/cli-bootstrap/policy.mjs @@ -0,0 +1,38 @@ +/** 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', + 'string-width', + '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 000000000..7ddd68ca7 --- /dev/null +++ b/tools/cli-bootstrap/preload.mjs @@ -0,0 +1,157 @@ +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import Module, { register, syncBuiltinESMExports } from 'node:module'; +import { dirname, 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, base = process.cwd()) { + if (value instanceof URL && value.protocol === 'file:') return fileURLToPath(value); + 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); + return ( + suffix === '' || (suffix !== '..' && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix)) + ); +} +function guard(method, args) { + const paths = args + .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 +// 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', + 'link', + 'mkdir', + 'mkdtemp', + 'open', + 'opendir', + 'readFile', + 'readdir', + 'readlink', + 'realpath', + 'rename', + 'rm', + 'rmdir', + 'stat', + 'symlink', + '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'); +}; +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'], + [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 000000000..4e6e23fac --- /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 000000000..e859489e9 --- /dev/null +++ b/tools/cli-bootstrap/witness.test.mjs @@ -0,0 +1,402 @@ +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, 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, controlName, grammarCases, metaCases } 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 [ + ['./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); + 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') { + 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('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 = requiredServicePackages.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('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'); + 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/cli-package/README.md b/tools/cli-package/README.md new file mode 100644 index 000000000..b65202006 --- /dev/null +++ b/tools/cli-package/README.md @@ -0,0 +1,118 @@ +# 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). +Step 8b adds required creation-owned native process controls to the same installed +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 +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. +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. + +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. +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 +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. + +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. + +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. 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. + +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 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 +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 +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 000000000..463436008 --- /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', ['--no-replace-objects', '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 000000000..6b2afa0cd --- /dev/null +++ b/tools/cli-package/archive.mjs @@ -0,0 +1,289 @@ +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'); + } + 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; + 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 000000000..fd381714b --- /dev/null +++ b/tools/cli-package/archive.test.mjs @@ -0,0 +1,414 @@ +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' }, + engines: { node: '>=22' }, + 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', + ); + } +}); + +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 {};')); + 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 000000000..d2236b4c9 --- /dev/null +++ b/tools/cli-package/binding.mjs @@ -0,0 +1,416 @@ +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'; + +/** @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 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'); + 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', + 'installedCommand', + '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 !== 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 || + !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 000000000..804f615ca --- /dev/null +++ b/tools/cli-package/binding.test.mjs @@ -0,0 +1,553 @@ +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', + bin: { relavium: './dist/index.js' }, + engines: { node: '>=22' }, + 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: 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', + 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: [] }, + { 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:' } }, + ]) + 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: 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) })) }, + { 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('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, 9); + 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, + ), + ); + } +}); + +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: 177, 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 000000000..3aaba4dd3 --- /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 000000000..581e0e2c7 --- /dev/null +++ b/tools/cli-package/consume.mjs @@ -0,0 +1,199 @@ +/** 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, + writeSync, +} 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 { exerciseInstalledCommand, 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)); +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'); +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 }, + ); + 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 installedCommand = await exerciseInstalledCommand(home, receipt.packageVersion); + 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 }, + installedCommand, + 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 000000000..1ba77b7b2 --- /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 000000000..df66448c6 --- /dev/null +++ b/tools/cli-package/consumer-lifetime.test.mjs @@ -0,0 +1,247 @@ +import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; +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'; +import { REQUIRED_SMOKE_CASES } from './smoke/cases.mjs'; + +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'), + 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')); + 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( + 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 { 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'); +const report = { + source, artifactSha256, pid: ${wrongPid ? '0' : 'process.pid'}, parentPid: process.ppid, + 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); +`, + ); + // 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 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.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); + } + 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/], +]) + 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/files.mjs b/tools/cli-package/files.mjs new file mode 100644 index 000000000..e63455ef3 --- /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 000000000..4eb0ef6c5 --- /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 000000000..eec383692 --- /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 000000000..7317cfd42 --- /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 000000000..cbf9a9897 --- /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 000000000..89127b3e2 --- /dev/null +++ b/tools/cli-package/process.mjs @@ -0,0 +1,171 @@ +/** 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, lstatSync, 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', '../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; + 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 = { + pid: child.pid, + 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); +} + +/** 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 new file mode 100644 index 000000000..318db0f39 --- /dev/null +++ b/tools/cli-package/process.test.mjs @@ -0,0 +1,157 @@ +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 { + 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'); + 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('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( + 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/, + ); +}); + +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 new file mode 100644 index 000000000..1af581b2b --- /dev/null +++ b/tools/cli-package/seal.mjs @@ -0,0 +1,130 @@ +/** 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 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'); + 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: producerNodeMajor(), + 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/seal.test.mjs b/tools/cli-package/seal.test.mjs new file mode 100644 index 000000000..822d181f5 --- /dev/null +++ b/tools/cli-package/seal.test.mjs @@ -0,0 +1,174 @@ +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, producerNodeMajor } 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('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); + 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 new file mode 100644 index 000000000..11fe23333 --- /dev/null +++ b/tools/cli-package/smoke/README.md @@ -0,0 +1,128 @@ +# 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. + +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. + +- 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. +- 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. + 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. +- 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, 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 + `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 +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 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. + +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. 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. + +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 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, +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 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. + +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: + +```sh +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 new file mode 100644 index 000000000..9369540fe --- /dev/null +++ b/tools/cli-package/smoke/cases.mjs @@ -0,0 +1,13 @@ +// 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', + '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', +]); diff --git a/tools/cli-package/smoke/chat.mjs b/tools/cli-package/smoke/chat.mjs new file mode 100644 index 000000000..2a9b35e38 --- /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/failure-diagnostic.mjs b/tools/cli-package/smoke/failure-diagnostic.mjs new file mode 100644 index 000000000..71f56b7dd --- /dev/null +++ b/tools/cli-package/smoke/failure-diagnostic.mjs @@ -0,0 +1,225 @@ +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([ + 'injected-error', + 'exit-fallback', + 'facility-refusal', + 'leader-exit', + 'bootstrap-fatal', + 'bootstrap-background', + 'bootstrap-output', +]); +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-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', + '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) : []); +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'; +// 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; + return { + operation: value.operation, + pid: pid(value.pid), + parentPid: pid(value.parentPid), + code: code(value.code), + signal: value.signal === 0 ? 0 : signal(value.signal), + detached: bool(value.detached), + shell: bool(value.shell), + synthetic: bool(value.synthetic), + error: privateFailure(value.reason), + ...(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) { + 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) => privateFailure(value)) + .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: 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) => { + const item = cases.find((value) => object(value).id === id); + if (item?.passed === true) return []; + return [ + { + id, + status: item === undefined ? 'missing' : 'failed', + error: privateFailure(item?.error), + ...(['actual-native-process-owner', 'actual-native-bootstrap-lifecycle'].includes(id) + ? { + 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) { + 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)]; + 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 the complete required failure inventory. + 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 000000000..26f0054e6 --- /dev/null +++ b/tools/cli-package/smoke/failure-diagnostic.test.mjs @@ -0,0 +1,299 @@ +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, + 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(); + 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'; + native.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, '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); +}); + +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.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); +}); + +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.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', () => { + 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); +}); + +// 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); + } +}); diff --git a/tools/cli-package/smoke/guard-log.mjs b/tools/cli-package/smoke/guard-log.mjs new file mode 100644 index 000000000..f8a204baa --- /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 000000000..16ce07a32 --- /dev/null +++ b/tools/cli-package/smoke/index.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readOrdinaryFile } from '../files.mjs'; +import { isolatedEnvironment, runCommand } from '../process.mjs'; + +/** Join native module lifetime before the caller removes the owned installation. */ +export async function runInstalledSmoke({ packageRoot, supportRoot, artifactSha256, source }) { + 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 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/keyring-denied.mjs b/tools/cli-package/smoke/keyring-denied.mjs new file mode 100644 index 000000000..1b76a7d81 --- /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 000000000..ec297f519 --- /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 000000000..c432bc364 --- /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-descendant.cjs b/tools/cli-package/smoke/native-descendant.cjs new file mode 100644 index 000000000..513c993d9 --- /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-load.mjs b/tools/cli-package/smoke/native-load.mjs new file mode 100644 index 000000000..91b2a96eb --- /dev/null +++ b/tools/cli-package/smoke/native-load.mjs @@ -0,0 +1,35 @@ +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'), + 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 new file mode 100644 index 000000000..f526bd6cf --- /dev/null +++ b/tools/cli-package/smoke/native-process.mjs @@ -0,0 +1,768 @@ +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'; +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; +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')); +} + +/** 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. 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); + 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. */ +/** 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. 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'); + 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 = 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); + 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' && + ['injected-error', 'bootstrap-fatal', 'bootstrap-output'].includes(mode) + ? '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'), + ...(bootstrap ? { release } : {}), + }), + { 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, + }); + // 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 (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( + () => + 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', + ); + if (bootstrap && mode !== 'bootstrap-background') { + assert.equal( + projectRuntimeStderr(owned.output().stderr, { + runtime: process.version, + pid: owned.child.pid, + }).diagnostics, + '', + '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'); + 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); + 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 if (!nativeGroupRefusal) assert.ok([0, 1].includes(result.code), result.stderr); + assert.equal( + ndjson(result.stdout).filter((event) => event.type === 'session:turn_completed').length, + nativeGroupRefusal || (bootstrap && mode !== 'bootstrap-background') ? 0 : 1, + 'The public offline turn completion differs from its actual lifetime', + ); + if (bootstrap) { + assert.ok( + observed.some((item) => item.operation === `native-${gate}-close-delivered`), + 'Actual gated close was not delivered before product completion', + ); + const projected = projectRuntimeStderr(result.stderr, { + runtime: process.version, + pid: owned.child.pid, + }); + outcome.runtimeWarnings = projected.runtimeWarnings; + const diagnostics = ndjson(projected.diagnostics); + assertNativeBootstrapDiagnostic(diagnostics, mode); + if (mode === 'bootstrap-background') { + 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[\t ]+at |file:\/\//); + } + 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.deepEqual( + 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', + ); + 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(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, + 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' && + ['injected-error', 'bootstrap-fatal', 'bootstrap-output'].includes(mode) + ) { + 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( + 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, { + 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, + }); + } 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', + ); + // 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, + publicOfflineAgentRoute: true, + capturedPackagedCreationExitClosure: true, + 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, + }; + } 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 000000000..5ceab75eb --- /dev/null +++ b/tools/cli-package/smoke/native-process.test.mjs @@ -0,0 +1,408 @@ +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 { + assertObservedGroupRefusal, + assertNativeBootstrapDiagnostic, + copyFixtureNode, + copyFixtureRuntimeSupport, + createFixtureSentinel, + InstalledNativeControlError, + pendingNativeFixturePids, +} 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. 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 { + 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 }); + } +}); + +for (const [mode, code, message] of [ + [ + 'bootstrap-fatal', + 'cli_internal', + 'An unexpected CLI failure occurred. 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/native-signal-diagnostic.mjs b/tools/cli-package/smoke/native-signal-diagnostic.mjs new file mode 100644 index 000000000..ea0a8466a --- /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 new file mode 100644 index 000000000..61f8054af --- /dev/null +++ b/tools/cli-package/smoke/native-spawn-witness.mjs @@ -0,0 +1,261 @@ +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'; +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) { + 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', + '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)); + 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 facilityAccessRefused = false; + 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) => { + 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: '' }; + 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); + record({ operation: `native-${kind}-close-delivered`, pid: child.pid }); + }; + 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({ + 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; + 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) { + 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); + ownedSignalTargets.add(target.pid); + if (options.detached) ownedSignalTargets.add(-target.pid); + 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 === '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') { + 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); + if (Number.isSafeInteger(helper.pid) && helper.pid > 0) ownedSignalTargets.add(helper.pid); + 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 000000000..cdf9b7e46 --- /dev/null +++ b/tools/cli-package/smoke/native-spawn-witness.test.mjs @@ -0,0 +1,519 @@ +import assert from 'node:assert/strict'; +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'; +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') { + const root = realpathSync(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'); + 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('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 }); + 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 000000000..074695383 --- /dev/null +++ b/tools/cli-package/smoke/native-status.cjs @@ -0,0 +1,49 @@ +// 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, existsSync } = require('node:fs'); +const { join } = require('node:path'); +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', + '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'); + 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 === '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/preload.mjs b/tools/cli-package/smoke/preload.mjs new file mode 100644 index 000000000..921f5c563 --- /dev/null +++ b/tools/cli-package/smoke/preload.mjs @@ -0,0 +1,136 @@ +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 http2 from 'node:http2'; +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'; + +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 name of ['connect', 'createServer', 'createSecureServer']) + http2[name] = denied(`http2.${name}`); +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; +const nativeWitness = process.env.RELAVIUM_PACKAGE_SMOKE_NATIVE + ? createNativeSpawnWitness(process.env.RELAVIUM_PACKAGE_SMOKE_NATIVE, originalSpawn) + : undefined; +if (nativeWitness !== undefined) { + nativeWitness.installSignalDiagnostics(); + fsPromises.access = nativeWitness.wrapFacilityAccess(fsPromises.access); +} +child.spawn = (...args) => { + 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 && + 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/preload.test.mjs b/tools/cli-package/smoke/preload.test.mjs new file mode 100644 index 000000000..8892477e5 --- /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/process.mjs b/tools/cli-package/smoke/process.mjs new file mode 100644 index 000000000..636a1748a --- /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 000000000..becc75491 --- /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/runtime-stderr.mjs b/tools/cli-package/smoke/runtime-stderr.mjs new file mode 100644 index 000000000..ee7b3c693 --- /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.includes(warning, first + warning.length) + ) + 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 000000000..3efa1eced --- /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/cli-package/smoke/worker.mjs b/tools/cli-package/smoke/worker.mjs new file mode 100644 index 000000000..d57123261 --- /dev/null +++ b/tools/cli-package/smoke/worker.mjs @@ -0,0 +1,323 @@ +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 { assertInstalledNativeProcesses, InstalledNativeControlError } from './native-process.mjs'; +import { emitInstalledWorkerFailure } from './failure-diagnostic.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: + error instanceof InstalledNativeControlError + ? error.primaryFailure + : String(error.stack ?? error), + ...(error instanceof InstalledNativeControlError ? { evidence: error.evidence } : {}), + }); + } + } + 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('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 () => { + 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, + }; + }); + 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)); + 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; + emitInstalledWorkerFailure(report); +} diff --git a/tools/cli-package/smoke/workflows.mjs b/tools/cli-package/smoke/workflows.mjs new file mode 100644 index 000000000..ec001f492 --- /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/coverage-gate/run.mjs b/tools/coverage-gate/run.mjs index 516612f03..535949d62 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' }, diff --git a/tools/fs-policy-controls/fixtures/media-native-packaging.mjs b/tools/fs-policy-controls/fixtures/media-native-packaging.mjs index e2f061627..50919d1bc 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', diff --git a/tools/fs-policy-controls/mutations.mjs b/tools/fs-policy-controls/mutations.mjs index 2853dc72e..7210b8d46 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], @@ -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', @@ -253,7 +253,7 @@ for (const mutation of mutations) { : file === floorCases[0] ? 14 : file === workspaceCases - ? 8 + ? 12 : file === cooperativeCases ? 6 : file === windowsWinnerCases diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index a1935af81..bce9595cf 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 276 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 @@ -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 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. The eleven Step 4 controls remove separate production mechanisms: @@ -66,14 +69,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, 76 run-command cases, three run-consent cases, 61 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 @@ -102,14 +109,14 @@ 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. 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 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. @@ -121,7 +128,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 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 @@ -164,8 +171,111 @@ 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 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 +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 +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. + +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 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 +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. + +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. + +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. + +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 +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,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 +`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. + 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 0bebce7b4..96d47e901 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', @@ -48,6 +48,199 @@ 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`, +); +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)})`, + ), +); +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)})`, + ), +); +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` 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; + const match = JOINED_STANDALONE_CREDENTIAL.exec(joined); + if (match === null) continue; + matchedUntil = JOINED_STANDALONE_CREDENTIAL.lastIndex; + mask(start, matchedUntil, '[redacted]'); + } +} + +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 { + if (!joined.includes(':')) return; + const header = /(?:authorization|(?:x-)?api-key|x-goog-api-key)["']?\s*:/iy; + 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 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; + 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. + * 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[] = []; + const separators = /[\n\t]+/g; + let next = separators.exec(text); + let removed = 0; + let copied = 0; + 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 target + removed; + } + visit(joined, (start, end, replacement) => { + const originalStart = advance(start, true); + const originalEnd = advance(end); + if (boundary?.test(text[originalStart - 1] ?? '')) return;`; +const withoutCoreOriginalBoundary = coreOriginalBoundary + .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( + id, + coreProjection, + before, + after, + coreProjectionCases, + 142, + targets[0], + marker, + targets.slice(1).map((target) => ({ target, marker })), + ); +} +function diagnosticControl(id, before, after, targets, marker = diagnosticMarker) { + return control( + id, + scrubSecretsLeaf, + before, + after, + ['packages/llm/src/scrub-secrets.test.ts'], + 103, + targets[0], + marker, + targets.slice(1).map((target) => ({ target, marker })), + ); +} + export const mutations = [ control( 'stdin-cap', @@ -148,7 +341,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'], @@ -159,11 +352,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'], - 161, + 180, "SECRETS_CAUSAL_RESIDUAL_SCRUB protects 'client_secret' beside a split credential: 'multiline'", 'SECRETS_CAUSAL_RESIDUAL_SCRUB', [ @@ -197,15 +389,15 @@ export const mutations = [ ), control( 'prefix-before-redaction', - 'packages/llm/src/llm-error.ts', - " const joined = remaining.replace(/\\s+/g, '');", - ' const joined = remaining;', + scrubSecretsLeaf, + " 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, + 278, 'SECRETS_CAUSAL_PREFIX_ORDER protects client_secret before overlapping query redaction', 'SECRETS_CAUSAL_PREFIX_ORDER', [ @@ -250,11 +442,11 @@ export const mutations = [ ), control( 'normalized-secret-probe', - llmError, + scrubSecretsLeaf, ' return stripTerminalControls(text);', ' return text;', overlapFiles, - 259, + 278, `${overlapMarker} protects client_secret normalized key before query redaction`, overlapMarker, overlapWitnesses('normalized key').filter( @@ -265,11 +457,11 @@ export const mutations = [ ), control( 'non-query-before-query', - llmError, + scrubSecretsLeaf, ' return scrubQuerySecrets(scrubNonQuerySecrets(text));', ' return scrubNonQuerySecrets(scrubQuerySecrets(text));', overlapFiles, - 259, + 278, `${overlapMarker} protects client_secret Bearer before query redaction`, overlapMarker, [ @@ -309,8 +501,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', @@ -344,12 +536,19 @@ 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'], - 76, - '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 +566,7 @@ export const mutations = [ ' const mayPrompt = args.secretStdin !== true;', ' const mayPrompt = true;', ['apps/cli/src/commands/gate.test.ts'], - 61, + 73, 'refuses unapproved stdio after a real read despite stale TTY, without prompting or writing a grant', 'SECRETS_CAUSAL_RESUME_CONSUMED_STDIN_CONSENT', ), @@ -404,20 +603,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', @@ -478,7 +672,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', ), @@ -488,7 +682,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', ), @@ -498,7 +692,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', ), @@ -508,7 +702,7 @@ export const mutations = [ ' deps.io.writeErr(SESSION_EXPORT_REVIEW_NOTICE);\n', '', ['apps/cli/src/commands/chat-export.test.ts'], - 15, + 18, "reports fixed continuity and review notice on stderr after successful 'human' export", 'W2_EXPORT_COMMAND_NOTICE', [ @@ -525,7 +719,7 @@ export const mutations = [ ' emitOutput(SESSION_EXPORT_REVIEW_NOTICE.trimEnd());\n', '', ['apps/cli/src/commands/chat.test.ts'], - 122, + 133, "reports fixed continuity and review notice on stderr after live 'human' /export", 'W2_EXPORT_LIVE_NOTICE', [ @@ -545,7 +739,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, + 133, "renders the live export 'success' through the interactive transcript", 'W2_EXPORT_LIVE_RENDERED_NOTICE', ), @@ -586,7 +780,7 @@ export const mutations = [ ' warnSnapshotLiterals(def, deps.io);\n', '', ['apps/cli/src/commands/run.test.ts'], - 76, + 84, 'warns after admission before services and preserves the actual augmented frozen workflow', 'SNAPSHOT_CAUSAL_RUN_CALLER', ), @@ -707,4 +901,133 @@ export const mutations = [ }, ], ), + // PR #95 B2: independent modern-prefix, joined-view, boundary and malformed-userinfo sites. + coreProjectionControl( + 'core-modern-key-alphabet', + 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}`, + ), + ), + 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', + coreOriginalBoundary, + withoutCoreOriginalBoundary, + ['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(start, matchedUntil, 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 (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( + '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`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. + coreProjectionControl( + 'core-joined-original-boundary-recovery', + String.raw` const candidates = /(^|[^\w])([A-Za-z])/g;`, + String.raw` const candidates = /([A-Za-z])/g;`, + originalBoundaryCases, + originalBoundaryMarker, + ), + diagnosticControl( + 'diagnostic-joined-original-boundary-recovery', + "!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', + String.raw` const candidates = /(^|[^A-Za-z0-9_-])([A-Za-z])/g;`, + String.raw` const candidates = /([A-Za-z])/g;`, + 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, + ), + // 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, + 180, + 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, + 180, + displayBoundaryCases[0], + displayBoundaryMarker, + [ + ...displayBoundaryCases.slice(1).map((target) => ({ target, marker: displayBoundaryMarker })), + ...displayRendererWitnesses(displayBoundaryMarker), + ], + ), ]; diff --git a/tools/usage-coverage-controls/README.md b/tools/usage-coverage-controls/README.md index 62527cefc..a8e285bb1 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 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/source-graph.mjs b/tools/usage-coverage-controls/source-graph.mjs index 43d77b7c2..7b7cd3205 100644 --- a/tools/usage-coverage-controls/source-graph.mjs +++ b/tools/usage-coverage-controls/source-graph.mjs @@ -21,9 +21,14 @@ 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/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.mjs b/tools/usage-coverage-controls/workload.mjs index 1a74bb07d..d4b5bed2e 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: 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 c876750bd..d1f5f3faf 100644 --- a/tools/usage-coverage-controls/workload.test.mjs +++ b/tools/usage-coverage-controls/workload.test.mjs @@ -1,35 +1,75 @@ 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 { digest, instrument } from './source-graph.mjs'; +import { permanentFiles } from './mutations.mjs'; +import { + aliases, + captureSource, + digest, + instrument, + rejectUnknownAlias, + relativeSource, +} from './source-graph.mjs'; import { assertNativeReadiness, assertPermanentWorkload, ordinaryPhaseTimeoutMs, permanentPhaseTimeoutMs, + permanentWorkload, } from './workload.mjs'; +test('supported shared, llm and core exports map to exact owned sources, with unknown aliases refused', () => { + const repository = fileURLToPath(new URL('../../', import.meta.url)); + 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/); +}); + 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); const result = { - tests: Array.from({ length: 435 }), + tests: Array.from({ length: 446 }), runtime: { nativeChildren: 71, readinessTimeoutChildren: 1 }, }; assert.doesNotThrow(() => assertPermanentWorkload(result)); - for (const count of [0, 433, 434, 436]) { + for (const count of [0, 440, 445, 447]) { assert.throws( () => assertPermanentWorkload({ ...result, tests: Array.from({ length: count }) }), /Permanent case inventory drift/, @@ -56,6 +96,115 @@ 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 = 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. + collect(); + 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) => relative(graph, realpathSync(item.file)).replaceAll('\\', '/')), + ), + ].sort(), + [...permanentFiles].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 = [