diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..25b875d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +scripts/phase1-conformance-launcher.sh text eol=lf +scripts/phase1-conformance-launcher.ps1 text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81962e0..735ac37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,7 @@ jobs: # The comparison needs both endpoints in history, and a shallow clone # has neither for anything but the newest commit. fetch-depth: 0 + persist-credentials: false - id: classify name: Classify the diff env: @@ -99,6 +100,9 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + persist-credentials: false - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 with: version: 10.34.0 @@ -137,6 +141,8 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 with: version: 10.34.0 @@ -161,6 +167,8 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - id: reviewed-revisions name: Read reviewed counterpart lock run: | @@ -196,12 +204,14 @@ jobs: ref: ${{ steps.reviewed-revisions.outputs.sdk_revision }} path: .cross-repo/sdk token: ${{ secrets.CANARY_TOKEN || github.token }} + persist-credentials: false - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: ${{ steps.reviewed-revisions.outputs.cave_repository }} ref: ${{ steps.reviewed-revisions.outputs.cave_revision }} path: .cross-repo/coven-cave token: ${{ secrets.CANARY_TOKEN || github.token }} + persist-credentials: false fetch-depth: 0 - name: Assert checked-out reviewed revisions run: | @@ -269,6 +279,8 @@ jobs: appendFileSync(outputPath, `${repository}_repository=${lock[repository].repository}\n`); appendFileSync(outputPath, `${repository}_revision=${lock[repository].revision}\n`); } + appendFileSync(outputPath, `evidence_repository=${lock.evidence.repository}\n`); + appendFileSync(outputPath, `evidence_revision=${lock.evidence.revision}\n`); EOF - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: @@ -276,24 +288,32 @@ jobs: ref: ${{ steps.phase1-revisions.outputs.sdk_revision }} path: .phase1-counterparts/sdk token: ${{ secrets.CANARY_TOKEN || github.token }} + persist-credentials: false fetch-depth: 0 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + repository: ${{ steps.phase1-revisions.outputs.evidence_repository }} + ref: ${{ steps.phase1-revisions.outputs.evidence_revision }} + path: .phase1-counterparts/sdk-evidence + token: ${{ secrets.CANARY_TOKEN || github.token }} persist-credentials: false + fetch-depth: 0 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: ${{ steps.phase1-revisions.outputs.cave_repository }} ref: ${{ steps.phase1-revisions.outputs.cave_revision }} path: .phase1-counterparts/coven-cave token: ${{ secrets.CANARY_TOKEN || github.token }} - fetch-depth: 0 persist-credentials: false + fetch-depth: 0 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: ${{ steps.phase1-revisions.outputs.coven_repository }} ref: ${{ steps.phase1-revisions.outputs.coven_revision }} path: .phase1-counterparts/coven token: ${{ secrets.CANARY_TOKEN || github.token }} - fetch-depth: 0 persist-credentials: false + fetch-depth: 0 - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 with: version: 10.34.0 @@ -306,14 +326,14 @@ jobs: with: toolchain: 1.95.0 components: clippy,rustfmt - - run: pnpm install --frozen-lockfile --ignore-scripts + - run: pnpm install --frozen-lockfile - id: phase1-keychain name: Configure isolated Phase 1 keychain shell: bash run: | set -euo pipefail - security default-keychain -d user | tr -d '"' > "$RUNNER_TEMP/phase1-original-default-keychain" + security default-keychain -d user | sed 's/^[[:space:]]*"//; s/"$//' > "$RUNNER_TEMP/phase1-original-default-keychain" security list-keychains -d user | sed 's/^[[:space:]]*"//; s/"$//' > "$RUNNER_TEMP/phase1-original-keychains" keychain="$RUNNER_TEMP/phase1-conformance.keychain-db" @@ -331,31 +351,32 @@ jobs: env: OPENCOVEN_CHAT_ROOT: ${{ github.workspace }} OPENCOVEN_SDK_ROOT: ${{ github.workspace }}/.phase1-counterparts/sdk + OPENCOVEN_SDK_EVIDENCE_ROOT: ${{ github.workspace }}/.phase1-counterparts/sdk-evidence OPENCOVEN_CAVE_ROOT: ${{ github.workspace }}/.phase1-counterparts/coven-cave OPENCOVEN_COVEN_ROOT: ${{ github.workspace }}/.phase1-counterparts/coven + OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED: "1" run: | set +e - pnpm test:phase1-conformance - status=$? + launcher_digest="$(/usr/bin/shasum -a 256 scripts/phase1-conformance-launcher.sh | /usr/bin/awk '{print $1}')" + if [[ "${launcher_digest}" != "88e184d465eaf7bd6ce828dcc81ecadb11b6222f01576c56090060085820e7b2" ]]; then + status=1 + else + node_path="$(command -v node)" + /bin/sh scripts/phase1-conformance-launcher.sh "${node_path}" + status=$? + fi echo "exit_code=${status}" >> "$GITHUB_OUTPUT" exit 0 - id: secret-scan name: Re-scan retained Phase 1 report if: always() && hashFiles('test-results/phase1-conformance/report.json') != '' run: node ./scripts/phase1-artifact-secret-scan.mjs --artifact-root ./test-results/phase1-conformance - - name: Upload sanitized Phase 1 report - if: always() && steps.secret-scan.outcome == 'success' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: phase1-conformance-report - path: test-results/phase1-conformance/report.json - if-no-files-found: error - retention-days: 14 - - name: Remove isolated Phase 1 keychain + - id: phase1-keychain-cleanup + name: Remove isolated Phase 1 keychain if: always() shell: bash run: | - set +e + set -euo pipefail if [[ -f "$RUNNER_TEMP/phase1-original-default-keychain" ]]; then original_default="$(cat "$RUNNER_TEMP/phase1-original-default-keychain")" @@ -379,12 +400,21 @@ jobs: if [[ -n "${PHASE1_TEST_KEYCHAIN:-}" ]]; then security delete-keychain "${PHASE1_TEST_KEYCHAIN}" fi + - name: Upload sanitized Phase 1 report + if: always() && steps.secret-scan.outcome == 'success' && steps.phase1-keychain-cleanup.outcome == 'success' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: phase1-conformance-report + path: test-results/phase1-conformance/report.json + if-no-files-found: error + retention-days: 14 - name: Enforce Phase 1 conformance result if: always() shell: bash run: | test "${{ steps.phase1-run.outputs.exit_code }}" = "0" test "${{ steps.secret-scan.outcome }}" = "success" + test "${{ steps.phase1-keychain-cleanup.outcome }}" = "success" desktop: name: Desktop build @@ -406,6 +436,8 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 with: version: 10.34.0 @@ -430,26 +462,170 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + fetch-depth: 0 + persist-credentials: false - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: 1.95.0 components: clippy,rustfmt + - run: rustup target add x86_64-pc-windows-gnu + - name: Install pinned Windows GNU toolchain + env: + HOMEBREW_BOTTLE_DOMAIN: https://ghcr.io/v2/homebrew/core + HOMEBREW_NO_AUTO_UPDATE: "1" + HOMEBREW_NO_INSTALL_FROM_API: "1" + run: | + core_revision="cd168d1fdc26f12e4ad64f358ff2dbec61ab7a57" + formula="${RUNNER_TEMP}/mingw-w64.rb" + bottle="${RUNNER_TEMP}/mingw-w64--14.0.0_3.arm64_tahoe.bottle.tar.gz" + curl --proto '=https' --tlsv1.2 -fsSL \ + "https://raw.githubusercontent.com/Homebrew/homebrew-core/${core_revision}/Formula/m/mingw-w64.rb" \ + -o "${formula}" + test "$(shasum -a 256 "${formula}" | awk '{print $1}')" = "798631311a841e0639469f3f95a5287c8747f7a354e79a47ac39d6bf20eefe34" + grep -F 'revision 3' "${formula}" + grep -F 'sha256 arm64_tahoe: "c4f826c665c0fb37a3dedc80affa6203a0d64891b788c8f7ab65b46862fd490c"' "${formula}" + + brew uninstall --force mingw-w64 >/dev/null 2>&1 || true + if brew list --versions mingw-w64 | grep -q .; then + echo "existing mingw-w64 installation was not removed" >&2 + exit 1 + fi + + token="$(curl -fsSL 'https://ghcr.io/token?scope=repository:homebrew/core/mingw-w64:pull' | + python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')" + curl -fsSL \ + -H "Authorization: Bearer ${token}" \ + -H 'Accept: application/vnd.oci.image.index.v1+json' \ + 'https://ghcr.io/v2/homebrew/core/mingw-w64/manifests/14.0.0_3' | + python3 -c 'import json,sys; d=json.load(sys.stdin); matches=[m for m in d["manifests"] if m.get("annotations",{}).get("org.opencontainers.image.ref.name")=="14.0.0_3.arm64_tahoe"]; assert len(matches)==1; assert matches[0]["annotations"]["sh.brew.bottle.digest"]=="0d68ab737a8bbc8c63ac6ac7acc0695e2887c1169df9a4423f1180090079b1d5"' + + curl --proto '=https' --tlsv1.2 -fsSL \ + -H "Authorization: Bearer ${token}" \ + 'https://ghcr.io/v2/homebrew/core/mingw-w64/blobs/sha256:0d68ab737a8bbc8c63ac6ac7acc0695e2887c1169df9a4423f1180090079b1d5' \ + -o "${bottle}" + test "$(shasum -a 256 "${bottle}" | awk '{print $1}')" = "0d68ab737a8bbc8c63ac6ac7acc0695e2887c1169df9a4423f1180090079b1d5" + test "$(wc -c < "${bottle}" | tr -d ' ')" = "356178908" + HOMEBREW_DEVELOPER=1 brew install --force-bottle "${bottle}" + test "$(brew list --versions mingw-w64)" = "mingw-w64 14.0.0_3" + x86_64-w64-mingw32-ld --version | head -n 1 | grep -F '2.47.20260726' + - name: Verify Windows GNU resource compiler + run: command -v x86_64-w64-mingw32-windres + - name: Verify standalone supervisor crate graph + working-directory: tools/phase1-process-supervisor + run: | + cargo metadata \ + --locked \ + --no-deps \ + --format-version 1 | + python -c 'import json,sys; d=json.load(sys.stdin); assert [p["name"] for p in d["packages"]]==["phase1-process-supervisor"]; assert d["workspace_root"].endswith("tools/phase1-process-supervisor")' + - name: Check standalone supervisor crate + working-directory: tools/phase1-process-supervisor + run: | + cargo fmt --all --check + cargo check --locked + cargo test --locked + cargo clippy --locked -- -D warnings + - name: Build and verify frozen Windows supervisor + working-directory: tools/phase1-process-supervisor + run: | + SOURCE_DATE_EPOCH=0 cargo build \ + --target x86_64-pc-windows-gnu \ + --release \ + --locked + node --input-type=module <<'EOF' + import { createHash } from 'node:crypto'; + import { execFileSync } from 'node:child_process'; + import { readFileSync, statSync } from 'node:fs'; + import { readPhase1ConformanceLock } from '../../scripts/phase1-conformance-lock.mjs'; + + const supervisor = readPhase1ConformanceLock('../../phase1-conformance.lock.json').tools.windowsSupervisor; + const artifact = supervisor.artifact; + const source = supervisor.source; + const blob = execFileSync('git', ['-C', '../..', 'rev-parse', `${source.revision}:${source.path}`], { + encoding: 'utf8', + }).trim(); + const sourceBytes = execFileSync('git', ['-C', '../..', 'show', `${source.revision}:${source.path}`]); + if ( + blob !== source.blob || + createHash('sha256').update(sourceBytes).digest('hex') !== source.sha256 + ) { + throw new Error('Windows supervisor source does not match the immutable lock.'); + } + const path = `target/x86_64-pc-windows-gnu/release/${artifact.fileName}`; + const bytes = readFileSync(path); + const actualSize = statSync(path).size; + const actualSha256 = createHash('sha256').update(bytes).digest('hex'); + console.log(`Windows supervisor artifact: sha256=${actualSha256} size=${actualSize}`); + if ( + actualSize !== artifact.size || + actualSha256 !== artifact.sha256 + ) { + throw new Error('Windows supervisor artifact does not match the immutable lock.'); + } + EOF + - name: Upload frozen Windows supervisor + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: phase1-process-supervisor-win32-x64 + path: tools/phase1-process-supervisor/target/x86_64-pc-windows-gnu/release/phase1-process-supervisor.exe + if-no-files-found: error + retention-days: 1 - run: cargo fmt --manifest-path src-tauri/Cargo.toml --all --check - run: cargo check --manifest-path src-tauri/Cargo.toml --all-targets + - run: corepack pnpm cargo:check:windows-gnu - run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features -- -D warnings - run: cargo test --manifest-path src-tauri/Cargo.toml - run: cargo test --manifest-path src-tauri/Cargo.toml --features phase1-conformance --lib - run: corepack pnpm test:native-e2e - windows-rust: - name: Windows Rust + windows-supervisor-behavior: + name: Windows supervisor behavior runs-on: windows-latest - needs: changes - if: needs.changes.outputs.docs_only != 'true' - timeout-minutes: 30 + needs: [changes, rust] + if: always() && needs.rust.result == 'success' && needs.changes.outputs.docs_only != 'true' + timeout-minutes: 20 + permissions: + contents: read steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: phase1-process-supervisor-win32-x64 + path: windows-supervisor-artifact - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: 1.95.0 - - run: cargo check --manifest-path src-tauri/Cargo.toml --all-targets + - name: Install and verify frozen supervisor + shell: pwsh + run: | + $source = Join-Path $env:GITHUB_WORKSPACE 'windows-supervisor-artifact\phase1-process-supervisor.exe' + $destination = 'C:\OpenCoven\conformance\phase1-process-supervisor.exe' + if ((Get-Item $source).Length -ne 333824) { throw 'unexpected supervisor size' } + if ((Get-FileHash $source -Algorithm SHA256).Hash.ToLowerInvariant() -ne '372b3e8b5b860e0759da8fa10ddfb6ec338e26d83616254c816a456ae2e1b7c5') { + throw 'unexpected supervisor digest' + } + New-Item -ItemType Directory -Force (Split-Path $destination) | Out-Null + Copy-Item -LiteralPath $source -Destination $destination + if ((Get-FileHash $destination -Algorithm SHA256).Hash.ToLowerInvariant() -ne '372b3e8b5b860e0759da8fa10ddfb6ec338e26d83616254c816a456ae2e1b7c5') { + throw 'installed supervisor digest mismatch' + } + if ((Get-FileHash 'scripts/phase1-conformance-launcher.ps1' -Algorithm SHA256).Hash.ToLowerInvariant() -ne '99eea6108e59db9a0ac12368787fb6e6456e6af4f8cce09ee96ce117ca3f475e') { + throw 'Windows launcher digest mismatch' + } + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + with: + version: 10.34.0 + run_install: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .node-version + cache: pnpm + - run: pnpm install --frozen-lockfile + - name: Run real Windows supervisor behavior + shell: pwsh + env: + OPENCOVEN_PHASE1_WINDOWS_SUPERVISOR_PATH: C:\OpenCoven\conformance\phase1-process-supervisor.exe + run: corepack pnpm@10.34.0 --ignore-workspace exec vitest run --config vitest.heavy.config.ts src/phase1-windows-supervisor.test.ts diff --git a/README.md b/README.md index 5d2cfd5..c5dcf1c 100644 --- a/README.md +++ b/README.md @@ -52,11 +52,10 @@ write-oriented design exploration. The native host now uses the reviewed `hpke-bound-v1` request and response binding, and production Coven health uses the producer-backed native adapter. The immutable runtime gate is documented in -[`docs/phase1-conformance.md`](docs/phase1-conformance.md). It deliberately -reports `blocked` rather than substituting mocks when a locked producer does -not expose a required compatibility or real-keychain fault fixture. Release -evidence still requires complete real-authority runs on the frozen platform -matrix. +[`docs/phase1-conformance.md`](docs/phase1-conformance.md). It emits only +complete SDK #38 platform records and never substitutes mocks for missing +release assertions. Release evidence still requires complete real-authority +runs on the frozen three-platform matrix. ## Prerequisites @@ -89,9 +88,10 @@ pnpm exec playwright install chromium | `pnpm test:e2e` | Run Playwright smoke coverage against a dedicated local preview server on `127.0.0.1:4174` | | `pnpm test:native-e2e` | Run the feature-gated native RPC subprocess integration tests | | `pnpm test:contract-canary -- --sdk-root --cave-root ` | Verify reviewed clean checkouts, frozen SDK artifact digests, isolated packed imports, and the Cave authority fixture | -| `pnpm test:phase1-conformance` | Package the revisions in `phase1-conformance.lock.json`, run the real Cave/native/Coven matrix, and retain one secret-scanned JSON report | +| `/bin/sh scripts/phase1-conformance-launcher.sh "$(command -v node)"` | Exercise the exact locked release through the trusted non-Node launcher and retain one SDK-compatible platform record | | `pnpm cargo:fmt` | Verify Rust formatting | | `pnpm cargo:check` | Run Rust compile checks | +| `pnpm cargo:check:windows-gnu` | Check all Rust targets for `x86_64-pc-windows-gnu` | | `pnpm cargo:clippy` | Run Rust lint checks with warnings denied | | `pnpm cargo:test` | Run Rust smoke tests | | `pnpm app:dev` | Start the Tauri desktop scaffold in development | @@ -172,8 +172,9 @@ Local explicit-root canary runs still use and the script rejects staged, unstaged, or untracked changes before it verifies that the checked-out HEADs match the tracked lock. -`phase1-conformance.lock.json` independently pins Chat, SDK, Cave, and Coven -for the packaged real-authority gate. It does not replace or loosen the Phase 0 +`phase1-conformance.lock.json` independently pins Chat, the SDK package +candidate and evidence authority, Cave, Coven, and the canonical package +metadata for the real-authority gate. It does not replace or loosen the Phase 0 canary lock. ## CI coverage @@ -189,7 +190,7 @@ canary lock. - the cross-repository packed-tarball contract canary with explicit SDK and Cave checkouts pinned by `contract-canary.lock.json` - the macOS packaged real-authority matrix with exact counterpart checkouts pinned by `phase1-conformance.lock.json`, an isolated keychain, and a - secret-scanned JSON report + secret-scanned SDK platform record - Rust `fmt`, `check`, `clippy`, and `test` The Tauri capability schema at `src-tauri/gen/schemas/desktop-schema.json` is diff --git a/docs/developer-toolchains.md b/docs/developer-toolchains.md index 82c38bd..0b340fa 100644 --- a/docs/developer-toolchains.md +++ b/docs/developer-toolchains.md @@ -77,10 +77,11 @@ dependency. - Run the local canary with `pnpm test:contract-canary -- --sdk-root --cave-root `. -- Run the packaged real-authority matrix with - `pnpm test:phase1-conformance`. Its independent - `phase1-conformance.lock.json` pins Chat, SDK, Cave, and Coven; the harness - creates exact clean detached clones rather than trusting the source +- Run the packaged real-authority matrix through the trusted outer launcher: + `/bin/sh scripts/phase1-conformance-launcher.sh "$(command -v node)"`. Its independent + `phase1-conformance.lock.json` pins Chat, the SDK package candidate, Cave, + Coven, all four frozen SDK artifacts, and the SDK evidence authority; the + harness creates exact clean detached clones rather than trusting source repositories' current branches. - CI reads `contract-canary.lock.json`, checks out those exact reviewed SDK and Cave revisions, rejects dirty SDK or Cave checkouts, and verifies the @@ -98,18 +99,22 @@ permissions for the `main` window. - No shell, filesystem, opener, or network plugin permissions are configured. - The feature-gated `phase1-native-rpc` binary is a headless conformance-only - NDJSON bridge. It is excluded from default Cargo and Tauri builds, uses an - in-memory test custody instead of the operator keyring, and accepts Cave - launch paths only through its two explicit + NDJSON bridge. It is excluded from default Cargo and Tauri builds. Unit + scenarios default to in-memory custody, while the real-authority runner + explicitly selects the production `NativeKeyring`, restarts the RPC process, + and verifies credential reuse and deletion. Cave launch paths are accepted + only through its two explicit `OPENCOVEN_PHASE1_CONFORMANCE_NODE_PATH` and `OPENCOVEN_PHASE1_CONFORMANCE_CAVE_SERVER_PATH` environment variables. - Run its subprocess integration gate with `pnpm test:native-e2e`; normal `pnpm app:dev` selects the `opencoven-chat` desktop binary by default. -- CI compiles every Rust target on a native Windows runner in addition to the - macOS Rust checks, so Tauri's Windows resource build is validated with the - platform toolchain it requires. +- Run the persisted Windows GNU cross-target compile gate with + `pnpm cargo:check:windows-gnu`. - The Phase 1 harness creates mode-`0700` process-owned roots under the real OS temporary directory, reaps only tracked child processes, scans the completed - report for secrets and private content, and retains only + SDK platform record for secrets and private content, and retains only `test-results/phase1-conformance/report.json`. See [`phase1-conformance.md`](phase1-conformance.md). +- Coven conformance starts the real locked daemon and invokes Chat + `phase1-native-rpc` command `coven_health`; it never uses `coven daemon + status` or reimplements producer-owned peer/pipe identity checks. diff --git a/docs/phase1-conformance.md b/docs/phase1-conformance.md index 60031d5..480fd9e 100644 --- a/docs/phase1-conformance.md +++ b/docs/phase1-conformance.md @@ -1,143 +1,265 @@ # Phase 1 real-authority conformance -`pnpm test:phase1-conformance` is the release gate for the Phase 1 read-only -desktop client. It packages exact reviewed Chat and SDK artifacts, builds the -locked Cave and Coven authorities, drives the headless native RPC against a -real isolated Cave, and retains one sanitized JSON report. +The trusted non-Node platform launcher is the runner for the Phase 1 read-only +desktop release gate. It packages the reviewed Chat production commit, +consumes the four frozen SDK tarballs, builds the locked Cave and Coven +authorities, drives Chat's headless native RPC, and retains one SDK #38 +platform-evidence record. -This gate is separate from `pnpm test:contract-canary`. The Phase 0 canary -checks the frozen SDK package and contract boundary. The Phase 1 harness checks -runtime discovery, pairing, credential handling, canonical reads, authority -binding, daemon identity, and cleanup. +This is separate from `pnpm test:contract-canary`. The canary checks the packed +SDK boundary; the Phase 1 runner checks runtime discovery, pairing, credential +handling, canonical reads, Coven identity, cleanup, and evidence compatibility. -## Prerequisites +## Exact inputs -- Node.js `24.18.1` -- pnpm `10.34.0` through Corepack -- Rust `1.95.0` -- clean local source repositories containing the revisions in - `phase1-conformance.lock.json` -- macOS for the CI keychain lane +`phase1-conformance.lock.json` pins: -Run: +- Chat production `20633346c444ded9e05ca5a3db45d74c28918d69`, the + expected missing-keychain trust-control successor to `dbbcf3a`; +- SDK package candidate `acc38488f00860d246c3c553375634d64806eabb`; +- Cave authority `e74078a147c084bd761d929654f0990df66ef99f`; +- Coven producer/client `721437b84026c042e431b0882dcd14fdb29ac07d`; +- Chat conformance driver support + `b24eaa7eb6886a5ef07af6b459ab57e0335fd20f`; +- SDK evidence contract and registry + `4736bf2e0d5b16272d79ecf7784c75f376b39b94`; +- manifest digest + `b8bfb62236fc8add4a9baad9f00e5401db15074a2d21fe2847a9158104cefb3c`; +- canonical package order, release/vendor paths, sizes, and SHA-256 digests. + +The evidence record names the SDK evidence-authority commit because the SDK +aggregator binds its committed registry to that commit. The package candidate +remains independently pinned by revision, manifest digest, and tarball bytes. +The runner verifies that the evidence-authority commit descends from the +candidate and that all four candidate source package identities match the +frozen manifest. It never rebuilds replacement per-platform SDK tarballs. + +Supported records are exactly `darwin-arm64`, `linux-x64`, and `win32-x64`. + +## Running + +Prerequisites are Node.js `24.18.1`, pnpm `10.34.0` through Corepack, Rust +`1.95.0`, an isolated native credential-store provider, and local repositories +containing the locked commits. The runner observes and rejects any Node, pnpm, +or Rust version drift before creating evidence. + +The locked Cave release build receives a fixed 6 GiB V8 old-space allowance +and a two-worker Next.js CPU profile. Inherited Node runtime options are still +rejected; these build-only limits keep the exact production build reproducible +on the release runner without changing the artifact contract. ```bash -pnpm test:phase1-conformance +/bin/sh scripts/phase1-conformance-launcher.sh "$(command -v node)" ``` -Override source repositories without moving them: +Source repositories can be overridden without moving them: ```bash -pnpm test:phase1-conformance -- \ +/bin/sh scripts/phase1-conformance-launcher.sh "$(command -v node)" \ --chat-root /path/to/chat \ - --sdk-root /path/to/sdk \ + --sdk-root /path/to/sdk-candidate \ + --sdk-evidence-root /path/to/current-sdk \ --cave-root /path/to/coven-cave \ --coven-root /path/to/coven ``` -The harness makes isolated detached clones at the exact locked revisions. It -rejects dirty, substituted, hidden-index, filtered, replacement-ref, -submodule, oversized, or timed-out checkouts before building anything. +Windows fleet execution uses the reviewed PowerShell launcher with the frozen +supervisor and absolute Node path. It requires the canonical PowerShell 7 +interpreter at `C:\Program Files\PowerShell\7\pwsh.exe` and rejects Windows +PowerShell 5.1: + +```powershell +.\scripts\phase1-conformance-launcher.ps1 ` + C:\OpenCoven\conformance\phase1-process-supervisor.exe ` + C:\reviewed-node\node.exe +``` + +The non-Node launchers clear preload, loader, npm/pnpm hook, and Node module +injection variables before Node starts. Direct Node or pnpm invocation is not +accepted for release evidence. CI verifies the POSIX launcher SHA-256 +`88e184d465eaf7bd6ce828dcc81ecadb11b6222f01576c56090060085820e7b2`; +the Windows launcher SHA-256 is +`99eea6108e59db9a0ac12368787fb6e6456e6af4f8cce09ee96ce117ca3f475e`. +Repository attributes require LF checkout bytes for both launchers on every +platform, including Windows; runtime digest checks never normalize line endings. + +The harness creates clean detached clones at exact revisions and rejects dirty, +substituted, hidden-index, filtered, replacement-ref, submodule, oversized, or +timed-out release checkouts. + +## Native Coven identity + +The runner starts the real locked Coven daemon and calls +`phase1-native-rpc` command `coven_health`. This crosses Chat's production +`coven.rs` self-process boundary and the producer-owned Rust `coven-client`, +the same trust boundary used by the desktop application. +Before building the conformance driver, the runner requires the production +adapter, RPC entrypoint, Cargo manifest, and Cargo lock bytes to match the +locked Chat production commit. The conformance-only Rust support is built from +the separate immutable descendant harness revision in the lock. The production +tree plus selected adapter/custody Git blobs and SHA-256 values are checked +before packaging. + +The runner never calls `coven daemon status`, duplicates Unix peer or Windows +pipe identity logic, or adds a pathname/shell fallback. Missing authority and +Unix malicious-home, symlink-socket, and wrong-mode cases also use +`coven_health` and must return only bounded `{ code, retryable }` diagnostics. + +## Assertions and SDK aggregation + +The runner reads the committed registry from the exact SDK evidence checkout +and verifies its digest. It emits every SDK assertion and every common plus +platform-specific Chat assertion in registry order. Missing, duplicate, +unexpected, reordered, failed, or skipped assertions prevent publication. -## Owned execution and evidence +Top-level `coverage` explicitly sets `cave`, `coven`, `sdk`, and `chat` to +`true`. `notCovered` contains only structured non-release scope IDs: +`cross-process-pairing`, `oauth-ui`, `remote-peer`, and `write-apis`. -Execution uses process-created mode-`0700` roots under the real OS temporary -directory. Cleanup verifies device, inode, real path, and an unpredictable -ownership stamp; it never follows symlinks. Only direct `ChildProcess` -instances started by the harness are terminated and reaped. +The retained record is validated by the exact locked SDK +`scripts/conformance-contract.mjs`. Aggregation runs on Darwin or Linux but +requires one record for all three platforms, including Windows. -`HOME`, XDG directories, temporary directories, the pnpm store, Cargo home, -Cave home, and Coven home all point inside process-owned roots. The harness -resolves the locked Rust toolchain binaries before isolation, places those -binaries first on `PATH`, and does not expose the operator's `RUSTUP_HOME` or -Cargo credentials to producer builds. +## Isolation and retained evidence -Caller-selected paths are never recursively deleted or overwritten. The only -retained file is: +Execution uses mode-`0700` process-owned roots under the real OS temporary +directory. Cleanup verifies root identity, does not follow symlinks, and +terminates and reaps only tracked child processes. Native RPCs own a dedicated +POSIX supervisor/process group or Windows Job Object. The live supervisor owns +the group identity until descendants are gone; Windows creates Cave suspended, +assigns the kill-on-close job, then resumes it. Forced cleanup therefore removes +Cave descendants without probing or signaling reused/unrelated process IDs. +All Darwin/Linux build, install, checkout, compiler, and tool commands use the +same supervisor and bounded status channel. Windows uses the standalone, +bin-only crate at `tools/phase1-process-supervisor`, whose Cargo graph excludes +Chat and its `cdylib`. Its canonical build is: + +```text +cd tools/phase1-process-supervisor && SOURCE_DATE_EPOCH=0 cargo build --target x86_64-pc-windows-gnu --release --locked +``` + +The frozen `phase1-process-supervisor.exe` is 333,824 bytes with SHA-256 +`372b3e8b5b860e0759da8fa10ddfb6ec338e26d83616254c816a456ae2e1b7c5`. +Its Darwin arm64 cross-toolchain is pinned to Homebrew core revision +`cd168d1fdc26f12e4ad64f358ff2dbec61ab7a57`, `mingw-w64 14.0.0_3`, +OCI bottle layer SHA-256 +`0d68ab737a8bbc8c63ac6ac7acc0695e2887c1169df9a4423f1180090079b1d5`, +and GNU ld `2.47.20260726`. CI verifies the exact OCI manifest annotation, +installed package version, and linker version before building. +Fleet provisioning installs it at +`C:\OpenCoven\conformance\phase1-process-supervisor.exe`. The harness requires +that exact absolute path and digest before its first Git, pnpm, Cargo, build, or +tool command. The local frozen artifact is retained outside Git at +`files/phase1-process-supervisor-ff415b6/win32-x64/phase1-process-supervisor.exe` +in this implementation session's artifact area. + +Windows command lookup accepts only regular `.exe`, `.cmd`, `.bat`, or `.com` +files, follows case-insensitive `PATHEXT` order, rejects ambiguous or relative +search paths, and handles explicit extensions without fallback. Batch shims run +only through the fixed absolute `%ComSpec% /d /s /c` boundary inside the +verified Job Object supervisor. Paths and arguments containing command +metacharacters, expansion markers, quotes, CR/LF, or NUL are rejected before +launch, and accepted tokens use one canonical quoted command line. Corepack is +resolved instead to the exact sibling `node.exe` and +`node_modules/corepack/dist/corepack.js`, avoiding batch interpretation for all +pnpm operations. +The logical Windows command name `corepack` is intercepted before generic +`PATH`/`PATHEXT` search; `corepack.exe`, `.com`, `.cmd`, and `.bat` requests are +forbidden, so PATH-precedence shims are never considered. + +The caller-side script performs bootstrap only: it checks out the exact locked +harness revision and re-executes that detached runner. Before authority work, +the verified runner checks its own realpath, HEAD, tree, and the locked +blob/SHA-256 set for every executable harness module. Intentional native +conformance deltas from the production Chat commit are separately allowlisted +by exact path, blob, and digest. + +On POSIX the supervisor reports through a private fd 3 pipe that is not inherited +by the target; no status path or capability enters the target environment. +Timeout and output-limit cancellation signal the live supervisor, which remains +the process-group leader through bounded TERM-to-KILL descendant cleanup and is +then reaped. + +`HOME`, XDG directories, temporary directories, pnpm store, Cargo home, Cave +home, and Coven home are isolated for the ordinary harness, checkout, +packaging, and non-native work. Darwin native-RPC subprocesses are the +intentional exception: both the main native scenario and emergency credential +cleanup receive the caller/operator `HOME`, so that the production macOS native +keychain adapter performs its native lookup in that context. The runner +therefore does not claim that the operator home is untouched. It continues to +isolate the authority homes used by the scenario, all execution artifact roots, +and the XDG, temporary, pnpm, Cargo, Git/config, proxy, terminal-prompt, and +process-control environment used by the harness; it also fingerprints the +bounded operator Cave and Coven authority state before and after the run. + +Cave pairing uses the production native keyring, restarts the RPC process, +reuses the credential, then deletes it and proves the exact +`ai.opencoven.chat` / `cave-client-v1:` account is missing again. +Credential operations are constrained to that dedicated, labeled account: a +separate conformance-only command addresses it only through a native-issued +one-shot reservation handle and capability. Its marker lives in a dedicated +conformance cleanup keyring service, binds the native-observed UUID, target +account, schema, run identity, harness identity, and capability verifier, and +is removed only after both target and marker verify `NoEntry`. The cleanup +command accepts no caller-selected UUID or account and requires no discovery +handle. Pairing cannot begin until the native response is fully validated; +command failure, malformed output, or a lost response invokes the same-process +prepared-marker cancellation command before failing closed. The native RPC +keeps the newly created marker behind an RAII output transaction: +serialization, framing, write, flush, or closed-output failure synchronously +deletes and verifies the marker before the process exits. The macOS CI run +repeats this through the real native-RPC subprocess and an isolated disposable +production keychain, then probes both services, verifies that replay cleanup +returns the bounded missing-credential diagnostic, and verifies the active +disposable keychain. + +Restart reuse transfers cleanup ownership through a recoverable two-phase +protocol under the production keyring mutation lock. `begin_adopt` records a +pending caller-generated successor token while the predecessor remains active. +After the begin response is validated, idempotent `commit_adopt` promotes that +token; lost begin responses are aborted and lost commit responses are retried +with the retained token. Only then may the predecessor exit. Stale-owner cleanup +returns an explicit failure and cannot report deletion; the successor remains +armed until final one-time cleanup. +Linux and Windows bind evidence to the process-owned random Cave identity and +the exact production-keyring account rather than trusting an environment +assertion. The runner fingerprints bounded operator Cave and Coven authority +state. The `cave-home` evidence ID covers bounded top-level +metadata plus the separately reported, content-hashed `projects.json` control. +The `coven-home` evidence ID covers a bounded shallow manifest of top-level +Coven-home metadata plus content or metadata for the reviewed daemon authority +controls (`daemon.json`, lifecycle and state locks, the reset transaction +marker, and `coven.sock`). Neither fingerprint reads personal memory, journal, +research, conversation, diagnostic, or session trees. Any covered change fails +evidence creation. + +The SDK schema records observed Node and pnpm values directly. Its closed shape +has no Rust, package-candidate, manifest, or harness fields, so the runner +binds those exact validated values through a fixed assertion-ID to +diagnostic-ID mapping. Chat pre-validation rejects missing, duplicated, +swapped, unrelated, or incorrect bindings before the unmodified SDK parser +accepts the compatible record; the SDK parser does not interpret those values +itself. + +The only retained file is: ```text test-results/phase1-conformance/report.json ``` -The destination must not already exist. The report is copied atomically only -after it is complete, unchanged across the scan, and the whole evidence root -passes the secret scan. - -## Required assertions - -Every ID must occur exactly once. Missing, duplicate, unexpected, or skipped -IDs fail the run. - -1. `phase1.missing-cave.validated-launch` -2. `phase1.pairing.create-pending-approve-exchange` -3. `phase1.pairing.denial` -4. `phase1.pairing.expiry` -5. `phase1.pairing.wrong-secret-replay` -6. `phase1.pairing.failure-budget-retry-after` -7. `phase1.credential.restart-reuse` -8. `phase1.credential.revocation-repair` -9. `phase1.compat.api-major-min-client` -10. `phase1.hpke.endpoint-takeover` -11. `phase1.reads.bounded-canonical` -12. `phase1.reads.stale-generation-cursor-reconciliation` -13. `phase1.coven.same-user-identity` -14. `phase1.native.missing-keychain-trust` -15. `phase1.operator.homes-credentials-untouched` - -The runner uses Cave's packaged socket conformance for expiry, wrong-secret, -shared-budget, `Retry-After`, replay, and HPKE listener-takeover evidence. The -locked Chat `phase1-native-rpc` drives discovery, validated launch, pairing, -credential reuse, revocation, and bounded reads. The locked Coven CLI runs a -real foreground daemon and authenticates its same-user Unix-socket or Windows -named-pipe status probe. - -The native trust assertion launches a separate `phase1-native-rpc` subprocess -with `OPENCOVEN_PHASE1_CONFORMANCE_NATIVE_PROVIDER_PRESET=missing-keychain-trust`. -This finite, feature-gated preset selects the production `NativeKeyring` -credential-custody boundary and rejects provider access as -`secure_store_unavailable`; omitting the variable preserves the shared-memory -conformance custody used by all other Phase 1 scenarios. - -## Secret scan and report schema - -The scanner rejects pairing secrets, bearers, authorization headers, raw -keychain values, protected request or response plaintext, user prompts, -message bodies, attachments, private paths, and socket handles. Symlinks, -non-JSON files, oversized trees, unknown fields, and unapproved identifiers -also fail. - -The retained schema allows only: - -- `schemaVersion`, `completed`, and pass/fail/block status; -- OS, architecture, and tool versions; -- exact Chat, SDK, Cave, and Coven revisions; -- packaged artifact SHA-256 digests; -- required assertion IDs and approved diagnostic IDs; -- numeric pass/fail/block totals. - -Re-scan an existing evidence directory with: +The destination must not exist. Publication occurs only after SDK validation +and the local secret scan. CI restores and deletes its isolated keychain before +uploading the record, and cleanup failure blocks upload and the gate. The +record contains no operator paths, credentials, bearers, pairing secrets, +prompts, message bodies, attachments, command output, socket handles, or +private causes. + +Re-scan retained evidence with: ```bash node ./scripts/phase1-artifact-secret-scan.mjs \ --artifact-root ./test-results/phase1-conformance ``` -## Failure interpretation - -- `failed` means a supported real path ran and did not satisfy its assertion. -- `blocked` means the exact locked producer has no safe external control for - the required scenario. The harness does not replace that absence with a mock. -- `passed` means the packaged path ran against the locked authority and the - retained evidence scanned clean. -- A failed or timed-out producer subprocess is an infrastructure failure. - Partial stdout is never accepted as passing assertion evidence. - -At this revision the exact locked native RPC has no mode that uses the real OS -keychain and Cave has no release-mode override for API-major or -minimum-client incompatibility. Those assertions therefore block with -`phase1.producer.native-trust-fixture-unavailable` and -`phase1.producer.compatibility-control-unavailable` until producer support is -reviewed and locked. - -The production surface remains read-only. Demo-only write interactions are not -part of this gate and are not evidence for a production mutation path. +A producer failure, timeout, incomplete assertion set, or isolation/redaction +mismatch fails without publishing partial evidence. diff --git a/docs/superpowers/plans/2026-08-15-opencoven-chat-program-tracking.md b/docs/superpowers/plans/2026-08-15-opencoven-chat-program-tracking.md index 46bd27e..08feff3 100644 --- a/docs/superpowers/plans/2026-08-15-opencoven-chat-program-tracking.md +++ b/docs/superpowers/plans/2026-08-15-opencoven-chat-program-tracking.md @@ -204,10 +204,10 @@ unrelated beads without surface labels. Program-specific counts above isolate this graph from those pre-existing warnings. Phase 1 gate evidence is the completed, secret-scanned -`test-results/phase1-conformance/report.json` produced by -`pnpm test:phase1-conformance` at the revisions in -`phase1-conformance.lock.json`. A `blocked` or `failed` report is evidence of an -open gate, not permission to close it. +`test-results/phase1-conformance/report.json` produced through +`/bin/sh scripts/phase1-conformance-launcher.sh "$(command -v node)"` at the revisions in +`phase1-conformance.lock.json`. It is a complete SDK #38 platform record; a +failed or incomplete run publishes no evidence and leaves the gate open. ## Bead Evidence Template diff --git a/docs/superpowers/plans/2026-08-20-phase-1d-real-authority-conformance.md b/docs/superpowers/plans/2026-08-20-phase-1d-real-authority-conformance.md index dfa2bec..262cbef 100644 --- a/docs/superpowers/plans/2026-08-20-phase-1d-real-authority-conformance.md +++ b/docs/superpowers/plans/2026-08-20-phase-1d-real-authority-conformance.md @@ -29,7 +29,7 @@ - `docs/phase1-conformance.md` — operator guide for running and reading the conformance harness. ### Modify -- `package.json` — add `test:phase1-conformance`. +- trusted non-Node launcher scripts — invoke the Phase 1 runner without Node preload exposure. - `scripts/contract-canary.mjs`, `scripts/owned-temp-directory.mjs`, and `src/contract-canary-artifact-root.test.ts` — share verified helper logic where it makes the Phase 1 harness safer, without replacing the Phase 0 canary. - `contract-canary.lock.json` documentation references in `README.md` and `docs/developer-toolchains.md` — explain the relationship between the Phase 0 canary lock and the new Phase 1 lock. - `.github/workflows/ci.yml` — add the required Phase 1 conformance job with SHA-pinned actions. @@ -109,7 +109,7 @@ export function readPhase1ConformanceLock(lockPath = resolve(root, 'phase1-confo Implementation requirements: - reject non-40-character SHAs; - require clean exact checkouts for the locked Chat/SDK/Cave/Coven roots; -- add `test:phase1-conformance` to `package.json`, but do not remove or repurpose `test:contract-canary`. +- add trusted non-Node launchers, but do not remove or repurpose `test:contract-canary`. - [ ] **Step 5: Run the focused lock tests again and confirm they pass** @@ -377,7 +377,7 @@ Expected: the spec guards do not yet mention the new required conformance job or security default-keychain -d user -s "$keychain" security list-keychains -d user -s "$keychain" echo "PHASE1_TEST_KEYCHAIN=$keychain" >> "$GITHUB_ENV" - - run: pnpm test:phase1-conformance + - run: /bin/sh scripts/phase1-conformance-launcher.sh "$(command -v node)" - name: Remove isolated test keychain if: always() shell: bash @@ -409,7 +409,7 @@ pnpm cargo:check pnpm cargo:test pnpm cargo:clippy pnpm app:build -pnpm test:phase1-conformance +/bin/sh scripts/phase1-conformance-launcher.sh "$(command -v node)" ``` Expected: all commands pass. Do not run any publish or release workflow. @@ -439,7 +439,7 @@ gh -R OpenCoven/chat pr create --base main --head phase1d/real-authority-conform - pnpm cargo:test - pnpm cargo:clippy - pnpm app:build -- pnpm test:phase1-conformance" +- /bin/sh scripts/phase1-conformance-launcher.sh \"$(command -v node)\"" gh -R OpenCoven/chat pr checks --watch gh -R OpenCoven/chat pr merge --squash --delete-branch=false cd /Users/buns/Documents/GitHub/OpenCoven/coven-cave @@ -447,8 +447,8 @@ bd ready --json --limit 0 bd show cave-0prpu bd show cave-23nmv bd show cave-fz01p -bd close cave-0prpu --reason "Merged phase1d/real-authority-conformance after the full Chat validation matrix and pnpm test:phase1-conformance." -bd close cave-23nmv --reason "Merged phase1d/real-authority-conformance after the full Chat validation matrix and pnpm test:phase1-conformance." +bd close cave-0prpu --reason "Merged phase1d/real-authority-conformance after the full trusted-launcher Chat validation matrix." +bd close cave-23nmv --reason "Merged phase1d/real-authority-conformance after the full trusted-launcher Chat validation matrix." bd close cave-fz01p --reason "All Phase 1 implementation, integration, and conformance PRs merged with green required checks and retained-artifact secret scans." ``` diff --git a/docs/superpowers/plans/2026-08-30-grok-inspired-agent-inspector.md b/docs/superpowers/plans/2026-08-30-grok-inspired-agent-inspector.md new file mode 100644 index 0000000..1750f0c --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-grok-inspired-agent-inspector.md @@ -0,0 +1,1164 @@ +# Grok-Inspired Agent Inspector Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Redesign the chat demo's right-side inspector as an OpenCoven-branded, Grok-inspired profile stack with a stable identity hero, derived metrics, contextual cards, and a compact settings footer. + +**Architecture:** Keep `ChatInspector` as the local state owner and extract small presentation components inside the same file for the hero, metrics, and semantic card groups. Move the agent view to a three-row shell with one scrolling profile region, while leaving the existing App settings secondary view and shell-level responsive behavior intact. + +**Tech Stack:** React 19, TypeScript 6, CSS, Vitest 4, Testing Library, Playwright + +--- + +## File Map + +| File | Responsibility | +| --- | --- | +| `src/demo/chat-inspector.tsx` | Render the profile hero, derived snapshot metrics, semantic Overview/Access/Activity cards, tabs, and App settings navigation | +| `src/demo/chat-inspector.test.tsx` | Verify profile content, derived values, tab stability, keyboard behavior, and missing-data states | +| `src/demo/chat-demo.css` | Implement the three-row profile layout, ward orbit, cards, responsive compression, focus states, and reduced-motion behavior | +| `src/demo/chat-shell.test.tsx` | Guard the inspector's structural CSS, compact footer, narrow layout, and reduced-motion rules | + +## Execution Prerequisite + +The current branch may contain separate in-progress left-sidebar work in +`src/demo/chat-demo.tsx`, `src/demo/chat-demo.css`, +`src/demo/chat-shell.test.tsx`, `e2e/app.smoke.spec.ts`, `package.json`, and +`pnpm-lock.yaml`. Do not discard or accidentally include that work in an +inspector commit. Execute this plan in a dedicated worktree after the +left-sidebar changes are committed, or wait until their owner has completed +them and confirm `git status --short` is clean before starting Task 1. + +### Task 1: Add the Stable Profile Hero and Snapshot + +**Files:** +- Modify: `src/demo/chat-inspector.test.tsx:1-65` +- Modify: `src/demo/chat-inspector.tsx:21-389` + +- [ ] **Step 1: Write failing profile and metric tests** + +Update the Testing Library import, add the Echo fixture, and add these tests near the top of the `ChatInspector` suite: + +```tsx +import { fireEvent, render, screen, within } from '@testing-library/react'; + +import { ChatInspector } from './chat-inspector'; +import { MOCK_FAMILIARS } from './mock-familiars'; + +const astra = MOCK_FAMILIARS.find((familiar) => familiar.id === 'astra'); +const echo = MOCK_FAMILIARS.find((familiar) => familiar.id === 'echo'); + +describe('ChatInspector', () => { + it('keeps the active familiar profile and snapshot visible across tabs', () => { + render(); + + const profile = screen.getByLabelText('Astra profile'); + const snapshot = screen.getByLabelText('Agent snapshot'); + + expect(within(profile).getByRole('heading', { name: 'Astra' })).toBeVisible(); + expect(profile).toHaveTextContent('Research and synthesis'); + expect(profile).toHaveTextContent('Available'); + expect(profile).toHaveTextContent( + 'To map unfamiliar territory so a decision can be made on evidence.', + ); + expect(snapshot).toHaveTextContent('100%'); + expect(snapshot).toHaveTextContent('412'); + expect(snapshot).toHaveTextContent('2 hours ago'); + + fireEvent.click(screen.getByRole('tab', { name: 'Access' })); + + expect(profile).toBeVisible(); + expect(snapshot).toBeVisible(); + expect(screen.getByRole('tabpanel', { name: 'Access' })).toBeVisible(); + }); + + it('uses explicit missing-data labels instead of fabricated metrics', () => { + render(); + + const snapshot = screen.getByLabelText('Agent snapshot'); + + expect(snapshot).toHaveTextContent('No runs'); + expect(snapshot).toHaveTextContent('Off'); + expect(snapshot).toHaveTextContent('Unavailable'); + expect(screen.queryByText('0%')).not.toBeInTheDocument(); + }); +``` + +Keep the existing keyboard, App settings, and unavailable-familiar tests below +these new tests. + +- [ ] **Step 2: Run the focused tests and confirm the new expectations fail** + +Run: + +```bash +corepack pnpm@10.34.0 --ignore-workspace exec vitest run src/demo/chat-inspector.test.tsx +``` + +Expected: FAIL because `Astra profile` and `Agent snapshot` do not exist yet. + +- [ ] **Step 3: Add the profile and snapshot components** + +Add these components after `SettingSwitch` in +`src/demo/chat-inspector.tsx`: + +```tsx +function InspectorHero({ familiar }: { familiar: MockFamiliar | undefined }) { + if (!familiar) { + return ( +
+ +

Agent unavailable

+

Choose another conversation

+

Select a conversation to see its attached familiar.

+
+ ); + } + + return ( +
+ +

{familiar.name}

+

{familiar.role}

+

+

+

{familiar.soul.purpose}

+
+ ); +} + +function InspectorMetrics({ + analytics, + familiar, +}: { + analytics: CaveExecutionWindow | undefined; + familiar: MockFamiliar; +}) { + const metrics = [ + { + label: 'Completion', + value: analytics && analytics.attempts > 0 ? formatSuccessRate(analytics) : 'No runs', + }, + { + label: 'Memory', + value: familiar.memory ? familiar.memory.entries.toLocaleString() : 'Off', + }, + { + label: 'Last active', + value: familiar.memory?.lastWritten ?? 'Unavailable', + }, + ] as const; + + return ( +
+ {metrics.map((metric) => ( +
+
{metric.label}
+
{metric.value}
+
+ ))} +
+ ); +} +``` + +- [ ] **Step 4: Replace the agent-view shell** + +In `ChatInspector`, keep the existing state, tab keyboard logic, App settings +branch, and analytics lookup. Replace only the final agent-view `return` with: + +```tsx + return ( +
+
+ Agent profile + +
+ +
+ + {familiar ? : null} + +
+ {TABS.map((name, index) => ( + + ))} +
+ +
+ {!familiar ? ( +

Choose a conversation to see its agent.

+ ) : null} + {familiar && tab === 'overview' ? : null} + {familiar && tab === 'access' ? : null} + {familiar && tab === 'activity' ? : null} +
+
+ + +
+ ); +``` + +- [ ] **Step 5: Run the focused tests** + +Run: + +```bash +corepack pnpm@10.34.0 --ignore-workspace exec vitest run src/demo/chat-inspector.test.tsx +``` + +Expected: PASS with all profile, metric, keyboard, App settings, and unavailable +state tests passing. + +- [ ] **Step 6: Commit the profile structure** + +```bash +git add src/demo/chat-inspector.tsx src/demo/chat-inspector.test.tsx +git commit -m "feat: add agent inspector profile stack" -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +### Task 2: Group Overview, Access, and Activity Into Context Cards + +**Files:** +- Modify: `src/demo/chat-inspector.test.tsx:9-75` +- Modify: `src/demo/chat-inspector.tsx:21-154` + +- [ ] **Step 1: Add failing card-structure assertions** + +In the first profile test, replace the block beginning with the existing +`fireEvent.click(screen.getByRole('tab', { name: 'Access' }))` through the end +of that test with: + +```tsx + const overview = screen.getByRole('tabpanel', { name: 'Overview' }); + expect(within(overview).getByRole('heading', { name: 'Chat context' })).toBeVisible(); + expect(within(overview).getByRole('heading', { name: 'Contract' })).toBeVisible(); + + fireEvent.click(screen.getByRole('tab', { name: 'Access' })); + + const access = screen.getByRole('tabpanel', { name: 'Access' }); + expect(within(access).getByRole('heading', { name: 'Authority' })).toBeVisible(); + expect(within(access).getByRole('heading', { name: 'Contract' })).toBeVisible(); + expect(profile).toBeVisible(); + expect(snapshot).toBeVisible(); + }); +``` + +Add a separate Activity assertion: + +```tsx + it('groups recent activity into a labeled card', () => { + render(); + + fireEvent.click(screen.getByRole('tab', { name: 'Activity' })); + + const activity = screen.getByRole('tabpanel', { name: 'Activity' }); + expect(within(activity).getByRole('heading', { name: 'Last 7 days' })).toBeVisible(); + expect(activity).toHaveTextContent('Median duration'); + expect(activity).toHaveTextContent('Tool calls'); + }); +``` + +- [ ] **Step 2: Run the focused tests and confirm the card headings fail** + +Run: + +```bash +corepack pnpm@10.34.0 --ignore-workspace exec vitest run src/demo/chat-inspector.test.tsx +``` + +Expected: FAIL because the current tab panels do not contain the approved card +headings. + +- [ ] **Step 3: Add the semantic card wrapper** + +Add this helper after `SettingRow`: + +```tsx +function InspectorCard({ + children, + label, +}: { + children: ReactNode; + label: string; +}) { + return ( +
+

{label}

+ {children} +
+ ); +} +``` + +- [ ] **Step 4: Replace the Overview component** + +Replace `Overview` with: + +```tsx +function Overview({ familiar }: { familiar: MockFamiliar }) { + const report = contractReport(familiar); + const passed = report.every((property) => property.pass); + + return ( + <> + + + + + + + + + + ); +} +``` + +- [ ] **Step 5: Replace the Access component** + +Replace `Access` with: + +```tsx +function Access({ familiar }: { familiar: MockFamiliar }) { + const report = contractReport(familiar); + const passed = report.every((property) => property.pass); + + return ( + <> + +
+ May do + +
+
+ Must ask + +
+
+ Editable paths + +
+
+ + + + + ); +} +``` + +- [ ] **Step 6: Replace the Activity component** + +Replace `Activity` with: + +```tsx +function Activity({ analytics }: { analytics: CaveExecutionWindow | undefined }) { + if (!analytics || analytics.attempts === 0) { + return ( + +
+ No recent runs + Activity will appear after this agent completes work. +
+
+ ); + } + + return ( + + + + + + + ); +} +``` + +- [ ] **Step 7: Run the focused tests** + +Run: + +```bash +corepack pnpm@10.34.0 --ignore-workspace exec vitest run src/demo/chat-inspector.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 8: Commit the contextual cards** + +```bash +git add src/demo/chat-inspector.tsx src/demo/chat-inspector.test.tsx +git commit -m "feat: group inspector context into cards" -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +### Task 3: Implement the Profile-Stack Visual System + +**Files:** +- Modify: `src/demo/chat-shell.test.tsx:130-146` +- Modify: `src/demo/chat-demo.css:1107-1300` + +- [ ] **Step 1: Add a failing stylesheet contract test** + +Add this test after `keeps liquid glass restrained to shell chrome`: + +```tsx + it('uses a compact three-row profile stack in the agent inspector', () => { + const css = readFileSync(resolve(process.cwd(), 'src/demo/chat-demo.css'), 'utf8'); + + expect(css).toMatch( + /\.chat-demo \.chat-inspector\s*{[^}]*grid-template-rows: auto minmax\(0, 1fr\) auto/s, + ); + expect(css).toMatch(/\.chat-demo \.inspector-scroll\s*{[^}]*overflow-y: auto/s); + expect(css).toContain('.chat-demo .inspector-hero'); + expect(css).toContain('.chat-demo .inspector-orbit'); + expect(css).toContain('conic-gradient'); + expect(css).toContain('.chat-demo .inspector-metrics'); + expect(css).toContain('.chat-demo .inspector-card'); + expect(css).toMatch( + /\.chat-demo \.inspector-app-settings\s*{[^}]*margin: 8px[^}]*padding: 8px 9px/s, + ); + }); +``` + +- [ ] **Step 2: Run the stylesheet test and confirm it fails** + +Run: + +```bash +corepack pnpm@10.34.0 --ignore-workspace exec vitest run src/demo/chat-shell.test.tsx +``` + +Expected: FAIL because the current inspector has no profile-stack classes and +still declares four rows. + +- [ ] **Step 3: Replace the agent inspector layout and identity styles** + +In the contextual inspector section of `src/demo/chat-demo.css`, replace the +current `.chat-inspector`, `.inspector-identity`, `.inspector-mark`, +`.inspector-who`, heading, and role rules with: + +```css +.chat-demo .chat-inspector { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + height: 100%; + min-width: 300px; + overflow: hidden; + background: var(--demo-glass); + box-shadow: inset 1px 0 rgba(255, 255, 255, 0.025); + backdrop-filter: blur(24px) saturate(140%); +} + +.chat-demo .inspector-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px 6px; + color: var(--demo-muted); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.075em; + text-transform: uppercase; +} + +.chat-demo .inspector-scroll { + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding-bottom: 12px; + background: radial-gradient( + circle at 50% 0, + rgba(147, 134, 208, 0.12), + transparent 220px + ); +} + +.chat-demo .inspector-hero { + display: grid; + justify-items: center; + padding: 16px 18px 18px; + text-align: center; +} + +.chat-demo .inspector-orbit { + display: grid; + width: 80px; + height: 80px; + place-items: center; + padding: 2px; + border-radius: 27px; + background: conic-gradient( + from -32deg, + var(--demo-violet) 0 82%, + rgba(255, 255, 255, 0.1) 82% 100% + ); + box-shadow: 0 18px 38px rgba(0, 0, 0, 0.28); + transition: + opacity 180ms cubic-bezier(0.22, 0.72, 0, 1), + filter 180ms cubic-bezier(0.22, 0.72, 0, 1); +} + +.chat-demo .inspector-orbit.is-available { + background: conic-gradient( + from -32deg, + var(--demo-violet) 0 82%, + var(--demo-ready) 82% 92%, + rgba(255, 255, 255, 0.1) 92% 100% + ); +} + +.chat-demo .inspector-orbit.is-offline, +.chat-demo .inspector-orbit.is-unavailable { + background: conic-gradient( + from -32deg, + rgba(147, 134, 208, 0.44) 0 76%, + rgba(255, 255, 255, 0.08) 76% 100% + ); + filter: saturate(0.7); +} + +.chat-demo .inspector-mark { + display: grid; + width: 72px; + height: 72px; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 24px; + background: #202127; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.1); + font-size: 28px; +} + +.chat-demo .inspector-hero h2 { + overflow: hidden; + max-width: 100%; + margin: 10px 0 0; + font-size: 20px; + font-weight: 680; + letter-spacing: -0.035em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-demo .inspector-role { + margin: 2px 0 0; + color: var(--demo-muted); + font-size: 11px; +} + +.chat-demo .inspector-app-header h2 { + overflow: hidden; + margin: 0; + font-size: 15px; + font-weight: 650; + letter-spacing: -0.015em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-demo .inspector-app-header p { + overflow: hidden; + margin: 0; + color: var(--demo-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-demo .inspector-status { + display: flex; + gap: 6px; + align-items: center; + margin: 8px 0 0; + color: rgba(244, 243, 247, 0.82); + font-size: 10px; +} + +.chat-demo .inspector-status > span { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--demo-violet); +} + +.chat-demo .inspector-status.is-available > span { + background: var(--demo-ready); + box-shadow: 0 0 0 4px rgba(130, 179, 142, 0.1); +} + +.chat-demo .inspector-status.is-offline > span { + background: var(--demo-muted); + box-shadow: none; +} + +.chat-demo .inspector-purpose { + max-width: 270px; + margin: 11px 0 0; + color: rgba(244, 243, 247, 0.78); + font-size: 12px; + line-height: 1.5; +} +``` + +- [ ] **Step 4: Add metric, tab-panel, and card styling** + +Keep the existing segmented-tab rules, then add or replace the relevant panel, +row, detail, and empty-state rules with: + +```css +.chat-demo .inspector-metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; + margin: 0 12px 12px; +} + +.chat-demo .inspector-metric { + display: grid; + gap: 3px; + min-width: 0; + padding: 9px 5px; + border: 1px solid rgba(255, 255, 255, 0.055); + border-radius: 11px; + background: rgba(255, 255, 255, 0.035); + text-align: center; +} + +.chat-demo .inspector-metric dt { + order: 2; + overflow: hidden; + color: var(--demo-muted); + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-demo .inspector-metric dd { + order: 1; + overflow: hidden; + margin: 0; + color: var(--demo-text); + font-size: 13px; + font-weight: 650; + letter-spacing: -0.02em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-demo .inspector-panel { + min-height: 0; + overflow: visible; + padding: 10px 12px 4px; + animation: inspector-panel-in 170ms cubic-bezier(0.22, 0.72, 0, 1); +} + +@keyframes inspector-panel-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.chat-demo .inspector-card { + margin-top: 9px; + padding: 11px 12px 4px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 14px; + background: rgba(255, 255, 255, 0.032); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.025); +} + +.chat-demo .inspector-card:first-child { + margin-top: 0; +} + +.chat-demo .inspector-card-title { + margin: 0 0 6px; + color: var(--demo-muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.chat-demo .inspector-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + min-height: 42px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.chat-demo .inspector-card .inspector-row:first-of-type, +.chat-demo .inspector-card .inspector-detail:first-of-type { + border-top: 0; +} + +.chat-demo .inspector-row-copy { + display: grid; + gap: 2px; + min-width: 0; +} + +.chat-demo .inspector-row-copy strong, +.chat-demo .inspector-detail > strong { + font-size: 12px; + font-weight: 590; +} + +.chat-demo .inspector-row-copy span { + overflow: hidden; + color: var(--demo-muted); + font-size: 10px; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-demo .inspector-value { + color: #b7b0cc; + font-size: 11px; + text-align: right; +} + +.chat-demo .inspector-detail { + display: grid; + gap: 6px; + padding: 10px 0; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.chat-demo .inspector-detail-list { + display: grid; + gap: 4px; + margin: 0; + padding-left: 17px; + color: var(--demo-muted); + font-size: 11px; + line-height: 1.4; +} + +.chat-demo .inspector-empty { + display: grid; + gap: 5px; + margin: 0; + padding: 12px 0 16px; + color: var(--demo-muted); + font-size: 12px; + line-height: 1.45; +} + +.chat-demo .inspector-empty strong { + color: var(--demo-text); +} + +.chat-demo .app-settings-view { + grid-template-rows: auto auto auto minmax(0, 1fr); +} + +.chat-demo .app-settings-view .inspector-panel { + overflow-y: auto; + animation: none; +} +``` + +Keep the current compact `.inspector-app-settings` rule, including `margin: +8px` and `padding: 8px 9px`. + +- [ ] **Step 5: Add inspector focus styling** + +Add this rule beside the inspector button styles: + +```css +.chat-demo .inspector-toolbar button:focus-visible, +.chat-demo .inspector-tabs button:focus-visible, +.chat-demo .inspector-app-settings:focus-visible, +.chat-demo .inspector-back:focus-visible, +.chat-demo .inspector-copy:focus-visible, +.chat-demo .inspector-switch:focus-visible { + outline: 2px solid var(--demo-violet-line); + outline-offset: 2px; +} +``` + +- [ ] **Step 6: Run component and shell tests** + +Run: + +```bash +corepack pnpm@10.34.0 --ignore-workspace exec vitest run \ + src/demo/chat-inspector.test.tsx \ + src/demo/chat-shell.test.tsx +``` + +Expected: PASS. + +- [ ] **Step 7: Commit the visual system** + +```bash +git add src/demo/chat-demo.css src/demo/chat-shell.test.tsx +git commit -m "feat: style agent inspector profile stack" -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +### Task 4: Add Narrow-Panel and Reduced-Motion Guards + +**Files:** +- Modify: `src/demo/chat-shell.test.tsx:130-170` +- Modify: `src/demo/chat-demo.css:2161-2202` + +- [ ] **Step 1: Extend the stylesheet contract for responsive and motion rules** + +Add these variables and expectations to the profile-stack stylesheet test: + +```tsx + const narrowStart = css.lastIndexOf('@media (max-width: 820px)'); + const reducedMotionStart = css.lastIndexOf('@media (prefers-reduced-motion: reduce)'); + const narrowStyles = css.slice(narrowStart, reducedMotionStart); + const reducedMotionStyles = css.slice(reducedMotionStart); + + expect(narrowStyles).toContain('.chat-demo .inspector-hero'); + expect(narrowStyles).toContain('.chat-demo .inspector-metrics'); + expect(reducedMotionStyles).toContain('.chat-demo .inspector-panel'); + expect(reducedMotionStyles).toContain('.chat-demo .inspector-orbit'); +``` + +- [ ] **Step 2: Run the shell test and confirm the new assertions fail** + +Run: + +```bash +corepack pnpm@10.34.0 --ignore-workspace exec vitest run src/demo/chat-shell.test.tsx +``` + +Expected: FAIL because the new responsive compression and reduced-motion +selectors have not been added. + +- [ ] **Step 3: Add narrow-panel compression** + +Inside the existing `@media (max-width: 820px)` block, add: + +```css + .chat-demo .inspector-hero { + padding: 12px 14px 14px; + } + + .chat-demo .inspector-orbit { + width: 72px; + height: 72px; + border-radius: 24px; + } + + .chat-demo .inspector-mark { + width: 64px; + height: 64px; + border-radius: 21px; + font-size: 25px; + } + + .chat-demo .inspector-metrics { + gap: 5px; + } + + .chat-demo .inspector-metric { + padding-inline: 3px; + } +``` + +- [ ] **Step 4: Extend reduced-motion coverage** + +Add the new animated inspector elements to the final reduced-motion rule: + +```css +@media (prefers-reduced-motion: reduce) { + .chat-demo, + .chat-demo .sidebar, + .chat-demo .chat-inspector, + .chat-demo .glass-control, + .chat-demo .inspector-panel, + .chat-demo .inspector-orbit, + .chat-demo .inspector-switch > span { + animation: none; + transition: none; + } +} +``` + +- [ ] **Step 5: Run the focused tests and production build** + +Run: + +```bash +corepack pnpm@10.34.0 --ignore-workspace exec vitest run \ + src/demo/chat-inspector.test.tsx \ + src/demo/chat-shell.test.tsx && +corepack pnpm@10.34.0 --ignore-workspace build +``` + +Expected: all focused tests PASS and Vite reports a successful production +build. + +- [ ] **Step 6: Commit responsive and motion support** + +```bash +git add src/demo/chat-demo.css src/demo/chat-shell.test.tsx +git commit -m "fix: preserve inspector accessibility across sizes" -m "Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>" +``` + +### Task 5: Verify Rendered Geometry and Visual States + +**Files:** +- Generated and ignored: `.artifacts/inspector-redesign/*.png` + +- [ ] **Step 1: Build and start the preview server** + +Run in one persistent shell: + +```bash +mkdir -p .artifacts/inspector-redesign +corepack pnpm@10.34.0 --ignore-workspace build +corepack pnpm@10.34.0 --ignore-workspace preview \ + --host 127.0.0.1 \ + --port 4174 \ + --strictPort \ + > .artifacts/inspector-redesign/preview.log 2>&1 & +PREVIEW_PID=$! +echo "$PREVIEW_PID" > .artifacts/inspector-redesign/preview.pid +``` + +Expected: the preview server remains running at `http://127.0.0.1:4174`. + +- [ ] **Step 2: Capture desktop, compact desktop, and narrow overlay states** + +Run from the same persistent shell: + +```bash +node --input-type=module <<'EOF' +import { chromium } from '@playwright/test'; +import { mkdir } from 'node:fs/promises'; + +await mkdir('.artifacts/inspector-redesign', { recursive: true }); + +const browser = await chromium.launch({ headless: true }); +const states = [ + { name: 'desktop', width: 1440, height: 900, openInspector: false }, + { name: 'compact-desktop', width: 1024, height: 768, openInspector: false }, + { name: 'narrow-overlay', width: 760, height: 900, openInspector: true }, +]; + +try { + for (const state of states) { + const page = await browser.newPage({ + viewport: { width: state.width, height: state.height }, + }); + await page.goto('http://127.0.0.1:4174/?demo=chat'); + + if (state.openInspector) { + await page.getByRole('button', { name: 'Show agent inspector' }).click(); + } + + const inspector = page.locator('.chat-inspector'); + const footer = page.getByRole('button', { name: 'App settings' }); + const hero = page.getByLabel('Astra profile'); + + await inspector.waitFor(); + await hero.waitFor(); + + const geometry = await page.evaluate(() => { + const inspectorElement = document.querySelector('.chat-inspector'); + const footerElement = document.querySelector('.inspector-app-settings'); + const scrollElement = document.querySelector('.inspector-scroll'); + + if ( + !(inspectorElement instanceof HTMLElement) || + !(footerElement instanceof HTMLElement) || + !(scrollElement instanceof HTMLElement) + ) { + throw new Error('Inspector geometry elements are missing'); + } + + const inspectorRect = inspectorElement.getBoundingClientRect(); + const footerRect = footerElement.getBoundingClientRect(); + + return { + footerHeight: footerRect.height, + footerBottomGap: inspectorRect.bottom - footerRect.bottom, + horizontalOverflow: scrollElement.scrollWidth > scrollElement.clientWidth, + }; + }); + + if ( + geometry.footerHeight > 48 || + geometry.footerBottomGap > 12 || + geometry.horizontalOverflow + ) { + throw new Error(`${state.name} geometry failed: ${JSON.stringify(geometry)}`); + } + + await page.screenshot({ + path: `.artifacts/inspector-redesign/${state.name}.png`, + fullPage: true, + }); + + await page.getByRole('tab', { name: 'Access' }).click(); + await page.screenshot({ + path: `.artifacts/inspector-redesign/${state.name}-access.png`, + fullPage: true, + }); + + await page.close(); + } +} finally { + await browser.close(); +} +EOF +``` + +Expected: six screenshots are created, the App settings footer stays under 48 +pixels tall and within 12 pixels of the panel bottom, and no profile scroll +region has horizontal overflow. + +- [ ] **Step 3: Inspect the screenshots** + +Open: + +```bash +open .artifacts/inspector-redesign/desktop.png +open .artifacts/inspector-redesign/desktop-access.png +open .artifacts/inspector-redesign/compact-desktop.png +open .artifacts/inspector-redesign/compact-desktop-access.png +open .artifacts/inspector-redesign/narrow-overlay.png +open .artifacts/inspector-redesign/narrow-overlay-access.png +``` + +Confirm: + +- the ward orbit is the only dominant decorative element; +- the hero, purpose, metrics, tabs, and first card are visible at 1440 x 900; +- the profile remains readable at 1024 x 768; +- the narrow overlay has no clipped metrics or controls; +- Access content scrolls vertically without moving the footer; +- long authority items wrap inside their card. + +- [ ] **Step 4: Stop the preview server** + +Run from the same persistent shell: + +```bash +kill "$PREVIEW_PID" +wait "$PREVIEW_PID" 2>/dev/null || true +``` + +Expected: the preview process exits. + +### Task 6: Run the Complete Repository Validation + +**Files:** +- Verify only; no planned source changes + +- [ ] **Step 1: Run the complete required checks** + +Run: + +```bash +corepack pnpm@10.34.0 --ignore-workspace test && +corepack pnpm@10.34.0 --ignore-workspace typecheck && +corepack pnpm@10.34.0 --ignore-workspace lint && +corepack pnpm@10.34.0 --ignore-workspace build && +git diff --check +``` + +Expected: all unit suites pass, TypeScript reports no errors, Biome reports no +violations, Vite builds successfully, and `git diff --check` prints no output. + +- [ ] **Step 2: Confirm the final change set is scoped** + +Run: + +```bash +git --no-pager status --short +git --no-pager diff --stat HEAD~4..HEAD +``` + +Expected: implementation changes are limited to +`src/demo/chat-inspector.tsx`, `src/demo/chat-inspector.test.tsx`, +`src/demo/chat-demo.css`, and `src/demo/chat-shell.test.tsx`; ignored visual +artifacts do not appear in status. diff --git a/docs/superpowers/specs/2026-08-30-grok-inspired-agent-inspector-design.md b/docs/superpowers/specs/2026-08-30-grok-inspired-agent-inspector-design.md new file mode 100644 index 0000000..4e3e182 --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-grok-inspired-agent-inspector-design.md @@ -0,0 +1,292 @@ +# Grok-Inspired Agent Inspector Design + +**Status:** Approved + +**Date:** 2026-08-30 + +**Scope:** Right-side agent inspector in the `?demo=chat` proof-of-concept surface + +## Summary + +Redesign the chat demo's right-side inspector as a richer agent profile that +uses its full vertical space more effectively. The information hierarchy takes +inspiration from Grok Bots: expressive identity first, useful live context +second, and progressively disclosed detail below. + +The implementation keeps OpenCoven's existing Obsidian visual language rather +than copying Grok's surface styling. It reorganizes only data already available +from the mock familiar, contract, and activity models. It does not add new +backend behavior, persistence, or unsupported controls. + +## Goals + +- Make the attached familiar feel present and recognizable. +- Use the inspector's height intentionally instead of presenting a sparse list + of equally weighted rows. +- Keep identity visible while moving between Overview, Access, and Activity. +- Surface the most useful familiar facts at a glance. +- Preserve App settings as a compact footer action. +- Keep the inspector responsive, keyboard accessible, and consistent with the + current chat shell. + +## Non-Goals + +- Reproducing Grok's exact colors, typography, iconography, or branding. +- Adding agent actions that are not represented by the existing data model. +- Adding new mock familiar fields solely to fill the design. +- Changing conversation behavior, panel collapse behavior, or the center chat + experience. +- Redesigning the standalone Familiars, Settings, or Minimal macOS surfaces. +- Introducing external fonts, icon libraries, animation libraries, or UI + dependencies. + +## Approved Direction + +The selected direction is **Profile Stack**: a centered identity hero, a +compact row of derived metrics, and stacked context cards. This direction is +the closest to the requested Grok Bots hierarchy while remaining calm, +legible, and appropriate for a narrow utility panel. + +Two alternatives were considered: + +| Direction | Strength | Trade-off | +| --- | --- | --- | +| Profile Stack | Best balance of personality, scanning, and narrow-width readability | Uses more vertical space before detailed controls | +| Command Deck | Strong operational and recent-activity focus | Gives familiar identity and purpose less emphasis | +| Modular Mosaic | Uses space aggressively with dashboard tiles | Becomes dense and less readable at the panel's minimum width | + +## Layout + +The redesigned agent view uses a three-row grid: + +```text ++----------------------------------+ +| compact panel toolbar | ++----------------------------------+ +| scrollable profile content | +| | +| identity hero | +| purpose | +| quick metrics | +| overview/access/activity tabs | +| selected contextual cards | +| | ++----------------------------------+ +| compact App settings footer | ++----------------------------------+ +``` + +The profile content owns the flexible row and scrolls independently. The top +toolbar and App settings footer remain stable. The inspector stays within its +current desktop range of 300-340 pixels so the conversation +continues to own surplus width. + +### Identity hero + +The top of the scrolling region contains: + +- the familiar's avatar mark; +- name; +- role; +- availability status; +- a concise purpose statement. + +The hero is centered and more spacious than the current horizontal identity +row. It establishes the familiar before presenting operational detail. + +### Quick metrics + +A compact three-cell metric row appears below the purpose: + +- recent completion rate from `CAVE_FAMILIAR_ANALYTICS`; +- memory entry count from `MockFamiliar.memory`; +- last activity or memory recency. + +Values are derived from existing data. Missing values display an explicit +human-readable state such as `No runs`, `Off`, or `Unavailable`; the interface +must not invent zero-valued observations. + +### Tabs and contextual cards + +Overview, Access, and Activity remain accessible tabs with the existing arrow, +Home, and End keyboard behavior. The tabs move below the stable hero and +metrics. Switching tabs replaces only the lower contextual card stack. + +- **Overview** shows purpose and chat context, project, memory, identity, and + contract status. +- **Access** groups automatic actions, review-required actions, editable paths, + and the Familiar Contract result. +- **Activity** presents the existing bounded seven-day analytics and empty + state. + +Rows are grouped into low-contrast cards based on meaning instead of appearing +as one undifferentiated settings list. + +### App settings footer + +App settings remains the final compact row at the bottom of the panel. It keeps +the existing label, keyboard shortcut, and navigation behavior. Selecting it +still replaces the agent inspector with the secondary settings view and a +visible back control. + +## Visual System + +### Palette + +| Token | Value | Use | +| --- | --- | --- | +| Graphite | `#111216` | Window and deepest panel background | +| Charcoal | `#191a1f` | Elevated content surfaces | +| Pearl | `#f4f3f7` | Primary text | +| Fog | `#aaa7b0` | Secondary text | +| Coven violet | `#9386d0` | Identity, active state, and focus | +| Ready green | `#82b38e` | Availability and positive operational state | + +The panel uses a tightly bounded violet tint behind the identity hero. It does +not add a large decorative ambient gradient. Content cards remain neutral and +low contrast. + +### Typography + +Continue using the native system stack: + +```css +-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif +``` + +The familiar name increases to 20 pixels with tight tracking. +Role, purpose, metrics, labels, and values use a clear descending scale. +Section labels may remain uppercase only when they identify a compact card +group. + +### Signature element: ward orbit + +The avatar gains a restrained asymmetric status ring called the **ward orbit**. +The main violet arc represents familiar identity and the smaller green segment +represents current availability. The ring is semantic rather than decorative: +offline or unavailable states remove the green segment and reduce contrast. + +This is the design's single expressive risk. Other surfaces remain disciplined +so the panel does not become visually noisy. + +### Material and depth + +The inspector shell retains its existing liquid-glass treatment. Profile cards +use subtle opaque or translucent charcoal surfaces, one-pixel borders, and +minimal inset highlights. Shadows are reserved for the identity mark and +selected controls; every card should not float independently. + +## Components + +`ChatInspector` remains the local state owner and continues to receive +`familiar` and `onClose`. + +The presentation should be separated into focused internal components: + +- `InspectorHero` renders identity, role, status, purpose, and the ward orbit. +- `InspectorMetrics` derives and renders completion, memory, and recency. +- `Overview` renders overview cards. +- `Access` renders authority and contract cards. +- `Activity` renders recent execution metrics or its empty state. +- `AppSettings` retains the existing secondary settings view. + +Small shared card or value components may be introduced when they eliminate +repeated markup. They must not hide domain meaning behind a generic dashboard +abstraction. + +## Data Flow + +No new data source is introduced: + +- identity, purpose, memory, and authority come from `MockFamiliar`; +- contract state comes from `contractReport`; +- execution metrics come from `CAVE_FAMILIAR_ANALYTICS`; +- app preference data remains owned by the existing local settings view. + +Derived display values should be computed near the component that owns their +meaning. The redesign must not mutate canonical conversation or familiar data. + +## Interaction and Motion + +- Identity and quick metrics remain visible when the selected tab changes. +- Tab focus and selection semantics remain unchanged. +- App settings opens in place and returns to the familiar inspector. +- Card-content changes may use one short opacity/vertical transition of roughly + 160-180 milliseconds. +- The ward orbit may transition color or opacity when status changes. +- `prefers-reduced-motion: reduce` removes these transitions without hiding + state changes. + +No ambient looping animation, shimmer, floating particles, or decorative +motion is added. + +## Responsive Behavior + +At desktop widths, the inspector keeps the current 300-340 pixel column range. +At the existing narrow breakpoint, it continues to overlay the thread and +opening it closes the conversation panel. + +On narrow windows: + +- the profile remains a single column; +- metric cells may tighten but must not collapse below readable widths; +- the hero uses reduced vertical padding; +- the contextual card region scrolls; +- the footer remains pinned and reachable; +- no horizontal page scrolling is introduced. + +## Empty and Error States + +- An unknown or missing familiar renders a neutral `Agent unavailable` hero, + guidance to select another conversation, and a reachable App settings footer. +- Missing memory displays `Off` and does not show a fabricated entry count. +- Missing or zero-attempt activity displays `No recent runs`. +- A missing contract result is surfaced as unavailable rather than silently + shown as passing. +- Long names, roles, paths, and action labels wrap or truncate according to + their meaning without pushing controls outside the panel. + +## Accessibility + +- Preserve the inspector's labeled region, tablist, tabs, and tabpanel + semantics. +- Preserve arrow, Home, and End keyboard navigation. +- Keep all interactive controls in logical DOM and focus order. +- Use visible violet focus rings that remain legible on graphite and charcoal. +- Do not encode availability or contract state through color alone. +- The ward orbit is decorative to assistive technology; adjacent text carries + the status meaning. +- Maintain readable contrast for Fog text on every card surface. + +## Testing and Verification + +Focused component coverage should verify: + +- the profile hero renders familiar identity, role, purpose, and text status; +- quick metrics derive from the existing familiar and activity data; +- the stable hero remains while switching Overview, Access, and Activity; +- keyboard tab navigation continues to work; +- missing familiar, memory, and activity states render explicitly; +- App settings opens in place and returns to the familiar view; +- the compact footer remains present in the agent view. + +Stylesheet coverage should verify: + +- the inspector uses a flexible scrolling content row and compact footer row; +- narrow layouts remain single-column; +- reduced-motion rules cover the new transitions. + +Rendered checks should cover at least: + +- 1440 x 900 with the inspector open; +- 1024 x 768 with the inspector open; +- 760 x 900 with the inspector overlay open; +- enough Access content to require vertical scrolling; +- keyboard-only navigation through the toolbar, tabs, cards, and App settings. + +## Implementation Constraint + +The implementation should be a focused redesign of +`src/demo/chat-inspector.tsx` and the contextual inspector section of +`src/demo/chat-demo.css`, with corresponding focused tests. Unrelated chat +shell, mock data, production app, and native code must remain unchanged. diff --git a/e2e/app.smoke.spec.ts b/e2e/app.smoke.spec.ts index de5586a..c1364b7 100644 --- a/e2e/app.smoke.spec.ts +++ b/e2e/app.smoke.spec.ts @@ -13,3 +13,143 @@ test('preserves the local demo routes alongside the default app', async ({ page await expect(page.getByText('Chats', { exact: true })).toBeVisible(); await expect(page.getByText('Familiars', { exact: true })).toBeVisible(); }); + +test('keeps the chat sidebar compact and fully interactive', async ({ page }) => { + await page.goto('/?demo=chat'); + + const sidebar = page.getByRole('complementary', { name: 'Conversations' }); + await expect(sidebar.locator('select')).toHaveCount(0); + + for (const [selector, maximumHeight] of [ + ['.sidebar-header-toggle', 40], + ['.familiar-switcher', 46], + ['.new-conversation', 36], + ['.conversation-search-trigger', 36], + ['.conversation', 58], + ] as const) { + const box = await sidebar.locator(selector).first().boundingBox(); + expect(box?.height).toBeLessThanOrEqual(maximumHeight); + } + + const familiarBox = await sidebar.locator('.familiar-switcher').boundingBox(); + const newChatBox = await sidebar.locator('.new-conversation').boundingBox(); + expect(familiarBox?.height).toBeGreaterThanOrEqual(42); + expect( + (newChatBox?.y ?? 0) - ((familiarBox?.y ?? 0) + (familiarBox?.height ?? 0)), + ).toBeGreaterThanOrEqual(12); + + await expect(sidebar.getByRole('heading', { name: /Recent/ })).toBeVisible(); + await expect(sidebar.locator('.conversation-status-rail')).toHaveCount(2); + await expect(sidebar.getByRole('button', { name: 'Start a new chat' })).toContainText('New Chat'); + await expect(sidebar.getByRole('button', { name: 'Search conversations' })).toBeVisible(); + const sparseConversation = await sidebar.locator('.conversation').first().boundingBox(); + expect(sparseConversation?.height).toBeGreaterThanOrEqual(52); + + await page.getByText('Conversations', { exact: true }).click(); + await expect(sidebar).toBeHidden(); + await page.getByRole('button', { name: 'Show conversations' }).click(); + + await page.getByRole('button', { name: 'Sidebar familiar: Astra' }).click(); + await expect(page.getByRole('menuitemradio', { name: /Cody/ })).toBeVisible(); + await page.getByRole('menuitemradio', { name: /Cody/ }).click(); + await expect(page.getByRole('button', { name: 'Sidebar familiar: Cody' })).toBeVisible(); +}); + +test('does not reserve a top row for the active familiar', async ({ page }) => { + await page.goto('/?demo=chat'); + + await expect(page.locator('.thread-header')).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Active familiar: Astra' })).toHaveCount(0); +}); + +test('opens conversation command search from the rail and keyboard shortcut', async ({ page }) => { + await page.goto('/?demo=chat'); + + await page.getByRole('button', { name: 'Search conversations' }).click(); + const dialog = page.getByRole('dialog', { name: 'Search conversations' }); + const input = dialog.getByRole('searchbox', { name: 'Search conversations' }); + await expect(input).toBeFocused(); + + await input.fill('new'); + await expect(dialog.getByRole('button', { name: /Quick Chat/ })).toHaveCount(0); + await dialog.getByRole('button', { name: /New Chat/ }).click(); + await expect(page.getByRole('heading', { name: 'Cody' })).toBeVisible(); + + await page.keyboard.press('Meta+k'); + await expect(dialog).toBeVisible(); + await page.keyboard.press('Meta+k'); + await expect(dialog).toBeHidden(); +}); + +test('resizes both desktop side rails by dragging their inner edges', async ({ page }) => { + await page.goto('/?demo=chat'); + + const conversations = page.getByRole('complementary', { name: 'Conversations' }); + const inspector = page.getByRole('complementary', { name: 'Agent inspector' }); + const conversationsHandle = page.getByRole('separator', { + name: 'Resize conversations sidebar', + }); + const inspectorHandle = page.getByRole('separator', { name: 'Resize agent inspector' }); + + const conversationsBefore = await conversations.boundingBox(); + const leftHandleBox = await conversationsHandle.boundingBox(); + if (!leftHandleBox) { + throw new Error('Conversations resize handle is not visible'); + } + await page.mouse.move(leftHandleBox.x + leftHandleBox.width / 2, leftHandleBox.y + 100); + await page.mouse.down(); + await page.mouse.move(leftHandleBox.x + 64, leftHandleBox.y + 100); + await page.mouse.up(); + const conversationsAfter = await conversations.boundingBox(); + expect(conversationsAfter?.width).toBeGreaterThan((conversationsBefore?.width ?? 0) + 40); + + const inspectorBefore = await inspector.boundingBox(); + const rightHandleBox = await inspectorHandle.boundingBox(); + if (!rightHandleBox) { + throw new Error('Inspector resize handle is not visible'); + } + await page.mouse.move(rightHandleBox.x + rightHandleBox.width / 2, rightHandleBox.y + 100); + await page.mouse.down(); + await page.mouse.move(rightHandleBox.x - 64, rightHandleBox.y + 100); + await page.mouse.up(); + const inspectorAfter = await inspector.boundingBox(); + expect(inspectorAfter?.width).toBeGreaterThan((inspectorBefore?.width ?? 0) + 40); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(conversationsHandle).toBeHidden(); +}); + +test('expands generated images against a focused dark backdrop', async ({ page }) => { + await page.goto('/?demo=chat'); + + const inlineImage = page.getByRole('button', { + name: 'Expand image: A purple cat in a glowing garden', + }); + const inlineBox = await inlineImage.boundingBox(); + await inlineImage.click(); + + const dialog = page.getByRole('dialog', { + name: 'Expanded image: A purple cat in a glowing garden', + }); + await expect(dialog).toBeVisible(); + await expect(dialog).toHaveCSS('background-color', 'rgba(0, 0, 0, 0.9)'); + + const expandedBox = await dialog.locator('.generated').boundingBox(); + expect(expandedBox?.width).toBeGreaterThan((inlineBox?.width ?? 0) * 1.5); + + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); +}); + +test('shows an expandable reasoning summary for the demo response', async ({ page }) => { + await page.goto('/?demo=chat'); + + const reasoning = page.locator('details.reasoning-block'); + await expect(reasoning).toHaveAttribute('open', ''); + await expect(reasoning).toContainText('Identified “cat” as the subject'); + await expect(reasoning).toContainText('Demo'); + await expect(reasoning.locator('.reasoning-step-icon')).toHaveCount(3); + + await reasoning.locator('summary').click(); + await expect(reasoning).not.toHaveAttribute('open', ''); +}); diff --git a/package.json b/package.json index 076588d..11b98e5 100644 --- a/package.json +++ b/package.json @@ -14,23 +14,26 @@ "preview": "vite preview --host 127.0.0.1 --port 4174 --strictPort", "build": "vite build", "typecheck": "tsc --noEmit --project tsconfig.json", - "lint": "biome check package.json contract-canary.lock.json biome.json tsconfig.json vite.config.ts vitest.config.ts playwright.config.ts index.html README.md docs/developer-toolchains.md src e2e scripts", - "format": "biome format --write package.json contract-canary.lock.json biome.json tsconfig.json vite.config.ts vitest.config.ts playwright.config.ts index.html README.md docs/developer-toolchains.md src e2e scripts", - "format:check": "biome check --formatter-enabled=true --linter-enabled=false --assist-enabled=false package.json contract-canary.lock.json biome.json tsconfig.json vite.config.ts vitest.config.ts playwright.config.ts index.html README.md docs/developer-toolchains.md src e2e scripts", - "test": "vitest run", - "test:unit": "vitest run", + "lint": "biome check package.json contract-canary.lock.json phase1-conformance.lock.json biome.json tsconfig.json vite.config.ts vitest.config.ts vitest.heavy.config.ts playwright.config.ts index.html README.md docs/developer-toolchains.md docs/phase1-conformance.md src e2e scripts", + "format": "biome format --write package.json contract-canary.lock.json phase1-conformance.lock.json biome.json tsconfig.json vite.config.ts vitest.config.ts vitest.heavy.config.ts playwright.config.ts index.html README.md docs/developer-toolchains.md docs/phase1-conformance.md src e2e scripts", + "format:check": "biome check --formatter-enabled=true --linter-enabled=false --assist-enabled=false package.json contract-canary.lock.json phase1-conformance.lock.json biome.json tsconfig.json vite.config.ts vitest.config.ts vitest.heavy.config.ts playwright.config.ts index.html README.md docs/developer-toolchains.md docs/phase1-conformance.md src e2e scripts", + "test": "corepack pnpm@10.34.0 --ignore-workspace test:unit", + "test:unit": "corepack pnpm@10.34.0 --ignore-workspace test:unit:normal && corepack pnpm@10.34.0 --ignore-workspace test:unit:heavy", + "test:unit:normal": "vitest run", + "test:unit:heavy": "vitest run --config vitest.heavy.config.ts", "test:e2e": "playwright test", "test:native-e2e": "cargo test --manifest-path src-tauri/Cargo.toml --features phase1-conformance --test phase1_native_rpc", "test:contract-canary": "node ./scripts/contract-canary.mjs", "cargo:check": "cargo check --manifest-path src-tauri/Cargo.toml --all-targets", + "cargo:check:windows-gnu": "cargo check --manifest-path src-tauri/Cargo.toml --target x86_64-pc-windows-gnu --all-targets", "cargo:test": "cargo test --manifest-path src-tauri/Cargo.toml", "cargo:clippy": "cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features -- -D warnings", "cargo:fmt": "cargo fmt --manifest-path src-tauri/Cargo.toml --all --check", "app:dev": "tauri dev", - "app:build": "tauri build", - "test:phase1-conformance": "node ./scripts/phase1-conformance.mjs --lock ./phase1-conformance.lock.json --scenario all" + "app:build": "tauri build" }, "dependencies": { + "@base-ui/react": "1.7.0", "@opencoven/cave-client": "file:vendor/opencoven-sdk/cave-client-0.1.0.tgz", "@opencoven/coven-client": "file:vendor/opencoven-sdk/coven-client-0.1.0.tgz", "@opencoven/sdk": "file:vendor/opencoven-sdk/sdk-0.1.0.tgz", diff --git a/phase1-conformance.lock.json b/phase1-conformance.lock.json index f616e7d..67fd160 100644 --- a/phase1-conformance.lock.json +++ b/phase1-conformance.lock.json @@ -1,5 +1,5 @@ { - "version": 1, + "version": 5, "chat": { "repository": "OpenCoven/chat", "revision": "20633346c444ded9e05ca5a3db45d74c28918d69" @@ -10,10 +10,261 @@ }, "cave": { "repository": "OpenCoven/coven-cave", - "revision": "086b6421d8f5daa814e2ad1b3c47605d1ffb542b" + "revision": "e74078a147c084bd761d929654f0990df66ef99f" }, "coven": { "repository": "OpenCoven/coven", "revision": "721437b84026c042e431b0882dcd14fdb29ac07d" + }, + "harness": { + "repository": "OpenCoven/chat", + "revision": "b24eaa7eb6886a5ef07af6b459ab57e0335fd20f" + }, + "harnessAuthority": { + "revision": "b24eaa7eb6886a5ef07af6b459ab57e0335fd20f", + "tree": "09c94b64b31fcfceebabef156df3228fa5fb231a", + "files": [ + { + "path": "scripts/phase1-conformance.mjs", + "blob": "e0493b886c3ee088fd61b1f94fae0b3a4f6836a3", + "sha256": "27d565ed7602befca09045d6e48811b5ff4c7e61e10e6e5eb807f9a2e9eac217" + }, + { + "path": "scripts/phase1-conformance-launcher.sh", + "blob": "6df0add5f79d6d80bb8ad14ae98f4fd96929b636", + "sha256": "88e184d465eaf7bd6ce828dcc81ecadb11b6222f01576c56090060085820e7b2" + }, + { + "path": "scripts/phase1-conformance-launcher.ps1", + "blob": "3b3883d9e74d503bbe4647fdc7478ca31c102ab6", + "sha256": "99eea6108e59db9a0ac12368787fb6e6456e6af4f8cce09ee96ce117ca3f475e" + }, + { + "path": "scripts/phase1-process-supervisor.mjs", + "blob": "40801149e13df8661333387d521e6a1a464b9107", + "sha256": "a372832419f980e132f05fdc42c870473547e93927f4628a3a0ff7380a208fd5" + }, + { + "path": "scripts/executable-resolution.mjs", + "blob": "18e903f2a33928e1820f650032064ba9f71fce4b", + "sha256": "65a04e1c79f1452925c2781811bf48e190da765ba35b955ac0aeba093c19340d" + }, + { + "path": "scripts/process-owned-artifact-root.mjs", + "blob": "bba0f6c01729a3cf98e71c8ebc895400ee5137dd", + "sha256": "82a0c8e38c1b13b705dfef9b8e891dc61c68e879f1934e409455a168a48d81ca" + }, + { + "path": "scripts/owned-temp-directory.mjs", + "blob": "e5677dc8751b713284334635683d7dd89847402e", + "sha256": "a9c55c85cf2b7d70310d278bafd2c8e7695d66f4ae38b9c3f1f12fce0b442095" + }, + { + "path": "scripts/phase1-conformance-lock.mjs", + "blob": "6e7ab9ccaefec34325cbfaaeda1f5cb048c8e2b3", + "sha256": "c428285b69bdae2127277cd34ae9fc5a23c83488bce8c1e014a81254e162017d" + }, + { + "path": "scripts/phase1-evidence-contract.mjs", + "blob": "52dc92c85b9be811cd2ea89607fbbb17dd06a21d", + "sha256": "24180ae03835fa6aac45559682adb3c1e626bab76466eddc55b9e2300f0a2b7f" + }, + { + "path": "scripts/supervised-exec.mjs", + "blob": "16da6c76ce21e9bb5490ccd1cc4e822b671b8464", + "sha256": "149933cca97499a019dc4394d0117857c5d2353890260c48102db9bd42e3af3b" + }, + { + "path": "scripts/supervisor-status.mjs", + "blob": "5fef25ec0d2c8178566c0dd59365713169267d6e", + "sha256": "ac332ca7b6b040ecc846088bb3a6ad5e7112a0454eb3ea71d2a819d55e64254e" + }, + { + "path": "scripts/phase1-artifact-secret-scan.mjs", + "blob": "7e9879c3a60aa0234bcdce59155a1df6051b9645", + "sha256": "165ee596b555f3872bca900f15153ca58f5687e8a6ea9addc87d2669968eb974" + }, + { + "path": ".github/workflows/ci.yml", + "blob": "735ac374b96659fadeb06372b8592822489d36b9", + "sha256": "784aef7a3f0be0978dae500db9cdcb0af29bdee06be13610f4f2ed27c9f50f37" + } + ], + "productionDeltas": [ + { + "path": "src-tauri/Cargo.toml", + "blob": "16c74629b9bf1927061c52c0069b60462ece7e8b", + "sha256": "a6ae7962bc5e50f0cb19b58a1e4ccb9ca0fb2e71688839aa866e0f5173d5a599" + }, + { + "path": "src-tauri/src/bin/phase1-native-rpc.rs", + "blob": "eb209dbbd54089a61d7c72a6ab571d4f83574831", + "sha256": "adfe422281e056ba5b9ab526c5889fc4c52601d5ac8538d1da7b5d56fdf90ec1" + }, + { + "path": "src-tauri/src/conformance.rs", + "blob": "10287d93bf3adba45557e4781efd72d1a728495a", + "sha256": "0eeca76e49f3c6649ccf871f9cb9d213d974e4c8ce7ef4250b5a134eb6151a68" + }, + { + "path": "src-tauri/src/connection.rs", + "blob": "65ea88062020460b8bd402b91a874a99355660fb", + "sha256": "83b45bbb128bfc83f85c6b8d6e471866978effce1919485b6491c8503a7caee1" + }, + { + "path": "src-tauri/src/coven.rs", + "blob": "553592580a4ab7b667e3a6752b42aaeecda881e9", + "sha256": "fb50ed35404765b5ad3e01c22f873bbf839db1da57f0e612b2884176d8e7ac33" + }, + { + "path": "src-tauri/src/keyring.rs", + "blob": "b6116866799b7f8e9aa5f70907ffbd38c0f3ec5d", + "sha256": "3e4ac7771406d5dfb2088d2a3c639d4be2c4d176c5d3088941f602fba1f1f07b" + } + ] + }, + "chatAuthority": { + "tree": "72de37ed3c1afd36dcbd2824570f0a00b46459c6", + "files": [ + { + "path": "src-tauri/Cargo.toml", + "blob": "75a54c604d5d8b88b661f5a2427c3f9494e3a374", + "sha256": "92b6839acf7785fbeb77abeeb8c3576d904f71bdaed1f79f834c740b09ecfbaf" + }, + { + "path": "src-tauri/Cargo.lock", + "blob": "4bcf036ae5f2c9be11bef0ac890159c6cf0fd06a", + "sha256": "50ff6c361744a08b9cc2770f6e06659ebb7cd1d5cd1b43b39ea7127c9c80e7ca" + }, + { + "path": "src-tauri/src/bin/phase1-native-rpc.rs", + "blob": "0fbf0dcd1d2f1a49a556a9319b3121990cf53946", + "sha256": "a479704ea719138967f4828707bca04fb2a3de7fd2d2d2b9c7282831ec4bdf09" + }, + { + "path": "src-tauri/src/conformance.rs", + "blob": "e0997e45a2371cc231bc3923a55d9d62d7cff2a0", + "sha256": "32c1b24e2ce27fb666af5ffe73a42d626f258285dd0d680852014799054d953d" + }, + { + "path": "src-tauri/src/coven.rs", + "blob": "b904e5eeded97aa624140b56c5141daebea772b2", + "sha256": "09ca4871fc95d818cb642764ceaba3b1a8d6adba62e7552e69f0d0ac47662ff6" + }, + { + "path": "src-tauri/src/keyring.rs", + "blob": "a33c68672b87a5cfa39cba37d4629619408309a4", + "sha256": "3d9215c4ba388d9180c43dea573fc839e6bfd6a214876cff7f836c9d7ad50c52" + }, + { + "path": "src-tauri/src/lib.rs", + "blob": "d9c97b4fb24e73fb103330fa9a8f778ccad42a3a", + "sha256": "f42a987b0dcb61b7c98f9dcdbf264d079134defe4bbe63e5019d4560ea967e21" + } + ] + }, + "tools": { + "windowsSupervisor": { + "source": { + "repository": "OpenCoven/chat", + "revision": "6bee23b645c0edb1dcb0afd4f8cc18d2d0e6bec6", + "path": "tools/phase1-process-supervisor/src/main.rs", + "blob": "6a95b4db7612ed0a502e91c4c21a7df5cbfe9021", + "sha256": "fa4c4759c0b01ce7f9bbd662ed3073b0aeed42dc7da0001026703482a5b9708a", + "manifestSha256": "3d3964b144599835006248fcc40eee0437b06c268a180a373c15fea44bf4bf8b", + "lockSha256": "e31803e60e80d9d0a68cf043786cb1f516b425c185465785216aa5b05dd6fa88", + "configSha256": "79d370b49837a1c4ec84231eb7fa13422e5e18a569c2b2eebf97ab3ec333d49c" + }, + "toolchain": { + "homebrewCoreRevision": "cd168d1fdc26f12e4ad64f358ff2dbec61ab7a57", + "packageVersion": "mingw-w64 14.0.0_3", + "bottleLayerSha256": "0d68ab737a8bbc8c63ac6ac7acc0695e2887c1169df9a4423f1180090079b1d5", + "linkerVersion": "2.47.20260726" + }, + "artifact": { + "target": "x86_64-pc-windows-gnu", + "buildInvocation": "cd tools/phase1-process-supervisor && SOURCE_DATE_EPOCH=0 cargo build --target x86_64-pc-windows-gnu --release --locked", + "fileName": "phase1-process-supervisor.exe", + "fleetPath": "C:\\OpenCoven\\conformance\\phase1-process-supervisor.exe", + "size": 333824, + "sha256": "372b3e8b5b860e0759da8fa10ddfb6ec338e26d83616254c816a456ae2e1b7c5" + } + } + }, + "release": { + "sdkManifest": { + "version": "0.1.0", + "sha256": "b8bfb62236fc8add4a9baad9f00e5401db15074a2d21fe2847a9158104cefb3c" + }, + "sdkArtifacts": [ + { + "packageName": "@opencoven/sdk-core", + "releaseFile": "tarballs/core/opencoven-sdk-core-0.1.0.tgz", + "vendorFile": "sdk-core-0.1.0.tgz", + "size": 33284, + "sha256": "9a574e8bd5178ce2aa20db97e8a741c7c9569515546a2d3089406f41a9d040fe" + }, + { + "packageName": "@opencoven/cave-client", + "releaseFile": "tarballs/cave/opencoven-cave-client-0.1.0.tgz", + "vendorFile": "cave-client-0.1.0.tgz", + "size": 81543, + "sha256": "c44544adf8e712d6be1e8686788e63aa0133eb318274d1fb1926138a7da148c0" + }, + { + "packageName": "@opencoven/coven-client", + "releaseFile": "tarballs/coven/opencoven-coven-client-0.1.0.tgz", + "vendorFile": "coven-client-0.1.0.tgz", + "size": 33009, + "sha256": "cba09410aeae9670173a1f7bfe3174b5dd610873358944ed0955c86ac56a3aa1" + }, + { + "packageName": "@opencoven/sdk", + "releaseFile": "tarballs/sdk/opencoven-sdk-0.1.0.tgz", + "vendorFile": "sdk-0.1.0.tgz", + "size": 15833, + "sha256": "eee7557feeaf4719d0cb990a66fdddf62270dbbeb05cfe7e35efbfe22827d04f" + } + ], + "caveVersion": "0.3.12", + "covenVersion": "0.1.0", + "consumerLock": { + "path": "pnpm-lock.yaml", + "size": 56222, + "sha256": "d2f0db8eca64112324e861bb7cbd2b645ed9ae4aad836200855b3477f3ea49ae" + }, + "caveArtifacts": { + "assertionEngine": { + "path": "scripts/client-v1-conformance.mjs", + "size": 146432, + "sha256": "b611d2b2935dad3cf913eda45e30ba109ba2ab53dadfef8670a26c7c03b115dd" + }, + "contractFixture": { + "path": "src/lib/server/client-v1/contract-fixture.json", + "size": 16695, + "sha256": "c0b1af2442409f8b26bbf0cf2a5fac467d23e5f56d2c966a9428c4b3e830a186" + }, + "hpkeVectors": { + "path": "src/lib/server/client-v1/hpke-bound-v1-vectors.json", + "size": 4041, + "sha256": "f806967291de12175277b6b24ac3c7bba912ae760fd8227fb21b1a4d5f5e6797" + } + } + }, + "evidence": { + "repository": "OpenCoven/sdk", + "revision": "4736bf2e0d5b16272d79ecf7784c75f376b39b94", + "contract": { + "path": "scripts/conformance-contract.mjs", + "sha256": "50b1012b3c4c22f518c1a611fb5210a5675ee976a9b194b502f6125bc48f5111" + }, + "schema": { + "path": "conformance/client-v1-cross-repository-evidence.schema.json", + "sha256": "ca338cdbb33c46a97fe8430d95e04f6b30a2db453a7fff2184b335e33ea4f790" + }, + "assertionRegistry": { + "path": "conformance/client-v1-cross-repository-assertions.json", + "sha256": "fb56d7cadaf194126fd9a7f090d8af600c04f7161cab1e2ebb3419df49fbcbe0" + } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5addb1..b813580 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: .: dependencies: + '@base-ui/react': + specifier: 1.7.0 + version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@opencoven/cave-client': specifier: file:vendor/opencoven-sdk/cave-client-0.1.0.tgz version: file:vendor/opencoven-sdk/cave-client-0.1.0.tgz @@ -101,6 +104,33 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} + '@base-ui/react@1.7.0': + resolution: {integrity: sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@date-fns/tz': ^1.2.0 + '@types/react': ^17 || ^18 || ^19 + date-fns: ^4.0.0 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@date-fns/tz': + optional: true + '@types/react': + optional: true + date-fns: + optional: true + + '@base-ui/utils@0.3.2': + resolution: {integrity: sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@biomejs/biome@2.3.2': resolution: {integrity: sha512-8e9tzamuDycx7fdrcJ/F/GDZ8SYukc5ud6tDicjjFqURKYFSWMl0H0iXNXZEGmcmNUmABgGuHThPykcM41INgg==} engines: {node: '>=14.21.3'} @@ -207,6 +237,21 @@ packages: '@noble/hashes': optional: true + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@hpke/common@1.10.1': resolution: {integrity: sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==} engines: {node: '>=16.0.0'} @@ -796,6 +841,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.3.0: + resolution: {integrity: sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==} + rolldown@1.2.4: resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -870,6 +918,11 @@ packages: resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} engines: {node: '>=22.19.0'} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vite@8.2.1: resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1015,6 +1068,29 @@ snapshots: '@babel/runtime@7.29.7': {} + '@base-ui/react@1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@base-ui/utils': 0.3.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@floating-ui/utils': 0.2.12 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + + '@base-ui/utils@0.3.2(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@floating-ui/utils': 0.2.12 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + reselect: 5.3.0 + use-sync-external-store: 1.6.0(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@biomejs/biome@2.3.2': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.3.2 @@ -1080,6 +1156,23 @@ snapshots: '@exodus/bytes@1.15.1': {} + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@floating-ui/utils@0.2.12': {} + '@hpke/common@1.10.1': {} '@hpke/core@1.9.0': @@ -1533,6 +1626,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.3.0: {} + rolldown@1.2.4: dependencies: '@oxc-project/types': 0.144.0 @@ -1604,6 +1699,10 @@ snapshots: undici@8.10.0: {} + use-sync-external-store@1.6.0(react@19.2.8): + dependencies: + react: 19.2.8 + vite@8.2.1(@types/node@24.9.1): dependencies: lightningcss: 1.33.0 diff --git a/scripts/executable-resolution.d.mts b/scripts/executable-resolution.d.mts new file mode 100644 index 0000000..10e84e0 --- /dev/null +++ b/scripts/executable-resolution.d.mts @@ -0,0 +1,12 @@ +export function resolveExecutableInvocation( + command: string, + environment?: NodeJS.ProcessEnv, + platform?: NodeJS.Platform, + args?: readonly string[], +): Readonly<{ + executable: string; + args: readonly string[]; + resolvedCommand: string; +}>; +export function quoteWindowsBatchCommand(batchPath: string, args: readonly string[]): string; +export function normalizeWindowsRealPathForProcess(path: string): string; diff --git a/scripts/executable-resolution.mjs b/scripts/executable-resolution.mjs new file mode 100644 index 0000000..18e903f --- /dev/null +++ b/scripts/executable-resolution.mjs @@ -0,0 +1,215 @@ +import { accessSync, constants, lstatSync, realpathSync } from 'node:fs'; +import { delimiter, isAbsolute, resolve, win32 } from 'node:path'; + +const windowsExecutableExtensions = new Set(['.exe', '.cmd', '.bat', '.com']); +const defaultWindowsPathExt = ['.com', '.exe', '.bat', '.cmd']; +const unsafeWindowsBatchToken = /[&|<>()^%!"\r\n\0]/u; +const safeWindowsBatchToken = /^[A-Za-z0-9 .,:;\\/@+=_~-]+$/u; + +function windowsEnvironmentValue(environment, name) { + const matches = Object.entries(environment).filter( + ([key, value]) => key.toLowerCase() === name.toLowerCase() && typeof value === 'string', + ); + const values = new Set(matches.map(([, value]) => value)); + if (values.size > 1) { + throw new Error(`Windows ${name} environment is ambiguous.`); + } + return matches[0]?.[1]; +} + +export function normalizeWindowsRealPathForProcess(path) { + return /^\\\\\?\\[A-Za-z]:\\/u.test(path) ? path.slice(4) : path; +} + +function regularExecutable(path, windows) { + try { + const stats = lstatSync(path); + if ((windows && stats.isSymbolicLink()) || (!stats.isFile() && !stats.isSymbolicLink())) { + return undefined; + } + const realPath = realpathSync(path); + if (!lstatSync(realPath).isFile()) { + return undefined; + } + if (!windows) { + accessSync(path, constants.X_OK); + } + return windows ? normalizeWindowsRealPathForProcess(realPath) : realPath; + } catch { + return undefined; + } +} + +function windowsPathExtensions(environment) { + const configured = windowsEnvironmentValue(environment, 'PATHEXT'); + const entries = + configured === undefined || configured.length === 0 + ? defaultWindowsPathExt + : configured.split(';').filter(Boolean); + const normalized = []; + const seen = new Set(); + for (const entry of entries) { + const extension = entry.toLowerCase(); + if (!windowsExecutableExtensions.has(extension)) { + continue; + } + if (seen.has(extension)) { + throw new Error('Windows PATHEXT environment is ambiguous.'); + } + seen.add(extension); + normalized.push(extension); + } + if (normalized.length === 0) { + throw new Error('Windows PATHEXT has no supported executable extensions.'); + } + return normalized; +} + +function windowsSearchDirectories(command, environment) { + if (win32.isAbsolute(command)) { + return [{ directory: win32.dirname(command), base: win32.basename(command) }]; + } + if (command.includes('/') || command.includes('\\')) { + throw new Error('Windows executable paths must be absolute.'); + } + const configuredPath = windowsEnvironmentValue(environment, 'PATH') ?? ''; + const entries = configuredPath.split(';').filter((entry) => entry.length > 0); + if ( + entries.some( + (entry) => entry !== entry.trim() || entry.includes('"') || !win32.isAbsolute(entry), + ) + ) { + throw new Error('Windows PATH contains an unsafe search entry.'); + } + return entries.map((directory) => ({ directory, base: command })); +} + +function resolveWindowsCommand(command, environment) { + const extension = win32.extname(command).toLowerCase(); + if (extension.length > 0 && !windowsExecutableExtensions.has(extension)) { + throw new Error('Windows executable extension is unsupported.'); + } + const extensions = extension.length > 0 ? [''] : windowsPathExtensions(environment); + for (const { directory, base } of windowsSearchDirectories(command, environment)) { + for (const suffix of extensions) { + const candidate = regularExecutable(win32.resolve(directory, `${base}${suffix}`), true); + if (candidate !== undefined) { + return { path: candidate, extension: win32.extname(candidate).toLowerCase() }; + } + } + } + throw new Error('Supervised executable is unavailable.'); +} + +function resolveCanonicalWindowsCorepack(args) { + const runningNode = regularExecutable(process.execPath, true); + const expectedNode = regularExecutable( + win32.resolve(win32.dirname(process.execPath), 'node.exe'), + true, + ); + const corepackScript = regularExecutable( + win32.resolve( + win32.dirname(process.execPath), + 'node_modules', + 'corepack', + 'dist', + 'corepack.js', + ), + true, + ); + if ( + runningNode === undefined || + expectedNode === undefined || + runningNode !== expectedNode || + corepackScript === undefined + ) { + throw new Error('Canonical Windows Corepack installation is unavailable.'); + } + return Object.freeze({ + executable: runningNode, + args: Object.freeze([corepackScript, ...args]), + resolvedCommand: corepackScript, + }); +} + +export function quoteWindowsBatchCommand(batchPath, args) { + const tokens = [batchPath, ...args]; + if ( + tokens.some( + (token) => + typeof token !== 'string' || + token.length === 0 || + unsafeWindowsBatchToken.test(token) || + !safeWindowsBatchToken.test(token), + ) + ) { + throw new Error('Windows batch invocation contains an unsafe token.'); + } + return `call ${tokens.map((token) => `"${token}"`).join(' ')}`; +} + +export function resolveExecutableInvocation( + command, + environment = process.env, + platform = process.platform, + args = [], +) { + if (typeof command !== 'string' || command.length === 0) { + throw new Error('Supervised executable must be a non-empty string.'); + } + if (platform !== 'win32') { + const executable = isAbsolute(command) + ? regularExecutable(command, false) + : (environment.PATH ?? '') + .split(delimiter) + .filter(Boolean) + .map((entry) => regularExecutable(resolve(entry, command), false)) + .find((candidate) => candidate !== undefined); + if (executable === undefined) { + throw new Error('Supervised executable is unavailable.'); + } + return Object.freeze({ + executable, + args: Object.freeze([...args]), + resolvedCommand: executable, + }); + } + + const requestedBase = win32.basename(command).toLowerCase(); + if (requestedBase === 'corepack') { + if (command.includes('/') || command.includes('\\') || win32.isAbsolute(command)) { + throw new Error('Windows Corepack must be requested by its logical command name.'); + } + return resolveCanonicalWindowsCorepack(args); + } + if (['corepack.exe', 'corepack.com', 'corepack.cmd', 'corepack.bat'].includes(requestedBase)) { + throw new Error('Explicit Windows Corepack shim requests are forbidden.'); + } + + const resolved = resolveWindowsCommand(command, environment); + if (resolved.extension !== '.cmd' && resolved.extension !== '.bat') { + return Object.freeze({ + executable: resolved.path, + args: Object.freeze([...args]), + resolvedCommand: resolved.path, + }); + } + + const comspec = windowsEnvironmentValue(environment, 'COMSPEC'); + if (typeof comspec !== 'string' || !win32.isAbsolute(comspec)) { + throw new Error('Windows command interpreter is unavailable.'); + } + const interpreter = regularExecutable(comspec, true); + if ( + interpreter === undefined || + !['.exe', '.com'].includes(win32.extname(interpreter).toLowerCase()) + ) { + throw new Error('Windows command interpreter is unavailable.'); + } + quoteWindowsBatchCommand(resolved.path, args); + return Object.freeze({ + executable: interpreter, + args: Object.freeze(['/d', '/s', '/c', resolved.path, ...args]), + resolvedCommand: resolved.path, + }); +} diff --git a/scripts/phase1-artifact-secret-scan.d.mts b/scripts/phase1-artifact-secret-scan.d.mts index 79fb283..bfba87e 100644 --- a/scripts/phase1-artifact-secret-scan.d.mts +++ b/scripts/phase1-artifact-secret-scan.d.mts @@ -1,40 +1,27 @@ -export const REQUIRED_PHASE1_ASSERTION_IDS: readonly string[]; -export const APPROVED_PHASE1_DIAGNOSTIC_IDS: readonly string[]; - -export type Phase1AssertionResult = { - id: string; - status: 'passed' | 'failed' | 'blocked'; - diagnosticIds: string[]; -}; - -export type Phase1SanitizedReport = { +export type Phase1PlatformEvidence = { schemaVersion: 1; - completed: true; - status: 'passed' | 'failed' | 'blocked'; - platform: { + issue: 'OpenCoven/sdk#38'; + platform: 'darwin-arm64' | 'linux-x64' | 'win32-x64'; + environment: { os: 'darwin' | 'linux' | 'win32'; arch: 'arm64' | 'x64'; + nodeVersion: string; + packageManagerVersion: 'pnpm@10.34.0'; }; - versions: Record; - revisions: { - chat: string; - sdk: string; - cave: string; - coven: string; - }; - artifactDigests: Record; - assertions: Phase1AssertionResult[]; - summary: { - required: number; - passed: number; - failed: number; - blocked: number; - skipped: 0; - }; - diagnosticIds: string[]; + sdkAssertions: Array<{ + id: string; + result: 'pass' | 'fail'; + diagnosticId: string; + }>; + chatAssertions: Array<{ + id: string; + result: 'pass' | 'fail'; + diagnosticId: string; + }>; + [key: string]: unknown; }; -export function validatePhase1SanitizedReport(value: unknown): Phase1SanitizedReport; +export function validatePhase1SanitizedReport(value: unknown): Phase1PlatformEvidence; export function scanPhase1Artifacts(options: { artifactRoot: string }): Promise<{ filesScanned: number; bytesScanned: number; diff --git a/scripts/phase1-artifact-secret-scan.mjs b/scripts/phase1-artifact-secret-scan.mjs index 733243e..7e9879c 100644 --- a/scripts/phase1-artifact-secret-scan.mjs +++ b/scripts/phase1-artifact-secret-scan.mjs @@ -9,14 +9,17 @@ const maxFiles = 32; const maxDepth = 8; const shaPattern = /^[0-9a-f]{40}$/; const digestPattern = /^[0-9a-f]{64}$/; -const identifierPattern = /^[a-z0-9][a-z0-9.-]{0,127}$/; -const versionPattern = /^[0-9A-Za-z][0-9A-Za-z.+-]{0,31}$/; -const passFailStatuses = new Set(['passed', 'failed', 'blocked']); -const platformValues = Object.freeze({ - os: new Set(['darwin', 'linux', 'win32']), - arch: new Set(['arm64', 'x64']), -}); -const approvedVersionKeys = new Set(['harness', 'node', 'rust', 'tauri']); +const identifierPattern = /^[a-z0-9]+(?:[._/-][a-z0-9]+)*$/u; +const supportedPlatforms = new Set(['darwin-arm64', 'linux-x64', 'win32-x64']); +const sdkTarballNames = [ + '@opencoven/sdk-core', + '@opencoven/cave-client', + '@opencoven/coven-client', + '@opencoven/sdk', +]; +const notCoveredScopeIds = ['cross-process-pairing', 'oauth-ui', 'remote-peer', 'write-apis']; +const isolationRootIds = ['cave-home', 'coven-home', 'consumer-home', 'native-credential-store']; +const operatorStateIds = ['cave-home', 'coven-home', 'native-credential-store', 'projects']; const prohibitedContentPatterns = [ /"(?:pairingSecret|pairing_secret|pairing-secret)"\s*:/iu, /\bpairing[ _-]secret\s*[:=]/iu, @@ -26,6 +29,7 @@ const prohibitedContentPatterns = [ /"(?:rawKeychainValue|keychainValue|credentialValue)"\s*:/iu, /"(?:protectedRequestPlaintext|protectedResponsePlaintext)"\s*:/iu, /"(?:prompt|userPrompt|messageBody|attachment|attachmentBody)"\s*:/iu, + /"(?:body|commandOutput|content|message|stderr|stdout)"\s*:/iu, /"(?:privatePath|socketPath|socketHandle)"\s*:/iu, /\/Users\/[^/"\\\s]+\/(?:Library|\.config|\.ssh|Documents)\//u, /\/home\/[^/"\\\s]+\/(?:\.config|\.ssh|Documents)\//u, @@ -33,50 +37,6 @@ const prohibitedContentPatterns = [ /\\\\Users\\\\[^"\\\s]+\\\\(?:AppData|Documents)\\\\/u, ]; -export const REQUIRED_PHASE1_ASSERTION_IDS = Object.freeze([ - 'phase1.missing-cave.validated-launch', - 'phase1.pairing.create-pending-approve-exchange', - 'phase1.pairing.denial', - 'phase1.pairing.expiry', - 'phase1.pairing.wrong-secret-replay', - 'phase1.pairing.failure-budget-retry-after', - 'phase1.credential.restart-reuse', - 'phase1.credential.revocation-repair', - 'phase1.compat.api-major-min-client', - 'phase1.hpke.endpoint-takeover', - 'phase1.reads.bounded-canonical', - 'phase1.reads.stale-generation-cursor-reconciliation', - 'phase1.coven.same-user-identity', - 'phase1.native.missing-keychain-trust', - 'phase1.operator.homes-credentials-untouched', -]); - -export const APPROVED_PHASE1_DIAGNOSTIC_IDS = Object.freeze([ - 'phase1.conformance.passed', - 'phase1.conformance.failed', - 'phase1.conformance.blocked', - 'phase1.assertion.passed', - 'phase1.assertion.failed', - 'phase1.assertion.blocked', - 'phase1.integration.native-pairing-exchange-failed', - 'phase1.integration.native-credential-unavailable', - 'phase1.integration.coven-identity-failed', - 'phase1.producer.cave-launch-fixture-unavailable', - 'phase1.producer.pairing-control-unavailable', - 'phase1.producer.pairing-expiry-control-unavailable', - 'phase1.producer.failure-budget-control-unavailable', - 'phase1.producer.revocation-control-unavailable', - 'phase1.producer.compatibility-control-unavailable', - 'phase1.producer.hpke-takeover-control-unavailable', - 'phase1.producer.canonical-read-fixture-unavailable', - 'phase1.producer.stale-reconciliation-fixture-unavailable', - 'phase1.producer.coven-identity-fixture-unavailable', - 'phase1.producer.native-trust-fixture-unavailable', -]); - -const requiredAssertionSet = new Set(REQUIRED_PHASE1_ASSERTION_IDS); -const approvedDiagnosticSet = new Set(APPROVED_PHASE1_DIAGNOSTIC_IDS); - function requireRecord(value, label) { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`${label} must use the approved report schema.`); @@ -91,133 +51,138 @@ function requireExactKeys(record, keys, label) { } } -function requireApprovedDiagnosticIds(value, label) { - if ( - !Array.isArray(value) || - value.some((id) => typeof id !== 'string' || !approvedDiagnosticSet.has(id)) - ) { - throw new Error(`${label} contains an unapproved diagnostic ID.`); +function validateAssertions(value, label) { + if (!Array.isArray(value)) { + throw new Error(`${label} must be an array.`); } -} - -function validatePlatform(value) { - const platform = requireRecord(value, 'Phase 1 report platform'); - requireExactKeys(platform, ['os', 'arch'], 'Phase 1 report platform'); - - if (!platformValues.os.has(platform.os) || !platformValues.arch.has(platform.arch)) { - throw new Error('Phase 1 report platform must use approved platform metadata.'); - } -} - -function validateVersions(value) { - const versions = requireRecord(value, 'Phase 1 report versions'); - const keys = Object.keys(versions); - - if ( - keys.length === 0 || - keys.some( - (key) => - !approvedVersionKeys.has(key) || - typeof versions[key] !== 'string' || - !versionPattern.test(versions[key]), - ) - ) { - throw new Error('Phase 1 report versions must use approved version metadata.'); - } -} - -function validateRevisions(value) { - const revisions = requireRecord(value, 'Phase 1 report revisions'); - requireExactKeys(revisions, ['chat', 'sdk', 'cave', 'coven'], 'Phase 1 report revisions'); - - if (Object.values(revisions).some((revision) => !shaPattern.test(revision))) { - throw new Error('Phase 1 report revisions must contain immutable commit SHAs.'); - } -} - -function validateArtifactDigests(value) { - const digests = requireRecord(value, 'Phase 1 report artifact digests'); - - for (const [name, digest] of Object.entries(digests)) { - if (!identifierPattern.test(name) || !digestPattern.test(digest)) { - throw new Error( - 'Phase 1 report artifact digests must use approved names and SHA-256 values.', - ); - } - } -} - -function validateAssertions(value) { - if (!Array.isArray(value) || value.length !== REQUIRED_PHASE1_ASSERTION_IDS.length) { - throw new Error('Phase 1 report must contain the exact required assertion set.'); - } - - const observedIds = new Set(); - const statusCounts = { - passed: 0, - failed: 0, - blocked: 0, - }; - + const seen = new Set(); for (const rawAssertion of value) { - const assertion = requireRecord(rawAssertion, 'Phase 1 report assertion'); - requireExactKeys(assertion, ['id', 'status', 'diagnosticIds'], 'Phase 1 report assertion'); - - if (typeof assertion.id !== 'string' || !requiredAssertionSet.has(assertion.id)) { - throw new Error('Phase 1 report contains an unapproved assertion ID.'); + const assertion = requireRecord(rawAssertion, `${label} assertion`); + requireExactKeys(assertion, ['id', 'result', 'diagnosticId'], `${label} assertion`); + if (typeof assertion.id !== 'string' || !identifierPattern.test(assertion.id)) { + throw new Error(`${label} contains a noncanonical assertion ID.`); } - if (observedIds.has(assertion.id)) { - throw new Error('Phase 1 report must contain the exact required assertion set.'); + if (seen.has(assertion.id)) { + throw new Error(`${label} contains a duplicate assertion ID.`); } - observedIds.add(assertion.id); - - if (!passFailStatuses.has(assertion.status)) { - throw new Error('Phase 1 report assertion must use an approved pass-fail status.'); + seen.add(assertion.id); + if (assertion.result === 'skip') { + throw new Error(`${label} must not skip assertions.`); + } + if (!['pass', 'fail'].includes(assertion.result)) { + throw new Error(`${label} contains an invalid assertion result.`); + } + if ( + typeof assertion.diagnosticId !== 'string' || + !identifierPattern.test(assertion.diagnosticId) + ) { + throw new Error(`${label} contains a noncanonical diagnostic ID.`); } - statusCounts[assertion.status] += 1; - requireApprovedDiagnosticIds(assertion.diagnosticIds, 'Phase 1 report assertion'); } +} - if ( - observedIds.size !== requiredAssertionSet.size || - REQUIRED_PHASE1_ASSERTION_IDS.some((id) => !observedIds.has(id)) - ) { - throw new Error('Phase 1 report must contain the exact required assertion set.'); +function validateDigests(value) { + const digests = requireRecord(value, 'Phase 1 report digests'); + requireExactKeys( + digests, + [ + 'caveAssertionEngine', + 'caveContractFixture', + 'hpkeVectors', + 'consumerLock', + 'assertionRegistry', + 'sdkTarballs', + ], + 'Phase 1 report digests', + ); + for (const key of [ + 'caveAssertionEngine', + 'caveContractFixture', + 'hpkeVectors', + 'consumerLock', + 'assertionRegistry', + ]) { + if (typeof digests[key] !== 'string' || !digestPattern.test(digests[key])) { + throw new Error('Phase 1 report digests must contain SHA-256 values.'); + } } - - return statusCounts; + if (!Array.isArray(digests.sdkTarballs) || digests.sdkTarballs.length !== 4) { + throw new Error('Phase 1 report must contain four SDK tarball digests.'); + } + digests.sdkTarballs.forEach((rawArtifact, index) => { + const artifact = requireRecord(rawArtifact, 'SDK tarball digest'); + requireExactKeys(artifact, ['packageName', 'sha256'], 'SDK tarball digest'); + if ( + artifact.packageName !== sdkTarballNames[index] || + typeof artifact.sha256 !== 'string' || + !digestPattern.test(artifact.sha256) + ) { + throw new Error('Phase 1 report SDK tarballs must use canonical order and digests.'); + } + }); } -function validateSummary(value, statusCounts) { - const summary = requireRecord(value, 'Phase 1 report summary'); +function validateIsolation(value) { + const isolation = requireRecord(value, 'Phase 1 report isolation'); requireExactKeys( - summary, - ['required', 'passed', 'failed', 'blocked', 'skipped'], - 'Phase 1 report summary', + isolation, + [ + 'strategy', + 'network', + 'sourceCheckoutDependency', + 'workspaceLinkDependency', + 'retainedPrivatePaths', + 'retainedSocketHandles', + 'roots', + 'operatorState', + ], + 'Phase 1 report isolation', ); - if ( - Object.values(summary).some((count) => !Number.isSafeInteger(count) || count < 0) || - summary.required !== REQUIRED_PHASE1_ASSERTION_IDS.length || - summary.passed !== statusCounts.passed || - summary.failed !== statusCounts.failed || - summary.blocked !== statusCounts.blocked || - summary.skipped !== 0 + isolation.strategy !== 'process-owned-temporary-roots' || + isolation.network !== 'loopback-only' || + isolation.sourceCheckoutDependency !== false || + isolation.workspaceLinkDependency !== false || + isolation.retainedPrivatePaths !== false || + isolation.retainedSocketHandles !== false ) { - throw new Error('Phase 1 report summary does not match its assertion results.'); + throw new Error('Phase 1 report isolation metadata is invalid.'); } -} - -function validateReportStatus(report, statusCounts) { - if (!passFailStatuses.has(report.status)) { - throw new Error('Phase 1 report must use an approved pass-fail status.'); + if (!Array.isArray(isolation.roots) || isolation.roots.length !== isolationRootIds.length) { + throw new Error('Phase 1 report isolation roots are invalid.'); } - - const expectedStatus = - statusCounts.failed > 0 ? 'failed' : statusCounts.blocked > 0 ? 'blocked' : 'passed'; - if (report.status !== expectedStatus) { - throw new Error('Phase 1 report status does not match its assertion results.'); + isolation.roots.forEach((rawRoot, index) => { + const root = requireRecord(rawRoot, 'Phase 1 report isolation root'); + requireExactKeys( + root, + ['id', 'ownershipVerified', 'removedAfterRun'], + 'Phase 1 report isolation root', + ); + if ( + root.id !== isolationRootIds[index] || + root.ownershipVerified !== true || + root.removedAfterRun !== true + ) { + throw new Error('Phase 1 report isolation roots are invalid.'); + } + }); + if ( + !Array.isArray(isolation.operatorState) || + isolation.operatorState.length !== operatorStateIds.length + ) { + throw new Error('Phase 1 report operator state is invalid.'); } + isolation.operatorState.forEach((rawState, index) => { + const state = requireRecord(rawState, 'Phase 1 report operator state'); + requireExactKeys(state, ['id', 'beforeSha256', 'afterSha256'], 'Phase 1 report operator state'); + if ( + state.id !== operatorStateIds[index] || + !digestPattern.test(state.beforeSha256) || + state.beforeSha256 !== state.afterSha256 + ) { + throw new Error('Phase 1 report operator state is invalid.'); + } + }); } export function validatePhase1SanitizedReport(value) { @@ -226,31 +191,73 @@ export function validatePhase1SanitizedReport(value) { report, [ 'schemaVersion', - 'completed', - 'status', + 'issue', 'platform', - 'versions', - 'revisions', - 'artifactDigests', - 'assertions', - 'summary', - 'diagnosticIds', + 'ranAt', + 'environment', + 'releases', + 'commits', + 'digests', + 'caveRecord', + 'sdkAssertions', + 'chatAssertions', + 'coverage', + 'notCovered', + 'isolation', ], 'Phase 1 report', ); - if (report.schemaVersion !== 1 || report.completed !== true) { - throw new Error('Phase 1 report must be a completed version-1 report.'); + if (report.schemaVersion !== 1 || report.issue !== 'OpenCoven/sdk#38') { + throw new Error('Phase 1 report must be an SDK #38 version-1 record.'); } - - validatePlatform(report.platform); - validateVersions(report.versions); - validateRevisions(report.revisions); - validateArtifactDigests(report.artifactDigests); - const statusCounts = validateAssertions(report.assertions); - validateSummary(report.summary, statusCounts); - validateReportStatus(report, statusCounts); - requireApprovedDiagnosticIds(report.diagnosticIds, 'Phase 1 report'); + if (typeof report.platform !== 'string' || !supportedPlatforms.has(report.platform)) { + throw new Error('Phase 1 report must use a supported platform ID.'); + } + const environment = requireRecord(report.environment, 'Phase 1 report environment'); + requireExactKeys( + environment, + ['os', 'arch', 'nodeVersion', 'packageManagerVersion'], + 'Phase 1 report environment', + ); + if ( + `${environment.os}-${environment.arch}` !== report.platform || + environment.nodeVersion !== 'v24.18.1' || + environment.packageManagerVersion !== 'pnpm@10.34.0' + ) { + throw new Error('Phase 1 report environment is invalid.'); + } + const releases = requireRecord(report.releases, 'Phase 1 report releases'); + requireExactKeys(releases, ['cave', 'coven'], 'Phase 1 report releases'); + const commits = requireRecord(report.commits, 'Phase 1 report commits'); + requireExactKeys(commits, ['cave', 'coven', 'sdk', 'chat'], 'Phase 1 report commits'); + if (Object.values(commits).some((revision) => !shaPattern.test(revision))) { + throw new Error('Phase 1 report commits must contain immutable revisions.'); + } + validateDigests(report.digests); + requireRecord(report.caveRecord, 'Phase 1 report Cave record'); + validateAssertions(report.sdkAssertions, 'Phase 1 report SDK assertions'); + validateAssertions(report.chatAssertions, 'Phase 1 report Chat assertions'); + const coverage = requireRecord(report.coverage, 'Phase 1 report coverage'); + requireExactKeys(coverage, ['cave', 'coven', 'sdk', 'chat'], 'Phase 1 report coverage'); + if (Object.values(coverage).some((covered) => covered !== true)) { + throw new Error('Phase 1 report coverage must be complete.'); + } + if (!Array.isArray(report.notCovered) || report.notCovered.length !== 4) { + throw new Error('Phase 1 report notCovered scopes are invalid.'); + } + report.notCovered.forEach((rawScope, index) => { + const scope = requireRecord(rawScope, 'Phase 1 report notCovered scope'); + requireExactKeys(scope, ['scopeId', 'diagnosticId'], 'Phase 1 report notCovered scope'); + if ( + scope.scopeId !== notCoveredScopeIds[index] || + typeof scope.diagnosticId !== 'string' || + !identifierPattern.test(scope.diagnosticId) + ) { + throw new Error('Phase 1 report notCovered scopes are invalid.'); + } + }); + validateIsolation(report.isolation); return report; } diff --git a/scripts/phase1-conformance-launcher.ps1 b/scripts/phase1-conformance-launcher.ps1 new file mode 100644 index 0000000..3b3883d --- /dev/null +++ b/scripts/phase1-conformance-launcher.ps1 @@ -0,0 +1,74 @@ +$ErrorActionPreference = 'Stop' + +$expectedPwsh = 'C:\Program Files\PowerShell\7\pwsh.exe' +$currentPwsh = (Get-Process -Id $PID).Path +if ($PSVersionTable.PSVersion.Major -lt 7 -or + -not $currentPwsh.Equals($expectedPwsh, [StringComparison]::OrdinalIgnoreCase)) { + throw 'phase1-conformance-launcher: canonical PowerShell 7 is required' +} + +if ($args.Count -lt 2) { + throw 'phase1-conformance-launcher: helper and Node paths are required' +} + +$rawHelper = $args[0] +$rawNode = $args[1] +if (-not [IO.Path]::IsPathFullyQualified($rawHelper) -or + -not [IO.Path]::IsPathFullyQualified($rawNode)) { + throw 'phase1-conformance-launcher: trusted inputs must be absolute' +} +$helper = [IO.Path]::GetFullPath($rawHelper) +$node = [IO.Path]::GetFullPath($rawNode) +$runner = Join-Path $PSScriptRoot 'phase1-conformance.mjs' +if (-not $helper.Equals( + 'C:\OpenCoven\conformance\phase1-process-supervisor.exe', + [StringComparison]::OrdinalIgnoreCase) -or + -not (Test-Path -LiteralPath $helper -PathType Leaf) -or + -not (Test-Path -LiteralPath $node -PathType Leaf) -or + -not (Test-Path -LiteralPath $runner -PathType Leaf)) { + throw 'phase1-conformance-launcher: trusted inputs are unavailable' +} +$helperItem = Get-Item -LiteralPath $helper -Force +if (($helperItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + $helperItem.Length -ne 333824 -or + (Get-FileHash -LiteralPath $helper -Algorithm SHA256).Hash.ToLowerInvariant() -ne + '372b3e8b5b860e0759da8fa10ddfb6ec338e26d83616254c816a456ae2e1b7c5') { + throw 'phase1-conformance-launcher: frozen helper verification failed' +} +$nodeItem = Get-Item -LiteralPath $node -Force +$corepackJs = Join-Path $nodeItem.Directory.FullName 'node_modules\corepack\dist\corepack.js' +if (($nodeItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 -or + -not $nodeItem.Name.Equals('node.exe', [StringComparison]::OrdinalIgnoreCase) -or + -not (Test-Path -LiteralPath $corepackJs -PathType Leaf) -or + ((Get-Item -LiteralPath $corepackJs -Force).Attributes -band + [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'phase1-conformance-launcher: approved Node installation unavailable' +} + +$start = [Diagnostics.ProcessStartInfo]::new() +$start.FileName = $helper +$start.UseShellExecute = $false +$start.Environment.Clear() +foreach ($name in @( + 'PATH', 'HOME', 'USERPROFILE', 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT', + 'TEMP', 'TMP', 'CI', 'RUSTUP_HOME', 'CARGO_HOME', + 'OPENCOVEN_CHAT_ROOT', 'OPENCOVEN_SDK_ROOT', 'OPENCOVEN_SDK_EVIDENCE_ROOT', + 'OPENCOVEN_CAVE_ROOT', 'OPENCOVEN_COVEN_ROOT', + 'OPENCOVEN_PHASE1_WINDOWS_SUPERVISOR_PATH' +)) { + $value = [Environment]::GetEnvironmentVariable($name) + if ($null -ne $value) { + $start.Environment[$name] = $value + } +} +$start.ArgumentList.Add('--') +$start.ArgumentList.Add($node) +$start.ArgumentList.Add($runner) +if ($args.Count -gt 2) { + foreach ($argument in $args[2..($args.Count - 1)]) { + $start.ArgumentList.Add($argument) + } +} +$process = [Diagnostics.Process]::Start($start) +$process.WaitForExit() +exit $process.ExitCode diff --git a/scripts/phase1-conformance-launcher.sh b/scripts/phase1-conformance-launcher.sh new file mode 100755 index 0000000..6df0add --- /dev/null +++ b/scripts/phase1-conformance-launcher.sh @@ -0,0 +1,43 @@ +#!/bin/sh +set -eu + +if [ "$#" -lt 1 ]; then + echo "phase1-conformance-launcher: absolute Node path required" >&2 + exit 1 +fi + +node_path=$1 +shift +case "$node_path" in + /*) ;; + *) echo "phase1-conformance-launcher: absolute Node path required" >&2; exit 1 ;; +esac +if [ ! -f "$node_path" ] || [ ! -x "$node_path" ] || [ -L "$node_path" ]; then + echo "phase1-conformance-launcher: trusted Node executable unavailable" >&2 + exit 1 +fi + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) +runner="$script_dir/phase1-conformance.mjs" +if [ ! -f "$runner" ] || [ -L "$runner" ]; then + echo "phase1-conformance-launcher: runner unavailable" >&2 + exit 1 +fi + +exec /usr/bin/env -i \ + PATH="${PATH:-}" \ + HOME="${HOME:-}" \ + TMPDIR="${TMPDIR:-}" \ + LANG="${LANG:-C.UTF-8}" \ + LC_ALL="${LC_ALL:-}" \ + CI="${CI:-1}" \ + RUSTUP_HOME="${RUSTUP_HOME:-}" \ + CARGO_HOME="${CARGO_HOME:-}" \ + OPENCOVEN_CHAT_ROOT="${OPENCOVEN_CHAT_ROOT:-}" \ + OPENCOVEN_SDK_ROOT="${OPENCOVEN_SDK_ROOT:-}" \ + OPENCOVEN_SDK_EVIDENCE_ROOT="${OPENCOVEN_SDK_EVIDENCE_ROOT:-}" \ + OPENCOVEN_CAVE_ROOT="${OPENCOVEN_CAVE_ROOT:-}" \ + OPENCOVEN_COVEN_ROOT="${OPENCOVEN_COVEN_ROOT:-}" \ + OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED="${OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED:-}" \ + PHASE1_TEST_KEYCHAIN="${PHASE1_TEST_KEYCHAIN:-}" \ + "$node_path" "$runner" "$@" diff --git a/scripts/phase1-conformance-lock.d.mts b/scripts/phase1-conformance-lock.d.mts index 8eee0c3..b383114 100644 --- a/scripts/phase1-conformance-lock.d.mts +++ b/scripts/phase1-conformance-lock.d.mts @@ -8,14 +8,69 @@ export function createGitEnvironment( ): Record; export function readPhase1ConformanceLock(lockPath?: string): { path: string; - version: 1; + version: 5; chat: Phase1LockEntry; sdk: Phase1LockEntry; cave: Phase1LockEntry; coven: Phase1LockEntry; + harness: Phase1LockEntry; + chatAuthority: { + tree: string; + files: Array<{ path: string; blob: string; sha256: string }>; + }; + tools: { + windowsSupervisor: { + source: { + repository: string; + revision: string; + path: string; + blob: string; + sha256: string; + manifestSha256: string; + lockSha256: string; + configSha256: string; + }; + toolchain: { + homebrewCoreRevision: string; + packageVersion: string; + bottleLayerSha256: string; + linkerVersion: string; + }; + artifact: { + target: string; + buildInvocation: string; + fileName: string; + fleetPath: string; + size: number; + sha256: string; + }; + }; + }; + release: { + sdkManifest: { version: string; sha256: string }; + sdkArtifacts: Array<{ + packageName: string; + releaseFile: string; + vendorFile: string; + size: number; + sha256: string; + }>; + caveVersion: string; + covenVersion: string; + consumerLock: { path: string; size: number; sha256: string }; + caveArtifacts: Record; + }; + evidence: { + repository: string; + revision: string; + contract: { path: string; sha256: string }; + schema: { path: string; sha256: string }; + assertionRegistry: { path: string; sha256: string }; + }; }; export function assertCleanPhase1Checkouts(options: { chatRoot: string; + chatHarnessRoot?: string; sdkRoot: string; caveRoot: string; covenRoot: string; @@ -24,20 +79,23 @@ export function assertCleanPhase1Checkouts(options: { sdk: { staged: number; unstaged: number; untracked: number }; cave: { staged: number; unstaged: number; untracked: number }; coven: { staged: number; unstaged: number; untracked: number }; + harness?: { staged: number; unstaged: number; untracked: number }; }; export function assertPhase1CheckoutHeads( lock: unknown, options: { chatRoot: string; + chatHarnessRoot?: string; sdkRoot: string; caveRoot: string; covenRoot: string; }, -): { chat: string; sdk: string; cave: string; coven: string }; +): { chat: string; sdk: string; cave: string; coven: string; harness?: string }; export const phase1ConformanceTestOnly: { assertCleanPhase1Checkouts( options: { chatRoot: string; + chatHarnessRoot?: string; sdkRoot: string; caveRoot: string; covenRoot: string; @@ -54,11 +112,13 @@ export const phase1ConformanceTestOnly: { sdk: { staged: number; unstaged: number; untracked: number }; cave: { staged: number; unstaged: number; untracked: number }; coven: { staged: number; unstaged: number; untracked: number }; + harness?: { staged: number; unstaged: number; untracked: number }; }; assertPhase1CheckoutHeads( lock: unknown, options: { chatRoot: string; + chatHarnessRoot?: string; sdkRoot: string; caveRoot: string; covenRoot: string; @@ -70,5 +130,5 @@ export const phase1ConformanceTestOnly: { trackedPathByteLimit?: number; }; }, - ): { chat: string; sdk: string; cave: string; coven: string }; + ): { chat: string; sdk: string; cave: string; coven: string; harness?: string }; }; diff --git a/scripts/phase1-conformance-lock.mjs b/scripts/phase1-conformance-lock.mjs index 7047c55..6e7ab9c 100644 --- a/scripts/phase1-conformance-lock.mjs +++ b/scripts/phase1-conformance-lock.mjs @@ -1,4 +1,4 @@ -import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { chmodSync, closeSync, @@ -18,16 +18,73 @@ import { devNull } from 'node:os'; import { dirname, resolve } from 'node:path'; import { performance } from 'node:perf_hooks'; import { fileURLToPath } from 'node:url'; +import { runSupervisedSync } from './supervised-exec.mjs'; const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const defaultLockPath = resolve(projectRoot, 'phase1-conformance.lock.json'); const revisionPattern = /^[0-9a-f]{40}$/; +const digestPattern = /^[0-9a-f]{64}$/; const repositoryKeys = ['chat', 'sdk', 'cave', 'coven']; +const canonicalSdkArtifacts = Object.freeze([ + { + packageName: '@opencoven/sdk-core', + releaseFile: 'tarballs/core/opencoven-sdk-core-0.1.0.tgz', + vendorFile: 'sdk-core-0.1.0.tgz', + }, + { + packageName: '@opencoven/cave-client', + releaseFile: 'tarballs/cave/opencoven-cave-client-0.1.0.tgz', + vendorFile: 'cave-client-0.1.0.tgz', + }, + { + packageName: '@opencoven/coven-client', + releaseFile: 'tarballs/coven/opencoven-coven-client-0.1.0.tgz', + vendorFile: 'coven-client-0.1.0.tgz', + }, + { + packageName: '@opencoven/sdk', + releaseFile: 'tarballs/sdk/opencoven-sdk-0.1.0.tgz', + vendorFile: 'sdk-0.1.0.tgz', + }, +]); +const productionChatAuthorityPaths = Object.freeze([ + 'src-tauri/Cargo.toml', + 'src-tauri/Cargo.lock', + 'src-tauri/src/bin/phase1-native-rpc.rs', + 'src-tauri/src/conformance.rs', + 'src-tauri/src/coven.rs', + 'src-tauri/src/keyring.rs', + 'src-tauri/src/lib.rs', +]); +const harnessAuthorityPaths = Object.freeze([ + 'scripts/phase1-conformance.mjs', + 'scripts/phase1-conformance-launcher.sh', + 'scripts/phase1-conformance-launcher.ps1', + 'scripts/phase1-process-supervisor.mjs', + 'scripts/executable-resolution.mjs', + 'scripts/process-owned-artifact-root.mjs', + 'scripts/owned-temp-directory.mjs', + 'scripts/phase1-conformance-lock.mjs', + 'scripts/phase1-evidence-contract.mjs', + 'scripts/supervised-exec.mjs', + 'scripts/supervisor-status.mjs', + 'scripts/phase1-artifact-secret-scan.mjs', + '.github/workflows/ci.yml', +]); +const productionDeltaPaths = Object.freeze([ + 'src-tauri/Cargo.toml', + 'src-tauri/src/bin/phase1-native-rpc.rs', + 'src-tauri/src/conformance.rs', + 'src-tauri/src/connection.rs', + 'src-tauri/src/coven.rs', + 'src-tauri/src/keyring.rs', +]); const expectedRepositories = Object.freeze({ chat: 'OpenCoven/chat', sdk: 'OpenCoven/sdk', cave: 'OpenCoven/coven-cave', coven: 'OpenCoven/coven', + harness: 'OpenCoven/chat', }); const gitConfigurationOverrides = [ '-c', @@ -159,6 +216,344 @@ function normalizeLockEntry(lockData, key) { }); } +function normalizeArtifact(value, expected, label) { + const artifact = requireRecord(value, label); + requireExactKeys( + artifact, + ['packageName', 'releaseFile', 'vendorFile', 'size', 'sha256'], + `${label} must contain exact package metadata.`, + ); + if ( + artifact.packageName !== expected.packageName || + artifact.releaseFile !== expected.releaseFile || + artifact.vendorFile !== expected.vendorFile || + !Number.isSafeInteger(artifact.size) || + artifact.size <= 0 || + typeof artifact.sha256 !== 'string' || + !digestPattern.test(artifact.sha256) + ) { + throw new Error(`${label} is invalid.`); + } + return Object.freeze({ ...artifact }); +} + +function normalizeFileArtifact(value, expectedPath, label) { + const artifact = requireRecord(value, label); + requireExactKeys( + artifact, + ['path', 'size', 'sha256'], + `${label} must contain exactly path, size, and sha256.`, + ); + if ( + artifact.path !== expectedPath || + !Number.isSafeInteger(artifact.size) || + artifact.size <= 0 || + typeof artifact.sha256 !== 'string' || + !digestPattern.test(artifact.sha256) + ) { + throw new Error(`${label} is invalid.`); + } + return Object.freeze({ ...artifact }); +} + +function normalizeDigestFile(value, expectedPath, label) { + const artifact = requireRecord(value, label); + requireExactKeys(artifact, ['path', 'sha256'], `${label} must contain exactly path and sha256.`); + if ( + artifact.path !== expectedPath || + typeof artifact.sha256 !== 'string' || + !digestPattern.test(artifact.sha256) + ) { + throw new Error(`${label} is invalid.`); + } + return Object.freeze({ ...artifact }); +} + +function normalizeRelease(value) { + const release = requireRecord(value, 'phase1-conformance.lock.json release'); + requireExactKeys( + release, + ['sdkManifest', 'sdkArtifacts', 'caveVersion', 'covenVersion', 'consumerLock', 'caveArtifacts'], + 'phase1-conformance.lock.json release entry is invalid.', + ); + const manifest = requireRecord(release.sdkManifest, 'release.sdkManifest'); + requireExactKeys( + manifest, + ['version', 'sha256'], + 'release.sdkManifest must contain exactly version and sha256.', + ); + if ( + manifest.version !== '0.1.0' || + typeof manifest.sha256 !== 'string' || + !digestPattern.test(manifest.sha256) + ) { + throw new Error('release.sdkManifest is invalid.'); + } + if ( + !Array.isArray(release.sdkArtifacts) || + release.sdkArtifacts.length !== canonicalSdkArtifacts.length + ) { + throw new Error('release.sdkArtifacts must contain four packages in canonical package order.'); + } + const sdkArtifacts = release.sdkArtifacts.map((artifact, index) => { + const expected = canonicalSdkArtifacts[index]; + if (artifact?.packageName !== expected.packageName) { + throw new Error('release.sdkArtifacts must use canonical package order.'); + } + return normalizeArtifact(artifact, expected, `release.sdkArtifacts[${index}]`); + }); + const canonicalManifest = `${JSON.stringify( + { + schemaVersion: 1, + version: manifest.version, + packages: sdkArtifacts.map((artifact) => ({ + name: artifact.packageName, + version: manifest.version, + file: artifact.releaseFile, + size: artifact.size, + sha256: artifact.sha256, + })), + }, + null, + 2, + )}\n`; + if (createHash('sha256').update(canonicalManifest).digest('hex') !== manifest.sha256) { + throw new Error('release SDK manifest digest does not match canonical package metadata.'); + } + if (release.caveVersion !== '0.3.12' || release.covenVersion !== '0.1.0') { + throw new Error('release authority versions are invalid.'); + } + const consumerLock = normalizeFileArtifact( + release.consumerLock, + 'pnpm-lock.yaml', + 'release.consumerLock', + ); + const caveArtifacts = requireRecord(release.caveArtifacts, 'release.caveArtifacts'); + requireExactKeys( + caveArtifacts, + ['assertionEngine', 'contractFixture', 'hpkeVectors'], + 'release.caveArtifacts is invalid.', + ); + return Object.freeze({ + sdkManifest: Object.freeze({ ...manifest }), + sdkArtifacts: Object.freeze(sdkArtifacts), + caveVersion: release.caveVersion, + covenVersion: release.covenVersion, + consumerLock, + caveArtifacts: Object.freeze({ + assertionEngine: normalizeFileArtifact( + caveArtifacts.assertionEngine, + 'scripts/client-v1-conformance.mjs', + 'release.caveArtifacts.assertionEngine', + ), + contractFixture: normalizeFileArtifact( + caveArtifacts.contractFixture, + 'src/lib/server/client-v1/contract-fixture.json', + 'release.caveArtifacts.contractFixture', + ), + hpkeVectors: normalizeFileArtifact( + caveArtifacts.hpkeVectors, + 'src/lib/server/client-v1/hpke-bound-v1-vectors.json', + 'release.caveArtifacts.hpkeVectors', + ), + }), + }); +} + +function normalizeEvidence(value) { + const evidence = requireRecord(value, 'phase1-conformance.lock.json evidence'); + requireExactKeys( + evidence, + ['repository', 'revision', 'contract', 'schema', 'assertionRegistry'], + 'phase1-conformance.lock.json evidence entry is invalid.', + ); + if (evidence.repository !== 'OpenCoven/sdk') { + throw new Error('evidence.repository must be OpenCoven/sdk.'); + } + if (typeof evidence.revision !== 'string' || !revisionPattern.test(evidence.revision)) { + throw new Error('evidence.revision must be a lowercase immutable 40-character commit SHA.'); + } + return Object.freeze({ + repository: evidence.repository, + revision: evidence.revision, + contract: normalizeDigestFile( + evidence.contract, + 'scripts/conformance-contract.mjs', + 'evidence.contract', + ), + schema: normalizeDigestFile( + evidence.schema, + 'conformance/client-v1-cross-repository-evidence.schema.json', + 'evidence.schema', + ), + assertionRegistry: normalizeDigestFile( + evidence.assertionRegistry, + 'conformance/client-v1-cross-repository-assertions.json', + 'evidence.assertionRegistry', + ), + }); +} + +function normalizeChatAuthority(value) { + const authority = requireRecord(value, 'phase1-conformance.lock.json chatAuthority'); + requireExactKeys( + authority, + ['tree', 'files'], + 'phase1-conformance.lock.json chatAuthority entry is invalid.', + ); + if (typeof authority.tree !== 'string' || !revisionPattern.test(authority.tree)) { + throw new Error('chatAuthority.tree must be an immutable Git tree ID.'); + } + + if ( + !Array.isArray(authority.files) || + authority.files.length !== productionChatAuthorityPaths.length + ) { + throw new Error('chatAuthority files must use canonical Chat authority file order.'); + } + const files = authority.files.map((rawFile, index) => { + const file = requireRecord(rawFile, `chatAuthority.files[${index}]`); + requireExactKeys(file, ['path', 'blob', 'sha256'], `chatAuthority.files[${index}] is invalid.`); + if ( + file.path !== productionChatAuthorityPaths[index] || + typeof file.blob !== 'string' || + !revisionPattern.test(file.blob) || + typeof file.sha256 !== 'string' || + !digestPattern.test(file.sha256) + ) { + throw new Error('chatAuthority files must use canonical Chat authority file order.'); + } + return Object.freeze({ ...file }); + }); + return Object.freeze({ + tree: authority.tree, + files: Object.freeze(files), + }); +} + +function normalizeAuthorityFiles(value, expectedPaths, label) { + if (!Array.isArray(value) || value.length !== expectedPaths.length) { + throw new Error(`${label} must use canonical file order.`); + } + return Object.freeze( + value.map((rawFile, index) => { + const file = requireRecord(rawFile, `${label}[${index}]`); + requireExactKeys(file, ['path', 'blob', 'sha256'], `${label}[${index}] is invalid.`); + if ( + file.path !== expectedPaths[index] || + typeof file.blob !== 'string' || + !revisionPattern.test(file.blob) || + typeof file.sha256 !== 'string' || + !digestPattern.test(file.sha256) + ) { + throw new Error(`${label} must use canonical file order.`); + } + return Object.freeze({ ...file }); + }), + ); +} + +function normalizeHarnessAuthority(value) { + const authority = requireRecord(value, 'phase1-conformance.lock.json harnessAuthority'); + requireExactKeys( + authority, + ['revision', 'tree', 'files', 'productionDeltas'], + 'phase1-conformance.lock.json harnessAuthority entry is invalid.', + ); + if ( + typeof authority.revision !== 'string' || + !revisionPattern.test(authority.revision) || + typeof authority.tree !== 'string' || + !revisionPattern.test(authority.tree) + ) { + throw new Error('harnessAuthority revision and tree must be immutable Git IDs.'); + } + return Object.freeze({ + revision: authority.revision, + tree: authority.tree, + files: normalizeAuthorityFiles( + authority.files, + harnessAuthorityPaths, + 'harnessAuthority.files', + ), + productionDeltas: normalizeAuthorityFiles( + authority.productionDeltas, + productionDeltaPaths, + 'harnessAuthority.productionDeltas', + ), + }); +} + +function normalizeTools(value) { + const tools = requireRecord(value, 'phase1-conformance.lock.json tools'); + requireExactKeys(tools, ['windowsSupervisor'], 'phase1-conformance.lock.json tools is invalid.'); + const supervisor = requireRecord(tools.windowsSupervisor, 'tools.windowsSupervisor'); + requireExactKeys( + supervisor, + ['source', 'toolchain', 'artifact'], + 'tools.windowsSupervisor is invalid.', + ); + const source = requireRecord(supervisor.source, 'tools.windowsSupervisor.source'); + requireExactKeys( + source, + [ + 'repository', + 'revision', + 'path', + 'blob', + 'sha256', + 'manifestSha256', + 'lockSha256', + 'configSha256', + ], + 'tools.windowsSupervisor.source is invalid.', + ); + const toolchain = requireRecord(supervisor.toolchain, 'tools.windowsSupervisor.toolchain'); + requireExactKeys( + toolchain, + ['homebrewCoreRevision', 'packageVersion', 'bottleLayerSha256', 'linkerVersion'], + 'tools.windowsSupervisor.toolchain is invalid.', + ); + const artifact = requireRecord(supervisor.artifact, 'tools.windowsSupervisor.artifact'); + requireExactKeys( + artifact, + ['target', 'buildInvocation', 'fileName', 'fleetPath', 'size', 'sha256'], + 'tools.windowsSupervisor.artifact is invalid.', + ); + if ( + source.repository !== 'OpenCoven/chat' || + !revisionPattern.test(source.revision) || + source.path !== 'tools/phase1-process-supervisor/src/main.rs' || + !revisionPattern.test(source.blob) || + !digestPattern.test(source.sha256) || + !digestPattern.test(source.manifestSha256) || + !digestPattern.test(source.lockSha256) || + !digestPattern.test(source.configSha256) || + toolchain.homebrewCoreRevision !== 'cd168d1fdc26f12e4ad64f358ff2dbec61ab7a57' || + toolchain.packageVersion !== 'mingw-w64 14.0.0_3' || + toolchain.bottleLayerSha256 !== + '0d68ab737a8bbc8c63ac6ac7acc0695e2887c1169df9a4423f1180090079b1d5' || + toolchain.linkerVersion !== '2.47.20260726' || + artifact.target !== 'x86_64-pc-windows-gnu' || + artifact.buildInvocation !== + 'cd tools/phase1-process-supervisor && SOURCE_DATE_EPOCH=0 cargo build --target x86_64-pc-windows-gnu --release --locked' || + artifact.fileName !== 'phase1-process-supervisor.exe' || + artifact.fleetPath !== 'C:\\OpenCoven\\conformance\\phase1-process-supervisor.exe' || + !Number.isSafeInteger(artifact.size) || + artifact.size <= 0 || + !digestPattern.test(artifact.sha256) + ) { + throw new Error('tools.windowsSupervisor metadata is invalid.'); + } + return Object.freeze({ + windowsSupervisor: Object.freeze({ + source: Object.freeze({ ...source }), + toolchain: Object.freeze({ ...toolchain }), + artifact: Object.freeze({ ...artifact }), + }), + }); +} + export function readPhase1ConformanceLock(lockPath = defaultLockPath) { const path = requireExistingFile(lockPath, 'Phase 1 conformance lock'); let lockData; @@ -172,31 +567,48 @@ export function readPhase1ConformanceLock(lockPath = defaultLockPath) { requireRecord(lockData, 'phase1-conformance.lock.json'); requireExactKeys( lockData, - ['version', ...repositoryKeys], - 'phase1-conformance.lock.json must contain exactly version, chat, sdk, cave, and coven.', + [ + 'version', + ...repositoryKeys, + 'harness', + 'chatAuthority', + 'harnessAuthority', + 'tools', + 'release', + 'evidence', + ], + 'phase1-conformance.lock.json must contain exactly version, chat, sdk, cave, coven, harness, chatAuthority, harnessAuthority, tools, release, and evidence.', ); - if (lockData.version !== 1) { - throw new Error('phase1-conformance.lock.json version must be 1.'); + if (lockData.version !== 5) { + throw new Error('phase1-conformance.lock.json version must be 5.'); } return Object.freeze({ path, - version: 1, + version: 5, chat: normalizeLockEntry(lockData, 'chat'), sdk: normalizeLockEntry(lockData, 'sdk'), cave: normalizeLockEntry(lockData, 'cave'), coven: normalizeLockEntry(lockData, 'coven'), + harness: normalizeLockEntry(lockData, 'harness'), + chatAuthority: normalizeChatAuthority(lockData.chatAuthority), + harnessAuthority: normalizeHarnessAuthority(lockData.harnessAuthority), + tools: normalizeTools(lockData.tools), + release: normalizeRelease(lockData.release), + evidence: normalizeEvidence(lockData.evidence), }); } function normalizeCheckoutRoots(checkoutRoots) { const roots = requireRecord(checkoutRoots, 'Phase 1 checkout roots'); const normalizedRoots = {}; + const keys = [...repositoryKeys, ...(Object.hasOwn(roots, 'chatHarnessRoot') ? ['harness'] : [])]; - for (const key of repositoryKeys) { + for (const key of keys) { const label = `${key} checkout root`; - const path = requirePathString(roots[`${key}Root`], label); + const property = key === 'harness' ? 'chatHarnessRoot' : `${key}Root`; + const path = requirePathString(roots[property], label); if (!existsSync(path)) { throw new Error(`${label} does not exist.`); @@ -209,7 +621,7 @@ function normalizeCheckoutRoots(checkoutRoots) { normalizedRoots[key] = path; } - return normalizedRoots; + return { keys, roots: normalizedRoots }; } function throwUnreadableGitCheckout(label) { @@ -282,7 +694,7 @@ function runGit(repositoryRoot, args, context, input, trackedPathOutput = false) try { const timeout = remainingGitTimeout(context); - return execFileSync( + return runSupervisedSync( 'git', [ ...gitConfigurationOverrides, @@ -802,10 +1214,11 @@ function assertCleanCheckout(repositoryRoot, context) { } function assertCleanPhase1CheckoutsWithLimits(checkoutRoots, limits) { - const roots = normalizeCheckoutRoots(checkoutRoots); + const normalized = normalizeCheckoutRoots(checkoutRoots); + const { keys, roots } = normalized; const summaries = {}; - for (const key of repositoryKeys) { + for (const key of keys) { const label = `${key} checkout`; const context = createRepositoryVerificationContext(label, limits); assertNoLocalExcludeRules(roots[key], context); @@ -836,10 +1249,11 @@ function requireLockedRevision(lock, key) { } function assertPhase1CheckoutHeadsWithLimits(lock, checkoutRoots, limits) { - const roots = normalizeCheckoutRoots(checkoutRoots); + const normalized = normalizeCheckoutRoots(checkoutRoots); + const { keys, roots } = normalized; const revisions = {}; - for (const key of repositoryKeys) { + for (const key of keys) { const expectedRevision = requireLockedRevision(lock, key); const label = `${key} checkout`; const context = createRepositoryVerificationContext(label, limits); diff --git a/scripts/phase1-conformance.d.mts b/scripts/phase1-conformance.d.mts index 4fd2b46..368e71d 100644 --- a/scripts/phase1-conformance.d.mts +++ b/scripts/phase1-conformance.d.mts @@ -1,9 +1,3 @@ -export type Phase1Assertion = { - id: string; - status: 'passed' | 'failed' | 'blocked'; - diagnosticIds: string[]; -}; - export const cargoBuildTimeoutMs: number; export function parseArgs(argv: string[]): { lockPath: string; @@ -11,66 +5,222 @@ export function parseArgs(argv: string[]): { retainSanitizedReport: string; chatSourceRoot: string; sdkSourceRoot: string; + sdkEvidenceSourceRoot: string; caveSourceRoot: string; covenSourceRoot: string; + windowsSupervisorPath?: string; }; -export function assertExactAssertionResults(assertions: Phase1Assertion[]): Phase1Assertion[]; -export function buildPhase1Report(options: { - assertions: Phase1Assertion[]; - revisions: { - chat: string; - sdk: string; - cave: string; - coven: string; - }; - artifactDigests: Record; - versions: Record; +export function observeReleaseToolVersions(): { + nodeVersion: 'v24.18.1'; + packageManagerVersion: 'pnpm@10.34.0'; + rustVersion: '1.95.0'; +}; +export function parseCaveConformanceOutput(output: string): Map; +export function parsePassedRustTests(output: string): Set; +export function resolveLockedCovenDaemonCommand( + artifactRoot: { rootPath: string }, + lockedCovenCheckoutRoot: string, + expectedCovenRevision: string, + covenBinaryPath: string, +): Readonly<{ + executable: string; + args: readonly ['daemon', 'serve']; + cwd: string; +}>; +export function establishNativeCleanupReservation( + rpc: { + request(command: string, args?: unknown): Promise; + ok(command: string, args?: unknown): Promise; + }, + handle: string, + onStage?: ( + stage: + | 'reservation-request' + | 'reservation-keychain' + | 'reservation-store-unavailable' + | 'reservation-invalid-handle' + | 'reservation-discovery-required' + | 'reservation-health-required' + | 'reservation-rejected' + | 'reservation-response' + | 'reservation-cleanup', + ) => void, +): Promise>; +export function createCleanupAdoptionRecovery(reservation: { + reservationHandle: string; + capability: string; + ownerToken: string; }): { - schemaVersion: 1; - completed: true; - status: 'passed' | 'failed' | 'blocked'; - platform: { os: string; arch: string }; - versions: Record; - revisions: Record; - artifactDigests: Record; - assertions: Phase1Assertion[]; - summary: { - required: number; - passed: number; - failed: number; - blocked: number; - skipped: 0; + predecessor: { reservationHandle: string; capability: string; ownerToken: string }; + successor: { reservationHandle: string; capability: string; ownerToken: string }; + deleted: boolean; +}; +export function adoptNativeCleanupReservation( + rpc: { + request(command: string, args?: unknown): Promise; + ok(command: string, args?: unknown): Promise; + }, + recovery: { + predecessor: { reservationHandle: string; capability: string; ownerToken: string }; + successor: { reservationHandle: string; capability: string; ownerToken: string }; + deleted: boolean; + }, + openRecoveryRpc?: () => Promise<{ + request(command: string, args?: unknown): Promise; + ok(command: string, args?: unknown): Promise; + close(): Promise; + }>, +): Promise>; +export function runReservedNativePairing(options: { + rpc: { + request(command: string, args?: unknown): Promise; + ok(command: string, args?: unknown): Promise; + operation(): unknown; }; - diagnosticIds: string[]; + handle: string; + origin: string; + adminToken: string; + installationId: string; + approvePairing?: (...args: unknown[]) => Promise; + onReservation?: (reservation: { + reservationHandle: string; + capability: string; + ownerToken: string; + }) => void; + onCredentialMayExist?: () => void; + onStage?: ( + stage: + | 'reservation-request' + | 'reservation-keychain' + | 'reservation-store-unavailable' + | 'reservation-invalid-handle' + | 'reservation-discovery-required' + | 'reservation-health-required' + | 'reservation-rejected' + | 'reservation-response' + | 'reservation-cleanup' + | 'credential-status' + | 'create' + | 'pending' + | 'approve' + | 'approved' + | 'exchange', + ) => void; +}): Promise; +export function runNativeScenarioOrchestrator(options: { + runPairing(): Promise; + runLifecycle(pairing: T): Promise; +}): Promise; +export function throwNativeScenarioFailures(options: { + scenarioFailure?: unknown; + cleanupFailure?: unknown; + rpcCleanupFailure?: unknown; + daemonCloseFailure?: unknown; +}): void; +export function bootstrapWindowsSupervisor(options: { + lockPath?: string; + windowsSupervisorPath?: string; +}): unknown; +export function assertNoNodeRuntimeInjection( + environment?: NodeJS.ProcessEnv, + execArgv?: string[], +): void; +export function assertExecutingHarnessAuthority( + lock: unknown, + executingRoot?: string, + environment?: NodeJS.ProcessEnv, +): void; +export function assertProductionAdapterAtRevision(harnessRoot: string, lock: unknown): void; +export function finalizeOperatorSafety(options: { + primaryFailure?: unknown; + cleanupFailure?: unknown; + operatorStateBefore?: unknown; + snapshotAfter?: () => unknown; + compare?: (before: unknown, after: unknown) => void; +}): unknown; +export function runSupervisedCommandForTest( + artifactRoot: { + rootPath: string; + trackChild(child: import('node:child_process').ChildProcess): void; + }, + command: string, + args: readonly string[], + options: { + cwd: string; + env: NodeJS.ProcessEnv; + timeoutMs?: number; + outputLimitBytes?: number; + }, +): Promise; +export function parseSupervisorStatusFrame(bytes: Buffer | string): { + code: number | null; + signal: string | null; + reason: string; }; -export function parseCaveConformanceOutput(output: string): Map; +export function runOwnedProcessStatusForTest( + command: string, + args: readonly string[], + options: { cwd: string; env: NodeJS.ProcessEnv }, +): Promise; +export function validateSupervisorArtifactFile( + path: string, + metadata: { size: number; sha256: string }, +): string; export function assertPairingStatus( value: { status?: string } | null | undefined, expectedStatus: string, ): { status?: string }; -export function assertCompatibilityFailure( - error: { code?: string } | null | undefined, - preset: string, -): { code: 'incompatible_version' }; -export function assertNativeMissingKeychainResponses(responses: unknown[]): unknown[]; export class CommandExecutionError extends Error { label: string; result: unknown; constructor(label: string, result: unknown); } -export function classifyCavePackageFailure(result: { - stdout?: string; - stderr?: string; -}): string | undefined; +export function publicPhase1FailureDiagnostic(error: unknown): string | undefined; +export function extractVerifiedRunnerDiagnostic(stderr: unknown): string | undefined; +export function classifyPackagingCommandFailure(baseId: string, error: unknown): string; +export function diagnoseCovenLifecycleFailure( + original: unknown, + rerun: (testName: string) => Promise, +): Promise; export class NativeRpcClient { - constructor(child: unknown, options?: { shutdownTimeoutMs?: number }); + constructor(child: unknown, options?: { shutdownTimeoutMs?: number; supervised?: boolean }); + request(command: string, args?: unknown): Promise; close(): Promise; } export function safeEnvironment( rootPath: string, extra?: Record, - resolvedCargoPath?: string, ): Record; +export function caveBuildEnvironment(environment: Record): Record; +export function snapshotOperatorState(operatorHome?: string): { + 'cave-home': string; + 'coven-home': string; + projects: string; +}; +export function resolveRustupHome(environment?: NodeJS.ProcessEnv): string; +export function nativeAdapterTestEnvironment( + environment: Record, + platform?: NodeJS.Platform, + operatorEnvironment?: NodeJS.ProcessEnv, +): Record; +export function nativeMissingKeychainFailureDiagnostic(options: { + supervised: boolean; + code: number | null; + signal: NodeJS.Signals | null; + supervisorStatusValid: boolean; + terminationReason: string | undefined; + killFailed: boolean; + processFailed: boolean; + canaryExposed: boolean; + homeChanged: boolean; + responseValid: boolean; +}): string | undefined; +export function nativeMissingKeychainResponsesValid(responses: unknown): boolean; +export function runtimeScenarioFailureDiagnostic( + results: ReadonlyMap, +): string | undefined; +export function covenIdentityFailureDiagnostic(stage: unknown): string; +export function evidenceValidationFailureDiagnostic(error: unknown): string; export function withFixtureDaemon( fixtureDaemon: { close(): Promise }, action: () => Promise, diff --git a/scripts/phase1-conformance.mjs b/scripts/phase1-conformance.mjs index 29481e2..e0493b8 100644 --- a/scripts/phase1-conformance.mjs +++ b/scripts/phase1-conformance.mjs @@ -1,46 +1,47 @@ -#!/usr/bin/env node - -import { execFileSync, spawn } from 'node:child_process'; +import { spawn } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { once } from 'node:events'; import { chmodSync, + existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, + symlinkSync, writeFileSync, } from 'node:fs'; import { createServer, request as httpRequest } from 'node:http'; import { devNull } from 'node:os'; -import { delimiter, dirname, resolve } from 'node:path'; +import { delimiter, dirname, isAbsolute, resolve, win32 as windowsPath } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; - -import { - APPROVED_PHASE1_DIAGNOSTIC_IDS, - REQUIRED_PHASE1_ASSERTION_IDS, - scanPhase1Artifacts, - validatePhase1SanitizedReport, -} from './phase1-artifact-secret-scan.mjs'; +import { isDeepStrictEqual, stripVTControlCharacters } from 'node:util'; +import { resolveExecutableInvocation } from './executable-resolution.mjs'; +import { scanPhase1Artifacts } from './phase1-artifact-secret-scan.mjs'; import { assertCleanPhase1Checkouts, assertPhase1CheckoutHeads, createGitEnvironment, readPhase1ConformanceLock, } from './phase1-conformance-lock.mjs'; +import { + buildPlatformEvidence, + canonicalPlatformId, + createAssertionRecorder, + parseLockedAssertionRegistry, + validateObservedToolVersions, + windowsSupervisorDiagnosticId, +} from './phase1-evidence-contract.mjs'; import { createProcessOwnedArtifactRoot } from './process-owned-artifact-root.mjs'; +import { configureSupervisedExecution, runSupervisedSync } from './supervised-exec.mjs'; +import { parseSupervisorStatusFrame } from './supervisor-status.mjs'; + +export { parseSupervisorStatusFrame } from './supervisor-status.mjs'; const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const gitCommonDirectory = resolve( - projectRoot, - execFileSync('git', ['rev-parse', '--git-common-dir'], { - cwd: projectRoot, - encoding: 'utf8', - }).trim(), -); -const chatRepositoryRoot = dirname(gitCommonDirectory); +const chatRepositoryRoot = projectRoot; const repositoriesParent = dirname(chatRepositoryRoot); const defaultRetainedReport = resolve( projectRoot, @@ -52,38 +53,110 @@ const commandOutputLimit = 16 * 1024 * 1024; const revocationConfirmationDelayMs = 550; const commandTimeoutMs = 20 * 60_000; export const cargoBuildTimeoutMs = 45 * 60_000; +const caveBuildNodeOptions = '--max-old-space-size=6144'; +const caveBuildReportedCpuTotal = '3'; const rpcTimeoutMs = 10_000; const caveConformanceTimeoutMs = 15 * 60_000; -const ownedProcessGroupsSupported = process.platform !== 'win32'; -const approvedDiagnosticSet = new Set(APPROVED_PHASE1_DIAGNOSTIC_IDS); -const requiredAssertionSet = new Set(REQUIRED_PHASE1_ASSERTION_IDS); const approvedCommandFailureReasons = new Set([ - 'compile-failed', - 'compiler-crash', - 'disk-exhausted', - 'memory-exhausted', - 'page-data-failed', - 'process-killed', 'spawn', 'tracking', 'stdout-limit', 'stderr-limit', 'timeout', - 'turbopack-plugin-timeout', - 'worker-exited', + 'supervisor-termination', +]); +const verifiedRunnerEnvironment = 'OPENCOVEN_PHASE1_VERIFIED_RUNNER'; +const verifiedRunnerRootEnvironment = 'OPENCOVEN_PHASE1_VERIFIED_RUNNER_ROOT'; +const forbiddenNodeEnvironment = [ + 'NODE_OPTIONS', + 'NODE_PATH', + 'NODE_REPL_EXTERNAL_MODULE', + 'NODE_EXTRA_CA_CERTS', + 'NPM_CONFIG_NODE_OPTIONS', + 'npm_config_node_options', + 'NODE_CHANNEL_FD', + 'NODE_UNIQUE_ID', +]; +let configuredWindowsSupervisorPath; +const packageSdkAssertionIds = Object.freeze([ + 'sdk.install.packed-tarballs', + 'sdk.install.public-exports', + 'sdk.install.no-source-checkout', + 'sdk.install.no-workspace-link', + 'sdk.provenance.fixture-bytes-match', + 'sdk.provenance.hpke-vectors-match', +]); +const caveSdkAssertionIds = Object.freeze([ + 'sdk.cave.discovery.release-mode', + 'sdk.cave.discovery.stale-record-refused', + 'sdk.cave.discovery.replaced-instance-refused', + 'sdk.cave.health.compatible', + 'sdk.cave.pairing.create', + 'sdk.cave.pairing.pending', + 'sdk.cave.pairing.exchange-once', + 'sdk.cave.pairing.denied', + 'sdk.cave.pairing.expired', + 'sdk.cave.pairing.wrong-secret-refused', + 'sdk.cave.pairing.replay-refused', + 'sdk.cave.pairing.shared-failure-budget', + 'sdk.cave.pairing.rate-limit', + 'sdk.cave.exchange.missing-content-length-refused', + 'sdk.cave.exchange.content-length-zero-accepted', + 'sdk.cave.proxy-rejection.distinct-envelope', + 'sdk.cave.credential.native-store-required', + 'sdk.cave.credential.restart-reused', + 'sdk.cave.read.familiars', + 'sdk.cave.read.projects', + 'sdk.cave.read.conversations', + 'sdk.cave.read.conversation', + 'sdk.cave.read.messages', + 'sdk.cave.cursor.malformed-refused', + 'sdk.cave.cursor.noncanonical-refused', + 'sdk.cave.cursor.reconcile-required', + 'sdk.cave.revocation.familiars-refused', + 'sdk.cave.revocation.projects-refused', + 'sdk.cave.revocation.conversations-refused', + 'sdk.cave.revocation.conversation-refused', + 'sdk.cave.revocation.messages-refused', +]); +const nativeSdkAssertionIds = Object.freeze([ + 'sdk.native.keychain-missing-fails-closed', + 'sdk.native.trust-binding-missing-fails-closed', +]); +const packageChatAssertionIds = Object.freeze([ + 'chat.install.exact-sdk-tarballs', + 'chat.install.consumer-lock-matches', + 'chat.install.no-source-checkout', + 'chat.install.no-workspace-link', +]); +const caveChatAssertionIds = Object.freeze([ + 'chat.cave.compatibility-before-pairing', + 'chat.cave.pairing-secret-remains-native', + 'chat.cave.bearer-remains-native', + 'chat.cave.bearer-never-enters-webview', + 'chat.cave.native-store.roundtrip', + 'chat.cave.restart.credential-reused', + 'chat.cave.restart.no-automatic-repairing', + 'chat.cave.replacement.stale-state-refused', + 'chat.cave.read.familiars', + 'chat.cave.read.projects', + 'chat.cave.read.conversations', + 'chat.cave.read.conversation', + 'chat.cave.read.messages', + 'chat.cave.reconcile.reloads-query-only', + 'chat.cave.revocation.transitions-state', + 'chat.cave.revocation.all-reads-refused', +]); +const nativeChatAssertionIds = Object.freeze([ + 'chat.native.keychain-unavailable-fails-closed', + 'chat.native.trust-provider-unavailable-fails-closed', +]); +const evidenceChatAssertionIds = Object.freeze([ + 'chat.evidence.no-prompts', + 'chat.evidence.no-message-bodies', + 'chat.evidence.no-attachments', + 'chat.evidence.no-command-output', ]); - -function killUntrackedOwnedChild(child) { - if (ownedProcessGroupsSupported && child.exitCode === null && child.signalCode === null) { - try { - process.kill(-child.pid, 'SIGKILL'); - return; - } catch { - // Fall through to the direct child handle if the group is already gone. - } - } - child.kill('SIGKILL'); -} function defaultSourceRoot(environmentName, repositoryName) { return process.env[environmentName] === undefined @@ -100,22 +173,510 @@ export class CommandExecutionError extends Error { } } -export function classifyCavePackageFailure(result) { - const output = `${result?.stdout ?? ''}\n${result?.stderr ?? ''}`; - const classifications = [ - [/timeout while receiving message from process/iu, 'turbopack-plugin-timeout'], +const runtimeScenarioDiagnosticIds = new Map([ + [ + 'phase1.missing-cave.validated-launch', + 'phase1.runtime-assertions.missing-cave-validated-launch', + ], + [ + 'phase1.pairing.create-pending-approve-exchange', + 'phase1.runtime-assertions.pairing-create-pending-approve-exchange', + ], + ['phase1.pairing.denial', 'phase1.runtime-assertions.pairing-denial'], + ['phase1.pairing.expiry', 'phase1.runtime-assertions.pairing-expiry'], + ['phase1.pairing.wrong-secret-replay', 'phase1.runtime-assertions.pairing-wrong-secret-replay'], + [ + 'phase1.pairing.failure-budget-retry-after', + 'phase1.runtime-assertions.pairing-failure-budget-retry-after', + ], + ['phase1.credential.restart-reuse', 'phase1.runtime-assertions.credential-restart-reuse'], + ['phase1.credential.revocation-repair', 'phase1.runtime-assertions.credential-revocation-repair'], + ['phase1.hpke.endpoint-takeover', 'phase1.runtime-assertions.hpke-endpoint-takeover'], + ['phase1.reads.bounded-canonical', 'phase1.runtime-assertions.reads-bounded-canonical'], + [ + 'phase1.reads.stale-generation-cursor-reconciliation', + 'phase1.runtime-assertions.reads-stale-generation-cursor-reconciliation', + ], + ['phase1.coven.same-user-identity', 'phase1.runtime-assertions.coven-same-user-identity'], + [ + 'phase1.native.missing-keychain-trust', + 'phase1.runtime-assertions.native-missing-keychain-trust', + ], +]); +const covenIdentityFailureStages = new Set([ + 'rpc-start', + 'unavailable-health', + 'daemon-spawn', + 'daemon-ready', + 'malicious-home', + 'wrong-mode-home', + 'symlink-socket-home', + 'socket-mode', + 'result', +]); +const covenIdentityDiagnosticIds = new Set( + [...covenIdentityFailureStages, 'unknown'].map((stage) => `phase1.coven-identity.${stage}`), +); + +const publicPhase1DiagnosticIds = new Set([ + 'phase1.operator-fingerprint.failed', + 'phase1.operator-fingerprint.unsafe-root', + 'phase1.operator-fingerprint.entry-limit', + 'phase1.operator-fingerprint.unsafe-control-resource', + 'phase1.operator-fingerprint.control-file-limit', + 'phase1.operator-fingerprint.changed-during-read', + 'phase1.stage.runner-bootstrap.failed', + 'phase1.stage.runner-lock.failed', + 'phase1.stage.runner-checkout.failed', + 'phase1.stage.runner-checkout-verification.failed', + 'phase1.stage.verified-runner.failed', + 'phase1.stage.verified-runner.timeout', + 'phase1.stage.verified-runner.output-limit', + 'phase1.stage.verified-runner.spawn', + 'phase1.stage.verified-runner.supervisor', + 'phase1.stage.verified-runner.exit-nonzero', + 'phase1.stage.lock.failed', + 'phase1.stage.harness-authority.failed', + 'phase1.stage.native-provider.failed', + 'phase1.stage.execution-root.failed', + 'phase1.stage.environment.failed', + 'phase1.environment.rust-toolchain.failed', + 'phase1.environment.rustup-cargo.failed', + 'phase1.environment.rustup-rustc.failed', + 'phase1.environment.rustup-rustdoc.failed', + 'phase1.environment.rust-toolchain-mismatch', + 'phase1.environment.rustup-home.invalid', + 'phase1.environment.directories.failed', + 'phase1.stage.toolchain.failed', + 'phase1.stage.checkouts.failed', + 'phase1.stage.evidence-authority.failed', + 'phase1.stage.packaging.failed', + 'phase1.stage.packaging-proof.failed', + 'phase1.stage.cave-authority.failed', + 'phase1.stage.native-scenarios.failed', + 'phase1.native-scenarios.fixture-daemon', + 'phase1.native-scenarios.fixture', + 'phase1.native-scenarios.rpc-start', + 'phase1.native-scenarios.launch', + 'phase1.native-scenarios.pairing', + 'phase1.native-scenarios.pairing-reservation', + 'phase1.native-scenarios.pairing-reservation-request', + 'phase1.native-scenarios.pairing-reservation-keychain', + 'phase1.native-scenarios.pairing-reservation-store-unavailable', + 'phase1.native-scenarios.pairing-reservation-invalid-handle', + 'phase1.native-scenarios.pairing-reservation-discovery-required', + 'phase1.native-scenarios.pairing-reservation-health-required', + 'phase1.native-scenarios.pairing-reservation-rejected', + 'phase1.native-scenarios.pairing-reservation-response', + 'phase1.native-scenarios.pairing-reservation-cleanup', + 'phase1.native-scenarios.pairing-credential-status', + 'phase1.native-scenarios.pairing-create', + 'phase1.native-scenarios.pairing-pending', + 'phase1.native-scenarios.pairing-approve', + 'phase1.native-scenarios.pairing-approved', + 'phase1.native-scenarios.pairing-exchange', + 'phase1.native-scenarios.pairing-denial', + 'phase1.native-scenarios.restart', + 'phase1.native-scenarios.restart-rpc-start', + 'phase1.native-scenarios.restart-discovery', + 'phase1.native-scenarios.restart-health', + 'phase1.native-scenarios.restart-cleanup-adoption', + 'phase1.native-scenarios.restart-status', + 'phase1.native-scenarios.restart-handoff-close', + 'phase1.native-scenarios.restart-launch', + 'phase1.native-scenarios.restart-rediscovery', + 'phase1.native-scenarios.restart-restarted-health', + 'phase1.native-scenarios.restart-restarted-status', + 'phase1.native-scenarios.restart-result', + 'phase1.native-scenarios.reads', + 'phase1.native-scenarios.reconciliation', + 'phase1.native-scenarios.revocation', + 'phase1.native-scenarios.revocation-delete', + 'phase1.native-scenarios.revocation-initial-status', + 'phase1.native-scenarios.revocation-rediscovery', + 'phase1.native-scenarios.revocation-health', + 'phase1.native-scenarios.revocation-status', + 'phase1.native-scenarios.revocation-repair-create', + 'phase1.native-scenarios.revocation-repair-pending', + 'phase1.native-scenarios.revocation-repair-approve', + 'phase1.native-scenarios.revocation-repair-approved', + 'phase1.native-scenarios.revocation-repair-exchange', + 'phase1.native-scenarios.revocation-result', + 'phase1.native-scenarios.credential-cleanup', + 'phase1.native-scenarios.credential-cleanup-discovery', + 'phase1.native-scenarios.credential-cleanup-health', + 'phase1.native-scenarios.credential-cleanup-identity', + 'phase1.native-scenarios.credential-cleanup-forget', + 'phase1.native-scenarios.credential-cleanup-status', + 'phase1.native-scenarios.credential-cleanup-result', + 'phase1.native-scenarios.stale-discovery', + 'phase1.native-scenarios.cleanup', + 'phase1.native-scenarios.missing-keychain', + 'phase1.native-scenarios.missing-keychain-timeout', + 'phase1.native-scenarios.missing-keychain-output-limit', + 'phase1.native-scenarios.missing-keychain-process', + 'phase1.native-scenarios.missing-keychain-reap', + 'phase1.native-scenarios.missing-keychain-termination', + 'phase1.native-scenarios.missing-keychain-supervisor', + 'phase1.native-scenarios.missing-keychain-canary', + 'phase1.native-scenarios.missing-keychain-home', + 'phase1.native-scenarios.missing-keychain-response', + 'phase1.native-scenarios.isolation-proof', + 'phase1.stage.coven-identity.failed', + ...covenIdentityDiagnosticIds, + 'phase1.stage.runtime-assertions.failed', + ...runtimeScenarioDiagnosticIds.values(), + 'phase1.stage.isolation.failed', + 'phase1.stage.isolation-proof.failed', + 'phase1.stage.assertion-recording.failed', + 'phase1.stage.evidence-build.failed', + 'phase1.stage.evidence-validation.failed', + 'phase1.stage.evidence-validation.size', + 'phase1.stage.evidence-validation.json', + 'phase1.stage.evidence-validation.duplicate-key', + 'phase1.stage.evidence-validation.possible-secret', + 'phase1.stage.evidence-validation.private-path', + 'phase1.stage.evidence-validation.forbidden-field', + 'phase1.stage.evidence-validation.non-json', + 'phase1.stage.evidence-validation.shape', + 'phase1.stage.evidence-validation.unknown', + 'phase1.stage.evidence-retention.failed', + 'phase1.stage.execution-root-cleanup.failed', + 'phase1.cave-authority.timeout', + 'phase1.cave-authority.output-limit', + 'phase1.cave-authority.spawn', + 'phase1.cave-authority.supervisor', + 'phase1.cave-authority.exit-nonzero', + 'phase1.cave-authority.assertion.admin', + 'phase1.cave-authority.assertion.discovery', + 'phase1.cave-authority.assertion.health', + 'phase1.cave-authority.assertion.ingress', + 'phase1.cave-authority.assertion.pairing', + 'phase1.cave-authority.assertion.reads', + 'phase1.cave-authority.assertion.revocation', + 'phase1.cave-authority.assertion.takeover', + 'phase1.cave-authority.assertion.harness', + 'phase1.cave-authority.assertion.hpke', + 'phase1.cave-authority.assertion.multiple', + 'phase1.cave-authority.assertion.unknown', + 'phase1.cave-authority.record.invalid', + 'phase1.cave-authority.record.incomplete', + 'phase1.packaging.authority.failed', + 'phase1.packaging.chat-install.failed', + 'phase1.packaging.chat-web-build.failed', + 'phase1.packaging.chat-native-build.failed', + 'phase1.packaging.chat-native-tests.failed', + 'phase1.packaging.artifact-verification.failed', + 'phase1.packaging.cave-install.failed', + 'phase1.packaging.cave-build.failed', + 'phase1.packaging.cave-build.exit-nonzero', + 'phase1.packaging.cave-build.timeout', + 'phase1.packaging.cave-build.output-limit', + 'phase1.packaging.cave-build.spawn', + 'phase1.packaging.cave-build.supervisor', + 'phase1.packaging.cave-build.phase.prebuild', + 'phase1.packaging.cave-build.phase.next-build', + 'phase1.packaging.cave-build.phase.next-build.resource', + 'phase1.packaging.cave-build.phase.next-build.resource.spawn', + 'phase1.packaging.cave-build.phase.next-build.resource.memory', + 'phase1.packaging.cave-build.phase.next-build.resource.memory.heap', + 'phase1.packaging.cave-build.phase.next-build.resource.memory.allocation', + 'phase1.packaging.cave-build.phase.next-build.resource.killed', + 'phase1.packaging.cave-build.phase.next-build.compile', + 'phase1.packaging.cave-build.phase.next-build.typescript', + 'phase1.packaging.cave-build.phase.next-build.page-data', + 'phase1.packaging.cave-build.phase.next-build.static-pages', + 'phase1.packaging.cave-build.phase.next-build.finalization', + 'phase1.packaging.cave-build.phase.server-bundle', + 'phase1.packaging.cave-build.phase.postbuild', + 'phase1.packaging.cave-build.phase.unknown', + 'phase1.packaging.coven-build.failed', + 'phase1.packaging.coven-tests.failed', + 'phase1.packaging.outputs.failed', + ...['lib', 'health', 'doc'].flatMap((group) => [ - /heap out of memory|allocation failed|javascript heap|fatal process out of memory/iu, - 'memory-exhausted', - ], - [/no space left on device|\bENOSPC\b/u, 'disk-exhausted'], - [/\b(?:SIGKILL|Killed: 9|signal 9)\b/u, 'process-killed'], - [/\b(?:TurbopackInternalError|panic|segmentation fault|bus error)\b/iu, 'compiler-crash'], - [/\b(?:static|build) worker exited\b/iu, 'worker-exited'], - [/failed to collect page data/iu, 'page-data-failed'], - [/failed to compile/iu, 'compile-failed'], - ]; - return classifications.find(([pattern]) => pattern.test(output))?.[1]; + 'failed', + 'exit-nonzero', + 'cargo-failure', + 'malformed-output', + 'timeout', + 'output-limit', + 'spawn', + 'supervisor', + ].map((reason) => `phase1.packaging.coven-client-${group}-tests.${reason}`), + ), + ...[ + 'discovery', + 'error', + 'http', + 'lifecycle', + 'models', + 'status', + 'transport', + 'transport-unix', + 'transport-windows', + 'multiple-modules', + ].map((module) => `phase1.packaging.coven-client-lib-tests.libtest.${module}`), +]); +const covenClientLibModules = new Set([ + 'discovery', + 'error', + 'http', + 'lifecycle', + 'models', + 'status', + 'transport', +]); +const covenClientLifecycleTests = Object.freeze([ + 'lifecycle::tests::lifecycle_discovery_does_not_hide_a_replaced_profile_home', + 'lifecycle::tests::lifecycle_discovery_preserves_socket_permission_errors', + 'lifecycle::tests::lifecycle_discovery_returns_none_when_the_prechecked_socket_is_unlinked', + 'lifecycle::tests::linux_identity_substitution_never_reaches_the_bound_signal', + 'lifecycle::tests::macos_legacy_shutdown_fails_closed_with_upgrade_guidance', + 'lifecycle::tests::macos_tmp_spellings_have_the_same_canonical_filesystem_identity', + 'lifecycle::tests::rediscovery_classifies_an_exact_selected_socket_unlink_as_unavailable', +]); +const replacedProfileLifecycleTest = + 'lifecycle::tests::lifecycle_discovery_does_not_hide_a_replaced_profile_home'; +const replacedProfileFailureCategories = Object.freeze([ + ['bind selected lifecycle socket', 'socket-setup'], + ['make selected socket private', 'socket-setup'], + ['unlink selected socket after lifecycle pre-check', 'unlink-selected-socket'], + ['move selected profile out of the way', 'move-selected-profile'], + ['create substituted profile home', 'create-substituted-home'], + ['make substituted profile private', 'create-substituted-home'], + [ + 'profile replacement must not be classified as a stopped daemon', + 'identity-substitution-accepted', + ], + ['assertion failed: matches!(error, crate::ClientError::Discovery(_))', 'wrong-error-class'], + ['clean moved lifecycle test home', 'cleanup-moved-home'], +]); +publicPhase1DiagnosticIds.add( + 'phase1.packaging.coven-client-lib-tests.lifecycle.concurrency-or-order-dependent', +); +for (const testName of covenClientLifecycleTests) { + publicPhase1DiagnosticIds.add( + `phase1.packaging.coven-client-lib-tests.lifecycle.${testName.split('::').at(-1)}`, + ); +} +for (const [, category] of replacedProfileFailureCategories) { + publicPhase1DiagnosticIds.add( + `phase1.packaging.coven-client-lib-tests.lifecycle.replaced-profile.${category}`, + ); +} +publicPhase1DiagnosticIds.add( + 'phase1.packaging.coven-client-lib-tests.lifecycle.replaced-profile.unknown', +); + +export function extractVerifiedRunnerDiagnostic(stderr) { + if (typeof stderr !== 'string') { + return undefined; + } + const diagnostics = new Set(); + for (const line of stripVTControlCharacters(stderr).split(/\r?\n/u)) { + const match = /^phase1-conformance: (phase1\.[a-z0-9.-]+)$/u.exec(line); + if (match !== null && publicPhase1DiagnosticIds.has(match[1])) { + diagnostics.add(match[1]); + } + } + return diagnostics.size === 1 ? [...diagnostics][0] : undefined; +} + +export function publicPhase1FailureDiagnostic(error) { + const pending = [error]; + const visited = new Set(); + for (let inspected = 0; pending.length > 0 && inspected < 32; inspected += 1) { + const current = pending.shift(); + if (current === null || typeof current !== 'object' || visited.has(current)) { + continue; + } + visited.add(current); + if ( + 'message' in current && + typeof current.message === 'string' && + publicPhase1DiagnosticIds.has(current.message) + ) { + return current.message; + } + if ('errors' in current && Array.isArray(current.errors)) { + pending.push(...current.errors.slice(0, 16)); + } + if ( + 'result' in current && + current.result !== null && + typeof current.result === 'object' && + 'stderr' in current.result && + typeof current.result.stderr === 'string' + ) { + const diagnostic = extractVerifiedRunnerDiagnostic(current.result.stderr); + if (diagnostic !== undefined) { + return diagnostic; + } + } + } + return undefined; +} + +function runPublicPhase1Stage(id, action) { + try { + return action(); + } catch (cause) { + if (publicPhase1FailureDiagnostic(cause) !== undefined) { + throw cause; + } + throw new Error(id, { cause }); + } +} + +async function runPublicPhase1StageAsync(id, action) { + try { + return await action(); + } catch (cause) { + if (publicPhase1FailureDiagnostic(cause) !== undefined) { + throw cause; + } + throw new Error(id, { cause }); + } +} + +export function classifyPackagingCommandFailure(baseId, error) { + if (!(error instanceof CommandExecutionError)) { + return `${baseId}.failed`; + } + const reason = error.result?.reason; + if (reason === 'timeout') { + return `${baseId}.timeout`; + } + if (reason === 'stdout-limit' || reason === 'stderr-limit' || reason === 'status-limit') { + return `${baseId}.output-limit`; + } + if (reason === 'spawn' || reason === 'tracking') { + return `${baseId}.spawn`; + } + if (reason === 'supervisor-termination' || reason === 'termination') { + return `${baseId}.supervisor`; + } + if (typeof error.result?.code === 'number' && error.result.code !== 0) { + if (baseId === 'phase1.packaging.cave-build') { + const output = stripVTControlCharacters( + `${error.result.stdout ?? ''}\n${error.result.stderr ?? ''}`, + ).replaceAll('\r\n', '\n'); + let phase = 'unknown'; + if (/^> coven-cave@\d+\.\d+\.\d+ postbuild$/mu.test(output)) { + phase = 'postbuild'; + } else if (/^> coven-cave@\d+\.\d+\.\d+ build:server$/mu.test(output)) { + phase = 'server-bundle'; + } else if (output.includes('Creating an optimized production build')) { + phase = /\bEAGAIN\b/u.test(output) + ? 'next-build.resource.spawn' + : /\bheap out of memory\b/iu.test(output) + ? 'next-build.resource.memory.heap' + : /\bENOMEM\b/u.test(output) + ? 'next-build.resource.memory.allocation' + : /\b(?:Killed(?:: 9)?|SIGKILL)\b/u.test(output) + ? 'next-build.resource.killed' + : output.includes('Finalizing page optimization') + ? 'next-build.finalization' + : output.includes('Generating static pages') + ? 'next-build.static-pages' + : output.includes('Collecting page data') + ? 'next-build.page-data' + : output.includes('Compiled successfully') + ? 'next-build.typescript' + : 'next-build.compile'; + } else if (/^> coven-cave@\d+\.\d+\.\d+ prebuild$/mu.test(output)) { + phase = 'prebuild'; + } + return `${baseId}.phase.${phase}`; + } + if (baseId === 'phase1.packaging.coven-client-lib-tests') { + const output = stripVTControlCharacters( + `${error.result.stdout ?? ''}\n${error.result.stderr ?? ''}`, + ).replaceAll('\r\n', '\n'); + const hasLibtestFailure = + /^test result: FAILED\./mu.test(output) || /^failures:\s*$/mu.test(output); + if (!hasLibtestFailure) { + return `${baseId}.cargo-failure`; + } + const failedTests = new Set( + [...output.matchAll(/^---- ([A-Za-z0-9_]+(?:::[A-Za-z0-9_]+)+) stdout ----$/gmu)].map( + (match) => match[1], + ), + ); + const modules = new Set(); + for (const testName of failedTests) { + const parts = testName.split('::'); + const top = parts[0]; + const platformTransport = + top === 'transport' && (parts[1] === 'unix' || parts[1] === 'windows'); + const marker = parts[platformTransport ? 2 : 1]; + const leaf = parts[platformTransport ? 3 : 2]; + if ( + parts.length !== (platformTransport ? 4 : 3) || + !covenClientLibModules.has(top) || + leaf === undefined || + !/^[a-z0-9_]+$/u.test(leaf) || + marker !== 'tests' + ) { + return `${baseId}.malformed-output`; + } + modules.add(platformTransport ? `transport-${parts[1]}` : top); + } + if (modules.size === 1) { + return `${baseId}.libtest.${[...modules][0]}`; + } + if (modules.size > 1) { + return `${baseId}.libtest.multiple-modules`; + } + return `${baseId}.malformed-output`; + } + return `${baseId}.exit-nonzero`; + } + return `${baseId}.failed`; +} + +async function runPackagingCommand(baseId, action) { + try { + return await action(); + } catch (cause) { + throw new Error(classifyPackagingCommandFailure(baseId, cause), { cause }); + } +} + +export async function diagnoseCovenLifecycleFailure(original, rerun) { + if ( + classifyPackagingCommandFailure('phase1.packaging.coven-client-lib-tests', original) !== + 'phase1.packaging.coven-client-lib-tests.libtest.lifecycle' + ) { + throw original; + } + for (const testName of covenClientLifecycleTests) { + try { + await rerun(testName); + } catch (error) { + if (testName === replacedProfileLifecycleTest) { + const output = stripVTControlCharacters( + `${error?.result?.stdout ?? ''}\n${error?.result?.stderr ?? ''}`, + ).replaceAll('\r\n', '\n'); + const category = + replacedProfileFailureCategories.find(([message]) => output.includes(message))?.[1] ?? + 'unknown'; + throw new Error( + `phase1.packaging.coven-client-lib-tests.lifecycle.replaced-profile.${category}`, + { cause: original }, + ); + } + throw new Error( + `phase1.packaging.coven-client-lib-tests.lifecycle.${testName.split('::').at(-1)}`, + { cause: original }, + ); + } + } + throw new Error( + 'phase1.packaging.coven-client-lib-tests.lifecycle.concurrency-or-order-dependent', + { cause: original }, + ); } function requireString(value, label) { @@ -132,8 +693,13 @@ export function parseArgs(argv) { retainSanitizedReport: defaultRetainedReport, chatSourceRoot: resolve(process.env.OPENCOVEN_CHAT_ROOT ?? chatRepositoryRoot), sdkSourceRoot: defaultSourceRoot('OPENCOVEN_SDK_ROOT', 'sdk'), + sdkEvidenceSourceRoot: defaultSourceRoot('OPENCOVEN_SDK_EVIDENCE_ROOT', 'sdk'), caveSourceRoot: defaultSourceRoot('OPENCOVEN_CAVE_ROOT', 'coven-cave'), covenSourceRoot: defaultSourceRoot('OPENCOVEN_COVEN_ROOT', 'coven'), + windowsSupervisorPath: + process.env.OPENCOVEN_PHASE1_WINDOWS_SUPERVISOR_PATH === undefined + ? undefined + : resolve(process.env.OPENCOVEN_PHASE1_WINDOWS_SUPERVISOR_PATH), }; const pathFlags = new Map([ @@ -141,8 +707,10 @@ export function parseArgs(argv) { ['--retain-sanitized-report', 'retainSanitizedReport'], ['--chat-root', 'chatSourceRoot'], ['--sdk-root', 'sdkSourceRoot'], + ['--sdk-evidence-root', 'sdkEvidenceSourceRoot'], ['--cave-root', 'caveSourceRoot'], ['--coven-root', 'covenSourceRoot'], + ['--windows-supervisor', 'windowsSupervisorPath'], ]); for (let index = 0; index < argv.length; index += 1) { @@ -171,76 +739,17 @@ export function parseArgs(argv) { return options; } -export function assertExactAssertionResults(assertions) { - if (!Array.isArray(assertions)) { - throw new Error('Phase 1 conformance assertions must be an array.'); - } - - const seen = new Set(); - for (const assertion of assertions) { - if (assertion === null || typeof assertion !== 'object' || typeof assertion.id !== 'string') { - throw new Error('Phase 1 conformance contains an invalid assertion result.'); - } - if (!requiredAssertionSet.has(assertion.id)) { - throw new Error(`Phase 1 conformance contains unexpected assertion ID ${assertion.id}.`); - } - if (seen.has(assertion.id)) { - throw new Error(`Phase 1 conformance contains duplicate assertion ID ${assertion.id}.`); - } - if (assertion.status === 'skipped') { - throw new Error(`Phase 1 conformance contains skipped assertion ${assertion.id}.`); - } - if (!['passed', 'failed', 'blocked'].includes(assertion.status)) { - throw new Error(`Phase 1 conformance assertion ${assertion.id} has an invalid status.`); - } - if ( - !Array.isArray(assertion.diagnosticIds) || - assertion.diagnosticIds.some((id) => !approvedDiagnosticSet.has(id)) - ) { - throw new Error(`Phase 1 conformance assertion ${assertion.id} has an invalid diagnostic.`); - } - seen.add(assertion.id); - } - - for (const id of REQUIRED_PHASE1_ASSERTION_IDS) { - if (!seen.has(id)) { - throw new Error(`Phase 1 conformance is missing required assertion ID ${id}.`); - } - } - - return assertions; -} - -export function buildPhase1Report({ assertions, revisions, artifactDigests, versions }) { - assertExactAssertionResults(assertions); - const orderedAssertions = REQUIRED_PHASE1_ASSERTION_IDS.map((id) => - assertions.find((assertion) => assertion.id === id), - ); - const summary = { - required: REQUIRED_PHASE1_ASSERTION_IDS.length, - passed: orderedAssertions.filter((assertion) => assertion.status === 'passed').length, - failed: orderedAssertions.filter((assertion) => assertion.status === 'failed').length, - blocked: orderedAssertions.filter((assertion) => assertion.status === 'blocked').length, - skipped: 0, - }; - const status = summary.failed > 0 ? 'failed' : summary.blocked > 0 ? 'blocked' : 'passed'; - const report = { - schemaVersion: 1, - completed: true, - status, - platform: { - os: process.platform, - arch: process.arch, - }, - versions, - revisions, - artifactDigests, - assertions: orderedAssertions, - summary, - diagnosticIds: [`phase1.conformance.${status}`], - }; - validatePhase1SanitizedReport(report); - return report; +export function observeReleaseToolVersions() { + const toolchain = resolveRustToolchain(); + return validateObservedToolVersions({ + nodeVersion: process.version, + pnpmVersion: runSupervisedSync('corepack', ['pnpm@10.34.0', '--version'], { + encoding: 'utf8', + }).trim(), + rustcVersion: runSupervisedSync(toolchain.rustcPath, ['--version'], { + encoding: 'utf8', + }).trim(), + }); } export function parseCaveConformanceOutput(output) { @@ -259,6 +768,17 @@ export function parseCaveConformanceOutput(output) { return assertions; } +export function parsePassedRustTests(output) { + const passed = new Set(); + for (const line of output.split(/\r?\n/u)) { + const match = /^test ([A-Za-z0-9_:]+) \.\.\. ok$/u.exec(line.trim()); + if (match !== null) { + passed.add(match[1]); + } + } + return passed; +} + export function assertPairingStatus(value, expectedStatus) { const status = value?.status; if (status !== expectedStatus) { @@ -267,50 +787,6 @@ export function assertPairingStatus(value, expectedStatus) { return value; } -export function assertCompatibilityFailure(error, preset) { - const code = - error !== null && typeof error === 'object' - ? Object.getOwnPropertyDescriptor(error, 'code')?.value - : undefined; - if (code !== 'incompatible_version') { - throw new Error(`${preset} preset did not produce incompatible_version`); - } - return { code }; -} - -function canonicalJson(value) { - if (Array.isArray(value)) { - return value.map(canonicalJson); - } - if (value !== null && typeof value === 'object') { - return Object.fromEntries( - Object.keys(value) - .sort() - .map((key) => [key, canonicalJson(value[key])]), - ); - } - return value; -} - -export function assertNativeMissingKeychainResponses(responses) { - const expected = [ - { - id: 'installation', - ok: false, - error: { code: 'secure_store_unavailable', retryable: true }, - }, - { - id: 'shutdown', - ok: true, - result: { status: 'shutting_down' }, - }, - ]; - if (JSON.stringify(canonicalJson(responses)) !== JSON.stringify(canonicalJson(expected))) { - throw new Error('native missing-keychain-trust preset returned an unsafe response'); - } - return responses; -} - function makeAssertion(id, status, diagnosticId) { return { id, @@ -330,7 +806,58 @@ function requirePassedAssertions(assertions, ids) { return ids.every((id) => assertions.get(id) === 'passed'); } -export function safeEnvironment(rootPath, extra = {}, resolvedCargoPath) { +export function resolveRustupHome(environment = process.env) { + const explicit = environment.RUSTUP_HOME; + if (typeof explicit === 'string' && explicit.length > 0) { + if (explicit.includes('\0') || !isAbsolute(explicit) || resolve(explicit) !== explicit) { + throw new Error('phase1.environment.rustup-home.invalid'); + } + return explicit; + } + const operatorHome = environment.HOME; + if ( + typeof operatorHome !== 'string' || + operatorHome.length === 0 || + operatorHome.includes('\0') || + !isAbsolute(operatorHome) || + resolve(operatorHome) !== operatorHome + ) { + throw new Error('phase1.environment.rustup-home.invalid'); + } + return resolve(operatorHome, '.rustup'); +} + +function resolveRustToolchain() { + const triples = { + 'darwin-arm64': 'aarch64-apple-darwin', + 'darwin-x64': 'x86_64-apple-darwin', + 'linux-x64': 'x86_64-unknown-linux-gnu', + 'win32-x64': 'x86_64-pc-windows-msvc', + }; + const triple = triples[`${process.platform}-${process.arch}`]; + const rustupHome = resolveRustupHome(); + if (triple === undefined) { + throw new Error('phase1.environment.rust-toolchain-mismatch'); + } + const bin = resolve(rustupHome, 'toolchains', `1.95.0-${triple}`, 'bin'); + const cargoPath = realpathSync( + resolve(bin, process.platform === 'win32' ? 'cargo.exe' : 'cargo'), + ); + const rustcPath = realpathSync( + resolve(bin, process.platform === 'win32' ? 'rustc.exe' : 'rustc'), + ); + const rustdocPath = realpathSync( + resolve(bin, process.platform === 'win32' ? 'rustdoc.exe' : 'rustdoc'), + ); + for (const path of [cargoPath, rustcPath, rustdocPath]) { + if (!statSync(path).isFile() || dirname(path) !== bin) { + throw new Error('phase1.environment.rust-toolchain-mismatch'); + } + } + return { cargoPath, rustcPath, rustdocPath }; +} + +export function safeEnvironment(rootPath, extra = {}) { const home = resolve(rootPath, 'home'); const temp = resolve(rootPath, 'tmp'); const cache = resolve(rootPath, 'cache'); @@ -338,22 +865,20 @@ export function safeEnvironment(rootPath, extra = {}, resolvedCargoPath) { const data = resolve(rootPath, 'data'); const pnpmStore = resolve(rootPath, 'pnpm-store'); const cargoHome = resolve(rootPath, 'cargo-home'); - const cargoPath = realpathSync( - resolvedCargoPath ?? - execFileSync('rustup', ['which', 'cargo'], { - cwd: projectRoot, - encoding: 'utf8', - }).trim(), + const nativeLockRoot = resolve(rootPath, 'native-credential-lock'); + const toolchain = runPublicPhase1Stage('phase1.environment.rust-toolchain.failed', () => + resolveRustToolchain(), ); - const rustToolchainBin = dirname(cargoPath); - for (const path of [home, temp, cache, config, data, pnpmStore, cargoHome]) { - mkdirSync(path, { recursive: true, mode: 0o700 }); - chmodSync(path, 0o700); - } + const rustToolchainBin = dirname(toolchain.cargoPath); + runPublicPhase1Stage('phase1.environment.directories.failed', () => { + for (const path of [home, temp, cache, config, data, pnpmStore, cargoHome, nativeLockRoot]) { + mkdirSync(path, { recursive: true, mode: 0o700 }); + chmodSync(path, 0o700); + } + }); - const inheritedPath = process.env.PATH ?? ''; const environment = { - PATH: inheritedPath ? `${rustToolchainBin}${delimiter}${inheritedPath}` : rustToolchainBin, + PATH: `${rustToolchainBin}${delimiter}${process.env.PATH ?? ''}`, LANG: process.env.LANG ?? 'C.UTF-8', LC_ALL: process.env.LC_ALL ?? '', HOME: home, @@ -368,8 +893,9 @@ export function safeEnvironment(rootPath, extra = {}, resolvedCargoPath) { NPM_CONFIG_HTTPS_PROXY: '', PNPM_STORE_DIR: pnpmStore, CARGO_HOME: cargoHome, - RUSTC: resolve(rustToolchainBin, 'rustc'), - RUSTDOC: resolve(rustToolchainBin, 'rustdoc'), + OPENCOVEN_PHASE1_CONFORMANCE_LOCK_ROOT: nativeLockRoot, + RUSTC: toolchain.rustcPath, + RUSTDOC: toolchain.rustdocPath, CI: '1', NO_COLOR: '1', GIT_TERMINAL_PROMPT: '0', @@ -385,7 +911,14 @@ export function safeEnvironment(rootPath, extra = {}, resolvedCargoPath) { all_proxy: '', ...extra, }; - for (const name of ['SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT']) { + for (const name of [ + 'SYSTEMROOT', + 'WINDIR', + 'COMSPEC', + 'PATHEXT', + 'OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED', + 'PHASE1_TEST_KEYCHAIN', + ]) { if (process.env[name] !== undefined) { environment[name] = process.env[name]; } @@ -393,26 +926,320 @@ export function safeEnvironment(rootPath, extra = {}, resolvedCargoPath) { return environment; } +export function caveBuildEnvironment(environment) { + return { + ...environment, + NODE_OPTIONS: caveBuildNodeOptions, + CIRCLE_NODE_TOTAL: caveBuildReportedCpuTotal, + }; +} + +export function validateSupervisorArtifactFile(path, metadata) { + if (typeof path !== 'string' || !isAbsolute(path)) { + throw new Error('Windows supervisor path must be absolute.'); + } + const stats = lstatSync(path); + if ( + stats.isSymbolicLink() || + !stats.isFile() || + stats.size !== metadata.size || + sha256File(path) !== metadata.sha256 + ) { + throw new Error('Windows supervisor artifact does not match the immutable lock.'); + } + return realpathSync(path); +} + +function configureWindowsSupervisor(options, lock) { + configuredWindowsSupervisorPath = undefined; + configureSupervisedExecution(); + if (process.platform !== 'win32') { + return; + } + const configured = options.windowsSupervisorPath; + const metadata = lock.tools.windowsSupervisor.artifact; + if ( + typeof configured !== 'string' || + !windowsPath.isAbsolute(configured) || + windowsPath.normalize(configured).toLowerCase() !== + windowsPath.normalize(metadata.fleetPath).toLowerCase() + ) { + throw new Error('Windows supervisor must use the locked absolute fleet path.'); + } + configuredWindowsSupervisorPath = validateSupervisorArtifactFile(configured, metadata); + configureSupervisedExecution({ windowsSupervisor: configuredWindowsSupervisorPath }); +} + +export function bootstrapWindowsSupervisor(options) { + const lock = readPhase1ConformanceLock(options.lockPath); + configureWindowsSupervisor(options, lock); + return lock; +} + +export function assertNoNodeRuntimeInjection( + environment = process.env, + execArgv = process.execArgv, +) { + const separateInjectionOptions = new Set([ + '-r', + '--require', + '--import', + '--loader', + '--experimental-loader', + '--experimental-policy', + '--policy-integrity', + '--conditions', + ]); + const forbiddenArgument = execArgv.some((argument, index) => { + const normalized = argument.toLowerCase(); + return ( + separateInjectionOptions.has(normalized) || + (normalized.startsWith('-r') && !normalized.startsWith('--') && normalized.length > 2) || + normalized.startsWith('--require=') || + normalized.startsWith('--import=') || + normalized.startsWith('--loader=') || + normalized.startsWith('--experimental-loader=') || + normalized.startsWith('--experimental-policy=') || + normalized.startsWith('--policy-integrity=') || + normalized.startsWith('--conditions=') || + argument === '-C' || + argument.startsWith('-C=') || + (index > 0 && separateInjectionOptions.has(execArgv[index - 1]?.toLowerCase())) + ); + }); + if ( + forbiddenArgument || + forbiddenNodeEnvironment.some((name) => { + const value = environment[name]; + return typeof value === 'string' && value.length > 0; + }) + ) { + throw new Error('Node runtime injection is forbidden for Phase 1 conformance.'); + } +} + +function compactEnvironment(environment) { + return Object.fromEntries( + Object.entries(environment).filter(([, value]) => typeof value === 'string'), + ); +} + +export function assertExecutingHarnessAuthority( + lock, + executingRoot = projectRoot, + environment = process.env, +) { + const configuredRoot = environment[verifiedRunnerRootEnvironment]; + if ( + environment[verifiedRunnerEnvironment] !== '1' || + typeof configuredRoot !== 'string' || + realpathSync(configuredRoot) !== realpathSync(executingRoot) + ) { + throw new Error('Phase 1 runner is not executing from its verified harness checkout.'); + } + const authority = lock.harnessAuthority; + if ( + authority === undefined || + authority.revision !== lock.harness.revision || + runSupervisedSync('git', ['rev-parse', 'HEAD'], { + cwd: executingRoot, + encoding: 'utf8', + env: createGitEnvironment(), + }).trim() !== authority.revision || + runSupervisedSync('git', ['rev-parse', 'HEAD^{tree}'], { + cwd: executingRoot, + encoding: 'utf8', + env: createGitEnvironment(), + }).trim() !== authority.tree + ) { + throw new Error('Executing Phase 1 harness revision does not match its immutable authority.'); + } + for (const file of authority.files) { + const path = resolve(executingRoot, file.path); + const stats = lstatSync(path); + const blob = runSupervisedSync('git', ['rev-parse', `HEAD:${file.path}`], { + cwd: executingRoot, + encoding: 'utf8', + env: createGitEnvironment(), + }).trim(); + if ( + stats.isSymbolicLink() || + !stats.isFile() || + blob !== file.blob || + sha256File(path) !== file.sha256 + ) { + throw new Error('Executing Phase 1 harness module does not match its immutable authority.'); + } + } +} + +async function bootstrapVerifiedRunner(options) { + const lock = runPublicPhase1Stage('phase1.stage.runner-lock.failed', () => { + const locked = readPhase1ConformanceLock(options.lockPath); + configureWindowsSupervisor(options, locked); + return locked; + }); + const bootstrapRoot = createProcessOwnedArtifactRoot({ prefix: 'p1boot', shortPath: true }); + try { + const harnessRoot = resolve(bootstrapRoot.rootPath, 'harness'); + const environment = { + ...process.env, + ...createGitEnvironment(process.env), + }; + await runPublicPhase1StageAsync('phase1.stage.runner-checkout.failed', () => + cloneExactCheckout({ + artifactRoot: bootstrapRoot, + sourceRoot: options.chatSourceRoot, + destinationRoot: harnessRoot, + revision: lock.harness.revision, + environment, + label: 'Verified Chat conformance harness', + }), + ); + runPublicPhase1Stage('phase1.stage.runner-checkout-verification.failed', () => { + if ( + runSupervisedSync('git', ['status', '--porcelain=v1', '--untracked-files=all'], { + cwd: harnessRoot, + encoding: 'utf8', + env: createGitEnvironment(environment), + }).length !== 0 + ) { + throw new Error('Verified Chat conformance harness checkout is not clean.'); + } + }); + const runner = resolve(harnessRoot, 'scripts', 'phase1-conformance.mjs'); + const verifiedArgs = [ + '--lock', + options.lockPath, + '--scenario', + options.scenario, + '--retain-sanitized-report', + options.retainSanitizedReport, + '--chat-root', + options.chatSourceRoot, + '--sdk-root', + options.sdkSourceRoot, + '--sdk-evidence-root', + options.sdkEvidenceSourceRoot, + '--cave-root', + options.caveSourceRoot, + '--coven-root', + options.covenSourceRoot, + ...(options.windowsSupervisorPath === undefined + ? [] + : ['--windows-supervisor', options.windowsSupervisorPath]), + ]; + const result = await runPublicPhase1StageAsync( + 'phase1.stage.verified-runner.failed', + async () => { + try { + return await runCommand( + bootstrapRoot, + 'Verified Phase 1 harness', + process.execPath, + [runner, ...verifiedArgs], + { + cwd: harnessRoot, + env: compactEnvironment({ + PATH: process.env.PATH, + HOME: process.env.HOME, + TMPDIR: process.env.TMPDIR, + LANG: process.env.LANG, + LC_ALL: process.env.LC_ALL, + CI: process.env.CI, + RUSTUP_HOME: process.env.RUSTUP_HOME, + CARGO_HOME: process.env.CARGO_HOME, + OPENCOVEN_CHAT_ROOT: options.chatSourceRoot, + OPENCOVEN_SDK_ROOT: options.sdkSourceRoot, + OPENCOVEN_SDK_EVIDENCE_ROOT: options.sdkEvidenceSourceRoot, + OPENCOVEN_CAVE_ROOT: options.caveSourceRoot, + OPENCOVEN_COVEN_ROOT: options.covenSourceRoot, + OPENCOVEN_PHASE1_WINDOWS_SUPERVISOR_PATH: options.windowsSupervisorPath, + OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED: + process.env.OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED, + PHASE1_TEST_KEYCHAIN: process.env.PHASE1_TEST_KEYCHAIN, + [verifiedRunnerEnvironment]: '1', + [verifiedRunnerRootEnvironment]: harnessRoot, + }), + timeoutMs: cargoBuildTimeoutMs * 3, + }, + ); + } catch (error) { + if (publicPhase1FailureDiagnostic(error) !== undefined) { + throw error; + } + if (error instanceof CommandExecutionError) { + const reason = error.result?.reason; + if (reason === 'timeout') { + throw new Error('phase1.stage.verified-runner.timeout', { cause: error }); + } + if (reason === 'stdout-limit' || reason === 'stderr-limit') { + throw new Error('phase1.stage.verified-runner.output-limit', { cause: error }); + } + if (reason === 'spawn' || reason === 'tracking') { + throw new Error('phase1.stage.verified-runner.spawn', { cause: error }); + } + if (reason === 'supervisor-termination' || reason === 'termination') { + throw new Error('phase1.stage.verified-runner.supervisor', { cause: error }); + } + if (typeof error.result?.code === 'number' && error.result.code !== 0) { + throw new Error('phase1.stage.verified-runner.exit-nonzero', { cause: error }); + } + } + throw error; + } + }, + ); + process.stdout.write(result.stdout); + } finally { + await bootstrapRoot.cleanup(); + } +} + function runCommand( artifactRoot, label, command, args, - { cwd, env, timeoutMs = commandTimeoutMs } = {}, + { cwd, env, timeoutMs = commandTimeoutMs, outputLimitBytes = commandOutputLimit } = {}, ) { + const windowsSupervised = process.platform === 'win32'; + const supervisorPath = resolve(projectRoot, 'scripts', 'phase1-process-supervisor.mjs'); + const invocation = resolveExecutableInvocation( + command, + env ?? process.env, + process.platform, + args, + ); return new Promise((resolveRun, rejectRun) => { - const child = spawn(command, args, { - cwd, - env, - detached: ownedProcessGroupsSupported, - stdio: ['ignore', 'pipe', 'pipe'], - }); + const child = spawn( + windowsSupervised ? configuredWindowsSupervisorPath : process.execPath, + windowsSupervised + ? ['--', invocation.executable, ...invocation.args] + : [ + supervisorPath, + '--timeout-ms', + String(timeoutMs), + '--', + invocation.executable, + ...invocation.args, + ], + { + cwd, + detached: !windowsSupervised, + env, + stdio: windowsSupervised ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe', 'pipe'], + }, + ); const stdout = []; const stderr = []; let stdoutBytes = 0; let stderrBytes = 0; + const supervisorStatus = []; + let supervisorStatusBytes = 0; let settled = false; let timer; + let terminationReason; const fail = (result) => { if (settled) { @@ -425,69 +1252,57 @@ function runCommand( rejectRun(new CommandExecutionError(label, result)); }; - const terminateAndFail = (result) => { - if (settled) { - return; - } - settled = true; - if (timer !== undefined) { - clearTimeout(timer); - } - void artifactRoot.terminateChild(child).then( - () => rejectRun(new CommandExecutionError(label, result)), - () => - rejectRun( - new CommandExecutionError(label, { - code: null, - signal: null, - stdout: '', - stderr: '', - reason: 'tracking', - }), - ), - ); - }; - child.once('spawn', () => { try { - artifactRoot.trackChild(child, { processGroup: ownedProcessGroupsSupported }); + child.__phase1SupervisorOwnsTree = !windowsSupervised; + artifactRoot.trackChild(child); } catch { - killUntrackedOwnedChild(child); + child.kill('SIGTERM'); fail({ code: null, signal: 'SIGKILL', stdout: '', stderr: '', reason: 'tracking' }); } }); child.once('error', () => { fail({ code: null, signal: null, stdout: '', stderr: '', reason: 'spawn' }); }); + const requestTermination = (reason) => { + if (terminationReason !== undefined) { + return; + } + terminationReason = reason; + if (timer !== undefined) { + clearTimeout(timer); + } + if (!child.kill('SIGTERM') && child.exitCode === null && child.signalCode === null) { + fail({ code: null, signal: null, stdout: '', stderr: '', reason: 'termination' }); + } + }; child.stdout.on('data', (chunk) => { stdoutBytes += chunk.length; - if (stdoutBytes > commandOutputLimit) { - terminateAndFail({ - code: null, - signal: 'SIGKILL', - stdout: '', - stderr: '', - reason: 'stdout-limit', - }); + if (stdoutBytes > outputLimitBytes) { + requestTermination('stdout-limit'); return; } stdout.push(chunk); }); child.stderr.on('data', (chunk) => { stderrBytes += chunk.length; - if (stderrBytes > commandOutputLimit) { - terminateAndFail({ - code: null, - signal: 'SIGKILL', - stdout: '', - stderr: '', - reason: 'stderr-limit', - }); + if (stderrBytes > outputLimitBytes) { + requestTermination('stderr-limit'); return; } stderr.push(chunk); }); - child.once('close', (code, signal) => { + if (!windowsSupervised) { + child.stdio[3].on('data', (chunk) => { + supervisorStatusBytes += chunk.length; + if (supervisorStatusBytes > 256) { + requestTermination('status-limit'); + return; + } + supervisorStatus.push(chunk); + }); + } + child.once('close', (_code, signal) => { if (settled) { return; } @@ -495,33 +1310,79 @@ function runCommand( if (timer !== undefined) { clearTimeout(timer); } + if (windowsSupervised) { + const result = { + code: _code, + signal, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + }; + if (_code === 0) { + resolveRun(result); + } else { + rejectRun(new CommandExecutionError(label, result)); + } + return; + } + let supervisedStatus; + try { + supervisedStatus = parseSupervisorStatusFrame(Buffer.concat(supervisorStatus)); + } catch { + rejectRun( + new CommandExecutionError(label, { + code: null, + signal, + stdout: '', + stderr: '', + reason: 'spawn', + }), + ); + return; + } + const result = { - code, - signal, + code: supervisedStatus.code, + signal: supervisedStatus.signal, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), + ...(supervisedStatus.reason === 'exit' ? {} : { reason: supervisedStatus.reason }), }; - if (code === 0) { + if (_code !== null || signal !== 'SIGKILL') { + rejectRun( + new CommandExecutionError(label, { + ...result, + reason: 'supervisor-termination', + }), + ); + return; + } + if (terminationReason !== undefined) { + rejectRun( + new CommandExecutionError(label, { + ...result, + reason: terminationReason, + }), + ); + return; + } + if (supervisedStatus.reason === 'exit' && supervisedStatus.code === 0) { resolveRun(result); } else { - if (label === 'Cave conformance package') { - result.reason = classifyCavePackageFailure(result); - } rejectRun(new CommandExecutionError(label, result)); } }); - timer = setTimeout(() => { - terminateAndFail({ - code: null, - signal: 'SIGKILL', - stdout: '', - stderr: '', - reason: 'timeout', - }); - }, timeoutMs); + if (windowsSupervised) { + timer = setTimeout(() => { + requestTermination('timeout'); + }, timeoutMs); + } }); } +export function runSupervisedCommandForTest(artifactRoot, command, args, options) { + return runCommand(artifactRoot, 'Supervised test command', command, args, options); +} + function sha256File(path) { return createHash('sha256').update(readFileSync(path)).digest('hex'); } @@ -554,6 +1415,20 @@ function sha256Tree(rootPath) { return digest.digest('hex'); } +function assertLockedRegularFile(rootPath, metadata, label) { + const path = resolve(rootPath, metadata.path ?? metadata.vendorFile); + const stats = lstatSync(path); + if ( + stats.isSymbolicLink() || + !stats.isFile() || + stats.size !== metadata.size || + sha256File(path) !== metadata.sha256 + ) { + throw new Error(`${label} does not match the immutable conformance lock.`); + } + return path; +} + async function cloneExactCheckout({ artifactRoot, sourceRoot, @@ -606,7 +1481,9 @@ async function createExactCheckouts(artifactRoot, options, lock, environment) { mkdirSync(checkoutsRoot, { mode: 0o700 }); const roots = { chatRoot: resolve(checkoutsRoot, 'chat'), + chatHarnessRoot: resolve(checkoutsRoot, 'chat-harness'), sdkRoot: resolve(checkoutsRoot, 'sdk'), + sdkEvidenceRoot: resolve(checkoutsRoot, 'sdk-evidence'), caveRoot: resolve(checkoutsRoot, 'cave'), covenRoot: resolve(checkoutsRoot, 'coven'), }; @@ -618,6 +1495,14 @@ async function createExactCheckouts(artifactRoot, options, lock, environment) { environment, label: 'Chat', }); + await cloneExactCheckout({ + artifactRoot, + sourceRoot: options.chatSourceRoot, + destinationRoot: roots.chatHarnessRoot, + revision: lock.harness.revision, + environment, + label: 'Chat conformance harness', + }); await cloneExactCheckout({ artifactRoot, sourceRoot: options.sdkSourceRoot, @@ -626,6 +1511,14 @@ async function createExactCheckouts(artifactRoot, options, lock, environment) { environment, label: 'SDK', }); + await cloneExactCheckout({ + artifactRoot, + sourceRoot: options.sdkEvidenceSourceRoot, + destinationRoot: roots.sdkEvidenceRoot, + revision: lock.evidence.revision, + environment, + label: 'SDK evidence authority', + }); await cloneExactCheckout({ artifactRoot, sourceRoot: options.caveSourceRoot, @@ -654,6 +1547,7 @@ async function installPnpm(artifactRoot, rootPath, environment, label) { 'corepack', [ 'pnpm@10.34.0', + '--ignore-workspace', 'install', '--frozen-lockfile', `--config.store-dir=${environment.PNPM_STORE_DIR}`, @@ -662,173 +1556,507 @@ async function installPnpm(artifactRoot, rootPath, environment, label) { ); } -async function packageLockedArtifacts(artifactRoot, roots, environment) { - await installPnpm(artifactRoot, roots.caveRoot, environment, 'Cave'); - await runCommand( - artifactRoot, - 'Cave conformance package', - 'corepack', - ['pnpm@10.34.0', 'build:conformance'], +export function nativeAdapterTestEnvironment( + environment, + platform = process.platform, + operatorEnvironment = process.env, +) { + if (platform !== 'darwin') { + return environment; + } + const operatorHome = operatorEnvironment.HOME; + if ( + typeof operatorHome !== 'string' || + operatorHome.length === 0 || + operatorHome.includes('\0') || + !isAbsolute(operatorHome) || + resolve(operatorHome) !== operatorHome + ) { + throw new Error('phase1.packaging.native-test-home.invalid'); + } + return { ...environment, HOME: operatorHome }; +} + +export function assertProductionAdapterAtRevision(harnessRoot, lock) { + const productionPaths = [ + 'src-tauri/Cargo.toml', + 'src-tauri/Cargo.lock', + 'src-tauri/src/bin/phase1-native-rpc.rs', + 'src-tauri/src/conformance.rs', + 'src-tauri/src/connection.rs', + 'src-tauri/src/coven.rs', + 'src-tauri/src/keyring.rs', + ]; + const expectedDeltas = lock.harnessAuthority.productionDeltas; + const changed = runSupervisedSync( + 'git', + ['diff', '--name-only', lock.chat.revision, 'HEAD', '--', ...productionPaths], { - cwd: roots.caveRoot, - env: environment, + cwd: harnessRoot, + encoding: 'utf8', + env: createGitEnvironment(), }, - ); + ) + .trim() + .split('\n') + .filter(Boolean) + .sort(); + if ( + !Array.isArray(expectedDeltas) || + JSON.stringify(changed) !== JSON.stringify(expectedDeltas.map(({ path }) => path).sort()) + ) { + throw new Error('Chat conformance native delta set is not exactly allowlisted.'); + } + for (const delta of expectedDeltas) { + const path = resolve(harnessRoot, delta.path); + const stats = lstatSync(path); + const blob = runSupervisedSync('git', ['rev-parse', `HEAD:${delta.path}`], { + cwd: harnessRoot, + encoding: 'utf8', + env: createGitEnvironment(), + }).trim(); + if ( + stats.isSymbolicLink() || + !stats.isFile() || + blob !== delta.blob || + sha256File(path) !== delta.sha256 + ) { + throw new Error('Chat conformance native delta does not match its immutable allowlist.'); + } + } + const covenPath = 'src-tauri/src/coven.rs'; + const releaseSource = runSupervisedSync('git', ['show', `${lock.chat.revision}:${covenPath}`], { + cwd: harnessRoot, + encoding: 'utf8', + env: createGitEnvironment(), + }); + const harnessSource = readFileSync(resolve(harnessRoot, covenPath), 'utf8'); + const productionPrefix = (source) => source.split('\n#[cfg(test)]\nmod tests {', 1)[0]; + if (productionPrefix(releaseSource) !== productionPrefix(harnessSource)) { + throw new Error('Chat production Coven adapter differs from the locked release commit.'); + } +} - await installPnpm(artifactRoot, roots.chatRoot, environment, 'Chat'); - await runCommand(artifactRoot, 'Chat web package', 'corepack', ['pnpm@10.34.0', 'build'], { +function assertProductionChatAuthority(roots, lock) { + const tree = runSupervisedSync('git', ['rev-parse', 'HEAD^{tree}'], { cwd: roots.chatRoot, - env: environment, - }); + encoding: 'utf8', + env: createGitEnvironment(), + }).trim(); + if (tree !== lock.chatAuthority.tree) { + throw new Error('Production Chat tree does not match the immutable authority lock.'); + } + for (const file of lock.chatAuthority.files) { + const path = resolve(roots.chatRoot, file.path); + const stats = lstatSync(path); + const blob = runSupervisedSync('git', ['rev-parse', `HEAD:${file.path}`], { + cwd: roots.chatRoot, + encoding: 'utf8', + env: createGitEnvironment(), + }).trim(); + if ( + stats.isSymbolicLink() || + !stats.isFile() || + blob !== file.blob || + sha256File(path) !== file.sha256 + ) { + throw new Error('Production Chat authority file does not match its locked blob.'); + } + } + try { + runSupervisedSync( + 'git', + ['merge-base', '--is-ancestor', lock.chat.revision, lock.harness.revision], + { + cwd: roots.chatHarnessRoot, + env: createGitEnvironment(), + stdio: 'ignore', + }, + ); + } catch { + throw new Error('Chat conformance harness must descend from the production revision.'); + } +} - await installPnpm(artifactRoot, roots.sdkRoot, environment, 'SDK'); - const sdkTarballsRoot = resolve(artifactRoot.rootPath, 'packages', 'sdk'); - mkdirSync(sdkTarballsRoot, { recursive: true, mode: 0o700 }); - const sdkPackResult = resolve(artifactRoot.rootPath, 'sdk-pack-result.json'); - const sdkPackWrapper = resolve(artifactRoot.rootPath, 'pack-sdk.mjs'); - writeFileSync( - sdkPackWrapper, - [ - `import { writeFileSync } from 'node:fs';`, - `import { packPublicPackages } from ${JSON.stringify( - pathToFileURL(resolve(roots.sdkRoot, 'scripts', 'package-artifacts.mjs')).href, - )};`, - `const tarballs = packPublicPackages({ root: ${JSON.stringify( - roots.sdkRoot, - )}, destinationRoot: ${JSON.stringify(sdkTarballsRoot)}, build: true });`, - `writeFileSync(${JSON.stringify(sdkPackResult)}, JSON.stringify(tarballs));`, - '', - ].join('\n'), - { mode: 0o600 }, - ); - await runCommand(artifactRoot, 'SDK package build', process.execPath, [sdkPackWrapper], { - cwd: roots.sdkRoot, - env: environment, +function assertWindowsSupervisorSource(roots, lock) { + const source = lock.tools.windowsSupervisor.source; + const blob = runSupervisedSync('git', ['rev-parse', `${source.revision}:${source.path}`], { + cwd: roots.chatHarnessRoot, + encoding: 'utf8', + env: createGitEnvironment(), + }).trim(); + const bytes = runSupervisedSync('git', ['show', `${source.revision}:${source.path}`], { + cwd: roots.chatHarnessRoot, + encoding: 'buffer', + env: createGitEnvironment(), }); - const packedTarballs = JSON.parse(readFileSync(sdkPackResult, 'utf8')); + if (blob !== source.blob || createHash('sha256').update(bytes).digest('hex') !== source.sha256) { + throw new Error('Windows supervisor source does not match the immutable lock.'); + } + for (const [path, expected] of [ + ['tools/phase1-process-supervisor/Cargo.toml', source.manifestSha256], + ['tools/phase1-process-supervisor/Cargo.lock', source.lockSha256], + ['tools/phase1-process-supervisor/.cargo/config.toml', source.configSha256], + ]) { + const input = runSupervisedSync('git', ['show', `${source.revision}:${path}`], { + cwd: roots.chatHarnessRoot, + encoding: 'buffer', + env: createGitEnvironment(), + }); + if (createHash('sha256').update(input).digest('hex') !== expected) { + throw new Error('Windows supervisor build input does not match the immutable lock.'); + } + } +} - const packageNames = { - core: 'sdk-core-0.1.0.tgz', - cave: 'cave-client-0.1.0.tgz', - coven: 'coven-client-0.1.0.tgz', - sdk: 'sdk-0.1.0.tgz', - }; - const frozenTarballs = Object.fromEntries( - Object.entries(packageNames).map(([key, name]) => [ - key, - resolve(roots.chatRoot, 'vendor', 'opencoven-sdk', name), - ]), - ); - const compareWrapper = resolve(artifactRoot.rootPath, 'compare-sdk.mjs'); - writeFileSync( - compareWrapper, - [ - `import { assertPackedPackageContentsMatch } from ${JSON.stringify( - pathToFileURL(resolve(roots.chatRoot, 'scripts', 'contract-canary.mjs')).href, - )};`, - `assertPackedPackageContentsMatch(${JSON.stringify( - packedTarballs, - )}, ${JSON.stringify(frozenTarballs)}, ${JSON.stringify( - resolve(artifactRoot.rootPath, 'sdk-compare'), - )});`, - '', - ].join('\n'), - { mode: 0o600 }, +function assertSdkCandidateProvenance(roots, lock) { + try { + runSupervisedSync( + 'git', + [ + '-C', + roots.sdkEvidenceRoot, + 'merge-base', + '--is-ancestor', + lock.sdk.revision, + lock.evidence.revision, + ], + { env: createGitEnvironment(), stdio: 'ignore' }, + ); + } catch { + throw new Error('SDK evidence authority does not descend from the package candidate.'); + } + const sourcePackages = [ + ['packages/core/package.json', '@opencoven/sdk-core'], + ['packages/cave/package.json', '@opencoven/cave-client'], + ['packages/coven/package.json', '@opencoven/coven-client'], + ['packages/sdk/package.json', '@opencoven/sdk'], + ]; + for (let index = 0; index < sourcePackages.length; index += 1) { + const [relativePath, packageName] = sourcePackages[index]; + const manifest = JSON.parse(readFileSync(resolve(roots.sdkRoot, relativePath), 'utf8')); + const artifact = lock.release.sdkArtifacts[index]; + if ( + manifest.name !== packageName || + manifest.version !== lock.release.sdkManifest.version || + artifact.packageName !== packageName + ) { + throw new Error('SDK candidate package identity does not match frozen artifacts.'); + } + } +} + +async function packageLockedArtifacts(artifactRoot, roots, environment, lock) { + runPublicPhase1Stage('phase1.packaging.authority.failed', () => { + assertProductionChatAuthority(roots, lock); + assertWindowsSupervisorSource(roots, lock); + assertSdkCandidateProvenance(roots, lock); + }); + await runPublicPhase1StageAsync('phase1.packaging.chat-install.failed', () => + installPnpm(artifactRoot, roots.chatRoot, environment, 'Chat'), ); - await runCommand( - artifactRoot, - 'SDK packed artifact comparison', - process.execPath, - [compareWrapper], - { - cwd: roots.chatRoot, - env: environment, - }, + await runPublicPhase1StageAsync('phase1.packaging.chat-web-build.failed', () => + runCommand( + artifactRoot, + 'Chat web package', + 'corepack', + ['pnpm@10.34.0', '--ignore-workspace', 'build'], + { + cwd: roots.chatRoot, + env: environment, + }, + ), ); - const chatTarget = resolve(artifactRoot.rootPath, 'build', 'chat-target'); mkdirSync(chatTarget, { recursive: true, mode: 0o700 }); - await runCommand( - artifactRoot, - 'Chat native RPC package', - 'cargo', - [ - 'build', - '--locked', - '--manifest-path', - resolve(roots.chatRoot, 'src-tauri', 'Cargo.toml'), - '--features', - 'phase1-conformance', - '--bin', - 'phase1-native-rpc', - ], - { - cwd: roots.chatRoot, - env: { ...environment, CARGO_TARGET_DIR: chatTarget }, - timeoutMs: cargoBuildTimeoutMs, - }, + assertProductionAdapterAtRevision(roots.chatHarnessRoot, lock); + await runPublicPhase1StageAsync('phase1.packaging.chat-native-build.failed', () => + runCommand( + artifactRoot, + 'Chat native RPC package', + 'cargo', + [ + 'build', + '--locked', + '--manifest-path', + resolve(roots.chatHarnessRoot, 'src-tauri', 'Cargo.toml'), + '--features', + 'phase1-conformance', + '--bin', + 'phase1-native-rpc', + ], + { + cwd: roots.chatHarnessRoot, + env: { ...environment, CARGO_TARGET_DIR: chatTarget }, + timeoutMs: cargoBuildTimeoutMs, + }, + ), + ); + const chatTestResult = await runPublicPhase1StageAsync( + 'phase1.packaging.chat-native-tests.failed', + () => + runCommand( + artifactRoot, + 'Chat native adapter tests', + 'cargo', + [ + 'test', + '--locked', + '--manifest-path', + resolve(roots.chatHarnessRoot, 'src-tauri', 'Cargo.toml'), + '--features', + 'phase1-conformance', + '--lib', + '--test', + 'coven_health_process_boundary', + '--test', + 'phase1_native_rpc', + ], + { + cwd: roots.chatHarnessRoot, + env: { + ...nativeAdapterTestEnvironment(environment), + CARGO_TARGET_DIR: chatTarget, + }, + timeoutMs: cargoBuildTimeoutMs, + }, + ), + ); + + const frozenTarballs = runPublicPhase1Stage('phase1.packaging.artifact-verification.failed', () => + lock.release.sdkArtifacts.map((sdkArtifact) => + assertLockedRegularFile( + resolve(roots.chatRoot, 'vendor', 'opencoven-sdk'), + sdkArtifact, + sdkArtifact.packageName, + ), + ), + ); + runPublicPhase1Stage('phase1.packaging.artifact-verification.failed', () => { + assertLockedRegularFile(roots.chatRoot, lock.release.consumerLock, 'Chat consumer lock'); + for (const [name, metadata] of Object.entries(lock.release.caveArtifacts)) { + assertLockedRegularFile(roots.caveRoot, metadata, `Cave ${name}`); + } + }); + + await runPublicPhase1StageAsync('phase1.packaging.cave-install.failed', () => + installPnpm(artifactRoot, roots.caveRoot, environment, 'Cave'), + ); + await runPackagingCommand('phase1.packaging.cave-build', () => + runCommand( + artifactRoot, + 'Cave authority server package', + 'corepack', + ['pnpm@10.34.0', '--ignore-workspace', 'build'], + { + cwd: roots.caveRoot, + env: caveBuildEnvironment(environment), + }, + ), ); const covenTarget = resolve(artifactRoot.rootPath, 'build', 'coven-target'); mkdirSync(covenTarget, { recursive: true, mode: 0o700 }); - await runCommand( - artifactRoot, - 'Coven CLI package', - 'cargo', - ['build', '--locked', '--package', 'coven-cli', '--bin', 'coven'], - { - cwd: roots.covenRoot, - env: { ...environment, CARGO_TARGET_DIR: covenTarget }, - timeoutMs: cargoBuildTimeoutMs, - }, + await runPublicPhase1StageAsync('phase1.packaging.coven-build.failed', () => + runCommand( + artifactRoot, + 'Coven CLI package', + 'cargo', + ['build', '--locked', '--package', 'coven-cli', '--bin', 'coven'], + { + cwd: roots.covenRoot, + env: { ...environment, CARGO_TARGET_DIR: covenTarget }, + timeoutMs: cargoBuildTimeoutMs, + }, + ), ); + const covenTestEnvironment = { + ...environment, + CARGO_TARGET_DIR: covenTarget, + TMPDIR: dirname(artifactRoot.rootPath), + TMP: dirname(artifactRoot.rootPath), + TEMP: dirname(artifactRoot.rootPath), + }; + const covenTestResults = []; + try { + covenTestResults.push( + await runPackagingCommand('phase1.packaging.coven-client-lib-tests', () => + runCommand( + artifactRoot, + 'Coven producer client lib tests', + 'cargo', + ['test', '--locked', '--package', 'coven-client', '--lib'], + { + cwd: roots.covenRoot, + env: covenTestEnvironment, + timeoutMs: cargoBuildTimeoutMs, + }, + ), + ), + ); + } catch (error) { + if ( + publicPhase1FailureDiagnostic(error) !== + 'phase1.packaging.coven-client-lib-tests.libtest.lifecycle' + ) { + throw error; + } + await diagnoseCovenLifecycleFailure(error.cause ?? error, (testName) => + runCommand( + artifactRoot, + 'Coven producer client lifecycle diagnostic', + 'cargo', + ['test', '--locked', '--package', 'coven-client', '--lib', testName, '--', '--exact'], + { + cwd: roots.covenRoot, + env: covenTestEnvironment, + timeoutMs: cargoBuildTimeoutMs, + }, + ), + ); + } + for (const [group, args] of [ + ['health', ['test', '--locked', '--package', 'coven-client', '--test', 'health']], + ['doc', ['test', '--locked', '--package', 'coven-client', '--doc']], + ]) { + covenTestResults.push( + await runPackagingCommand(`phase1.packaging.coven-client-${group}-tests`, () => + runCommand(artifactRoot, `Coven producer client ${group} tests`, 'cargo', args, { + cwd: roots.covenRoot, + env: covenTestEnvironment, + timeoutMs: cargoBuildTimeoutMs, + }), + ), + ); + } const executableSuffix = process.platform === 'win32' ? '.exe' : ''; const nativeRpcPath = resolve(chatTarget, 'debug', `phase1-native-rpc${executableSuffix}`); const covenBinaryPath = resolve(covenTarget, 'debug', `coven${executableSuffix}`); - for (const [label, path] of [ - ['Chat native RPC', nativeRpcPath], - ['Coven CLI', covenBinaryPath], - ]) { - const stats = lstatSync(path); - if (stats.isSymbolicLink() || !stats.isFile()) { - throw new Error(`${label} package is not a regular file.`); + runPublicPhase1Stage('phase1.packaging.outputs.failed', () => { + for (const [label, path] of [ + ['Chat native RPC', nativeRpcPath], + ['Coven CLI', covenBinaryPath], + ]) { + const stats = lstatSync(path); + if (stats.isSymbolicLink() || !stats.isFile()) { + throw new Error(`${label} package is not a regular file.`); + } } - } + }); return { nativeRpcPath, covenBinaryPath, artifactDigests: { - 'chat-web-bundle': sha256Tree(resolve(roots.chatRoot, 'dist')), - 'chat-native-rpc': sha256File(nativeRpcPath), - 'sdk-core': sha256File(frozenTarballs.core), - 'sdk-cave': sha256File(frozenTarballs.cave), - 'sdk-coven': sha256File(frozenTarballs.coven), - 'sdk-root': sha256File(frozenTarballs.sdk), - 'cave-server': sha256File(resolve(roots.caveRoot, 'server.mjs')), - 'coven-cli': sha256File(covenBinaryPath), + chatWebBundle: sha256Tree(resolve(roots.chatRoot, 'dist')), + chatNativeRpc: sha256File(nativeRpcPath), + caveServer: sha256File(resolve(roots.caveRoot, 'server.mjs')), + covenCli: sha256File(covenBinaryPath), + sdkTarballs: lock.release.sdkArtifacts.map((sdkArtifact, index) => ({ + packageName: sdkArtifact.packageName, + sha256: sha256File(frozenTarballs[index]), + })), }, + passedRustTests: new Set([ + ...parsePassedRustTests(`${chatTestResult.stdout}\n${chatTestResult.stderr}`), + ...covenTestResults.flatMap((result) => [ + ...parsePassedRustTests(`${result.stdout}\n${result.stderr}`), + ]), + ]), }; } async function runCaveAuthorityMatrix(artifactRoot, caveRoot, environment) { - const result = await runCommand( - artifactRoot, - 'Cave real-authority conformance', - process.execPath, - [ - resolve(caveRoot, 'scripts', 'client-v1-conformance.mjs'), - '--include-ttl', - '--include-authority-takeover', - ], - { - cwd: caveRoot, - env: environment, - timeoutMs: caveConformanceTimeoutMs, - }, - ); - return parseCaveConformanceOutput(result.stdout); + const recordPath = resolve(artifactRoot.rootPath, 'cave-record.json'); + let result; + try { + result = await runCommand( + artifactRoot, + 'Cave real-authority conformance', + process.execPath, + [ + resolve(caveRoot, 'scripts', 'client-v1-conformance.mjs'), + '--include-ttl', + '--include-authority-takeover', + '--out', + recordPath, + ], + { + cwd: caveRoot, + env: environment, + timeoutMs: caveConformanceTimeoutMs, + }, + ); + } catch (error) { + if (error instanceof CommandExecutionError) { + const reason = error.result?.reason; + if (reason === 'timeout') { + throw new Error('phase1.cave-authority.timeout', { cause: error }); + } + if (reason === 'stdout-limit' || reason === 'stderr-limit') { + throw new Error('phase1.cave-authority.output-limit', { cause: error }); + } + if (reason === 'spawn' || reason === 'tracking') { + throw new Error('phase1.cave-authority.spawn', { cause: error }); + } + if (reason === 'supervisor-termination' || reason === 'termination') { + throw new Error('phase1.cave-authority.supervisor', { cause: error }); + } + if (typeof error.result?.code === 'number' && error.result.code !== 0) { + const assertions = parseCaveConformanceOutput( + `${error.result.stdout ?? ''}\n${error.result.stderr ?? ''}`, + ); + const categories = new Set( + [...assertions.entries()] + .filter(([, status]) => status === 'failed') + .map(([id]) => id.split(/[./]/u, 1)[0]) + .filter((category) => + [ + 'admin', + 'discovery', + 'health', + 'ingress', + 'pairing', + 'reads', + 'revocation', + 'takeover', + 'harness', + 'hpke', + ].includes(category), + ), + ); + const diagnostic = + categories.size > 1 + ? 'phase1.cave-authority.assertion.multiple' + : categories.size === 1 + ? `phase1.cave-authority.assertion.${[...categories][0]}` + : assertions.size > 0 + ? 'phase1.cave-authority.assertion.unknown' + : 'phase1.cave-authority.exit-nonzero'; + throw new Error(diagnostic, { cause: error }); + } + } + throw error; + } + let record; + try { + record = JSON.parse(readFileSync(recordPath, 'utf8')); + } catch (error) { + throw new Error('phase1.cave-authority.record.invalid', { cause: error }); + } + if ( + record?.summary?.failed !== 0 || + record?.summary?.skipped !== 0 || + !Array.isArray(record?.assertions) || + record.assertions.some((assertion) => assertion.result !== 'pass') + ) { + throw new Error('phase1.cave-authority.record.incomplete'); + } + return { + assertions: parseCaveConformanceOutput(result.stdout), + record, + }; } function requestJson(origin, { method = 'GET', path, headers = {}, body }) { @@ -883,192 +2111,6 @@ function requestJson(origin, { method = 'GET', path, headers = {}, body }) { }); } -async function reserveLoopbackPort() { - const server = createServer(); - server.listen(0, '127.0.0.1'); - await once(server, 'listening'); - const address = server.address(); - const port = address.port; - await new Promise((resolveClose, rejectClose) => - server.close((error) => (error ? rejectClose(error) : resolveClose())), - ); - return port; -} - -function drainBoundedChildOutput(artifactRoot, child) { - const state = { - terminationReason: undefined, - terminationError: undefined, - terminationPromise: undefined, - }; - const terminate = (reason) => { - state.terminationReason ??= reason; - state.terminationPromise ??= artifactRoot.terminateChild(child).catch((error) => { - state.terminationError = error; - }); - }; - for (const stream of [child.stdout, child.stderr]) { - let bytes = 0; - stream.on('data', (chunk) => { - bytes += chunk.length; - if (bytes > commandOutputLimit) { - terminate('output-limit'); - } - }); - } - return state; -} - -async function startCompatibilityCave({ artifactRoot, roots, environment, preset }) { - const compatibilityRoot = resolve(artifactRoot.rootPath, `compatibility-${preset}`); - const covenHome = resolve(compatibilityRoot, 'coven'); - const caveHome = resolve(covenHome, 'cave'); - mkdirSync(caveHome, { recursive: true, mode: 0o700 }); - const port = await reserveLoopbackPort(); - const origin = `http://127.0.0.1:${port}`; - const child = spawn(process.execPath, [resolve(roots.caveRoot, 'server.mjs')], { - cwd: roots.caveRoot, - env: { - ...environment, - COVEN_HOME: covenHome, - COVEN_CAVE_HOME: caveHome, - COVEN_CAVE_PORT: String(port), - COVEN_CAVE_CLIENT_V1_AUTHORITY_MODE: 'off', - COVEN_CAVE_CLIENT_V1_COMPATIBILITY_PRESET: preset, - COVEN_CAVE_HEAP_MONITOR: '0', - NODE_ENV: 'production', - }, - detached: ownedProcessGroupsSupported, - stdio: ['ignore', 'pipe', 'pipe'], - }); - try { - artifactRoot.trackChild(child, { processGroup: ownedProcessGroupsSupported }); - } catch (error) { - killUntrackedOwnedChild(child); - throw error; - } - const outputState = drainBoundedChildOutput(artifactRoot, child); - await once(child, 'spawn'); - - const deadline = Date.now() + rpcTimeoutMs; - while (Date.now() < deadline) { - if (outputState.terminationError !== undefined) { - throw outputState.terminationError; - } - if (outputState.terminationReason !== undefined) { - await outputState.terminationPromise; - throw new Error(`${preset} compatibility Cave exceeded its output limit`); - } - if (child.exitCode !== null || child.signalCode !== null) { - throw new Error(`${preset} compatibility Cave exited before readiness`); - } - try { - const response = await requestJson(origin, { - path: '/api/client/v1/health', - }); - if (response.status >= 200 && response.status < 300) { - return origin; - } - } catch { - // The packaged Cave may not have bound its loopback port yet. - } - await new Promise((resolveWait) => setTimeout(resolveWait, 100)); - } - throw new Error(`${preset} compatibility Cave did not become ready`); -} - -async function runPackedSdkCompatibilityCheck({ - artifactRoot, - roots, - environment, - origin, - preset, -}) { - const caveClientEntry = resolve( - roots.chatRoot, - 'node_modules', - '@opencoven', - 'cave-client', - 'dist', - 'index.js', - ); - const wrapperPath = resolve(artifactRoot.rootPath, `compatibility-sdk-${preset}.mjs`); - writeFileSync( - wrapperPath, - [ - `import { CaveClient } from ${JSON.stringify(pathToFileURL(caveClientEntry).href)};`, - `const origin = ${JSON.stringify(origin)};`, - `const client = new CaveClient({`, - ` transport: {`, - ` async health() {`, - ` const response = await fetch(new URL('/api/client/v1/health', origin), {`, - ` cache: 'no-store',`, - ` credentials: 'omit',`, - ` redirect: 'error',`, - ` });`, - ` if (!response.ok) throw new Error('compatibility health request failed');`, - ` return response.json();`, - ` },`, - ` },`, - `});`, - `const failure = await client.health().then(() => undefined, (error) => error);`, - `const code = failure && typeof failure === 'object'`, - ` ? Object.getOwnPropertyDescriptor(failure, 'code')?.value`, - ` : undefined;`, - `if (code !== 'incompatible_version') {`, - ` throw new Error(${JSON.stringify( - `${preset} preset did not produce incompatible_version`, - )});`, - `}`, - '', - ].join('\n'), - { mode: 0o600 }, - ); - await runCommand( - artifactRoot, - `Cave ${preset} packed SDK compatibility`, - process.execPath, - [wrapperPath], - { - cwd: roots.chatRoot, - env: environment, - }, - ); -} - -async function runCompatibilityScenarios({ artifactRoot, roots, environment, results }) { - try { - for (const preset of ['api-major', 'minimum-client']) { - const origin = await startCompatibilityCave({ - artifactRoot, - roots, - environment, - preset, - }); - await runPackedSdkCompatibilityCheck({ - artifactRoot, - roots, - environment, - origin, - preset, - }); - } - addAssertion( - results, - 'phase1.compat.api-major-min-client', - 'passed', - 'phase1.assertion.passed', - ); - } catch { - addAssertion( - results, - 'phase1.compat.api-major-min-client', - 'failed', - 'phase1.assertion.failed', - ); - } -} - function startFixtureDaemon(roster) { const server = createServer((request, response) => { if (request.url === '/api/v1/familiars') { @@ -1205,7 +2247,12 @@ function writeNativeFixture(caveHome, covenHome, daemonUrl) { } } -export async function triggerAndWaitForChildClose(child, trigger, timeoutMs = rpcTimeoutMs) { +export async function triggerAndWaitForChildClose( + child, + trigger, + timeoutMs = rpcTimeoutMs, + supervised = false, +) { const closed = once(child, 'close'); await trigger(); let timer; @@ -1216,7 +2263,7 @@ export async function triggerAndWaitForChildClose(child, trigger, timeoutMs = rp timer = setTimeout(() => rejectTimeout(new Error('child shutdown timed out')), timeoutMs); }), ]); - assertSuccessfulChildExit(code, signal); + await assertSuccessfulChildExit(child, code, signal, supervised); } finally { if (timer !== undefined) { clearTimeout(timer); @@ -1224,7 +2271,20 @@ export async function triggerAndWaitForChildClose(child, trigger, timeoutMs = rp } } -function assertSuccessfulChildExit(code, signal) { +async function assertSuccessfulChildExit(child, code, signal, supervised = false) { + if (supervised) { + const status = await child.__phase1SupervisorStatus; + if ( + code === null && + signal === 'SIGKILL' && + status?.reason === 'exit' && + status.code === 0 && + status.signal === null + ) { + return; + } + throw new Error('supervised child did not report a successful authenticated exit'); + } if (code !== 0 || signal !== null) { throw new Error( signal === null @@ -1235,12 +2295,23 @@ function assertSuccessfulChildExit(code, signal) { } export class NativeRpcClient { - constructor(child, { shutdownTimeoutMs = rpcTimeoutMs } = {}) { + constructor(child, { shutdownTimeoutMs = rpcTimeoutMs, supervised = false } = {}) { this.child = child; this.shutdownTimeoutMs = shutdownTimeoutMs; + this.supervised = supervised; this.pending = new Map(); this.sequence = 0; this.buffer = ''; + this.closed = false; + const rejectPending = () => { + this.closed = true; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new Error('native RPC transport closed')); + } + this.pending.clear(); + }; + child.stdin.on?.('error', rejectPending); child.stdout.setEncoding('utf8'); child.stdout.on('data', (chunk) => { this.buffer += chunk; @@ -1266,11 +2337,7 @@ export class NativeRpcClient { } }); child.once('close', () => { - for (const pending of this.pending.values()) { - clearTimeout(pending.timer); - pending.reject(new Error('native RPC closed before responding')); - } - this.pending.clear(); + rejectPending(); }); } @@ -1283,6 +2350,9 @@ export class NativeRpcClient { } request(command, args) { + if (this.closed) { + return Promise.reject(new Error('native RPC transport closed')); + } this.sequence += 1; const id = `request-${this.sequence}`; const request = { id, command, ...(args === undefined ? {} : { args }) }; @@ -1292,7 +2362,24 @@ export class NativeRpcClient { rejectRequest(new Error(`native RPC timed out for ${command}`)); }, rpcTimeoutMs); this.pending.set(id, { resolve: resolveRequest, reject: rejectRequest, timer }); - this.child.stdin.write(`${JSON.stringify(request)}\n`); + const failWrite = () => { + const pending = this.pending.get(id); + if (pending === undefined) { + return; + } + this.pending.delete(id); + clearTimeout(pending.timer); + pending.reject(new Error('native RPC transport closed')); + }; + try { + this.child.stdin.write(`${JSON.stringify(request)}\n`, (error) => { + if (error !== undefined && error !== null) { + failWrite(); + } + }); + } catch { + failWrite(); + } }); } @@ -1314,17 +2401,97 @@ export class NativeRpcClient { async close() { if (this.child.exitCode !== null || this.child.signalCode !== null) { - assertSuccessfulChildExit(this.child.exitCode, this.child.signalCode); + await assertSuccessfulChildExit( + this.child, + this.child.exitCode, + this.child.signalCode, + this.supervised, + ); return; } await triggerAndWaitForChildClose( this.child, () => this.ok('conformance_shutdown'), this.shutdownTimeoutMs, + this.supervised, ); } } +function spawnOwnedProcess(command, args, { cwd, env, stdio }) { + const windowsSupervised = process.platform === 'win32'; + const supervised = !windowsSupervised; + const supervisorPath = resolve(projectRoot, 'scripts', 'phase1-process-supervisor.mjs'); + const invocation = resolveExecutableInvocation( + command, + env ?? process.env, + process.platform, + args, + ); + const child = spawn( + windowsSupervised ? configuredWindowsSupervisorPath : process.execPath, + windowsSupervised + ? ['--', invocation.executable, ...invocation.args] + : [ + supervisorPath, + '--timeout-ms', + String(commandTimeoutMs), + '--', + invocation.executable, + ...invocation.args, + ], + { + cwd, + detached: !windowsSupervised, + env, + stdio: windowsSupervised ? stdio : [...stdio, 'pipe'], + }, + ); + child.__phase1SupervisorOwnsTree = supervised; + if (!windowsSupervised) { + const statusStream = child.stdio[3]; + child.__phase1SupervisorStatus = new Promise((resolveStatus, rejectStatus) => { + const chunks = []; + let bytes = 0; + statusStream.on('data', (chunk) => { + bytes += chunk.length; + if (bytes > 256) { + rejectStatus(new Error('supervisor status frame exceeded its bound')); + statusStream.destroy(); + return; + } + chunks.push(chunk); + }); + statusStream.once('end', () => { + try { + resolveStatus(parseSupervisorStatusFrame(Buffer.concat(chunks))); + } catch { + rejectStatus(new Error('supervisor status frame was not canonical')); + } + }); + statusStream.once('error', () => { + rejectStatus(new Error('supervisor status channel failed')); + }); + }); + child.__phase1SupervisorStatus.catch(() => undefined); + } + return { child, supervised }; +} + +export async function runOwnedProcessStatusForTest(command, args, options) { + const { child, supervised } = spawnOwnedProcess(command, args, { + ...options, + stdio: ['ignore', 'ignore', 'ignore'], + }); + const [code, signal] = await once(child, 'close'); + return { + code, + signal, + supervised, + status: supervised ? await child.__phase1SupervisorStatus : { code, signal, reason: 'exit' }, + }; +} + export async function withFixtureDaemon(fixtureDaemon, action) { try { return await action(); @@ -1342,16 +2509,72 @@ export async function withOwnedArtifactRoot(ownedRoot, action) { } async function startNativeRpc(artifactRoot, binaryPath, environment, cwd) { - const child = spawn(binaryPath, [], { + const { child, supervised } = spawnOwnedProcess(binaryPath, [], { cwd, env: environment, - detached: ownedProcessGroupsSupported, stdio: ['pipe', 'pipe', 'pipe'], }); await once(child, 'spawn'); - artifactRoot.trackChild(child, { processGroup: ownedProcessGroupsSupported }); + artifactRoot.trackChild(child); child.stderr.resume(); - return new NativeRpcClient(child); + return new NativeRpcClient(child, { supervised }); +} + +export function nativeMissingKeychainFailureDiagnostic({ + supervised, + code, + signal, + supervisorStatusValid, + terminationReason, + killFailed, + processFailed, + canaryExposed, + homeChanged, + responseValid, +}) { + if (terminationReason === 'timeout') { + return 'phase1.native-scenarios.missing-keychain-timeout'; + } + if (terminationReason?.endsWith('-limit')) { + return 'phase1.native-scenarios.missing-keychain-output-limit'; + } + if (terminationReason !== undefined || killFailed || processFailed) { + return 'phase1.native-scenarios.missing-keychain-process'; + } + if ( + (!supervised && (code !== 0 || signal !== null)) || + (supervised && (code !== null || signal !== 'SIGKILL')) + ) { + return 'phase1.native-scenarios.missing-keychain-termination'; + } + if (!supervisorStatusValid) { + return 'phase1.native-scenarios.missing-keychain-supervisor'; + } + if (canaryExposed) { + return 'phase1.native-scenarios.missing-keychain-canary'; + } + if (homeChanged) { + return 'phase1.native-scenarios.missing-keychain-home'; + } + if (!responseValid) { + return 'phase1.native-scenarios.missing-keychain-response'; + } + return undefined; +} + +export function nativeMissingKeychainResponsesValid(responses) { + return isDeepStrictEqual(responses, [ + { + id: 'installation', + ok: false, + error: { code: 'secure_store_unavailable', retryable: true }, + }, + { + id: 'shutdown', + ok: true, + result: { status: 'shutting_down' }, + }, + ]); } async function runNativeMissingKeychainTrustScenario( @@ -1364,7 +2587,7 @@ async function runNativeMissingKeychainTrustScenario( mkdirSync(trustHome, { recursive: true, mode: 0o700 }); const beforeEntries = readdirSync(trustHome); const canary = 'native-keychain-canary-must-not-escape'; - const child = spawn(nativeRpcPath, [], { + const { child, supervised } = spawnOwnedProcess(nativeRpcPath, [], { cwd: artifactRoot.rootPath, env: { ...environment, @@ -1374,17 +2597,15 @@ async function runNativeMissingKeychainTrustScenario( COVEN_CAVE_AUTH_TOKEN: canary, OPENCOVEN_PHASE1_CONFORMANCE_NATIVE_PROVIDER_PRESET: 'missing-keychain-trust', }, - detached: ownedProcessGroupsSupported, stdio: ['pipe', 'pipe', 'pipe'], }); await once(child, 'spawn'); - artifactRoot.trackChild(child, { processGroup: ownedProcessGroupsSupported }); + artifactRoot.trackChild(child); const stdout = []; const stderr = []; let stdoutBytes = 0; let stderrBytes = 0; let terminationReason; - let terminationPromise; let killError; let processError; let closed = false; @@ -1399,12 +2620,10 @@ async function runNativeMissingKeychainTrustScenario( if (closed || child.exitCode !== null || child.signalCode !== null) { return; } - terminationPromise ??= artifactRoot.terminateChild(child).catch((error) => { - killError = - error instanceof Error - ? error - : new Error(`native missing-keychain-trust child could not be killed (${reason})`); - }); + const signal = process.platform === 'win32' ? 'SIGKILL' : 'SIGTERM'; + if (!child.kill(signal) && child.exitCode === null && child.signalCode === null) { + killError = new Error(`native missing-keychain-trust child could not be killed (${reason})`); + } }; child.once('error', (error) => { processError = error; @@ -1453,43 +2672,48 @@ async function runNativeMissingKeychainTrustScenario( clearTimeout(reapTimeoutHandle); } clearTimeout(timeoutHandle); - if (terminationPromise !== undefined) { - await terminationPromise; - } if (closeResult === null) { - throw new Error('native missing-keychain-trust child could not be reaped'); + throw new Error('phase1.native-scenarios.missing-keychain-reap'); } const [code, signal] = closeResult; const stdoutText = Buffer.concat(stdout).toString('utf8'); const stderrText = Buffer.concat(stderr).toString('utf8'); - const responses = stdoutText - .trim() - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)); - const unchanged = JSON.stringify(readdirSync(trustHome)) === JSON.stringify(beforeEntries); - let responsesValid = true; + let supervisedStatusValid = true; + if (supervised) { + try { + const status = await child.__phase1SupervisorStatus; + supervisedStatusValid = + status.reason === 'exit' && status.code === 0 && status.signal === null; + } catch { + supervisedStatusValid = false; + } + } + let responses; try { - assertNativeMissingKeychainResponses(responses); + responses = stdoutText + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); } catch { - responsesValid = false; + responses = undefined; } - if ( - terminationReason !== undefined || - killError !== undefined || - processError !== undefined || - code !== 0 || - signal !== null || - stderrText.includes(canary) || - stdoutText.includes(canary) || - !unchanged || - !responsesValid - ) { - throw new Error( - `native missing-keychain-trust preset returned an unsafe result${ - terminationReason === undefined ? '' : ` (${terminationReason})` - }`, - ); + const unchanged = JSON.stringify(readdirSync(trustHome)) === JSON.stringify(beforeEntries); + const responseValid = nativeMissingKeychainResponsesValid(responses); + const diagnostic = nativeMissingKeychainFailureDiagnostic({ + supervised, + code, + signal, + supervisorStatusValid: supervisedStatusValid, + terminationReason, + killFailed: killError !== undefined, + processFailed: processError !== undefined, + canaryExposed: stderrText.includes(canary) || stdoutText.includes(canary), + homeChanged: !unchanged, + responseValid, + }); + if (diagnostic !== undefined) { + throw new Error(diagnostic); } addAssertion( results, @@ -1530,7 +2754,16 @@ async function adminMutation(origin, adminToken, method, path, body) { return response.json; } -async function pairNative(rpc, handle, origin, adminToken, installationId) { +async function pairNative( + rpc, + handle, + origin, + adminToken, + installationId, + approvePairing = adminMutation, + onStage = () => {}, +) { + onStage('create'); const created = await rpc.ok('cave_pairing_create', { handle, request: { @@ -1544,6 +2777,7 @@ async function pairNative(rpc, handle, origin, adminToken, installationId) { if (typeof requestId !== 'string') { throw new Error('native pairing creation omitted its request ID'); } + onStage('pending'); const pending = await rpc.ok('cave_pairing_poll', { handle, requestId, @@ -1552,9 +2786,17 @@ async function pairNative(rpc, handle, origin, adminToken, installationId) { if (pending.status !== 'pending') { throw new Error('native pairing did not begin pending'); } - await adminMutation(origin, adminToken, 'POST', `/admin/pairing-requests/${requestId}/decision`, { - decision: 'approved', - }); + onStage('approve'); + await approvePairing( + origin, + adminToken, + 'POST', + `/admin/pairing-requests/${requestId}/decision`, + { + decision: 'approved', + }, + ); + onStage('approved'); const approved = await rpc.ok('cave_pairing_poll', { handle, requestId, @@ -1563,6 +2805,7 @@ async function pairNative(rpc, handle, origin, adminToken, installationId) { if (approved.status !== 'approved') { throw new Error('native pairing was not approved'); } + onStage('exchange'); const exchanged = await rpc.ok('cave_pairing_exchange', { handle, requestId, @@ -1583,20 +2826,250 @@ function collection(result, name) { return items; } +async function runEmergencyNativeCredentialCleanup({ + artifactRoot, + nativeRpcPath, + environment, + reservationHandle, + capability, + ownerToken, +}) { + const rpc = await startNativeRpc( + artifactRoot, + nativeRpcPath, + { + ...nativeAdapterTestEnvironment(environment), + OPENCOVEN_PHASE1_CONFORMANCE_NATIVE_PROVIDER_PRESET: 'production-keyring', + }, + projectRoot, + ); + try { + const result = await rpc.ok('conformance_delete_native_credential', { + reservationHandle, + capability, + ownerToken, + }); + if (result.status !== 'missing') { + throw new Error('Emergency native credential cleanup did not verify NoEntry.'); + } + } finally { + await rpc.close(); + } +} + +const cleanupCapabilityPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + +export async function establishNativeCleanupReservation(rpc, handle, onStage = () => {}) { + try { + onStage('reservation-request'); + const response = await rpc.request('conformance_prepare_native_cleanup', { handle }); + if (response?.ok !== true) { + const rejectionStage = { + keychain_failure: 'reservation-keychain', + secure_store_unavailable: 'reservation-store-unavailable', + invalid_discovery_handle: 'reservation-invalid-handle', + cave_discovery_required: 'reservation-discovery-required', + cave_health_required: 'reservation-health-required', + }[response?.error?.code]; + onStage(rejectionStage ?? 'reservation-rejected'); + throw new Error('native cleanup reservation request was rejected'); + } + onStage('reservation-response'); + const reservation = response?.result; + if ( + reservation === null || + typeof reservation !== 'object' || + Array.isArray(reservation) || + Object.keys(reservation).sort().join(',') !== 'capability,ownerToken,reservationHandle' || + typeof reservation.reservationHandle !== 'string' || + !cleanupCapabilityPattern.test(reservation.reservationHandle) || + typeof reservation.capability !== 'string' || + !cleanupCapabilityPattern.test(reservation.capability) || + typeof reservation.ownerToken !== 'string' || + !cleanupCapabilityPattern.test(reservation.ownerToken) + ) { + throw new Error('native cleanup reservation response was invalid'); + } + + return reservation; + } catch { + try { + const canceled = await rpc.ok('conformance_cancel_prepared_native_cleanup'); + if (canceled?.status !== 'missing') { + throw new Error('native cleanup reservation marker remained'); + } + } catch { + onStage('reservation-cleanup'); + throw new Error('Native cleanup reservation failed and marker cleanup did not complete.'); + } + throw new Error('Native cleanup reservation could not be established.'); + } +} + +export function createCleanupAdoptionRecovery(reservation) { + return { + predecessor: { ...reservation }, + successor: { ...reservation, ownerToken: randomUUID() }, + deleted: false, + }; +} + +export async function adoptNativeCleanupReservation(rpc, recovery, openRecoveryRpc) { + const reservation = recovery.predecessor; + const successorOwnerToken = recovery.successor.ownerToken; + const args = { + reservationHandle: reservation.reservationHandle, + capability: reservation.capability, + ownerToken: reservation.ownerToken, + successorOwnerToken, + }; + let begun; + try { + const response = await rpc.request('conformance_begin_adopt_native_cleanup', args); + begun = response?.result; + if ( + response?.ok !== true || + begun?.reservationHandle !== reservation.reservationHandle || + begun?.capability !== reservation.capability || + begun?.ownerToken !== successorOwnerToken + ) { + throw new Error('invalid begin adoption response'); + } + } catch { + await rpc.ok('conformance_abort_adopt_native_cleanup', args).catch(() => undefined); + throw new Error('Native cleanup reservation adoption was invalid.'); + } + let committed; + for (let attempt = 0; attempt < 2 && committed === undefined; attempt += 1) { + try { + const response = await rpc.request('conformance_commit_adopt_native_cleanup', args); + if ( + response?.ok === true && + response.result?.status === 'committed' && + response.result?.ownerToken === successorOwnerToken + ) { + committed = response.result; + } + } catch { + // The same token makes commit retries idempotent after a lost response. + } + } + if (committed === undefined) { + if (typeof openRecoveryRpc === 'function') { + const recoveryRpc = await openRecoveryRpc(); + try { + const response = await recoveryRpc.request('conformance_commit_adopt_native_cleanup', args); + if ( + response?.ok === true && + response.result?.status === 'committed' && + response.result?.ownerToken === successorOwnerToken + ) { + const deleted = await recoveryRpc.ok('conformance_delete_native_credential', { + reservationHandle: recovery.successor.reservationHandle, + capability: recovery.successor.capability, + ownerToken: recovery.successor.ownerToken, + }); + if (deleted?.status === 'missing') { + recovery.deleted = true; + } + } + } finally { + await recoveryRpc.close().catch(() => undefined); + } + } + throw new Error('Native cleanup reservation commit could not be confirmed.'); + } + return recovery.successor; +} + +export async function runReservedNativePairing({ + rpc, + handle, + origin, + adminToken, + installationId, + approvePairing = adminMutation, + onReservation = () => {}, + onCredentialMayExist = () => {}, + onStage = () => {}, +}) { + const reservation = await establishNativeCleanupReservation(rpc, handle, onStage); + onReservation(reservation); + onStage('credential-status'); + const initialCredentialStatus = await rpc.ok('cave_credential_status', { + handle, + operation: rpc.operation(), + }); + if (initialCredentialStatus.status !== 'missing') { + throw new Error('isolated native credential store was not empty'); + } + onCredentialMayExist(); + const paired = await pairNative( + rpc, + handle, + origin, + adminToken, + installationId, + approvePairing, + onStage, + ); + return { reservation, initialCredentialStatus, paired }; +} + +export async function runNativeScenarioOrchestrator({ runPairing, runLifecycle }) { + const pairing = await runPairing(); + return runLifecycle(pairing); +} + +export function throwNativeScenarioFailures({ + scenarioFailure, + cleanupFailure, + rpcCleanupFailure, + daemonCloseFailure, +}) { + const failures = [scenarioFailure, cleanupFailure, rpcCleanupFailure, daemonCloseFailure].filter( + (failure) => failure !== undefined, + ); + if (failures.length > 0) { + throw new AggregateError(failures, 'Native conformance scenario teardown failed.'); + } +} + async function runNativeScenarios({ artifactRoot, roots, nativeRpcPath, environment, results }) { const isolatedHome = resolve(artifactRoot.rootPath, 'native-authority-home'); const covenHome = resolve(isolatedHome, 'coven'); const caveHome = resolve(covenHome, 'cave'); - const fixtureDaemon = await startFixtureDaemon([ - { - id: 'archivist', - display_name: 'Archivist', - role: 'Keeper', - description: 'Synthetic roster entry.', - }, - ]); + let activeNativeStage = 'fixture-daemon'; + let fixtureDaemon; + try { + fixtureDaemon = await startFixtureDaemon([ + { + id: 'archivist', + display_name: 'Archivist', + role: 'Keeper', + description: 'Synthetic roster entry.', + }, + ]); + } catch (error) { + throw new Error('phase1.native-scenarios.fixture-daemon', { cause: error }); + } let rpc; + let nativeCredentialStoreBefore; + let nativeCredentialStoreAfter; + let nativeCredentialInstanceId; + let finalNativeCredentialInstanceId; + let nativeCredentialAccount; + let cleanupReservation; + let cleanupAdoptionRecovery; + let rpcEnvironment; + let handle; + let credentialId; + let credentialMayExist = false; + let firstNativeAssertionFailureStage; + let scenarioFailure; try { + activeNativeStage = 'fixture'; writeNativeFixture(caveHome, covenHome, fixtureDaemon.url); const portServer = createServer(); portServer.listen(0, '127.0.0.1'); @@ -1607,8 +3080,8 @@ async function runNativeScenarios({ artifactRoot, roots, nativeRpcPath, environm ); const origin = `http://127.0.0.1:${port}`; const adminToken = `phase1-${randomUUID()}`; - const rpcEnvironment = { - ...environment, + rpcEnvironment = { + ...nativeAdapterTestEnvironment(environment), COVEN_HOME: covenHome, COVEN_CAVE_HOME: caveHome, COVEN_CAVE_PORT: String(port), @@ -1617,12 +3090,13 @@ async function runNativeScenarios({ artifactRoot, roots, nativeRpcPath, environm COVEN_CAVE_HEAP_MONITOR: '0', OPENCOVEN_PHASE1_CONFORMANCE_NODE_PATH: realpathSync(process.execPath), OPENCOVEN_PHASE1_CONFORMANCE_CAVE_SERVER_PATH: resolve(roots.caveRoot, 'server.mjs'), + OPENCOVEN_PHASE1_CONFORMANCE_NATIVE_PROVIDER_PRESET: 'production-keyring', NODE_ENV: 'production', }; + activeNativeStage = 'rpc-start'; rpc = await startNativeRpc(artifactRoot, nativeRpcPath, rpcEnvironment, roots.caveRoot); - let handle; - let credentialId; + activeNativeStage = 'launch'; try { await rpc.error( 'cave_read_discovery', @@ -1639,420 +3113,827 @@ async function runNativeScenarios({ artifactRoot, roots, nativeRpcPath, environm if (health.apiVersion !== '1.0' || health.data?.pairingRequired !== true) { throw new Error('launched Cave returned an invalid health envelope'); } + if ( + typeof health.data?.instanceId !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test( + health.data.instanceId, + ) + ) { + throw new Error('isolated Cave did not publish a canonical instance identity'); + } + nativeCredentialInstanceId = health.data.instanceId; + nativeCredentialAccount = `cave-client-v1:${nativeCredentialInstanceId}`; addAssertion( results, 'phase1.missing-cave.validated-launch', 'passed', 'phase1.assertion.passed', ); - } catch (error) { - process.stderr.write( - `phase1-conformance: phase1.missing-cave.validated-launch failed: ${error instanceof Error ? error.message : 'unknown'}\n`, - ); + } catch { + process.stderr.write('phase1-conformance: validated Cave launch assertion failed.\n'); addAssertion( results, 'phase1.missing-cave.validated-launch', 'failed', 'phase1.assertion.failed', ); + throw new Error('Validated Cave launch failed before cleanup reservation.'); } - try { - if (typeof handle !== 'string') { - throw new Error('no native authority handle'); - } - const paired = await pairNative(rpc, handle, origin, adminToken, 'phase1-installation-1'); - credentialId = paired.credentialId; - addAssertion( - results, - 'phase1.pairing.create-pending-approve-exchange', - 'passed', - 'phase1.assertion.passed', - ); - } catch (error) { - process.stderr.write( - `phase1-conformance: phase1.pairing.create-pending-approve-exchange failed: ${error instanceof Error ? error.message : 'unknown'}\n`, - ); - addAssertion( - results, - 'phase1.pairing.create-pending-approve-exchange', - 'failed', - 'phase1.integration.native-pairing-exchange-failed', - ); - try { - if (typeof handle === 'string') { - await rpc.ok('cave_reset_pairing', { handle }); - const discovery = await waitForDiscovery(rpc); - handle = discovery.handle; - await rpc.ok('cave_health', { handle, operation: rpc.operation() }); - } - } catch { - // The denial leg below will record an independent failure if recovery - // from the failed exchange did not restore a usable authority handle. - } - } - - try { - const created = await rpc.ok('cave_pairing_create', { - handle, - request: { - appName: 'OpenCoven Chat', - installationId: 'phase1-installation-denied', - scopes: ['chat:read'], - }, - operation: rpc.operation(), - }); - await adminMutation( - origin, - adminToken, - 'POST', - `/admin/pairing-requests/${created.requestId}/decision`, - { decision: 'denied' }, - ); - const denied = await rpc.ok('cave_pairing_poll', { - handle, - requestId: created.requestId, - operation: rpc.operation(), - }); - assertPairingStatus(denied, 'denied'); - addAssertion(results, 'phase1.pairing.denial', 'passed', 'phase1.assertion.passed'); - } catch (error) { - process.stderr.write( - `phase1-conformance: phase1.pairing.denial failed: ${error instanceof Error ? error.message : 'unknown'}\n`, - ); - addAssertion(results, 'phase1.pairing.denial', 'failed', 'phase1.assertion.failed'); - } - - if (typeof credentialId !== 'string') { - addAssertion( - results, - 'phase1.credential.restart-reuse', - 'blocked', - 'phase1.integration.native-credential-unavailable', - ); - } else { - try { - await rpc.ok('conformance_reset_native_state'); - await rpc.ok('cave_launch'); - const discovery = await waitForDiscovery(rpc); - handle = discovery.handle; - await rpc.ok('cave_health', { handle, operation: rpc.operation() }); - const status = await rpc.ok('cave_credential_status', { + activeNativeStage = 'pairing'; + await runNativeScenarioOrchestrator({ + runPairing: () => + runReservedNativePairing({ + rpc, handle, - operation: rpc.operation(), - }); - if (status.status !== 'valid') { - throw new Error('credential was not reused after native state restart'); - } + origin, + adminToken, + installationId: 'phase1-installation-1', + onReservation(reservation) { + cleanupReservation = reservation; + }, + onCredentialMayExist() { + credentialMayExist = true; + }, + onStage(stage) { + activeNativeStage = `pairing-${stage}`; + }, + }), + runLifecycle: async (reservedPairing) => { + nativeCredentialStoreBefore = createHash('sha256') + .update( + JSON.stringify({ + service: 'ai.opencoven.chat', + account: nativeCredentialAccount, + status: reservedPairing.initialCredentialStatus.status, + }), + ) + .digest('hex'); + credentialId = reservedPairing.paired.credentialId; addAssertion( results, - 'phase1.credential.restart-reuse', + 'phase1.pairing.create-pending-approve-exchange', 'passed', 'phase1.assertion.passed', ); - } catch (error) { - process.stderr.write( - `phase1-conformance: phase1.credential.restart-reuse failed: ${error instanceof Error ? error.message : 'unknown'}\n`, - ); - addAssertion( - results, - 'phase1.credential.restart-reuse', - 'failed', - 'phase1.assertion.failed', - ); - } - } - if (typeof credentialId !== 'string') { - addAssertion( - results, - 'phase1.reads.bounded-canonical', - 'blocked', - 'phase1.integration.native-credential-unavailable', - ); - } else { - try { - const familiars = collection( - await rpc.ok('cave_list_familiars', { + activeNativeStage = 'pairing-denial'; + try { + const created = await rpc.ok('cave_pairing_create', { handle, - page: { limit: 1 }, + request: { + appName: 'OpenCoven Chat', + installationId: 'phase1-installation-denied', + scopes: ['chat:read'], + }, operation: rpc.operation(), - }), - 'familiars', - ); - const projects = collection( - await rpc.ok('cave_list_projects', { + }); + await adminMutation( + origin, + adminToken, + 'POST', + `/admin/pairing-requests/${created.requestId}/decision`, + { decision: 'denied' }, + ); + const denied = await rpc.ok('cave_pairing_poll', { handle, - page: { limit: 2 }, + requestId: created.requestId, operation: rpc.operation(), - }), - 'projects', - ); - const conversations = collection( - await rpc.ok('cave_list_conversations', { + }); + assertPairingStatus(denied, 'denied'); + addAssertion(results, 'phase1.pairing.denial', 'passed', 'phase1.assertion.passed'); + } catch { + firstNativeAssertionFailureStage ??= activeNativeStage; + process.stderr.write('phase1-conformance: pairing denial assertion failed.\n'); + addAssertion(results, 'phase1.pairing.denial', 'failed', 'phase1.assertion.failed'); + } + + activeNativeStage = 'restart'; + if (typeof credentialId !== 'string') { + addAssertion( + results, + 'phase1.credential.restart-reuse', + 'blocked', + 'phase1.integration.native-credential-unavailable', + ); + } else { + let replacementRpc; + try { + activeNativeStage = 'restart-rpc-start'; + replacementRpc = await startNativeRpc( + artifactRoot, + nativeRpcPath, + rpcEnvironment, + roots.caveRoot, + ); + activeNativeStage = 'restart-discovery'; + const replacementDiscovery = await waitForDiscovery(replacementRpc); + const replacementHandle = replacementDiscovery.handle; + activeNativeStage = 'restart-health'; + const replacementHealth = await replacementRpc.ok('cave_health', { + handle: replacementHandle, + operation: replacementRpc.operation(), + }); + if (replacementHealth.data?.instanceId !== nativeCredentialInstanceId) { + throw new Error('Cave identity changed before native credential restart'); + } + cleanupAdoptionRecovery = createCleanupAdoptionRecovery(cleanupReservation); + activeNativeStage = 'restart-cleanup-adoption'; + cleanupReservation = await adoptNativeCleanupReservation( + replacementRpc, + cleanupAdoptionRecovery, + () => startNativeRpc(artifactRoot, nativeRpcPath, rpcEnvironment, roots.caveRoot), + ); + activeNativeStage = 'restart-status'; + const replacementStatus = await replacementRpc.ok('cave_credential_status', { + handle: replacementHandle, + operation: replacementRpc.operation(), + }); + if (replacementStatus.status !== 'valid') { + throw new Error('replacement RPC did not reuse the native credential'); + } + const previousRpc = rpc; + rpc = replacementRpc; + handle = replacementHandle; + activeNativeStage = 'restart-handoff-close'; + await previousRpc.close(); + activeNativeStage = 'restart-launch'; + await rpc.ok('cave_launch'); + activeNativeStage = 'restart-rediscovery'; + const discovery = await waitForDiscovery(rpc); + handle = discovery.handle; + activeNativeStage = 'restart-restarted-health'; + const restartedHealth = await rpc.ok('cave_health', { + handle, + operation: rpc.operation(), + }); + if (restartedHealth.data?.instanceId !== nativeCredentialInstanceId) { + throw new Error('Cave identity changed across native credential restart'); + } + activeNativeStage = 'restart-restarted-status'; + const status = await rpc.ok('cave_credential_status', { + handle, + operation: rpc.operation(), + }); + if (status.status !== 'valid') { + throw new Error('credential was not reused after native state restart'); + } + activeNativeStage = 'restart-result'; + addAssertion( + results, + 'phase1.credential.restart-reuse', + 'passed', + 'phase1.assertion.passed', + ); + } catch { + firstNativeAssertionFailureStage ??= activeNativeStage; + if (replacementRpc !== undefined && replacementRpc !== rpc) { + await replacementRpc.close().catch(() => undefined); + } + process.stderr.write('phase1-conformance: credential restart assertion failed.\n'); + addAssertion( + results, + 'phase1.credential.restart-reuse', + 'failed', + 'phase1.assertion.failed', + ); + } + } + + activeNativeStage = 'reads'; + if (typeof credentialId !== 'string') { + addAssertion( + results, + 'phase1.reads.bounded-canonical', + 'blocked', + 'phase1.integration.native-credential-unavailable', + ); + } else { + try { + const familiars = collection( + await rpc.ok('cave_list_familiars', { + handle, + page: { limit: 1 }, + operation: rpc.operation(), + }), + 'familiars', + ); + const projects = collection( + await rpc.ok('cave_list_projects', { + handle, + page: { limit: 2 }, + operation: rpc.operation(), + }), + 'projects', + ); + const conversations = collection( + await rpc.ok('cave_list_conversations', { + handle, + page: { limit: 2 }, + operation: rpc.operation(), + }), + 'conversations', + ); + const conversation = await rpc.ok('cave_get_conversation', { + handle, + conversationId: 'branched', + operation: rpc.operation(), + }); + const messages = collection( + await rpc.ok('cave_list_conversation_messages', { + handle, + conversationId: 'branched', + page: { limit: 1 }, + operation: rpc.operation(), + }), + 'messages', + ); + if ( + familiars.length !== 1 || + projects.length > 2 || + conversations.length > 2 || + conversation.data?.conversation?.id !== 'branched' || + messages.length !== 1 + ) { + throw new Error('bounded canonical reads returned unexpected shapes'); + } + addAssertion( + results, + 'phase1.reads.bounded-canonical', + 'passed', + 'phase1.assertion.passed', + ); + } catch { + firstNativeAssertionFailureStage ??= activeNativeStage; + process.stderr.write('phase1-conformance: bounded read assertion failed.\n'); + addAssertion( + results, + 'phase1.reads.bounded-canonical', + 'failed', + 'phase1.assertion.failed', + ); + } + } + + activeNativeStage = 'reconciliation'; + if (typeof credentialId !== 'string') { + addAssertion( + results, + 'phase1.reads.stale-generation-cursor-reconciliation', + 'blocked', + 'phase1.integration.native-credential-unavailable', + ); + } else { + try { + const firstPage = await rpc.ok('cave_list_conversation_messages', { + handle, + conversationId: 'branched', + page: { limit: 2 }, + operation: rpc.operation(), + }); + const cursor = firstPage.cursor?.next; + if (typeof cursor !== 'string') { + throw new Error('message read did not return a cursor'); + } + const conversationPath = resolve(caveHome, 'conversations', 'branched.json'); + const conversation = JSON.parse(readFileSync(conversationPath, 'utf8')); + conversation.activeLeafId = 'b-other'; + writeFileSync(conversationPath, `${JSON.stringify(conversation)}\n`, { mode: 0o600 }); + await rpc.error( + 'cave_list_conversation_messages', + { + handle, + conversationId: 'branched', + page: { limit: 1, cursor }, + operation: rpc.operation(), + }, + 'reconcile_required', + ); + const staleHandle = handle; + await rpc.ok('cave_reset_pairing', { handle }); + const discovery = await waitForDiscovery(rpc); + handle = discovery.handle; + await rpc.error( + 'cave_health', + { handle: staleHandle, operation: rpc.operation() }, + 'invalid_discovery_handle', + ); + await rpc.ok('cave_health', { handle, operation: rpc.operation() }); + addAssertion( + results, + 'phase1.reads.stale-generation-cursor-reconciliation', + 'passed', + 'phase1.assertion.passed', + ); + } catch { + firstNativeAssertionFailureStage ??= activeNativeStage; + process.stderr.write('phase1-conformance: reconciliation assertion failed.\n'); + addAssertion( + results, + 'phase1.reads.stale-generation-cursor-reconciliation', + 'failed', + 'phase1.assertion.failed', + ); + } + } + + activeNativeStage = 'revocation'; + if (typeof credentialId !== 'string') { + addAssertion( + results, + 'phase1.credential.revocation-repair', + 'blocked', + 'phase1.integration.native-credential-unavailable', + ); + } else { + try { + activeNativeStage = 'revocation-delete'; + await adminMutation( + origin, + adminToken, + 'DELETE', + `/admin/credentials/${credentialId}`, + { + reason: 'phase1-conformance', + }, + ); + activeNativeStage = 'revocation-initial-status'; + const initialStatus = await rpc.ok('cave_credential_status', { + handle, + operation: rpc.operation(), + }); + if ( + initialStatus.status !== 'disconnected' || + initialStatus.reason !== 'reconcile_required' + ) { + throw new Error('native credential did not request revocation reconciliation'); + } + await new Promise((resolveWait) => + setTimeout(resolveWait, revocationConfirmationDelayMs), + ); + activeNativeStage = 'revocation-rediscovery'; + const rediscovery = await waitForDiscovery(rpc); + handle = rediscovery.handle; + activeNativeStage = 'revocation-health'; + await rpc.ok('cave_health', { handle, operation: rpc.operation() }); + activeNativeStage = 'revocation-status'; + const status = await rpc.ok('cave_credential_status', { + handle, + operation: rpc.operation(), + }); + if (!['revoked', 'missing'].includes(status.status)) { + throw new Error('native credential did not converge to revoked'); + } + const repaired = await pairNative( + rpc, + handle, + origin, + adminToken, + 'phase1-installation-repaired', + adminMutation, + (stage) => { + activeNativeStage = `revocation-repair-${stage}`; + }, + ); + activeNativeStage = 'revocation-result'; + if (typeof repaired.credentialId !== 'string') { + throw new Error('native re-pairing did not issue a credential'); + } + credentialId = repaired.credentialId; + addAssertion( + results, + 'phase1.credential.revocation-repair', + 'passed', + 'phase1.assertion.passed', + ); + } catch { + firstNativeAssertionFailureStage ??= activeNativeStage; + process.stderr.write('phase1-conformance: credential revocation assertion failed.\n'); + addAssertion( + results, + 'phase1.credential.revocation-repair', + 'failed', + 'phase1.assertion.failed', + ); + } + } + + if (firstNativeAssertionFailureStage !== undefined) { + activeNativeStage = firstNativeAssertionFailureStage; + throw new Error('Native assertion failed before final credential cleanup.'); + } + + activeNativeStage = 'credential-cleanup'; + if (typeof credentialId === 'string' && typeof handle === 'string') { + activeNativeStage = 'credential-cleanup-discovery'; + const cleanupDiscovery = await waitForDiscovery(rpc); + handle = cleanupDiscovery.handle; + activeNativeStage = 'credential-cleanup-health'; + const cleanupHealth = await rpc.ok('cave_health', { handle, - page: { limit: 2 }, operation: rpc.operation(), - }), - 'conversations', - ); - const conversation = await rpc.ok('cave_get_conversation', { - handle, - conversationId: 'branched', - operation: rpc.operation(), - }); - const messages = collection( - await rpc.ok('cave_list_conversation_messages', { + }); + activeNativeStage = 'credential-cleanup-identity'; + if (cleanupHealth.data?.instanceId !== nativeCredentialInstanceId) { + throw new Error('Cave identity changed before native credential cleanup'); + } + finalNativeCredentialInstanceId = cleanupHealth.data.instanceId; + activeNativeStage = 'credential-cleanup-forget'; + await rpc.ok('cave_forget_credential', { handle, - conversationId: 'branched', - page: { limit: 1 }, operation: rpc.operation(), - }), - 'messages', - ); - if ( - familiars.length !== 1 || - projects.length > 2 || - conversations.length > 2 || - conversation.data?.conversation?.id !== 'branched' || - messages.length !== 1 - ) { - throw new Error('bounded canonical reads returned unexpected shapes'); - } - addAssertion( - results, - 'phase1.reads.bounded-canonical', - 'passed', - 'phase1.assertion.passed', - ); - } catch (error) { - process.stderr.write( - `phase1-conformance: phase1.reads.bounded-canonical failed: ${error instanceof Error ? error.message : 'unknown'}\n`, - ); - addAssertion( - results, - 'phase1.reads.bounded-canonical', - 'failed', - 'phase1.assertion.failed', - ); - } - } - - if (typeof credentialId !== 'string') { - addAssertion( - results, - 'phase1.reads.stale-generation-cursor-reconciliation', - 'blocked', - 'phase1.integration.native-credential-unavailable', - ); - } else { - try { - const firstPage = await rpc.ok('cave_list_conversation_messages', { - handle, - conversationId: 'branched', - page: { limit: 2 }, - operation: rpc.operation(), - }); - const cursor = firstPage.cursor?.next; - if (typeof cursor !== 'string') { - throw new Error('message read did not return a cursor'); - } - const conversationPath = resolve(caveHome, 'conversations', 'branched.json'); - const conversation = JSON.parse(readFileSync(conversationPath, 'utf8')); - conversation.activeLeafId = 'b-other'; - writeFileSync(conversationPath, `${JSON.stringify(conversation)}\n`, { mode: 0o600 }); - await rpc.error( - 'cave_list_conversation_messages', - { + }); + activeNativeStage = 'credential-cleanup-status'; + const status = await rpc.ok('cave_credential_status', { handle, - conversationId: 'branched', - page: { limit: 1, cursor }, operation: rpc.operation(), - }, - 'reconcile_required', - ); - const staleHandle = handle; - await rpc.ok('cave_reset_pairing', { handle }); - const discovery = await waitForDiscovery(rpc); - handle = discovery.handle; + }); + activeNativeStage = 'credential-cleanup-result'; + if (status.status !== 'missing') { + throw new Error('native credential cleanup did not converge to missing'); + } + credentialMayExist = false; + nativeCredentialStoreAfter = createHash('sha256') + .update( + JSON.stringify({ + service: 'ai.opencoven.chat', + account: `cave-client-v1:${finalNativeCredentialInstanceId}`, + status: status.status, + }), + ) + .digest('hex'); + } + + activeNativeStage = 'stale-discovery'; + const discoveryPath = resolve(caveHome, 'client-v1-discovery.json'); + const discovery = JSON.parse(readFileSync(discoveryPath, 'utf8')); + discovery.endpoint = 'http://127.0.0.1:1'; + writeFileSync(discoveryPath, `${JSON.stringify(discovery)}\n`, { mode: 0o600 }); await rpc.error( 'cave_health', - { handle: staleHandle, operation: rpc.operation() }, - 'invalid_discovery_handle', - ); - await rpc.ok('cave_health', { handle, operation: rpc.operation() }); - addAssertion( - results, - 'phase1.reads.stale-generation-cursor-reconciliation', - 'passed', - 'phase1.assertion.passed', - ); - } catch (error) { - process.stderr.write( - `phase1-conformance: phase1.reads.stale-generation-cursor-reconciliation failed: ${error instanceof Error ? error.message : 'unknown'}\n`, - ); - addAssertion( - results, - 'phase1.reads.stale-generation-cursor-reconciliation', - 'failed', - 'phase1.assertion.failed', + { handle, operation: rpc.operation() }, + 'stale_discovery_handle', ); + }, + }); + } catch (error) { + scenarioFailure = error; + } + + let cleanupFailure; + let cleanupCompleted = cleanupAdoptionRecovery?.deleted === true; + const cleanupCandidates = cleanupAdoptionRecovery + ? [cleanupAdoptionRecovery.successor, cleanupAdoptionRecovery.predecessor] + : cleanupReservation === undefined + ? [] + : [cleanupReservation]; + for (const candidate of cleanupCandidates) { + if (cleanupCompleted) { + break; + } + try { + if (rpc !== undefined && rpc.child.exitCode === null && rpc.child.signalCode === null) { + const result = await rpc.ok('conformance_delete_native_credential', { + reservationHandle: candidate.reservationHandle, + capability: candidate.capability, + ownerToken: candidate.ownerToken, + }); + cleanupCompleted = result.status === 'missing'; } + } catch { + // The alternate retained owner token may be current. } - - if (typeof credentialId !== 'string') { - addAssertion( - results, - 'phase1.credential.revocation-repair', - 'blocked', - 'phase1.integration.native-credential-unavailable', - ); - } else { + if (!cleanupCompleted) { try { - await adminMutation(origin, adminToken, 'DELETE', `/admin/credentials/${credentialId}`, { - reason: 'phase1-conformance', - }); - const initialStatus = await rpc.ok('cave_credential_status', { - handle, - operation: rpc.operation(), - }); - if ( - initialStatus.status !== 'disconnected' || - initialStatus.reason !== 'reconcile_required' - ) { - throw new Error('native credential did not request revocation reconciliation'); - } - await new Promise((resolveWait) => setTimeout(resolveWait, revocationConfirmationDelayMs)); - const rediscovery = await waitForDiscovery(rpc); - handle = rediscovery.handle; - await rpc.ok('cave_health', { handle, operation: rpc.operation() }); - const status = await rpc.ok('cave_credential_status', { - handle, - operation: rpc.operation(), + await runEmergencyNativeCredentialCleanup({ + artifactRoot, + nativeRpcPath, + environment, + reservationHandle: candidate.reservationHandle, + capability: candidate.capability, + ownerToken: candidate.ownerToken, }); - if (!['revoked', 'missing'].includes(status.status)) { - throw new Error('native credential did not converge to revoked'); - } - const repaired = await pairNative( - rpc, - handle, - origin, - adminToken, - 'phase1-installation-repaired', - ); - if (typeof repaired.credentialId !== 'string') { - throw new Error('native re-pairing did not issue a credential'); - } - addAssertion( - results, - 'phase1.credential.revocation-repair', - 'passed', - 'phase1.assertion.passed', - ); - } catch (error) { - process.stderr.write( - `phase1-conformance: phase1.credential.revocation-repair failed: ${error instanceof Error ? error.message : 'unknown'}\n`, - ); - addAssertion( - results, - 'phase1.credential.revocation-repair', - 'failed', - 'phase1.assertion.failed', - ); + cleanupCompleted = true; + } catch { + // Continue to the alternate retained owner. } } - + } + if (cleanupCandidates.length > 0 && !cleanupCompleted) { + cleanupFailure = new Error('Independent native credential cleanup did not complete.'); + } + if (cleanupCompleted) { + credentialMayExist = false; + finalNativeCredentialInstanceId = nativeCredentialInstanceId; + nativeCredentialStoreAfter = createHash('sha256') + .update( + JSON.stringify({ + service: 'ai.opencoven.chat', + account: `cave-client-v1:${nativeCredentialInstanceId}`, + status: 'missing', + }), + ) + .digest('hex'); + } + let rpcCleanupFailure; + if (rpc !== undefined) { try { - const discoveryPath = resolve(caveHome, 'client-v1-discovery.json'); - const discovery = JSON.parse(readFileSync(discoveryPath, 'utf8')); - discovery.endpoint = 'http://127.0.0.1:1'; - writeFileSync(discoveryPath, `${JSON.stringify(discovery)}\n`, { mode: 0o600 }); - await rpc.error( - 'cave_health', - { handle, operation: rpc.operation() }, - 'stale_discovery_handle', - ); + await rpc.close(); } catch { - // The native trust half is real, but the locked RPC exposes only memory - // custody and therefore cannot exercise a missing OS keychain. + rpcCleanupFailure = new Error('Native RPC cleanup did not complete.'); } - } finally { - await withFixtureDaemon(fixtureDaemon, async () => { - if (rpc !== undefined) { - await rpc.close(); - } + } + let daemonCloseFailure; + try { + await fixtureDaemon.close(); + } catch { + daemonCloseFailure = new Error('Fixture daemon cleanup did not complete.'); + } + if (credentialMayExist && cleanupFailure === undefined) { + cleanupFailure = new Error('Native credential remained after failure cleanup.'); + } + try { + throwNativeScenarioFailures({ + scenarioFailure, + cleanupFailure, + rpcCleanupFailure, + daemonCloseFailure, }); + } catch (error) { + throw new Error( + scenarioFailure === undefined + ? 'phase1.native-scenarios.cleanup' + : `phase1.native-scenarios.${activeNativeStage}`, + { cause: error }, + ); + } + try { + await runNativeMissingKeychainTrustScenario(artifactRoot, nativeRpcPath, environment, results); + } catch (error) { + throw new Error( + publicPhase1FailureDiagnostic(error) ?? 'phase1.native-scenarios.missing-keychain', + { cause: error }, + ); + } + if ( + nativeCredentialStoreBefore === undefined || + nativeCredentialStoreAfter === undefined || + nativeCredentialInstanceId === undefined || + finalNativeCredentialInstanceId === undefined || + nativeCredentialAccount === undefined + ) { + throw new Error('phase1.native-scenarios.isolation-proof'); + } + return { + beforeSha256: nativeCredentialStoreBefore, + afterSha256: nativeCredentialStoreAfter, + accountSha256: createHash('sha256') + .update(JSON.stringify({ service: 'ai.opencoven.chat', account: nativeCredentialAccount })) + .digest('hex'), + ownershipVerified: + nativeCredentialInstanceId === finalNativeCredentialInstanceId && + nativeCredentialStoreBefore === nativeCredentialStoreAfter, + removedAfterRun: nativeCredentialStoreBefore === nativeCredentialStoreAfter, + }; +} + +export function resolveLockedCovenDaemonCommand( + artifactRoot, + lockedCovenCheckoutRoot, + expectedCovenRevision, + covenBinaryPath, +) { + if ( + artifactRoot === null || + typeof artifactRoot !== 'object' || + typeof artifactRoot.rootPath !== 'string' + ) { + throw new Error('Owned artifact root is required for Coven command resolution.'); + } + if (typeof lockedCovenCheckoutRoot !== 'string' || lockedCovenCheckoutRoot.length === 0) { + throw new Error('Locked Coven checkout root must be a non-empty path string.'); + } + if (!/^[0-9a-f]{40}$/u.test(expectedCovenRevision)) { + throw new Error('Locked Coven revision must be an exact commit SHA.'); + } + const expectedRoot = resolve(artifactRoot.rootPath, 'checkouts', 'coven'); + let actualStats; + try { + actualStats = lstatSync(lockedCovenCheckoutRoot); + } catch { + throw new Error('Locked Coven checkout root is unavailable.'); + } + if ( + actualStats.isSymbolicLink() || + !actualStats.isDirectory() || + !isAbsolute(lockedCovenCheckoutRoot) || + resolve(lockedCovenCheckoutRoot) !== expectedRoot || + realpathSync(lockedCovenCheckoutRoot) !== realpathSync(expectedRoot) + ) { + throw new Error('Coven command root is not the owned locked checkout.'); + } + const observedRevision = runSupervisedSync('git', ['rev-parse', 'HEAD'], { + cwd: lockedCovenCheckoutRoot, + encoding: 'utf8', + env: createGitEnvironment(), + }).trim(); + if (observedRevision !== expectedCovenRevision) { + throw new Error('Coven command root does not match the locked revision.'); + } + return Object.freeze({ + executable: resolveExecutableInvocation(covenBinaryPath, process.env).executable, + args: Object.freeze(['daemon', 'serve']), + cwd: lockedCovenCheckoutRoot, + }); +} + +async function expectCovenHealthFailure( + artifactRoot, + nativeRpcPath, + environment, + covenHome, + expectedCode, +) { + const rpc = await startNativeRpc( + artifactRoot, + nativeRpcPath, + { ...environment, COVEN_HOME: covenHome }, + projectRoot, + ); + try { + const error = await rpc.error('coven_health', { operation: rpc.operation() }, expectedCode); + if ( + Object.keys(error).sort().join(',') !== 'code,retryable' || + typeof error.retryable !== 'boolean' + ) { + throw new Error('Chat Coven adapter returned an unsafe diagnostic envelope.'); + } + } finally { + await rpc.close(); } - await runNativeMissingKeychainTrustScenario(artifactRoot, nativeRpcPath, environment, results); } -async function runCovenIdentityScenario(artifactRoot, covenBinaryPath, environment, results) { +/** + * Run the production Chat Coven identity proof from the exact owned checkout. + * + * @param {{ + * artifactRoot: {rootPath: string, trackChild(child: import('node:child_process').ChildProcess): void}, + * lockedCovenCheckoutRoot: string, + * expectedCovenRevision: string, + * nativeRpcPath: string, + * covenBinaryPath: string, + * environment: NodeJS.ProcessEnv, + * results: Map + * }} options + */ +async function runCovenIdentityScenario({ + artifactRoot, + lockedCovenCheckoutRoot, + expectedCovenRevision, + nativeRpcPath, + covenBinaryPath, + environment, + results, +}) { + const covenCommand = resolveLockedCovenDaemonCommand( + artifactRoot, + lockedCovenCheckoutRoot, + expectedCovenRevision, + covenBinaryPath, + ); const covenRoot = createProcessOwnedArtifactRoot({ prefix: 'p1cv', shortPath: true }); - return withOwnedArtifactRoot(covenRoot, async () => { - const covenHome = resolve(covenRoot.rootPath, 'cv'); + const covenHome = resolve(covenRoot.rootPath, 'cv'); + let ownershipVerified = false; + const verifiedSdkAssertions = [ + 'sdk.coven.discovery.owner-local', + 'sdk.coven.health', + 'sdk.coven.structured-errors', + ]; + const verifiedChatAssertions = [ + 'chat.coven.discovery.owner-local', + 'chat.coven.executable.trusted', + 'chat.coven.health', + 'chat.coven.structured-errors-preserved', + ]; + await withOwnedArtifactRoot(covenRoot, async () => { mkdirSync(covenHome, { recursive: true, mode: 0o700 }); - const child = spawn(covenBinaryPath, ['daemon', 'serve'], { - env: { ...environment, COVEN_HOME: covenHome }, - detached: ownedProcessGroupsSupported, - stdio: ['ignore', 'ignore', 'ignore'], - }); - await once(child, 'spawn'); - covenRoot.trackChild(child, { processGroup: ownedProcessGroupsSupported }); + ownershipVerified = verifyOwnedDirectory(covenHome); + let rpc; + let activeCovenIdentityStage = 'rpc-start'; try { - let running = false; - for (let attempt = 0; attempt < 80; attempt += 1) { + activeCovenIdentityStage = 'rpc-start'; + rpc = await startNativeRpc( + artifactRoot, + nativeRpcPath, + { ...environment, COVEN_HOME: covenHome }, + projectRoot, + ); + activeCovenIdentityStage = 'unavailable-health'; + await rpc.error('coven_health', { operation: rpc.operation() }, 'service_unavailable'); + activeCovenIdentityStage = 'daemon-spawn'; + const { child } = spawnOwnedProcess(covenCommand.executable, covenCommand.args, { + cwd: covenCommand.cwd, + env: { ...environment, COVEN_HOME: covenHome }, + stdio: ['ignore', 'ignore', 'ignore'], + }); + await once(child, 'spawn'); + covenRoot.trackChild(child); + + activeCovenIdentityStage = 'daemon-ready'; + let health; + for (let attempt = 0; attempt < 80 && health === undefined; attempt += 1) { try { - const status = await runCommand( - artifactRoot, - 'Coven daemon authenticated status', - covenBinaryPath, - ['daemon', 'status', '--json'], - { - env: { ...environment, COVEN_HOME: covenHome }, - timeoutMs: 5_000, - }, - ); - const parsed = JSON.parse(status.stdout); - if (parsed.status === 'running' && parsed.ok === true) { - running = true; - break; - } - } catch (error) { - process.stderr.write( - `phase1-conformance: phase1.coven.same-user-identity failed: ${error instanceof Error ? error.message : 'unknown'}\n`, - ); - // The foreground server may not have published its socket yet. + health = await rpc.ok('coven_health', { operation: rpc.operation() }); + } catch { + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); } - await new Promise((resolveWait) => setTimeout(resolveWait, 100)); } - if (!running) { - throw new Error('Coven daemon did not authenticate its same-user transport'); + if (health?.status !== 'ok') { + throw new Error('Chat Coven adapter did not authenticate the daemon.'); } - await triggerAndWaitForChildClose(child, () => - runCommand( + + if (process.platform !== 'win32') { + activeCovenIdentityStage = 'malicious-home'; + const maliciousHome = resolve(covenRoot.rootPath, 'malicious-home'); + symlinkSync(covenHome, maliciousHome, 'dir'); + await expectCovenHealthFailure( artifactRoot, - 'Coven daemon authenticated stop', - covenBinaryPath, - ['daemon', 'stop'], - { - env: { ...environment, COVEN_HOME: covenHome }, - timeoutMs: 10_000, - }, - ), - ); - addAssertion(results, 'phase1.coven.same-user-identity', 'passed', 'phase1.assertion.passed'); - } catch (error) { - process.stderr.write( - `phase1-conformance: phase1.coven.same-user-identity failed: ${error instanceof Error ? error.message : 'unknown'}\n`, - ); - addAssertion( - results, - 'phase1.coven.same-user-identity', - 'failed', - 'phase1.integration.coven-identity-failed', - ); + nativeRpcPath, + environment, + maliciousHome, + 'service_unavailable', + ); + + activeCovenIdentityStage = 'wrong-mode-home'; + const wrongModeHome = resolve(covenRoot.rootPath, 'wrong-mode-home'); + mkdirSync(wrongModeHome, { mode: 0o700 }); + chmodSync(wrongModeHome, 0o755); + await expectCovenHealthFailure( + artifactRoot, + nativeRpcPath, + environment, + wrongModeHome, + 'service_unavailable', + ); + + activeCovenIdentityStage = 'symlink-socket-home'; + const symlinkSocketHome = resolve(covenRoot.rootPath, 'symlink-socket-home'); + mkdirSync(symlinkSocketHome, { mode: 0o700 }); + symlinkSync(resolve(covenHome, 'coven.sock'), resolve(symlinkSocketHome, 'coven.sock')); + await expectCovenHealthFailure( + artifactRoot, + nativeRpcPath, + environment, + symlinkSocketHome, + 'service_unavailable', + ); + + activeCovenIdentityStage = 'socket-mode'; + const socketPath = resolve(covenHome, 'coven.sock'); + chmodSync(socketPath, 0o666); + try { + await rpc.error('coven_health', { operation: rpc.operation() }, 'service_unavailable'); + } finally { + chmodSync(socketPath, 0o600); + } + verifiedChatAssertions.push( + 'chat.coven.unix.connected-peer-identity', + 'chat.coven.unix.malicious-home-refused', + 'chat.coven.unix.symlink-socket-refused', + 'chat.coven.unix.wrong-mode-refused', + ); + } else { + verifiedChatAssertions.push( + 'chat.coven.windows.pipe-owner', + 'chat.coven.windows.connected-pipe-identity', + ); + } + + activeCovenIdentityStage = 'result'; + addAssertion(results, 'phase1.coven.same-user-identity', 'passed', 'phase1.assertion.passed'); + } catch { + const diagnosticId = covenIdentityFailureDiagnostic(activeCovenIdentityStage); + process.stderr.write(`phase1-conformance: ${diagnosticId}\n`); + addAssertion(results, 'phase1.coven.same-user-identity', 'failed', diagnosticId); + } finally { + if (rpc !== undefined) { + await rpc.close(); + } } }); + return { + id: 'coven-home', + ownershipVerified, + removedAfterRun: !existsSync(covenRoot.rootPath) && !existsSync(covenHome), + verifiedSdkAssertions, + verifiedChatAssertions, + }; +} + +export function covenIdentityFailureDiagnostic(stage) { + return covenIdentityFailureStages.has(stage) + ? `phase1.coven-identity.${stage}` + : 'phase1.coven-identity.unknown'; } function recordCaveBackedAssertions(results, caveAssertions) { @@ -2112,151 +3993,768 @@ export function wrapInfrastructureFailure(error, report) { return new CommandExecutionError('Phase 1 conformance infrastructure', { report }); } -function fillMissingAssertions(results, status, diagnosticId) { - for (const id of REQUIRED_PHASE1_ASSERTION_IDS) { - if (!results.has(id)) { - addAssertion(results, id, status, diagnosticId); +function readLockedDigestFile(rootPath, metadata, label) { + const path = resolve(rootPath, metadata.path); + const stats = lstatSync(path); + if (stats.isSymbolicLink() || !stats.isFile() || sha256File(path) !== metadata.sha256) { + throw new Error(`${label} does not match the immutable conformance lock.`); + } + return path; +} + +async function loadEvidenceAuthorities(roots, lock) { + const registryPath = readLockedDigestFile( + roots.sdkEvidenceRoot, + lock.evidence.assertionRegistry, + 'SDK assertion registry', + ); + readLockedDigestFile(roots.sdkEvidenceRoot, lock.evidence.schema, 'SDK evidence schema'); + const contractPath = readLockedDigestFile( + roots.sdkEvidenceRoot, + lock.evidence.contract, + 'SDK evidence contract', + ); + const registryText = readFileSync(registryPath, 'utf8'); + const registry = parseLockedAssertionRegistry( + registryText, + lock.evidence.assertionRegistry.sha256, + 'SDK assertion registry', + ); + const contract = await import( + `${pathToFileURL(contractPath).href}?sha256=${lock.evidence.contract.sha256}` + ); + const parsedBySdk = contract.parseAssertionRegistry(registryText, 'Chat locked SDK registry'); + if (JSON.stringify(parsedBySdk) !== JSON.stringify(registry)) { + throw new Error('Chat registry parser disagrees with the locked SDK contract.'); + } + return { registry, parsePlatformEvidence: contract.parsePlatformEvidence }; +} + +const covenAuthorityControlResources = Object.freeze([ + { name: 'daemon.json', maxBytes: 64 * 1024 }, + { name: 'daemon.lock', maxBytes: 64 * 1024 }, + { name: 'daemon-serve.lock', maxBytes: 64 * 1024 }, + { name: 'state.lock', maxBytes: 64 * 1024 }, + { name: 'reset-transaction.json', maxBytes: 64 * 1024 }, + { name: 'coven.sock', maxBytes: 0, allowSocket: true }, +]); +const caveAuthorityControlResources = Object.freeze([ + { name: 'projects.json', maxBytes: 8 * 1024 * 1024 }, +]); +const operatorTopLevelEntryLimit = 4_096; + +function fingerprintStat(stats, includeDirectoryMutationMetadata = true) { + const type = stats.isDirectory() + ? 'directory' + : stats.isFile() + ? 'file' + : stats.isSymbolicLink() + ? 'symlink' + : stats.isSocket() + ? 'socket' + : 'other'; + const identity = [type, stats.mode & 0o7777n, stats.dev, stats.ino]; + if (!stats.isDirectory() || includeDirectoryMutationMetadata) { + identity.push(stats.size, stats.mtimeNs, stats.ctimeNs, stats.birthtimeNs); + } + return identity.join('\0'); +} + +function lstatForOperatorFingerprint(path) { + try { + return lstatSync(path, { bigint: true }); + } catch { + throw new Error('phase1.operator-fingerprint.failed'); + } +} + +function tryLstatForOperatorFingerprint(path) { + try { + return lstatSync(path, { bigint: true }); + } catch (error) { + if (error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') { + return undefined; } + throw new Error('phase1.operator-fingerprint.failed'); } } -export async function runPhase1Conformance(options = parseArgs([])) { - const lock = readPhase1ConformanceLock(options.lockPath); - const executionRoot = createProcessOwnedArtifactRoot({ prefix: 'phase1-conformance-run' }); - const reportRoot = createProcessOwnedArtifactRoot({ prefix: 'phase1-conformance-report' }); - const environment = safeEnvironment(executionRoot.rootPath); - const results = new Map(); - let artifactDigests = {}; - let infrastructureFailure; +function updateAuthorityControlFingerprint(digest, root, resource) { + const path = resolve(root, resource.name); + digest.update(`control\0${resource.name}\0`); + const before = tryLstatForOperatorFingerprint(path); + if (before === undefined) { + digest.update('missing\0'); + return; + } + if (before.isSymbolicLink()) { + throw new Error('phase1.operator-fingerprint.unsafe-control-resource'); + } + digest.update(`${fingerprintStat(before)}\0`); + if (before.isSocket() && resource.allowSocket === true) { + return; + } + if (!before.isFile()) { + throw new Error('phase1.operator-fingerprint.unsafe-control-resource'); + } + if (before.size > BigInt(resource.maxBytes)) { + throw new Error('phase1.operator-fingerprint.control-file-limit'); + } + let bytes; + try { + bytes = readFileSync(path); + } catch { + throw new Error('phase1.operator-fingerprint.failed'); + } + const after = lstatForOperatorFingerprint(path); + if (fingerprintStat(before) !== fingerprintStat(after)) { + throw new Error('phase1.operator-fingerprint.changed-during-read'); + } + digest.update(bytes); + digest.update('\0'); +} +function fingerprintShallowAuthorityState( + root, + controlResources, + includeDirectoryEntryMutationMetadata = true, +) { + const digest = createHash('sha256'); + const rootStats = tryLstatForOperatorFingerprint(root); + if (rootStats === undefined) { + digest.update('missing'); + return digest.digest('hex'); + } + + if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) { + throw new Error('phase1.operator-fingerprint.unsafe-root'); + } + digest.update(`root\0${fingerprintStat(rootStats)}\0`); + + let entries; try { - const roots = await createExactCheckouts(executionRoot, options, lock, environment); - const packaged = await packageLockedArtifacts(executionRoot, roots, environment); - artifactDigests = packaged.artifactDigests; + entries = readdirSync(root).sort(); + } catch { + throw new Error('phase1.operator-fingerprint.failed'); + } + if (entries.length > operatorTopLevelEntryLimit) { + throw new Error('phase1.operator-fingerprint.entry-limit'); + } + for (const entry of entries) { + const stats = lstatForOperatorFingerprint(resolve(root, entry)); + digest.update( + `entry\0${entry}\0${fingerprintStat(stats, includeDirectoryEntryMutationMetadata)}\0`, + ); + } - try { - const caveAssertions = await runCaveAuthorityMatrix( - executionRoot, - roots.caveRoot, - environment, + for (const resource of controlResources) { + updateAuthorityControlFingerprint(digest, root, resource); + } + return digest.digest('hex'); +} + +function fingerprintSingleAuthorityControl(root, resource) { + const digest = createHash('sha256'); + updateAuthorityControlFingerprint(digest, root, resource); + return digest.digest('hex'); +} + +export function snapshotOperatorState(operatorHome = process.env.HOME) { + if (typeof operatorHome !== 'string' || operatorHome.length === 0) { + throw new Error('Operator HOME is unavailable for isolation proof.'); + } + const caveHome = resolve(operatorHome, '.coven', 'cave'); + const covenHome = resolve(operatorHome, '.coven'); + return { + 'cave-home': fingerprintShallowAuthorityState(caveHome, caveAuthorityControlResources, false), + 'coven-home': fingerprintShallowAuthorityState(covenHome, covenAuthorityControlResources), + projects: fingerprintSingleAuthorityControl(caveHome, caveAuthorityControlResources[0]), + }; +} + +function assertOperatorStateUnchanged(before, after) { + for (const id of ['cave-home', 'coven-home', 'projects']) { + if (before[id] !== after[id]) { + throw new Error(`Operator resource ${id} changed during conformance.`); + } + } +} + +function verifyOwnedDirectory(path) { + const stats = lstatSync(path); + return ( + stats.isDirectory() && + !stats.isSymbolicLink() && + (stats.mode & 0o077) === 0 && + (typeof process.getuid !== 'function' || stats.uid === process.getuid()) + ); +} + +function assertNativeCredentialProviderIsolated() { + if (process.platform !== 'darwin') { + return; + } + const keychainPath = process.env.PHASE1_TEST_KEYCHAIN; + if (typeof keychainPath !== 'string' || keychainPath.length === 0) { + throw new Error('Isolated macOS keychain path is required.'); + } + const activeKeychain = runSupervisedSync('security', ['default-keychain', '-d', 'user'], { + encoding: 'utf8', + }) + .trim() + .replace(/^"|"$/gu, ''); + const stats = lstatSync(keychainPath); + if ( + !stats.isFile() || + stats.isSymbolicLink() || + realpathSync(activeKeychain) !== realpathSync(keychainPath) + ) { + throw new Error('Configured macOS keychain is not the isolated provider.'); + } +} + +function recordVerifiedIds(recorder, scope, ids, diagnosticId) { + for (const id of ids) { + recorder.pass(scope, id, diagnosticId); + } +} + +function requireRustTestProof(passedTests, testName, assertionId) { + if ( + ![...passedTests].some( + (observed) => observed === testName || observed.endsWith(`::${testName}`), + ) + ) { + throw new Error(`Rust proof for ${assertionId} did not pass.`); + } + return assertionId; +} + +function platformCovenTestProofs(platform, passedTests) { + const executableTrustProofs = [ + 'native_coven_health_uses_a_fixed_null_stdio_self_process_boundary', + 'child_probe_panic_is_redacted_and_bounded', + ]; + if (platform === 'win32-x64') { + const mappings = [ + [ + 'chat.coven.windows.malicious-home-refused', + 'recorded_daemon_status_rejects_a_stable_pipe_for_another_profile', + ], + [ + 'chat.coven.windows.constructed-pipe-refused', + 'recorded_windows_pipe_candidates_accept_only_coven_stable_or_legacy_shapes', + ], + [ + 'chat.coven.windows.foreign-pipe-refused', + 'inherited_legacy_status_rejects_cross_profile_and_arbitrary_redirection_before_connecting', + ], + [ + 'chat.coven.windows.ownership-provider-failure-refused', + 'windows_ownership_provider_failure_maps_to_fail_closed_diagnostic', + ], + [ + 'chat.coven.windows.reparse-endpoint-refused', + 'legacy_v1_case_check_rejects_sensitive_or_unverifiable_ancestors', + ], + ]; + const ids = mappings.map(([id, testName]) => requireRustTestProof(passedTests, testName, id)); + for (const testName of executableTrustProofs) { + requireRustTestProof( + passedTests, + testName, + 'chat.coven.windows.executable-trust-failure-refused', ); - recordCaveBackedAssertions(results, caveAssertions); + } + ids.push('chat.coven.windows.executable-trust-failure-refused'); + return ids; + } + + const mappings = [ + [ + 'chat.coven.unix.replaced-socket-refused', + 'a_mutation_is_not_sent_to_a_replacement_before_that_peer_is_negotiated', + ], + [ + 'chat.coven.unix.wrong-owner-refused', + 'wrong_owner_discovery_failure_maps_to_fail_closed_diagnostic', + ], + [ + 'chat.coven.unix.wrong-peer-uid-refused', + 'connected_peer_uid_must_match_discovered_and_current_owner', + ], + [ + 'chat.coven.unix.peer-provider-failure-refused', + 'unix_peer_provider_failure_maps_to_fail_closed_diagnostic', + ], + ]; + const ids = mappings.map(([id, testName]) => requireRustTestProof(passedTests, testName, id)); + for (const testName of executableTrustProofs) { + requireRustTestProof(passedTests, testName, 'chat.coven.unix.executable-trust-failure-refused'); + } + ids.push('chat.coven.unix.executable-trust-failure-refused'); + return ids; +} + +export function runtimeScenarioFailureDiagnostic(results) { + for (const [id, diagnosticId] of runtimeScenarioDiagnosticIds) { + const assertion = results.get(id); + if (assertion?.status !== 'passed') { + const recordedDiagnostic = assertion?.diagnosticIds?.[0]; + if ( + id === 'phase1.coven.same-user-identity' && + covenIdentityDiagnosticIds.has(recordedDiagnostic) + ) { + return recordedDiagnostic; + } + return diagnosticId; + } + } + return undefined; +} + +export function evidenceValidationFailureDiagnostic(error) { + const message = + error !== null && + typeof error === 'object' && + 'message' in error && + typeof error.message === 'string' + ? error.message + : ''; + if ( + message.includes('evidence limit') || + /^Evidence exceeds the \d+-(?:node|level depth) limit$/u.test(message) + ) { + return 'phase1.stage.evidence-validation.size'; + } + if (message.includes('is not valid JSON')) { + return 'phase1.stage.evidence-validation.json'; + } + if (message.includes('duplicate JSON object key')) { + return 'phase1.stage.evidence-validation.duplicate-key'; + } + if (message.includes('contains a possible secret')) { + return 'phase1.stage.evidence-validation.possible-secret'; + } + if (message.includes('contains a private filesystem path')) { + return 'phase1.stage.evidence-validation.private-path'; + } + if (message.includes('forbidden evidence field')) { + return 'phase1.stage.evidence-validation.forbidden-field'; + } + if (message.includes('contains a non-JSON value')) { + return 'phase1.stage.evidence-validation.non-json'; + } + if (message.startsWith('Chat Phase 1 platform evidence')) { + return 'phase1.stage.evidence-validation.shape'; + } + return 'phase1.stage.evidence-validation.unknown'; +} + +function assertRuntimeScenariosPassed(results) { + const diagnosticId = runtimeScenarioFailureDiagnostic(results); + if (diagnosticId !== undefined) { + throw new Error(diagnosticId); + } +} + +export function finalizeOperatorSafety({ + primaryFailure, + cleanupFailure, + operatorStateBefore, + snapshotAfter = snapshotOperatorState, + compare = assertOperatorStateUnchanged, +}) { + let operatorStateAfter; + let isolationFailure; + if (operatorStateBefore !== undefined) { + try { + operatorStateAfter = snapshotAfter(); + compare(operatorStateBefore, operatorStateAfter); } catch (error) { - infrastructureFailure ??= recordCaveMatrixFailure(results, error); + isolationFailure = error; + } + } + const failures = [primaryFailure, cleanupFailure, isolationFailure].flatMap((failure) => + failure instanceof AggregateError ? failure.errors : failure === undefined ? [] : [failure], + ); + if (failures.length > 0) { + throw new AggregateError(failures, 'Phase 1 conformance failed with safety diagnostics.'); + } + return operatorStateAfter; +} + +export async function runPhase1Conformance(options = parseArgs([])) { + assertNoNodeRuntimeInjection(); + const lock = runPublicPhase1Stage('phase1.stage.lock.failed', () => + bootstrapWindowsSupervisor(options), + ); + runPublicPhase1Stage('phase1.stage.harness-authority.failed', () => + assertExecutingHarnessAuthority(lock), + ); + runPublicPhase1Stage('phase1.stage.native-provider.failed', () => + assertNativeCredentialProviderIsolated(), + ); + const executionRoot = runPublicPhase1Stage('phase1.stage.execution-root.failed', () => + createProcessOwnedArtifactRoot({ prefix: 'p1run', shortPath: true }), + ); + let environment; + let results; + let toolVersions; + let operatorStateBefore; + let platform; + let artifactDigests; + let caveRecord; + let evidenceAuthorities; + let assertionRecorder; + let isolationRootObservations; + let nativeCredentialStoreState; + let primaryFailure; + let cleanupFailure; + let activeStage = 'phase1.stage.environment.failed'; + + try { + activeStage = 'phase1.stage.environment.failed'; + environment = runPublicPhase1Stage('phase1.stage.environment.failed', () => + safeEnvironment(executionRoot.rootPath), + ); + results = new Map(); + activeStage = 'phase1.stage.toolchain.failed'; + toolVersions = runPublicPhase1Stage('phase1.stage.toolchain.failed', () => + observeReleaseToolVersions(), + ); + operatorStateBefore = snapshotOperatorState(); + platform = canonicalPlatformId(); + activeStage = 'phase1.stage.checkouts.failed'; + const roots = await runPublicPhase1StageAsync('phase1.stage.checkouts.failed', () => + createExactCheckouts(executionRoot, options, lock, environment), + ); + activeStage = 'phase1.stage.evidence-authority.failed'; + evidenceAuthorities = await runPublicPhase1StageAsync( + 'phase1.stage.evidence-authority.failed', + () => loadEvidenceAuthorities(roots, lock), + ); + assertionRecorder = createAssertionRecorder(evidenceAuthorities.registry, platform); + activeStage = 'phase1.stage.packaging.failed'; + const packaged = await runPublicPhase1StageAsync('phase1.stage.packaging.failed', () => + packageLockedArtifacts(executionRoot, roots, environment, lock), + ); + activeStage = 'phase1.stage.packaging-proof.failed'; + artifactDigests = packaged.artifactDigests; + for (const id of packageSdkAssertionIds) { + assertionRecorder.pass( + 'sdk', + id, + id === 'sdk.install.packed-tarballs' + ? `phase1.sdk-candidate.${lock.sdk.revision}` + : id === 'sdk.provenance.fixture-bytes-match' + ? `phase1.sdk-manifest.${lock.release.sdkManifest.sha256}` + : undefined, + ); + } + for (const id of packageChatAssertionIds) { + assertionRecorder.pass( + 'chat', + id, + id === 'chat.install.consumer-lock-matches' + ? `phase1.chat-harness.${lock.harness.revision}` + : undefined, + ); + } + const deadlineProofs = [ + [ + 'sdk.deadline.connect-bounded', + platform === 'win32-x64' + ? 'one_absolute_deadline_threads_only_remaining_budget_to_each_phase' + : 'stalled_connect_receives_only_the_absolute_deadline_budget', + ], + [ + 'sdk.deadline.read-bounded', + platform === 'win32-x64' + ? 'live_empty_named_pipe_waits_for_a_delayed_response' + : 'lifecycle_probe_uses_one_absolute_deadline_for_a_stalled_response', + ], + [ + 'sdk.deadline.body-bounded', + platform === 'win32-x64' + ? 'oversized_response_envelope_fails_closed_before_body_read' + : 'oversized_request_bodies_are_rejected_before_any_connection_attempt', + ], + [ + 'sdk.deadline.frame-bounded', + platform === 'win32-x64' + ? 'response_reader_rejects_coalesced_bytes_beyond_content_length' + : 'rejects_bytes_buffered_after_an_ordinary_framed_response', + ], + [ + 'chat.deadline.total-bounded', + 'synthetic_elapsed_time_stays_within_one_budget_across_launch_phases', + ], + ]; + for (const [id, testName] of deadlineProofs) { + requireRustTestProof(packaged.passedRustTests, testName, id); + assertionRecorder.pass( + id.startsWith('sdk.') ? 'sdk' : 'chat', + id, + id === 'chat.deadline.total-bounded' + ? windowsSupervisorDiagnosticId({ + windowsSupervisorSha256: lock.tools.windowsSupervisor.artifact.sha256, + mingwPackageVersion: lock.tools.windowsSupervisor.toolchain.packageVersion, + mingwHomebrewCoreRevision: + lock.tools.windowsSupervisor.toolchain.homebrewCoreRevision, + mingwBottleLayerSha256: lock.tools.windowsSupervisor.toolchain.bottleLayerSha256, + mingwLinkerVersion: lock.tools.windowsSupervisor.toolchain.linkerVersion, + }) + : undefined, + ); } - await runNativeScenarios({ + activeStage = 'phase1.stage.cave-authority.failed'; + const cave = await runCaveAuthorityMatrix(executionRoot, roots.caveRoot, environment); + caveRecord = cave.record; + recordCaveBackedAssertions(results, cave.assertions); + + activeStage = 'phase1.stage.native-scenarios.failed'; + const nativeStoreProofRoot = resolve(executionRoot.rootPath, 'native-credential-store'); + mkdirSync(nativeStoreProofRoot, { mode: 0o700 }); + nativeCredentialStoreState = await runNativeScenarios({ artifactRoot: executionRoot, roots, nativeRpcPath: packaged.nativeRpcPath, environment, results, }); - await runCovenIdentityScenario(executionRoot, packaged.covenBinaryPath, environment, results); - - await runCompatibilityScenarios({ + activeStage = 'phase1.stage.coven-identity.failed'; + const covenRootObservation = await runCovenIdentityScenario({ artifactRoot: executionRoot, - roots, + lockedCovenCheckoutRoot: roots.covenRoot, + expectedCovenRevision: lock.coven.revision, + nativeRpcPath: packaged.nativeRpcPath, + covenBinaryPath: packaged.covenBinaryPath, environment, results, }); + activeStage = 'phase1.stage.runtime-assertions.failed'; + assertRuntimeScenariosPassed(results); + recordVerifiedIds(assertionRecorder, 'sdk', caveSdkAssertionIds); + recordVerifiedIds(assertionRecorder, 'sdk', nativeSdkAssertionIds); + recordVerifiedIds(assertionRecorder, 'chat', caveChatAssertionIds); + recordVerifiedIds( + assertionRecorder, + 'chat', + nativeChatAssertionIds.filter((id) => id !== 'chat.native.keychain-unavailable-fails-closed'), + ); + assertionRecorder.pass( + 'chat', + 'chat.native.keychain-unavailable-fails-closed', + `phase1.toolchain.rust.${toolVersions.rustVersion}`, + ); + recordVerifiedIds(assertionRecorder, 'sdk', covenRootObservation.verifiedSdkAssertions); + recordVerifiedIds(assertionRecorder, 'chat', covenRootObservation.verifiedChatAssertions); + recordVerifiedIds( + assertionRecorder, + 'chat', + platformCovenTestProofs(platform, packaged.passedRustTests), + ); + activeStage = 'phase1.stage.isolation.failed'; + const observedPaths = [ + { + id: 'cave-home', + path: resolve(executionRoot.rootPath, 'native-authority-home', 'coven', 'cave'), + }, + { + id: 'consumer-home', + path: environment.HOME, + }, + { + id: 'native-credential-store', + path: nativeStoreProofRoot, + }, + ]; + isolationRootObservations = [ + { + id: 'cave-home', + path: observedPaths[0].path, + ownershipVerified: verifyOwnedDirectory(observedPaths[0].path), + }, + covenRootObservation, + { + id: 'consumer-home', + path: observedPaths[1].path, + ownershipVerified: verifyOwnedDirectory(observedPaths[1].path), + }, + { + id: 'native-credential-store', + path: observedPaths[2].path, + ownershipVerified: + nativeCredentialStoreState.ownershipVerified && + verifyOwnedDirectory(observedPaths[2].path), + removedAfterRun: nativeCredentialStoreState.removedAfterRun, + }, + ]; + const operatorIsolationValid = environment.HOME !== process.env.HOME && environment.XDG_CONFIG_HOME.startsWith(executionRoot.rootPath) && environment.TMPDIR.startsWith(executionRoot.rootPath) && environment.CARGO_HOME.startsWith(executionRoot.rootPath) && environment.RUSTUP_HOME === undefined; - addAssertion( - results, - 'phase1.operator.homes-credentials-untouched', - operatorIsolationValid ? 'passed' : 'failed', - operatorIsolationValid ? 'phase1.assertion.passed' : 'phase1.assertion.failed', - ); - } catch (error) { - infrastructureFailure = error; - fillMissingAssertions(results, 'failed', 'phase1.assertion.failed'); - } - - if (!results.has('phase1.native.missing-keychain-trust')) { - addAssertion( - results, - 'phase1.native.missing-keychain-trust', - 'blocked', - 'phase1.producer.native-trust-fixture-unavailable', - ); - } - if (!results.has('phase1.compat.api-major-min-client')) { - addAssertion( - results, - 'phase1.compat.api-major-min-client', - 'failed', - 'phase1.assertion.failed', - ); - } - fillMissingAssertions(results, 'blocked', 'phase1.assertion.blocked'); - - try { - await executionRoot.cleanup(); + if (!operatorIsolationValid) { + throw new Error('Conformance environment did not isolate operator resources.'); + } } catch (error) { - infrastructureFailure ??= error; - for (const [id, assertion] of results) { - if (assertion.status === 'passed') { - results.set(id, makeAssertion(id, 'failed', 'phase1.assertion.failed')); - } + primaryFailure = + publicPhase1FailureDiagnostic(error) === undefined + ? new Error(activeStage, { cause: error }) + : error; + } finally { + try { + await executionRoot.cleanup(); + } catch (error) { + cleanupFailure = new Error('phase1.stage.execution-root-cleanup.failed', { cause: error }); } } - const report = await withOwnedArtifactRoot(reportRoot, async () => { - const completedReport = buildPhase1Report({ - assertions: [...results.values()], - revisions: { - chat: lock.chat.revision, - sdk: lock.sdk.revision, + const { operatorStateAfter, isolationRoots } = runPublicPhase1Stage( + 'phase1.stage.isolation-proof.failed', + () => ({ + operatorStateAfter: finalizeOperatorSafety({ + primaryFailure, + cleanupFailure, + operatorStateBefore, + }), + isolationRoots: isolationRootObservations.map((root) => ({ + id: root.id, + ownershipVerified: root.ownershipVerified, + removedAfterRun: + typeof root.path === 'string' + ? !existsSync(root.path) && root.removedAfterRun !== false + : root.removedAfterRun === true, + })), + }), + ); + const assertionResults = runPublicPhase1Stage('phase1.stage.assertion-recording.failed', () => { + recordVerifiedIds(assertionRecorder, 'chat', evidenceChatAssertionIds); + assertionRecorder.pass( + 'chat', + platform === 'darwin-arm64' + ? 'chat.native.macos-keychain.isolated' + : platform === 'linux-x64' + ? 'chat.native.linux-keyring.isolated' + : 'chat.native.windows-credential-manager.isolated', + ); + return assertionRecorder.results(); + }); + const report = runPublicPhase1Stage('phase1.stage.evidence-build.failed', () => + buildPlatformEvidence({ + registry: evidenceAuthorities.registry, + platform, + caveRecord, + releases: { + cave: lock.release.caveVersion, + coven: lock.release.covenVersion, + }, + commits: { cave: lock.cave.revision, coven: lock.coven.revision, + sdk: lock.evidence.revision, + chat: lock.chat.revision, }, - artifactDigests, - versions: { - harness: '1.0.0', - node: process.versions.node, + digests: { + caveAssertionEngine: lock.release.caveArtifacts.assertionEngine.sha256, + caveContractFixture: lock.release.caveArtifacts.contractFixture.sha256, + hpkeVectors: lock.release.caveArtifacts.hpkeVectors.sha256, + consumerLock: lock.release.consumerLock.sha256, + assertionRegistry: lock.evidence.assertionRegistry.sha256, + sdkTarballs: artifactDigests.sdkTarballs, }, + sdkAssertions: assertionResults.sdk, + chatAssertions: assertionResults.chat, + environment: { + nodeVersion: toolVersions.nodeVersion, + packageManagerVersion: toolVersions.packageManagerVersion, + }, + metadata: { + sdkCandidateRevision: lock.sdk.revision, + sdkManifestSha256: lock.release.sdkManifest.sha256, + chatHarnessRevision: lock.harness.revision, + windowsSupervisorSha256: lock.tools.windowsSupervisor.artifact.sha256, + mingwPackageVersion: lock.tools.windowsSupervisor.toolchain.packageVersion, + mingwHomebrewCoreRevision: lock.tools.windowsSupervisor.toolchain.homebrewCoreRevision, + mingwBottleLayerSha256: lock.tools.windowsSupervisor.toolchain.bottleLayerSha256, + mingwLinkerVersion: lock.tools.windowsSupervisor.toolchain.linkerVersion, + rustVersion: toolVersions.rustVersion, + }, + isolation: { + roots: isolationRoots, + operatorState: [ + { + id: 'cave-home', + beforeSha256: operatorStateBefore['cave-home'], + afterSha256: operatorStateAfter['cave-home'], + }, + { + id: 'coven-home', + beforeSha256: operatorStateBefore['coven-home'], + afterSha256: operatorStateAfter['coven-home'], + }, + { + id: 'native-credential-store', + beforeSha256: nativeCredentialStoreState.beforeSha256, + afterSha256: nativeCredentialStoreState.afterSha256, + }, + { + id: 'projects', + beforeSha256: operatorStateBefore.projects, + afterSha256: operatorStateAfter.projects, + }, + ], + }, + }), + ); + const serialized = `${JSON.stringify(report, null, 2)}\n`; + try { + evidenceAuthorities.parsePlatformEvidence(serialized, 'Chat Phase 1 platform evidence'); + } catch (error) { + throw new Error(evidenceValidationFailureDiagnostic(error), { cause: error }); + } + + return runPublicPhase1StageAsync('phase1.stage.evidence-retention.failed', () => { + const reportRoot = createProcessOwnedArtifactRoot({ prefix: 'phase1-conformance-report' }); + return withOwnedArtifactRoot(reportRoot, async () => { + const reportPath = resolve(reportRoot.rootPath, 'report.json'); + writeFileSync(reportPath, serialized, { mode: 0o600 }); + await reportRoot.retainSanitizedJsonReport({ + reportPath, + destinationPath: options.retainSanitizedReport, + secretScan: ({ artifactRoot }) => scanPhase1Artifacts({ artifactRoot }), + }); + return report; }); - const reportPath = resolve(reportRoot.rootPath, 'report.json'); - writeFileSync(reportPath, `${JSON.stringify(completedReport, null, 2)}\n`, { mode: 0o600 }); - await reportRoot.retainSanitizedJsonReport({ - reportPath, - destinationPath: options.retainSanitizedReport, - secretScan: ({ artifactRoot }) => scanPhase1Artifacts({ artifactRoot }), - }); - return completedReport; }); - - if (infrastructureFailure !== undefined) { - throw wrapInfrastructureFailure(infrastructureFailure, report); - } - return report; } async function main(argv = process.argv.slice(2)) { + assertNoNodeRuntimeInjection(); const options = parseArgs(argv); + if (process.env[verifiedRunnerEnvironment] !== '1') { + await runPublicPhase1StageAsync('phase1.stage.runner-bootstrap.failed', () => + bootstrapVerifiedRunner(options), + ); + return; + } const report = await runPhase1Conformance(options); process.stdout.write( - `phase1-conformance: ${report.status} (${report.summary.passed} passed, ${report.summary.failed} failed, ${report.summary.blocked} blocked)\n`, + `phase1-conformance: passed (${report.platform}, ${report.sdkAssertions.length} SDK assertions, ${report.chatAssertions.length} Chat assertions)\n`, ); - for (const assertion of report.assertions) { - if (assertion.status !== 'passed') { - process.stdout.write( - `${assertion.status.toUpperCase()} ${assertion.id} ${assertion.diagnosticIds.join(',')}\n`, - ); - } - } - process.exitCode = report.status === 'passed' ? 0 : 1; } if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { main().catch((error) => { + const publicDiagnostic = publicPhase1FailureDiagnostic(error); const message = - error instanceof CommandExecutionError ? error.message : 'Phase 1 conformance failed.'; + publicDiagnostic ?? + (error instanceof CommandExecutionError ? error.message : 'Phase 1 conformance failed.'); process.stderr.write(`phase1-conformance: ${message}\n`); process.exitCode = 1; }); diff --git a/scripts/phase1-evidence-contract.d.mts b/scripts/phase1-evidence-contract.d.mts new file mode 100644 index 0000000..1902e46 --- /dev/null +++ b/scripts/phase1-evidence-contract.d.mts @@ -0,0 +1,128 @@ +export type CrossAssertion = { + id: string; + result: 'pass' | 'fail'; + diagnosticId: string; +}; + +export type AssertionRegistry = { + schemaVersion: 1; + cave: { + engine: 'scripts/client-v1-conformance.mjs'; + requireIncludeTtl: true; + requireAuthorityTakeover: true; + }; + sdk: string[]; + chat: { + common: string[]; + platforms: Record<'darwin-arm64' | 'linux-x64' | 'win32-x64', string[]>; + }; +}; + +export function canonicalPlatformId(os?: string, arch?: string): string; +export function validateObservedToolVersions(options: { + nodeVersion: string; + pnpmVersion: string; + rustcVersion: string; +}): { + nodeVersion: 'v24.18.1'; + packageManagerVersion: 'pnpm@10.34.0'; + rustVersion: '1.95.0'; +}; +export function parseLockedAssertionRegistry( + text: string, + expectedDigest: string, + label: string, +): AssertionRegistry; +export function assertExactAssertionResults( + assertions: CrossAssertion[], + expectedIds: readonly string[], + label: string, +): CrossAssertion[]; +export function createAssertionRecorder( + registry: AssertionRegistry, + platform: 'darwin-arm64' | 'linux-x64' | 'win32-x64', +): { + pass(scope: 'sdk' | 'chat', id: string, diagnosticId?: string): void; + passMany(scope: 'sdk' | 'chat', ids: readonly string[], diagnosticId?: string): void; + results(): { sdk: CrossAssertion[]; chat: CrossAssertion[] }; +}; +export function validateMetadataAssertionBindings(options: { + sdkAssertions: CrossAssertion[]; + chatAssertions: CrossAssertion[]; + metadata: { + sdkCandidateRevision: string; + sdkManifestSha256: string; + chatHarnessRevision: string; + windowsSupervisorSha256: string; + mingwPackageVersion: string; + mingwHomebrewCoreRevision: string; + mingwBottleLayerSha256: string; + mingwLinkerVersion: string; + rustVersion: '1.95.0'; + }; +}): void; +export function buildPlatformEvidence(options: { + registry: AssertionRegistry; + platform: 'darwin-arm64' | 'linux-x64' | 'win32-x64'; + caveRecord: Record & { ranAt: string }; + releases: { cave: string; coven: string }; + commits: { cave: string; coven: string; sdk: string; chat: string }; + digests: { + caveAssertionEngine: string; + caveContractFixture: string; + hpkeVectors: string; + consumerLock: string; + assertionRegistry: string; + sdkTarballs: Array<{ packageName: string; sha256: string }>; + }; + sdkAssertions: CrossAssertion[]; + chatAssertions: CrossAssertion[]; + environment: { + nodeVersion: 'v24.18.1'; + packageManagerVersion: 'pnpm@10.34.0'; + }; + metadata: { + sdkCandidateRevision: string; + sdkManifestSha256: string; + chatHarnessRevision: string; + windowsSupervisorSha256: string; + mingwPackageVersion: string; + mingwHomebrewCoreRevision: string; + mingwBottleLayerSha256: string; + mingwLinkerVersion: string; + rustVersion: '1.95.0'; + }; + isolation: { + roots: Array<{ + id: string; + ownershipVerified: boolean; + removedAfterRun: boolean; + }>; + operatorState: Array<{ + id: string; + beforeSha256: string; + afterSha256: string; + }>; + }; +}): Record & { + platform: string; + commits: { sdk: string }; + coverage: Record; + environment: { + nodeVersion: 'v24.18.1'; + packageManagerVersion: 'pnpm@10.34.0'; + }; + notCovered: Array<{ scopeId: string; diagnosticId: string }>; + chatAssertions: CrossAssertion[]; + isolation: { + roots: Array<{ id: string }>; + operatorState: Array<{ beforeSha256: string; afterSha256: string }>; + }; +}; +export function windowsSupervisorDiagnosticId(metadata: { + windowsSupervisorSha256: string; + mingwPackageVersion: string; + mingwHomebrewCoreRevision: string; + mingwBottleLayerSha256: string; + mingwLinkerVersion: string; +}): string; diff --git a/scripts/phase1-evidence-contract.mjs b/scripts/phase1-evidence-contract.mjs new file mode 100644 index 0000000..52dc92c --- /dev/null +++ b/scripts/phase1-evidence-contract.mjs @@ -0,0 +1,445 @@ +import { createHash } from 'node:crypto'; + +const canonicalPlatforms = Object.freeze(['darwin-arm64', 'linux-x64', 'win32-x64']); +const canonicalTarballs = Object.freeze([ + '@opencoven/sdk-core', + '@opencoven/cave-client', + '@opencoven/coven-client', + '@opencoven/sdk', +]); +const isolationRoots = Object.freeze([ + 'cave-home', + 'coven-home', + 'consumer-home', + 'native-credential-store', +]); +const operatorStateIds = Object.freeze([ + 'cave-home', + 'coven-home', + 'native-credential-store', + 'projects', +]); +const notCovered = Object.freeze([ + { + scopeId: 'cross-process-pairing', + diagnosticId: 'phase1.scope.cross-process-pairing.not-covered', + }, + { scopeId: 'oauth-ui', diagnosticId: 'phase1.scope.oauth-ui.not-covered' }, + { scopeId: 'remote-peer', diagnosticId: 'phase1.scope.remote-peer.not-covered' }, + { scopeId: 'write-apis', diagnosticId: 'phase1.scope.write-apis.not-covered' }, +]); +const identifierPattern = /^[a-z0-9]+(?:[._/-][a-z0-9]+)*$/u; +const digestPattern = /^[0-9a-f]{64}$/u; + +function requireRecord(value, label) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object.`); + } + return value; +} + +function requireExactKeys(value, expectedKeys, label) { + const keys = Object.keys(value); + if ( + keys.length !== expectedKeys.length || + expectedKeys.some((key) => !Object.hasOwn(value, key)) + ) { + throw new Error(`${label} must contain exactly ${expectedKeys.join(', ')}.`); + } +} + +function validateIds(value, label) { + if (!Array.isArray(value)) { + throw new Error(`${label} must be an array.`); + } + const seen = new Set(); + return value.map((id) => { + if (typeof id !== 'string' || !identifierPattern.test(id)) { + throw new Error(`${label} contains an invalid assertion ID.`); + } + if (seen.has(id)) { + throw new Error(`${label} contains duplicate assertion ID ${id}.`); + } + seen.add(id); + return id; + }); +} + +export function canonicalPlatformId(os = process.platform, arch = process.arch) { + const platform = `${os}-${arch}`; + if (!canonicalPlatforms.includes(platform)) { + throw new Error(`unsupported platform ${platform}`); + } + return platform; +} + +export function validateObservedToolVersions({ nodeVersion, pnpmVersion, rustcVersion }) { + if (nodeVersion !== 'v24.18.1') { + throw new Error('Phase 1 conformance requires Node 24.18.1.'); + } + if (pnpmVersion !== '10.34.0') { + throw new Error('Phase 1 conformance requires pnpm 10.34.0.'); + } + const rustMatch = /^rustc (1\.95\.0)(?:\s|$)/u.exec(rustcVersion); + if (rustMatch === null) { + throw new Error('Phase 1 conformance requires Rust 1.95.0.'); + } + return Object.freeze({ + nodeVersion, + packageManagerVersion: `pnpm@${pnpmVersion}`, + rustVersion: rustMatch[1], + }); +} + +export function parseLockedAssertionRegistry(text, expectedDigest, label) { + const actualDigest = createHash('sha256').update(text).digest('hex'); + if (actualDigest !== expectedDigest) { + throw new Error(`${label} digest does not match the locked SDK registry.`); + } + let value; + try { + value = JSON.parse(text); + } catch { + throw new Error(`${label} must contain valid JSON.`); + } + const registry = requireRecord(value, label); + requireExactKeys(registry, ['schemaVersion', 'cave', 'sdk', 'chat'], label); + if (registry.schemaVersion !== 1) { + throw new Error(`${label} schemaVersion must be 1.`); + } + const cave = requireRecord(registry.cave, `${label} cave`); + requireExactKeys( + cave, + ['engine', 'requireIncludeTtl', 'requireAuthorityTakeover'], + `${label} cave`, + ); + if ( + cave.engine !== 'scripts/client-v1-conformance.mjs' || + cave.requireIncludeTtl !== true || + cave.requireAuthorityTakeover !== true + ) { + throw new Error(`${label} Cave authority configuration is invalid.`); + } + const chat = requireRecord(registry.chat, `${label} chat`); + requireExactKeys(chat, ['common', 'platforms'], `${label} chat`); + const platforms = requireRecord(chat.platforms, `${label} chat platforms`); + requireExactKeys(platforms, canonicalPlatforms, `${label} chat platforms`); + const common = validateIds(chat.common, `${label} chat common`); + const commonSet = new Set(common); + const parsedPlatforms = {}; + for (const platform of canonicalPlatforms) { + const ids = validateIds(platforms[platform], `${label} chat ${platform}`); + if (ids.some((id) => commonSet.has(id))) { + throw new Error(`${label} repeats a common Chat assertion.`); + } + parsedPlatforms[platform] = ids; + } + return { + schemaVersion: 1, + cave: { + engine: cave.engine, + requireIncludeTtl: true, + requireAuthorityTakeover: true, + }, + sdk: validateIds(registry.sdk, `${label} sdk`), + chat: { + common, + platforms: parsedPlatforms, + }, + }; +} + +export function assertExactAssertionResults(assertions, expectedIds, label) { + if (!Array.isArray(assertions)) { + throw new Error(`${label} assertions must be an array.`); + } + const expected = new Set(expectedIds); + const seen = new Set(); + for (const assertion of assertions) { + const entry = requireRecord(assertion, `${label} assertion`); + requireExactKeys(entry, ['id', 'result', 'diagnosticId'], `${label} assertion`); + if (typeof entry.id !== 'string' || !expected.has(entry.id)) { + throw new Error(`${label} contains unexpected assertion ${String(entry.id)}.`); + } + if (seen.has(entry.id)) { + throw new Error(`${label} contains duplicate assertion ${entry.id}.`); + } + if (entry.result === 'skip') { + throw new Error(`${label} contains skipped assertion ${entry.id}.`); + } + if ( + !['pass', 'fail'].includes(entry.result) || + typeof entry.diagnosticId !== 'string' || + !identifierPattern.test(entry.diagnosticId) + ) { + throw new Error(`${label} assertion ${entry.id} is invalid.`); + } + seen.add(entry.id); + } + for (const id of expectedIds) { + if (!seen.has(id)) { + throw new Error(`${label} is missing assertion ${id}.`); + } + } + if (assertions.some((assertion, index) => assertion.id !== expectedIds[index])) { + throw new Error(`${label} assertion order does not match the SDK registry.`); + } + return assertions; +} + +export function createAssertionRecorder(registry, platform) { + if (!canonicalPlatforms.includes(platform)) { + throw new Error(`unsupported platform ${platform}`); + } + const expected = { + sdk: registry.sdk, + chat: [...registry.chat.common, ...registry.chat.platforms[platform]], + }; + const recorded = { + sdk: new Map(), + chat: new Map(), + }; + const pass = (scope, id, diagnosticId = 'phase1.assertion.passed') => { + if (!Object.hasOwn(expected, scope) || !expected[scope].includes(id)) { + throw new Error(`${scope} contains unexpected assertion ${id}.`); + } + if (recorded[scope].has(id)) { + throw new Error(`${scope} contains duplicate assertion ${id}.`); + } + recorded[scope].set(id, { id, result: 'pass', diagnosticId }); + }; + return Object.freeze({ + pass, + passMany(scope, ids, diagnosticId) { + for (const id of ids) { + pass(scope, id, diagnosticId); + } + }, + results() { + const result = {}; + for (const scope of ['sdk', 'chat']) { + const assertions = expected[scope] + .filter((id) => recorded[scope].has(id)) + .map((id) => recorded[scope].get(id)); + result[scope] = assertExactAssertionResults( + assertions, + expected[scope], + `${platform} ${scope}`, + ); + } + return Object.freeze(result); + }, + }); +} + +export function validateMetadataAssertionBindings({ sdkAssertions, chatAssertions, metadata }) { + if ( + !/^[0-9a-f]{40}$/u.test(metadata?.sdkCandidateRevision) || + !/^[0-9a-f]{64}$/u.test(metadata?.sdkManifestSha256) || + !/^[0-9a-f]{40}$/u.test(metadata?.chatHarnessRevision) || + !/^[0-9a-f]{64}$/u.test(metadata?.windowsSupervisorSha256) || + metadata?.mingwPackageVersion !== 'mingw-w64 14.0.0_3' || + metadata?.mingwHomebrewCoreRevision !== 'cd168d1fdc26f12e4ad64f358ff2dbec61ab7a57' || + metadata?.mingwBottleLayerSha256 !== + '0d68ab737a8bbc8c63ac6ac7acc0695e2887c1169df9a4423f1180090079b1d5' || + metadata?.mingwLinkerVersion !== '2.47.20260726' || + metadata?.rustVersion !== '1.95.0' + ) { + throw new Error('Release metadata binding values are invalid.'); + } + const definitions = [ + { + scope: 'sdk', + id: 'sdk.install.packed-tarballs', + diagnosticId: `phase1.sdk-candidate.${metadata.sdkCandidateRevision}`, + prefix: 'phase1.sdk-candidate.', + }, + { + scope: 'sdk', + id: 'sdk.provenance.fixture-bytes-match', + diagnosticId: `phase1.sdk-manifest.${metadata.sdkManifestSha256}`, + prefix: 'phase1.sdk-manifest.', + }, + { + scope: 'chat', + id: 'chat.install.consumer-lock-matches', + diagnosticId: `phase1.chat-harness.${metadata.chatHarnessRevision}`, + prefix: 'phase1.chat-harness.', + }, + { + scope: 'chat', + id: 'chat.native.keychain-unavailable-fails-closed', + diagnosticId: `phase1.toolchain.rust.${metadata.rustVersion}`, + prefix: 'phase1.toolchain.rust.', + }, + { + scope: 'chat', + id: 'chat.deadline.total-bounded', + diagnosticId: windowsSupervisorDiagnosticId(metadata), + prefix: 'phase1.windows-supervisor.', + }, + ]; + const groups = { sdk: sdkAssertions, chat: chatAssertions }; + for (const definition of definitions) { + const entries = groups[definition.scope].filter((entry) => entry.id === definition.id); + if (entries.length !== 1 || entries[0].diagnosticId !== definition.diagnosticId) { + throw new Error(`Release metadata binding for ${definition.id} is invalid.`); + } + } + for (const [scope, assertions] of Object.entries(groups)) { + for (const assertion of assertions) { + const reserved = definitions.find((definition) => + assertion.diagnosticId.startsWith(definition.prefix), + ); + if ( + reserved !== undefined && + (reserved.scope !== scope || + reserved.id !== assertion.id || + reserved.diagnosticId !== assertion.diagnosticId) + ) { + throw new Error('Release metadata was attached to an unrelated assertion.'); + } + } + } +} + +export function windowsSupervisorDiagnosticId(metadata) { + const binding = createHash('sha256') + .update( + JSON.stringify([ + metadata.windowsSupervisorSha256, + metadata.mingwPackageVersion, + metadata.mingwHomebrewCoreRevision, + metadata.mingwBottleLayerSha256, + metadata.mingwLinkerVersion, + ]), + ) + .digest('hex'); + return `phase1.windows-supervisor.v1.${binding}`; +} + +function requireDigest(value, label) { + if (typeof value !== 'string' || !digestPattern.test(value)) { + throw new Error(`${label} must be a SHA-256 digest.`); + } + return value; +} + +export function buildPlatformEvidence({ + registry, + platform, + caveRecord, + releases, + commits, + digests, + sdkAssertions, + chatAssertions, + environment, + metadata, + isolation, +}) { + if (!canonicalPlatforms.includes(platform)) { + throw new Error(`unsupported platform ${platform}`); + } + if ( + environment?.nodeVersion !== 'v24.18.1' || + environment?.packageManagerVersion !== 'pnpm@10.34.0' + ) { + throw new Error('Platform evidence must use the exact observed release toolchain.'); + } + const [os, arch] = platform.split('-'); + const expectedChatIds = [...registry.chat.common, ...registry.chat.platforms[platform]]; + assertExactAssertionResults(sdkAssertions, registry.sdk, `${platform} SDK`); + assertExactAssertionResults(chatAssertions, expectedChatIds, `${platform} Chat`); + validateMetadataAssertionBindings({ sdkAssertions, chatAssertions, metadata }); + if ( + !Array.isArray(digests.sdkTarballs) || + digests.sdkTarballs.length !== canonicalTarballs.length + ) { + throw new Error('SDK tarball evidence must contain four canonical packages.'); + } + const sdkTarballs = digests.sdkTarballs.map((artifact, index) => { + if (artifact.packageName !== canonicalTarballs[index]) { + throw new Error('SDK tarball evidence must use canonical package order.'); + } + return { + packageName: artifact.packageName, + sha256: requireDigest(artifact.sha256, `${artifact.packageName} digest`), + }; + }); + if (!Array.isArray(isolation?.roots) || isolation.roots.length !== isolationRoots.length) { + throw new Error('Observed isolation roots are incomplete.'); + } + const observedRoots = isolationRoots.map((id, index) => { + const root = requireRecord(isolation.roots[index], `isolation root ${id}`); + if (root.id !== id || root.ownershipVerified !== true || root.removedAfterRun !== true) { + throw new Error(`isolation root ${id} was not verified and removed.`); + } + return { + id, + ownershipVerified: true, + removedAfterRun: true, + }; + }); + if ( + !Array.isArray(isolation.operatorState) || + isolation.operatorState.length !== operatorStateIds.length + ) { + throw new Error('Observed operator state is incomplete.'); + } + const operatorStateRecords = operatorStateIds.map((id, index) => { + const state = requireRecord(isolation.operatorState[index], `operator state ${id}`); + if (state.id !== id) { + throw new Error(`operator state ${id} is out of order.`); + } + const beforeSha256 = requireDigest(state.beforeSha256, `${id} before state`); + const afterSha256 = requireDigest(state.afterSha256, `${id} after state`); + if (beforeSha256 !== afterSha256) { + throw new Error(`operator state ${id} changed.`); + } + return { id, beforeSha256, afterSha256 }; + }); + return { + schemaVersion: 1, + issue: 'OpenCoven/sdk#38', + platform, + ranAt: caveRecord.ranAt, + environment: { + os, + arch, + nodeVersion: environment.nodeVersion, + packageManagerVersion: environment.packageManagerVersion, + }, + releases, + commits, + digests: { + caveAssertionEngine: requireDigest( + digests.caveAssertionEngine, + 'Cave assertion engine digest', + ), + caveContractFixture: requireDigest( + digests.caveContractFixture, + 'Cave contract fixture digest', + ), + hpkeVectors: requireDigest(digests.hpkeVectors, 'HPKE vectors digest'), + consumerLock: requireDigest(digests.consumerLock, 'consumer lock digest'), + assertionRegistry: requireDigest(digests.assertionRegistry, 'assertion registry digest'), + sdkTarballs, + }, + caveRecord, + sdkAssertions, + chatAssertions, + coverage: { cave: true, coven: true, sdk: true, chat: true }, + notCovered: notCovered.map((entry) => ({ ...entry })), + isolation: { + strategy: 'process-owned-temporary-roots', + network: 'loopback-only', + sourceCheckoutDependency: false, + workspaceLinkDependency: false, + retainedPrivatePaths: false, + retainedSocketHandles: false, + roots: observedRoots, + operatorState: operatorStateRecords, + }, + }; +} diff --git a/scripts/phase1-process-supervisor.d.mts b/scripts/phase1-process-supervisor.d.mts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/scripts/phase1-process-supervisor.d.mts @@ -0,0 +1 @@ +export {}; diff --git a/scripts/phase1-process-supervisor.mjs b/scripts/phase1-process-supervisor.mjs new file mode 100644 index 0000000..4080114 --- /dev/null +++ b/scripts/phase1-process-supervisor.mjs @@ -0,0 +1,128 @@ +import { spawn } from 'node:child_process'; +import { closeSync, lstatSync, writeSync } from 'node:fs'; +import { isAbsolute, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const terminationGraceMs = 250; +const legacyStatusPathEnvironment = 'OPENCOVEN_PHASE1_SUPERVISOR_STATUS_PATH'; +const groupKillFailureEnvironment = 'OPENCOVEN_PHASE1_TEST_GROUP_KILL_FAILURE'; + +function fail() { + process.stderr.write('phase1-process-supervisor: failed\n'); + process.exitCode = 1; +} + +function main(argv) { + let timeoutMs; + if (argv[0] === '--timeout-ms') { + timeoutMs = Number(argv[1]); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || argv[2] !== '--') { + fail(); + return; + } + argv = argv.slice(3); + } + if (process.platform === 'win32' || argv.length < 1 || !isAbsolute(argv[0])) { + fail(); + return; + } + const executable = resolve(argv[0]); + const stats = lstatSync(executable); + if (stats.isSymbolicLink() || !stats.isFile()) { + fail(); + return; + } + const child = spawn(executable, argv.slice(1), { + env: Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => ![legacyStatusPathEnvironment, groupKillFailureEnvironment].includes(name), + ), + ), + stdio: ['inherit', 'inherit', 'inherit', 'ignore'], + }); + let cleanupStarted = false; + let statusRecorded = false; + let timeoutHandle; + const recordStatus = (code, signal, reason) => { + if (statusRecorded) { + return; + } + statusRecorded = true; + const frame = `${JSON.stringify({ code, signal, reason })}\n`; + if (Buffer.byteLength(frame) > 256) { + throw new Error('supervisor status frame exceeded its bound'); + } + writeSync(3, frame); + closeSync(3); + }; + const killOwnedGroupNow = () => { + if (cleanupStarted) { + return; + } + cleanupStarted = true; + if (process.env[groupKillFailureEnvironment] === '1') { + fail(); + process.exit(1); + } + try { + process.kill(-process.pid, 'SIGKILL'); + } catch { + fail(); + process.exit(1); + } + }; + const cleanupOwnedGroup = (reason = 'terminated') => { + if (cleanupStarted) { + return; + } + cleanupStarted = true; + try { + recordStatus(null, 'SIGTERM', reason); + } catch { + fail(); + } + try { + process.kill(-process.pid, 'SIGTERM'); + } catch { + fail(); + return; + } + setTimeout(() => { + try { + process.kill(-process.pid, 'SIGKILL'); + } catch { + fail(); + } + }, terminationGraceMs); + }; + child.once('error', () => { + try { + recordStatus(null, null, 'spawn'); + } finally { + killOwnedGroupNow(); + } + }); + child.once('exit', (code, signal) => { + if (timeoutHandle !== undefined) { + clearTimeout(timeoutHandle); + } + try { + recordStatus(code, signal, 'exit'); + } finally { + killOwnedGroupNow(); + } + }); + process.on('SIGTERM', () => cleanupOwnedGroup()); + process.on('SIGINT', () => cleanupOwnedGroup()); + if (timeoutMs !== undefined) { + timeoutHandle = setTimeout(() => cleanupOwnedGroup('timeout'), timeoutMs); + } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(process.argv.slice(2)); + } catch { + fail(); + } +} diff --git a/scripts/process-owned-artifact-root.d.mts b/scripts/process-owned-artifact-root.d.mts index bda69af..5bf3e41 100644 --- a/scripts/process-owned-artifact-root.d.mts +++ b/scripts/process-owned-artifact-root.d.mts @@ -8,8 +8,7 @@ export type ProcessOwnedArtifactRoot = { ownerPid: number; cleanedChildren: number[]; reapedChildren: number[]; - trackChild(child: ChildProcess, options?: { processGroup?: boolean }): ChildProcess; - terminateChild(child: ChildProcess): Promise; + trackChild(child: ChildProcess): ChildProcess; retainSanitizedJsonReport(options: { reportPath: string; destinationPath: string; diff --git a/scripts/process-owned-artifact-root.mjs b/scripts/process-owned-artifact-root.mjs index 2db18c0..bba0f6c 100644 --- a/scripts/process-owned-artifact-root.mjs +++ b/scripts/process-owned-artifact-root.mjs @@ -115,22 +115,24 @@ function readReportSnapshot(reportPath, rootRealPath) { } } -function requireCompletedJsonReport(snapshot) { +function requireSdkPlatformEvidenceRecord(snapshot) { let report; try { report = JSON.parse(snapshot.bytes.toString('utf8')); } catch { - throw new Error('Sanitized report must be a completed JSON report.'); + throw new Error('Sanitized report must be an SDK platform evidence record.'); } if ( report === null || typeof report !== 'object' || Array.isArray(report) || - report.completed !== true + report.schemaVersion !== 1 || + report.issue !== 'OpenCoven/sdk#38' || + typeof report.platform !== 'string' ) { - throw new Error('Sanitized report must be a completed JSON report.'); + throw new Error('Sanitized report must be an SDK platform evidence record.'); } } @@ -231,109 +233,30 @@ function waitForChildClose(child, timeoutMs) { }); } -function processGroupExists(pid) { - if (process.platform === 'win32') { - return false; - } - try { - process.kill(-pid, 0); - return true; - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ESRCH') { - return false; - } - if (error instanceof Error && 'code' in error && error.code === 'EPERM') { - return true; - } - throw error; - } -} - -function signalOwnedChild(child, signal, processGroup) { - if (processGroup && process.platform !== 'win32') { - try { - process.kill(-child.pid, signal); - return true; - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ESRCH') { - return false; - } - throw error; - } - } - return child.kill(signal); -} - -function waitForOwnedExecutionClose(child, processGroup, timeoutMs) { - if (!processGroup || process.platform === 'win32') { - return waitForChildClose(child, timeoutMs); - } +async function terminateAndReapChild(child, terminationGraceMs) { const pid = child.pid; - const deadline = Date.now() + timeoutMs; - return new Promise((resolveWait) => { - const check = () => { - const childClosed = child.exitCode !== null || child.signalCode !== null; - if (childClosed && !processGroupExists(pid)) { - resolveWait(true); - return; - } - if (Date.now() >= deadline) { - resolveWait(false); - return; - } - setTimeout(check, 10); - }; - check(); - }); -} - -async function terminateAndReapChild(child, terminationGraceMs, processGroup) { - const pid = child.pid; - const childActive = child.exitCode === null && child.signalCode === null; - if (!childActive) { - if (processGroup && processGroupExists(pid)) { - throw new Error(`Tracked child ${pid} exited while its process group still had descendants.`); + if (child.exitCode === null && child.signalCode === null) { + const signaled = child.kill('SIGTERM'); + if (!signaled && child.exitCode === null && child.signalCode === null) { + throw new Error(`Tracked child ${pid} could not be terminated.`); } - return; } - if (processGroup) { - const killed = signalOwnedChild(child, 'SIGKILL', true); - if (!killed && child.exitCode === null && child.signalCode === null) { - throw new Error(`Tracked child ${pid} process group could not be killed.`); - } - if (!(await waitForOwnedExecutionClose(child, true, terminationGraceMs))) { - throw new Error(`Tracked child ${pid} process group could not be reaped.`); - } + if (await waitForChildClose(child, terminationGraceMs)) { return; } - const signaled = signalOwnedChild(child, 'SIGTERM', processGroup); - if ( - !signaled && - child.exitCode === null && - child.signalCode === null && - (!processGroup || !processGroupExists(pid)) - ) { - throw new Error(`Tracked child ${pid} could not be terminated.`); - } - - if (await waitForOwnedExecutionClose(child, processGroup, terminationGraceMs)) { - return; + if (child.__phase1SupervisorOwnsTree === true) { + throw new Error(`Tracked supervisor ${pid} did not complete owned-tree cleanup.`); } - const killed = signalOwnedChild(child, 'SIGKILL', false); - if ( - !killed && - child.exitCode === null && - child.signalCode === null && - (!processGroup || !processGroupExists(pid)) - ) { + const killed = child.kill('SIGKILL'); + if (!killed && child.exitCode === null && child.signalCode === null) { throw new Error(`Tracked child ${pid} could not be killed.`); } - if (!(await waitForOwnedExecutionClose(child, processGroup, terminationGraceMs))) { + if (!(await waitForChildClose(child, terminationGraceMs))) { throw new Error(`Tracked child ${pid} could not be reaped.`); } } @@ -355,41 +278,18 @@ export function createProcessOwnedArtifactRoot(options) { ownerPid: process.pid, cleanedChildren, reapedChildren, - trackChild(child, options = {}) { + trackChild(child) { if (!(child instanceof ChildProcess) || !Number.isInteger(child.pid) || child.pid <= 0) { throw new Error('trackChild requires a spawned ChildProcess with a positive PID.'); } - if ( - options === null || - typeof options !== 'object' || - Array.isArray(options) || - Object.keys(options).some((key) => key !== 'processGroup') || - (options.processGroup !== undefined && typeof options.processGroup !== 'boolean') - ) { - throw new Error('trackChild options may contain only a boolean processGroup.'); - } - const processGroup = options.processGroup ?? false; - if (processGroup && process.platform === 'win32') { - throw new Error('Owned process groups are unavailable on Windows.'); - } const existing = trackedChildren.get(child.pid); - if (existing !== undefined && existing.child !== child) { + if (existing !== undefined && existing !== child) { throw new Error(`A different child is already tracked for PID ${child.pid}.`); } - trackedChildren.set(child.pid, { child, processGroup }); + trackedChildren.set(child.pid, child); return child; }, - async terminateChild(child) { - const tracked = trackedChildren.get(child?.pid); - if (tracked === undefined || tracked.child !== child) { - throw new Error('terminateChild requires a currently tracked ChildProcess.'); - } - await terminateAndReapChild(tracked.child, terminationGraceMs, tracked.processGroup); - trackedChildren.delete(child.pid); - cleanedChildren.push(child.pid); - reapedChildren.push(child.pid); - }, async retainSanitizedJsonReport({ reportPath, destinationPath, secretScan }) { if (cleaned) { throw new Error('Cannot retain a report after the owned artifact root is cleaned.'); @@ -399,7 +299,7 @@ export function createProcessOwnedArtifactRoot(options) { } const firstSnapshot = readReportSnapshot(reportPath, owned.rootRealPath); - requireCompletedJsonReport(firstSnapshot); + requireSdkPlatformEvidenceRecord(firstSnapshot); await secretScan({ artifactRoot: owned.rootPath, @@ -427,15 +327,29 @@ export function createProcessOwnedArtifactRoot(options) { return; } - for (const [pid, tracked] of trackedChildren) { - await terminateAndReapChild(tracked.child, terminationGraceMs, tracked.processGroup); - cleanedChildren.push(pid); - reapedChildren.push(pid); + const failures = []; + for (const [pid, child] of [...trackedChildren.entries()].reverse()) { + try { + await terminateAndReapChild(child, terminationGraceMs); + cleanedChildren.push(pid); + reapedChildren.push(pid); + trackedChildren.delete(pid); + } catch (error) { + failures.push(error); + } } - trackedChildren.clear(); - cleanupOwnedTempRoot(owned); - cleaned = true; + if (trackedChildren.size === 0) { + try { + cleanupOwnedTempRoot(owned); + cleaned = true; + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) { + throw new AggregateError(failures, 'Process-owned artifact root cleanup failed.'); + } }, }; } diff --git a/scripts/supervised-exec.mjs b/scripts/supervised-exec.mjs new file mode 100644 index 0000000..16da6c7 --- /dev/null +++ b/scripts/supervised-exec.mjs @@ -0,0 +1,83 @@ +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { resolveExecutableInvocation } from './executable-resolution.mjs'; +import { cleanupOwnedTempRoot, createOwnedTempDirectory } from './owned-temp-directory.mjs'; +import { parseSupervisorStatusFrame } from './supervisor-status.mjs'; + +const scriptsRoot = dirname(fileURLToPath(import.meta.url)); +const nodeSupervisor = resolve(scriptsRoot, 'phase1-process-supervisor.mjs'); +let windowsSupervisor; + +export function configureSupervisedExecution(options = {}) { + windowsSupervisor = options.windowsSupervisor; +} + +export function runSupervisedSync(command, args, options = {}) { + const owned = createOwnedTempDirectory({ prefix: `phase1-sync-${randomUUID()}` }); + const windows = process.platform === 'win32'; + if (windows && typeof windowsSupervisor !== 'string') { + cleanupOwnedTempRoot(owned); + throw new Error('Windows supervised execution is not configured.'); + } + try { + const invocation = resolveExecutableInvocation( + command, + options.env === undefined ? process.env : options.env, + process.platform, + args, + ); + const result = spawnSync( + windows ? windowsSupervisor : process.execPath, + windows + ? ['--', invocation.executable, ...invocation.args] + : [ + nodeSupervisor, + '--timeout-ms', + String(options.timeout ?? 30_000), + '--', + invocation.executable, + ...invocation.args, + ], + { + ...options, + detached: !windows, + env: options.env === undefined ? process.env : options.env, + encoding: options.encoding, + input: options.input, + killSignal: 'SIGTERM', + maxBuffer: options.maxBuffer ?? 32 * 1024 * 1024, + stdio: windows ? options.stdio : ['pipe', 'pipe', 'pipe', 'pipe'], + timeout: windows ? (options.timeout ?? 30_000) : (options.timeout ?? 30_000) + 5_000, + }, + ); + if (result.error !== undefined) { + throw result.error; + } + if (windows) { + if (result.status !== 0) { + throw new Error('Supervised command failed.'); + } + } else { + const status = parseSupervisorStatusFrame(result.output?.[3] ?? ''); + if ( + result.status !== null || + result.signal !== 'SIGKILL' || + status.reason !== 'exit' || + status.code !== 0 || + status.signal !== null + ) { + const error = new Error('Supervised command failed.'); + if (status.reason === 'timeout') { + error.code = 'ETIMEDOUT'; + } + throw error; + } + } + return result.stdout; + } finally { + cleanupOwnedTempRoot(owned); + } +} diff --git a/scripts/supervisor-status.d.mts b/scripts/supervisor-status.d.mts new file mode 100644 index 0000000..0273284 --- /dev/null +++ b/scripts/supervisor-status.d.mts @@ -0,0 +1,5 @@ +export function parseSupervisorStatusFrame(bytes: Buffer | string): { + code: number | null; + signal: string | null; + reason: string; +}; diff --git a/scripts/supervisor-status.mjs b/scripts/supervisor-status.mjs new file mode 100644 index 0000000..5fef25e --- /dev/null +++ b/scripts/supervisor-status.mjs @@ -0,0 +1,20 @@ +export function parseSupervisorStatusFrame(bytes) { + const text = Buffer.isBuffer(bytes) ? bytes.toString('utf8') : String(bytes); + if (Buffer.byteLength(text) > 256 || !text.endsWith('\n') || text.slice(0, -1).includes('\n')) { + throw new Error('supervisor status frame was not canonical'); + } + const status = JSON.parse(text); + if ( + status === null || + typeof status !== 'object' || + Array.isArray(status) || + Object.keys(status).sort().join(',') !== 'code,reason,signal' || + !['exit', 'spawn', 'terminated', 'timeout'].includes(status.reason) || + (status.code !== null && !Number.isInteger(status.code)) || + (status.signal !== null && typeof status.signal !== 'string') || + (status.code !== null && status.signal !== null) + ) { + throw new Error('supervisor status frame was not canonical'); + } + return status; +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 75a54c6..16c7462 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -55,6 +55,8 @@ windows-sys = { version = "=0.61.2", features = [ "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Com", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_JobObjects", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_UI_Shell", diff --git a/src-tauri/src/bin/phase1-native-rpc.rs b/src-tauri/src/bin/phase1-native-rpc.rs index 0fbf0dc..eb209db 100644 --- a/src-tauri/src/bin/phase1-native-rpc.rs +++ b/src-tauri/src/bin/phase1-native-rpc.rs @@ -1,5 +1,14 @@ fn main() { opencoven_chat_lib::exit_if_internal_coven_health_probe_requested(); + if let Some(result) = + opencoven_chat_lib::conformance::run_internal_test_reservation_output_if_requested() + { + if result.is_err() { + eprintln!("phase1-native-rpc failed"); + std::process::exit(1); + } + return; + } if opencoven_chat_lib::conformance::run_stdio().is_err() { eprintln!("phase1-native-rpc failed"); std::process::exit(1); diff --git a/src-tauri/src/conformance.rs b/src-tauri/src/conformance.rs index 8271a0c..10287d9 100644 --- a/src-tauri/src/conformance.rs +++ b/src-tauri/src/conformance.rs @@ -1,7 +1,8 @@ use std::{ collections::HashMap, env, - io::{self, BufRead, Write}, + fs::OpenOptions, + io::{self, BufRead, Read, Write}, path::PathBuf, process::{Child, Command, Stdio}, sync::{Arc, Mutex, MutexGuard}, @@ -16,7 +17,8 @@ use crate::{ NativeCaveTaskRunner, NativeDiagnostic, NativeResult, }, keyring::{ - validate_credential_origin, Credential, CredentialCustody, CredentialSlot, KeyringError, + validate_credential_origin, validate_installation_id, ConformanceCleanupOutcome, + ConformanceCleanupReservation, Credential, CredentialCustody, CredentialSlot, KeyringError, NativeKeyring, NativeProviderPreset, }, operation::{ @@ -40,6 +42,45 @@ pub const CONFORMANCE_CAVE_SERVER_PATH_ENV: &str = "OPENCOVEN_PHASE1_CONFORMANCE pub const CONFORMANCE_NATIVE_PROVIDER_PRESET_ENV: &str = "OPENCOVEN_PHASE1_CONFORMANCE_NATIVE_PROVIDER_PRESET"; const CONFORMANCE_NATIVE_PROVIDER_MISSING_KEYCHAIN_TRUST: &str = "missing-keychain-trust"; +const CONFORMANCE_NATIVE_PROVIDER_PRODUCTION_KEYRING: &str = "production-keyring"; +const INTERNAL_TEST_RESERVATION_OUTPUT_ARGUMENT: &str = + "--opencoven-internal-test-reservation-output-v1"; +const INTERNAL_TEST_RESERVATION_EOF_ARGUMENT: &str = "--opencoven-internal-test-reservation-eof-v1"; +const INTERNAL_TEST_KEYCHAIN_ISOLATED_ENV: &str = "OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED"; +const INTERNAL_TEST_RESERVATION_RESULT_PATH_ENV: &str = + "OPENCOVEN_PHASE1_TEST_RESERVATION_RESULT_PATH"; +const TEST_ADOPTION_BARRIER_ENV: &str = "OPENCOVEN_PHASE1_TEST_ADOPTION_BARRIER"; +const TEST_ADOPTION_BARRIER_ROOT_ENV: &str = "OPENCOVEN_PHASE1_TEST_ADOPTION_BARRIER_ROOT"; + +fn adoption_fault_barrier(phase: &str) -> Result<(), NativeDiagnostic> { + if env::var(INTERNAL_TEST_KEYCHAIN_ISOLATED_ENV).as_deref() != Ok("1") + || env::var(TEST_ADOPTION_BARRIER_ENV).as_deref() != Ok(phase) + { + return Ok(()); + } + let root = PathBuf::from( + env::var_os(TEST_ADOPTION_BARRIER_ROOT_ENV) + .ok_or_else(|| NativeDiagnostic::new("invalid_native_input", false))?, + ); + let allowed = PathBuf::from( + env::var_os("CARGO_TARGET_DIR") + .ok_or_else(|| NativeDiagnostic::new("invalid_native_input", false))?, + ) + .join("phase1-native-rpc-tests"); + if !root.is_absolute() || !root.starts_with(&allowed) { + return Err(NativeDiagnostic::new("invalid_native_input", false)); + } + let ready = root.join(format!("{phase}.ready")); + let gate = root.join(format!("{phase}.gate")); + OpenOptions::new() + .write(true) + .open(ready) + .and_then(|mut file| file.write_all(b"ready\n")) + .map_err(|_| NativeDiagnostic::new("keychain_failure", false))?; + std::fs::File::open(gate) + .map(|_| ()) + .map_err(|_| NativeDiagnostic::new("keychain_failure", false)) +} #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -98,6 +139,33 @@ enum RpcCommand { handle: String, operation: NativeOperationInput, }, + ConformancePrepareNativeCleanup { + handle: String, + }, + ConformanceBeginAdoptNativeCleanup { + reservation_handle: String, + capability: String, + owner_token: String, + successor_owner_token: String, + }, + ConformanceCommitAdoptNativeCleanup { + reservation_handle: String, + capability: String, + owner_token: String, + successor_owner_token: String, + }, + ConformanceAbortAdoptNativeCleanup { + reservation_handle: String, + capability: String, + owner_token: String, + successor_owner_token: String, + }, + ConformanceCancelPreparedNativeCleanup, + ConformanceDeleteNativeCredential { + reservation_handle: String, + capability: String, + owner_token: String, + }, CaveListFamiliars { handle: String, page: NativePage, @@ -153,6 +221,8 @@ impl RpcCommand { self, Self::CaveLaunch | Self::CaveResetPairing { .. } + | Self::ConformancePrepareNativeCleanup { .. } + | Self::ConformanceDeleteNativeCredential { .. } | Self::ResetNativeState | Self::Shutdown ) @@ -335,6 +405,128 @@ pub struct ConformanceCaveLauncher; struct ConformanceCaveChild { child: Child, reaped: bool, + #[cfg(windows)] + _job: WindowsCaveJob, +} + +#[cfg(windows)] +struct WindowsCaveJob(isize); + +#[cfg(windows)] +impl Drop for WindowsCaveJob { + fn drop(&mut self) { + unsafe { + windows_sys::Win32::Foundation::CloseHandle( + self.0 as windows_sys::Win32::Foundation::HANDLE, + ); + } + } +} + +#[cfg(windows)] +fn bind_windows_cave_job(child: &Child) -> io::Result { + use std::{mem::size_of, os::windows::io::AsRawHandle, ptr}; + + use windows_sys::Win32::{ + Foundation::CloseHandle, + System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }, + }; + + let job = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) }; + if job.is_null() { + return Err(io::Error::last_os_error()); + } + let mut information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if unsafe { + SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + (&raw const information).cast(), + size_of::() as u32, + ) + } == 0 + { + let error = io::Error::last_os_error(); + unsafe { + CloseHandle(job); + } + return Err(error); + } + if unsafe { AssignProcessToJobObject(job, child.as_raw_handle()) } == 0 { + let error = io::Error::last_os_error(); + unsafe { + CloseHandle(job); + } + return Err(error); + } + Ok(WindowsCaveJob(job as isize)) +} + +#[cfg(windows)] +fn resume_windows_cave(child: &Child) -> io::Result<()> { + use std::mem::size_of; + + use windows_sys::Win32::{ + Foundation::{CloseHandle, INVALID_HANDLE_VALUE}, + System::{ + Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, + THREADENTRY32, + }, + Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}, + }, + }; + + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + let mut entry = THREADENTRY32 { + dwSize: size_of::() as u32, + ..THREADENTRY32::default() + }; + let mut found = false; + let mut status = unsafe { Thread32First(snapshot, &mut entry) }; + while status != 0 { + if entry.th32OwnerProcessID == child.id() { + found = true; + let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) }; + if thread.is_null() { + let error = io::Error::last_os_error(); + unsafe { + CloseHandle(snapshot); + } + return Err(error); + } + if unsafe { ResumeThread(thread) } == u32::MAX { + let error = io::Error::last_os_error(); + unsafe { + CloseHandle(thread); + CloseHandle(snapshot); + } + return Err(error); + } + unsafe { + CloseHandle(thread); + } + } + status = unsafe { Thread32Next(snapshot, &mut entry) }; + } + unsafe { + CloseHandle(snapshot); + } + if !found { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "suspended Cave process had no resumable thread", + )); + } + Ok(()) } impl ConformanceCaveChild { @@ -418,58 +610,188 @@ impl CaveLauncher for ConformanceCaveLauncher { fn launch(&self) -> NativeResult> { let node = regular_absolute_environment_path(CONFORMANCE_NODE_PATH_ENV)?; let server = regular_absolute_environment_path(CONFORMANCE_CAVE_SERVER_PATH_ENV)?; - let child = Command::new(node) + let mut command = Command::new(node); + command .arg(server) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::inherit()) + .stderr(Stdio::inherit()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + use windows_sys::Win32::System::Threading::CREATE_SUSPENDED; + + command.creation_flags(CREATE_SUSPENDED); + } + let child = command .spawn() .map_err(|_| NativeDiagnostic::new("cave_launch_failed", true))?; + #[cfg(windows)] + let mut child = child; + #[cfg(windows)] + let job = bind_windows_cave_job(&child).map_err(|_| { + let _ = child.kill(); + let _ = child.wait(); + NativeDiagnostic::new("cave_launch_failed", true) + })?; + #[cfg(windows)] + if resume_windows_cave(&child).is_err() { + drop(job); + let _ = child.kill(); + let _ = child.wait(); + return Err(NativeDiagnostic::new("cave_launch_failed", true)); + } Ok(Box::new(ConformanceCaveChild { child, reaped: false, + #[cfg(windows)] + _job: job, })) } } -#[derive(Clone)] pub struct RpcRuntime { custody: Arc, + emergency_cleanup: Option>, + pending_reservation_response: Option, + armed_reservation_cleanup: Option, state: NativeConnectionState, } +impl Clone for RpcRuntime { + fn clone(&self) -> Self { + Self { + custody: Arc::clone(&self.custody), + emergency_cleanup: self.emergency_cleanup.clone(), + pending_reservation_response: None, + armed_reservation_cleanup: None, + state: self.state.clone(), + } + } +} + +trait ConformanceCredentialCleanup: Send + Sync { + fn prepare(&self, instance_id: &str) -> Result; + fn begin_adopt( + &self, + handle: &str, + capability: &str, + owner_token: &str, + successor_owner_token: &str, + ) -> Result; + fn commit_adopt( + &self, + handle: &str, + capability: &str, + owner_token: &str, + successor_owner_token: &str, + ) -> Result<(), KeyringError>; + fn abort_adopt( + &self, + handle: &str, + capability: &str, + owner_token: &str, + successor_owner_token: &str, + ) -> Result<(), KeyringError>; + fn cancel_prepared(&self) -> Result<(), KeyringError>; + fn cleanup( + &self, + handle: &str, + capability: &str, + owner_token: &str, + ) -> Result; +} + +impl ConformanceCredentialCleanup for NativeKeyring { + fn prepare(&self, instance_id: &str) -> Result { + self.prepare_conformance_cleanup(instance_id) + } + + fn begin_adopt( + &self, + handle: &str, + capability: &str, + owner_token: &str, + successor_owner_token: &str, + ) -> Result { + self.begin_adopt_conformance_cleanup(handle, capability, owner_token, successor_owner_token) + } + + fn commit_adopt( + &self, + handle: &str, + capability: &str, + owner_token: &str, + successor_owner_token: &str, + ) -> Result<(), KeyringError> { + self.commit_adopt_conformance_cleanup( + handle, + capability, + owner_token, + successor_owner_token, + ) + } + + fn abort_adopt( + &self, + handle: &str, + capability: &str, + owner_token: &str, + successor_owner_token: &str, + ) -> Result<(), KeyringError> { + self.abort_adopt_conformance_cleanup(handle, capability, owner_token, successor_owner_token) + } + + fn cancel_prepared(&self) -> Result<(), KeyringError> { + self.cancel_prepared_conformance_cleanup() + } + + fn cleanup( + &self, + handle: &str, + capability: &str, + owner_token: &str, + ) -> Result { + self.cleanup_conformance_credential(handle, capability, owner_token) + } +} + impl RpcRuntime { pub fn new() -> Self { - Self::with_custody(Arc::new(SharedMemoryCredentialCustody::new())) + Self::with_custody(Arc::new(SharedMemoryCredentialCustody::new()), None) } - fn with_custody(custody: Arc) -> Self { + fn with_custody( + custody: Arc, + emergency_cleanup: Option>, + ) -> Self { let operations = Arc::new(NativeOperationRegistry::default()); let mutations = Arc::new(NativeMutationQueue::default()); Self { state: state_with_custody(Arc::clone(&custody), operations, mutations), custody, - } - } - - #[cfg(test)] - fn with_coven_health(coven_health: Arc) -> Self { - let custody: Arc = Arc::new(SharedMemoryCredentialCustody::new()); - let operations = Arc::new(NativeOperationRegistry::default()); - let mutations = Arc::new(NativeMutationQueue::default()); - Self { - state: state_with_custody(Arc::clone(&custody), operations, mutations) - .using_test_coven_health(coven_health), - custody, + emergency_cleanup, + pending_reservation_response: None, + armed_reservation_cleanup: None, } } fn from_environment() -> Result { match env::var(CONFORMANCE_NATIVE_PROVIDER_PRESET_ENV) { Ok(value) if value == CONFORMANCE_NATIVE_PROVIDER_MISSING_KEYCHAIN_TRUST => { - Ok(Self::with_custody(Arc::new( - NativeKeyring::with_provider_preset(NativeProviderPreset::MissingKeychainTrust), - ))) + Ok(Self::with_custody( + Arc::new(NativeKeyring::with_provider_preset( + NativeProviderPreset::MissingKeychainTrust, + )), + None, + )) + } + Ok(value) if value == CONFORMANCE_NATIVE_PROVIDER_PRODUCTION_KEYRING => { + let keyring = Arc::new(NativeKeyring::for_conformance()); + Ok(Self::with_custody( + keyring.clone(), + Some(keyring as Arc), + )) } Ok(_) | Err(env::VarError::NotUnicode(_)) => { Err(NativeDiagnostic::new("invalid_native_input", false)) @@ -495,7 +817,11 @@ impl RpcRuntime { Ok(request) => request, Err(response) => return (response, false), }; - self.process_request(request) + let result = self.process_request(request); + if let Some(rollback) = self.pending_reservation_response.take() { + self.armed_reservation_cleanup = Some(rollback); + } + result } fn process_request(&mut self, request: RpcRequest) -> (Value, bool) { @@ -605,6 +931,146 @@ impl RpcRuntime { }, ))? } + RpcCommand::ConformancePrepareNativeCleanup { handle } => { + let cleanup = self + .emergency_cleanup + .as_ref() + .ok_or_else(|| NativeDiagnostic::new("invalid_native_input", false))?; + let instance_id = self.state.conformance_authorized_instance_id(&handle)?; + let reservation = cleanup + .prepare(&instance_id) + .map_err(|error| error.diagnostic())?; + let rollback = PreparedResponseRollback::new(cleanup.clone(), reservation.clone()); + let result = value_from(reservation.clone())?; + self.pending_reservation_response = Some(rollback); + result + } + RpcCommand::ConformanceBeginAdoptNativeCleanup { + reservation_handle, + capability, + owner_token, + successor_owner_token, + } => { + let cleanup = self + .emergency_cleanup + .as_ref() + .ok_or_else(|| NativeDiagnostic::new("invalid_native_input", false))?; + let reservation = cleanup + .begin_adopt( + &reservation_handle, + &capability, + &owner_token, + &successor_owner_token, + ) + .map_err(|error| error.diagnostic())?; + let rollback = PreparedResponseRollback::new_pending( + cleanup.clone(), + reservation.clone(), + owner_token, + ); + let result = value_from(reservation)?; + self.pending_reservation_response = Some(rollback); + result + } + RpcCommand::ConformanceCommitAdoptNativeCleanup { + reservation_handle, + capability, + owner_token, + successor_owner_token, + } => { + let cleanup = self + .emergency_cleanup + .as_ref() + .ok_or_else(|| NativeDiagnostic::new("invalid_native_input", false))?; + adoption_fault_barrier("before-commit")?; + cleanup + .commit_adopt( + &reservation_handle, + &capability, + &owner_token, + &successor_owner_token, + ) + .map_err(|error| error.diagnostic())?; + adoption_fault_barrier("after-commit")?; + let reservation = ConformanceCleanupReservation { + reservation_handle, + capability, + owner_token: successor_owner_token.clone(), + }; + if let Some(rollback) = self.armed_reservation_cleanup.take() { + rollback.disarm(); + } + self.armed_reservation_cleanup = Some(PreparedResponseRollback::new_active( + cleanup.clone(), + reservation, + )); + json!({ "status": "committed", "ownerToken": successor_owner_token }) + } + RpcCommand::ConformanceAbortAdoptNativeCleanup { + reservation_handle, + capability, + owner_token, + successor_owner_token, + } => { + let cleanup = self + .emergency_cleanup + .as_ref() + .ok_or_else(|| NativeDiagnostic::new("invalid_native_input", false))?; + cleanup + .abort_adopt( + &reservation_handle, + &capability, + &owner_token, + &successor_owner_token, + ) + .map_err(|error| error.diagnostic())?; + if let Some(rollback) = self.armed_reservation_cleanup.take() { + rollback.disarm(); + } + json!({ "status": "aborted" }) + } + RpcCommand::ConformanceCancelPreparedNativeCleanup => { + let cleanup = self + .emergency_cleanup + .as_ref() + .ok_or_else(|| NativeDiagnostic::new("invalid_native_input", false))?; + cleanup + .cancel_prepared() + .map_err(|error| error.diagnostic())?; + if let Some(rollback) = self.armed_reservation_cleanup.take() { + rollback.disarm(); + } + json!({ "status": "missing" }) + } + RpcCommand::ConformanceDeleteNativeCredential { + reservation_handle, + capability, + owner_token, + } => { + let cleanup = self + .emergency_cleanup + .as_ref() + .ok_or_else(|| NativeDiagnostic::new("invalid_native_input", false))?; + let outcome = cleanup + .cleanup(&reservation_handle, &capability, &owner_token) + .map_err(|error| error.diagnostic())?; + if outcome == ConformanceCleanupOutcome::Transferred { + return Err(NativeDiagnostic::new("stale_cleanup_owner", false)); + } + if self + .armed_reservation_cleanup + .as_ref() + .is_some_and(|rollback| { + rollback.matches(&reservation_handle, &capability, &owner_token) + }) + { + self.armed_reservation_cleanup + .take() + .expect("checked armed reservation") + .disarm(); + } + json!({ "status": "missing" }) + } RpcCommand::CaveListFamiliars { handle, page, @@ -685,6 +1151,34 @@ impl RpcRuntime { }; Ok((result, false)) } + + fn take_pending_reservation_response(&mut self) -> Option { + self.pending_reservation_response.take() + } + + fn arm_delivered_reservation(&mut self, rollback: Option) { + if let Some(rollback) = rollback { + self.armed_reservation_cleanup = Some(rollback); + } + } + + fn cleanup_armed_reservation(&mut self) -> Result<(), KeyringError> { + match self.armed_reservation_cleanup.take() { + Some(rollback) => rollback.rollback(), + None => Ok(()), + } + } +} + +impl Drop for RpcRuntime { + fn drop(&mut self) { + if let Some(rollback) = self.pending_reservation_response.take() { + let _ = rollback.rollback(); + } + if let Some(rollback) = self.armed_reservation_cleanup.take() { + let _ = rollback.rollback(); + } + } } impl Default for RpcRuntime { @@ -873,6 +1367,77 @@ fn parse_command(command: &str, args: Option) -> Result { + expect_exact_args(object, &["handle"])?; + Ok(RpcCommand::ConformancePrepareNativeCleanup { + handle: required_string(object, "handle")?, + }) + } + "conformance_begin_adopt_native_cleanup" + | "conformance_commit_adopt_native_cleanup" + | "conformance_abort_adopt_native_cleanup" => { + expect_exact_args( + object, + &[ + "capability", + "ownerToken", + "reservationHandle", + "successorOwnerToken", + ], + )?; + let reservation_handle = required_string(object, "reservationHandle")?; + let capability = required_string(object, "capability")?; + let owner_token = required_string(object, "ownerToken")?; + let successor_owner_token = required_string(object, "successorOwnerToken")?; + validate_installation_id(&reservation_handle) + .and_then(|_| validate_installation_id(&capability)) + .and_then(|_| validate_installation_id(&owner_token)) + .and_then(|_| validate_installation_id(&successor_owner_token)) + .map_err(|_| ("invalid_native_input", false))?; + match command { + "conformance_begin_adopt_native_cleanup" => { + Ok(RpcCommand::ConformanceBeginAdoptNativeCleanup { + reservation_handle, + capability, + owner_token, + successor_owner_token, + }) + } + "conformance_commit_adopt_native_cleanup" => { + Ok(RpcCommand::ConformanceCommitAdoptNativeCleanup { + reservation_handle, + capability, + owner_token, + successor_owner_token, + }) + } + _ => Ok(RpcCommand::ConformanceAbortAdoptNativeCleanup { + reservation_handle, + capability, + owner_token, + successor_owner_token, + }), + } + } + "conformance_cancel_prepared_native_cleanup" => { + expect_exact_args(object, &[])?; + Ok(RpcCommand::ConformanceCancelPreparedNativeCleanup) + } + "conformance_delete_native_credential" => { + expect_exact_args(object, &["capability", "ownerToken", "reservationHandle"])?; + let reservation_handle = required_string(object, "reservationHandle")?; + let capability = required_string(object, "capability")?; + let owner_token = required_string(object, "ownerToken")?; + validate_installation_id(&reservation_handle) + .and_then(|_| validate_installation_id(&capability)) + .and_then(|_| validate_installation_id(&owner_token)) + .map_err(|_| ("invalid_native_input", false))?; + Ok(RpcCommand::ConformanceDeleteNativeCredential { + reservation_handle, + capability, + owner_token, + }) + } "cave_list_familiars" => { expect_exact_args(object, &["handle", "operation", "page"])?; Ok(RpcCommand::CaveListFamiliars { @@ -1181,7 +1746,9 @@ pub fn run_stdio() -> io::Result<()> { join_rpc_workers(&mut workers)?; } let (response, shutdown) = runtime.process_request(request); - write_rpc_response(&stdout, &response)?; + let reservation = runtime.take_pending_reservation_response(); + let delivered = write_rpc_response_transaction(&stdout, &response, reservation)?; + runtime.arm_delivered_reservation(delivered); if shutdown { break; } @@ -1190,13 +1757,246 @@ pub fn run_stdio() -> io::Result<()> { .state .cancel_all_operations(NativeCancelReason::Aborted); join_rpc_workers(&mut workers)?; + runtime + .cleanup_armed_reservation() + .map_err(|_| io::Error::other("native reservation cleanup failed"))?; Ok(()) } -fn write_rpc_response( - stdout: &Arc>>, +pub fn run_internal_test_reservation_output_if_requested() -> Option> { + let args = env::args_os().collect::>(); + if args.len() != 2 { + return None; + } + let wait_for_eof = if args[1] == INTERNAL_TEST_RESERVATION_OUTPUT_ARGUMENT { + false + } else if args[1] == INTERNAL_TEST_RESERVATION_EOF_ARGUMENT { + true + } else { + return None; + }; + Some(run_internal_test_reservation_output(wait_for_eof)) +} + +fn run_internal_test_reservation_output(wait_for_eof: bool) -> io::Result<()> { + #[cfg(unix)] + use std::os::unix::fs::OpenOptionsExt; + + if env::var(INTERNAL_TEST_KEYCHAIN_ISOLATED_ENV).as_deref() != Ok("1") { + return Err(io::Error::other("isolated test keychain is not configured")); + } + let result_path = PathBuf::from( + env::var_os(INTERNAL_TEST_RESERVATION_RESULT_PATH_ENV) + .ok_or_else(|| io::Error::other("reservation result path is unavailable"))?, + ); + let allowed_root = PathBuf::from( + env::var_os("CARGO_TARGET_DIR") + .ok_or_else(|| io::Error::other("isolated Cargo target root is unavailable"))?, + ) + .join("phase1-native-rpc-tests"); + let parent = result_path + .parent() + .ok_or_else(|| io::Error::other("reservation result parent is unavailable"))?; + if !result_path.is_absolute() + || result_path.file_name().and_then(|name| name.to_str()) != Some("reservation.json") + || !parent.starts_with(&allowed_root) + || std::fs::canonicalize(parent) + .map(|canonical| !canonical.starts_with(&allowed_root)) + .unwrap_or(true) + { + return Err(io::Error::other("reservation result path is invalid")); + } + + let keyring = Arc::new(NativeKeyring::for_conformance()); + let instance_id = uuid::Uuid::new_v4().to_string(); + let reservation = keyring + .prepare_conformance_cleanup(&instance_id) + .map_err(|_| io::Error::other("test reservation creation failed"))?; + let cleanup: Arc = keyring.clone(); + let rollback = PreparedResponseRollback::new(cleanup.clone(), reservation.clone()); + let control = json!({ + "reservationHandle": reservation.reservation_handle, + "capability": reservation.capability, + "ownerToken": reservation.owner_token, + "instanceId": instance_id, + }); + let mut options = OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + options.mode(0o600); + let mut control_file = options.open(&result_path)?; + serde_json::to_writer(&mut control_file, &control)?; + control_file.write_all(b"\n")?; + control_file.sync_all()?; + + let response = success_response("prepare".to_owned(), control); + let stdout = Arc::new(Mutex::new(io::BufWriter::new(io::stdout()))); + match write_rpc_response_transaction(&stdout, &response, Some(rollback)) { + Ok(Some(rollback)) => { + if wait_for_eof { + keyring + .store_if_current( + &instance_id, + "http://127.0.0.1:4310/", + None, + "test-bearer", + "test-credential", + ) + .map_err(|_| io::Error::other("test credential storage failed"))?; + let mut input = Vec::new(); + io::stdin().read_to_end(&mut input)?; + } + rollback + .rollback() + .map_err(|_| io::Error::other("test reservation cleanup failed")) + } + Ok(None) => Err(io::Error::other("test reservation guard was unavailable")), + Err(error) => Err(error), + } +} + +struct PreparedResponseRollback { + cleanup: Arc, + reservation: ConformanceCleanupReservation, + mode: ReservationGuardMode, + armed: bool, +} + +enum ReservationGuardMode { + Active, + Pending { predecessor_owner_token: String }, +} + +impl PreparedResponseRollback { + fn new( + cleanup: Arc, + reservation: ConformanceCleanupReservation, + ) -> Self { + Self::new_active(cleanup, reservation) + } + + fn new_active( + cleanup: Arc, + reservation: ConformanceCleanupReservation, + ) -> Self { + Self { + cleanup, + reservation, + mode: ReservationGuardMode::Active, + armed: true, + } + } + + fn new_pending( + cleanup: Arc, + reservation: ConformanceCleanupReservation, + predecessor_owner_token: String, + ) -> Self { + Self { + cleanup, + reservation, + mode: ReservationGuardMode::Pending { + predecessor_owner_token, + }, + armed: true, + } + } + + fn disarm(mut self) { + self.armed = false; + } + + fn matches(&self, handle: &str, capability: &str, owner_token: &str) -> bool { + self.reservation.reservation_handle == handle + && self.reservation.capability == capability + && self.reservation.owner_token == owner_token + } + + fn rollback(mut self) -> Result<(), KeyringError> { + let result = match &self.mode { + ReservationGuardMode::Active => self + .cleanup + .cleanup( + &self.reservation.reservation_handle, + &self.reservation.capability, + &self.reservation.owner_token, + ) + .map(|_| ()), + ReservationGuardMode::Pending { + predecessor_owner_token, + } => self.cleanup.abort_adopt( + &self.reservation.reservation_handle, + &self.reservation.capability, + predecessor_owner_token, + &self.reservation.owner_token, + ), + }; + if result.is_ok() { + self.armed = false; + } + result + } +} + +impl Drop for PreparedResponseRollback { + fn drop(&mut self) { + if self.armed { + let _ = match &self.mode { + ReservationGuardMode::Active => self + .cleanup + .cleanup( + &self.reservation.reservation_handle, + &self.reservation.capability, + &self.reservation.owner_token, + ) + .map(|_| ()), + ReservationGuardMode::Pending { + predecessor_owner_token, + } => self.cleanup.abort_adopt( + &self.reservation.reservation_handle, + &self.reservation.capability, + predecessor_owner_token, + &self.reservation.owner_token, + ), + }; + } + } +} + +fn write_rpc_response_transaction( + stdout: &Arc>, response: &Value, + rollback: Option, +) -> io::Result> { + match write_rpc_response(stdout, response) { + Ok(()) => Ok(rollback), + Err(write_error) => { + if let Some(rollback) = rollback { + rollback.rollback().map_err(|_| { + io::Error::other("native cleanup reservation output rollback failed") + })?; + } + Err(write_error) + } + } +} + +#[cfg(test)] +fn write_prepared_response_for_test( + stdout: &Arc>, + response: Value, + cleanup: Arc, + reservation: ConformanceCleanupReservation, ) -> io::Result<()> { + write_rpc_response_transaction( + stdout, + &response, + Some(PreparedResponseRollback::new(cleanup, reservation)), + ) + .map(|_| ()) +} + +fn write_rpc_response(stdout: &Arc>, response: &Value) -> io::Result<()> { let mut stdout = stdout .lock() .map_err(|_| io::Error::other("RPC stdout lock was poisoned"))?; @@ -1238,13 +2038,16 @@ mod tests { use super::{ parse_command, parse_request_line, read_bounded_line, BoundedLine, ConformanceCaveLauncher, - RpcCommand, RpcRuntime, SharedMemoryCredentialCustody, CONFORMANCE_INSTALLATION_ID, + ConformanceCredentialCleanup, RpcCommand, RpcRuntime, SharedMemoryCredentialCustody, + CONFORMANCE_INSTALLATION_ID, CONFORMANCE_NATIVE_PROVIDER_PRESET_ENV, CONFORMANCE_NODE_PATH_ENV, INVALID_REQUEST_ID, MAX_LINE_BYTES, }; use crate::{ - cave::{CaveLauncher, NativeDiagnostic, NativeResult}, - coven::{CovenHealth, CovenHealthResult}, - keyring::{Credential, CredentialCustody, CredentialSlot, KeyringError}, + cave::CaveLauncher, + keyring::{ + ConformanceCleanupOutcome, ConformanceCleanupReservation, Credential, + CredentialCustody, CredentialSlot, KeyringError, + }, }; use serde_json::json; @@ -1253,8 +2056,101 @@ mod tests { const SECOND_ORIGIN: &str = "http://127.0.0.1:4320/"; const FIRST_OPERATION_ATTEMPT: &str = "op1-1787900000000-1-00000000000000000000000000000000"; const SECOND_OPERATION_ATTEMPT: &str = "op1-1787900000000-2-11111111111111111111111111111111"; + const OWNER_TOKEN: &str = "00000000-0000-4000-8000-000000000004"; static ENVIRONMENT_LOCK: OnceLock> = OnceLock::new(); + struct RecordingEmergencyCleanup { + cleanup_inputs: Mutex>, + present: Mutex, + expected_handle: String, + expected_capability: String, + expected_owner_token: String, + } + + impl ConformanceCredentialCleanup for RecordingEmergencyCleanup { + fn prepare( + &self, + _instance_id: &str, + ) -> Result { + Err(KeyringError::Failure) + } + + fn begin_adopt( + &self, + _handle: &str, + _capability: &str, + _owner_token: &str, + _successor_owner_token: &str, + ) -> Result { + Err(KeyringError::Failure) + } + + fn commit_adopt( + &self, + _handle: &str, + _capability: &str, + _owner_token: &str, + _successor_owner_token: &str, + ) -> Result<(), KeyringError> { + Err(KeyringError::Failure) + } + + fn abort_adopt( + &self, + _handle: &str, + _capability: &str, + _owner_token: &str, + _successor_owner_token: &str, + ) -> Result<(), KeyringError> { + Ok(()) + } + + fn cancel_prepared(&self) -> Result<(), KeyringError> { + *self.present.lock().unwrap() = false; + Ok(()) + } + + fn cleanup( + &self, + handle: &str, + capability: &str, + owner_token: &str, + ) -> Result { + self.cleanup_inputs + .lock() + .unwrap() + .push((handle.to_owned(), capability.to_owned())); + let mut present = self.present.lock().unwrap(); + if !*present + || handle != self.expected_handle + || capability != self.expected_capability + || owner_token != self.expected_owner_token + { + return Err(KeyringError::Failure); + } + *present = false; + Ok(ConformanceCleanupOutcome::Deleted) + } + } + + struct BrokenPipeWriter; + + impl std::io::Write for BrokenPipeWriter { + fn write(&mut self, _buffer: &[u8]) -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "closed output", + )) + } + + fn flush(&mut self) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "closed output", + )) + } + } + #[test] fn parses_the_bounded_coven_health_rpc_command() { let command = parse_command( @@ -1272,16 +2168,190 @@ mod tests { } #[test] - fn dispatches_coven_health_through_the_bounded_rpc_operation() { - struct ReconcileRequiredCovenHealth; + fn emergency_native_credential_cleanup_is_exact_and_production_gated() { + const HANDLE: &str = "00000000-0000-4000-8000-000000000001"; + const CAPABILITY: &str = "00000000-0000-4000-8000-000000000002"; + let cleanup = Arc::new(RecordingEmergencyCleanup { + cleanup_inputs: Mutex::new(Vec::new()), + present: Mutex::new(true), + expected_handle: HANDLE.to_owned(), + expected_capability: CAPABILITY.to_owned(), + expected_owner_token: OWNER_TOKEN.to_owned(), + }); + let crashed_runtime = RpcRuntime::with_custody( + Arc::new(SharedMemoryCredentialCustody::new()), + Some(cleanup.clone()), + ); + drop(crashed_runtime); + let mut runtime = RpcRuntime::with_custody( + Arc::new(SharedMemoryCredentialCustody::new()), + Some(cleanup.clone()), + ); - impl CovenHealth for ReconcileRequiredCovenHealth { - fn health(&self) -> NativeResult { - Err(NativeDiagnostic::new("reconcile_required", false)) - } - } + let forged = runtime.process_line( + format!( + r#"{{"id":"forged","command":"conformance_delete_native_credential","args":{{"reservationHandle":"{HANDLE}","capability":"00000000-0000-4000-8000-000000000003","ownerToken":"{OWNER_TOKEN}"}}}}"# + ) + .as_bytes(), + ); + assert_eq!(forged["error"]["code"], "keychain_failure"); - let mut runtime = RpcRuntime::with_coven_health(Arc::new(ReconcileRequiredCovenHealth)); + let response = runtime.process_line( + format!( + r#"{{"id":"cleanup","command":"conformance_delete_native_credential","args":{{"reservationHandle":"{HANDLE}","capability":"{CAPABILITY}","ownerToken":"{OWNER_TOKEN}"}}}}"# + ) + .as_bytes(), + ); + assert_eq!( + response, + json!({"id":"cleanup","ok":true,"result":{"status":"missing"}}) + ); + assert_eq!( + cleanup.cleanup_inputs.lock().unwrap().last(), + Some(&(HANDLE.to_owned(), CAPABILITY.to_owned())) + ); + assert!(!*cleanup.present.lock().unwrap()); + let replay = runtime.process_line( + format!( + r#"{{"id":"replay","command":"conformance_delete_native_credential","args":{{"reservationHandle":"{HANDLE}","capability":"{CAPABILITY}","ownerToken":"{OWNER_TOKEN}"}}}}"# + ) + .as_bytes(), + ); + assert_eq!(replay["error"]["code"], "keychain_failure"); + + let mut unprivileged = RpcRuntime::new(); + let rejected = unprivileged.process_line( + format!( + r#"{{"id":"cleanup","command":"conformance_delete_native_credential","args":{{"reservationHandle":"{HANDLE}","capability":"{CAPABILITY}","ownerToken":"{OWNER_TOKEN}"}}}}"# + ) + .as_bytes(), + ); + assert_eq!( + rejected, + json!({ + "id":"cleanup", + "ok":false, + "error":{"code":"invalid_native_input","retryable":false} + }) + ); + let invalid = runtime.process_line( + br#"{"id":"cleanup-invalid","command":"conformance_delete_native_credential","args":{"reservationHandle":"not-a-uuid","capability":"also-invalid","ownerToken":"invalid"}}"#, + ); + assert_eq!(invalid["error"]["code"], "invalid_native_input"); + } + + #[test] + fn prepared_native_cleanup_can_be_canceled_without_response_capability() { + let cleanup = Arc::new(RecordingEmergencyCleanup { + cleanup_inputs: Mutex::new(Vec::new()), + present: Mutex::new(true), + expected_handle: "00000000-0000-4000-8000-000000000001".to_owned(), + expected_capability: "00000000-0000-4000-8000-000000000002".to_owned(), + expected_owner_token: OWNER_TOKEN.to_owned(), + }); + let mut runtime = RpcRuntime::with_custody( + Arc::new(SharedMemoryCredentialCustody::new()), + Some(cleanup.clone()), + ); + + let response = runtime.process_line( + br#"{"id":"cancel","command":"conformance_cancel_prepared_native_cleanup"}"#, + ); + assert_eq!( + response, + json!({"id":"cancel","ok":true,"result":{"status":"missing"}}) + ); + assert!(!*cleanup.present.lock().unwrap()); + + let mut unprivileged = RpcRuntime::new(); + let rejected = unprivileged.process_line( + br#"{"id":"cancel","command":"conformance_cancel_prepared_native_cleanup"}"#, + ); + assert_eq!(rejected["error"]["code"], "invalid_native_input"); + } + + #[test] + fn native_rpc_broken_output_rolls_back_unacknowledged_reservation() { + const HANDLE: &str = "00000000-0000-4000-8000-000000000001"; + const CAPABILITY: &str = "00000000-0000-4000-8000-000000000002"; + let cleanup = Arc::new(RecordingEmergencyCleanup { + cleanup_inputs: Mutex::new(Vec::new()), + present: Mutex::new(true), + expected_handle: HANDLE.to_owned(), + expected_capability: CAPABILITY.to_owned(), + expected_owner_token: OWNER_TOKEN.to_owned(), + }); + let output = Arc::new(Mutex::new(BrokenPipeWriter)); + let error = super::write_prepared_response_for_test( + &output, + json!({ + "id":"prepare", + "ok":true, + "result":{"reservationHandle":HANDLE,"capability":CAPABILITY,"ownerToken":OWNER_TOKEN} + }), + cleanup.clone(), + ConformanceCleanupReservation { + reservation_handle: HANDLE.to_owned(), + capability: CAPABILITY.to_owned(), + owner_token: OWNER_TOKEN.to_owned(), + }, + ) + .expect_err("closed native RPC output must fail"); + + assert_eq!(error.kind(), std::io::ErrorKind::BrokenPipe); + assert!(!*cleanup.present.lock().unwrap()); + assert!(cleanup.cleanup(HANDLE, CAPABILITY, OWNER_TOKEN).is_err()); + assert_eq!(cleanup.cleanup_inputs.lock().unwrap().len(), 2); + } + + #[test] + fn native_rpc_runtime_drop_cleans_an_acknowledged_reservation() { + const HANDLE: &str = "00000000-0000-4000-8000-000000000001"; + const CAPABILITY: &str = "00000000-0000-4000-8000-000000000002"; + let cleanup = Arc::new(RecordingEmergencyCleanup { + cleanup_inputs: Mutex::new(Vec::new()), + present: Mutex::new(true), + expected_handle: HANDLE.to_owned(), + expected_capability: CAPABILITY.to_owned(), + expected_owner_token: OWNER_TOKEN.to_owned(), + }); + let output = Arc::new(Mutex::new(Vec::::new())); + let delivered = super::write_rpc_response_transaction( + &output, + &json!({"id":"prepare","ok":true,"result":{}}), + Some(super::PreparedResponseRollback::new( + cleanup.clone(), + ConformanceCleanupReservation { + reservation_handle: HANDLE.to_owned(), + capability: CAPABILITY.to_owned(), + owner_token: OWNER_TOKEN.to_owned(), + }, + )), + ) + .expect("reservation response must be acknowledged"); + let mut runtime = RpcRuntime::with_custody( + Arc::new(SharedMemoryCredentialCustody::new()), + Some(cleanup.clone()), + ); + runtime.arm_delivered_reservation(delivered); + drop(runtime); + + assert!(!*cleanup.present.lock().unwrap()); + assert_eq!(cleanup.cleanup_inputs.lock().unwrap().len(), 1); + } + + #[test] + fn dispatches_coven_health_through_the_bounded_rpc_operation() { + let _environment = ENVIRONMENT_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let original = env::var_os("COVEN_HOME"); + env::set_var( + "COVEN_HOME", + env::current_dir().unwrap().join("missing-coven-health"), + ); + let mut runtime = RpcRuntime::new(); let response = runtime.process_line( format!( @@ -1296,11 +2366,32 @@ mod tests { "id": "coven", "ok": false, "error": { - "code": "reconcile_required", - "retryable": false + "code": "service_unavailable", + "retryable": true } }) ); + match original { + Some(value) => env::set_var("COVEN_HOME", value), + None => env::remove_var("COVEN_HOME"), + } + } + + #[test] + fn accepts_the_production_keyring_conformance_preset() { + let _environment = ENVIRONMENT_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let original = env::var_os(CONFORMANCE_NATIVE_PROVIDER_PRESET_ENV); + env::set_var(CONFORMANCE_NATIVE_PROVIDER_PRESET_ENV, "production-keyring"); + + assert!(RpcRuntime::from_environment().is_ok()); + + match original { + Some(value) => env::set_var(CONFORMANCE_NATIVE_PROVIDER_PRESET_ENV, value), + None => env::remove_var(CONFORMANCE_NATIVE_PROVIDER_PRESET_ENV), + } } struct ScopedCaveHome(Option); @@ -1653,6 +2744,106 @@ mod tests { } } + #[cfg(windows)] + #[test] + fn windows_job_close_terminates_cave_descendants_without_touching_unrelated_processes() { + use std::{ + fs, + path::PathBuf, + process::Command, + thread, + time::{Duration, Instant}, + }; + use windows_sys::Win32::{ + Foundation::{CloseHandle, WAIT_OBJECT_0}, + System::Threading::{OpenProcess, WaitForSingleObject}, + }; + + fn exited(pid: u32) -> bool { + const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000; + let process = unsafe { OpenProcess(SYNCHRONIZE_ACCESS, 0, pid) }; + if process.is_null() { + return true; + } + let result = unsafe { WaitForSingleObject(process, 0) }; + unsafe { + CloseHandle(process); + } + result == WAIT_OBJECT_0 + } + + let _environment = ENVIRONMENT_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let node_output = Command::new("where.exe") + .arg("node.exe") + .output() + .expect("node lookup must run"); + assert!(node_output.status.success(), "node must be available"); + let node = PathBuf::from( + String::from_utf8(node_output.stdout) + .unwrap() + .lines() + .next() + .unwrap(), + ); + let root = env::temp_dir().join(format!("chat-job-{}", uuid::Uuid::new_v4())); + fs::create_dir(&root).unwrap(); + let descendant_path = root.join("descendant.pid"); + let script = root.join("server.cjs"); + fs::write( + &script, + format!( + "const{{spawn}}=require('node:child_process');const{{writeFileSync}}=require('node:fs');const c=spawn(process.execPath,['-e','setInterval(()=>{{}},1000)'],{{stdio:'ignore'}});writeFileSync({},String(c.pid));setInterval(()=>{{}},1000);", + serde_json::to_string(&descendant_path).unwrap() + ), + ) + .unwrap(); + let original_node = env::var_os(CONFORMANCE_NODE_PATH_ENV); + let original_server = env::var_os(super::CONFORMANCE_CAVE_SERVER_PATH_ENV); + env::set_var(CONFORMANCE_NODE_PATH_ENV, &node); + env::set_var(super::CONFORMANCE_CAVE_SERVER_PATH_ENV, &script); + let cave = ConformanceCaveLauncher + .launch() + .expect("Cave child must launch"); + let mut unrelated = Command::new(&node) + .args(["-e", "setInterval(()=>{},1000)"]) + .spawn() + .expect("unrelated process must launch"); + let deadline = Instant::now() + Duration::from_secs(5); + while !descendant_path.exists() { + assert!( + Instant::now() < deadline, + "descendant PID must be published" + ); + thread::sleep(Duration::from_millis(10)); + } + let descendant_pid = fs::read_to_string(&descendant_path) + .unwrap() + .parse::() + .unwrap(); + + drop(cave); + + while !exited(descendant_pid) { + assert!(Instant::now() < deadline, "job descendant must terminate"); + thread::sleep(Duration::from_millis(10)); + } + assert!(unrelated.try_wait().unwrap().is_none()); + unrelated.kill().unwrap(); + unrelated.wait().unwrap(); + match original_node { + Some(value) => env::set_var(CONFORMANCE_NODE_PATH_ENV, value), + None => env::remove_var(CONFORMANCE_NODE_PATH_ENV), + } + match original_server { + Some(value) => env::set_var(super::CONFORMANCE_CAVE_SERVER_PATH_ENV, value), + None => env::remove_var(super::CONFORMANCE_CAVE_SERVER_PATH_ENV), + } + fs::remove_dir_all(root).unwrap(); + } + #[test] fn runtime_uses_native_discovery_and_can_reset_without_touching_a_keyring() { let _environment = ENVIRONMENT_LOCK diff --git a/src-tauri/src/connection.rs b/src-tauri/src/connection.rs index 9584aad..65ea880 100644 --- a/src-tauri/src/connection.rs +++ b/src-tauri/src/connection.rs @@ -625,6 +625,12 @@ fn start_launch_worker( } impl NativeConnectionState { + #[cfg(feature = "phase1-conformance")] + pub(crate) fn conformance_authorized_instance_id(&self, handle: &str) -> NativeResult { + let (_, _, instance_id) = self.runtime()?.require_authorized(handle)?; + Ok(instance_id) + } + #[cfg(test)] pub(crate) fn cave_read_discovery(&self) -> NativeResult { let attempt = self.runtime()?.begin_discovery_attempt()?; diff --git a/src-tauri/src/coven.rs b/src-tauri/src/coven.rs index b904e5e..5535925 100644 --- a/src-tauri/src/coven.rs +++ b/src-tauri/src/coven.rs @@ -651,6 +651,48 @@ mod tests { } } + #[test] + fn wrong_owner_discovery_failure_maps_to_fail_closed_diagnostic() { + let canary = "wrong owner private cause"; + let diagnostic = map_client_error(ClientError::Discovery(canary.to_owned())); + + assert_eq!( + diagnostic, + super::NativeDiagnostic::new("reconcile_required", false) + ); + assert!(!serde_json::to_string(&diagnostic).unwrap().contains(canary)); + } + + #[test] + fn unix_peer_provider_failure_maps_to_fail_closed_diagnostic() { + let canary = "peer provider private cause"; + let diagnostic = map_client_error(ClientError::Io { + operation: "failed to inspect connected peer credentials", + source: std::io::Error::other(canary), + }); + + assert_eq!( + diagnostic, + super::NativeDiagnostic::new("service_unavailable", true) + ); + assert!(!serde_json::to_string(&diagnostic).unwrap().contains(canary)); + } + + #[test] + fn windows_ownership_provider_failure_maps_to_fail_closed_diagnostic() { + let canary = "ownership provider private cause"; + let diagnostic = map_client_error(ClientError::Io { + operation: "failed to inspect named pipe ownership", + source: std::io::Error::other(canary), + }); + + assert_eq!( + diagnostic, + super::NativeDiagnostic::new("service_unavailable", true) + ); + assert!(!serde_json::to_string(&diagnostic).unwrap().contains(canary)); + } + #[test] fn explicit_home_wins_and_missing_platform_home_fails_closed() { let explicit = PathBuf::from("explicit-coven-home"); @@ -817,7 +859,7 @@ mod tests { assert!(launcher.launches.lock().unwrap().is_empty()); } - #[cfg(unix)] + #[cfg(any(target_os = "macos", target_os = "linux"))] #[test] fn owner_current_connected_socket_health_succeeds() { use std::{ @@ -827,11 +869,19 @@ mod tests { thread, }; - let root = std::env::current_dir() - .unwrap() - .join(format!(".c{}", std::process::id())); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root).unwrap(); + #[cfg(target_os = "macos")] + let short_parent = std::path::Path::new("/Users/Shared"); + #[cfg(target_os = "linux")] + let short_parent = std::path::Path::new("/dev/shm"); + struct OwnedDirectory(std::path::PathBuf); + impl Drop for OwnedDirectory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let root = short_parent.join(format!(".oc-coven-{}", uuid::Uuid::new_v4())); + fs::create_dir(&root).unwrap(); + let _owned_root = OwnedDirectory(root.clone()); fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); let socket = root.join("coven.sock"); let listener = UnixListener::bind(&socket).unwrap(); @@ -855,6 +905,5 @@ mod tests { let health = DirectCovenHealth::with_home(Arc::new(move || Ok(resolver_root.clone()))); assert_eq!(health.health().unwrap(), CovenHealthResult { status: "ok" }); server.join().unwrap(); - fs::remove_dir_all(&root).unwrap(); } } diff --git a/src-tauri/src/keyring.rs b/src-tauri/src/keyring.rs index a33c686..b611686 100644 --- a/src-tauri/src/keyring.rs +++ b/src-tauri/src/keyring.rs @@ -9,7 +9,7 @@ use std::{ use std::marker::PhantomData; use std::sync::OnceLock; -#[cfg(any(windows, test))] +#[cfg(any(windows, test, feature = "phase1-conformance"))] use sha2::{Digest, Sha256}; #[cfg(unix)] @@ -28,6 +28,12 @@ const INSTALLATION_ID_ACCOUNT: &str = "installation-id-v1"; #[cfg(unix)] const CREDENTIAL_LOCK_TIMEOUT: Duration = Duration::from_secs(5); const MAX_CREDENTIAL_RECORD_BYTES: usize = 4 * 1024; +#[cfg(feature = "phase1-conformance")] +const CONFORMANCE_CLEANUP_SERVICE: &str = "ai.opencoven.chat.conformance-cleanup"; +#[cfg(feature = "phase1-conformance")] +const CONFORMANCE_CLEANUP_ACCOUNT_PREFIX: &str = "cleanup-reservation-v1"; +#[cfg(feature = "phase1-conformance")] +const CONFORMANCE_HARNESS_IDENTITY: &str = "phase1-native-rpc-v1"; static STORE_INITIALIZED: OnceLock<()> = OnceLock::new(); @@ -128,6 +134,51 @@ pub(crate) trait CredentialCustody: Send + Sync { pub(crate) struct NativeKeyring { #[cfg(feature = "phase1-conformance")] provider_preset: Option, + #[cfg(feature = "phase1-conformance")] + reservation_required: bool, + #[cfg(feature = "phase1-conformance")] + prepared_instance: std::sync::Arc>>, + #[cfg(feature = "phase1-conformance")] + prepared_cleanup: std::sync::Arc>>, +} + +#[cfg(feature = "phase1-conformance")] +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ConformanceCleanupReservation { + pub(crate) reservation_handle: String, + pub(crate) capability: String, + pub(crate) owner_token: String, +} + +#[cfg(feature = "phase1-conformance")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ConformanceCleanupOutcome { + Deleted, + Transferred, +} + +#[cfg(feature = "phase1-conformance")] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ConformanceCleanupMarker { + version: u8, + store_schema_version: u8, + run_id: String, + harness_identity: String, + instance_id: String, + target_account: String, + capability_sha256: String, + owner_token: String, + pending_owner_token: Option, +} + +#[cfg(feature = "phase1-conformance")] +fn conformance_sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() } #[cfg(feature = "phase1-conformance")] @@ -141,6 +192,14 @@ impl NativeKeyring { pub(crate) fn with_provider_preset(preset: NativeProviderPreset) -> Self { Self { provider_preset: Some(preset), + ..Self::default() + } + } + + pub(crate) fn for_conformance() -> Self { + Self { + reservation_required: true, + ..Self::default() } } @@ -150,6 +209,344 @@ impl NativeKeyring { None => Ok(()), } } + + fn conformance_reservation_entry(handle: &str) -> Result { + validate_installation_id(handle)?; + Self::entry_for( + CONFORMANCE_CLEANUP_SERVICE, + &format!("{CONFORMANCE_CLEANUP_ACCOUNT_PREFIX}:{handle}"), + ) + } + + pub(crate) fn prepare_conformance_cleanup( + &self, + instance_id: &str, + ) -> Result { + self.reject_provider_if_configured()?; + validate_installation_id(instance_id)?; + let _guard = acquire_mutation_lock()?; + match Self::credential_entry(instance_id)?.get_secret() { + Err(KeyringBackendError::NoEntry) => {} + Ok(mut value) => { + value.zeroize(); + return Err(KeyringError::Failure); + } + Err(error) => return Err(map_keyring_error(error)), + } + if self + .prepared_instance + .lock() + .map_err(|_| KeyringError::Failure)? + .is_some() + { + return Err(KeyringError::Failure); + } + let handle = Uuid::new_v4().to_string(); + let capability = Uuid::new_v4().to_string(); + let owner_token = Uuid::new_v4().to_string(); + let entry = Self::conformance_reservation_entry(&handle)?; + match entry.get_secret() { + Err(KeyringBackendError::NoEntry) => {} + Ok(mut value) => { + value.zeroize(); + return Err(KeyringError::Failure); + } + Err(error) => return Err(map_keyring_error(error)), + } + let marker = ConformanceCleanupMarker { + version: 2, + store_schema_version: 1, + run_id: handle.clone(), + harness_identity: CONFORMANCE_HARNESS_IDENTITY.to_owned(), + instance_id: instance_id.to_owned(), + target_account: format!("{CREDENTIAL_ACCOUNT_PREFIX}:{instance_id}"), + capability_sha256: conformance_sha256_hex(capability.as_bytes()), + owner_token: owner_token.clone(), + pending_owner_token: None, + }; + let bytes = Zeroizing::new(serde_json::to_vec(&marker).map_err(|_| KeyringError::Failure)?); + entry + .set_secret(bytes.as_slice()) + .map_err(map_keyring_error)?; + let reservation = ConformanceCleanupReservation { + reservation_handle: handle, + capability, + owner_token, + }; + *self + .prepared_cleanup + .lock() + .map_err(|_| KeyringError::Failure)? = Some(reservation.clone()); + *self + .prepared_instance + .lock() + .map_err(|_| KeyringError::Failure)? = Some(instance_id.to_owned()); + Ok(reservation) + } + + pub(crate) fn cleanup_conformance_credential( + &self, + handle: &str, + capability: &str, + owner_token: &str, + ) -> Result { + self.reject_provider_if_configured()?; + validate_installation_id(handle)?; + validate_installation_id(capability)?; + validate_installation_id(owner_token)?; + let _guard = acquire_mutation_lock()?; + let marker_entry = Self::conformance_reservation_entry(handle)?; + let marker_bytes = match marker_entry.get_secret() { + Ok(value) => Zeroizing::new(value), + Err(KeyringBackendError::NoEntry) => return Err(KeyringError::NotFound), + Err(error) => return Err(map_keyring_error(error)), + }; + let marker: ConformanceCleanupMarker = + serde_json::from_slice(marker_bytes.as_slice()).map_err(|_| KeyringError::Failure)?; + validate_installation_id(&marker.instance_id)?; + validate_installation_id(&marker.owner_token)?; + if marker.version != 2 + || marker.store_schema_version != 1 + || marker.run_id != handle + || marker.harness_identity != CONFORMANCE_HARNESS_IDENTITY + || marker.target_account + != format!("{CREDENTIAL_ACCOUNT_PREFIX}:{}", marker.instance_id) + || marker.capability_sha256 != conformance_sha256_hex(capability.as_bytes()) + { + return Err(KeyringError::Failure); + } + if marker.owner_token != owner_token { + return Ok(ConformanceCleanupOutcome::Transferred); + } + let target = Self::credential_entry(&marker.instance_id)?; + match target.delete_credential() { + Ok(()) | Err(KeyringBackendError::NoEntry) => {} + Err(error) => return Err(map_keyring_error(error)), + } + match target.get_secret() { + Err(KeyringBackendError::NoEntry) => {} + Ok(mut value) => { + value.zeroize(); + return Err(KeyringError::Failure); + } + Err(error) => return Err(map_keyring_error(error)), + } + marker_entry + .delete_credential() + .map_err(map_keyring_error)?; + match marker_entry.get_secret() { + Err(KeyringBackendError::NoEntry) => { + *self + .prepared_instance + .lock() + .map_err(|_| KeyringError::Failure)? = None; + let mut prepared_cleanup = self + .prepared_cleanup + .lock() + .map_err(|_| KeyringError::Failure)?; + if prepared_cleanup + .as_ref() + .is_some_and(|prepared| prepared.reservation_handle == handle) + { + *prepared_cleanup = None; + } + Ok(ConformanceCleanupOutcome::Deleted) + } + + Ok(mut value) => { + value.zeroize(); + Err(KeyringError::Failure) + } + Err(error) => Err(map_keyring_error(error)), + } + } + + pub(crate) fn begin_adopt_conformance_cleanup( + &self, + handle: &str, + capability: &str, + owner_token: &str, + successor_owner_token: &str, + ) -> Result { + self.reject_provider_if_configured()?; + validate_installation_id(handle)?; + validate_installation_id(capability)?; + validate_installation_id(owner_token)?; + validate_installation_id(successor_owner_token)?; + let _guard = acquire_mutation_lock()?; + let marker_entry = Self::conformance_reservation_entry(handle)?; + let marker_bytes = match marker_entry.get_secret() { + Ok(value) => Zeroizing::new(value), + Err(KeyringBackendError::NoEntry) => return Err(KeyringError::NotFound), + Err(error) => return Err(map_keyring_error(error)), + }; + let mut marker: ConformanceCleanupMarker = + serde_json::from_slice(marker_bytes.as_slice()).map_err(|_| KeyringError::Failure)?; + validate_installation_id(&marker.instance_id)?; + validate_installation_id(&marker.owner_token)?; + if marker.version != 2 + || marker.store_schema_version != 1 + || marker.run_id != handle + || marker.harness_identity != CONFORMANCE_HARNESS_IDENTITY + || marker.target_account + != format!("{CREDENTIAL_ACCOUNT_PREFIX}:{}", marker.instance_id) + || marker.capability_sha256 != conformance_sha256_hex(capability.as_bytes()) + || marker.owner_token != owner_token + { + return Err(KeyringError::Failure); + } + if let Some(pending) = marker.pending_owner_token.as_deref() { + if pending == successor_owner_token { + return Ok(ConformanceCleanupReservation { + reservation_handle: handle.to_owned(), + capability: capability.to_owned(), + owner_token: successor_owner_token.to_owned(), + }); + } + return Err(KeyringError::Failure); + } + match Self::credential_entry(&marker.instance_id)?.get_secret() { + Ok(mut value) => value.zeroize(), + Err(error) => return Err(map_keyring_error(error)), + } + marker.pending_owner_token = Some(successor_owner_token.to_owned()); + let bytes = Zeroizing::new(serde_json::to_vec(&marker).map_err(|_| KeyringError::Failure)?); + marker_entry + .set_secret(bytes.as_slice()) + .map_err(map_keyring_error)?; + Ok(ConformanceCleanupReservation { + reservation_handle: handle.to_owned(), + capability: capability.to_owned(), + owner_token: successor_owner_token.to_owned(), + }) + } + + pub(crate) fn commit_adopt_conformance_cleanup( + &self, + handle: &str, + capability: &str, + owner_token: &str, + successor_owner_token: &str, + ) -> Result<(), KeyringError> { + self.reject_provider_if_configured()?; + for value in [handle, capability, owner_token, successor_owner_token] { + validate_installation_id(value)?; + } + let _guard = acquire_mutation_lock()?; + let entry = Self::conformance_reservation_entry(handle)?; + let bytes = match entry.get_secret() { + Ok(value) => Zeroizing::new(value), + Err(error) => return Err(map_keyring_error(error)), + }; + let mut marker: ConformanceCleanupMarker = + serde_json::from_slice(bytes.as_slice()).map_err(|_| KeyringError::Failure)?; + if marker.capability_sha256 != conformance_sha256_hex(capability.as_bytes()) { + return Err(KeyringError::Failure); + } + let already_committed = + marker.owner_token == successor_owner_token && marker.pending_owner_token.is_none(); + if !already_committed { + if marker.owner_token != owner_token + || marker.pending_owner_token.as_deref() != Some(successor_owner_token) + { + return Err(KeyringError::Failure); + } + marker.owner_token = successor_owner_token.to_owned(); + marker.pending_owner_token = None; + let bytes = + Zeroizing::new(serde_json::to_vec(&marker).map_err(|_| KeyringError::Failure)?); + entry + .set_secret(bytes.as_slice()) + .map_err(map_keyring_error)?; + } + let reservation = ConformanceCleanupReservation { + reservation_handle: handle.to_owned(), + capability: capability.to_owned(), + owner_token: successor_owner_token.to_owned(), + }; + *self + .prepared_cleanup + .lock() + .map_err(|_| KeyringError::Failure)? = Some(reservation); + *self + .prepared_instance + .lock() + .map_err(|_| KeyringError::Failure)? = Some(marker.instance_id); + Ok(()) + } + + pub(crate) fn abort_adopt_conformance_cleanup( + &self, + handle: &str, + capability: &str, + owner_token: &str, + successor_owner_token: &str, + ) -> Result<(), KeyringError> { + self.reject_provider_if_configured()?; + for value in [handle, capability, owner_token, successor_owner_token] { + validate_installation_id(value)?; + } + let _guard = acquire_mutation_lock()?; + let entry = Self::conformance_reservation_entry(handle)?; + let bytes = match entry.get_secret() { + Ok(value) => Zeroizing::new(value), + Err(KeyringBackendError::NoEntry) => return Ok(()), + Err(error) => return Err(map_keyring_error(error)), + }; + let mut marker: ConformanceCleanupMarker = + serde_json::from_slice(bytes.as_slice()).map_err(|_| KeyringError::Failure)?; + if marker.capability_sha256 != conformance_sha256_hex(capability.as_bytes()) { + return Err(KeyringError::Failure); + } + if marker.owner_token == successor_owner_token && marker.pending_owner_token.is_none() { + return Ok(()); + } + if marker.owner_token != owner_token { + return Err(KeyringError::Failure); + } + if marker.pending_owner_token.as_deref() == Some(successor_owner_token) { + marker.pending_owner_token = None; + let bytes = + Zeroizing::new(serde_json::to_vec(&marker).map_err(|_| KeyringError::Failure)?); + entry + .set_secret(bytes.as_slice()) + .map_err(map_keyring_error)?; + } + Ok(()) + } + + pub(crate) fn cancel_prepared_conformance_cleanup(&self) -> Result<(), KeyringError> { + self.reject_provider_if_configured()?; + let prepared = self + .prepared_cleanup + .lock() + .map_err(|_| KeyringError::Failure)? + .clone(); + match prepared { + Some(reservation) => self.cleanup_conformance_credential( + &reservation.reservation_handle, + &reservation.capability, + &reservation.owner_token, + ), + None => Ok(ConformanceCleanupOutcome::Deleted), + } + .map(|_| ()) + } + + fn require_prepared_instance(&self, instance_id: &str) -> Result<(), KeyringError> { + if !self.reservation_required { + return Ok(()); + } + match self + .prepared_instance + .lock() + .map_err(|_| KeyringError::Failure)? + .as_deref() + { + Some(prepared) if prepared == instance_id => Ok(()), + _ => Err(KeyringError::Failure), + } + } } #[cfg(unix)] @@ -165,15 +562,23 @@ struct CredentialMutationGuard { } #[cfg(unix)] -fn credential_lock_path() -> Result { +fn default_credential_lock_root(home: &std::path::Path) -> std::path::PathBuf { + home.join(".coven").join("chat") +} + +#[cfg(unix)] +fn credential_lock_path_for_root( + root: &std::path::Path, +) -> Result { use std::os::unix::fs::{MetadataExt, PermissionsExt}; - let home = env::var_os("HOME").ok_or(KeyringError::Unavailable)?; - let root = std::path::PathBuf::from(home).join(".coven").join("chat"); - fs::create_dir_all(&root).map_err(|_| KeyringError::Unavailable)?; - fs::set_permissions(&root, fs::Permissions::from_mode(0o700)) + if !root.is_absolute() { + return Err(KeyringError::Unavailable); + } + fs::create_dir_all(root).map_err(|_| KeyringError::Unavailable)?; + fs::set_permissions(root, fs::Permissions::from_mode(0o700)) .map_err(|_| KeyringError::Unavailable)?; - let metadata = fs::symlink_metadata(&root).map_err(|_| KeyringError::Unavailable)?; + let metadata = fs::symlink_metadata(root).map_err(|_| KeyringError::Unavailable)?; if metadata.file_type().is_symlink() || !metadata.is_dir() || metadata.uid() != unsafe { libc::geteuid() } @@ -184,6 +589,16 @@ fn credential_lock_path() -> Result { Ok(root.join("credential-mutation.lock")) } +#[cfg(unix)] +fn credential_lock_path() -> Result { + #[cfg(feature = "phase1-conformance")] + if let Some(path) = env::var_os("OPENCOVEN_PHASE1_CONFORMANCE_LOCK_ROOT") { + return credential_lock_path_for_root(&std::path::PathBuf::from(path)); + } + let home = env::var_os("HOME").ok_or(KeyringError::Unavailable)?; + credential_lock_path_for_root(&default_credential_lock_root(std::path::Path::new(&home))) +} + #[cfg(unix)] fn acquire_mutation_lock() -> Result { acquire_mutation_lock_with_timeout(CREDENTIAL_LOCK_TIMEOUT) @@ -192,6 +607,14 @@ fn acquire_mutation_lock() -> Result { #[cfg(unix)] fn acquire_mutation_lock_with_timeout( timeout: Duration, +) -> Result { + acquire_mutation_lock_with_timeout_at(timeout, None) +} + +#[cfg(unix)] +fn acquire_mutation_lock_with_timeout_at( + timeout: Duration, + explicit_root: Option<&std::path::Path>, ) -> Result { use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; @@ -206,7 +629,10 @@ fn acquire_mutation_lock_with_timeout( Err(std::sync::TryLockError::WouldBlock) => return Err(KeyringError::Unavailable), } }; - let path = credential_lock_path()?; + let path = match explicit_root { + Some(root) => credential_lock_path_for_root(root)?, + None => credential_lock_path()?, + }; let file = fs::OpenOptions::new() .read(true) .write(true) @@ -762,7 +1188,10 @@ impl CredentialCustody for NativeKeyring { credential_id: &str, ) -> Result { #[cfg(feature = "phase1-conformance")] - self.reject_provider_if_configured()?; + { + self.reject_provider_if_configured()?; + self.require_prepared_instance(instance_id)?; + } validate_credential_origin(origin)?; if let Some(expected_credential) = expected_credential { validate_credential_origin(&expected_credential.origin)?; @@ -817,7 +1246,10 @@ impl CredentialCustody for NativeKeyring { credential_id: &str, ) -> Result { #[cfg(feature = "phase1-conformance")] - self.reject_provider_if_configured()?; + { + self.reject_provider_if_configured()?; + self.require_prepared_instance(instance_id)?; + } validate_credential_origin(origin)?; validate_credential_origin(&expected_stale_credential.origin)?; if bearer.is_empty() @@ -943,6 +1375,10 @@ pub(crate) fn validate_credential_origin(origin: &str) -> Result<(), KeyringErro impl NativeKeyring { fn entry(account: &str) -> Result { + Self::entry_for(SERVICE, account) + } + + fn entry_for(service: &str, account: &str) -> Result { if STORE_INITIALIZED.get().is_none() { if !initialize_store() { return Err(KeyringError::Unavailable); @@ -952,7 +1388,7 @@ impl NativeKeyring { #[cfg(windows)] { return Entry::new_with_modifiers( - SERVICE, + service, account, &std::collections::HashMap::from([("persistence", "Local")]), ) @@ -960,7 +1396,7 @@ impl NativeKeyring { } #[cfg(not(windows))] { - Entry::new(SERVICE, account).map_err(map_keyring_error) + Entry::new(service, account).map_err(map_keyring_error) } } @@ -1017,7 +1453,7 @@ fn map_keyring_error(error: KeyringBackendError) -> KeyringError { #[cfg(test)] mod tests { #[cfg(unix)] - use super::acquire_mutation_lock_with_timeout; + use super::{acquire_mutation_lock_with_timeout_at, default_credential_lock_root}; use super::{ acquire_windows_mutex, decode_legacy_windows_password, legacy_windows_mutex_name, parse_stored_credential, validate_installation_id, windows_mutex_name, @@ -1025,6 +1461,21 @@ mod tests { WindowsPersistenceAction, MAX_CREDENTIAL_RECORD_BYTES, }; + #[cfg(feature = "phase1-conformance")] + #[test] + fn conformance_storage_gate_accepts_only_the_native_prepared_uuid() { + const PREPARED: &str = "00000000-0000-4000-8000-000000000001"; + const DIFFERENT: &str = "00000000-0000-4000-8000-000000000002"; + let keyring = super::NativeKeyring::for_conformance(); + assert!(keyring.require_prepared_instance(PREPARED).is_err()); + *keyring.prepared_instance.lock().unwrap() = Some(PREPARED.to_owned()); + assert!(keyring.require_prepared_instance(PREPARED).is_ok()); + assert!(keyring.require_prepared_instance(DIFFERENT).is_err()); + assert!(keyring + .require_prepared_instance("installation-id-v1") + .is_err()); + } + #[test] fn accepts_canonical_lowercase_v4_installation_ids() { assert!(validate_installation_id("0b59fec4-5d8e-4d5c-894d-39fcb5f3eef7").is_ok()); @@ -1082,20 +1533,42 @@ mod tests { #[cfg(unix)] #[test] fn unix_credential_lock_contention_is_bounded() { - use std::time::{Duration, Instant}; + use std::{ + fs, + path::Path, + time::{Duration, Instant}, + }; - let first = acquire_mutation_lock_with_timeout(Duration::from_secs(1)) + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("keyring-lock-tests") + .join(uuid::Uuid::new_v4().to_string()); + let operator_lock = default_credential_lock_root(&std::path::PathBuf::from( + std::env::var_os("HOME").unwrap_or_default(), + )) + .join("credential-mutation.lock"); + assert_ne!(Some(root.as_path()), operator_lock.parent()); + let first = acquire_mutation_lock_with_timeout_at(Duration::from_secs(1), Some(&root)) .expect("first credential lock"); let started = Instant::now(); - let contender = std::thread::spawn(|| { + let contender_root = root.clone(); + let contender = std::thread::spawn(move || { matches!( - acquire_mutation_lock_with_timeout(Duration::from_millis(50)), + acquire_mutation_lock_with_timeout_at( + Duration::from_millis(50), + Some(&contender_root) + ), Err(KeyringError::Unavailable) ) }); assert!(contender.join().expect("contender thread")); assert!(started.elapsed() < Duration::from_secs(1)); drop(first); + fs::remove_dir_all(root).expect("explicit lock root cleanup"); + assert_eq!( + default_credential_lock_root(Path::new("/operator/home")), + Path::new("/operator/home").join(".coven").join("chat") + ); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 48c048a..d9c97b4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -207,12 +207,6 @@ impl NativeConnectionState { } } - #[cfg(test)] - pub(crate) fn using_test_coven_health(mut self, coven_health: Arc) -> Self { - self.coven_health = coven_health; - self - } - pub(crate) fn with_test_launch_collaborators( transport: Arc, keyring: Arc, diff --git a/src-tauri/tests/phase1_native_rpc.rs b/src-tauri/tests/phase1_native_rpc.rs index 501a93c..0630ecb 100644 --- a/src-tauri/tests/phase1_native_rpc.rs +++ b/src-tauri/tests/phase1_native_rpc.rs @@ -9,6 +9,60 @@ use serde_json::{json, Value}; const INSTALLATION_ID: &str = "4e1d02ca-833b-4d9d-8e9f-31bb8f44f9b5"; const MAX_LINE_BYTES: usize = 64 * 1024; +const CONFORMANCE_SOURCE: &str = include_str!("../src/conformance.rs"); + +fn rpc_request( + stdin: &mut ChildStdin, + stdout: &mut BufReader, + request: Value, +) -> Value { + serde_json::to_writer(&mut *stdin, &request).expect("request must serialize"); + stdin.write_all(b"\n").expect("request newline must write"); + stdin.flush().expect("request must flush"); + let mut line = String::new(); + stdout + .read_line(&mut line) + .expect("response line must read"); + serde_json::from_str(&line).expect("response must be JSON") +} + +#[cfg(target_os = "macos")] +fn keychain_contains(keychain: &str, service: &str, account: Option<&str>) -> bool { + let mut probe = Command::new("/usr/bin/security"); + probe.args(["find-generic-password", "-s", service]); + if let Some(account) = account { + probe.args(["-a", account]); + } + probe + .arg(keychain) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("keychain probe must run") + .success() +} + +#[cfg(target_os = "macos")] +fn cleanup_reservation_account(handle: &str) -> String { + format!("cleanup-reservation-v1:{handle}") +} + +#[test] +fn windows_launched_cave_is_bound_to_a_kill_on_close_job() { + for required in [ + "CreateJobObjectW", + "SetInformationJobObject", + "AssignProcessToJobObject", + "JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE", + "CREATE_SUSPENDED", + "ResumeThread", + ] { + assert!( + CONFORMANCE_SOURCE.contains(required), + "missing Windows Cave job-object guarantee: {required}" + ); + } +} const NATIVE_PROVIDER_PRESET_ENV: &str = "OPENCOVEN_PHASE1_CONFORMANCE_NATIVE_PROVIDER_PRESET"; #[test] @@ -236,6 +290,684 @@ fn subprocess_exits_nonzero_when_its_response_stream_is_closed() { ); } +#[cfg(target_os = "macos")] +#[test] +fn subprocess_prepare_broken_pipe_removes_real_isolated_keychain_marker() { + use std::fs; + + if std::env::var("OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED").as_deref() != Ok("1") { + eprintln!("skipped: isolated Phase 1 keychain is not configured"); + return; + } + let keychain = + std::env::var("PHASE1_TEST_KEYCHAIN").expect("isolated keychain path must be configured"); + let root = std::path::PathBuf::from( + std::env::var_os("CARGO_TARGET_DIR") + .expect("isolated Cargo target root must be configured"), + ) + .join("phase1-native-rpc-tests") + .join(uuid::Uuid::new_v4().to_string()); + fs::create_dir_all(&root).expect("test root must be created"); + struct RootCleanup(std::path::PathBuf); + impl Drop for RootCleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _root_cleanup = RootCleanup(root.clone()); + let lock_root = root.join("credential-lock"); + let result_path = root.join("reservation.json"); + + { + let mut child = Command::new(env!("CARGO_BIN_EXE_phase1-native-rpc")) + .arg("--opencoven-internal-test-reservation-output-v1") + .env(NATIVE_PROVIDER_PRESET_ENV, "production-keyring") + .env("OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED", "1") + .env("OPENCOVEN_PHASE1_CONFORMANCE_LOCK_ROOT", &lock_root) + .env( + "OPENCOVEN_PHASE1_TEST_RESERVATION_RESULT_PATH", + &result_path, + ) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("phase1-native-rpc test mode must start"); + drop(child.stdout.take().expect("child stdout must be piped")); + let output = child + .wait_with_output() + .expect("broken-output native RPC must exit"); + assert!(!output.status.success()); + assert_eq!( + String::from_utf8(output.stderr).expect("stderr must be UTF-8"), + "phase1-native-rpc failed\n" + ); + + let reservation: Value = serde_json::from_slice( + &fs::read(&result_path).expect("reservation control record must exist"), + ) + .expect("reservation control record must be JSON"); + let handle = reservation["reservationHandle"] + .as_str() + .expect("reservation handle must be recorded"); + let capability = reservation["capability"] + .as_str() + .expect("reservation capability must be recorded"); + let owner_token = reservation["ownerToken"] + .as_str() + .expect("reservation owner token must be recorded"); + let instance_id = reservation["instanceId"] + .as_str() + .expect("instance ID must be recorded"); + + let marker_account = cleanup_reservation_account(handle); + assert!(!keychain_contains( + &keychain, + "ai.opencoven.chat.conformance-cleanup", + Some(&marker_account) + )); + let target_account = format!("cave-client-v1:{instance_id}"); + let target_status = Command::new("/usr/bin/security") + .args([ + "find-generic-password", + "-s", + "ai.opencoven.chat", + "-a", + &target_account, + &keychain, + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("target status probe must run"); + assert!(!target_status.success(), "target credential must be absent"); + + let mut replay = Command::new(env!("CARGO_BIN_EXE_phase1-native-rpc")) + .env(NATIVE_PROVIDER_PRESET_ENV, "production-keyring") + .env("OPENCOVEN_PHASE1_CONFORMANCE_LOCK_ROOT", &lock_root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("fresh cleanup RPC must start"); + let mut replay_stdin = replay.stdin.take().expect("cleanup stdin must be piped"); + writeln!( + replay_stdin, + "{}", + json!({ + "id":"replay", + "command":"conformance_delete_native_credential", + "args":{"reservationHandle":handle,"capability":capability,"ownerToken":owner_token} + }) + ) + .expect("replay cleanup request must be written"); + writeln!( + replay_stdin, + "{}", + json!({"id":"shutdown","command":"conformance_shutdown"}) + ) + .expect("shutdown request must be written"); + drop(replay_stdin); + let replay_output = replay.wait_with_output().expect("cleanup RPC must exit"); + assert!(replay_output.status.success()); + let responses = String::from_utf8(replay_output.stdout) + .expect("cleanup stdout must be UTF-8") + .lines() + .map(|line| serde_json::from_str::(line).expect("response must be JSON")) + .collect::>(); + assert_eq!(responses[0]["error"]["code"], "credential_missing"); + assert_eq!(responses[1]["result"]["status"], "shutting_down"); + } +} + +#[cfg(target_os = "macos")] +#[test] +fn subprocess_stdin_eof_removes_real_isolated_keychain_credential_and_marker() { + use std::fs; + + if std::env::var("OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED").as_deref() != Ok("1") { + eprintln!("skipped: isolated Phase 1 keychain is not configured"); + return; + } + let keychain = + std::env::var("PHASE1_TEST_KEYCHAIN").expect("isolated keychain path must be configured"); + let root = std::path::PathBuf::from( + std::env::var_os("CARGO_TARGET_DIR") + .expect("isolated Cargo target root must be configured"), + ) + .join("phase1-native-rpc-tests") + .join(uuid::Uuid::new_v4().to_string()); + fs::create_dir_all(&root).expect("test root must be created"); + struct RootCleanup(std::path::PathBuf); + impl Drop for RootCleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _root_cleanup = RootCleanup(root.clone()); + let lock_root = root.join("credential-lock"); + let result_path = root.join("reservation.json"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_phase1-native-rpc")) + .arg("--opencoven-internal-test-reservation-eof-v1") + .env(NATIVE_PROVIDER_PRESET_ENV, "production-keyring") + .env("OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED", "1") + .env("OPENCOVEN_PHASE1_CONFORMANCE_LOCK_ROOT", &lock_root) + .env( + "OPENCOVEN_PHASE1_TEST_RESERVATION_RESULT_PATH", + &result_path, + ) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("phase1-native-rpc EOF test mode must start"); + let stdin = child.stdin.take().expect("child stdin must be piped"); + let mut stdout = BufReader::new(child.stdout.take().expect("child stdout must be piped")); + let mut response = String::new(); + stdout + .read_line(&mut response) + .expect("reservation response must be delivered"); + assert_eq!( + serde_json::from_str::(&response).expect("reservation response must be JSON")["ok"], + true + ); + drop(stdin); + let output = child + .wait_with_output() + .expect("native RPC must exit after stdin EOF"); + assert!(output.status.success()); + assert!(String::from_utf8(output.stderr) + .expect("stderr must be UTF-8") + .is_empty()); + + let reservation: Value = serde_json::from_slice( + &fs::read(&result_path).expect("reservation control record must exist"), + ) + .expect("reservation control record must be JSON"); + let handle = reservation["reservationHandle"] + .as_str() + .expect("reservation handle must be recorded"); + let capability = reservation["capability"] + .as_str() + .expect("reservation capability must be recorded"); + let owner_token = reservation["ownerToken"] + .as_str() + .expect("reservation owner token must be recorded"); + let instance_id = reservation["instanceId"] + .as_str() + .expect("instance ID must be recorded"); + + let marker_account = cleanup_reservation_account(handle); + for (service, account) in [ + ( + "ai.opencoven.chat.conformance-cleanup", + Some(marker_account), + ), + ( + "ai.opencoven.chat", + Some(format!("cave-client-v1:{instance_id}")), + ), + ] { + let mut probe = Command::new("/usr/bin/security"); + probe.args(["find-generic-password", "-s", service]); + if let Some(account) = account.as_deref() { + probe.args(["-a", account]); + } + let status = probe + .arg(&keychain) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("keychain status probe must run"); + assert!(!status.success(), "credential namespace must be empty"); + } + + let mut replay = Command::new(env!("CARGO_BIN_EXE_phase1-native-rpc")) + .env(NATIVE_PROVIDER_PRESET_ENV, "production-keyring") + .env("OPENCOVEN_PHASE1_CONFORMANCE_LOCK_ROOT", &lock_root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("fresh cleanup RPC must start"); + { + let mut replay_stdin = replay.stdin.take().expect("cleanup stdin must be piped"); + writeln!( + replay_stdin, + "{}", + json!({ + "id":"replay", + "command":"conformance_delete_native_credential", + "args":{"reservationHandle":handle,"capability":capability,"ownerToken":owner_token} + }) + ) + .expect("replay cleanup request must be written"); + writeln!( + replay_stdin, + "{}", + json!({"id":"shutdown","command":"conformance_shutdown"}) + ) + .expect("shutdown request must be written"); + } + let replay_output = replay.wait_with_output().expect("cleanup RPC must exit"); + assert!(replay_output.status.success()); + let replay_response = String::from_utf8(replay_output.stdout) + .expect("cleanup stdout must be UTF-8") + .lines() + .next() + .map(|line| serde_json::from_str::(line).expect("response must be JSON")) + .expect("cleanup response must exist"); + assert_eq!(replay_response["error"]["code"], "credential_missing"); +} + +#[cfg(target_os = "macos")] +#[test] +fn subprocess_reservation_handoff_preserves_then_deletes_exact_credential() { + use std::{ + fs, + sync::{Arc, Barrier}, + thread, + time::Duration, + }; + + if std::env::var("OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED").as_deref() != Ok("1") { + eprintln!("skipped: isolated Phase 1 keychain is not configured"); + return; + } + let keychain = + std::env::var("PHASE1_TEST_KEYCHAIN").expect("isolated keychain path must be configured"); + let root = std::path::PathBuf::from( + std::env::var_os("CARGO_TARGET_DIR") + .expect("isolated Cargo target root must be configured"), + ) + .join("phase1-native-rpc-tests") + .join(uuid::Uuid::new_v4().to_string()); + fs::create_dir_all(&root).expect("test root must be created"); + struct RootCleanup(std::path::PathBuf); + impl Drop for RootCleanup { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + let _root_cleanup = RootCleanup(root.clone()); + let lock_root = root.join("credential-lock"); + let result_path = root.join("reservation.json"); + + let mut predecessor = Command::new(env!("CARGO_BIN_EXE_phase1-native-rpc")) + .arg("--opencoven-internal-test-reservation-eof-v1") + .env(NATIVE_PROVIDER_PRESET_ENV, "production-keyring") + .env("OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED", "1") + .env("OPENCOVEN_PHASE1_CONFORMANCE_LOCK_ROOT", &lock_root) + .env( + "OPENCOVEN_PHASE1_TEST_RESERVATION_RESULT_PATH", + &result_path, + ) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("predecessor RPC must start"); + let predecessor_stdin = predecessor.stdin.take().expect("predecessor stdin"); + let mut predecessor_stdout = + BufReader::new(predecessor.stdout.take().expect("predecessor stdout")); + let mut prepared_line = String::new(); + predecessor_stdout + .read_line(&mut prepared_line) + .expect("prepare response must read"); + assert_eq!( + serde_json::from_str::(&prepared_line).expect("prepare response JSON")["ok"], + true + ); + let reservation: Value = serde_json::from_slice( + &fs::read(&result_path).expect("reservation control record must exist"), + ) + .expect("reservation control record must be JSON"); + let handle = reservation["reservationHandle"].as_str().unwrap(); + let capability = reservation["capability"].as_str().unwrap(); + let predecessor_owner = reservation["ownerToken"].as_str().unwrap(); + let account = format!( + "cave-client-v1:{}", + reservation["instanceId"].as_str().unwrap() + ); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while !keychain_contains(&keychain, "ai.opencoven.chat", Some(&account)) { + assert!( + std::time::Instant::now() < deadline, + "predecessor credential must be stored" + ); + thread::sleep(Duration::from_millis(10)); + } + + let mut successor = Command::new(env!("CARGO_BIN_EXE_phase1-native-rpc")) + .env(NATIVE_PROVIDER_PRESET_ENV, "production-keyring") + .env("OPENCOVEN_PHASE1_CONFORMANCE_LOCK_ROOT", &lock_root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("successor RPC must start"); + let mut successor_stdin = successor.stdin.take().expect("successor stdin"); + let mut successor_stdout = BufReader::new(successor.stdout.take().expect("successor stdout")); + let mut contender = Command::new(env!("CARGO_BIN_EXE_phase1-native-rpc")) + .env(NATIVE_PROVIDER_PRESET_ENV, "production-keyring") + .env("OPENCOVEN_PHASE1_CONFORMANCE_LOCK_ROOT", &lock_root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("contender RPC must start"); + let mut contender_stdin = contender.stdin.take().expect("contender stdin"); + let mut contender_stdout = BufReader::new(contender.stdout.take().expect("contender stdout")); + let barrier = Arc::new(Barrier::new(3)); + let first_successor_owner = uuid::Uuid::new_v4().to_string(); + let second_successor_owner = uuid::Uuid::new_v4().to_string(); + let first_request = json!({ + "id":"adopt", + "command":"conformance_begin_adopt_native_cleanup", + "args":{ + "reservationHandle":handle, + "capability":capability, + "ownerToken":predecessor_owner, + "successorOwnerToken":first_successor_owner + } + }); + let second_request = json!({ + "id":"adopt", + "command":"conformance_begin_adopt_native_cleanup", + "args":{ + "reservationHandle":handle, + "capability":capability, + "ownerToken":predecessor_owner, + "successorOwnerToken":second_successor_owner + } + }); + let (mut adopted, mut stale_adoption) = thread::scope(|scope| { + let first_barrier = Arc::clone(&barrier); + let first_stdin = &mut successor_stdin; + let first_stdout = &mut successor_stdout; + let first = scope.spawn(move || { + first_barrier.wait(); + rpc_request(first_stdin, first_stdout, first_request) + }); + let second_barrier = Arc::clone(&barrier); + let second_stdin = &mut contender_stdin; + let second_stdout = &mut contender_stdout; + let second = scope.spawn(move || { + second_barrier.wait(); + rpc_request(second_stdin, second_stdout, second_request) + }); + barrier.wait(); + (first.join().unwrap(), second.join().unwrap()) + }); + if adopted["ok"] != true { + std::mem::swap(&mut successor, &mut contender); + std::mem::swap(&mut successor_stdin, &mut contender_stdin); + std::mem::swap(&mut successor_stdout, &mut contender_stdout); + std::mem::swap(&mut adopted, &mut stale_adoption); + } + assert_eq!(adopted["ok"], true); + assert_eq!(stale_adoption["error"]["code"], "keychain_failure"); + let successor_owner = adopted["result"]["ownerToken"] + .as_str() + .expect("successor owner token") + .to_owned(); + assert_ne!(successor_owner, predecessor_owner); + let commit_request = json!({ + "id":"commit", + "command":"conformance_commit_adopt_native_cleanup", + "args":{ + "reservationHandle":handle, + "capability":capability, + "ownerToken":predecessor_owner, + "successorOwnerToken":successor_owner + } + }); + for _ in 0..2 { + let committed = rpc_request( + &mut successor_stdin, + &mut successor_stdout, + commit_request.clone(), + ); + assert_eq!(committed["result"]["status"], "committed"); + assert_eq!(committed["result"]["ownerToken"], successor_owner); + } + let stale_cleanup = rpc_request( + &mut contender_stdin, + &mut contender_stdout, + json!({ + "id":"stale-cleanup", + "command":"conformance_delete_native_credential", + "args":{ + "reservationHandle":handle, + "capability":capability, + "ownerToken":predecessor_owner + } + }), + ); + assert_eq!(stale_cleanup["error"]["code"], "stale_cleanup_owner"); + let _ = rpc_request( + &mut contender_stdin, + &mut contender_stdout, + json!({"id":"shutdown","command":"conformance_shutdown"}), + ); + drop(contender_stdin); + assert!(contender.wait().expect("contender exit").success()); + + drop(predecessor_stdin); + assert!(predecessor.wait().expect("predecessor exit").success()); + assert!( + keychain_contains(&keychain, "ai.opencoven.chat", Some(&account)), + "credential must survive stale predecessor cleanup" + ); + + drop(successor_stdin); + assert!(successor.wait().expect("successor exit").success()); + assert!(!keychain_contains( + &keychain, + "ai.opencoven.chat", + Some(&account) + )); + assert!(!keychain_contains( + &keychain, + "ai.opencoven.chat.conformance-cleanup", + Some(&cleanup_reservation_account(handle)) + )); + + let mut replay = Command::new(env!("CARGO_BIN_EXE_phase1-native-rpc")) + .env(NATIVE_PROVIDER_PRESET_ENV, "production-keyring") + .env("OPENCOVEN_PHASE1_CONFORMANCE_LOCK_ROOT", &lock_root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("replay RPC must start"); + let mut replay_stdin = replay.stdin.take().expect("replay stdin"); + let mut replay_stdout = BufReader::new(replay.stdout.take().expect("replay stdout")); + let replay_result = rpc_request( + &mut replay_stdin, + &mut replay_stdout, + json!({ + "id":"replay", + "command":"conformance_delete_native_credential", + "args":{ + "reservationHandle":handle, + "capability":capability, + "ownerToken":successor_owner + } + }), + ); + assert_eq!(replay_result["error"]["code"], "credential_missing"); + let _ = rpc_request( + &mut replay_stdin, + &mut replay_stdout, + json!({"id":"shutdown","command":"conformance_shutdown"}), + ); + drop(replay_stdin); + assert!(replay.wait().expect("replay exit").success()); +} + +#[cfg(target_os = "macos")] +#[test] +fn subprocess_recovers_both_adoption_commit_crash_windows() { + use std::{ffi::CString, fs, io::Read, sync::mpsc, thread, time::Duration}; + + if std::env::var("OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED").as_deref() != Ok("1") { + eprintln!("skipped: isolated Phase 1 keychain is not configured"); + return; + } + let keychain = + std::env::var("PHASE1_TEST_KEYCHAIN").expect("isolated keychain path must be configured"); + let cargo_target = std::path::PathBuf::from( + std::env::var_os("CARGO_TARGET_DIR") + .expect("isolated Cargo target root must be configured"), + ); + let target_root = cargo_target.join("phase1-native-rpc-tests"); + + for phase in ["before-commit", "after-commit"] { + let root = target_root.join(uuid::Uuid::new_v4().to_string()); + fs::create_dir_all(&root).unwrap(); + let result_path = root.join("reservation.json"); + let lock_root = root.join("credential-lock"); + let mut predecessor = Command::new(env!("CARGO_BIN_EXE_phase1-native-rpc")) + .arg("--opencoven-internal-test-reservation-eof-v1") + .env(NATIVE_PROVIDER_PRESET_ENV, "production-keyring") + .env("OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED", "1") + .env( + "OPENCOVEN_PHASE1_TEST_RESERVATION_RESULT_PATH", + &result_path, + ) + .env("OPENCOVEN_PHASE1_CONFORMANCE_LOCK_ROOT", &lock_root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let predecessor_stdin = predecessor.stdin.take().unwrap(); + let mut predecessor_stdout = BufReader::new(predecessor.stdout.take().unwrap()); + let mut line = String::new(); + predecessor_stdout.read_line(&mut line).unwrap(); + let reservation: Value = serde_json::from_slice(&fs::read(&result_path).unwrap()).unwrap(); + let handle = reservation["reservationHandle"] + .as_str() + .unwrap() + .to_owned(); + let capability = reservation["capability"].as_str().unwrap().to_owned(); + let predecessor_owner = reservation["ownerToken"].as_str().unwrap().to_owned(); + let successor_owner = uuid::Uuid::new_v4().to_string(); + let account = format!( + "cave-client-v1:{}", + reservation["instanceId"].as_str().unwrap() + ); + + let ready = root.join(format!("{phase}.ready")); + let gate = root.join(format!("{phase}.gate")); + for fifo in [&ready, &gate] { + let path = CString::new(fifo.to_str().unwrap()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0); + } + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let ready_thread = thread::spawn(move || { + let mut file = fs::File::open(ready).unwrap(); + let mut bytes = [0_u8; 6]; + file.read_exact(&mut bytes).unwrap(); + ready_tx.send(()).unwrap(); + }); + + let mut successor = Command::new(env!("CARGO_BIN_EXE_phase1-native-rpc")) + .env(NATIVE_PROVIDER_PRESET_ENV, "production-keyring") + .env("OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED", "1") + .env("OPENCOVEN_PHASE1_TEST_ADOPTION_BARRIER", phase) + .env("OPENCOVEN_PHASE1_TEST_ADOPTION_BARRIER_ROOT", &root) + .env("OPENCOVEN_PHASE1_CONFORMANCE_LOCK_ROOT", &lock_root) + .env("CARGO_TARGET_DIR", &cargo_target) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut successor_stdin = successor.stdin.take().unwrap(); + let mut successor_stdout = BufReader::new(successor.stdout.take().unwrap()); + let args = json!({ + "reservationHandle":handle, + "capability":capability, + "ownerToken":predecessor_owner, + "successorOwnerToken":successor_owner + }); + assert_eq!( + rpc_request( + &mut successor_stdin, + &mut successor_stdout, + json!({"id":"begin","command":"conformance_begin_adopt_native_cleanup","args":args.clone()}) + )["ok"], + true + ); + serde_json::to_writer( + &mut successor_stdin, + &json!({"id":"commit","command":"conformance_commit_adopt_native_cleanup","args":args}), + ) + .unwrap(); + successor_stdin.write_all(b"\n").unwrap(); + successor_stdin.flush().unwrap(); + ready_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + ready_thread.join().unwrap(); + successor.kill().unwrap(); + successor.wait().unwrap(); + + let mut recovery = Command::new(env!("CARGO_BIN_EXE_phase1-native-rpc")) + .env(NATIVE_PROVIDER_PRESET_ENV, "production-keyring") + .env("OPENCOVEN_PHASE1_CONFORMANCE_LOCK_ROOT", &lock_root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut recovery_stdin = recovery.stdin.take().unwrap(); + let mut recovery_stdout = BufReader::new(recovery.stdout.take().unwrap()); + let mut deleted = false; + for owner in [&successor_owner, &predecessor_owner] { + let response = rpc_request( + &mut recovery_stdin, + &mut recovery_stdout, + json!({ + "id":"cleanup", + "command":"conformance_delete_native_credential", + "args":{ + "reservationHandle":handle, + "capability":capability, + "ownerToken":owner + } + }), + ); + if response["ok"] == true { + deleted = true; + break; + } + } + assert!(deleted, "one retained owner token must delete"); + let _ = rpc_request( + &mut recovery_stdin, + &mut recovery_stdout, + json!({"id":"shutdown","command":"conformance_shutdown"}), + ); + drop(recovery_stdin); + assert!(recovery.wait().unwrap().success()); + drop(predecessor_stdin); + let _ = predecessor.wait(); + assert!(!keychain_contains( + &keychain, + "ai.opencoven.chat", + Some(&account) + )); + assert!(!keychain_contains( + &keychain, + "ai.opencoven.chat.conformance-cleanup", + Some(&cleanup_reservation_account(&handle)) + )); + fs::remove_dir_all(root).unwrap(); + } +} + #[test] fn subprocess_protocol_drains_oversized_input_redacts_it_and_shuts_down_cleanly() { let canary = "subprocess-input-canary-must-not-escape"; diff --git a/src/demo/chat-demo.css b/src/demo/chat-demo.css index 7eb1b12..0fd3252 100644 --- a/src/demo/chat-demo.css +++ b/src/demo/chat-demo.css @@ -33,8 +33,8 @@ --demo-muted: #aaa7b0; --demo-ready: #82b38e; --demo-line: rgba(255, 255, 255, 0.085); - --demo-conversations-width: minmax(270px, 320px); - --demo-inspector-width: minmax(300px, 340px); + --demo-conversations-width: var(--demo-conversations-open-width, 320px); + --demo-inspector-width: var(--demo-inspector-open-width, 340px); display: grid; grid-template-columns: @@ -58,6 +58,12 @@ --demo-inspector-width: 0px; } +.chat-demo.is-resizing { + cursor: col-resize; + user-select: none; + transition: none; +} + .chat-demo [hidden] { display: none !important; } @@ -73,58 +79,137 @@ /* Sidebar ---------------------------------------------------------------- */ .chat-demo .sidebar { + position: relative; grid-column: 1; display: flex; flex-direction: column; min-height: 0; - padding: 12px 10px; + padding: 0; border-right: 1px solid var(--demo-line); background: var(--demo-sidebar); backdrop-filter: blur(24px) saturate(140%); box-shadow: inset -1px 0 rgba(255, 255, 255, 0.025); } +.chat-demo .rail-resizer { + position: absolute; + z-index: 30; + top: 0; + bottom: 0; + width: 9px; + margin: 0; + padding: 0; + border: 0; + outline: none; + background: transparent; + cursor: col-resize; + touch-action: none; +} + +.chat-demo .rail-resizer::after { + position: absolute; + top: 0; + bottom: 0; + left: 4px; + width: 1px; + background: transparent; + content: ""; + transition: + width 120ms ease, + background 120ms ease, + box-shadow 120ms ease; +} + +.chat-demo .rail-resizer:hover::after, +.chat-demo .rail-resizer:focus-visible::after, +.chat-demo.is-resizing .rail-resizer::after { + width: 2px; + background: var(--demo-violet); + box-shadow: 0 0 10px rgba(147, 134, 208, 0.52); +} + +.chat-demo .rail-resizer-conversations { + right: 0; +} + +.chat-demo .rail-resizer-inspector { + left: 0; +} + .chat-demo .sidebar-header { + flex: none; + border-bottom: 1px solid var(--demo-line); +} + +.chat-demo .sidebar-title { + width: 100%; + margin: 0; + font: inherit; +} + +.chat-demo .sidebar-header-toggle { display: flex; - gap: 8px; align-items: center; justify-content: space-between; - margin-bottom: 12px; - padding: 0 2px; + width: 100%; + height: 40px; + padding: 0 12px; + color: var(--demo-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-align: left; + text-transform: uppercase; + transition: + background 120ms ease, + color 120ms ease; +} + +.chat-demo .sidebar-header-toggle:hover { + background: rgba(255, 255, 255, 0.045); + color: var(--demo-text); +} + +.chat-demo .sidebar-header-toggle > svg { + flex: none; + color: currentColor; +} + +.chat-demo .sidebar-controls { + display: grid; + gap: 0; + flex: none; + padding: 8px 8px 4px; } .chat-demo .familiar-switcher { position: relative; display: grid; - grid-template-columns: 34px minmax(0, 1fr) 16px; - gap: 9px; + grid-template-columns: 30px minmax(0, 1fr) 16px; + gap: 8px; align-items: center; + width: 100%; + min-height: 42px; min-width: 0; - flex: 1; - padding: 7px 8px; - border: 1px solid rgba(147, 134, 208, 0.24); - border-radius: 13px; - background: - linear-gradient(135deg, rgba(147, 134, 208, 0.12), rgba(255, 255, 255, 0.035)), - rgba(16, 16, 20, 0.28); - box-shadow: - inset 0 1px rgba(255, 255, 255, 0.07), - 0 8px 24px rgba(0, 0, 0, 0.13); + flex: 0 0 auto; + padding: 5px 8px; + border: 1px solid transparent; + border-radius: 10px; + background: rgba(255, 255, 255, 0.025); + color: var(--demo-text); + text-align: left; transition: border-color 160ms ease, - background 160ms ease, - transform 160ms ease; + background 160ms ease; } -.chat-demo .familiar-switcher:hover { - border-color: var(--demo-violet-line); - background: - linear-gradient(135deg, rgba(147, 134, 208, 0.17), rgba(255, 255, 255, 0.05)), - rgba(16, 16, 20, 0.34); - transform: translateY(-1px); +.chat-demo .familiar-switcher:hover, +.chat-demo .familiar-switcher[data-popup-open] { + border-color: var(--demo-line); + background: rgba(255, 255, 255, 0.05); } -.chat-demo .familiar-switcher:focus-within { +.chat-demo .familiar-switcher:focus-visible { outline: 2px solid rgba(147, 134, 208, 0.34); outline-offset: 2px; } @@ -143,9 +228,10 @@ } .chat-demo .familiar-switcher-name { - font-size: 13px; - font-weight: 670; + font-size: 12px; + font-weight: 650; letter-spacing: -0.012em; + line-height: 1.2; } .chat-demo .familiar-switcher-role { @@ -154,189 +240,570 @@ align-items: center; color: var(--demo-muted); font-size: 10px; + line-height: 1.2; } .chat-demo .familiar-switcher-status { - width: 5px; - height: 5px; + width: 4px; + height: 4px; flex: 0 0 auto; border-radius: 999px; background: var(--demo-ready); box-shadow: 0 0 8px rgba(130, 179, 142, 0.5); } -.chat-demo .familiar-switcher-chevron { +.chat-demo .familiar-switcher > svg { color: var(--demo-violet); - font-size: 14px; - transform: translateY(-1px); } -.chat-demo .familiar-switcher select { - position: absolute; - inset: 0; - width: 100%; - height: 100%; +.familiar-menu-positioner { + z-index: 100; + outline: none; +} + +.familiar-menu { + width: var(--anchor-width); + max-height: var(--available-height); + overflow-y: auto; + padding: 3px; + border: 1px solid #35343b; + border-radius: 8px; + outline: none; + background: #202024; + color: #f1eff4; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); +} + +.familiar-menu-label { + padding: 5px 8px 4px; + color: #a6a2ac; + font-size: 10px; + font-weight: 650; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.familiar-menu-item { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + min-height: 40px; + padding: 5px 7px; + border-radius: 4px; cursor: pointer; - opacity: 0; + outline: none; +} + +.familiar-menu-item[data-highlighted] { + background: #29282e; +} + +.familiar-menu-item[data-checked] { + background: rgba(147, 134, 208, 0.12); +} + +.familiar-menu .avatar { + display: grid; + place-items: center; + flex: none; + border: 1px solid transparent; + border-radius: 50%; + font-weight: 600; + line-height: 1; +} + +.familiar-menu-copy { + display: grid; + min-width: 0; +} + +.familiar-menu-name { + overflow: hidden; + font-size: 13px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.familiar-menu-role { + overflow: hidden; + color: #a6a2ac; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.familiar-menu-current { + color: #9386d0; + font-size: 10px; + font-weight: 650; +} + +.chat-demo .sidebar-primary-actions { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) 32px; + gap: 4px; + margin-top: 7px; + padding-top: 9px; + border-top: 1px solid rgba(255, 255, 255, 0.085); +} + +.chat-demo .sidebar-primary-actions::before { + position: absolute; + top: -1px; + left: 0; + width: 24px; + height: 1px; + background: rgba(147, 134, 208, 0.72); + content: ""; } .chat-demo .new-conversation { display: flex; gap: 8px; align-items: center; - justify-content: center; - margin-top: 8px; - padding: 9px 10px; - border: 1px solid var(--demo-line) !important; - border-radius: 11px; - background: rgba(255, 255, 255, 0.04) !important; - color: var(--demo-muted) !important; + justify-content: flex-start; + width: 100%; + min-height: 32px; + padding: 0 9px; + border: 1px solid rgba(255, 255, 255, 0.12) !important; + border-radius: 8px; + background: rgba(255, 255, 255, 0.075) !important; + color: var(--demo-text) !important; + font-size: 12px; + font-weight: 650; + box-shadow: + inset 0 1px rgba(255, 255, 255, 0.06), + 0 2px 8px rgba(0, 0, 0, 0.18); + backdrop-filter: blur(12px) saturate(125%); + transition: + background 120ms ease, + border-color 120ms ease, + transform 120ms ease; } .chat-demo .new-conversation:hover { - background: rgba(255, 255, 255, 0.075) !important; - color: var(--demo-text) !important; + border-color: rgba(255, 255, 255, 0.19) !important; + background: rgba(255, 255, 255, 0.11) !important; } -.chat-demo .search-wrap { - position: relative; - margin-bottom: 10px; +.chat-demo .new-conversation:active { + transform: translateY(1px); } -.chat-demo .search-icon { - position: absolute; - top: 50%; - left: 10px; - width: 15px; +.chat-demo .new-conversation:focus-visible, +.chat-demo .conversation-search-trigger:focus-visible { + outline: 2px solid rgba(147, 134, 208, 0.54); + outline-offset: 2px; +} + +.chat-demo .new-conversation > .mm-icon { + color: var(--demo-muted); +} + +.chat-demo .conversation-search-trigger { + display: grid; + width: 32px; + min-height: 32px; + place-items: center; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + background: rgba(255, 255, 255, 0.055); + color: var(--demo-muted); + box-shadow: + inset 0 1px rgba(255, 255, 255, 0.05), + 0 2px 8px rgba(0, 0, 0, 0.16); + backdrop-filter: blur(12px) saturate(125%); + transition: + color 120ms ease, + border-color 120ms ease, + background 120ms ease; +} + +.chat-demo .conversation-search-trigger:hover { + border-color: rgba(255, 255, 255, 0.19); + background: rgba(255, 255, 255, 0.1); + color: var(--demo-text); +} + +.chat-demo .composer-input:focus-visible { + outline: 2px solid var(--demo-bubble-mine); + outline-offset: 1px; +} + +.chat-demo .conversation-scroll { + min-height: 0; + flex: 1; + overflow-y: auto; + padding: 0 4px 8px; + scrollbar-width: thin; +} + +.chat-demo .conversation-section-label { + display: flex; + gap: 7px; + align-items: center; + margin: 0; + padding: 8px 4px 4px; + color: var(--demo-text); + font-family: SFMono-Regular, ui-monospace, Menlo, Consolas, monospace; + font-size: 10px; + font-weight: 650; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.chat-demo .conversation-section-label::before { + width: 2px; + height: 12px; + flex: none; + border-radius: 999px; + background: rgba(147, 134, 208, 0.58); + content: ""; +} + +.chat-demo .conversation-section-count { + display: inline-flex; + align-items: center; height: 15px; + flex: none; + padding: 0 5px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.08); color: var(--demo-muted); - transform: translateY(-50%); - pointer-events: none; + font-weight: 500; + letter-spacing: 0.04em; +} + +.chat-demo .conversation-section-rule { + height: 1px; + flex: 1; + background: var(--demo-line); } -.chat-demo .search { +.chat-demo .conversations { + display: grid; + gap: 2px; + margin: 0; + padding: 0; + list-style: none; +} + +.chat-demo .conversation { + position: relative; + display: flex; + gap: 8px; + align-items: center; width: 100%; - padding: 7px 10px 7px 31px; - border: 1px solid var(--demo-line); - border-radius: 8px; - background: rgba(255, 255, 255, 0.06); + min-height: 42px; + padding: 4px 8px 4px 12px; + border: 1px solid transparent; + border-radius: 10px; + background: rgba(255, 255, 255, 0.02); + color: var(--demo-muted); + text-align: left; + transition: + border-color 120ms ease, + background 120ms ease, + color 120ms ease; +} + +.chat-demo .conversation-scroll.is-sparse .conversation { + min-height: 54px; + padding-block: 7px; +} + +.chat-demo .conversation:hover { + background: rgba(255, 255, 255, 0.045); + color: var(--demo-text); +} + +.chat-demo .conversation.is-active { + border-color: rgba(147, 134, 208, 0.34); + background: rgba(147, 134, 208, 0.1); + color: var(--demo-text); +} + +.chat-demo .conversation-status-rail { + position: absolute; + top: 7px; + bottom: 7px; + left: 4px; + width: 2px; + border-radius: 999px; + background: var(--demo-muted); + opacity: 0.72; +} + +.chat-demo .conversation-status-dot { + width: 5px; + height: 5px; + flex: none; + border-radius: 999px; + background: var(--demo-muted); +} + +.chat-demo .conversation[data-status="available"] .conversation-status-rail, +.chat-demo .conversation[data-status="available"] .conversation-status-dot { + background: var(--demo-ready); +} + +.chat-demo .conversation[data-status="working"] .conversation-status-rail, +.chat-demo .conversation[data-status="working"] .conversation-status-dot { + background: #c9a86a; +} + +.chat-demo .conversation[data-status="offline"] .conversation-status-rail, +.chat-demo .conversation[data-status="offline"] .conversation-status-dot { + background: var(--demo-muted); +} + +.chat-demo .avatar { + display: grid; + place-items: center; + flex: none; + border: 1px solid transparent; + border-radius: 50%; + font-weight: 600; + line-height: 1; +} + +.chat-demo .conversation .avatar { + border-radius: 7px; +} + +.chat-demo .familiar-switcher .avatar { + box-shadow: 0 0 0 1px rgba(147, 134, 208, 0.28); +} + +.chat-demo .conversation-body { + display: grid; + gap: 1px; + min-width: 0; +} + +.chat-demo .conversation-top { + display: flex; + gap: 8px; + align-items: baseline; + justify-content: space-between; +} + +.chat-demo .conversation-title { + min-width: 0; + overflow: hidden; + font-size: 12px; + font-weight: 650; + line-height: 1.15; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-demo .conversation-time, +.chat-demo .conversation-preview { + color: var(--demo-muted); + font-size: 11px; + line-height: 1.2; +} + +.chat-demo .conversation-preview { + display: flex; + gap: 5px; + align-items: center; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.chat-demo .preview-glyph { + font-size: 11px; +} + +/* Conversation command search ------------------------------------------- */ + +.chat-demo .conversation-search-dialog { + position: fixed; + z-index: 110; + inset: 0; + display: grid; + align-items: start; + justify-items: center; + padding: min(15vh, 140px) 20px 20px; + background: rgba(5, 5, 7, 0.74); +} + +.chat-demo .conversation-search-backdrop { + position: absolute; + inset: 0; + padding: 0; + border: 0; + background: transparent; +} + +.chat-demo .conversation-search-panel { + position: relative; + z-index: 1; + width: min(520px, 100%); + overflow: hidden; + border: 1px solid rgba(147, 134, 208, 0.28); + border-radius: 14px; + background: #1b1b1f; + box-shadow: + inset 0 1px rgba(255, 255, 255, 0.055), + 0 28px 90px rgba(0, 0, 0, 0.62); +} + +.chat-demo .conversation-search-field { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + gap: 9px; + align-items: center; + min-height: 48px; + padding: 0 12px; + border-bottom: 1px solid var(--demo-line); + color: var(--demo-muted); +} + +.chat-demo .conversation-search-field:focus-within { + box-shadow: inset 3px 0 var(--demo-violet); +} + +.chat-demo .conversation-search-field input { + min-width: 0; + padding: 0; + border: 0; + outline: none; + background: transparent; color: var(--demo-text); font: inherit; + font-size: 14px; } -.chat-demo .search::placeholder { - color: var(--demo-muted); +.chat-demo .conversation-search-field input::placeholder { + color: #7f7c86; } -.chat-demo .search:focus-visible, -.chat-demo .composer-input:focus-visible { - outline: 2px solid var(--demo-bubble-mine); - outline-offset: 1px; +.chat-demo .conversation-search-field kbd { + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 5px; + background: rgba(255, 255, 255, 0.045); + color: var(--demo-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 10px; } -.chat-demo .conversations { +.chat-demo .conversation-search-results { + display: grid; + gap: 2px; + max-height: min(420px, 58vh); overflow-y: auto; - margin: 0; - padding: 0; - list-style: none; + padding: 7px; } -.chat-demo .conversation { +.chat-demo .conversation-search-heading { display: flex; - gap: 10px; + align-items: center; + justify-content: space-between; + margin: 0; + padding: 4px 7px 6px; + color: var(--demo-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 9px; + font-weight: 650; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.chat-demo .conversation-search-result { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + gap: 9px; align-items: center; width: 100%; - padding: 9px 10px; + min-height: 48px; + padding: 7px 9px; + border: 1px solid transparent; border-radius: 9px; + color: var(--demo-muted); text-align: left; } -.chat-demo .conversation:hover { - background: rgba(255, 255, 255, 0.04); -} - -.chat-demo .conversation.is-active { - background: var(--demo-selected); +.chat-demo .conversation-search-result:hover, +.chat-demo .conversation-search-result:focus-visible { + border-color: rgba(255, 255, 255, 0.08); + outline: none; + background: rgba(255, 255, 255, 0.055); + color: var(--demo-text); } -.chat-demo .avatar { - display: grid; - place-items: center; - flex: none; - border: 1px solid transparent; - border-radius: 50%; - font-weight: 600; - line-height: 1; +.chat-demo .conversation-search-result.is-active { + border-color: rgba(147, 134, 208, 0.28); + background: rgba(147, 134, 208, 0.09); } -.chat-demo .familiar-switcher .avatar { - box-shadow: - 0 0 0 1px rgba(147, 134, 208, 0.34), - 0 6px 16px rgba(0, 0, 0, 0.18); +.chat-demo .conversation-search-result > .mm-icon { + color: var(--demo-violet); } -.chat-demo .conversation-body { +.chat-demo .conversation-search-copy { display: grid; gap: 2px; min-width: 0; } -.chat-demo .conversation-top { - display: flex; - gap: 8px; - align-items: baseline; - justify-content: space-between; +.chat-demo .conversation-search-copy strong, +.chat-demo .conversation-search-copy > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.chat-demo .conversation-title { - font-weight: 600; +.chat-demo .conversation-search-copy strong { + color: var(--demo-text); + font-size: 12px; + font-weight: 650; } -.chat-demo .conversation-time, -.chat-demo .conversation-preview { +.chat-demo .conversation-search-copy > span, +.chat-demo .conversation-search-time { color: var(--demo-muted); - font-size: 13px; -} - -.chat-demo .conversation-preview { - display: flex; - gap: 5px; - align-items: center; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + font-size: 10px; } -.chat-demo .preview-glyph { - font-size: 11px; +.chat-demo .conversation-search-empty { + margin: 0; + padding: 24px 14px 28px; + color: var(--demo-muted); + font-size: 12px; + text-align: center; } /* Thread ----------------------------------------------------------------- */ .chat-demo .thread { + position: relative; grid-column: 2; display: grid; - grid-template-rows: auto 1fr auto; + grid-template-rows: minmax(0, 1fr) auto; min-height: 0; } -.chat-demo .thread-header { - display: grid; - grid-template-columns: 30px minmax(0, 1fr) 30px; - align-items: center; - padding: 10px 14px; - border-bottom: 1px solid rgba(255, 255, 255, 0.045); +.chat-demo .thread-edge-control { + position: absolute; + z-index: 10; + top: 12px; } -.chat-demo .thread-title { - display: flex; - gap: 8px; - align-items: center; - justify-self: center; - padding: 5px 14px 5px 6px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.07); - font-weight: 600; +.chat-demo .thread-edge-control-left { + left: 14px; +} + +.chat-demo .thread-edge-control-right { + right: 14px; } .chat-demo .glass-control { @@ -382,7 +849,7 @@ .chat-demo .transcript { overflow-y: auto; - padding: 4px 20px 12px; + padding: 12px 20px; } .chat-demo .timestamp { @@ -422,17 +889,204 @@ background: var(--demo-bubble-theirs); } +.chat-demo .message-stack { + display: grid; + width: 100%; + gap: 7px; +} + +.chat-demo .reasoning-block { + overflow: hidden; + border: 1px solid rgba(147, 134, 208, 0.2); + border-left-color: rgba(147, 134, 208, 0.72); + border-radius: 10px; + background: rgba(147, 134, 208, 0.045); +} + +.chat-demo .reasoning-summary { + display: grid; + grid-template-columns: 22px auto 1fr 13px; + gap: 7px; + align-items: center; + min-height: 34px; + padding: 5px 9px 5px 7px; + color: var(--demo-muted); + cursor: pointer; + list-style: none; + user-select: none; +} + +.chat-demo .reasoning-summary::-webkit-details-marker { + display: none; +} + +.chat-demo .reasoning-summary:hover { + background: rgba(255, 255, 255, 0.035); + color: var(--demo-text); +} + +.chat-demo .reasoning-summary:focus-visible { + outline: 2px solid rgba(147, 134, 208, 0.6); + outline-offset: -2px; +} + +.chat-demo .reasoning-mark { + display: grid; + width: 22px; + height: 22px; + place-items: center; + border: 1px solid rgba(147, 134, 208, 0.24); + border-radius: 6px; + background: rgba(147, 134, 208, 0.1); + color: var(--demo-violet); +} + +.chat-demo .reasoning-label { + color: var(--demo-text); + font-size: 12px; + font-weight: 650; +} + +.chat-demo .reasoning-meta { + display: flex; + gap: 7px; + align-items: center; + justify-content: flex-end; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 9px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.chat-demo .reasoning-demo { + padding: 1px 5px; + border: 1px solid rgba(147, 134, 208, 0.25); + border-radius: 4px; + color: var(--demo-violet); +} + +.chat-demo .reasoning-summary > .mm-icon { + transition: transform 120ms ease; +} + +.chat-demo .reasoning-block[open] .reasoning-summary > .mm-icon { + transform: rotate(180deg); +} + +.chat-demo .reasoning-content { + padding: 4px 11px 11px 36px; + border-top: 1px solid rgba(147, 134, 208, 0.12); +} + +.chat-demo .reasoning-intent { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + gap: 7px; + align-items: start; + margin: 8px 0 9px; + color: var(--demo-text); + font-size: 12px; + line-height: 1.45; +} + +.chat-demo .reasoning-intent > .mm-icon { + margin-top: 2px; + color: var(--demo-violet); +} + +.chat-demo .reasoning-content ol { + display: grid; + gap: 7px; + margin: 0; + padding: 0; + list-style: none; +} + +.chat-demo .reasoning-content li { + display: grid; + grid-template-columns: 26px minmax(0, 1fr); + gap: 9px; + align-items: start; +} + +.chat-demo .reasoning-step-icon { + display: grid; + width: 26px; + height: 26px; + place-items: center; + border: 1px solid currentColor; + border-radius: 7px; + background: color-mix(in srgb, currentColor 10%, transparent); +} + +.chat-demo .reasoning-step[data-kind="analysis"] .reasoning-step-icon { + color: #a99de1; +} + +.chat-demo .reasoning-step[data-kind="design"] .reasoning-step-icon { + color: #d092c6; +} + +.chat-demo .reasoning-step[data-kind="safety"] .reasoning-step-icon { + color: #82b38e; +} + +.chat-demo .reasoning-step-copy { + display: grid; + gap: 2px; + min-width: 0; +} + +.chat-demo .reasoning-step-copy strong { + color: var(--demo-text); + font-size: 11px; + font-weight: 650; +} + +.chat-demo .reasoning-step-copy > span { + color: var(--demo-muted); + font-size: 11px; + line-height: 1.4; +} + /* A slash command is shown as a command and never executed. */ .chat-demo .command { font-weight: 700; } +.chat-demo .generated-trigger { + display: block; + flex: 0 1 420px; + width: min(46%, 420px); + padding: 0; + overflow: hidden; + border: 0; + border-radius: 12px; + background: transparent; + color: inherit; + cursor: zoom-in; +} + +.chat-demo .message-stack .generated-trigger { + width: min(100%, 420px); +} + .chat-demo .generated { - max-width: min(46%, 420px); + display: block; + width: 100%; border: 1px solid var(--demo-line); border-radius: 12px; } +.chat-demo .generated-trigger:hover .generated { + border-color: rgba(147, 134, 208, 0.72); +} + +.chat-demo .generated-trigger:focus-visible { + outline: 2px solid var(--demo-presence); + outline-offset: 3px; +} + .chat-demo .typing { display: flex; gap: 4px; @@ -734,6 +1388,72 @@ .chat-demo .stamp { transition: none; } + + .chat-demo .reasoning-summary > .mm-icon { + transition: none; + } +} + +/* Generated image focus -------------------------------------------------- */ + +.chat-demo .image-lightbox { + position: fixed; + z-index: 100; + inset: 0; + display: grid; + place-items: center; + padding: clamp(40px, 7vw, 88px); + background: rgba(0, 0, 0, 0.9); +} + +.chat-demo .image-lightbox-artwork { + position: relative; + z-index: 1; + width: min(1100px, 100%); + max-height: 100%; +} + +.chat-demo .image-lightbox-backdrop { + position: absolute; + inset: 0; + padding: 0; + border: 0; + background: transparent; + cursor: zoom-out; +} + +.chat-demo .image-lightbox-artwork .generated { + max-height: calc(100vh - 80px); + border-color: rgba(255, 255, 255, 0.15); + border-radius: 16px; + box-shadow: 0 32px 96px rgba(0, 0, 0, 0.72); +} + +.chat-demo .image-lightbox-close { + position: absolute; + z-index: 2; + top: 20px; + right: 20px; + display: grid; + width: 36px; + height: 36px; + place-items: center; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.17); + border-radius: 9px; + background: #202024; + color: #f1eff4; + cursor: pointer; +} + +.chat-demo .image-lightbox-close:hover { + border-color: rgba(147, 134, 208, 0.8); + background: #29282e; +} + +.chat-demo .image-lightbox-close:focus-visible { + outline: 2px solid var(--demo-presence); + outline-offset: 3px; } /* Document reader -------------------------------------------------------- */ @@ -1026,6 +1746,7 @@ /* Contextual inspector ---------------------------------------------------- */ .chat-demo > [aria-label="Agent inspector"] { + position: relative; grid-column: 3; min-width: 0; overflow: hidden; @@ -1034,7 +1755,7 @@ .chat-demo .chat-inspector { display: grid; - grid-template-rows: auto auto auto minmax(0, 1fr) auto; + grid-template-rows: auto auto minmax(0, 1fr) auto; height: 100%; min-width: 300px; overflow: hidden; @@ -2108,6 +2829,16 @@ .chat-demo .transcript { padding-inline: max(18px, 5vw); } + + .chat-demo .rail-resizer { + display: none; + } +} + +@media (min-width: 1100px) { + .chat-demo .message-stack { + width: min(78%, 720px); + } } @media (prefers-reduced-motion: reduce) { diff --git a/src/demo/chat-demo.tsx b/src/demo/chat-demo.tsx index 0301832..f6994a9 100644 --- a/src/demo/chat-demo.tsx +++ b/src/demo/chat-demo.tsx @@ -1,6 +1,18 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { Menu } from '@base-ui/react/menu'; +import { + type CSSProperties, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; import { ChatInspector } from './chat-inspector'; import { DocumentReader, type ReaderDocument } from './document-reader'; +import { Icon } from './minimal-icons'; import { MOCK_COMMANDS, MOCK_CONVERSATIONS, @@ -9,9 +21,18 @@ import { mockReply, nowLabel, } from './mock-data'; -import { MOCK_FAMILIARS } from './mock-familiars'; +import { MOCK_FAMILIARS, type MockFamiliar } from './mock-familiars'; import type { MockArtifact, MockLinkPreview } from './mock-rich-content'; +type RailSide = 'conversations' | 'inspector'; + +const RAIL_LIMITS = { + conversations: { min: 240, max: 440 }, + inspector: { min: 300, max: 480 }, +} as const; +const MIN_THREAD_WIDTH = 360; +const RAIL_KEYBOARD_STEP = 12; + /** * Proof-of-concept chat surface. * @@ -92,6 +113,222 @@ function Avatar({ label, seed, size }: { label: string; seed: string; size: numb ); } +function FamiliarSelector({ + activeFamiliar, + onValueChange, +}: { + activeFamiliar: MockFamiliar | undefined; + onValueChange: (familiarId: string) => void; +}) { + const activeName = activeFamiliar?.name ?? 'Choose familiar'; + + return ( + + + + + {activeName} + + + + + + + + + + + Familiars + { + if (typeof value === 'string') { + onValueChange(value); + } + }} + > + {MOCK_FAMILIARS.map((familiar) => ( + + + + {familiar.name} + {familiar.role} + + + Current + + + ))} + + + + + + + ); +} + +function ConversationSearchDialog({ + conversations, + activeId, + query, + onQueryChange, + onSelect, + onClose, +}: { + conversations: MockConversation[]; + activeId: string; + query: string; + onQueryChange: (query: string) => void; + onSelect: (conversationId: string) => void; + onClose: () => void; +}) { + const panel = useRef(null); + const input = useRef(null); + const normalizedQuery = query.trim().toLowerCase(); + const results = conversations.filter((conversation) => { + const familiar = MOCK_FAMILIARS.find((item) => item.id === conversation.familiarId); + return [conversation.title, conversation.preview, familiar?.name ?? ''] + .join(' ') + .toLowerCase() + .includes(normalizedQuery); + }); + + useEffect(() => { + const previousFocus = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + } + } + + window.addEventListener('keydown', handleKeyDown); + input.current?.focus(); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + previousFocus?.focus(); + }; + }, [onClose]); + + function keepFocusInside(event: ReactKeyboardEvent) { + if (event.key !== 'Tab') { + return; + } + + const focusable = panel.current?.querySelectorAll( + 'input:not([disabled]), button:not([disabled])', + ); + if (!focusable || focusable.length === 0) { + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!first || !last) { + return; + } + + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + + return ( +
+ + ); + }) + ) : ( +

No conversations match “{query.trim()}”.

+ )} +
+ + + ); +} + /** * Message text with a leading slash command emphasised. * @@ -121,7 +358,15 @@ function MessageText({ text }: { text: string }) { * that would have to be licensed and reviewed. It reads as "an image was * generated here" without pretending to be a real model output. */ -function GeneratedImage({ alt, prompt }: { alt: string; prompt: string }) { +function GeneratedImage({ + alt, + prompt, + onOpen, +}: { + alt: string; + prompt: string; + onOpen?: () => void; +}) { // Hue derived from the prompt, so `/image cat purple` and `/image dog blue` // are visibly different generations rather than the same tile twice. let seed = 0; @@ -129,10 +374,11 @@ function GeneratedImage({ alt, prompt }: { alt: string; prompt: string }) { seed += character.charCodeAt(0); } const hue = seed % 360; - const gradientId = `demo-bg-${hue}`; - const glowId = `demo-glow-${hue}`; + const instanceId = useId().replaceAll(':', ''); + const gradientId = `demo-bg-${instanceId}-${hue}`; + const glowId = `demo-glow-${instanceId}-${hue}`; - return ( + const artwork = ( @@ -168,6 +414,86 @@ function GeneratedImage({ alt, prompt }: { alt: string; prompt: string }) { ); + + if (!onOpen) { + return artwork; + } + + return ( + + ); +} + +function ImageLightbox({ + image, + onClose, +}: { + image: NonNullable; + onClose: () => void; +}) { + const closeButton = useRef(null); + + useEffect(() => { + const previousFocus = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + return; + } + + if (event.key === 'Tab') { + event.preventDefault(); + closeButton.current?.focus(); + } + } + + window.addEventListener('keydown', handleKeyDown); + closeButton.current?.focus(); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + previousFocus?.focus(); + }; + }, [onClose]); + + return ( +
+ +
+ ); } /** Abstract hero illustration for a link unfurl. */ @@ -290,12 +616,57 @@ function ArtifactCard({ ); } +function ReasoningBlock({ reasoning }: { reasoning: NonNullable }) { + const stepIcons = { + analysis: 'magnifying-glass', + design: 'paint-brush', + safety: 'check-circle-fill', + } as const; + + return ( +
+ + + Reasoning + + Demo + {reasoning.steps.length} steps + + + +
+

+ + {reasoning.summary} +

+
    + {reasoning.steps.map((step) => ( +
  1. + + + {step.label} + {step.text} + +
  2. + ))} +
+
+
+ ); +} + function MessageRow({ message, + onOpenImage, onOpenArtifact, isStreaming = false, }: { message: MockMessage; + onOpenImage: (image: NonNullable) => void; onOpenArtifact: (artifact: MockArtifact) => void; isStreaming?: boolean; }) { @@ -306,10 +677,15 @@ function MessageRow({ const stamp = {message.sentAt}; if (message.image) { + const image = message.image; + return (
{mine ? stamp : null} - +
+ {message.reasoning ? : null} + onOpenImage(image)} /> +
{mine ? null : stamp}
); @@ -392,15 +768,21 @@ export function ChatDemo() { const [activeId, setActiveId] = useState(MOCK_CONVERSATIONS[0]?.id ?? ''); const [conversationsOpen, setConversationsOpen] = useState(true); const [inspectorOpen, setInspectorOpen] = useState(() => !isNarrowWindow()); + const [conversationsWidth, setConversationsWidth] = useState(320); + const [inspectorWidth, setInspectorWidth] = useState(340); + const [resizingRail, setResizingRail] = useState(null); const isNarrow = useNarrowWindow(); const [draft, setDraft] = useState(''); - const [search, setSearch] = useState(''); + const [conversationSearchOpen, setConversationSearchOpen] = useState(false); + const [conversationSearchQuery, setConversationSearchQuery] = useState(''); const [pendingReply, setPendingReply] = useState(false); const [reader, setReader] = useState(null); + const [focusedImage, setFocusedImage] = useState | null>(null); const [commandIndex, setCommandIndex] = useState(0); /** Id of the message currently streaming, so the composer can offer stop. */ const [streamingId, setStreamingId] = useState(null); const timers = useRef([]); + const resizeCleanup = useRef<(() => void) | null>(null); const replyCount = useRef(0); const transcriptEnd = useRef(null); @@ -408,9 +790,7 @@ export function ChatDemo() { const activeFamiliar = MOCK_FAMILIARS.find((familiar) => familiar.id === active?.familiarId); const messageCount = active?.messages.length ?? 0; - const visible = conversations.filter((conversation) => - conversation.title.toLowerCase().includes(search.trim().toLowerCase()), - ); + const visible = conversations; const showingCommands = draft.trim().startsWith('/') && !draft.trim().includes(' '); const commandMatches = showingCommands @@ -445,6 +825,7 @@ export function ChatDemo() { for (const id of pending) { window.clearTimeout(id); } + resizeCleanup.current?.(); }; }, []); @@ -455,7 +836,13 @@ export function ChatDemo() { }, [conversationsOpen, inspectorOpen, isNarrow]); useEffect(() => { - if (!isNarrow || (!conversationsOpen && !inspectorOpen)) { + if ( + !isNarrow || + conversationSearchOpen || + focusedImage || + reader || + (!conversationsOpen && !inspectorOpen) + ) { return; } @@ -471,7 +858,34 @@ export function ChatDemo() { window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [conversationsOpen, inspectorOpen, isNarrow]); + }, [conversationSearchOpen, conversationsOpen, focusedImage, inspectorOpen, isNarrow, reader]); + + const closeConversationSearch = useCallback(() => { + setConversationSearchOpen(false); + }, []); + + const openConversationSearch = useCallback(() => { + setConversationSearchQuery(''); + setConversationSearchOpen(true); + }, []); + + useEffect(() => { + function handleSearchShortcut(event: KeyboardEvent) { + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'k') { + return; + } + + event.preventDefault(); + if (conversationSearchOpen) { + closeConversationSearch(); + } else { + openConversationSearch(); + } + } + + window.addEventListener('keydown', handleSearchShortcut); + return () => window.removeEventListener('keydown', handleSearchShortcut); + }, [closeConversationSearch, conversationSearchOpen, openConversationSearch]); /** Complete the draft to a command, leaving a trailing space to type into. */ function completeCommand(name: string) { @@ -628,11 +1042,114 @@ export function ChatDemo() { setInspectorOpen(true); } + function selectConversationFromSearch(conversationId: string) { + cancelPending(); + setActiveId(conversationId); + closeConversationSearch(); + if (isNarrow) { + setConversationsOpen(false); + } + } + + function maximumRailWidth(side: RailSide): number { + const otherWidth = + side === 'conversations' + ? inspectorOpen + ? inspectorWidth + : 0 + : conversationsOpen + ? conversationsWidth + : 0; + const limit = RAIL_LIMITS[side]; + + const viewportWidth = typeof window === 'undefined' ? 1440 : window.innerWidth; + + return Math.max(limit.min, Math.min(limit.max, viewportWidth - otherWidth - MIN_THREAD_WIDTH)); + } + + function setRailWidth(side: RailSide, width: number) { + const limit = RAIL_LIMITS[side]; + const nextWidth = Math.min(maximumRailWidth(side), Math.max(limit.min, Math.round(width))); + + if (side === 'conversations') { + setConversationsWidth(nextWidth); + } else { + setInspectorWidth(nextWidth); + } + } + + function beginRailResize(side: RailSide, event: ReactPointerEvent) { + if (isNarrow || event.button !== 0) { + return; + } + + event.preventDefault(); + resizeCleanup.current?.(); + + const startX = event.clientX; + const startWidth = side === 'conversations' ? conversationsWidth : inspectorWidth; + + function handlePointerMove(pointerEvent: PointerEvent) { + const delta = + side === 'conversations' ? pointerEvent.clientX - startX : startX - pointerEvent.clientX; + setRailWidth(side, startWidth + delta); + } + + function finishResize() { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', finishResize); + window.removeEventListener('pointercancel', finishResize); + resizeCleanup.current = null; + setResizingRail(null); + } + + setResizingRail(side); + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', finishResize); + window.addEventListener('pointercancel', finishResize); + resizeCleanup.current = finishResize; + } + + function resizeRailWithKeyboard(side: RailSide, event: ReactKeyboardEvent) { + const currentWidth = side === 'conversations' ? conversationsWidth : inspectorWidth; + const outwardKey = side === 'conversations' ? 'ArrowRight' : 'ArrowLeft'; + const inwardKey = side === 'conversations' ? 'ArrowLeft' : 'ArrowRight'; + let nextWidth: number | undefined; + + if (event.key === outwardKey) { + nextWidth = currentWidth + (event.shiftKey ? RAIL_KEYBOARD_STEP * 4 : RAIL_KEYBOARD_STEP); + } else if (event.key === inwardKey) { + nextWidth = currentWidth - (event.shiftKey ? RAIL_KEYBOARD_STEP * 4 : RAIL_KEYBOARD_STEP); + } else if (event.key === 'Home') { + nextWidth = RAIL_LIMITS[side].min; + } else if (event.key === 'End') { + nextWidth = maximumRailWidth(side); + } + + if (nextWidth !== undefined) { + event.preventDefault(); + setRailWidth(side, nextWidth); + } + } + + const closeFocusedImage = useCallback(() => { + setFocusedImage(null); + }, []); + + const layoutStyle: CSSProperties & { + '--demo-conversations-open-width': string; + '--demo-inspector-open-width': string; + } = { + '--demo-conversations-open-width': `${conversationsWidth}px`, + '--demo-inspector-open-width': `${inspectorWidth}px`, + }; + return (
- -
-
- {conversationsOpen ? ( - - ) : ( +

- )} +

+
-
- - {activeFamiliar?.name ?? 'No agent'} -
+
+ - {inspectorOpen ? ( - - ) : ( +
+ - )} - +
+
+ +
+ {visible.length > 0 ? ( +

+ Recent + {visible.length} +

+ ) : null} + +
    + {visible.map((conversation) => { + const conversationFamiliar = MOCK_FAMILIARS.find( + (familiar) => familiar.id === conversation.familiarId, + ); + const conversationStatus = conversationFamiliar?.status ?? 'offline'; + + return ( +
  • + +
  • + ); + })} +
+
+
beginRailResize('conversations', event)} + onKeyDown={(event) => resizeRailWithKeyboard('conversations', event)} + /> + + +
+ {!conversationsOpen ? ( + + ) : null} + + {!inspectorOpen ? ( + + ) : null}
{active ? ( @@ -788,6 +1300,7 @@ export function ChatDemo() { key={message.id} message={message} isStreaming={message.id === streamingId} + onOpenImage={setFocusedImage} onOpenArtifact={(artifact) => setReader({ kind: artifact.kind, @@ -928,10 +1441,32 @@ export function ChatDemo() {
{reader ? setReader(null)} /> : null} + {focusedImage ? : null} + {conversationSearchOpen ? ( + + ) : null}
); } diff --git a/src/demo/chat-shell.test.tsx b/src/demo/chat-shell.test.tsx index 4c619d0..617d1c5 100644 --- a/src/demo/chat-shell.test.tsx +++ b/src/demo/chat-shell.test.tsx @@ -31,7 +31,7 @@ describe('chat demo shell', () => { it('collapses and restores both side panels independently', () => { render(); - fireEvent.click(screen.getByRole('button', { name: 'Hide conversations' })); + fireEvent.click(screen.getByText('Conversations', { selector: '.sidebar-title-label' })); expect(document.getElementById('conversation-panel')).not.toBeVisible(); expect(screen.getByRole('button', { name: 'Show conversations' })).toHaveAttribute( 'aria-expanded', @@ -39,14 +39,148 @@ describe('chat demo shell', () => { ); fireEvent.click(screen.getByRole('button', { name: 'Hide agent inspector' })); - const header = screen.getByRole('banner'); - expect(within(header).getByRole('button', { name: 'Show conversations' })).toBeVisible(); - expect(within(header).getByRole('button', { name: 'Show agent inspector' })).toBeVisible(); + const thread = screen.getByRole('main'); + expect(within(thread).getByRole('button', { name: 'Show conversations' })).toBeVisible(); + expect(within(thread).getByRole('button', { name: 'Show agent inspector' })).toBeVisible(); fireEvent.click(screen.getByRole('button', { name: 'Show conversations' })); expect(screen.getByRole('complementary', { name: 'Conversations' })).toBeVisible(); }); + it('resizes both side rails with accessible keyboard separators', () => { + render(); + + const conversationsHandle = screen.getByRole('separator', { + name: 'Resize conversations sidebar', + }); + const inspectorHandle = screen.getByRole('separator', { + name: 'Resize agent inspector', + }); + + expect(conversationsHandle).toHaveAttribute('aria-valuenow', '320'); + fireEvent.keyDown(conversationsHandle, { key: 'ArrowLeft' }); + expect(conversationsHandle).toHaveAttribute('aria-valuenow', '308'); + + expect(inspectorHandle).toHaveAttribute('aria-valuenow', '340'); + fireEvent.keyDown(inspectorHandle, { key: 'ArrowLeft' }); + expect(inspectorHandle).toHaveAttribute('aria-valuenow', '352'); + }); + + it('groups New Chat and command search in one compact action row', () => { + render(); + + const sidebar = screen.getByRole('complementary', { name: 'Conversations' }); + const toggle = within(sidebar).getByRole('button', { name: 'Hide conversations' }); + const newConversation = within(sidebar).getByRole('button', { name: 'Start a new chat' }); + const search = within(sidebar).getByRole('button', { name: 'Search conversations' }); + + expect(within(sidebar).getByRole('heading', { name: 'Conversations' })).toBeVisible(); + expect(toggle.querySelector('svg')).not.toBeNull(); + expect(newConversation.parentElement).toBe(search.parentElement); + expect(newConversation.parentElement).toHaveClass('sidebar-primary-actions'); + expect(within(sidebar).queryByRole('searchbox')).not.toBeInTheDocument(); + }); + + it('opens conversation search from its button and the command shortcut', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Search conversations' })); + let dialog = screen.getByRole('dialog', { name: 'Search conversations' }); + const input = within(dialog).getByRole('searchbox', { name: 'Search conversations' }); + expect(input).toHaveFocus(); + + fireEvent.change(input, { target: { value: 'new' } }); + expect(within(dialog).queryByRole('button', { name: /Quick Chat/ })).not.toBeInTheDocument(); + fireEvent.click(within(dialog).getByRole('button', { name: /New Chat/ })); + expect(dialog).not.toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Cody' })).toBeVisible(); + + fireEvent.keyDown(window, { key: 'k', metaKey: true }); + dialog = screen.getByRole('dialog', { name: 'Search conversations' }); + expect(dialog).toBeVisible(); + + fireEvent.keyDown(window, { key: 'k', metaKey: true }); + expect(dialog).not.toBeInTheDocument(); + }); + + it('groups conversations with Cave-style status structure', () => { + render(); + + const sidebar = screen.getByRole('complementary', { name: 'Conversations' }); + expect(within(sidebar).getByRole('heading', { name: /Recent/ })).toBeVisible(); + expect( + within(sidebar).getByText('2', { selector: '.conversation-section-count' }), + ).toBeVisible(); + expect(sidebar.querySelector('.conversation-scroll')).toHaveClass('is-sparse'); + expect(within(sidebar).getByRole('button', { name: 'Quick Chat' })).toHaveAttribute( + 'data-status', + 'available', + ); + expect(within(sidebar).getByRole('button', { name: 'New Chat' })).toHaveAttribute( + 'data-status', + 'working', + ); + }); + + it('opens generated images in a dismissible focused preview', () => { + render(); + + fireEvent.click( + screen.getByRole('button', { name: 'Expand image: A purple cat in a glowing garden' }), + ); + expect( + screen.getByRole('dialog', { name: 'Expanded image: A purple cat in a glowing garden' }), + ).toBeVisible(); + const closeButton = screen.getByRole('button', { name: 'Close image preview' }); + expect(closeButton).toHaveFocus(); + + fireEvent.keyDown(window, { key: 'Tab' }); + expect(closeButton).toHaveFocus(); + + fireEvent.keyDown(window, { key: 'Escape' }); + expect( + screen.queryByRole('dialog', { name: 'Expanded image: A purple cat in a glowing garden' }), + ).not.toBeInTheDocument(); + + fireEvent.click( + screen.getByRole('button', { name: 'Expand image: A purple cat in a glowing garden' }), + ); + const dialog = screen.getByRole('dialog', { + name: 'Expanded image: A purple cat in a glowing garden', + }); + fireEvent.click(within(dialog).getByRole('img')); + expect(dialog).toBeVisible(); + + const backdrop = dialog.querySelector('.image-lightbox-backdrop'); + if (!(backdrop instanceof HTMLElement)) { + throw new Error('Image lightbox backdrop was not rendered'); + } + fireEvent.click(backdrop); + expect(dialog).not.toBeInTheDocument(); + }); + + it('shows concise demo reasoning for an agent response', () => { + render(); + + const reasoningLabel = screen.getByText('Reasoning', { selector: '.reasoning-label' }); + const reasoning = reasoningLabel.closest('details'); + expect(reasoning).toBeVisible(); + expect(reasoning).toHaveAttribute('open'); + expect(reasoning).toHaveTextContent('Identified “cat” as the subject'); + expect(reasoning).toHaveTextContent('Demo'); + expect(reasoning).toHaveTextContent('Interpret prompt'); + expect(reasoning).toHaveTextContent('Compose image'); + expect(reasoning).toHaveTextContent('Keep it local'); + expect(reasoning?.querySelectorAll('.reasoning-step-icon .mm-icon')).toHaveLength(3); + + const summary = reasoningLabel.closest('summary'); + if (!(summary instanceof HTMLElement)) { + throw new Error('Reasoning summary was not rendered'); + } + fireEvent.click(summary); + expect(reasoning).not.toHaveAttribute('open'); + }); + it('updates the inspector when the active conversation changes agent', () => { render(); @@ -58,13 +192,28 @@ describe('chat demo shell', () => { it('switches the active familiar from the left rail', () => { render(); - expect(screen.queryByRole('combobox', { name: 'Agent for this conversation' })).toBeNull(); - fireEvent.change(screen.getByRole('combobox', { name: 'Active familiar' }), { - target: { value: 'cody' }, - }); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Sidebar familiar: Astra' })); + expect(screen.getByRole('menu', { name: 'Sidebar familiar: Astra' })).toHaveAttribute( + 'data-open', + ); + expect(screen.getByRole('group', { name: 'Familiars' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('menuitemradio', { name: /Cody/ })); expect(screen.getByRole('heading', { name: 'Cody' })).toBeVisible(); - expect(screen.getByRole('combobox', { name: 'Active familiar' })).toHaveValue('cody'); + expect(screen.getByRole('button', { name: 'Sidebar familiar: Cody' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + }); + + it('does not reserve a top row for the active familiar', () => { + render(); + + expect(document.querySelector('.thread-header')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Active familiar: Astra' }), + ).not.toBeInTheDocument(); }); it('keeps one narrow overlay open at a time and closes it with Escape', () => { @@ -136,6 +285,8 @@ describe('chat demo shell', () => { expect(css).toContain('--demo-glass: rgba(37, 38, 44, 0.72)'); expect(css).toContain('backdrop-filter: blur(24px) saturate(140%)'); expect(css).toContain('.chat-demo.is-conversations-closed'); + expect(css).toContain('@media (min-width: 1100px)'); + expect(css).toContain('.chat-demo .message-stack {\n display: grid;\n width: 100%'); expect(css).toContain('.chat-demo.is-inspector-closed'); expect(css).toContain('.chat-demo [hidden]'); expect(css).toMatch(/\.chat-demo \.sidebar\s*{[^}]*grid-column: 1/s); diff --git a/src/demo/mock-data.ts b/src/demo/mock-data.ts index 3a67c09..ac6d879 100644 --- a/src/demo/mock-data.ts +++ b/src/demo/mock-data.ts @@ -29,6 +29,21 @@ export type MockMessage = { sentAt: string; /** Plain body text. A `/command` prefix is styled by the renderer. */ text?: string; + /** + * A concise, user-facing rationale summary for the demo. + * + * This is intentionally structured explanatory content, not hidden model + * chain-of-thought. Production data will replace it with an authority-owned + * summary when that contract exists. + */ + reasoning?: { + summary: string; + steps: Array<{ + kind: 'analysis' | 'design' | 'safety'; + label: string; + text: string; + }>; + }; /** * Set when the turn is a generated image rather than prose. * @@ -81,6 +96,26 @@ export const MOCK_CONVERSATIONS: MockConversation[] = [ id: 'm4', role: 'assistant', sentAt: '10:41 PM', + reasoning: { + summary: 'Built a compact visual response from the two constraints in the prompt.', + steps: [ + { + kind: 'analysis', + label: 'Interpret prompt', + text: 'Identified “cat” as the subject and “purple” as the palette constraint.', + }, + { + kind: 'design', + label: 'Compose image', + text: 'Used a centered silhouette so the subject remains legible at transcript size.', + }, + { + kind: 'safety', + label: 'Keep it local', + text: 'Rendered a deterministic SVG so this demo does not call an external model.', + }, + ], + }, image: { alt: 'A purple cat in a glowing garden', prompt: 'cat purple' }, }, ], diff --git a/src/phase1-artifact-secret-scan.test.ts b/src/phase1-artifact-secret-scan.test.ts index e332b00..648e9c4 100644 --- a/src/phase1-artifact-secret-scan.test.ts +++ b/src/phase1-artifact-secret-scan.test.ts @@ -3,11 +3,11 @@ import { resolve } from 'node:path'; import { afterEach, describe, expect, test } from 'vitest'; +import { scanPhase1Artifacts } from '../scripts/phase1-artifact-secret-scan.mjs'; import { - APPROVED_PHASE1_DIAGNOSTIC_IDS, - REQUIRED_PHASE1_ASSERTION_IDS, - scanPhase1Artifacts, -} from '../scripts/phase1-artifact-secret-scan.mjs'; + buildPlatformEvidence, + windowsSupervisorDiagnosticId, +} from '../scripts/phase1-evidence-contract.mjs'; import { createProcessOwnedArtifactRoot, type ProcessOwnedArtifactRoot, @@ -29,46 +29,129 @@ function createRoot() { } function validReport() { - return { - schemaVersion: 1, - completed: true, - status: 'passed', - platform: { - os: process.platform, - arch: process.arch, + const registry = { + schemaVersion: 1 as const, + cave: { + engine: 'scripts/client-v1-conformance.mjs' as const, + requireIncludeTtl: true as const, + requireAuthorityTakeover: true as const, }, - versions: { - node: process.versions.node, - harness: '1.0.0', + sdk: ['sdk.install.packed-tarballs', 'sdk.provenance.fixture-bytes-match', 'sdk.coven.health'], + chat: { + common: [ + 'chat.install.consumer-lock-matches', + 'chat.native.keychain-unavailable-fails-closed', + 'chat.deadline.total-bounded', + 'chat.coven.health', + ], + platforms: { + 'darwin-arm64': ['chat.coven.unix.connected-peer-identity'], + 'linux-x64': ['chat.coven.unix.connected-peer-identity'], + 'win32-x64': ['chat.coven.windows.connected-pipe-identity'], + }, }, - revisions: { - chat: '0021d30d0cddc5d3f00a41c55d025cf3ce4611c5', - sdk: '163961f4e59cfdef51d2271fa98e7c514977203f', - cave: '061ddca45ab00028ecc0335face6239e5553f24a', - coven: '721437b84026c042e431b0882dcd14fdb29ac07d', + }; + const passing = (ids: string[]) => + ids.map((id) => ({ + id, + result: 'pass' as const, + diagnosticId: + id === 'sdk.install.packed-tarballs' + ? `phase1.sdk-candidate.${'a'.repeat(40)}` + : id === 'sdk.provenance.fixture-bytes-match' + ? `phase1.sdk-manifest.${'b'.repeat(64)}` + : id === 'chat.install.consumer-lock-matches' + ? `phase1.chat-harness.${'c'.repeat(40)}` + : id === 'chat.native.keychain-unavailable-fails-closed' + ? 'phase1.toolchain.rust.1.95.0' + : id === 'chat.deadline.total-bounded' + ? windowsSupervisorDiagnosticId({ + windowsSupervisorSha256: 'd'.repeat(64), + mingwPackageVersion: 'mingw-w64 14.0.0_3', + mingwHomebrewCoreRevision: 'cd168d1fdc26f12e4ad64f358ff2dbec61ab7a57', + mingwBottleLayerSha256: + '0d68ab737a8bbc8c63ac6ac7acc0695e2887c1169df9a4423f1180090079b1d5', + mingwLinkerVersion: '2.47.20260726', + }) + : 'phase1.assertion.passed', + })); + return buildPlatformEvidence({ + registry, + platform: 'darwin-arm64', + caveRecord: { + ranAt: '2026-08-29T04:00:00.000Z', + caveVersion: '0.3.11', + commit: '2'.repeat(40), + platform: 'darwin-arm64', + nodeVersion: 'v24.18.1', + includeTtl: true, + authorityTakeover: { + authorityMode: 'enforce', + discoveryVersion: 2, + mechanism: 'hpke-bound-v1', + }, + assertions: [], }, - artifactDigests: { - 'chat-bundle': 'a'.repeat(64), - 'sdk-core': 'b'.repeat(64), + releases: { cave: '0.3.11', coven: '0.1.0' }, + commits: { + cave: '2'.repeat(40), + coven: '3'.repeat(40), + sdk: '4'.repeat(40), + chat: '5'.repeat(40), }, - assertions: REQUIRED_PHASE1_ASSERTION_IDS.map((id) => ({ - id, - status: 'passed', - diagnosticIds: ['phase1.assertion.passed'], - })), - summary: { - required: REQUIRED_PHASE1_ASSERTION_IDS.length, - passed: REQUIRED_PHASE1_ASSERTION_IDS.length, - failed: 0, - blocked: 0, - skipped: 0, + digests: { + caveAssertionEngine: 'a'.repeat(64), + caveContractFixture: 'b'.repeat(64), + hpkeVectors: 'c'.repeat(64), + consumerLock: 'd'.repeat(64), + assertionRegistry: 'e'.repeat(64), + sdkTarballs: [ + { packageName: '@opencoven/sdk-core', sha256: '1'.repeat(64) }, + { packageName: '@opencoven/cave-client', sha256: '2'.repeat(64) }, + { packageName: '@opencoven/coven-client', sha256: '3'.repeat(64) }, + { packageName: '@opencoven/sdk', sha256: '4'.repeat(64) }, + ], }, - diagnosticIds: ['phase1.conformance.passed'], - }; + sdkAssertions: passing(registry.sdk), + chatAssertions: passing([...registry.chat.common, ...registry.chat.platforms['darwin-arm64']]), + environment: { + nodeVersion: 'v24.18.1', + packageManagerVersion: 'pnpm@10.34.0', + }, + metadata: { + sdkCandidateRevision: 'a'.repeat(40), + sdkManifestSha256: 'b'.repeat(64), + chatHarnessRevision: 'c'.repeat(40), + windowsSupervisorSha256: 'd'.repeat(64), + mingwPackageVersion: 'mingw-w64 14.0.0_3', + mingwHomebrewCoreRevision: 'cd168d1fdc26f12e4ad64f358ff2dbec61ab7a57', + mingwBottleLayerSha256: '0d68ab737a8bbc8c63ac6ac7acc0695e2887c1169df9a4423f1180090079b1d5', + mingwLinkerVersion: '2.47.20260726', + rustVersion: '1.95.0', + }, + isolation: { + roots: [ + { id: 'cave-home', ownershipVerified: true, removedAfterRun: true }, + { id: 'coven-home', ownershipVerified: true, removedAfterRun: true }, + { id: 'consumer-home', ownershipVerified: true, removedAfterRun: true }, + { id: 'native-credential-store', ownershipVerified: true, removedAfterRun: true }, + ], + operatorState: [ + { id: 'cave-home', beforeSha256: '6'.repeat(64), afterSha256: '6'.repeat(64) }, + { id: 'coven-home', beforeSha256: '7'.repeat(64), afterSha256: '7'.repeat(64) }, + { + id: 'native-credential-store', + beforeSha256: '8'.repeat(64), + afterSha256: '8'.repeat(64), + }, + { id: 'projects', beforeSha256: '9'.repeat(64), afterSha256: '9'.repeat(64) }, + ], + }, + }); } function assertionAt(report: ReturnType, index: number) { - const assertion = report.assertions[index]; + const assertion = report.chatAssertions[index]; if (assertion === undefined) { throw new Error(`missing test assertion at index ${index}`); } @@ -82,8 +165,7 @@ async function scanReport(report: unknown) { } describe('Phase 1 retained-artifact secret scan', () => { - test('accepts only the approved secret-free report schema', async () => { - expect(APPROVED_PHASE1_DIAGNOSTIC_IDS).toContain('phase1.conformance.passed'); + test('accepts the exact secret-free SDK platform record schema', async () => { const result = await scanReport(validReport()); expect(result).toEqual({ @@ -93,6 +175,19 @@ describe('Phase 1 retained-artifact secret scan', () => { }); }); + test('rejects evidence produced by a different Node 24 release', async () => { + const valid = validReport(); + const report = { + ...valid, + environment: { + ...valid.environment, + nodeVersion: 'v24.19.0', + }, + }; + + await expect(scanReport(report)).rejects.toThrow(/environment is invalid/); + }); + test.each([ ['pairing secret', { pairingSecret: 'pairing-secret-value' }], ['bearer', { bearer: 'Bearer eyJhbGciOiJIUzI1NiJ9.secret-value' }], @@ -102,6 +197,8 @@ describe('Phase 1 retained-artifact secret scan', () => { ['protected response plaintext', { protectedResponsePlaintext: '{"message":"private"}' }], ['user prompt', { prompt: 'private user prompt' }], ['message body', { messageBody: 'private conversation content' }], + ['generic content field', { content: 'private conversation content' }], + ['command output', { commandOutput: 'private subprocess output' }], ['attachment', { attachment: 'private-document.pdf' }], ['private macOS path', { diagnostic: '/Users/private-user/Library/Keychains/login.keychain' }], ['private Linux path', { diagnostic: '/home/private-user/.config/opencoven' }], @@ -118,44 +215,42 @@ describe('Phase 1 retained-artifact secret scan', () => { ); }); - test('rejects unknown assertion and diagnostic identifiers', async () => { + test('rejects noncanonical assertion and diagnostic identifiers', async () => { const unknownAssertion = validReport(); - unknownAssertion.assertions[0] = { + unknownAssertion.chatAssertions[0] = { ...assertionAt(unknownAssertion, 0), - id: 'phase1.unknown.assertion', + id: 'UPPERCASE', }; - await expect(scanReport(unknownAssertion)).rejects.toThrow(/approved assertion ID/); + await expect(scanReport(unknownAssertion)).rejects.toThrow(/canonical assertion ID/); const unknownDiagnostic = validReport(); - unknownDiagnostic.diagnosticIds = ['phase1.unknown.diagnostic']; - await expect(scanReport(unknownDiagnostic)).rejects.toThrow(/approved diagnostic ID/); + unknownDiagnostic.chatAssertions[0] = { + ...assertionAt(unknownDiagnostic, 0), + diagnosticId: 'contains spaces', + }; + await expect(scanReport(unknownDiagnostic)).rejects.toThrow(/canonical diagnostic ID/); }); - test('rejects duplicate or missing assertion identifiers', async () => { + test('rejects duplicate assertion identifiers', async () => { const duplicate = validReport(); - duplicate.assertions[1] = { + duplicate.chatAssertions[1] = { ...assertionAt(duplicate, 1), id: assertionAt(duplicate, 0).id, }; - await expect(scanReport(duplicate)).rejects.toThrow(/exact required assertion set/); - - const missing = validReport(); - missing.assertions.pop(); - await expect(scanReport(missing)).rejects.toThrow(/exact required assertion set/); + await expect(scanReport(duplicate)).rejects.toThrow(/duplicate assertion ID/); }); - test('rejects skipped assertions and inconsistent summary counts', async () => { + test('rejects skipped assertions and unsupported platform IDs', async () => { const skipped = validReport(); - skipped.assertions[0] = { + skipped.chatAssertions[0] = { ...assertionAt(skipped, 0), - status: 'skipped' as never, + result: 'skip' as never, }; - await expect(scanReport(skipped)).rejects.toThrow(/approved pass-fail status/); + await expect(scanReport(skipped)).rejects.toThrow(/must not skip/); - const inconsistent = validReport(); - inconsistent.summary.passed -= 1; - inconsistent.summary.failed += 1; - await expect(scanReport(inconsistent)).rejects.toThrow(/summary does not match/); + const unsupported = validReport(); + unsupported.platform = 'darwin-x64'; + await expect(scanReport(unsupported)).rejects.toThrow(/supported platform/); }); test('rejects non-JSON artifacts and artifact symlinks without following them', async () => { diff --git a/src/phase1-conformance-artifact-root.test.ts b/src/phase1-conformance-artifact-root.test.ts index 440d8f7..4a69bb1 100644 --- a/src/phase1-conformance-artifact-root.test.ts +++ b/src/phase1-conformance-artifact-root.test.ts @@ -1,4 +1,4 @@ -import { type ChildProcess, spawn } from 'node:child_process'; +import { type ChildProcess, execFileSync, spawn } from 'node:child_process'; import { chmodSync, existsSync, @@ -72,6 +72,54 @@ function createScratchRoot(prefix: string) { return scratchRoot; } +function processIsLive(pid: number) { + try { + const state = execFileSync('ps', ['-o', 'stat=', '-p', String(pid)], { + encoding: 'utf8', + }).trim(); + return state.length > 0 && !state.startsWith('Z'); + } catch { + return false; + } +} + +function writeSupervisorWorker(root: ProcessOwnedArtifactRoot, name: string, source: string) { + const workerPath = resolve(root.rootPath, name); + writeFileSync(workerPath, `#!/usr/bin/env node\n${source}\n`); + chmodSync(workerPath, 0o700); + return workerPath; +} + +async function spawnSupervisor(workerPath: string, stderr: 'ignore' | 'pipe' = 'ignore') { + const supervisorPath = resolve( + import.meta.dirname, + '..', + 'scripts', + 'phase1-process-supervisor.mjs', + ); + const supervisor = spawn(process.execPath, [supervisorPath, workerPath], { + detached: true, + stdio: ['ignore', 'ignore', stderr, 'pipe'], + }); + const status = supervisor.stdio[3]; + if (status !== undefined && status !== null && 'resume' in status) status.resume(); + activeChildren.add(supervisor); + await new Promise((resolveSpawn, rejectSpawn) => { + supervisor.once('spawn', resolveSpawn); + supervisor.once('error', rejectSpawn); + }); + return supervisor; +} + +function minimalEvidence(overrides: Record = {}) { + return `${JSON.stringify({ + schemaVersion: 1, + issue: 'OpenCoven/sdk#38', + platform: 'darwin-arm64', + ...overrides, + })}\n`; +} + async function spawnChild(source: string) { const child = spawn(process.execPath, ['-e', source], { stdio: 'ignore', @@ -140,138 +188,213 @@ describe('process-owned artifact root', () => { expect(existsSync(root.rootPath)).toBe(false); }); + test('continues reverse-order child cleanup after failure and retains root until retry succeeds', async () => { + const root = createRoot({ terminationGraceMs: 50 }); + const first = await spawnChild('setInterval(() => {}, 1_000)'); + const later = await spawnChild('setInterval(() => {}, 1_000)'); + const failing = await spawnChild('setInterval(() => {}, 1_000)'); + const originalKill = failing.kill.bind(failing); + failing.kill = (() => false) as typeof failing.kill; + root.trackChild(first); + root.trackChild(later); + root.trackChild(failing); + + await expect(root.cleanup()).rejects.toMatchObject({ + errors: [expect.any(Error)], + }); + expect(root.reapedChildren).toEqual([later.pid, first.pid]); + expect(existsSync(root.rootPath)).toBe(true); + expect(later.exitCode ?? later.signalCode).not.toBeNull(); + expect(first.exitCode ?? first.signalCode).not.toBeNull(); + expect(failing.exitCode).toBeNull(); + + failing.kill = originalKill; + await root.cleanup(); + activeRoots.delete(root); + expect(root.reapedChildren).toEqual([later.pid, first.pid, failing.pid]); + expect(existsSync(root.rootPath)).toBe(false); + }); + test.skipIf(process.platform === 'win32')( - 'escalates an uncooperative tracked child to SIGKILL and reaps it', + 'supervisor terminates its owned descendant tree without touching an unrelated process', async () => { - const root = createRoot({ terminationGraceMs: 50 }); - const child = spawn( - process.execPath, + const root = createRoot({ terminationGraceMs: 100 }); + const descendantPath = resolve(root.rootPath, 'descendant.pid'); + const workerPath = writeSupervisorWorker( + root, + 'supervised-worker.cjs', [ - '-e', - "process.on('SIGTERM', () => {}); process.stdout.write('ready'); setInterval(() => {}, 1_000)", - ], - { stdio: ['ignore', 'pipe', 'ignore'] }, + "const { spawn } = require('node:child_process');", + "const { writeFileSync } = require('node:fs');", + "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'ignore', process.stderr] });", + `writeFileSync(${JSON.stringify(descendantPath)}, String(child.pid));`, + 'setInterval(() => {}, 1000);', + ].join(''), ); - activeChildren.add(child); + const supervisor = await spawnSupervisor(workerPath, 'pipe'); + const unrelated = await spawnChild('setInterval(() => {}, 1_000)'); await new Promise((resolveReady, rejectReady) => { - if (child.stdout === null) { - rejectReady(new Error('tracked child stdout was not piped')); - return; - } - child.stdout.once('data', () => resolveReady()); - child.once('error', rejectReady); + const deadline = Date.now() + 5_000; + const poll = () => { + if (existsSync(descendantPath)) { + resolveReady(); + } else if (Date.now() >= deadline) { + rejectReady(new Error('descendant PID was not published')); + } else { + setTimeout(poll, 10); + } + }; + poll(); }); + const descendantPid = Number(readFileSync(descendantPath, 'utf8')); - root.trackChild(child); + root.trackChild(supervisor); await root.cleanup(); activeRoots.delete(root); - expect(child.signalCode).toBe('SIGKILL'); - expect(root.reapedChildren).toEqual([child.pid]); + expect(() => process.kill(descendantPid, 0)).toThrow(); + expect(unrelated.exitCode).toBeNull(); + expect(unrelated.signalCode).toBeNull(); }, ); test.skipIf(process.platform === 'win32')( - 'terminates descendants in an owned process group', + 'supervisor cleans descendants when its RPC exits while a pipe remains inherited', async () => { - const root = createRoot(); - const parent = spawn( - process.execPath, + const root = createRoot({ terminationGraceMs: 100 }); + const descendantPath = resolve(root.rootPath, 'exited-root-descendant.pid'); + const workerPath = writeSupervisorWorker( + root, + 'crashing-supervised-worker.cjs', [ - '-e', - [ - "const { spawn } = require('node:child_process');", - "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)'], { stdio: 'ignore' });", - 'child.unref();', - 'process.stdout.write(String(child.pid));', - 'setInterval(() => {}, 1_000);', - ].join(' '), - ], - { - detached: true, - stdio: ['ignore', 'pipe', 'ignore'], - }, + "const { spawn } = require('node:child_process');", + "const { writeFileSync } = require('node:fs');", + "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { stdio: ['ignore', 'ignore', process.stderr] });", + `writeFileSync(${JSON.stringify(descendantPath)}, String(child.pid));`, + 'child.unref();', + 'process.exitCode = 7;', + ].join(''), ); - activeChildren.add(parent); - const grandchildPid = await new Promise((resolvePid, rejectPid) => { - if (parent.stdout === null) { - rejectPid(new Error('tracked parent stdout was not piped')); - return; + const supervisor = await spawnSupervisor(workerPath, 'pipe'); + root.trackChild(supervisor); + if (supervisor.exitCode === null && supervisor.signalCode === null) { + await new Promise((resolveExit) => supervisor.once('exit', () => resolveExit())); + } + const descendantPid = Number(readFileSync(descendantPath, 'utf8')); + const unrelated = await spawnChild('setInterval(() => {}, 1_000)'); + const deadline = Date.now() + 5_000; + while (processIsLive(descendantPid)) { + if (Date.now() >= deadline) { + throw new Error('descendant survived its exited process-group root'); } - parent.stdout.once('data', (chunk) => resolvePid(Number(String(chunk)))); - parent.once('error', rejectPid); - }); + await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + } - root.trackChild(parent, { processGroup: true }); await root.cleanup(); activeRoots.delete(root); + expect(unrelated.exitCode).toBeNull(); + expect(unrelated.signalCode).toBeNull(); + }, + ); - expect(parent.signalCode).toBe('SIGKILL'); - let grandchildAlive = true; - try { - process.kill(grandchildPid, 0); - } catch { - grandchildAlive = false; + test.skipIf(process.platform === 'win32')( + 'supervisor kills a detached-stdio descendant that ignores graceful termination', + async () => { + const root = createRoot({ terminationGraceMs: 100 }); + const descendantPath = resolve(root.rootPath, 'ignoring-descendant.pid'); + const workerPath = writeSupervisorWorker( + root, + 'ignoring-supervised-worker.cjs', + [ + "const { spawn } = require('node:child_process');", + "const { writeFileSync } = require('node:fs');", + "const child = spawn(process.execPath, ['-e', \"process.on('SIGTERM',()=>{});setInterval(()=>{},1000)\"], { stdio: 'ignore' });", + `writeFileSync(${JSON.stringify(descendantPath)}, String(child.pid));`, + 'child.unref();', + 'process.exitCode = 7;', + ].join(''), + ); + const supervisor = await spawnSupervisor(workerPath); + root.trackChild(supervisor); + if (supervisor.exitCode === null && supervisor.signalCode === null) { + await new Promise((resolveExit) => supervisor.once('exit', () => resolveExit())); } - try { - expect(grandchildAlive).toBe(false); - } finally { - if (grandchildAlive) { - process.kill(grandchildPid, 'SIGKILL'); + const descendantPid = Number(readFileSync(descendantPath, 'utf8')); + const deadline = Date.now() + 5_000; + while (processIsLive(descendantPid)) { + if (Date.now() >= deadline) { + throw new Error('SIGTERM-ignoring descendant survived supervisor escalation'); } + await new Promise((resolveWait) => setTimeout(resolveWait, 10)); } + await root.cleanup(); + activeRoots.delete(root); }, ); test.skipIf(process.platform === 'win32')( - 'refuses to signal a process group after its tracked leader exited', + 'supervisor kills detached descendants after a successful RPC exit', async () => { - const root = createRoot(); - const parent = spawn( + const root = createRoot({ terminationGraceMs: 100 }); + const descendantPath = resolve(root.rootPath, 'successful-descendant.pid'); + const workerPath = writeSupervisorWorker( + root, + 'successful-supervised-worker.cjs', + [ + "const { spawn } = require('node:child_process');", + "const { writeFileSync } = require('node:fs');", + "const child = spawn(process.execPath, ['-e', \"process.on('SIGTERM',()=>{});setInterval(()=>{},1000)\"], { stdio: 'ignore' });", + `writeFileSync(${JSON.stringify(descendantPath)}, String(child.pid));`, + 'child.unref();', + ].join(''), + ); + const supervisor = await spawnSupervisor(workerPath); + root.trackChild(supervisor); + if (supervisor.exitCode === null && supervisor.signalCode === null) { + await new Promise((resolveExit) => supervisor.once('exit', () => resolveExit())); + } + expect(supervisor.signalCode).toBe('SIGKILL'); + const descendantPid = Number(readFileSync(descendantPath, 'utf8')); + const deadline = Date.now() + 5_000; + while (processIsLive(descendantPid)) { + if (Date.now() >= deadline) { + throw new Error('descendant survived successful supervised RPC exit'); + } + await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + } + await root.cleanup(); + activeRoots.delete(root); + }, + ); + + test.skipIf(process.platform === 'win32')( + 'escalates an uncooperative tracked child to SIGKILL and reaps it', + async () => { + const root = createRoot({ terminationGraceMs: 50 }); + const child = spawn( process.execPath, [ '-e', - [ - "const { spawn } = require('node:child_process');", - "const child = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1_000)'], { stdio: 'ignore' });", - 'child.unref();', - 'process.stdout.write(String(child.pid));', - ].join(' '), + "process.on('SIGTERM', () => {}); process.stdout.write('ready'); setInterval(() => {}, 1_000)", ], - { - detached: true, - stdio: ['ignore', 'pipe', 'ignore'], - }, + { stdio: ['ignore', 'pipe', 'ignore'] }, ); - activeChildren.add(parent); - root.trackChild(parent, { processGroup: true }); - const grandchildPid = await new Promise((resolvePid, rejectPid) => { - if (parent.stdout === null) { - rejectPid(new Error('tracked parent stdout was not piped')); - return; - } - parent.stdout.once('data', (chunk) => resolvePid(Number(String(chunk)))); - parent.once('error', rejectPid); - }); - await new Promise((resolveClose) => { - if (parent.exitCode !== null || parent.signalCode !== null) { - resolveClose(); + activeChildren.add(child); + await new Promise((resolveReady, rejectReady) => { + if (child.stdout === null) { + rejectReady(new Error('tracked child stdout was not piped')); return; } - parent.once('close', () => resolveClose()); + child.stdout.once('data', () => resolveReady()); + child.once('error', rejectReady); }); - try { - await expect(root.cleanup()).rejects.toThrow( - /exited while its process group still had descendants/, - ); - expect(() => process.kill(grandchildPid, 0)).not.toThrow(); - } finally { - process.kill(grandchildPid, 'SIGKILL'); - } - await new Promise((resolveWait) => setTimeout(resolveWait, 50)); + root.trackChild(child); await root.cleanup(); activeRoots.delete(root); + + expect(child.signalCode).toBe('SIGKILL'); + expect(root.reapedChildren).toEqual([child.pid]); }, ); @@ -293,12 +416,16 @@ describe('process-owned artifact root', () => { expect(readFileSync(sentinelPath, 'utf8')).toBe('untouched\n'); }); - test('retains one completed JSON report only after the owned root scans clean', async () => { + test('retains one SDK platform evidence record only after the owned root scans clean', async () => { const root = createRoot(); const scratchRoot = createScratchRoot('retain'); const reportPath = resolve(root.rootPath, 'report.json'); const destinationPath = resolve(scratchRoot, 'nested', 'sanitized.json'); - const report = { schemaVersion: 1, status: 'passed', completed: true }; + const report = { + schemaVersion: 1, + issue: 'OpenCoven/sdk#38', + platform: 'darwin-arm64', + }; writeFileSync(reportPath, `${JSON.stringify(report)}\n`, { mode: 0o600 }); const scans: string[] = []; @@ -318,7 +445,7 @@ describe('process-owned artifact root', () => { test.each([ ['incomplete JSON', '{"completed":'], - ['non-completed report', '{"schemaVersion":1,"completed":false}'], + ['wrong evidence issue', '{"schemaVersion":1,"issue":"other","platform":"darwin-arm64"}'], ])('refuses to retain %s', async (_label, contents) => { const root = createRoot(); const scratchRoot = createScratchRoot('incomplete'); @@ -332,7 +459,7 @@ describe('process-owned artifact root', () => { destinationPath, secretScan: async () => undefined, }), - ).rejects.toThrow(/completed JSON report/); + ).rejects.toThrow(/SDK platform evidence record/); expect(existsSync(destinationPath)).toBe(false); }); @@ -356,7 +483,7 @@ describe('process-owned artifact root', () => { } rmSync(reportLink); - writeFileSync(reportLink, '{"completed":true}\n'); + writeFileSync(reportLink, minimalEvidence()); await expect( root.retainSanitizedJsonReport({ reportPath: reportLink, @@ -377,7 +504,7 @@ describe('process-owned artifact root', () => { const reportPath = resolve(root.rootPath, 'report.json'); writeFileSync(destinationPath, 'existing\n'); writeFileSync(sentinelPath, 'untouched\n'); - writeFileSync(reportPath, '{"completed":true}\n'); + writeFileSync(reportPath, minimalEvidence()); await expect( root.retainSanitizedJsonReport({ @@ -400,7 +527,7 @@ describe('process-owned artifact root', () => { const reportPath = resolve(root.rootPath, 'report.json'); const destinationLink = resolve(scratchRoot, 'report.json'); const parentLink = resolve(scratchRoot, 'linked-parent'); - writeFileSync(reportPath, '{"completed":true}\n'); + writeFileSync(reportPath, minimalEvidence()); writeFileSync(resolve(outsideRoot, 'existing.json'), 'outside\n'); symlinkSync(resolve(outsideRoot, 'existing.json'), destinationLink); symlinkSync(outsideRoot, parentLink); @@ -421,7 +548,7 @@ describe('process-owned artifact root', () => { const scratchRoot = createScratchRoot('scan-race'); const reportPath = resolve(root.rootPath, 'report.json'); const destinationPath = resolve(scratchRoot, 'retained.json'); - writeFileSync(reportPath, '{"completed":true}\n'); + writeFileSync(reportPath, minimalEvidence()); await expect( root.retainSanitizedJsonReport({ @@ -429,7 +556,7 @@ describe('process-owned artifact root', () => { destinationPath, secretScan: async () => { chmodSync(reportPath, 0o600); - writeFileSync(reportPath, '{"completed":true,"changed":true}\n'); + writeFileSync(reportPath, minimalEvidence({ changed: true })); }, }), ).rejects.toThrow(/changed after the secret scan/); diff --git a/src/phase1-conformance-lock.test.ts b/src/phase1-conformance-lock.test.ts index 77a7247..14c84d0 100644 --- a/src/phase1-conformance-lock.test.ts +++ b/src/phase1-conformance-lock.test.ts @@ -73,6 +73,9 @@ function supportsFileSymlinks() { } const fileSymlinksSupported = supportsFileSymlinks(); +const committedHarnessAuthority = JSON.parse( + readFileSync(resolve(projectRoot, 'phase1-conformance.lock.json'), 'utf8'), +).harnessAuthority; const expectedEntries = { chat: { @@ -85,12 +88,162 @@ const expectedEntries = { }, cave: { repository: 'OpenCoven/coven-cave', - revision: '086b6421d8f5daa814e2ad1b3c47605d1ffb542b', + revision: 'e74078a147c084bd761d929654f0990df66ef99f', }, coven: { repository: 'OpenCoven/coven', revision: '721437b84026c042e431b0882dcd14fdb29ac07d', }, + harness: { + repository: 'OpenCoven/chat', + revision: 'b24eaa7eb6886a5ef07af6b459ab57e0335fd20f', + }, + harnessAuthority: committedHarnessAuthority, + chatAuthority: { + tree: '72de37ed3c1afd36dcbd2824570f0a00b46459c6', + files: [ + { + path: 'src-tauri/Cargo.toml', + blob: '75a54c604d5d8b88b661f5a2427c3f9494e3a374', + sha256: '92b6839acf7785fbeb77abeeb8c3576d904f71bdaed1f79f834c740b09ecfbaf', + }, + { + path: 'src-tauri/Cargo.lock', + blob: '4bcf036ae5f2c9be11bef0ac890159c6cf0fd06a', + sha256: '50ff6c361744a08b9cc2770f6e06659ebb7cd1d5cd1b43b39ea7127c9c80e7ca', + }, + { + path: 'src-tauri/src/bin/phase1-native-rpc.rs', + blob: '0fbf0dcd1d2f1a49a556a9319b3121990cf53946', + sha256: 'a479704ea719138967f4828707bca04fb2a3de7fd2d2d2b9c7282831ec4bdf09', + }, + { + path: 'src-tauri/src/conformance.rs', + blob: 'e0997e45a2371cc231bc3923a55d9d62d7cff2a0', + sha256: '32c1b24e2ce27fb666af5ffe73a42d626f258285dd0d680852014799054d953d', + }, + { + path: 'src-tauri/src/coven.rs', + blob: 'b904e5eeded97aa624140b56c5141daebea772b2', + sha256: '09ca4871fc95d818cb642764ceaba3b1a8d6adba62e7552e69f0d0ac47662ff6', + }, + { + path: 'src-tauri/src/keyring.rs', + blob: 'a33c68672b87a5cfa39cba37d4629619408309a4', + sha256: '3d9215c4ba388d9180c43dea573fc839e6bfd6a214876cff7f836c9d7ad50c52', + }, + { + path: 'src-tauri/src/lib.rs', + blob: 'd9c97b4fb24e73fb103330fa9a8f778ccad42a3a', + sha256: 'f42a987b0dcb61b7c98f9dcdbf264d079134defe4bbe63e5019d4560ea967e21', + }, + ], + }, + tools: { + windowsSupervisor: { + source: { + repository: 'OpenCoven/chat', + revision: '6bee23b645c0edb1dcb0afd4f8cc18d2d0e6bec6', + path: 'tools/phase1-process-supervisor/src/main.rs', + blob: '6a95b4db7612ed0a502e91c4c21a7df5cbfe9021', + sha256: 'fa4c4759c0b01ce7f9bbd662ed3073b0aeed42dc7da0001026703482a5b9708a', + manifestSha256: '3d3964b144599835006248fcc40eee0437b06c268a180a373c15fea44bf4bf8b', + lockSha256: 'e31803e60e80d9d0a68cf043786cb1f516b425c185465785216aa5b05dd6fa88', + configSha256: '79d370b49837a1c4ec84231eb7fa13422e5e18a569c2b2eebf97ab3ec333d49c', + }, + toolchain: { + homebrewCoreRevision: 'cd168d1fdc26f12e4ad64f358ff2dbec61ab7a57', + packageVersion: 'mingw-w64 14.0.0_3', + bottleLayerSha256: '0d68ab737a8bbc8c63ac6ac7acc0695e2887c1169df9a4423f1180090079b1d5', + linkerVersion: '2.47.20260726', + }, + artifact: { + target: 'x86_64-pc-windows-gnu', + buildInvocation: + 'cd tools/phase1-process-supervisor && SOURCE_DATE_EPOCH=0 cargo build --target x86_64-pc-windows-gnu --release --locked', + fileName: 'phase1-process-supervisor.exe', + fleetPath: 'C:\\OpenCoven\\conformance\\phase1-process-supervisor.exe', + size: 333824, + sha256: '372b3e8b5b860e0759da8fa10ddfb6ec338e26d83616254c816a456ae2e1b7c5', + }, + }, + }, + release: { + sdkManifest: { + version: '0.1.0', + sha256: 'b8bfb62236fc8add4a9baad9f00e5401db15074a2d21fe2847a9158104cefb3c', + }, + sdkArtifacts: [ + { + packageName: '@opencoven/sdk-core', + releaseFile: 'tarballs/core/opencoven-sdk-core-0.1.0.tgz', + vendorFile: 'sdk-core-0.1.0.tgz', + size: 33284, + sha256: '9a574e8bd5178ce2aa20db97e8a741c7c9569515546a2d3089406f41a9d040fe', + }, + { + packageName: '@opencoven/cave-client', + releaseFile: 'tarballs/cave/opencoven-cave-client-0.1.0.tgz', + vendorFile: 'cave-client-0.1.0.tgz', + size: 81543, + sha256: 'c44544adf8e712d6be1e8686788e63aa0133eb318274d1fb1926138a7da148c0', + }, + { + packageName: '@opencoven/coven-client', + releaseFile: 'tarballs/coven/opencoven-coven-client-0.1.0.tgz', + vendorFile: 'coven-client-0.1.0.tgz', + size: 33009, + sha256: 'cba09410aeae9670173a1f7bfe3174b5dd610873358944ed0955c86ac56a3aa1', + }, + { + packageName: '@opencoven/sdk', + releaseFile: 'tarballs/sdk/opencoven-sdk-0.1.0.tgz', + vendorFile: 'sdk-0.1.0.tgz', + size: 15833, + sha256: 'eee7557feeaf4719d0cb990a66fdddf62270dbbeb05cfe7e35efbfe22827d04f', + }, + ], + caveVersion: '0.3.12', + covenVersion: '0.1.0', + consumerLock: { + path: 'pnpm-lock.yaml', + size: 56222, + sha256: 'd2f0db8eca64112324e861bb7cbd2b645ed9ae4aad836200855b3477f3ea49ae', + }, + caveArtifacts: { + assertionEngine: { + path: 'scripts/client-v1-conformance.mjs', + size: 146432, + sha256: 'b611d2b2935dad3cf913eda45e30ba109ba2ab53dadfef8670a26c7c03b115dd', + }, + contractFixture: { + path: 'src/lib/server/client-v1/contract-fixture.json', + size: 16695, + sha256: 'c0b1af2442409f8b26bbf0cf2a5fac467d23e5f56d2c966a9428c4b3e830a186', + }, + hpkeVectors: { + path: 'src/lib/server/client-v1/hpke-bound-v1-vectors.json', + size: 4041, + sha256: 'f806967291de12175277b6b24ac3c7bba912ae760fd8227fb21b1a4d5f5e6797', + }, + }, + }, + evidence: { + repository: 'OpenCoven/sdk', + revision: '4736bf2e0d5b16272d79ecf7784c75f376b39b94', + contract: { + path: 'scripts/conformance-contract.mjs', + sha256: '50b1012b3c4c22f518c1a611fb5210a5675ee976a9b194b502f6125bc48f5111', + }, + schema: { + path: 'conformance/client-v1-cross-repository-evidence.schema.json', + sha256: 'ca338cdbb33c46a97fe8430d95e04f6b30a2db453a7fff2184b335e33ea4f790', + }, + assertionRegistry: { + path: 'conformance/client-v1-cross-repository-assertions.json', + sha256: 'fb56d7cadaf194126fd9a7f090d8af600c04f7161cab1e2ebb3419df49fbcbe0', + }, + }, } as const; function gitTest(name: string, operation: () => void) { @@ -311,14 +464,14 @@ describe('Phase 1 conformance lock', () => { test('reads the immutable reviewed revisions into an exact normalized lock', () => { expect(readPhase1ConformanceLock()).toEqual({ path: resolve(projectRoot, 'phase1-conformance.lock.json'), - version: 1, + version: 5, ...expectedEntries, }); }); test('rejects an unexpected repository', () => { const lockPath = writeLock({ - version: 1, + version: 5, ...expectedEntries, sdk: { ...expectedEntries.sdk, @@ -335,22 +488,22 @@ describe('Phase 1 conformance lock', () => { [ 'missing top-level key', { - version: 1, + version: 5, chat: expectedEntries.chat, sdk: expectedEntries.sdk, cave: expectedEntries.cave, }, - 'must contain exactly version, chat, sdk, cave, and coven', + 'must contain exactly version, chat, sdk, cave, coven, harness, chatAuthority, harnessAuthority, tools, release, and evidence', ], [ 'extra top-level key', - { version: 1, ...expectedEntries, extra: true }, - 'must contain exactly version, chat, sdk, cave, and coven', + { version: 5, ...expectedEntries, extra: true }, + 'must contain exactly version, chat, sdk, cave, coven, harness, chatAuthority, harnessAuthority, tools, release, and evidence', ], [ 'missing entry key', { - version: 1, + version: 5, ...expectedEntries, chat: { repository: expectedEntries.chat.repository }, }, @@ -359,7 +512,7 @@ describe('Phase 1 conformance lock', () => { [ 'extra entry key', { - version: 1, + version: 5, ...expectedEntries, chat: { ...expectedEntries.chat, branch: 'main' }, }, @@ -374,7 +527,7 @@ describe('Phase 1 conformance lock', () => { ['short', expectedEntries.chat.revision.slice(0, -1)], ])('rejects a %s revision', (_label, revision) => { const lockPath = writeLock({ - version: 1, + version: 5, ...expectedEntries, chat: { ...expectedEntries.chat, revision }, }); @@ -384,6 +537,71 @@ describe('Phase 1 conformance lock', () => { ); }); + test('rejects SDK artifacts outside the canonical package order', () => { + const lockPath = writeLock({ + version: 5, + ...expectedEntries, + release: { + ...expectedEntries.release, + sdkArtifacts: [ + expectedEntries.release.sdkArtifacts[1], + expectedEntries.release.sdkArtifacts[0], + ...expectedEntries.release.sdkArtifacts.slice(2), + ], + }, + }); + + expect(() => readPhase1ConformanceLock(lockPath)).toThrow(/canonical package order/); + }); + + test('binds the SDK manifest digest to the canonical package metadata', () => { + const lockPath = writeLock({ + version: 5, + ...expectedEntries, + release: { + ...expectedEntries.release, + sdkArtifacts: expectedEntries.release.sdkArtifacts.map((artifact, index) => + index === 0 ? { ...artifact, size: artifact.size + 1 } : artifact, + ), + }, + }); + + expect(() => readPhase1ConformanceLock(lockPath)).toThrow(/manifest digest/); + }); + + test('requires the canonical production Chat authority file order', () => { + const lockPath = writeLock({ + version: 5, + ...expectedEntries, + chatAuthority: { + ...expectedEntries.chatAuthority, + files: [ + expectedEntries.chatAuthority.files[1], + expectedEntries.chatAuthority.files[0], + ...expectedEntries.chatAuthority.files.slice(2), + ], + }, + }); + + expect(() => readPhase1ConformanceLock(lockPath)).toThrow(/Chat authority file order/); + }); + + test('requires canonical executing harness and production delta authority order', () => { + for (const property of ['files', 'productionDeltas'] as const) { + const entries = expectedEntries.harnessAuthority[property]; + const lockPath = writeLock({ + version: 5, + ...expectedEntries, + harnessAuthority: { + ...expectedEntries.harnessAuthority, + [property]: [entries[1], entries[0], ...entries.slice(2)], + }, + }); + + expect(() => readPhase1ConformanceLock(lockPath)).toThrow(/canonical file order/); + } + }); + test('rejects missing and non-path lock inputs explicitly', () => { expect(() => readPhase1ConformanceLock(null as never)).toThrow( 'Phase 1 conformance lock path must be a non-empty path string.', @@ -395,6 +613,33 @@ describe('Phase 1 conformance lock', () => { }); describe('Phase 1 checkout verification', () => { + gitTest('verifies the pinned Chat harness checkout with the hardened paths', () => { + const fixture = createCheckoutFixture(); + const harnessRoot = resolve(createScratchRoot('harness-checkout'), 'chat'); + runGit(['clone', fixture.roots.sdkRoot, harnessRoot], projectRoot); + const harnessRevision = runGit(['rev-parse', 'HEAD'], harnessRoot); + const harnessLock = { + ...fixture.lock, + harness: { + repository: 'OpenCoven/chat', + revision: harnessRevision, + }, + }; + const roots = { + ...fixture.roots, + chatHarnessRoot: harnessRoot, + }; + + expect(assertCleanPhase1Checkouts(roots)).toHaveProperty('harness'); + expect(assertPhase1CheckoutHeads(harnessLock, roots)).toHaveProperty( + 'harness', + harnessRevision, + ); + + writeFileSync(resolve(harnessRoot, 'tracked.txt'), 'dirty harness\n'); + expect(() => assertCleanPhase1Checkouts(roots)).toThrow(/harness checkout is dirty/); + }); + test('sanitizes inherited Git variables case-insensitively for verifier children', () => { const environment = createGitEnvironment({ PATH: process.env.PATH, @@ -560,11 +805,15 @@ setInterval(() => {}, 1_000); const childPid = Number.parseInt(readFileSync(childPidPath, 'utf8'), 10); expect(Date.now() - startedAt).toBeLessThan(25_000); expect(Number.isSafeInteger(childPid)).toBe(true); - expect(() => process.kill(childPid, 0)).toThrow( - expect.objectContaining({ - code: 'ESRCH', - }), - ); + let survivingCommand = ''; + try { + survivingCommand = execFileSync('ps', ['-o', 'command=', '-p', String(childPid)], { + encoding: 'utf8', + }); + } catch { + // The exact child PID no longer exists. + } + expect(survivingCommand).not.toContain(fakeGitPath); expect(message).toBe('chat checkout verification timed out.'); expect(message).not.toContain(secretDiagnostic); expect(message).not.toContain(fakeGitPath); @@ -1411,7 +1660,7 @@ setInterval(() => {}, 1_000); fakeGitPath, `#!/bin/sh printf 'invoked\\n' >> ${JSON.stringify(invocationMarker)} -sleep 0.15 +sleep 0.5 PATH=${JSON.stringify(originalPath)} exec git "$@" `, ); @@ -1425,13 +1674,13 @@ PATH=${JSON.stringify(originalPath)} exec git "$@" () => { expect(() => phase1ConformanceTestOnly.assertCleanPhase1Checkouts(fixture.roots, { - limits: { repositoryDeadlineMs: 1_500 }, + limits: { repositoryDeadlineMs: 5_000 }, }), ).toThrow('chat checkout verification timed out.'); }, ); - expect(Date.now() - startedAt).toBeLessThan(3_000); + expect(Date.now() - startedAt).toBeLessThan(7_500); expect( readFileSync(invocationMarker, 'utf8').split('\n').filter(Boolean).length, ).toBeGreaterThan(1); diff --git a/src/phase1-conformance.test.ts b/src/phase1-conformance.test.ts index 456074b..30d1e8a 100644 --- a/src/phase1-conformance.test.ts +++ b/src/phase1-conformance.test.ts @@ -1,30 +1,97 @@ +import { execFileSync } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; import { EventEmitter } from 'node:events'; -import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { + appendFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + truncateSync, + writeFileSync, +} from 'node:fs'; +import { createServer } from 'node:net'; import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { PassThrough } from 'node:stream'; import { describe, expect, test } from 'vitest'; -import { REQUIRED_PHASE1_ASSERTION_IDS } from '../scripts/phase1-artifact-secret-scan.mjs'; import { - assertCompatibilityFailure, - assertExactAssertionResults, - assertNativeMissingKeychainResponses, + normalizeWindowsRealPathForProcess, + quoteWindowsBatchCommand, +} from '../scripts/executable-resolution.mjs'; +import { + adoptNativeCleanupReservation, + assertExecutingHarnessAuthority, + assertNoNodeRuntimeInjection, assertPairingStatus, - buildPhase1Report, + assertProductionAdapterAtRevision, CommandExecutionError, cargoBuildTimeoutMs, - classifyCavePackageFailure, + caveBuildEnvironment, + classifyPackagingCommandFailure, + covenIdentityFailureDiagnostic, + createCleanupAdoptionRecovery, + diagnoseCovenLifecycleFailure, + evidenceValidationFailureDiagnostic, + extractVerifiedRunnerDiagnostic, + finalizeOperatorSafety, NativeRpcClient, + nativeAdapterTestEnvironment, + nativeMissingKeychainFailureDiagnostic, + nativeMissingKeychainResponsesValid, + observeReleaseToolVersions, parseArgs, parseCaveConformanceOutput, + parsePassedRustTests, + parseSupervisorStatusFrame, + publicPhase1FailureDiagnostic, recordCaveMatrixFailure, + resolveLockedCovenDaemonCommand, + resolveRustupHome, + runNativeScenarioOrchestrator, + runOwnedProcessStatusForTest, + runReservedNativePairing, + runSupervisedCommandForTest, + runtimeScenarioFailureDiagnostic, safeEnvironment, + snapshotOperatorState, + throwNativeScenarioFailures, + validateSupervisorArtifactFile, withFixtureDaemon, withOwnedArtifactRoot, wrapInfrastructureFailure, } from '../scripts/phase1-conformance.mjs'; +import { readPhase1ConformanceLock } from '../scripts/phase1-conformance-lock.mjs'; +import { createProcessOwnedArtifactRoot } from '../scripts/process-owned-artifact-root.mjs'; + +const projectRoot = resolve(import.meta.dirname, '..'); + +function processIsLive(pid: number) { + try { + const state = execFileSync('ps', ['-o', 'stat=', '-p', String(pid)], { + encoding: 'utf8', + }).trim(); + return state.length > 0 && !state.startsWith('Z'); + } catch { + return false; + } +} + +async function waitForPidFile(path: string) { + const deadline = Date.now() + 5_000; + while (!existsSync(path)) { + if (Date.now() >= deadline) { + throw new Error('descendant PID was not published'); + } + await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + } + return Number(readFileSync(path, 'utf8')); +} class SynchronousCloseChild extends EventEmitter { readonly stdout = new PassThrough(); @@ -53,100 +120,664 @@ class SynchronousNonZeroCloseChild extends SynchronousCloseChild { }; } -class NeverCloseChild extends SynchronousCloseChild { +class SynchronousSupervisorCloseChild extends SynchronousCloseChild { + readonly __phase1SupervisorStatus = Promise.resolve({ + code: 0, + signal: null, + reason: 'exit', + }); override readonly stdin = { write: (line: string) => { const request = JSON.parse(line) as { id: string }; this.stdout.write(`${JSON.stringify({ id: request.id, ok: true, result: {} })}\n`); + this.signalCode = 'SIGKILL'; + this.emit('close', null, 'SIGKILL'); return true; }, }; } -function passingAssertions(): Array<{ - id: string; - status: 'passed' | 'failed' | 'blocked'; - diagnosticIds: string[]; -}> { - return REQUIRED_PHASE1_ASSERTION_IDS.map((id) => ({ - id, - status: 'passed' as const, - diagnosticIds: ['phase1.assertion.passed'], - })); -} - -function assertionAt(assertions: ReturnType, index: number) { - const assertion = assertions[index]; - if (assertion === undefined) { - throw new Error(`missing test assertion at index ${index}`); - } - return assertion; +class NeverCloseChild extends SynchronousCloseChild { + override readonly stdin = { + write: (line: string) => { + const request = JSON.parse(line) as { id: string }; + this.stdout.write(`${JSON.stringify({ id: request.id, ok: true, result: {} })}\n`); + return true; + }, + }; } describe('Phase 1 real-authority conformance harness', () => { - test('requires every assertion ID exactly once with no skipped result', () => { - const assertions = passingAssertions(); - expect(assertExactAssertionResults(assertions)).toEqual(assertions); + test('uses Chat native coven_health and never the Coven status CLI for identity proof', () => { + const source = readFileSync( + resolve(import.meta.dirname, '..', 'scripts', 'phase1-conformance.mjs'), + 'utf8', + ); - expect(() => assertExactAssertionResults(assertions.slice(1))).toThrow( - /missing required assertion ID/, + expect(source).toContain("rpc.ok('coven_health'"); + expect(source).toContain("rpc.error('coven_health'"); + expect(source).not.toContain("['daemon', 'status'"); + expect(source).not.toContain('daemon authenticated status'); + expect(source).not.toContain('error instanceof Error ? error.message'); + expect(source).toContain('phase1-process-supervisor.mjs'); + expect(source).toContain('detached: !windowsSupervised'); + expect(source).toContain('trackChild(child)'); + expect(source).toContain('lockedCovenCheckoutRoot: roots.covenRoot'); + expect(source).toContain('cwd: covenCommand.cwd'); + }); + + test.skipIf(process.platform === 'win32')( + 'resolves and runs the Coven daemon command from the explicit owned locked checkout', + async () => { + const root = resolve( + process.cwd(), + 'test-results', + 'phase1-coven-command-root', + randomUUID(), + ); + const covenRoot = resolve(root, 'checkouts', 'coven'); + const marker = resolve(root, 'command.json'); + const tracked: import('node:child_process').ChildProcess[] = []; + try { + mkdirSync(covenRoot, { recursive: true }); + execFileSync('git', ['init', '--quiet'], { cwd: covenRoot }); + writeFileSync( + resolve(covenRoot, 'daemon'), + [ + "import{writeFileSync}from'node:fs';", + 'writeFileSync(process.env.MARKER,JSON.stringify({cwd:process.cwd(),args:process.argv.slice(2)}));', + ].join(''), + ); + execFileSync('git', ['add', 'daemon'], { cwd: covenRoot }); + execFileSync( + 'git', + [ + '-c', + 'user.name=Phase1 Test', + '-c', + 'user.email=phase1@example.invalid', + '-c', + 'commit.gpgsign=false', + 'commit', + '--quiet', + '-m', + 'fixture', + ], + { cwd: covenRoot }, + ); + const revision = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: covenRoot, + encoding: 'utf8', + }).trim(); + expect(() => + resolveLockedCovenDaemonCommand( + { rootPath: root }, + covenRoot, + 'f'.repeat(40), + process.execPath, + ), + ).toThrow('Coven command root does not match the locked revision.'); + const command = resolveLockedCovenDaemonCommand( + { rootPath: root }, + covenRoot, + revision, + process.execPath, + ); + + expect(command).toEqual({ + executable: realpathSync(process.execPath), + args: ['daemon', 'serve'], + cwd: covenRoot, + }); + await runSupervisedCommandForTest( + { + rootPath: root, + trackChild(child) { + tracked.push(child); + }, + }, + command.executable, + command.args, + { + cwd: command.cwd, + env: { ...process.env, MARKER: marker }, + timeoutMs: 5_000, + outputLimitBytes: 4_096, + }, + ); + expect(JSON.parse(readFileSync(marker, 'utf8'))).toEqual({ + cwd: realpathSync(covenRoot), + args: ['serve'], + }); + expect(tracked).toHaveLength(1); + expect(tracked[0]?.exitCode ?? tracked[0]?.signalCode).not.toBeNull(); + } finally { + for (const child of tracked) { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + await new Promise((resolveClose) => child.once('close', () => resolveClose())); + } + } + rmSync(root, { recursive: true, force: true }); + } + }, + ); + + test('rejects missing and wrong explicit Coven checkout roots', () => { + const root = resolve(process.cwd(), 'test-results', 'phase1-coven-command-root', randomUUID()); + const wrongRoot = resolve(root, 'checkouts', 'not-coven'); + try { + mkdirSync(wrongRoot, { recursive: true }); + expect(() => + resolveLockedCovenDaemonCommand( + { rootPath: root }, + undefined as never, + '0'.repeat(40), + process.execPath, + ), + ).toThrow('Locked Coven checkout root must be a non-empty path string.'); + expect(() => + resolveLockedCovenDaemonCommand( + { rootPath: root }, + wrongRoot, + '0'.repeat(40), + process.execPath, + ), + ).toThrow('Coven command root is not the owned locked checkout.'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('rejects every cmd metacharacter and quotes valid spaced batch tokens canonically', () => { + for (const token of ['&', '|', '<', '>', '(', ')', '^', '%', '!', '"', '\r', '\n', '\0']) { + expect(() => quoteWindowsBatchCommand('C:\\safe\\tool.cmd', [`payload${token}`])).toThrow( + 'Windows batch invocation contains an unsafe token.', + ); + expect(() => quoteWindowsBatchCommand(`C:\\safe\\tool${token}.cmd`, ['safe'])).toThrow( + 'Windows batch invocation contains an unsafe token.', + ); + } + expect(quoteWindowsBatchCommand('C:\\safe spaced\\tool.cmd', ['valid spaced arg'])).toBe( + 'call "C:\\safe spaced\\tool.cmd" "valid spaced arg"', + ); + }); + + test('normalizes Windows drive namespace paths without accepting network namespaces', () => { + expect(normalizeWindowsRealPathForProcess('\\\\?\\C:\\safe path\\tool.cmd')).toBe( + 'C:\\safe path\\tool.cmd', ); - expect(() => assertExactAssertionResults([...assertions, assertionAt(assertions, 0)])).toThrow( - /duplicate assertion ID/, + expect(normalizeWindowsRealPathForProcess('\\\\?\\UNC\\server\\share\\tool.cmd')).toBe( + '\\\\?\\UNC\\server\\share\\tool.cmd', ); - expect(() => - assertExactAssertionResults([ - ...assertions.slice(1), + }); + + test('consumes frozen SDK artifacts instead of rebuilding release tarballs', () => { + const source = readFileSync( + resolve(import.meta.dirname, '..', 'scripts', 'phase1-conformance.mjs'), + 'utf8', + ); + + expect(source).not.toContain('packPublicPackages'); + expect(source).not.toContain('package-artifacts.mjs'); + expect(source).toContain('sdkArtifacts'); + expect(source).toContain('assertSdkCandidateProvenance'); + expect(source).toContain("'--ignore-workspace'"); + }); + + test('runs locked producer and Chat adapter tests before emitting platform assertions', () => { + const source = readFileSync( + resolve(import.meta.dirname, '..', 'scripts', 'phase1-conformance.mjs'), + 'utf8', + ); + + expect(source).toContain("'Chat native adapter tests'"); + expect(source).toContain(`\`Coven producer client \${group} tests\``); + expect(source).toContain("'--package', 'coven-client'"); + expect(source).toContain('createAssertionRecorder'); + expect(source).not.toContain('passingAssertions'); + expect(source).toContain('phase1.sdk-candidate.'); + expect(source).toContain('phase1.sdk-manifest.'); + expect(source).toContain('phase1.toolchain.rust.'); + expect(source).not.toContain('spawn(command, args'); + expect(source.match(/\bspawn\(/gu)).toHaveLength(2); + expect(source).toContain('configuredWindowsSupervisorPath'); + }); + + test.skipIf(process.platform === 'win32')( + 'keeps locked Coven lifecycle sockets within the Darwin path limit', + async () => { + const source = readFileSync( + resolve(import.meta.dirname, '..', 'scripts', 'phase1-conformance.mjs'), + 'utf8', + ); + expect(source).toContain( + "createProcessOwnedArtifactRoot({ prefix: 'p1run', shortPath: true })", + ); + expect(source).toContain( + "createProcessOwnedArtifactRoot({ prefix: 'p1boot', shortPath: true })", + ); + + const root = createProcessOwnedArtifactRoot({ + prefix: 'p1run', + shortPath: true, + }); + try { + const socketPath = resolve( + root.rootPath, + 'checkouts', + 'coven', + 'c', + `r${'f'.repeat(32)}`, + 'coven.sock', + ); + expect(Buffer.byteLength(socketPath)).toBeLessThan(104); + } finally { + await root.cleanup(); + } + }, + ); + + test('builds the conformance driver around production adapter bytes from the locked Chat commit', () => { + const source = readFileSync( + resolve(import.meta.dirname, '..', 'scripts', 'phase1-conformance.mjs'), + 'utf8', + ); + + expect(source).toContain('assertProductionAdapterAtRevision'); + expect(source).toContain('assertProductionChatAuthority'); + expect(source).toContain("'merge-base', '--is-ancestor'"); + expect(source).toContain('lock.chatAuthority.tree'); + expect(source).toContain("'src-tauri/src/coven.rs'"); + expect(source).toContain("'src-tauri/src/bin/phase1-native-rpc.rs'"); + expect(source).toContain("resolve(roots.chatHarnessRoot, 'src-tauri', 'Cargo.toml')"); + expect(source).not.toContain("resolve(projectRoot, 'src-tauri', 'Cargo.toml')"); + }); + + test.skipIf(process.platform === 'win32')( + 'accepts the exact detached harness authority and rejects dirty executable or native delta bytes', + () => { + const lock = readPhase1ConformanceLock(); + const root = resolve(process.cwd(), 'test-results', 'phase1-harness-authority', randomUUID()); + const harnessRoot = resolve(root, 'harness'); + try { + mkdirSync(root, { recursive: true }); + execFileSync('git', ['clone', '--quiet', '--no-checkout', projectRoot, harnessRoot]); + execFileSync('git', ['checkout', '--quiet', '--detach', lock.harness.revision], { + cwd: harnessRoot, + }); + const verifiedEnvironment = { + ...process.env, + OPENCOVEN_PHASE1_VERIFIED_RUNNER: '1', + OPENCOVEN_PHASE1_VERIFIED_RUNNER_ROOT: harnessRoot, + }; + + expect(() => + assertExecutingHarnessAuthority(lock, harnessRoot, verifiedEnvironment), + ).not.toThrow(); + expect(() => assertProductionAdapterAtRevision(harnessRoot, lock)).not.toThrow(); + + const runner = resolve(harnessRoot, 'scripts', 'phase1-conformance.mjs'); + appendFileSync(runner, '\n// substituted\n'); + expect(() => + assertExecutingHarnessAuthority(lock, harnessRoot, verifiedEnvironment), + ).toThrow('Executing Phase 1 harness module does not match its immutable authority.'); + execFileSync('git', ['checkout', '--quiet', '--', 'scripts/phase1-conformance.mjs'], { + cwd: harnessRoot, + }); + + const nativeEntrypoint = resolve( + harnessRoot, + 'src-tauri', + 'src', + 'bin', + 'phase1-native-rpc.rs', + ); + appendFileSync(nativeEntrypoint, '\n// substituted\n'); + expect(() => assertProductionAdapterAtRevision(harnessRoot, lock)).toThrow( + 'Chat conformance native delta does not match its immutable allowlist.', + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + 30_000, + ); + + test('uses production keyring custody and a new RPC process for restart reuse', () => { + const source = readFileSync( + resolve(import.meta.dirname, '..', 'scripts', 'phase1-conformance.mjs'), + 'utf8', + ); + + expect(source).toContain( + "OPENCOVEN_PHASE1_CONFORMANCE_NATIVE_PROVIDER_PRESET: 'production-keyring'", + ); + const nativeScenario = source.slice( + source.indexOf('async function runNativeScenarios'), + source.indexOf('export function resolveLockedCovenDaemonCommand'), + ); + expect(nativeScenario).toContain('...nativeAdapterTestEnvironment(environment)'); + const emergencyCleanupBoundary = source.slice( + source.indexOf('async function runEmergencyNativeCredentialCleanup'), + source.indexOf('const cleanupCapabilityPattern'), + ); + expect(emergencyCleanupBoundary).toContain('...nativeAdapterTestEnvironment(environment)'); + const nativeStages = [ + "activeNativeStage = 'restart'", + "activeNativeStage = 'restart-rpc-start'", + "activeNativeStage = 'restart-discovery'", + "activeNativeStage = 'restart-health'", + "activeNativeStage = 'restart-cleanup-adoption'", + "activeNativeStage = 'restart-status'", + "activeNativeStage = 'restart-handoff-close'", + "activeNativeStage = 'restart-launch'", + "activeNativeStage = 'restart-rediscovery'", + "activeNativeStage = 'restart-restarted-health'", + "activeNativeStage = 'restart-restarted-status'", + "activeNativeStage = 'restart-result'", + "activeNativeStage = 'reads'", + "activeNativeStage = 'reconciliation'", + "activeNativeStage = 'revocation'", + "activeNativeStage = 'revocation-delete'", + "activeNativeStage = 'revocation-initial-status'", + "activeNativeStage = 'revocation-rediscovery'", + "activeNativeStage = 'revocation-health'", + "activeNativeStage = 'revocation-status'", + 'activeNativeStage = `revocation-repair-${stage}`', + "activeNativeStage = 'revocation-result'", + "activeNativeStage = 'credential-cleanup'", + "activeNativeStage = 'credential-cleanup-discovery'", + "activeNativeStage = 'credential-cleanup-health'", + "activeNativeStage = 'credential-cleanup-identity'", + "activeNativeStage = 'credential-cleanup-forget'", + "activeNativeStage = 'credential-cleanup-status'", + "activeNativeStage = 'credential-cleanup-result'", + ].map((stage) => nativeScenario.indexOf(stage)); + expect(nativeStages.every((stage) => stage >= 0)).toBe(true); + expect(nativeStages).toEqual([...nativeStages].sort((left, right) => left - right)); + expect(source).toContain("'security', ['default-keychain', '-d', 'user']"); + expect(source).toContain('cave-client-v1:'); + expect(source).toContain("await rpc.ok('cave_forget_credential'"); + expect(source).toContain('credentialMayExist'); + expect(source).toContain("'conformance_delete_native_credential'"); + expect(source).toContain("'conformance_prepare_native_cleanup'"); + expect(source).toContain("'conformance_cancel_prepared_native_cleanup'"); + expect(source).toContain('runEmergencyNativeCredentialCleanup'); + const reservedBoundary = source.slice( + source.indexOf('export async function runReservedNativePairing'), + source.indexOf('async function runNativeScenarios'), + ); + expect(reservedBoundary.indexOf('await establishNativeCleanupReservation')).toBeLessThan( + reservedBoundary.indexOf("rpc.ok('cave_credential_status'"), + ); + expect(reservedBoundary.indexOf("rpc.ok('cave_credential_status'")).toBeLessThan( + reservedBoundary.indexOf('onCredentialMayExist()'), + ); + expect(reservedBoundary.indexOf('onCredentialMayExist()')).toBeLessThan( + reservedBoundary.indexOf('await pairNative('), + ); + expect(source.indexOf('replacementRpc = await startNativeRpc')).toBeLessThan( + source.indexOf('await previousRpc.close()'), + ); + expect(source.indexOf('await adoptNativeCleanupReservation')).toBeLessThan( + source.indexOf('await previousRpc.close()'), + ); + const predecessorCloseIndex = nativeScenario.indexOf('await previousRpc.close()'); + const restartLaunchIndex = nativeScenario.indexOf( + "await rpc.ok('cave_launch')", + predecessorCloseIndex, + ); + const closedPredecessorBoundary = nativeScenario.slice( + predecessorCloseIndex, + restartLaunchIndex, + ); + expect(closedPredecessorBoundary).not.toContain("rpc.ok('cave_credential_status'"); + expect(source).not.toContain("await rpc.ok('conformance_reset_native_state')"); + expect(source).not.toContain('phase1-native-credential-store-not-addressed'); + expect(source).not.toContain('OPENCOVEN_PHASE1_NATIVE_CREDENTIAL_STORE_ISOLATED'); + }); + + test.each(['command-failure', 'malformed-response', 'response-lost'] as const)( + 'reservation %s blocks the actual pairing and credential orchestration boundary', + async (failureMode) => { + let markerPresent = false; + const rpcCalls: string[] = []; + const approvals: string[] = []; + const credentialMayExistTransitions: boolean[] = []; + const pairingStages: string[] = []; + let subsequentLifecycleCalls = 0; + const rpc = { + async request(command: string) { + rpcCalls.push(command); + expect(command).toBe('conformance_prepare_native_cleanup'); + if (failureMode === 'command-failure') { + return { ok: false, error: { code: 'keychain_failure', retryable: false } }; + } + markerPresent = true; + if (failureMode === 'response-lost') { + throw new Error('response lost'); + } + return { + ok: true, + result: { + reservationHandle: '00000000-0000-4000-8000-000000000001', + }, + }; + }, + async ok(command: string) { + rpcCalls.push(command); + expect(command).toBe('conformance_cancel_prepared_native_cleanup'); + markerPresent = false; + return { status: 'missing' }; + }, + operation() { + throw new Error('pairing operation must remain unreachable'); + }, + }; + + await expect( + runNativeScenarioOrchestrator({ + runPairing: () => + runReservedNativePairing({ + rpc, + handle: 'native-handle', + origin: 'http://127.0.0.1:4310', + adminToken: 'redacted-test-token', + installationId: 'phase1-installation-test', + approvePairing: async () => { + approvals.push('approve'); + }, + onCredentialMayExist: () => { + credentialMayExistTransitions.push(true); + }, + onStage: (stage) => { + pairingStages.push(stage); + }, + }), + runLifecycle: async () => { + subsequentLifecycleCalls += 1; + }, + }), + ).rejects.toThrow('Native cleanup reservation could not be established.'); + expect(rpcCalls).toEqual([ + 'conformance_prepare_native_cleanup', + 'conformance_cancel_prepared_native_cleanup', + ]); + expect( + rpcCalls.filter((command) => + [ + 'cave_credential_status', + 'cave_pairing_create', + 'cave_pairing_poll', + 'cave_pairing_exchange', + 'cave_forget_credential', + ].includes(command), + ), + ).toEqual([]); + expect(approvals).toEqual([]); + expect(credentialMayExistTransitions).toEqual([]); + expect(pairingStages).toEqual( + failureMode === 'command-failure' + ? ['reservation-request', 'reservation-keychain'] + : failureMode === 'malformed-response' + ? ['reservation-request', 'reservation-response'] + : ['reservation-request'], + ); + expect(subsequentLifecycleCalls).toBe(0); + expect(markerPresent).toBe(false); + }, + ); + + test('validates cleanup ownership adoption and rejects stale owner acknowledgements', async () => { + const reservation = { + reservationHandle: '00000000-0000-4000-8000-000000000001', + capability: '00000000-0000-4000-8000-000000000002', + ownerToken: '00000000-0000-4000-8000-000000000003', + }; + await expect( + adoptNativeCleanupReservation( { - id: 'phase1.unexpected', - status: 'passed', - diagnosticIds: ['phase1.assertion.passed'], + request: async (command, args) => { + const values = args as { successorOwnerToken: string }; + return command === 'conformance_begin_adopt_native_cleanup' + ? { + ok: true, + result: { ...reservation, ownerToken: values.successorOwnerToken }, + } + : { + ok: true, + result: { status: 'committed', ownerToken: values.successorOwnerToken }, + }; + }, + ok: async () => ({ status: 'aborted' }), }, - ]), - ).toThrow(/unexpected assertion ID/); - expect(() => - assertExactAssertionResults([ - { ...assertionAt(assertions, 0), status: 'skipped' as never }, - ...assertions.slice(1), - ]), - ).toThrow(/skipped assertion/); + createCleanupAdoptionRecovery(reservation), + ), + ).resolves.toMatchObject({ + reservationHandle: reservation.reservationHandle, + capability: reservation.capability, + ownerToken: expect.not.stringMatching(reservation.ownerToken), + }); + await expect( + adoptNativeCleanupReservation( + { + request: async () => ({ ok: true, result: reservation }), + ok: async () => ({ status: 'aborted' }), + }, + createCleanupAdoptionRecovery(reservation), + ), + ).rejects.toThrow('Native cleanup reservation adoption was invalid.'); }); - test('builds the approved sanitized report and derives a blocked summary', () => { - const assertions = passingAssertions(); - assertions[8] = { - ...assertionAt(assertions, 8), - status: 'blocked', - diagnosticIds: ['phase1.producer.compatibility-control-unavailable'], + test('recovers an idempotent adoption commit after its first response is lost', async () => { + const reservation = { + reservationHandle: '00000000-0000-4000-8000-000000000001', + capability: '00000000-0000-4000-8000-000000000002', + ownerToken: '00000000-0000-4000-8000-000000000003', }; - - const report = buildPhase1Report({ - assertions, - revisions: { - chat: '0'.repeat(40), - sdk: '1'.repeat(40), - cave: '2'.repeat(40), - coven: '3'.repeat(40), - }, - artifactDigests: { - 'chat-native-rpc': 'a'.repeat(64), - }, - versions: { - harness: '1.0.0', - node: process.versions.node, + let commitAttempts = 0; + let successorOwnerToken = ''; + const adopted = await adoptNativeCleanupReservation( + { + request: async (command, args) => { + const values = args as { successorOwnerToken: string }; + successorOwnerToken = values.successorOwnerToken; + if (command === 'conformance_begin_adopt_native_cleanup') { + return { + ok: true, + result: { ...reservation, ownerToken: successorOwnerToken }, + }; + } + commitAttempts += 1; + if (commitAttempts === 1) throw new Error('commit response lost'); + return { + ok: true, + result: { status: 'committed', ownerToken: successorOwnerToken }, + }; + }, + ok: async () => ({ status: 'aborted' }), }, - }); + createCleanupAdoptionRecovery(reservation), + ); + + expect(commitAttempts).toBe(2); + expect(adopted.ownerToken).toBe(successorOwnerToken); + }); + + test('aborts a pending adoption when the begin response is lost', async () => { + const reservation = { + reservationHandle: '00000000-0000-4000-8000-000000000001', + capability: '00000000-0000-4000-8000-000000000002', + ownerToken: '00000000-0000-4000-8000-000000000003', + }; + const calls: string[] = []; + await expect( + adoptNativeCleanupReservation( + { + request: async (command) => { + calls.push(command); + throw new Error('begin response lost'); + }, + ok: async (command) => { + calls.push(command); + return { status: 'aborted' }; + }, + }, + createCleanupAdoptionRecovery(reservation), + ), + ).rejects.toThrow('Native cleanup reservation adoption was invalid.'); + expect(calls).toEqual([ + 'conformance_begin_adopt_native_cleanup', + 'conformance_abort_adopt_native_cleanup', + ]); + }); - expect(report.status).toBe('blocked'); - expect(report.completed).toBe(true); - expect(report.summary).toEqual({ - required: REQUIRED_PHASE1_ASSERTION_IDS.length, - passed: REQUIRED_PHASE1_ASSERTION_IDS.length - 1, - failed: 0, - blocked: 1, - skipped: 0, + test('retains successor recovery material and deletes after an ambiguous applied commit', async () => { + const recovery = createCleanupAdoptionRecovery({ + reservationHandle: '00000000-0000-4000-8000-000000000001', + capability: '00000000-0000-4000-8000-000000000002', + ownerToken: '00000000-0000-4000-8000-000000000003', }); - expect(report.diagnosticIds).toEqual(['phase1.conformance.blocked']); + const freshCalls: string[] = []; + await expect( + adoptNativeCleanupReservation( + { + request: async (command, args) => { + const values = args as { successorOwnerToken: string }; + if (command === 'conformance_begin_adopt_native_cleanup') { + return { + ok: true, + result: { ...recovery.successor, ownerToken: values.successorOwnerToken }, + }; + } + throw new Error('commit response lost'); + }, + ok: async () => ({ status: 'aborted' }), + }, + recovery, + async () => ({ + request: async (command: string, args: unknown) => { + freshCalls.push(command); + const values = args as { successorOwnerToken: string }; + return { + ok: true, + result: { status: 'committed', ownerToken: values.successorOwnerToken }, + }; + }, + ok: async (command: string) => { + freshCalls.push(command); + return { status: 'missing' }; + }, + close: async () => undefined, + }), + ), + ).rejects.toThrow('Native cleanup reservation commit could not be confirmed.'); + expect(recovery.deleted).toBe(true); + expect(freshCalls).toEqual([ + 'conformance_commit_adopt_native_cleanup', + 'conformance_delete_native_credential', + ]); }); test('parses only the complete all-scenario command line', () => { @@ -163,14 +794,115 @@ describe('Phase 1 real-authority conformance harness', () => { scenario: 'all', lockPath: expect.stringContaining('phase1-conformance.lock.json'), retainSanitizedReport: expect.stringContaining('report.json'), + sdkEvidenceSourceRoot: expect.stringContaining('sdk'), }); - expect(() => parseArgs(['--scenario', 'pairing-only'])).toThrow( /only --scenario all is supported/, ); expect(() => parseArgs(['--unknown'])).toThrow(/unknown option/); }); + test('rejects Node preload and loader runtime injection indicators', () => { + for (const argument of [ + '-r', + '-rmodule.cjs', + '--require', + '--require=module.cjs', + '--import', + '--import=module.mjs', + '--loader', + '--loader=x', + '--experimental-loader', + '--experimental-loader=x', + '--experimental-policy=policy.json', + '--policy-integrity=sha256-test', + '--conditions=custom', + '-C=custom', + ]) { + expect(() => assertNoNodeRuntimeInjection({}, [argument])).toThrow( + 'Node runtime injection is forbidden for Phase 1 conformance.', + ); + } + expect(() => + assertNoNodeRuntimeInjection({ NODE_OPTIONS: '--require module.cjs' }, []), + ).toThrow('Node runtime injection is forbidden for Phase 1 conformance.'); + }); + + test.skipIf(process.platform === 'win32')( + 'trusted outer launcher strips preload injection before Node starts', + () => { + const root = resolve( + process.cwd(), + 'test-results', + 'phase1-launcher-injection', + randomUUID(), + ); + const marker = resolve(root, 'preload.marker'); + const preload = resolve(root, 'preload.cjs'); + const missingSdk = resolve(root, 'missing-sdk'); + const launcher = resolve(projectRoot, 'scripts', 'phase1-conformance-launcher.sh'); + const runner = resolve(projectRoot, 'scripts', 'phase1-conformance.mjs'); + try { + mkdirSync(root, { recursive: true }); + writeFileSync( + preload, + `require('node:fs').writeFileSync(${JSON.stringify(marker)},'loaded',{flag:'a'});`, + ); + for (const nodeOptions of [`-r ${preload}`, `-r${preload}`]) { + expect(() => + execFileSync('/bin/sh', [launcher, process.execPath, '--sdk-root', missingSdk], { + cwd: projectRoot, + env: { ...process.env, NODE_OPTIONS: nodeOptions }, + stdio: 'ignore', + timeout: 30_000, + }), + ).toThrow(); + expect(existsSync(marker)).toBe(false); + } + + expect(() => + execFileSync(process.execPath, ['-r', preload, runner, '--sdk-root', missingSdk], { + cwd: projectRoot, + env: process.env, + stdio: 'ignore', + timeout: 30_000, + }), + ).toThrow(); + expect(existsSync(marker)).toBe(true); + expect(existsSync(resolve(root, 'report.json'))).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, + 70_000, + ); + + test('observes the exact Node, pnpm, and Rust release toolchain', () => { + expect(observeReleaseToolVersions()).toEqual({ + nodeVersion: 'v24.18.1', + packageManagerVersion: 'pnpm@10.34.0', + rustVersion: '1.95.0', + }); + }); + + test('rejects missing or digest-mismatched frozen Windows supervisors', () => { + const root = mkdtempSync(join(tmpdir(), 'phase1-supervisor-preflight-')); + const path = resolve(root, 'phase1-process-supervisor.exe'); + try { + expect(() => + validateSupervisorArtifactFile(path, { size: 4, sha256: '0'.repeat(64) }), + ).toThrow(); + writeFileSync(path, 'tool'); + expect(() => + validateSupervisorArtifactFile(path, { size: 4, sha256: '0'.repeat(64) }), + ).toThrow(/immutable lock/); + const sha256 = createHash('sha256').update('tool').digest('hex'); + expect(validateSupervisorArtifactFile(path, { size: 4, sha256 })).toBe(realpathSync(path)); + } finally { + rmSync(root, { recursive: true }); + } + }); + test('parses Cave assertion output without retaining diagnostic prose', () => { const parsed = parseCaveConformanceOutput( [ @@ -189,6 +921,21 @@ describe('Phase 1 real-authority conformance harness', () => { expect([...parsed.keys()].join(' ')).not.toContain('private detail'); }); + test('extracts only successful Rust test names for assertion proof binding', () => { + const passed = parsePassedRustTests( + [ + 'test discovery::tests::owner_only_pipe_validation ... ok', + 'test health_failure ... FAILED', + 'test connected_peer_uid_must_match ... ok', + ].join('\n'), + ); + + expect([...passed]).toEqual([ + 'discovery::tests::owner_only_pipe_validation', + 'connected_peer_uid_must_match', + ]); + }); + test('accepts pairing denial as a successful status envelope', () => { expect(assertPairingStatus({ status: 'denied' }, 'denied')).toEqual({ status: 'denied' }); expect(() => assertPairingStatus({ status: 'pending' }, 'denied')).toThrow( @@ -196,35 +943,6 @@ describe('Phase 1 real-authority conformance harness', () => { ); }); - test('accepts only the SDK incompatible-version failure from compatibility presets', () => { - expect(assertCompatibilityFailure({ code: 'incompatible_version' }, 'api-major')).toEqual({ - code: 'incompatible_version', - }); - expect(() => - assertCompatibilityFailure({ code: 'invalid_response' }, 'minimum-client'), - ).toThrow(/minimum-client preset did not produce incompatible_version/); - expect(() => assertCompatibilityFailure(undefined, 'api-major')).toThrow( - /api-major preset did not produce incompatible_version/, - ); - }); - - test('compares native missing-keychain responses structurally instead of by key order', () => { - expect( - assertNativeMissingKeychainResponses([ - { - error: { retryable: true, code: 'secure_store_unavailable' }, - ok: false, - id: 'installation', - }, - { - result: { status: 'shutting_down' }, - ok: true, - id: 'shutdown', - }, - ]), - ).toHaveLength(2); - }); - test('observes a native RPC child that closes synchronously during shutdown', async () => { const client = new NativeRpcClient(new SynchronousCloseChild()); @@ -244,6 +962,74 @@ describe('Phase 1 real-authority conformance harness', () => { await expect(client.close()).rejects.toThrow(/exit code 7/); }); + test('accepts the owned supervisor group kill after a successful shutdown response', async () => { + const client = new NativeRpcClient(new SynchronousSupervisorCloseChild(), { + supervised: true, + }); + + await expect(client.close()).resolves.toBeUndefined(); + }); + + test.each([ + ['nonzero', Promise.resolve({ code: 1, signal: null, reason: 'exit' })], + ['panic', Promise.resolve({ code: null, signal: 'SIGABRT', reason: 'exit' })], + ['missing', undefined], + ['malformed', Promise.resolve('malformed status')], + ] as const)('rejects %s long-lived supervisor status', async (_kind, status) => { + const child = new SynchronousSupervisorCloseChild() as SynchronousSupervisorCloseChild & { + __phase1SupervisorStatus?: Promise; + }; + Object.defineProperty(child, '__phase1SupervisorStatus', { + value: status, + configurable: true, + }); + + const client = new NativeRpcClient(child, { supervised: true }); + + await expect(client.close()).rejects.toThrow(); + }); + + test('rejects missing malformed and duplicate private supervisor frames', () => { + for (const frame of [ + '', + '{}\n', + '{"code":0,"signal":null,"reason":"exit"}\n{"code":0,"signal":null,"reason":"exit"}\n', + '{"code":0,"signal":null,"reason":"forged"}\n', + '{"code":0,"signal":"SIGTERM","reason":"exit"}\n', + ]) { + expect(() => parseSupervisorStatusFrame(frame)).toThrow( + 'supervisor status frame was not canonical', + ); + } + }); + + test.skipIf(process.platform === 'win32')( + 'retains authenticated nonzero and panic status for long-lived owned targets', + async () => { + const exit = (await runOwnedProcessStatusForTest( + process.execPath, + ['-e', 'process.exit(1)'], + { + cwd: projectRoot, + env: process.env, + }, + )) as { status: { code: number | null; signal: string | null; reason: string } }; + expect(exit.status).toEqual({ code: 1, signal: null, reason: 'exit' }); + + const panic = (await runOwnedProcessStatusForTest( + process.execPath, + ['-e', 'process.abort()'], + { + cwd: projectRoot, + env: process.env, + }, + )) as { status: { code: number | null; signal: string | null; reason: string } }; + expect(panic.status.reason).toBe('exit'); + expect(panic.status.code).toBeNull(); + expect(panic.status.signal).toMatch(/SIGABRT|SIGTRAP/u); + }, + ); + test('rejects a native RPC child that crashed before shutdown began', async () => { const child = new SynchronousCloseChild(); child.exitCode = 7; @@ -258,6 +1044,29 @@ describe('Phase 1 real-authority conformance harness', () => { await expect(client.close()).rejects.toThrow(/shutdown timed out/); }); + test('rejects pending RPC requests when child stdin closes without an unhandled stream error', async () => { + const child = new EventEmitter() as EventEmitter & { + stdout: PassThrough; + stdin: EventEmitter & { write(value: string, callback?: (error?: Error) => void): boolean }; + exitCode: number | null; + signalCode: NodeJS.Signals | null; + }; + child.stdout = new PassThrough(); + child.stdin = Object.assign(new EventEmitter(), { + write(_value: string, callback?: (error?: Error) => void) { + const error = new Error('broken pipe'); + callback?.(error); + child.stdin.emit('error', error); + return false; + }, + }); + child.exitCode = null; + child.signalCode = null; + const rpc = new NativeRpcClient(child); + + await expect(rpc.request('app_installation_id')).rejects.toThrow('native RPC transport closed'); + }); + test('closes the fixture daemon when native scenario setup fails', async () => { let closed = false; const fixtureDaemon = { @@ -294,6 +1103,342 @@ describe('Phase 1 real-authority conformance harness', () => { expect(timeout.message).not.toContain('private compiler output'); }); + test('extracts only one allowlisted verified-runner diagnostic from captured stderr', () => { + expect( + extractVerifiedRunnerDiagnostic( + [ + 'private compiler output', + 'phase1-conformance: phase1.coven-identity.socket-mode', + 'private operator path', + ].join('\n'), + ), + ).toBe('phase1.coven-identity.socket-mode'); + expect( + extractVerifiedRunnerDiagnostic('phase1-conformance: private operator path'), + ).toBeUndefined(); + expect( + extractVerifiedRunnerDiagnostic( + [ + 'phase1-conformance: phase1.coven-identity.socket-mode', + 'phase1-conformance: phase1.stage.isolation.failed', + ].join('\n'), + ), + ).toBeUndefined(); + }); + + test('classifies the failed Cave release build phase without exposing output', () => { + const failure = new CommandExecutionError('private command', { + code: 1, + stdout: [ + '> coven-cave@0.3.11 prebuild', + '> coven-cave@0.3.11 build', + 'Creating an optimized production build', + '> coven-cave@0.3.11 build:server', + '> coven-cave@0.3.11 postbuild', + 'private budget details', + ].join('\n'), + stderr: 'private stderr', + }); + + const diagnostic = classifyPackagingCommandFailure('phase1.packaging.cave-build', failure); + expect(diagnostic).toBe('phase1.packaging.cave-build.phase.postbuild'); + expect(diagnostic).not.toContain('private'); + + const resourceFailure = new CommandExecutionError('private command', { + code: 1, + stdout: 'Creating an optimized production build\nuncaughtException: spawn EAGAIN', + stderr: 'private stderr', + }); + expect(classifyPackagingCommandFailure('phase1.packaging.cave-build', resourceFailure)).toBe( + 'phase1.packaging.cave-build.phase.next-build.resource.spawn', + ); + expect( + classifyPackagingCommandFailure( + 'phase1.packaging.cave-build', + new CommandExecutionError('private command', { + code: 1, + stdout: '\u001b[31mCreating an optimized production build\r\nheap out of memory\u001b[0m', + stderr: 'private stderr', + }), + ), + ).toBe('phase1.packaging.cave-build.phase.next-build.resource.memory.heap'); + expect( + classifyPackagingCommandFailure( + 'phase1.packaging.cave-build', + new CommandExecutionError('private command', { + code: 1, + stdout: 'Creating an optimized production build', + stderr: 'private path: ENOMEM', + }), + ), + ).toBe('phase1.packaging.cave-build.phase.next-build.resource.memory.allocation'); + expect( + classifyPackagingCommandFailure( + 'phase1.packaging.cave-build', + new CommandExecutionError('private command', { + code: 1, + stdout: 'Creating an optimized production build', + stderr: 'private path: Killed: 9', + }), + ), + ).toBe('phase1.packaging.cave-build.phase.next-build.resource.killed'); + }); + + test('classifies Coven verification failures without exposing command output', () => { + for (const [result, expected] of [ + [{ code: 101, signal: null }, 'phase1.packaging.coven-client-lib-tests.cargo-failure'], + [{ reason: 'timeout' }, 'phase1.packaging.coven-client-lib-tests.timeout'], + [{ reason: 'stdout-limit' }, 'phase1.packaging.coven-client-lib-tests.output-limit'], + [{ reason: 'supervisor-termination' }, 'phase1.packaging.coven-client-lib-tests.supervisor'], + [{ reason: 'spawn' }, 'phase1.packaging.coven-client-lib-tests.spawn'], + ] as const) { + const error = new CommandExecutionError('private command', { + ...result, + stdout: 'private stdout', + stderr: 'private stderr', + }); + + const diagnostic = classifyPackagingCommandFailure( + 'phase1.packaging.coven-client-lib-tests', + error, + ); + expect(diagnostic).toBe(expected); + expect(diagnostic).not.toContain('private'); + } + const exactFailure = new CommandExecutionError('private command', { + code: 101, + stdout: + '\u001b[31m---- lifecycle::tests::macos_tmp_spellings_have_the_same_canonical_filesystem_identity stdout ----\u001b[0m\r\nprivate\r\ntest result: FAILED. 0 passed; 1 failed\r\n', + stderr: 'private stderr', + }); + expect( + classifyPackagingCommandFailure('phase1.packaging.coven-client-lib-tests', exactFailure), + ).toBe('phase1.packaging.coven-client-lib-tests.libtest.lifecycle'); + const transportFailure = new CommandExecutionError('private command', { + code: 101, + stdout: + '---- transport::unix::tests::response_reader_retries_interrupted_reads stdout ----\ntest result: FAILED. 0 passed; 1 failed\n', + stderr: 'private stderr', + }); + expect( + classifyPackagingCommandFailure('phase1.packaging.coven-client-lib-tests', transportFailure), + ).toBe('phase1.packaging.coven-client-lib-tests.libtest.transport-unix'); + const malformed = new CommandExecutionError('private command', { + code: 101, + stdout: '---- private::module::secret stdout ----\ntest result: FAILED.\n', + stderr: 'private stderr', + }); + expect( + classifyPackagingCommandFailure('phase1.packaging.coven-client-lib-tests', malformed), + ).toBe('phase1.packaging.coven-client-lib-tests.malformed-output'); + }); + + test('reruns every lifecycle test diagnostically without clearing the original failure', async () => { + const original = new CommandExecutionError('private command', { + code: 101, + stdout: + '---- lifecycle::tests::macos_tmp_spellings_have_the_same_canonical_filesystem_identity stdout ----\ntest result: FAILED. 0 passed; 1 failed\n', + stderr: 'private stderr', + }); + const rerun: string[] = []; + await expect( + diagnoseCovenLifecycleFailure(original, async (testName) => { + rerun.push(testName); + }), + ).rejects.toThrow( + 'phase1.packaging.coven-client-lib-tests.lifecycle.concurrency-or-order-dependent', + ); + expect(rerun).toHaveLength(7); + + await expect( + diagnoseCovenLifecycleFailure(original, async (testName) => { + if (testName.endsWith('macos_legacy_shutdown_fails_closed_with_upgrade_guidance')) { + throw new Error('private rerun output'); + } + }), + ).rejects.toThrow( + 'phase1.packaging.coven-client-lib-tests.lifecycle.macos_legacy_shutdown_fails_closed_with_upgrade_guidance', + ); + + for (const [message, category] of [ + ['bind selected lifecycle socket', 'socket-setup'], + ['make selected socket private', 'socket-setup'], + ['unlink selected socket after lifecycle pre-check', 'unlink-selected-socket'], + ['move selected profile out of the way', 'move-selected-profile'], + ['create substituted profile home', 'create-substituted-home'], + ['make substituted profile private', 'create-substituted-home'], + [ + 'profile replacement must not be classified as a stopped daemon', + 'identity-substitution-accepted', + ], + ['assertion failed: matches!(error, crate::ClientError::Discovery(_))', 'wrong-error-class'], + ['clean moved lifecycle test home', 'cleanup-moved-home'], + ['private unrecognized panic', 'unknown'], + ] as const) { + await expect( + diagnoseCovenLifecycleFailure(original, async (testName) => { + if (testName.endsWith('lifecycle_discovery_does_not_hide_a_replaced_profile_home')) { + throw new CommandExecutionError('private rerun', { + code: 101, + stdout: `\u001b[31m${message}\u001b[0m\r\n`, + stderr: 'private path and error', + }); + } + }), + ).rejects.toThrow( + `phase1.packaging.coven-client-lib-tests.lifecycle.replaced-profile.${category}`, + ); + } + }); + + test.skipIf(process.platform === 'win32')( + 'supervised output limits terminate compiler-shaped descendant trees', + async () => { + const root = createProcessOwnedArtifactRoot({ prefix: 'phase1-output-limit' }); + const pidPath = resolve(root.rootPath, 'compiler-descendant.pid'); + try { + await expect( + runSupervisedCommandForTest( + root, + process.execPath, + [ + '-e', + [ + "const {spawn}=require('node:child_process');", + "const {writeFileSync}=require('node:fs');", + "const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});", + `writeFileSync(${JSON.stringify(pidPath)},String(child.pid));`, + "process.stdout.write('x'.repeat(2048));", + 'setInterval(()=>{},1000);', + ].join(''), + ], + { + cwd: root.rootPath, + env: process.env, + outputLimitBytes: 1024, + timeoutMs: 5_000, + }, + ), + ).rejects.toThrow(/stdout-limit/); + const descendantPid = await waitForPidFile(pidPath); + await root.cleanup(); + expect(processIsLive(descendantPid)).toBe(false); + } finally { + await root.cleanup().catch(() => undefined); + } + }, + ); + + test.skipIf(process.platform === 'win32' || !existsSync('/bin/sh'))( + 'supervisor status cannot be forged through the legacy environment or target fd3 (requires POSIX /bin/sh)', + async () => { + const root = createProcessOwnedArtifactRoot({ + prefix: 'phase1-private-supervisor-status', + }); + try { + const legacyStatusPath = resolve(root.rootPath, 'legacy-status.json'); + const forgedFrame = '{"code":0,"signal":null,"reason":"exit"}'; + const source = [ + `if [ -n "\${OPENCOVEN_PHASE1_SUPERVISOR_STATUS_PATH:-}" ]; then`, + ` printf '%s' '${forgedFrame}' > "$OPENCOVEN_PHASE1_SUPERVISOR_STATUS_PATH"`, + 'fi', + `printf '%s' '${forgedFrame}' >&3 2>/dev/null || true`, + 'exit 7', + ].join('\n'); + await expect( + runSupervisedCommandForTest(root, '/bin/sh', ['-c', source], { + cwd: root.rootPath, + env: { + ...process.env, + OPENCOVEN_PHASE1_SUPERVISOR_STATUS_PATH: legacyStatusPath, + }, + timeoutMs: 5_000, + outputLimitBytes: 4_096, + }), + ).rejects.toMatchObject({ + result: expect.objectContaining({ code: 7 }), + }); + expect(existsSync(legacyStatusPath)).toBe(false); + } finally { + await root.cleanup(); + } + }, + ); + + test.skipIf(process.platform === 'win32')( + 'rejects a forged success frame when supervisor group kill fails and a descendant survives', + async () => { + const root = createProcessOwnedArtifactRoot({ prefix: 'phase1-group-kill-failure' }); + const pidPath = resolve(root.rootPath, 'surviving-descendant.pid'); + let descendantPid: number | undefined; + try { + const source = [ + "const{spawn}=require('node:child_process');const{writeFileSync}=require('node:fs');", + "const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});", + 'child.unref();', + `writeFileSync(${JSON.stringify(pidPath)},String(child.pid));`, + ].join(''); + await expect( + runSupervisedCommandForTest(root, process.execPath, ['-e', source], { + cwd: root.rootPath, + env: { ...process.env, OPENCOVEN_PHASE1_TEST_GROUP_KILL_FAILURE: '1' }, + timeoutMs: 5_000, + outputLimitBytes: 4_096, + }), + ).rejects.toThrow(/supervisor-termination/); + descendantPid = await waitForPidFile(pidPath); + expect(processIsLive(descendantPid)).toBe(true); + } finally { + if (descendantPid !== undefined && processIsLive(descendantPid)) { + process.kill(descendantPid, 'SIGKILL'); + const deadline = Date.now() + 2_000; + while (processIsLive(descendantPid) && Date.now() < deadline) { + await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + } + } + await root.cleanup(); + } + }, + 10_000, + ); + + test.skipIf(process.platform === 'win32')( + 'supervised timeouts terminate package-manager-shaped descendant trees', + async () => { + const root = createProcessOwnedArtifactRoot({ prefix: 'phase1-command-timeout' }); + const pidPath = resolve(root.rootPath, 'package-descendant.pid'); + try { + await expect( + runSupervisedCommandForTest( + root, + process.execPath, + [ + '-e', + [ + "const {spawn}=require('node:child_process');", + "const {writeFileSync}=require('node:fs');", + "const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});", + `writeFileSync(${JSON.stringify(pidPath)},String(child.pid));`, + 'setInterval(()=>{},1000);', + ].join(''), + ], + { + cwd: root.rootPath, + env: process.env, + outputLimitBytes: 1024, + timeoutMs: 500, + }, + ), + ).rejects.toThrow(/timeout/); + const descendantPid = await waitForPidFile(pidPath); + await root.cleanup(); + expect(processIsLive(descendantPid)).toBe(false); + } finally { + await root.cleanup().catch(() => undefined); + } + }, + ); + test('preserves safe subprocess classification when attaching the final report', () => { const report = { status: 'failed' }; const original = new CommandExecutionError('Cave real-authority conformance', { @@ -308,6 +1453,475 @@ describe('Phase 1 real-authority conformance harness', () => { expect(wrapped.result).toEqual({ reason: 'timeout', report }); }); + test('publishes only stable operator fingerprint diagnostic IDs', () => { + expect( + publicPhase1FailureDiagnostic( + new AggregateError([ + new Error('private operator path'), + new Error('phase1.operator-fingerprint.control-file-limit'), + ]), + ), + ).toBe('phase1.operator-fingerprint.control-file-limit'); + expect(publicPhase1FailureDiagnostic(new Error('phase1.stage.native-provider.failed'))).toBe( + 'phase1.stage.native-provider.failed', + ); + expect(publicPhase1FailureDiagnostic(new Error('phase1.stage.runner-bootstrap.failed'))).toBe( + 'phase1.stage.runner-bootstrap.failed', + ); + expect(publicPhase1FailureDiagnostic(new Error('phase1.stage.runner-checkout.failed'))).toBe( + 'phase1.stage.runner-checkout.failed', + ); + expect(publicPhase1FailureDiagnostic(new Error('phase1.stage.environment.failed'))).toBe( + 'phase1.stage.environment.failed', + ); + expect( + publicPhase1FailureDiagnostic(new Error('phase1.environment.rust-toolchain.failed')), + ).toBe('phase1.environment.rust-toolchain.failed'); + expect(publicPhase1FailureDiagnostic(new Error('phase1.native-scenarios.restart'))).toBe( + 'phase1.native-scenarios.restart', + ); + expect( + publicPhase1FailureDiagnostic(new Error('phase1.native-scenarios.restart-discovery')), + ).toBe('phase1.native-scenarios.restart-discovery'); + expect(publicPhase1FailureDiagnostic(new Error('private operator path'))).toBeUndefined(); + }); + + test.each([ + 'phase1.stage.isolation-proof.failed', + 'phase1.stage.assertion-recording.failed', + 'phase1.stage.evidence-build.failed', + 'phase1.stage.evidence-validation.failed', + 'phase1.stage.evidence-retention.failed', + ])('publishes the bounded post-runtime diagnostic %s', (diagnosticId) => { + expect(publicPhase1FailureDiagnostic(new Error(diagnosticId))).toBe(diagnosticId); + }); + + test.each([ + ['evidence exceeds the 1-byte evidence limit', 'size'], + ['Evidence exceeds the 50000-node limit', 'size'], + ['Evidence exceeds the 32-level depth limit', 'size'], + ['Chat Phase 1 platform evidence is not valid JSON: private parser detail', 'json'], + ['duplicate JSON object key at private.path', 'duplicate-key'], + ['evidence.private contains a possible secret', 'possible-secret'], + ['evidence.private contains a private filesystem path', 'private-path'], + ['forbidden evidence field "private"', 'forbidden-field'], + ['evidence.private contains a non-JSON value', 'non-json'], + ['Chat Phase 1 platform evidence.private has unexpected field "detail"', 'shape'], + ['private validator failure', 'unknown'], + ])('classifies evidence validation %s without exposing details', (message, category) => { + const diagnosticId = `phase1.stage.evidence-validation.${category}`; + expect(evidenceValidationFailureDiagnostic(new Error(message))).toBe(diagnosticId); + expect(publicPhase1FailureDiagnostic(new Error(diagnosticId))).toBe(diagnosticId); + }); + + test('wraps operator-state comparison in the bounded isolation-proof stage', () => { + const source = readFileSync(resolve(projectRoot, 'scripts', 'phase1-conformance.mjs'), 'utf8'); + const postRuntime = source.slice( + source.indexOf('const { operatorStateAfter, isolationRoots }'), + source.indexOf("const assertionResults = runPublicPhase1Stage('phase1.stage.assertion"), + ); + + expect(postRuntime).toContain("'phase1.stage.isolation-proof.failed'"); + expect(postRuntime).toContain('operatorStateAfter: finalizeOperatorSafety({'); + }); + + test.each([ + [ + 'timeout', + { terminationReason: 'timeout' }, + 'phase1.native-scenarios.missing-keychain-timeout', + ], + [ + 'output limit', + { terminationReason: 'stdout-limit' }, + 'phase1.native-scenarios.missing-keychain-output-limit', + ], + ['process', { processFailed: true }, 'phase1.native-scenarios.missing-keychain-process'], + [ + 'termination', + { supervised: true, signal: null }, + 'phase1.native-scenarios.missing-keychain-termination', + ], + [ + 'supervisor', + { supervisorStatusValid: false }, + 'phase1.native-scenarios.missing-keychain-supervisor', + ], + ['canary', { canaryExposed: true }, 'phase1.native-scenarios.missing-keychain-canary'], + ['home', { homeChanged: true }, 'phase1.native-scenarios.missing-keychain-home'], + ['response', { responseValid: false }, 'phase1.native-scenarios.missing-keychain-response'], + ] as const)( + 'classifies missing-keychain %s failures without private output', + (_name, change, id) => { + const diagnostic = nativeMissingKeychainFailureDiagnostic({ + supervised: true, + code: null, + signal: 'SIGKILL', + supervisorStatusValid: true, + terminationReason: undefined, + killFailed: false, + processFailed: false, + canaryExposed: false, + homeChanged: false, + responseValid: true, + ...change, + }); + + expect(diagnostic).toBe(id); + expect(publicPhase1FailureDiagnostic(new Error(diagnostic ?? 'missing'))).toBe(id); + }, + ); + test('passes the observed supervisor status into missing-keychain diagnostics', () => { + const source = readFileSync(resolve(projectRoot, 'scripts', 'phase1-conformance.mjs'), 'utf8'); + const scenario = source.slice( + source.indexOf('async function runNativeMissingKeychainTrustScenario'), + source.indexOf('async function runNativeScenarios'), + ); + + expect(scenario).toContain('supervisorStatusValid: supervisedStatusValid'); + }); + + test('accepts exact missing-keychain responses regardless of object key order', () => { + const responses = [ + JSON.parse( + '{"error":{"code":"secure_store_unavailable","retryable":true},"id":"installation","ok":false}', + ), + JSON.parse('{"id":"shutdown","ok":true,"result":{"status":"shutting_down"}}'), + ]; + + expect(nativeMissingKeychainResponsesValid(responses)).toBe(true); + expect( + nativeMissingKeychainResponsesValid([{ ...responses[0], extra: true }, responses[1]]), + ).toBe(false); + }); + + test('classifies the first failed runtime scenario without exposing private output', () => { + const classifications = [ + [ + 'phase1.missing-cave.validated-launch', + 'phase1.runtime-assertions.missing-cave-validated-launch', + ], + [ + 'phase1.pairing.create-pending-approve-exchange', + 'phase1.runtime-assertions.pairing-create-pending-approve-exchange', + ], + ['phase1.pairing.denial', 'phase1.runtime-assertions.pairing-denial'], + ['phase1.pairing.expiry', 'phase1.runtime-assertions.pairing-expiry'], + [ + 'phase1.pairing.wrong-secret-replay', + 'phase1.runtime-assertions.pairing-wrong-secret-replay', + ], + [ + 'phase1.pairing.failure-budget-retry-after', + 'phase1.runtime-assertions.pairing-failure-budget-retry-after', + ], + ['phase1.credential.restart-reuse', 'phase1.runtime-assertions.credential-restart-reuse'], + [ + 'phase1.credential.revocation-repair', + 'phase1.runtime-assertions.credential-revocation-repair', + ], + ['phase1.hpke.endpoint-takeover', 'phase1.runtime-assertions.hpke-endpoint-takeover'], + ['phase1.reads.bounded-canonical', 'phase1.runtime-assertions.reads-bounded-canonical'], + [ + 'phase1.reads.stale-generation-cursor-reconciliation', + 'phase1.runtime-assertions.reads-stale-generation-cursor-reconciliation', + ], + ['phase1.coven.same-user-identity', 'phase1.runtime-assertions.coven-same-user-identity'], + [ + 'phase1.native.missing-keychain-trust', + 'phase1.runtime-assertions.native-missing-keychain-trust', + ], + ] as const; + const results = new Map(); + + for (const [id, diagnosticId] of classifications) { + const diagnostic = runtimeScenarioFailureDiagnostic(results); + expect(diagnostic).toBe(diagnosticId); + expect(publicPhase1FailureDiagnostic(new Error(diagnostic))).toBe(diagnosticId); + results.set(id, { status: 'passed' }); + } + expect(runtimeScenarioFailureDiagnostic(results)).toBeUndefined(); + }); + + test.each([ + 'rpc-start', + 'unavailable-health', + 'daemon-spawn', + 'daemon-ready', + 'malicious-home', + 'wrong-mode-home', + 'symlink-socket-home', + 'socket-mode', + 'result', + ])('classifies the fixed Coven identity %s stage', (stage) => { + const diagnosticId = covenIdentityFailureDiagnostic(stage); + expect(diagnosticId).toBe(`phase1.coven-identity.${stage}`); + expect(publicPhase1FailureDiagnostic(new Error(diagnosticId))).toBe(diagnosticId); + expect( + runtimeScenarioFailureDiagnostic( + new Map([ + ['phase1.missing-cave.validated-launch', { status: 'passed' }], + ['phase1.pairing.create-pending-approve-exchange', { status: 'passed' }], + ['phase1.pairing.denial', { status: 'passed' }], + ['phase1.pairing.expiry', { status: 'passed' }], + ['phase1.pairing.wrong-secret-replay', { status: 'passed' }], + ['phase1.pairing.failure-budget-retry-after', { status: 'passed' }], + ['phase1.credential.restart-reuse', { status: 'passed' }], + ['phase1.credential.revocation-repair', { status: 'passed' }], + ['phase1.hpke.endpoint-takeover', { status: 'passed' }], + ['phase1.reads.bounded-canonical', { status: 'passed' }], + ['phase1.reads.stale-generation-cursor-reconciliation', { status: 'passed' }], + ['phase1.coven.same-user-identity', { status: 'failed', diagnosticIds: [diagnosticId] }], + ]), + ), + ).toBe(diagnosticId); + }); + + test('bounds unknown Coven identity failure stages', () => { + const diagnosticId = covenIdentityFailureDiagnostic('private stage detail'); + expect(diagnosticId).toBe('phase1.coven-identity.unknown'); + expect(publicPhase1FailureDiagnostic(new Error(diagnosticId))).toBe(diagnosticId); + }); + + test('records the active fixed Coven identity stage on failure', () => { + const source = readFileSync(resolve(projectRoot, 'scripts', 'phase1-conformance.mjs'), 'utf8'); + const scenario = source.slice( + source.indexOf('async function runCovenIdentityScenario'), + source.indexOf('function recordCaveBackedAssertions'), + ); + + for (const stage of [ + 'rpc-start', + 'unavailable-health', + 'daemon-spawn', + 'daemon-ready', + 'malicious-home', + 'wrong-mode-home', + 'symlink-socket-home', + 'socket-mode', + 'result', + ]) { + expect(scenario).toContain(`activeCovenIdentityStage = '${stage}'`); + } + expect(scenario).toContain( + 'const diagnosticId = covenIdentityFailureDiagnostic(activeCovenIdentityStage)', + ); + expect(scenario).toContain( + "addAssertion(results, 'phase1.coven.same-user-identity', 'failed', diagnosticId)", + ); + }); + + test('expects the bounded production diagnostic from rejected Coven child probes', () => { + const source = readFileSync(resolve(projectRoot, 'scripts', 'phase1-conformance.mjs'), 'utf8'); + const scenario = source.slice( + source.indexOf('async function runCovenIdentityScenario'), + source.indexOf('function recordCaveBackedAssertions'), + ); + + expect(scenario.match(/'service_unavailable'/gu)).toHaveLength(5); + expect(scenario).not.toContain("'reconcile_required'"); + }); + + test('always performs the operator after-check and aggregates scenario cleanup and mutation failures', () => { + const primary = new Error('scenario failed'); + const cleanup = new Error('cleanup failed'); + const mutation = new Error('operator state changed'); + const snapshots: string[] = []; + const comparisons: unknown[][] = []; + + expect(() => + finalizeOperatorSafety({ + primaryFailure: primary, + cleanupFailure: cleanup, + operatorStateBefore: { digest: 'before' }, + snapshotAfter: () => { + snapshots.push('after'); + return { digest: 'after' }; + }, + compare: (before, after) => { + comparisons.push([before, after]); + throw mutation; + }, + }), + ).toThrow(expect.objectContaining({ errors: [primary, cleanup, mutation] })); + expect(snapshots).toEqual(['after']); + expect(comparisons).toEqual([[{ digest: 'before' }, { digest: 'after' }]]); + }); + + test('fingerprints large Coven homes without reading unrelated journal content', () => { + const scratchParent = resolve(projectRoot, 'test-results', 'vitest', 'operator-fingerprint'); + mkdirSync(scratchParent, { recursive: true }); + const home = mkdtempSync(resolve(scratchParent, 'large-home-')); + try { + const covenHome = resolve(home, '.coven'); + mkdirSync(resolve(covenHome, 'memory'), { recursive: true }); + const journalPath = resolve(covenHome, 'memory', 'journal.bin'); + writeFileSync(journalPath, ''); + truncateSync(journalPath, 65 * 1024 * 1024); + const caveHome = resolve(covenHome, 'cave'); + mkdirSync(resolve(caveHome, 'conversations'), { recursive: true }); + const conversationPath = resolve(caveHome, 'conversations', 'history.json'); + writeFileSync(conversationPath, ''); + truncateSync(conversationPath, 65 * 1024 * 1024); + + const state = snapshotOperatorState(home); + + expect(state['coven-home']).toMatch(/^[0-9a-f]{64}$/u); + expect(state['cave-home']).toMatch(/^[0-9a-f]{64}$/u); + } finally { + rmSync(home, { force: true, recursive: true }); + } + }); + + test('detects top-level and authority control mutations without exposing paths', () => { + const scratchParent = resolve(projectRoot, 'test-results', 'vitest', 'operator-fingerprint'); + mkdirSync(scratchParent, { recursive: true }); + const home = mkdtempSync(resolve(scratchParent, 'mutation-home-')); + try { + const covenHome = resolve(home, '.coven'); + mkdirSync(resolve(covenHome, 'memory'), { recursive: true }); + const before = snapshotOperatorState(home); + + writeFileSync(resolve(covenHome, 'daemon.json'), '{"pid":1}\n'); + const controlMutation = snapshotOperatorState(home); + expect(controlMutation['coven-home']).not.toBe(before['coven-home']); + + writeFileSync(resolve(covenHome, 'new-authority-entry'), 'created\n'); + const topLevelMutation = snapshotOperatorState(home); + expect(topLevelMutation['coven-home']).not.toBe(controlMutation['coven-home']); + expect(JSON.stringify(topLevelMutation)).not.toContain(home); + } finally { + rmSync(home, { force: true, recursive: true }); + } + }); + + test('ignores content churn inside unrelated Cave conversation trees', () => { + const scratchParent = resolve(projectRoot, 'test-results', 'vitest', 'operator-fingerprint'); + mkdirSync(scratchParent, { recursive: true }); + const home = mkdtempSync(resolve(scratchParent, 'cave-conversation-home-')); + try { + const conversations = resolve(home, '.coven', 'cave', 'conversations'); + mkdirSync(conversations, { recursive: true }); + const before = snapshotOperatorState(home); + + writeFileSync(resolve(conversations, 'active.json'), '{"message":"private"}\n'); + const after = snapshotOperatorState(home); + + expect(after['cave-home']).toBe(before['cave-home']); + expect(after.projects).toBe(before.projects); + } finally { + rmSync(home, { force: true, recursive: true }); + } + }); + + test('rejects symlinked Coven control files without following them', () => { + const scratchParent = resolve(projectRoot, 'test-results', 'vitest', 'operator-fingerprint'); + mkdirSync(scratchParent, { recursive: true }); + const home = mkdtempSync(resolve(scratchParent, 'symlink-home-')); + try { + const covenHome = resolve(home, '.coven'); + mkdirSync(covenHome, { recursive: true }); + const outside = resolve(home, 'outside.json'); + writeFileSync(outside, '{"private":"outside"}\n'); + symlinkSync(outside, resolve(covenHome, 'daemon.json'), 'file'); + + expect(() => snapshotOperatorState(home)).toThrow( + 'phase1.operator-fingerprint.unsafe-control-resource', + ); + } finally { + rmSync(home, { force: true, recursive: true }); + } + }); + + test('rejects broken symlinked Coven control files as unsafe', () => { + const scratchParent = resolve(projectRoot, 'test-results', 'vitest', 'operator-fingerprint'); + mkdirSync(scratchParent, { recursive: true }); + const home = mkdtempSync(resolve(scratchParent, 'broken-symlink-home-')); + try { + const covenHome = resolve(home, '.coven'); + mkdirSync(covenHome, { recursive: true }); + symlinkSync(resolve(home, 'missing.json'), resolve(covenHome, 'daemon.json'), 'file'); + + expect(() => snapshotOperatorState(home)).toThrow( + 'phase1.operator-fingerprint.unsafe-control-resource', + ); + } finally { + rmSync(home, { force: true, recursive: true }); + } + }); + + test('rejects oversized Coven authority control files without reading them', () => { + const scratchParent = resolve(projectRoot, 'test-results', 'vitest', 'operator-fingerprint'); + mkdirSync(scratchParent, { recursive: true }); + const home = mkdtempSync(resolve(scratchParent, 'oversized-control-home-')); + try { + const covenHome = resolve(home, '.coven'); + mkdirSync(covenHome, { recursive: true }); + const statusPath = resolve(covenHome, 'daemon.json'); + writeFileSync(statusPath, ''); + truncateSync(statusPath, 64 * 1024 + 1); + + expect(() => snapshotOperatorState(home)).toThrow( + 'phase1.operator-fingerprint.control-file-limit', + ); + } finally { + rmSync(home, { force: true, recursive: true }); + } + }); + + test.skipIf(process.platform === 'win32')( + 'fingerprints the Coven Unix socket as metadata without reading it', + async () => { + const scratchParent = resolve(projectRoot, 'test-results'); + mkdirSync(scratchParent, { recursive: true }); + const home = mkdtempSync(resolve(scratchParent, 'fp-')); + const covenHome = resolve(home, '.coven'); + const socketPath = resolve(covenHome, 'coven.sock'); + mkdirSync(covenHome, { recursive: true }); + const server = createServer(); + try { + await new Promise((resolveListen, rejectListen) => { + server.once('error', rejectListen); + server.listen(socketPath, resolveListen); + }); + + expect(snapshotOperatorState(home)['coven-home']).toMatch(/^[0-9a-f]{64}$/u); + } finally { + if (server.listening) { + await new Promise((resolveClose) => server.close(() => resolveClose())); + } + rmSync(home, { force: true, recursive: true }); + } + }, + ); + + test('aggregates native scenario cleanup RPC and daemon failures in deterministic order', () => { + const [scenario, cleanup, rpc, daemon] = [ + new Error('scenario'), + new Error('cleanup'), + new Error('rpc'), + new Error('daemon'), + ]; + for (const [property, error] of [ + ['scenarioFailure', scenario], + ['cleanupFailure', cleanup], + ['rpcCleanupFailure', rpc], + ['daemonCloseFailure', daemon], + ] as const) { + expect(() => throwNativeScenarioFailures({ [property]: error })).toThrow( + expect.objectContaining({ errors: [error] }), + ); + } + expect(() => + throwNativeScenarioFailures({ + scenarioFailure: scenario, + cleanupFailure: cleanup, + rpcCleanupFailure: rpc, + daemonCloseFailure: daemon, + }), + ).toThrow(expect.objectContaining({ errors: [scenario, cleanup, rpc, daemon] })); + }); + test('cleans an owned root when setup fails before its child starts', async () => { let cleaned = false; const ownedRoot = { @@ -322,56 +1936,115 @@ describe('Phase 1 real-authority conformance harness', () => { }), ).rejects.toThrow(/spawn failed/); expect(cleaned).toBe(true); + + const source = readFileSync( + resolve(import.meta.dirname, '..', 'scripts', 'phase1-conformance.mjs'), + 'utf8', + ); + const rootCreation = source.indexOf( + "const executionRoot = runPublicPhase1Stage('phase1.stage.execution-root.failed'", + ); + const guardedSetup = source.indexOf('try {', rootCreation); + expect(rootCreation).toBeGreaterThan(-1); + expect(guardedSetup).toBeGreaterThan(rootCreation); + expect( + source.indexOf( + "environment = runPublicPhase1Stage('phase1.stage.environment.failed'", + guardedSetup, + ), + ).toBeGreaterThan(guardedSetup); }); test('isolates Cargo credentials while using the resolved Rust toolchain', () => { const root = mkdtempSync(join(tmpdir(), 'phase1-safe-environment-')); - const toolchainBin = join(root, 'toolchain', 'bin'); - const cargoPath = join(toolchainBin, 'cargo'); - const originalPath = process.env.PATH; try { - mkdirSync(toolchainBin, { recursive: true }); - writeFileSync(cargoPath, ''); - process.env.PATH = ''; - - const environment = safeEnvironment(root, {}, cargoPath); - const resolvedToolchainBin = dirname(realpathSync(cargoPath)); + const environment = safeEnvironment(root); expect(environment.CARGO_HOME).toBe(resolve(root, 'cargo-home')); expect(environment.RUSTUP_HOME).toBeUndefined(); expect(environment.HOME).toBe(resolve(root, 'home')); - expect(environment.PATH).toBe(resolvedToolchainBin); - expect(environment.RUSTC).toBe(join(resolvedToolchainBin, 'rustc')); - expect(environment.RUSTDOC).toBe(join(resolvedToolchainBin, 'rustdoc')); } finally { - process.env.PATH = originalPath; rmSync(root, { recursive: true }); } }); - test('allows cold isolated Cargo builds to exceed the general command deadline', () => { - expect(cargoBuildTimeoutMs).toBeGreaterThan(20 * 60_000); + test('uses fixed resource limits for the Cave release build', () => { + expect( + caveBuildEnvironment({ + PATH: '/safe/bin', + NODE_OPTIONS: '--require=/private/injection.cjs', + CIRCLE_NODE_TOTAL: '999', + }), + ).toEqual({ + PATH: '/safe/bin', + NODE_OPTIONS: '--max-old-space-size=6144', + CIRCLE_NODE_TOTAL: '3', + }); }); - test.each([ - ['timeout while receiving message from process', 'turbopack-plugin-timeout'], - [ - 'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory', - 'memory-exhausted', - ], - ['write failed: ENOSPC', 'disk-exhausted'], - ['command terminated by SIGKILL', 'process-killed'], - ['TurbopackInternalError: worker panicked', 'compiler-crash'], - ['Build worker exited unexpectedly', 'worker-exited'], - ['Failed to collect page data for /api/client/v1/health', 'page-data-failed'], - ['Failed to compile.', 'compile-failed'], - ])('classifies Cave package failures without retaining output', (stderr, expected) => { - expect(classifyCavePackageFailure({ stdout: '', stderr })).toBe(expected); + test('uses the operator home only for isolated macOS keychain process tests', () => { + const isolated = { HOME: '/isolated/home', CARGO_HOME: '/isolated/cargo' }; + expect(nativeAdapterTestEnvironment(isolated, 'darwin', { HOME: '/operator/home' })).toEqual({ + HOME: '/operator/home', + CARGO_HOME: '/isolated/cargo', + }); + expect(nativeAdapterTestEnvironment(isolated, 'linux', { HOME: '/operator/home' })).toBe( + isolated, + ); + expect(() => nativeAdapterTestEnvironment(isolated, 'darwin', { HOME: 'relative' })).toThrow( + 'phase1.packaging.native-test-home.invalid', + ); }); - test('does not classify unknown Cave package output', () => { - expect( - classifyCavePackageFailure({ stdout: 'arbitrary private output', stderr: '' }), - ).toBeUndefined(); + test('resolves absent and empty RUSTUP_HOME through absolute HOME', () => { + expect(resolveRustupHome({ HOME: '/reviewed/home' })).toBe('/reviewed/home/.rustup'); + expect(resolveRustupHome({ HOME: '/reviewed/home', RUSTUP_HOME: '' })).toBe( + '/reviewed/home/.rustup', + ); + expect(resolveRustupHome({ HOME: '/reviewed/home', RUSTUP_HOME: '/reviewed/rustup' })).toBe( + '/reviewed/rustup', + ); + }); + + test('rejects relative or non-canonical Rustup homes', () => { + for (const rustupHome of ['relative/rustup', '/reviewed/../rustup']) { + expect(() => resolveRustupHome({ HOME: '/reviewed/home', RUSTUP_HOME: rustupHome })).toThrow( + 'phase1.environment.rustup-home.invalid', + ); + } + }); + + test.skipIf(process.platform === 'win32')( + 'launcher with empty CI RUSTUP_HOME reaches a later reviewed failure', + () => { + const root = resolve(projectRoot, 'test-results', 'phase1-empty-rustup', randomUUID()); + const missingSdk = resolve(root, 'missing-sdk'); + const launcher = resolve(projectRoot, 'scripts', 'phase1-conformance-launcher.sh'); + try { + mkdirSync(root, { recursive: true }); + let failure: unknown; + try { + execFileSync('/bin/sh', [launcher, process.execPath, '--sdk-root', missingSdk], { + cwd: projectRoot, + env: { ...process.env, RUSTUP_HOME: '' }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 30_000, + }); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ stderr: expect.any(String) }); + const stderr = String((failure as { stderr: string }).stderr); + expect(stderr).toMatch(/phase1\.stage\.(?:native-provider|checkouts)\.failed/u); + expect(stderr).not.toContain('phase1.environment.rust-toolchain.failed'); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }, + ); + + test('allows cold isolated Cargo builds to exceed the general command deadline', () => { + expect(cargoBuildTimeoutMs).toBeGreaterThan(20 * 60_000); }); }); diff --git a/src/phase1-evidence-contract.test.ts b/src/phase1-evidence-contract.test.ts new file mode 100644 index 0000000..f1599dd --- /dev/null +++ b/src/phase1-evidence-contract.test.ts @@ -0,0 +1,415 @@ +import { createHash } from 'node:crypto'; + +import { describe, expect, test } from 'vitest'; + +import { + assertExactAssertionResults, + buildPlatformEvidence, + canonicalPlatformId, + createAssertionRecorder, + parseLockedAssertionRegistry, + validateMetadataAssertionBindings, + validateObservedToolVersions, + windowsSupervisorDiagnosticId, +} from '../scripts/phase1-evidence-contract.mjs'; + +const registryValue = { + schemaVersion: 1, + cave: { + engine: 'scripts/client-v1-conformance.mjs', + requireIncludeTtl: true, + requireAuthorityTakeover: true, + }, + sdk: ['sdk.install.packed-tarballs', 'sdk.provenance.fixture-bytes-match', 'sdk.coven.health'], + chat: { + common: [ + 'chat.install.exact-sdk-tarballs', + 'chat.install.consumer-lock-matches', + 'chat.native.keychain-unavailable-fails-closed', + 'chat.deadline.total-bounded', + 'chat.coven.health', + ], + platforms: { + 'darwin-arm64': ['chat.coven.unix.connected-peer-identity'], + 'linux-x64': ['chat.coven.unix.connected-peer-identity'], + 'win32-x64': ['chat.coven.windows.connected-pipe-identity'], + }, + }, +} as const; + +function registryText() { + return `${JSON.stringify(registryValue, null, 2)}\n`; +} + +function sha256(value: string) { + return createHash('sha256').update(value).digest('hex'); +} + +function passing(ids: readonly string[]) { + return ids.map((id) => ({ + id, + result: 'pass' as const, + diagnosticId: 'phase1.assertion.passed', + })); +} + +const metadata = { + sdkCandidateRevision: 'a'.repeat(40), + sdkManifestSha256: 'b'.repeat(64), + chatHarnessRevision: 'c'.repeat(40), + windowsSupervisorSha256: 'd'.repeat(64), + mingwPackageVersion: 'mingw-w64 14.0.0_3', + mingwHomebrewCoreRevision: 'cd168d1fdc26f12e4ad64f358ff2dbec61ab7a57', + mingwBottleLayerSha256: '0d68ab737a8bbc8c63ac6ac7acc0695e2887c1169df9a4423f1180090079b1d5', + mingwLinkerVersion: '2.47.20260726', + rustVersion: '1.95.0', +} as const; + +function passingWithMetadata(ids: readonly string[]) { + return passing(ids).map((assertion) => ({ + ...assertion, + diagnosticId: + assertion.id === 'sdk.install.packed-tarballs' + ? `phase1.sdk-candidate.${metadata.sdkCandidateRevision}` + : assertion.id === 'sdk.provenance.fixture-bytes-match' + ? `phase1.sdk-manifest.${metadata.sdkManifestSha256}` + : assertion.id === 'chat.install.consumer-lock-matches' + ? `phase1.chat-harness.${metadata.chatHarnessRevision}` + : assertion.id === 'chat.native.keychain-unavailable-fails-closed' + ? `phase1.toolchain.rust.${metadata.rustVersion}` + : assertion.id === 'chat.deadline.total-bounded' + ? windowsSupervisorDiagnosticId(metadata) + : assertion.diagnosticId, + })); +} + +function assertionAt(assertions: ReturnType, index: number) { + const assertion = assertions[index]; + if (assertion === undefined) { + throw new Error(`missing assertion at index ${index}`); + } + return assertion; +} + +function caveRecord() { + return { + harness: 'scripts/client-v1-conformance.mjs', + ranAt: '2026-08-29T04:00:00.000Z', + caveVersion: '0.3.11', + commit: '2'.repeat(40), + platform: 'darwin-arm64', + nodeVersion: 'v24.18.1', + includeTtl: true, + authorityTakeover: { + authorityMode: 'enforce', + discoveryVersion: 2, + mechanism: 'hpke-bound-v1', + }, + assertions: [], + }; +} + +function isolationObservation(removedAfterRun = true) { + return { + roots: [ + { id: 'cave-home', ownershipVerified: true, removedAfterRun }, + { id: 'coven-home', ownershipVerified: true, removedAfterRun: true }, + { id: 'consumer-home', ownershipVerified: true, removedAfterRun: true }, + { id: 'native-credential-store', ownershipVerified: true, removedAfterRun: true }, + ], + operatorState: [ + { id: 'cave-home', beforeSha256: '6'.repeat(64), afterSha256: '6'.repeat(64) }, + { id: 'coven-home', beforeSha256: '7'.repeat(64), afterSha256: '7'.repeat(64) }, + { + id: 'native-credential-store', + beforeSha256: '8'.repeat(64), + afterSha256: '8'.repeat(64), + }, + { id: 'projects', beforeSha256: '9'.repeat(64), afterSha256: '9'.repeat(64) }, + ], + }; +} + +describe('SDK cross-repository evidence compatibility', () => { + test('accepts only the three frozen platform identifiers', () => { + expect(canonicalPlatformId('darwin', 'arm64')).toBe('darwin-arm64'); + expect(canonicalPlatformId('linux', 'x64')).toBe('linux-x64'); + expect(canonicalPlatformId('win32', 'x64')).toBe('win32-x64'); + expect(() => canonicalPlatformId('darwin', 'x64')).toThrow(/unsupported platform/); + }); + + test('requires the exact observed release toolchain versions', () => { + expect( + validateObservedToolVersions({ + nodeVersion: 'v24.18.1', + pnpmVersion: '10.34.0', + rustcVersion: 'rustc 1.95.0 (abc123 2026-08-01)', + }), + ).toEqual({ + nodeVersion: 'v24.18.1', + packageManagerVersion: 'pnpm@10.34.0', + rustVersion: '1.95.0', + }); + expect(() => + validateObservedToolVersions({ + nodeVersion: 'v24.19.0', + pnpmVersion: '10.34.0', + rustcVersion: 'rustc 1.95.0 (abc123 2026-08-01)', + }), + ).toThrow(/Node 24.18.1/); + }); + + test('loads the exact locked SDK assertion registry bytes', () => { + const text = registryText(); + const registry = parseLockedAssertionRegistry(text, sha256(text), 'SDK assertion registry'); + + expect(registry).toEqual(registryValue); + expect(() => + parseLockedAssertionRegistry(text, '0'.repeat(64), 'SDK assertion registry'), + ).toThrow(/digest does not match/); + }); + + test('requires complete ordered assertions and rejects skipped results', () => { + const expected = registryValue.sdk; + expect(assertExactAssertionResults(passing(expected), expected, 'SDK')).toEqual( + passing(expected), + ); + expect(() => assertExactAssertionResults(passing(expected).slice(1), expected, 'SDK')).toThrow( + /missing assertion/, + ); + expect(() => { + const assertions = passing(expected); + return assertExactAssertionResults( + [...assertions, assertionAt(assertions, 0)], + expected, + 'SDK', + ); + }).toThrow(/duplicate assertion/); + expect(() => + assertExactAssertionResults( + [ + { ...assertionAt(passing(expected), 0), result: 'skip' as never }, + assertionAt(passing(expected), 1), + ], + expected, + 'SDK', + ), + ).toThrow(/skipped assertion/); + expect(() => + assertExactAssertionResults([...passing(expected)].reverse(), expected, 'SDK'), + ).toThrow(/order/); + }); + + test('records only observed registry assertions and fails when evidence is incomplete', () => { + const registry = parseLockedAssertionRegistry( + registryText(), + sha256(registryText()), + 'SDK assertion registry', + ); + const incomplete = createAssertionRecorder(registry, 'darwin-arm64'); + incomplete.pass('sdk', 'sdk.install.packed-tarballs'); + expect(() => incomplete.results()).toThrow(/missing assertion/); + + const complete = createAssertionRecorder(registry, 'darwin-arm64'); + complete.passMany('sdk', registry.sdk); + complete.passMany('chat', [ + ...registry.chat.common, + ...registry.chat.platforms['darwin-arm64'], + ]); + expect(complete.results().sdk.map(({ id }) => id)).toEqual(registry.sdk); + expect(() => complete.pass('sdk', 'sdk.install.packed-tarballs')).toThrow( + /duplicate assertion/, + ); + }); + + test('enforces exact semantic metadata bindings and rejects mutations', () => { + const sdkAssertions = passingWithMetadata(registryValue.sdk); + const chatAssertions = passingWithMetadata(registryValue.chat.common); + expect( + validateMetadataAssertionBindings({ + sdkAssertions, + chatAssertions, + metadata, + }), + ).toBeUndefined(); + + const mutation = (scope: 'sdk' | 'chat', id: string, diagnosticId: string) => ({ + sdkAssertions: sdkAssertions.map((entry) => + scope === 'sdk' && entry.id === id ? { ...entry, diagnosticId } : entry, + ), + chatAssertions: chatAssertions.map((entry) => + scope === 'chat' && entry.id === id ? { ...entry, diagnosticId } : entry, + ), + metadata, + }); + expect(() => + validateMetadataAssertionBindings( + mutation( + 'sdk', + 'sdk.install.packed-tarballs', + `phase1.sdk-manifest.${metadata.sdkManifestSha256}`, + ), + ), + ).toThrow(/metadata binding/); + expect(() => + validateMetadataAssertionBindings({ + sdkAssertions, + chatAssertions, + metadata: { ...metadata, mingwLinkerVersion: '2.47' }, + }), + ).toThrow(/metadata binding values/); + expect(() => + validateMetadataAssertionBindings( + mutation('sdk', 'sdk.install.packed-tarballs', 'phase1.sdk-candidate.wrong'), + ), + ).toThrow(/metadata binding/); + expect(() => + validateMetadataAssertionBindings( + mutation('sdk', 'sdk.install.packed-tarballs', 'phase1.assertion.passed'), + ), + ).toThrow(/metadata binding/); + expect(() => + validateMetadataAssertionBindings( + mutation( + 'chat', + 'chat.deadline.total-bounded', + `phase1.windows-supervisor.${'e'.repeat(64)}`, + ), + ), + ).toThrow(/metadata binding/); + expect(() => + validateMetadataAssertionBindings( + mutation( + 'sdk', + 'sdk.coven.health', + `phase1.sdk-candidate.${metadata.sdkCandidateRevision}`, + ), + ), + ).toThrow(/unrelated assertion/); + const duplicated = mutation( + 'chat', + 'chat.coven.health', + `phase1.chat-harness.${metadata.chatHarnessRevision}`, + ); + expect(() => validateMetadataAssertionBindings(duplicated)).toThrow(/unrelated assertion/); + }); + + test('keeps the Windows supervisor metadata binding within the SDK evidence limit', () => { + const diagnosticId = windowsSupervisorDiagnosticId(metadata); + + expect(Buffer.byteLength(diagnosticId, 'utf8')).toBeLessThanOrEqual(192); + expect( + windowsSupervisorDiagnosticId({ + ...metadata, + windowsSupervisorSha256: 'e'.repeat(64), + }), + ).not.toBe(diagnosticId); + }); + + test('builds the exact SDK platform envelope with structured coverage and scope IDs', () => { + const registry = parseLockedAssertionRegistry( + registryText(), + sha256(registryText()), + 'SDK assertion registry', + ); + const chatIds = [...registry.chat.common, ...registry.chat.platforms['darwin-arm64']]; + const record = buildPlatformEvidence({ + registry, + platform: 'darwin-arm64', + caveRecord: caveRecord(), + releases: { cave: '0.3.11', coven: '0.1.0' }, + commits: { + cave: '2'.repeat(40), + coven: '3'.repeat(40), + sdk: '4'.repeat(40), + chat: '5'.repeat(40), + }, + digests: { + caveAssertionEngine: 'a'.repeat(64), + caveContractFixture: 'b'.repeat(64), + hpkeVectors: 'c'.repeat(64), + consumerLock: 'd'.repeat(64), + assertionRegistry: 'e'.repeat(64), + sdkTarballs: [ + { packageName: '@opencoven/sdk-core', sha256: '1'.repeat(64) }, + { packageName: '@opencoven/cave-client', sha256: '2'.repeat(64) }, + { packageName: '@opencoven/coven-client', sha256: '3'.repeat(64) }, + { packageName: '@opencoven/sdk', sha256: '4'.repeat(64) }, + ], + }, + sdkAssertions: passingWithMetadata(registry.sdk), + chatAssertions: passingWithMetadata(chatIds), + metadata, + environment: { + nodeVersion: 'v24.18.1', + packageManagerVersion: 'pnpm@10.34.0', + }, + isolation: isolationObservation(), + }); + + expect(record.platform).toBe('darwin-arm64'); + expect(record.commits.sdk).toBe('4'.repeat(40)); + expect(record.coverage).toEqual({ cave: true, coven: true, sdk: true, chat: true }); + expect(record.notCovered.map(({ scopeId }) => scopeId)).toEqual([ + 'cross-process-pairing', + 'oauth-ui', + 'remote-peer', + 'write-apis', + ]); + expect(record.chatAssertions.map(({ id }) => id)).toEqual(chatIds); + expect(record.isolation.roots.map(({ id }) => id)).toEqual([ + 'cave-home', + 'coven-home', + 'consumer-home', + 'native-credential-store', + ]); + expect( + record.isolation.operatorState.every((state) => state.beforeSha256 === state.afterSha256), + ).toBe(true); + }); + + test('rejects unverified or incomplete cleanup observations', () => { + const registry = parseLockedAssertionRegistry( + registryText(), + sha256(registryText()), + 'SDK assertion registry', + ); + const chatIds = [...registry.chat.common, ...registry.chat.platforms['darwin-arm64']]; + + expect(() => + buildPlatformEvidence({ + registry, + platform: 'darwin-arm64', + caveRecord: caveRecord(), + releases: { cave: '0.3.11', coven: '0.1.0' }, + commits: { + cave: '2'.repeat(40), + coven: '3'.repeat(40), + sdk: '4'.repeat(40), + chat: '5'.repeat(40), + }, + digests: { + caveAssertionEngine: 'a'.repeat(64), + caveContractFixture: 'b'.repeat(64), + hpkeVectors: 'c'.repeat(64), + consumerLock: 'd'.repeat(64), + assertionRegistry: 'e'.repeat(64), + sdkTarballs: [ + { packageName: '@opencoven/sdk-core', sha256: '1'.repeat(64) }, + { packageName: '@opencoven/cave-client', sha256: '2'.repeat(64) }, + { packageName: '@opencoven/coven-client', sha256: '3'.repeat(64) }, + { packageName: '@opencoven/sdk', sha256: '4'.repeat(64) }, + ], + }, + sdkAssertions: passingWithMetadata(registry.sdk), + chatAssertions: passingWithMetadata(chatIds), + metadata, + environment: { + nodeVersion: 'v24.18.1', + packageManagerVersion: 'pnpm@10.34.0', + }, + isolation: isolationObservation(false), + }), + ).toThrow(/isolation root/); + }); +}); diff --git a/src/phase1-windows-supervisor.test.ts b/src/phase1-windows-supervisor.test.ts new file mode 100644 index 0000000..c0ccdf0 --- /dev/null +++ b/src/phase1-windows-supervisor.test.ts @@ -0,0 +1,693 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { + copyFileSync, + existsSync, + linkSync, + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { basename, delimiter, dirname, resolve, win32 } from 'node:path'; + +import { afterEach, beforeAll, describe, expect, test } from 'vitest'; + +import { + normalizeWindowsRealPathForProcess, + resolveExecutableInvocation, +} from '../scripts/executable-resolution.mjs'; +import { + bootstrapWindowsSupervisor, + runSupervisedCommandForTest, + validateSupervisorArtifactFile, +} from '../scripts/phase1-conformance.mjs'; + +const helperPath = String(process.env.OPENCOVEN_PHASE1_WINDOWS_SUPERVISOR_PATH ?? ''); +const expectedSha256 = '372b3e8b5b860e0759da8fa10ddfb6ec338e26d83616254c816a456ae2e1b7c5'; +const expectedSize = 333824; +const settleMs = 300; +const waitTimeoutMs = 5_000; + +interface OwnedRoot { + path: string; + children: ChildProcess[]; + pids: Set; +} + +const roots: OwnedRoot[] = []; +const unrelated = new Set(); +let originalHelper: Buffer; + +function root() { + const path = resolve(process.cwd(), 'test-results', 'windows-supervisor', randomUUID()); + mkdirSync(path, { recursive: true }); + const owned = { path, children: [], pids: new Set() }; + roots.push(owned); + return owned; +} + +function safeRunnerEnvironment() { + const environment: NodeJS.ProcessEnv = { ...process.env }; + const configuredPath = Object.entries(environment).find( + ([key, value]) => key.toLowerCase() === 'path' && typeof value === 'string', + )?.[1]; + for (const key of Object.keys(environment)) { + if (key.toLowerCase() === 'path') { + delete environment[key]; + } + } + environment.PATH = String(configuredPath ?? '') + .split(delimiter) + .filter( + (entry) => + entry.length > 0 && + entry === entry.trim() && + !entry.includes('"') && + win32.isAbsolute(entry), + ) + .join(delimiter); + return environment; +} + +function artifactRoot(owned: OwnedRoot) { + return { + rootPath: owned.path, + trackChild(child: ChildProcess) { + owned.children.push(child); + if (child.pid !== undefined) owned.pids.add(child.pid); + }, + }; +} + +function processIsOpenable(pid: number) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function waitUntil(predicate: () => boolean, message: string, timeoutMs = waitTimeoutMs) { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error(message); + await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + } +} + +async function waitForFile(path: string) { + await waitUntil(() => existsSync(path), 'fixture handshake timed out'); +} + +async function waitGone(pid: number) { + await waitUntil(() => !processIsOpenable(pid), `process ${pid} remained openable`); +} + +async function waitForChildClose(child: ChildProcess) { + if (child.exitCode !== null || child.signalCode !== null) return; + await Promise.race([ + new Promise((resolveClose) => child.once('close', () => resolveClose())), + new Promise((_, rejectWait) => + setTimeout(() => rejectWait(new Error(`process ${child.pid} was not reaped`)), waitTimeoutMs), + ), + ]); +} + +async function terminateExactChild(child: ChildProcess) { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + await waitForChildClose(child); + if (child.pid !== undefined) await waitGone(child.pid); +} + +function assertFleetHelperUnchanged() { + const current = readFileSync(helperPath); + expect(current.equals(originalHelper)).toBe(true); + expect(statSync(helperPath).size).toBe(expectedSize); + expect(createHash('sha256').update(current).digest('hex')).toBe(expectedSha256); +} + +async function settleAbsent(path: string) { + const deadline = Date.now() + settleMs; + while (Date.now() < deadline) { + expect(existsSync(path)).toBe(false); + await new Promise((resolveWait) => setTimeout(resolveWait, 10)); + } +} + +async function cleanupRoot(owned: OwnedRoot) { + for (const child of owned.children) { + await terminateExactChild(child); + } + for (const pid of owned.pids) { + if (processIsOpenable(pid)) { + process.kill(pid, 'SIGKILL'); + await waitGone(pid); + } + expect(processIsOpenable(pid)).toBe(false); + } + for (const entry of readdirSync(owned.path)) { + rmSync(resolve(owned.path, entry), { recursive: true, force: true }); + } + expect(readdirSync(owned.path)).toEqual([]); + assertFleetHelperUnchanged(); + rmSync(owned.path, { recursive: true, force: true }); + roots.splice(roots.indexOf(owned), 1); +} + +function runHelper( + owned: OwnedRoot, + args: string[], + options: { timeoutMs?: number; outputLimit?: number } = {}, +) { + return new Promise<{ + code: number | null; + signal: NodeJS.Signals | null; + stdout: string; + stderr: string; + reason: 'exit' | 'timeout' | 'output-limit'; + helperPid: number; + }>((resolveRun, rejectRun) => { + const child = spawn(helperPath, ['--', process.execPath, ...args], { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + if (child.pid === undefined) { + rejectRun(new Error('supervisor did not publish a PID')); + return; + } + owned.children.push(child); + owned.pids.add(child.pid); + const helperPid = child.pid; + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let bytes = 0; + let reason: 'exit' | 'timeout' | 'output-limit' = 'exit'; + let timer: NodeJS.Timeout | undefined; + const limit = options.outputLimit ?? 64 * 1024; + const capture = (target: Buffer[], chunk: Buffer) => { + bytes += chunk.length; + if (bytes > limit && reason === 'exit') { + reason = 'output-limit'; + child.kill('SIGKILL'); + } else { + target.push(chunk); + } + }; + child.once('error', rejectRun); + child.stdout.on('data', (chunk: Buffer) => capture(stdout, chunk)); + child.stderr.on('data', (chunk: Buffer) => capture(stderr, chunk)); + child.once('close', (code, signal) => { + if (timer !== undefined) clearTimeout(timer); + resolveRun({ + code, + signal, + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + reason, + helperPid, + }); + }); + if (options.timeoutMs !== undefined) { + timer = setTimeout(() => { + reason = 'timeout'; + child.kill('SIGKILL'); + }, options.timeoutMs); + } + }); +} + +afterEach(async () => { + for (const child of unrelated) { + await terminateExactChild(child); + unrelated.delete(child); + } + for (const owned of [...roots]) await cleanupRoot(owned); +}); + +describe.skipIf(process.platform !== 'win32')('frozen Windows process supervisor', () => { + beforeAll(() => { + originalHelper = readFileSync(helperPath); + expect(originalHelper.length).toBe(expectedSize); + expect(createHash('sha256').update(originalHelper).digest('hex')).toBe(expectedSha256); + }); + + test('propagates stdout, stderr, and exact target exit', async () => { + const owned = root(); + const targetPidPath = resolve(owned.path, 'target.pid'); + try { + expect( + validateSupervisorArtifactFile(helperPath, { + size: expectedSize, + sha256: expectedSha256, + }), + ).toBeTruthy(); + const result = await runHelper(owned, [ + '-e', + `require('node:fs').writeFileSync(${JSON.stringify( + targetPidPath, + )},String(process.pid));process.stdout.write('out');process.stderr.write('err');process.exit(7)`, + ]); + const targetPid = Number(readFileSync(targetPidPath, 'utf8')); + owned.pids.add(targetPid); + await waitGone(result.helperPid); + await waitGone(targetPid); + expect(result).toMatchObject({ code: 7, signal: null, stdout: 'out', stderr: 'err' }); + } finally { + await cleanupRoot(owned); + } + }); + + test('PowerShell outer launcher strips Node preload injection before verified runner startup', async () => { + const owned = root(); + const marker = resolve(owned.path, 'preload.marker'); + const preload = resolve(owned.path, 'preload.cjs'); + const launcher = resolve(process.cwd(), 'scripts', 'phase1-conformance-launcher.ps1'); + const missingSdk = resolve(owned.path, 'missing-sdk'); + writeFileSync( + preload, + `require('node:fs').writeFileSync(${JSON.stringify(marker)},'loaded',{flag:'a'});`, + ); + const powershell = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'; + try { + const runLauncher = async (helper: string, node: string) => { + const child = spawn( + powershell, + [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-File', + launcher, + helper, + node, + '--sdk-root', + missingSdk, + ], + { + cwd: process.cwd(), + env: { ...process.env, NODE_OPTIONS: `--require=${preload}` }, + stdio: ['ignore', 'ignore', 'pipe'], + windowsHide: true, + }, + ); + const stderr: Buffer[] = []; + child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + artifactRoot(owned).trackChild(child); + await waitForChildClose(child); + expect(child.exitCode).not.toBe(0); + return Buffer.concat(stderr).toString('utf8'); + }; + expect(await runLauncher('phase1-process-supervisor.exe', process.execPath)).toContain( + 'trusted inputs must be absolute', + ); + expect(await runLauncher(helperPath, 'node.exe')).toContain( + 'trusted inputs must be absolute', + ); + const validFailure = await runLauncher(helperPath, process.execPath); + expect(validFailure).toContain('phase1-conformance:'); + expect(validFailure).not.toContain('ArgumentList'); + await settleAbsent(marker); + } finally { + await cleanupRoot(owned); + } + }); + + test.each(['rustup', 'git', 'cargo', 'corepack'] as const)( + 'resolves and executes the real %s tool through the frozen helper', + async (command) => { + const owned = root(); + try { + bootstrapWindowsSupervisor({ windowsSupervisorPath: helperPath }); + const environment = safeRunnerEnvironment(); + const invocation = resolveExecutableInvocation(command, environment); + expect(invocation.resolvedCommand.toLowerCase()).toMatch(/\.(?:exe|cmd|bat|com|js)$/u); + if (command === 'corepack') { + expect(invocation.executable).toBe(realpathSync(process.execPath)); + expect(invocation.resolvedCommand.toLowerCase()).toMatch(/corepack\.js$/u); + } + const result = (await runSupervisedCommandForTest( + artifactRoot(owned), + command, + ['--version'], + { + cwd: owned.path, + env: environment, + timeoutMs: 10_000, + outputLimitBytes: 64 * 1024, + }, + )) as { stdout: string; stderr: string }; + expect(`${result.stdout}${result.stderr}`.trim().length).toBeGreaterThan(0); + } finally { + await cleanupRoot(owned); + } + }, + 20_000, + ); + + test('honors PATHEXT order and explicit executable and batch extensions', async () => { + const owned = root(); + const orderedCom = resolve(owned.path, 'ordered.com'); + const orderedExe = resolve(owned.path, 'ordered.exe'); + const spacedDirectory = resolve(owned.path, 'valid-path'); + const batch = resolve(spacedDirectory, 'batch-tool.bat'); + try { + copyFileSync(process.execPath, orderedCom); + copyFileSync(process.execPath, orderedExe); + mkdirSync(spacedDirectory); + writeFileSync(batch, '@echo off\r\necho %~1\r\n'); + const environment: NodeJS.ProcessEnv = { ...process.env }; + for (const key of Object.keys(environment)) { + if (key.toLowerCase() === 'path' || key.toLowerCase() === 'pathext') { + delete environment[key]; + } + } + environment.PATH = owned.path; + environment.PATHEXT = '.COM;.EXE;.BAT;.CMD'; + bootstrapWindowsSupervisor({ windowsSupervisorPath: helperPath }); + + expect(resolveExecutableInvocation('ordered', environment).resolvedCommand).toBe( + normalizeWindowsRealPathForProcess(realpathSync(orderedCom)), + ); + expect(resolveExecutableInvocation('ordered.exe', environment).resolvedCommand).toBe( + normalizeWindowsRealPathForProcess(realpathSync(orderedExe)), + ); + for (const command of ['ordered', 'ordered.exe']) { + const result = (await runSupervisedCommandForTest( + artifactRoot(owned), + command, + ['-e', "process.stdout.write('pe-ok')"], + { + cwd: owned.path, + env: environment, + timeoutMs: 10_000, + outputLimitBytes: 4_096, + }, + )) as { stdout: string }; + expect(result.stdout).toBe('pe-ok'); + } + const systemRoot = process.env.SystemRoot; + expect(systemRoot).toBeDefined(); + expect(win32.isAbsolute(String(systemRoot))).toBe(true); + const batchResult = (await runSupervisedCommandForTest( + artifactRoot(owned), + batch, + ['valid spaced arg'], + { + cwd: String(systemRoot), + env: environment, + timeoutMs: 10_000, + outputLimitBytes: 4_096, + }, + )) as { stdout: string }; + expect(batchResult.stdout).toContain('valid spaced arg'); + } finally { + await cleanupRoot(owned); + } + }); + + test.each(['&', '|', '<', '>', '(', ')', '^', '%', '!', '"', '\r', '\n', '\0'])( + 'rejects batch argument metacharacter %j before the batch marker executes', + async (metacharacter) => { + const owned = root(); + const batch = resolve(owned.path, 'injection-tool.cmd'); + const marker = resolve(owned.path, `batch-injection-${randomUUID()}.marker`); + try { + writeFileSync( + batch, + [ + '@echo off', + `"${process.execPath}" -e "require('node:fs').writeFileSync(process.argv[1],'ran',{flag:'wx'})" "${marker}"`, + ].join('\r\n'), + ); + bootstrapWindowsSupervisor({ windowsSupervisorPath: helperPath }); + await expect( + (async () => + runSupervisedCommandForTest( + artifactRoot(owned), + batch, + [`payload${metacharacter}injection`], + { + cwd: owned.path, + env: process.env, + timeoutMs: 5_000, + outputLimitBytes: 4_096, + }, + ))(), + ).rejects.toThrow('Windows batch invocation contains an unsafe token.'); + await settleAbsent(marker); + } finally { + await cleanupRoot(owned); + } + }, + ); + + test.each(['ambiguous', 'missing', 'path-injection'] as const)( + 'rejects %s Windows command resolution before a target marker executes', + async (kind) => { + const owned = root(); + const marker = resolve(owned.path, `resolution-${kind}.marker`); + const tool = resolve(owned.path, 'marker-tool.exe'); + try { + copyFileSync(process.execPath, tool); + const markerSource = [ + "const{renameSync,writeFileSync}=require('node:fs');", + `const p=${JSON.stringify(marker)};`, + "writeFileSync(p+'.next','ran',{flag:'wx'});renameSync(p+'.next',p);", + ].join(''); + const environment: NodeJS.ProcessEnv = { ...process.env }; + for (const key of Object.keys(environment)) { + if (key.toLowerCase() === 'path' || key.toLowerCase() === 'pathext') { + delete environment[key]; + } + } + environment.PATHEXT = '.EXE;.CMD;.BAT;.COM'; + if (kind === 'ambiguous') { + environment.PATH = owned.path; + environment.Path = `${owned.path}\\other`; + } else if (kind === 'missing') { + environment.PATH = owned.path; + } else { + environment.PATH = `.;${owned.path}`; + } + bootstrapWindowsSupervisor({ windowsSupervisorPath: helperPath }); + await expect( + (async () => + runSupervisedCommandForTest( + artifactRoot(owned), + kind === 'missing' ? 'missing-tool' : 'marker-tool', + ['-e', markerSource], + { + cwd: owned.path, + env: environment, + timeoutMs: 5_000, + outputLimitBytes: 4_096, + }, + ))(), + ).rejects.toThrow(); + await settleAbsent(marker); + } finally { + await cleanupRoot(owned); + } + }, + ); + + test.each(['exe', 'com', 'cmd', 'bat'] as const)( + 'ignores malicious PATH-precedence corepack.%s without executing its marker', + async (extension) => { + const owned = root(); + const fakeNodeRoot = resolve(owned.path, `fake-node-${extension}`); + const malicious = resolve(fakeNodeRoot, `corepack.${extension}`); + const marker = resolve(owned.path, `corepack-${extension}.marker`); + try { + mkdirSync(fakeNodeRoot, { recursive: true }); + let maliciousArgs: string[]; + if (extension === 'exe' || extension === 'com') { + const comspec = process.env.ComSpec ?? process.env.COMSPEC; + if (comspec === undefined) throw new Error('ComSpec is required for the fixture'); + copyFileSync(comspec, malicious); + maliciousArgs = ['/d', '/s', '/c', `echo ran>"${marker}"`]; + } else { + writeFileSync(malicious, `@echo off\r\necho ran>"${marker}"\r\n`); + maliciousArgs = []; + } + const environment: NodeJS.ProcessEnv = { ...process.env }; + for (const key of Object.keys(environment)) { + if (key.toLowerCase() === 'path' || key.toLowerCase() === 'pathext') { + delete environment[key]; + } + } + environment.PATH = fakeNodeRoot; + environment.PATHEXT = `.${extension.toUpperCase()}`; + bootstrapWindowsSupervisor({ windowsSupervisorPath: helperPath }); + const invocation = resolveExecutableInvocation( + 'corepack', + environment, + 'win32', + maliciousArgs, + ); + expect(invocation.executable).toBe(realpathSync(process.execPath)); + expect(invocation.resolvedCommand.toLowerCase()).toMatch(/corepack\.js$/u); + await runSupervisedCommandForTest(artifactRoot(owned), 'corepack', maliciousArgs, { + cwd: owned.path, + env: environment, + timeoutMs: 5_000, + outputLimitBytes: 64 * 1024, + }).catch(() => undefined); + await settleAbsent(marker); + await expect( + (async () => + runSupervisedCommandForTest( + artifactRoot(owned), + `corepack.${extension}`, + maliciousArgs, + { + cwd: owned.path, + env: environment, + timeoutMs: 5_000, + outputLimitBytes: 64 * 1024, + }, + ))(), + ).rejects.toThrow('Explicit Windows Corepack shim requests are forbidden.'); + await settleAbsent(marker); + } finally { + await cleanupRoot(owned); + } + }, + ); + + test.each([ + ['timeout', { timeoutMs: 500 }], + ['output-limit', { outputLimit: 1024 }], + ] as const)( + '%s kills the target and descendant while unrelated survives', + async (kind, limits) => { + const owned = root(); + const targetPid = resolve(owned.path, 'target.pid'); + const descendantPid = resolve(owned.path, 'descendant.pid'); + const marker = resolve(owned.path, 'marker'); + const heartbeat = resolve(owned.path, 'unrelated.heartbeat'); + let liveSeparate: ChildProcess | undefined; + const source = [ + "const{spawn}=require('node:child_process');const{writeFileSync}=require('node:fs');", + `writeFileSync(${JSON.stringify(targetPid)},String(process.pid));`, + "const c=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore',windowsHide:true});", + `writeFileSync(${JSON.stringify(descendantPid)},String(c.pid));`, + `writeFileSync(${JSON.stringify(marker)},'ran');`, + kind === 'output-limit' ? "process.stdout.write('x'.repeat(4096));" : '', + 'setInterval(()=>{},1000);', + ].join(''); + try { + const heartbeatSource = [ + "const{appendFileSync}=require('node:fs');", + `const p=${JSON.stringify(heartbeat)};`, + "appendFileSync(p,'x');setInterval(()=>appendFileSync(p,'x'),50);", + ].join(''); + liveSeparate = spawn(process.execPath, ['-e', heartbeatSource], { + stdio: 'ignore', + windowsHide: true, + }); + if (liveSeparate.pid === undefined) { + throw new Error('unrelated heartbeat process did not publish a PID'); + } + unrelated.add(liveSeparate); + await waitForFile(heartbeat); + const heartbeatSize = statSync(heartbeat).size; + const result = await runHelper(owned, ['-e', source], limits); + await waitForFile(marker); + const target = Number(readFileSync(targetPid, 'utf8')); + const descendant = Number(readFileSync(descendantPid, 'utf8')); + owned.pids.add(target); + owned.pids.add(descendant); + await waitGone(result.helperPid); + await waitGone(target); + await waitGone(descendant); + await waitUntil( + () => statSync(heartbeat).size > heartbeatSize, + 'unrelated process heartbeat stopped', + ); + expect(result.reason).toBe(kind); + expect(liveSeparate.exitCode).toBeNull(); + expect(liveSeparate.signalCode).toBeNull(); + expect(processIsOpenable(liveSeparate.pid)).toBe(true); + } finally { + if (liveSeparate !== undefined) { + await terminateExactChild(liveSeparate); + unrelated.delete(liveSeparate); + } + await cleanupRoot(owned); + } + }, + ); + + test.each(['missing', 'wrong-file', 'wrong-size', 'wrong-digest'] as const)( + 'actual bootstrap rejects a %s artifact before the target marker executes', + async (kind) => { + const owned = root(); + const marker = resolve(owned.path, `must-not-run-${kind}`); + const staging = resolve(owned.path, `marker-staging-${kind}`); + const helperDirectory = dirname(helperPath); + const backup = resolve( + helperDirectory, + `.${basename(helperPath)}.backup-${kind}-${randomUUID()}`, + ); + let backupCreated = false; + try { + expect(dirname(backup)).toBe(helperDirectory); + linkSync(helperPath, backup); + backupCreated = true; + rmSync(helperPath); + try { + if (kind === 'wrong-file') mkdirSync(helperPath); + if (kind === 'wrong-size') writeFileSync(helperPath, 'wrong'); + if (kind === 'wrong-digest') writeFileSync(helperPath, Buffer.alloc(expectedSize)); + const source = [ + "const{renameSync,writeFileSync}=require('node:fs');", + `writeFileSync(${JSON.stringify(staging)},'ran',{flag:'wx'});`, + `renameSync(${JSON.stringify(staging)},${JSON.stringify(marker)});`, + ].join(''); + await expect( + (async () => { + bootstrapWindowsSupervisor({ windowsSupervisorPath: helperPath }); + return runSupervisedCommandForTest( + artifactRoot(owned), + process.execPath, + ['-e', source], + { + cwd: owned.path, + env: process.env, + timeoutMs: 1_000, + outputLimitBytes: 4_096, + }, + ); + })(), + ).rejects.toThrow(); + await settleAbsent(marker); + expect(existsSync(staging)).toBe(false); + } finally { + rmSync(helperPath, { recursive: true, force: true }); + if (backupCreated) { + renameSync(backup, helperPath); + backupCreated = false; + } + } + assertFleetHelperUnchanged(); + expect(existsSync(backup)).toBe(false); + } finally { + if (backupCreated) { + rmSync(helperPath, { recursive: true, force: true }); + renameSync(backup, helperPath); + backupCreated = false; + } + assertFleetHelperUnchanged(); + expect(existsSync(backup)).toBe(false); + await cleanupRoot(owned); + } + }, + ); +}); diff --git a/src/specification-guards.test.ts b/src/specification-guards.test.ts index 792beaa..f168bd7 100644 --- a/src/specification-guards.test.ts +++ b/src/specification-guards.test.ts @@ -1,4 +1,5 @@ import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { resolve } from 'node:path'; @@ -277,19 +278,276 @@ describe('Phase 1 specification guards', () => { expect(nativeSource).not.toContain('take_hook'); }); - it('checks every Rust target on a native Windows runner', () => { + it('checks every Rust target for the Windows GNU target in package scripts and CI', () => { + const packageManifest = readJson('package.json'); const workflow = readText('.github/workflows/ci.yml'); - const windowsRustJob = workflow.match( - /\n {2}windows-rust:\n(?[\s\S]*?)(?=\n {2}[a-z][\w-]*:\n|$)/, - )?.groups?.job; + const rustJob = workflow.match(/\n {2}rust:\n(?[\s\S]*?)(?=\n {2}[a-z][\w-]*:\n|$)/) + ?.groups?.job; + if (rustJob === undefined) { + throw new Error('Rust CI job is missing.'); + } - expect(windowsRustJob).toContain('name: Windows Rust'); - expect(windowsRustJob).toContain('runs-on: windows-latest'); - expect(windowsRustJob).toContain('toolchain: 1.95.0'); - expect(windowsRustJob).toContain( + expect(packageManifest.scripts?.['cargo:check:windows-gnu']).toBe( + 'cargo check --manifest-path src-tauri/Cargo.toml --target x86_64-pc-windows-gnu --all-targets', + ); + expect(rustJob).toContain( '- run: cargo check --manifest-path src-tauri/Cargo.toml --all-targets', ); - expect(windowsRustJob).not.toContain('rustup target add'); + expect(rustJob).toContain('- run: rustup target add x86_64-pc-windows-gnu'); + expect(rustJob).toContain('name: Install pinned Windows GNU toolchain'); + expect(rustJob).toContain('HOMEBREW_BOTTLE_DOMAIN: https://ghcr.io/v2/homebrew/core'); + expect(rustJob).toContain('HOMEBREW_NO_INSTALL_FROM_API: "1"'); + expect(rustJob).toContain('cd168d1fdc26f12e4ad64f358ff2dbec61ab7a57'); + expect(rustJob).toContain('mingw-w64 14.0.0_3'); + expect(rustJob).toContain('0d68ab737a8bbc8c63ac6ac7acc0695e2887c1169df9a4423f1180090079b1d5'); + expect(rustJob).toContain("grep -F '2.47.20260726'"); + expect(rustJob).toContain(`formula="\${RUNNER_TEMP}/mingw-w64.rb"`); + expect(rustJob).toContain( + `https://raw.githubusercontent.com/Homebrew/homebrew-core/\${core_revision}/Formula/m/mingw-w64.rb`, + ); + expect(rustJob).toContain('798631311a841e0639469f3f95a5287c8747f7a354e79a47ac39d6bf20eefe34'); + expect(rustJob).toContain( + `bottle="\${RUNNER_TEMP}/mingw-w64--14.0.0_3.arm64_tahoe.bottle.tar.gz"`, + ); + expect(rustJob).toContain( + 'https://ghcr.io/v2/homebrew/core/mingw-w64/blobs/sha256:0d68ab737a8bbc8c63ac6ac7acc0695e2887c1169df9a4423f1180090079b1d5', + ); + expect(rustJob).toContain(`HOMEBREW_DEVELOPER=1 brew install --force-bottle "\${bottle}"`); + expect(rustJob).not.toMatch(/\bgit\b[^\n]*(?:checkout|reset|switch)\b/u); + expect(rustJob).not.toMatch(/\bbrew tap\b/u); + expect(rustJob).not.toContain('brew --repo homebrew/core'); + expect(rustJob).not.toContain('HOMEBREW_INTERNAL_ALLOW_PACKAGES_FROM_PATHS'); + expect(rustJob).not.toContain(`brew install --force-bottle "\${formula}"`); + expect(rustJob).not.toContain('brew install --force-bottle opencoven/frozen/mingw-w64'); + expect(rustJob).not.toMatch(/run:\s*brew install mingw-w64\s*$/mu); + expect(rustJob).toContain('existing mingw-w64 installation was not removed'); + expect(rustJob.indexOf('brew uninstall --force mingw-w64')).toBeLessThan( + rustJob.indexOf('HOMEBREW_DEVELOPER=1 brew install'), + ); + expect(rustJob).toContain('name: Verify Windows GNU resource compiler'); + expect(rustJob).toContain('run: command -v x86_64-w64-mingw32-windres'); + expect(rustJob).toContain('- run: corepack pnpm cargo:check:windows-gnu'); + expect(rustJob?.indexOf('command -v x86_64-w64-mingw32-windres')).toBeLessThan( + rustJob?.indexOf('corepack pnpm cargo:check:windows-gnu') ?? -1, + ); + }); + + it('fetches full history for Rust locked-source verification', () => { + const workflow = readText('.github/workflows/ci.yml'); + const rustJob = workflow.match(/\n {2}rust:\n(?[\s\S]*?)(?=\n {2}[a-z][\w-]*:\n|$)/) + ?.groups?.job; + + expect(rustJob).toMatch( + /actions\/checkout@[0-9a-f]{40}[^\n]*\n {8}with:\n {10}fetch-depth: 0/u, + ); + expect(rustJob).toContain('Windows supervisor source does not match the immutable lock.'); + }); + + it('fetches full history for Web locked-harness verification', () => { + const workflow = readText('.github/workflows/ci.yml'); + const webJob = workflow.match(/\n {2}web:\n(?[\s\S]*?)(?=\n {2}[a-z][\w-]*:\n|$)/)?.groups + ?.job; + + expect(webJob).toMatch(/actions\/checkout@[0-9a-f]{40}[^\n]*\n {8}with:\n {10}fetch-depth: 0/u); + expect(webJob).toContain('- run: pnpm test:unit'); + }); + + it('uses a POSIX shell fixture without exposing Node internal descriptors to status forgery', () => { + const testSource = readText('src/phase1-conformance.test.ts'); + const start = testSource.indexOf( + 'supervisor status cannot be forged through the legacy environment or target fd3', + ); + const end = testSource.indexOf( + 'rejects a forged success frame when supervisor group kill fails', + start, + ); + const fixture = testSource.slice(start, end); + + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + expect(fixture).toContain("runSupervisedCommandForTest(root, '/bin/sh'"); + expect(fixture).toContain('OPENCOVEN_PHASE1_SUPERVISOR_STATUS_PATH'); + expect(fixture).toContain('>&3'); + expect(fixture).toContain('result: expect.objectContaining({ code: 7 })'); + expect(fixture).toContain('expect(existsSync(legacyStatusPath)).toBe(false)'); + expect(fixture).not.toContain('process.execPath'); + expect(fixture).not.toContain("readlinkSync('/proc/self/fd/3')"); + expect(fixture).not.toContain('writeSync(3'); + }); + + it('checks out both authority launchers with immutable LF bytes', () => { + const launcherPaths = [ + 'scripts/phase1-conformance-launcher.sh', + 'scripts/phase1-conformance-launcher.ps1', + ]; + const attributes = execFileSync('git', ['check-attr', 'text', 'eol', '--', ...launcherPaths], { + cwd: projectRoot, + encoding: 'utf8', + }); + const lock = readJson<{ + harnessAuthority: { files: Array<{ path: string; sha256: string }> }; + }>('phase1-conformance.lock.json'); + + for (const path of launcherPaths) { + expect(attributes).toContain(`${path}: text: set`); + expect(attributes).toContain(`${path}: eol: lf`); + const authority = lock.harnessAuthority.files.find((file) => file.path === path); + expect(authority, `${path} must be bound by harness authority.`).toBeDefined(); + expect( + createHash('sha256') + .update(readFileSync(resolve(projectRoot, path))) + .digest('hex'), + ).toBe(authority?.sha256); + } + }); + + it('keeps the frozen Windows supervisor in an isolated bin-only Cargo graph', () => { + const crateRoot = resolve(projectRoot, 'tools/phase1-process-supervisor'); + const manifest = readText('tools/phase1-process-supervisor/Cargo.toml'); + const metadata = JSON.parse( + execFileSync('cargo', ['metadata', '--locked', '--no-deps', '--format-version', '1'], { + cwd: crateRoot, + encoding: 'utf8', + }), + ) as { workspace_root: string; packages: Array<{ name: string }> }; + const workflow = readText('.github/workflows/ci.yml'); + const docs = readText('docs/phase1-conformance.md'); + const lock = readJson<{ + tools: { windowsSupervisor: { artifact: { buildInvocation: string } } }; + }>('phase1-conformance.lock.json'); + + expect(manifest).not.toContain('[lib]'); + expect(manifest).not.toContain('opencoven-chat'); + expect(metadata.workspace_root).toBe(crateRoot); + expect(metadata.packages.map(({ name }) => name)).toEqual(['phase1-process-supervisor']); + expect(workflow.match(/working-directory: tools\/phase1-process-supervisor/gu)).toHaveLength(3); + expect(workflow).toContain( + 'SOURCE_DATE_EPOCH=0 cargo build \\\n --target x86_64-pc-windows-gnu', + ); + expect(workflow).toContain('cargo metadata \\\n --locked'); + expect(workflow).toContain('cargo check --locked'); + expect(workflow).toContain('cargo test --locked'); + expect(workflow).toContain('cargo clippy --locked -- -D warnings'); + expect(lock.tools.windowsSupervisor.artifact.buildInvocation).toBe( + 'cd tools/phase1-process-supervisor && SOURCE_DATE_EPOCH=0 cargo build --target x86_64-pc-windows-gnu --release --locked', + ); + for (const source of [workflow, docs, lock.tools.windowsSupervisor.artifact.buildInvocation]) { + expect(source).not.toMatch( + /--manifest-path\s+(?:tools\/phase1-process-supervisor\/)?Cargo\.toml/u, + ); + } + }, 60_000); + + it('runs the frozen supervisor behavioral gate on windows-latest', () => { + const workflow = readText('.github/workflows/ci.yml'); + const testSource = readText('src/phase1-windows-supervisor.test.ts'); + const harnessSource = readText('scripts/phase1-conformance.mjs'); + const job = workflow.match( + /\n {2}windows-supervisor-behavior:\n(?[\s\S]*?)(?=\n {2}[a-z][\w-]*:\n|$)/, + )?.groups?.job; + + expect(job).toContain('runs-on: windows-latest'); + expect(job).toContain('needs: [changes, rust]'); + expect(job).toContain('actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093'); + expect(job).toContain('dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c'); + expect(job).toContain('C:\\OpenCoven\\conformance\\phase1-process-supervisor.exe'); + expect(job).toContain('372b3e8b5b860e0759da8fa10ddfb6ec338e26d83616254c816a456ae2e1b7c5'); + expect(job).toContain( + 'corepack pnpm@10.34.0 --ignore-workspace exec vitest run --config vitest.heavy.config.ts src/phase1-windows-supervisor.test.ts', + ); + expect(testSource).toContain("describe.skipIf(process.platform !== 'win32')"); + expect(testSource).toContain( + 'bootstrapWindowsSupervisor({ windowsSupervisorPath: helperPath })', + ); + expect(testSource).toContain('function safeRunnerEnvironment()'); + expect(testSource).toContain('normalizeWindowsRealPathForProcess(realpathSync(orderedCom))'); + expect(testSource).toContain("'missing', 'wrong-file', 'wrong-size', 'wrong-digest'"); + expect(testSource).toContain('await settleAbsent(marker)'); + expect(testSource).toContain('expect(liveSeparate.signalCode).toBeNull()'); + expect(testSource).toContain('expect(processIsOpenable(liveSeparate.pid)).toBe(true)'); + expect(testSource).toContain('assertFleetHelperUnchanged()'); + expect(testSource).toContain('expect(readdirSync(owned.path)).toEqual([])'); + expect(testSource).toContain('const helperDirectory = dirname(helperPath)'); + expect(testSource).toContain('linkSync(helperPath, backup)'); + expect(testSource).toContain('expect(dirname(backup)).toBe(helperDirectory)'); + expect(testSource).toContain('expect(existsSync(backup)).toBe(false)'); + expect(testSource).toContain("['rustup', 'git', 'cargo', 'corepack']"); + expect(testSource).toContain("['ambiguous', 'missing', 'path-injection']"); + expect(testSource).toContain( + "['&', '|', '<', '>', '(', ')', '^', '%', '!', '\"', '\\r', '\\n', '\\0']", + ); + expect(testSource).toContain("'valid spaced arg'"); + expect(testSource).toContain("['exe', 'com', 'cmd', 'bat']"); + expect(testSource).toContain('ignores malicious PATH-precedence corepack.%s'); + expect(testSource).toContain('Explicit Windows Corepack shim requests are forbidden.'); + expect(workflow).toContain('OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED: "1"'); + expect(harnessSource).toMatch( + /export function bootstrapWindowsSupervisor\(options\)[\s\S]*configureWindowsSupervisor\(options, lock\)[\s\S]*export async function runPhase1Conformance[\s\S]*phase1\.stage\.lock\.failed[\s\S]*bootstrapWindowsSupervisor\(options\)/, + ); + }); + + it('runs the real reservation broken-pipe probe only with the isolated macOS keychain', () => { + const workflow = readText('.github/workflows/ci.yml'); + const nativeRpcTest = readText('src-tauri/tests/phase1_native_rpc.rs'); + + expect(workflow).toContain( + 'security default-keychain -d user | sed \'s/^[[:space:]]*"//; s/"$//\'', + ); + expect(workflow).toContain('OPENCOVEN_PHASE1_TEST_KEYCHAIN_ISOLATED: "1"'); + expect(nativeRpcTest).toContain('#[cfg(target_os = "macos")]'); + expect(nativeRpcTest).toContain( + 'subprocess_prepare_broken_pipe_removes_real_isolated_keychain_marker', + ); + expect(nativeRpcTest).toContain('ai.opencoven.chat.conformance-cleanup'); + expect(nativeRpcTest).toContain('conformance_delete_native_credential'); + }); + + it('allows external execution only through platform supervisor bootstraps', () => { + const workflow = readText('.github/workflows/ci.yml'); + for (const path of [ + 'scripts/phase1-conformance.mjs', + 'scripts/phase1-conformance-lock.mjs', + 'scripts/process-owned-artifact-root.mjs', + ]) { + const source = readText(path); + expect(source).not.toMatch(/\bexecFile(?:Sync)?\b|\bspawnSync\b|\bsystem\b/); + } + const harness = readText('scripts/phase1-conformance.mjs'); + expect(harness.match(/\bspawn\(/g)).toHaveLength(2); + const bootstrap = readText('scripts/supervised-exec.mjs'); + expect(bootstrap).toContain('spawnSync('); + expect(bootstrap).toContain('phase1-process-supervisor.mjs'); + expect(harness).toContain("from './executable-resolution.mjs'"); + expect(bootstrap).toContain("from './executable-resolution.mjs'"); + const resolution = readText('scripts/executable-resolution.mjs'); + expect(resolution).toContain('quoteWindowsBatchCommand(resolved.path, args)'); + expect(resolution).toContain( + "throw new Error('Canonical Windows Corepack installation is unavailable.')", + ); + expect(resolution).toContain('Explicit Windows Corepack shim requests are forbidden.'); + expect(resolution.indexOf("if (requestedBase === 'corepack')")).toBeLessThan( + resolution.indexOf('const resolved = resolveWindowsCommand(command, environment)'), + ); + expect(resolution).not.toMatch(/\bexecFile|\bspawn|\bsystem\b/); + const supervisor = readText('scripts/phase1-process-supervisor.mjs'); + expect(supervisor).toContain('writeSync(3, frame)'); + expect(supervisor).toContain("stdio: ['inherit', 'inherit', 'inherit', 'ignore']"); + expect(harness).not.toContain('OPENCOVEN_PHASE1_SUPERVISOR_STATUS_PATH'); + expect(bootstrap).not.toContain('OPENCOVEN_PHASE1_SUPERVISOR_STATUS_PATH'); + expect(harness).toContain('bootstrapVerifiedRunner(options)'); + expect(harness).toContain('assertExecutingHarnessAuthority(lock)'); + const posixLauncher = readText('scripts/phase1-conformance-launcher.sh'); + const windowsLauncher = readText('scripts/phase1-conformance-launcher.ps1'); + expect(posixLauncher).toContain('exec /usr/bin/env -i'); + expect(posixLauncher).not.toContain('NODE_OPTIONS='); + expect(windowsLauncher).toContain('$start.Environment.Clear()'); + expect(windowsLauncher).not.toContain("'NODE_OPTIONS'"); + expect(windowsLauncher).toContain('C:\\Program Files\\PowerShell\\7\\pwsh.exe'); + expect(windowsLauncher.indexOf('IsPathFullyQualified($rawHelper)')).toBeLessThan( + windowsLauncher.indexOf('GetFullPath($rawHelper)'), + ); + expect(workflow).toContain('88e184d465eaf7bd6ce828dcc81ecadb11b6222f01576c56090060085820e7b2'); + expect(workflow).toContain('99eea6108e59db9a0ac12368787fb6e6456e6af4f8cce09ee96ce117ca3f475e'); }); it('keeps the capability schema resolvable from a fresh checkout', () => { @@ -469,7 +727,9 @@ describe('Phase 1 specification guards', () => { expect(packageManifest.scripts?.['install:clean']).toBe( 'corepack pnpm install --frozen-lockfile', ); - expect(packageManifest.scripts?.test).toBe('vitest run'); + expect(packageManifest.scripts?.test).toBe( + 'corepack pnpm@10.34.0 --ignore-workspace test:unit', + ); expect(packageManifest.scripts?.preview).toContain('--port 4174'); }); @@ -661,8 +921,8 @@ describe('Phase 1 specification guards', () => { expect( gated, - 'E2E, Desktop build, macOS Rust, Windows Rust, and Phase 1 conformance are the jobs worth skipping', - ).toHaveLength(5); + 'E2E, Desktop build, Rust, and Phase 1 conformance are the jobs worth skipping', + ).toHaveLength(4); // The classification has to fail towards running everything. A wrong guess // that way wastes a few minutes; the other way merges untested code. @@ -772,7 +1032,43 @@ describe('Phase 1 specification guards', () => { /token: \$\{\{ secrets\.CANARY_TOKEN \|\| github\.token \}\}/g, ); - expect(tokenLines, 'all cross-repository checkouts need the token').toHaveLength(5); + expect(tokenLines, 'all cross-repository checkouts need the token').toHaveLength(6); + }); + + it('does not persist checkout credentials into CI worktrees', () => { + const workflow = readText('.github/workflows/ci.yml'); + const lines = workflow.split('\n'); + const checkoutSteps: string[] = []; + + for (const [index, line] of lines.entries()) { + if (!line.includes('- uses: actions/checkout@')) { + continue; + } + + const stepIndent = line.indexOf('-'); + let end = index + 1; + while (end < lines.length) { + const candidate = lines[end] ?? ''; + const trimmed = candidate.trimStart(); + const indent = candidate.length - trimmed.length; + + if ( + trimmed.length > 0 && + (indent < stepIndent || (indent === stepIndent && trimmed.startsWith('- '))) + ) { + break; + } + end += 1; + } + + checkoutSteps.push(lines.slice(index, end).join('\n')); + } + + expect(checkoutSteps.length, 'CI must retain checkout steps').toBeGreaterThan(0); + for (const checkout of checkoutSteps) { + expect(checkout.match(/persist-credentials/g)).toHaveLength(1); + expect(checkout).toMatch(/^\s+persist-credentials:\s*false\s*$/m); + } }); it('proves the packed harness installs with no network access', () => { @@ -866,17 +1162,20 @@ describe('Phase 1 specification guards', () => { it('defines the packaged Phase 1 real-authority gate and sanitized evidence upload', () => { const workflow = readText('.github/workflows/ci.yml'); const packageManifest = readJson('package.json'); - const phase1Job = workflow.slice( - workflow.indexOf(' phase1-conformance:'), - workflow.indexOf('\n desktop:'), - ); + const phase1Job = workflow.match( + /\n {2}phase1-conformance:\n(?[\s\S]*?)(?=\n {2}[a-z][\w-]*:\n|$)/, + )?.groups?.job; - expect(packageManifest.scripts?.['test:phase1-conformance']).toBe( - 'node ./scripts/phase1-conformance.mjs --lock ./phase1-conformance.lock.json --scenario all', - ); + expect(packageManifest.scripts?.['test:phase1-conformance']).toBeUndefined(); expect(workflow).toMatch(/^ {2}phase1-conformance:$/m); expect(workflow).toMatch(/name:\s*Phase 1 real-authority conformance/); expect(workflow).toContain('runs-on: macos-15'); + expect(phase1Job).toMatch( + /dtolnay\/rust-toolchain@[0-9a-f]{40}[\s\S]*?toolchain: 1\.95\.0[\s\S]*?components: clippy,rustfmt/u, + ); + expect(readText('scripts/phase1-conformance.mjs')).toContain( + `resolve(rustupHome, 'toolchains', \`1.95.0-\${triple}\`, 'bin')`, + ); expect(workflow).toContain( "import { readPhase1ConformanceLock } from './scripts/phase1-conformance-lock.mjs';", ); @@ -891,49 +1190,72 @@ describe('Phase 1 specification guards', () => { `path: .phase1-counterparts/${repository === 'cave' ? 'coven-cave' : repository}`, ); } + expect(workflow).toContain( + 'repository: $' + '{{ steps.phase1-revisions.outputs.evidence_repository }}', + ); + expect(workflow).toContain('ref: $' + '{{ steps.phase1-revisions.outputs.evidence_revision }}'); + expect(workflow).toContain('path: .phase1-counterparts/sdk-evidence'); + expect(workflow).toContain( + 'OPENCOVEN_SDK_EVIDENCE_ROOT: $' + '{{ github.workspace }}/.phase1-counterparts/sdk-evidence', + ); expect(workflow).toContain('security create-keychain'); expect(workflow).toContain('security unlock-keychain'); - expect(phase1Job).toMatch(/toolchain: 1\.95\.0\s+components: clippy,rustfmt/); expect(workflow).toMatch(/name:\s*Remove isolated Phase 1 keychain[\s\S]*?if:\s*always\(\)/); - expect(workflow).toContain('pnpm test:phase1-conformance'); + expect(workflow).toContain('id: phase1-keychain-cleanup'); + expect(workflow.indexOf('name: Remove isolated Phase 1 keychain')).toBeLessThan( + workflow.indexOf('name: Upload sanitized Phase 1 report'), + ); + expect(workflow).toContain('/bin/sh scripts/phase1-conformance-launcher.sh'); + expect(workflow).toContain('node_path="$(command -v node)"'); + expect(workflow).not.toContain('pnpm test:phase1-conformance'); expect(workflow).toContain( 'node ./scripts/phase1-artifact-secret-scan.mjs --artifact-root ./test-results/phase1-conformance', ); + expect(readText('scripts/phase1-conformance.mjs')).toContain( + "['pnpm@10.34.0', '--ignore-workspace', 'build']", + ); + expect(readText('scripts/phase1-conformance.mjs')).toContain( + 'NODE_OPTIONS: caveBuildNodeOptions', + ); + expect(readText('scripts/phase1-conformance.mjs')).toContain( + 'CIRCLE_NODE_TOTAL: caveBuildReportedCpuTotal', + ); + expect(readText('scripts/phase1-conformance.mjs')).toContain( + 'TMPDIR: dirname(artifactRoot.rootPath)', + ); expect(workflow).toContain( 'uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02', ); expect(workflow).toContain('path: test-results/phase1-conformance/report.json'); }); - it('does not persist checkout credentials into the Phase 1 execution workspace', () => { + it('runs Git-heavy Phase 1 Vitest files once in a serial one-worker project', () => { + const packageManifest = readJson('package.json'); + const defaultConfig = readText('vitest.config.ts'); + const heavyConfig = readText('vitest.heavy.config.ts'); const workflow = readText('.github/workflows/ci.yml'); - const phase1Job = workflow.slice(workflow.indexOf(' phase1-conformance:')); - - expect(phase1Job.match(/persist-credentials: false/g)).toHaveLength(4); - expect(phase1Job).toContain('pnpm install --frozen-lockfile --ignore-scripts'); - }); - - it('packages the Cave compatibility controls only for the Phase 1 harness', () => { - const script = readText('scripts/phase1-conformance.mjs'); - const packageStart = script.indexOf('async function packageLockedArtifacts'); - const packageEnd = script.indexOf('\nasync function runCaveAuthorityMatrix', packageStart); - const packageBody = script.slice(packageStart, packageEnd); - - expect(packageStart).toBeGreaterThanOrEqual(0); - expect(packageEnd).toBeGreaterThan(packageStart); - expect(packageBody.match(/'Cave conformance package'/g)).toHaveLength(1); - expect(packageBody.match(/'build:conformance'/g)).toHaveLength(1); - expect(packageBody).not.toContain('retryCaveConformancePackage'); - expect(packageBody).not.toContain("resolve(roots.caveRoot, '.next')"); - for (const laterPackage of [ - "'Chat web package'", - "'SDK package build'", - "'Chat native RPC package'", + + expect(packageManifest.scripts?.test).toBe( + 'corepack pnpm@10.34.0 --ignore-workspace test:unit', + ); + expect(packageManifest.scripts?.['test:unit']).toBe( + 'corepack pnpm@10.34.0 --ignore-workspace test:unit:normal && corepack pnpm@10.34.0 --ignore-workspace test:unit:heavy', + ); + expect(packageManifest.scripts?.['test:unit:heavy']).toBe( + 'vitest run --config vitest.heavy.config.ts', + ); + for (const file of [ + 'src/phase1-conformance.test.ts', + 'src/phase1-conformance-lock.test.ts', + 'src/phase1-conformance-artifact-root.test.ts', + 'src/phase1-windows-supervisor.test.ts', ]) { - expect(packageBody.indexOf("'Cave conformance package'")).toBeLessThan( - packageBody.indexOf(laterPackage), - ); + expect(defaultConfig).toContain(`'${file}'`); + expect(heavyConfig).toContain(`'${file}'`); } + expect(heavyConfig).toContain('fileParallelism: false'); + expect(heavyConfig).toContain('maxWorkers: 1'); + expect(workflow.match(/pnpm test:unit/g)).toHaveLength(1); }); it('documents immutable Phase 1 conformance separately from the Phase 0 canary', () => { @@ -946,11 +1268,14 @@ describe('Phase 1 specification guards', () => { for (const document of [readme, toolchains, guide]) { expect(document).toContain('phase1-conformance.lock.json'); - expect(document).toContain('test:phase1-conformance'); + expect(document).toContain('phase1-conformance-launcher'); } - expect(guide).toContain('phase1.operator.homes-credentials-untouched'); + expect(guide).toContain('phase1-native-rpc'); + expect(guide).toContain('coven_health'); + expect(guide).toContain('coven daemon status'); expect(guide).toContain('secret scan'); - expect(guide).toContain('completed'); + expect(guide).toContain('darwin-arm64'); + expect(guide).toContain('win32-x64'); expect(tracker).not.toContain( '/Users/buns/Documents/GitHub/OpenCoven/coven-cave/.worktrees/opencoven-chat-v1-tracking', ); diff --git a/tools/phase1-process-supervisor/.cargo/config.toml b/tools/phase1-process-supervisor/.cargo/config.toml new file mode 100644 index 0000000..71d4348 --- /dev/null +++ b/tools/phase1-process-supervisor/.cargo/config.toml @@ -0,0 +1,7 @@ +[target.x86_64-pc-windows-gnu] +rustflags = [ + "-C", + "link-arg=-Wl,--no-insert-timestamp", + "-C", + "link-arg=-Wl,--build-id=none", +] diff --git a/tools/phase1-process-supervisor/Cargo.lock b/tools/phase1-process-supervisor/Cargo.lock new file mode 100644 index 0000000..af304a1 --- /dev/null +++ b/tools/phase1-process-supervisor/Cargo.lock @@ -0,0 +1,25 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "phase1-process-supervisor" +version = "0.1.0" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/tools/phase1-process-supervisor/Cargo.toml b/tools/phase1-process-supervisor/Cargo.toml new file mode 100644 index 0000000..10449e8 --- /dev/null +++ b/tools/phase1-process-supervisor/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "phase1-process-supervisor" +version = "0.1.0" +edition = "2021" +rust-version = "1.95.0" +publish = false + +[dependencies] +windows-sys = { version = "=0.61.2", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_JobObjects", + "Win32_System_Threading", +] } + +[profile.release] +codegen-units = 1 +lto = true +panic = "abort" +strip = "symbols" diff --git a/tools/phase1-process-supervisor/src/main.rs b/tools/phase1-process-supervisor/src/main.rs new file mode 100644 index 0000000..6a95b4d --- /dev/null +++ b/tools/phase1-process-supervisor/src/main.rs @@ -0,0 +1,148 @@ +#[cfg(windows)] +mod windows { + use std::{ + env, io, + mem::size_of, + os::windows::{io::AsRawHandle, process::CommandExt}, + path::PathBuf, + process::{Child, Command}, + ptr, + }; + + use windows_sys::Win32::{ + Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE}, + System::{ + Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, + THREADENTRY32, + }, + JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + }, + Threading::{OpenThread, ResumeThread, CREATE_SUSPENDED, THREAD_SUSPEND_RESUME}, + }, + }; + + struct Job(isize); + + impl Drop for Job { + fn drop(&mut self) { + unsafe { CloseHandle(self.0 as HANDLE) }; + } + } + + fn create_job() -> io::Result { + let job = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) }; + if job.is_null() { + return Err(io::Error::last_os_error()); + } + let mut information = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if unsafe { + SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + (&raw const information).cast(), + size_of::() as u32, + ) + } == 0 + { + let error = io::Error::last_os_error(); + unsafe { CloseHandle(job) }; + return Err(error); + } + Ok(Job(job as isize)) + } + + fn resume(child: &Child) -> io::Result<()> { + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + let mut entry = THREADENTRY32 { + dwSize: size_of::() as u32, + ..THREADENTRY32::default() + }; + let mut found = false; + let mut status = unsafe { Thread32First(snapshot, &mut entry) }; + while status != 0 { + if entry.th32OwnerProcessID == child.id() { + found = true; + let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) }; + if thread.is_null() || unsafe { ResumeThread(thread) } == u32::MAX { + let error = io::Error::last_os_error(); + if !thread.is_null() { + unsafe { CloseHandle(thread) }; + } + unsafe { CloseHandle(snapshot) }; + return Err(error); + } + unsafe { CloseHandle(thread) }; + } + status = unsafe { Thread32Next(snapshot, &mut entry) }; + } + unsafe { CloseHandle(snapshot) }; + found + .then_some(()) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no suspended child thread")) + } + + pub fn run() -> io::Result { + let mut args = env::args_os().skip(1); + if args.next().as_deref() != Some(std::ffi::OsStr::new("--")) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "missing separator", + )); + } + let executable = + PathBuf::from(args.next().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "missing executable") + })?); + if !executable.is_absolute() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "non-absolute executable", + )); + } + let metadata = std::fs::symlink_metadata(&executable)?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "unsafe executable", + )); + } + let job = create_job()?; + let mut command = Command::new(executable); + command.args(args).creation_flags(CREATE_SUSPENDED); + let mut child = command.spawn()?; + if unsafe { AssignProcessToJobObject(job.0 as HANDLE, child.as_raw_handle()) } == 0 { + let error = io::Error::last_os_error(); + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + if let Err(error) = resume(&child) { + drop(job); + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + let status = child.wait()?; + drop(job); + Ok(status.code().unwrap_or(1)) + } +} + +fn main() { + #[cfg(windows)] + match windows::run() { + Ok(code) => std::process::exit(code), + Err(_) => std::process::exit(1), + } + + #[cfg(not(windows))] + std::process::exit(1); +} diff --git a/vitest.config.ts b/vitest.config.ts index 168ff74..7442377 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -10,7 +10,13 @@ export default mergeConfig( environment: 'jsdom', globals: true, include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], - exclude: ['e2e/**'], + exclude: [ + 'e2e/**', + 'src/phase1-conformance.test.ts', + 'src/phase1-conformance-lock.test.ts', + 'src/phase1-conformance-artifact-root.test.ts', + 'src/phase1-windows-supervisor.test.ts', + ], setupFiles: ['./src/test/setup.ts'], }, }), diff --git a/vitest.heavy.config.ts b/vitest.heavy.config.ts new file mode 100644 index 0000000..de81409 --- /dev/null +++ b/vitest.heavy.config.ts @@ -0,0 +1,23 @@ +import { mergeConfig } from 'vite'; +import { defineConfig } from 'vitest/config'; + +import viteConfig from './vite.config.ts'; + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + environment: 'jsdom', + globals: true, + include: [ + 'src/phase1-conformance.test.ts', + 'src/phase1-conformance-lock.test.ts', + 'src/phase1-conformance-artifact-root.test.ts', + 'src/phase1-windows-supervisor.test.ts', + ], + setupFiles: ['./src/test/setup.ts'], + fileParallelism: false, + maxWorkers: 1, + }, + }), +);