diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2ddf864b..37ceb87d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -37,12 +37,18 @@ jobs:
with:
node-version: '22'
- name: No credentials in tracked files
- run: node scripts/check-secrets.mjs
+ run: |
+ node scripts/check-secrets.mjs
+ node --test scripts/test-check-secrets.mjs
+ - name: Verify publisher authentication and private release snapshots
+ run: node --test scripts/test-native-release-auth.mjs
- name: Managed llama.cpp packaging (pins, notices, relative links, deb repack)
- run: node --test scripts/test-llama-runtime.mjs
+ run: node --test scripts/test-llama-runtime.mjs scripts/test-llama-build-inputs.mjs
- name: Packaging PATH ignores host node hijacks
run: node --test scripts/test-native-packaging-env.mjs
- name: Pinned-source failures and mirror recovery
run: node --test scripts/test-native-source-fetch.mjs
- name: Installer replacement, runtime swap and checksum refusal
- run: bash scripts/test-install-appimage.sh
+ run: |
+ node --test --test-reporter=tap scripts/test-install-auth.mjs
+ bash scripts/test-install-appimage.sh
diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml
index 6108f9d5..ca6f22a7 100644
--- a/.github/workflows/native.yml
+++ b/.github/workflows/native.yml
@@ -3,7 +3,7 @@ on:
push:
branches: [main]
pull_request:
- paths: ['native/**', 'src-tauri/**', 'ui/**', 'licenses/native/**', 'packaging/native-runtime/**', 'packaging/native-app-run.sh', 'packaging/llama.cpp/**', 'packaging/shadow-agent.desktop', 'tools/llama.cpp.pin', 'tools/glslc-flatpak.sh', 'scripts/build-llama.cpp.sh', 'scripts/*llama*.mjs', 'scripts/install-appimage.sh', 'scripts/test-install-appimage.sh', 'scripts/*native*.mjs', 'scripts/native-packaging-env.sh', 'scripts/native-mcp-peer/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/native.yml', '.github/workflows/release.yml']
+ paths: ['native/**', 'vendor/**', 'scripts/fixtures/native-glib-variant.rs', 'scripts/fixtures/native-runtime-write-errors.c', 'src-tauri/**', 'ui/**', 'licenses/native/**', 'packaging/native-runtime/**', 'packaging/native-app-run.sh', 'packaging/llama.cpp/**', 'packaging/shadow-agent.desktop', 'tools/llama.cpp.pin', 'tools/glslc-flatpak.sh', 'scripts/build-llama.cpp.sh', 'scripts/*llama*.mjs', 'scripts/install-appimage.sh', 'scripts/install-release-state.sh', 'scripts/installer-auth-fixtures.mjs', 'scripts/test-install-auth.mjs', 'release/**', 'scripts/test-install-appimage.sh', 'scripts/test-clean-host-*.mjs', 'scripts/clean-host-*.mjs', 'scripts/clean-host-mcp-smoke.sh', 'scripts/fixtures/native-*.mjs', 'scripts/clean-host-runtime.Dockerfile', 'scripts/clean-host-gui-smoke.sh', 'scripts/*native*.mjs', 'scripts/publisher-fixtures.mjs', 'scripts/test-publisher-auth.mjs', 'scripts/native-packaging-env.sh', 'scripts/native-release-auth-lib.sh', 'scripts/verify-native-release.sh', 'scripts/check-secrets.mjs', 'scripts/test-check-secrets.mjs', 'scripts/native-mcp-peer/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/native.yml', '.github/workflows/release.yml']
permissions:
contents: read
jobs:
@@ -20,19 +20,36 @@ jobs:
node-version: '22'
cache: npm
cache-dependency-path: ui/package-lock.json
+ - name: Native window dependencies
+ run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev libasound2-dev patchelf webkit2gtk-driver xvfb appstream dbus-daemon dbus-bin
+ - name: Test release staging and immutable retry behavior
+ run: node --test scripts/test-native-release.mjs scripts/test-native-release-verification.mjs scripts/test-publisher-auth.mjs scripts/test-native-release-signing.mjs scripts/test-native-test-session.mjs scripts/test-clean-host-mcp-receipt.mjs scripts/test-clean-host-container.mjs scripts/test-native-app-run.mjs scripts/test-llama-build-inputs.mjs && node scripts/test-native-process-ownership.mjs
+ - name: Verify publisher authentication and private release snapshots
+ run: node --test scripts/test-native-release-auth.mjs scripts/test-check-secrets.mjs
- name: Package version
run: echo "VERSION=$(node -p "require('./src-tauri/tauri.conf.json').version")" >> "$GITHUB_ENV"
- - name: Native window dependencies
- run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev libasound2-dev patchelf webkit2gtk-driver xvfb
- name: Managed llama.cpp build dependencies
run: sudo apt-get install -y cmake ninja-build libssl-dev libvulkan-dev glslc spirv-headers
- run: npm --prefix ui ci && npm --prefix ui run build && npm --prefix ui test
- name: Install pinned Rust toolchain
run: rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy
+ - name: Verify the exact GLib backport and optimized iterator behavior
+ run: |
+ rustfmt +1.95.0 --check --edition 2021 scripts/fixtures/native-glib-variant.rs
+ node --test --test-reporter=tap scripts/test-native-glib-backport.mjs
+ node scripts/test-native-glib-variant.mjs
- run: cargo +1.95.0 fmt --all --check
- run: cargo +1.95.0 clippy --workspace --all-targets --locked -- -D warnings
- run: cargo +1.95.0 build -p shadowcode-desktop --locked
- - run: cargo +1.95.0 test --workspace --locked
+ - run: cargo +1.95.0 test --workspace --locked -- --skip worktrees::cleanup::tests::actual_built_project_cleanup_is_bounded_and_completes
+ - name: Qualify cleanup over actual built UI dependencies and output
+ run: node scripts/native-release-verification.mjs run built-project-cleanup
+ - name: Preserve built-project cleanup diagnostics
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: built-project-cleanup-diagnostics
+ path: artifacts/built-project-cleanup
- name: Exercise the native CLI without a display
run: node scripts/test-native-cli.mjs
- name: Stress repeated native tasks, cancellation, output and cleanup
@@ -68,12 +85,20 @@ jobs:
artifacts/native-tui
- run: cargo +1.95.0 install tauri-driver --version 2.0.6 --locked
- name: Exercise the native window
- run: xvfb-run -a -s '-screen 0 1440x1100x24' dbus-run-session -- node scripts/test-native-desktop.mjs
+ run: node scripts/run-native-x11.mjs node scripts/test-native-desktop.mjs
+ - name: Exercise the embedded Markdown worker under the native security policy
+ run: node scripts/run-native-x11.mjs node scripts/test-native-markdown.mjs
+ - name: Qualify 100 sequential tasks through the native window
+ run: node scripts/native-release-verification.mjs run native-endurance
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: always()
with:
name: native-window-diagnostics
- path: artifacts/native
+ path: |
+ artifacts/native
+ artifacts/native-markdown
+ artifacts/native-endurance
+ artifacts/native-x11
- run: cargo +1.95.0 build --release -p shadowcode-core --examples --locked
- name: Check pinned-source failures and mirror recovery
run: node --test scripts/test-native-source-fetch.mjs
@@ -90,17 +115,25 @@ jobs:
- name: Build the managed llama.cpp runtime (Vulkan + all CPU variants)
run: bash scripts/build-llama.cpp.sh --no-user-install
- name: Check the managed runtime packaging steps
- run: node --test scripts/test-llama-runtime.mjs
+ run: node --test scripts/test-llama-runtime.mjs scripts/test-native-desktop-metadata.mjs
- name: Build native development packages with dependency notices
run: node scripts/build-native.mjs
- name: Inspect both packages
run: node scripts/check-native-package.mjs "target/release/bundle/appimage/ShadowCode_${VERSION}_amd64.AppImage" "target/release/bundle/deb/ShadowCode_${VERSION}_amd64.deb"
+ - name: Build runtime-only clean-host qualification image
+ run: docker build -f scripts/clean-host-runtime.Dockerfile -t shadowcode-clean-runtime:ci scripts
+ - name: Install and first-launch both packages in an offline clean host
+ run: node scripts/test-clean-host-packages.mjs "target/release/bundle/appimage/ShadowCode_${VERSION}_amd64.AppImage" "target/release/bundle/deb/ShadowCode_${VERSION}_amd64.deb" artifacts/native-package/SHA256SUMS
+ - name: Reject AppImage extraction writes that fail
+ run: node scripts/test-native-runtime-write-errors.mjs
- name: Rebuild the shipped runtime sources without network access
run: node scripts/test-native-runtime-sources.mjs
- name: Exercise concurrent AppImage extraction and shutdown
run: node scripts/test-native-runtime.mjs
- name: Verify installer runtime swap and checksum refusal
- run: bash scripts/test-install-appimage.sh
+ run: |
+ node --test --test-reporter=tap scripts/test-install-auth.mjs
+ bash scripts/test-install-appimage.sh
- name: Exercise the packaged CLI without a display
env:
SHADOW_DESKTOP_BINARY: ${{ github.workspace }}/target/release/bundle/appimage/ShadowCode_${{ env.VERSION }}_amd64.AppImage
@@ -119,7 +152,13 @@ jobs:
SHADOW_DESKTOP_ARGS: '["--appimage-extract-and-run","ui"]'
SHADOW_NATIVE_DEFAULT_PROFILE: '1'
SHADOW_NATIVE_ARTIFACTS: artifacts/native-package/window
- run: xvfb-run -a -s '-screen 0 1440x1100x24' dbus-run-session -- node scripts/test-native-desktop.mjs
+ run: node scripts/run-native-x11.mjs node scripts/test-native-desktop.mjs
+ - name: Exercise the packaged Markdown worker under its native security policy
+ env:
+ SHADOW_DESKTOP_BINARY: ${{ github.workspace }}/target/release/bundle/appimage/ShadowCode_${{ env.VERSION }}_amd64.AppImage
+ SHADOW_DESKTOP_ARGS: '["--appimage-extract-and-run"]'
+ SHADOW_NATIVE_ARTIFACTS: artifacts/native-package/markdown
+ run: node scripts/run-native-x11.mjs node scripts/test-native-markdown.mjs
- name: Exercise the packaged native MCP server
env:
SHADOW_DESKTOP_BINARY: ${{ github.workspace }}/target/release/bundle/appimage/ShadowCode_${{ env.VERSION }}_amd64.AppImage
@@ -143,7 +182,9 @@ jobs:
if: always()
with:
name: native-package-diagnostics
- path: artifacts/native-package
+ path: |
+ artifacts/native-package
+ artifacts/native-x11
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: native-linux-development
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9079d8ea..17e788dd 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,83 +2,209 @@ name: Native Linux release
on:
push:
tags: ['v*']
+concurrency:
+ group: native-release-${{ github.ref }}
+ cancel-in-progress: false
permissions:
- contents: write
+ contents: read
jobs:
- publish:
+ # Owner provisioning is intentionally required. No environment, key, or
+ # reviewed tooling commit is fabricated by this workflow.
+ configuration:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ outputs:
+ tooling: ${{ steps.validate.outputs.tooling }}
+ environment: ${{ steps.validate.outputs.environment }}
+ steps:
+ - id: validate
+ name: Require approved signing configuration and repository identity
+ env:
+ SIGNING_ENVIRONMENT: ${{ vars.NATIVE_RELEASE_SIGNING_ENVIRONMENT }}
+ TOOLING_COMMIT: ${{ vars.NATIVE_RELEASE_TOOLING_COMMIT }}
+ REPOSITORY_ID: ${{ github.repository_id }}
+ OWNER_ID: ${{ github.repository_owner_id }}
+ run: |
+ node --input-type=module <<'NODE'
+ import assert from 'node:assert/strict';
+ import { appendFileSync } from 'node:fs';
+ const env = process.env;
+ assert.equal(env.GITHUB_REPOSITORY, 'Shadowfetchapps/ShadowCode');
+ assert.equal(env.REPOSITORY_ID, '1377099349');
+ assert.equal(env.OWNER_ID, '209457103');
+ assert.equal(env.GITHUB_EVENT_NAME, 'push');
+ assert.equal(env.GITHUB_REF_TYPE, 'tag');
+ assert.match(env.GITHUB_REF_NAME, /^v(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$/);
+ assert.match(env.TOOLING_COMMIT || '', /^[a-f0-9]{40}$/, 'Provision a reviewed immutable tooling commit');
+ assert.match(env.SIGNING_ENVIRONMENT || '', /^[A-Za-z0-9][A-Za-z0-9 _.-]{0,254}$/, 'Provision an existing protected signing environment');
+ appendFileSync(env.GITHUB_OUTPUT, `tooling=${env.TOOLING_COMMIT}\nenvironment=${env.SIGNING_ENVIRONMENT}\n`);
+ NODE
+ build:
+ needs: configuration
runs-on: ubuntu-24.04
timeout-minutes: 150
env:
RUSTUP_TOOLCHAIN: '1.95.0'
SHADOWCODE_BUILD_JOBS: '4'
+ outputs:
+ artifact-id: ${{ steps.unsigned.outputs.artifact-id }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: '22'
cache: npm
cache-dependency-path: ui/package-lock.json
- - name: Validate native release tag
- run: |
- VERSION=$(node -p "require('./src-tauri/tauri.conf.json').version")
- test "$GITHUB_REF_NAME" = "v$VERSION"
- test "$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1)" = "$VERSION"
- test "$(node -p "require('./ui/package.json').version")" = "$VERSION"
- grep -Fxq "X-ShadowCode-Version=$VERSION" packaging/shadow-agent.desktop
- name: Native package dependencies
- run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev libasound2-dev patchelf webkit2gtk-driver xvfb
+ run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev libasound2-dev patchelf webkit2gtk-driver xvfb appstream dbus-daemon dbus-bin
+ - name: Test release staging and immutable retry behavior
+ run: node scripts/native-release-verification.mjs run release-tests
+ - name: Verify publisher authentication and private release snapshots
+ run: node scripts/native-release-verification.mjs run release-auth
+ - name: Validate native release tag
+ run: node scripts/native-release-verification.mjs run release-tag
- name: Managed llama.cpp build dependencies
run: sudo apt-get install -y cmake ninja-build libssl-dev libvulkan-dev glslc spirv-headers
- name: Install the pinned Rust toolchain
run: rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy
- name: Build and test the embedded interface
- run: npm --prefix ui ci && npm --prefix ui run build && npm --prefix ui test
+ run: node scripts/native-release-verification.mjs run interface
+ - name: Verify the exact GLib backport and optimized iterator behavior
+ run: node scripts/native-release-verification.mjs run glib-backport
- name: Verify native source
- run: |
- cargo +1.95.0 fmt --all --check
- cargo +1.95.0 clippy --workspace --all-targets --locked -- -D warnings
- cargo +1.95.0 build -p shadowcode-desktop --locked
- cargo +1.95.0 test --workspace --locked
+ run: node scripts/native-release-verification.mjs run native-source
+ - name: Qualify cleanup over actual built UI dependencies and output
+ run: node scripts/native-release-verification.mjs run built-project-cleanup
+ - name: Preserve built-project cleanup diagnostics
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: built-project-cleanup-diagnostics
+ path: artifacts/built-project-cleanup
- name: Exercise native application behavior
- run: |
- node scripts/test-native-cli.mjs
- node scripts/test-native-stress.mjs
- node scripts/test-native-tui.mjs
- node scripts/test-native-mcp-server.mjs
- SHADOW_MCP_TRANSPORT=http node scripts/test-native-mcp-server.mjs
+ run: node scripts/native-release-verification.mjs run native-behavior
- name: Exercise the native desktop window
- run: |
- cargo +1.95.0 install tauri-driver --version 2.0.6 --locked
- xvfb-run -a -s '-screen 0 1440x1100x24' dbus-run-session -- node scripts/test-native-desktop.mjs
+ run: node scripts/native-release-verification.mjs run native-window
+ - name: Qualify 100 sequential tasks through the native window
+ run: node scripts/native-release-verification.mjs run native-endurance
+ - name: Preserve native window and Markdown worker diagnostics
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: native-window-diagnostics
+ path: |
+ artifacts/native
+ artifacts/native-markdown
+ artifacts/native-endurance
+ artifacts/native-x11
- name: Build the managed llama.cpp runtime (Vulkan + all CPU variants)
- run: |
- bash scripts/build-llama.cpp.sh --no-user-install
- node --test scripts/test-llama-runtime.mjs
+ run: node scripts/native-release-verification.mjs run managed-runtime
- name: Build and inspect release packages
- run: |
- VERSION=$(node -p "require('./src-tauri/tauri.conf.json').version")
- node --test scripts/test-native-packaging-env.mjs
- node scripts/build-native.mjs
- node scripts/check-native-package.mjs "target/release/bundle/appimage/ShadowCode_${VERSION}_amd64.AppImage" "target/release/bundle/deb/ShadowCode_${VERSION}_amd64.deb"
- node scripts/test-native-runtime-sources.mjs
- node scripts/test-native-runtime.mjs
+ run: node scripts/native-release-verification.mjs run packages
+ - name: Install and first-launch both release packages in an offline clean host
+ run: node scripts/native-release-verification.mjs run clean-host-packages
- name: Exercise the packaged application
- run: |
- VERSION=$(node -p "require('./src-tauri/tauri.conf.json').version")
- BINARY="$GITHUB_WORKSPACE/target/release/bundle/appimage/ShadowCode_${VERSION}_amd64.AppImage"
- SHADOW_DESKTOP_BINARY="$BINARY" SHADOW_CLI_ARGS='["--appimage-extract-and-run"]' node scripts/test-native-cli.mjs
- SHADOW_DESKTOP_BINARY="$BINARY" SHADOW_CLI_ARGS='["--appimage-extract-and-run"]' node scripts/test-native-tui.mjs
- SHADOW_DESKTOP_BINARY="$BINARY" SHADOW_DESKTOP_ARGS='["--appimage-extract-and-run","ui"]' SHADOW_NATIVE_DEFAULT_PROFILE=1 xvfb-run -a -s '-screen 0 1440x1100x24' dbus-run-session -- node scripts/test-native-desktop.mjs
- SHADOW_DESKTOP_BINARY="$BINARY" SHADOW_CLI_ARGS='["--appimage-extract-and-run"]' SHADOW_MCP_TRANSPORT=http node scripts/test-native-mcp-server.mjs
- npm --prefix scripts/native-mcp-peer ci --ignore-scripts --no-audit --no-fund
- SHADOW_DESKTOP_BINARY="$BINARY" SHADOW_CLI_ARGS='["--appimage-extract-and-run"]' node scripts/test-native-mcp-peer.mjs
+ run: node scripts/native-release-verification.mjs run packaged-behavior
+ - name: Preserve final X11 session diagnostics
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: native-x11-session-diagnostics
+ path: artifacts/native-x11
- name: Create release checksums
run: cp artifacts/native-package/SHA256SUMS target/release/bundle/SHA256SUMS
- name: Verify installer replacement and checksum refusal
- run: bash scripts/test-install-appimage.sh
- - name: Publish checked native artifacts
+ run: node scripts/native-release-verification.mjs run installer
+ - name: Preserve exact release verification receipts
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: native-release-verification
+ path: artifacts/release-verification
+ - name: Prepare immutable unsigned handoff with all required receipts
+ run: node scripts/prepare-native-release.mjs "$RUNNER_TEMP/unsigned-release"
+ - id: unsigned
+ name: Retain this run's exact unsigned handoff
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: unsigned-release-${{ github.run_id }}-${{ github.run_attempt }}
+ path: ${{ runner.temp }}/unsigned-release
+ if-no-files-found: error
+ compression-level: 0
+ sign:
+ needs: [configuration, build]
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+ permissions:
+ contents: read
+ actions: read
+ environment: ${{ needs.configuration.outputs.environment }}
+ outputs:
+ artifact-id: ${{ steps.signed.outputs.artifact-id }}
+ steps:
+ - name: Checkout independently approved signing tooling and public trust
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ ref: ${{ needs.configuration.outputs.tooling }}
+ path: trusted
+ persist-credentials: false
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: '22'
+ - name: Require one build artifact identity
+ env:
+ RELEASE_ARTIFACT_ID: ${{ needs.build.outputs.artifact-id }}
+ run: node -e "require('node:assert/strict').match(process.env.RELEASE_ARTIFACT_ID || '', /^[1-9][0-9]*$/)"
+ - name: Download only the immutable artifact ID produced by this build
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ with:
+ artifact-ids: ${{ needs.build.outputs.artifact-id }}
+ path: ${{ runner.temp }}/unsigned-download
+ merge-multiple: true
+ - name: Sign and reverify qualified data with the protected key
+ working-directory: trusted
+ env:
+ NATIVE_RELEASE_SIGNING_KEY_PEM: ${{ secrets.NATIVE_RELEASE_SIGNING_KEY_PEM }}
+ run: node scripts/sign-native-release.mjs "$RUNNER_TEMP/unsigned-download" release/trust "$RUNNER_TEMP/signed-release"
+ - id: signed
+ name: Retain only authenticated public release data
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: signed-release-${{ github.run_id }}-${{ github.run_attempt }}
+ path: ${{ runner.temp }}/signed-release
+ if-no-files-found: error
+ compression-level: 0
+ publish:
+ needs: [configuration, build, sign]
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+ permissions:
+ contents: write
+ actions: read
+ steps:
+ - name: Checkout independently approved publication tooling and public trust
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ ref: ${{ needs.configuration.outputs.tooling }}
+ path: trusted
+ persist-credentials: false
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: '22'
+ - name: Require one signed artifact identity
+ env:
+ RELEASE_ARTIFACT_ID: ${{ needs.sign.outputs.artifact-id }}
+ run: node -e "require('node:assert/strict').match(process.env.RELEASE_ARTIFACT_ID || '', /^[1-9][0-9]*$/)"
+ - name: Download only this signing job's immutable public bundle
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
+ with:
+ artifact-ids: ${{ needs.sign.outputs.artifact-id }}
+ path: ${{ runner.temp }}/signed-download
+ merge-multiple: true
+ - name: Authenticate private snapshots before any release operation
+ working-directory: trusted
env:
GH_TOKEN: ${{ github.token }}
- run: |
- gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "ShadowCode $GITHUB_REF_NAME" --notes-file docs/RELEASE_NOTES.md
- gh release upload "$GITHUB_REF_NAME" target/release/bundle/appimage/*.AppImage target/release/bundle/appimage/*runtime-sources.tar.gz target/release/bundle/deb/*.deb target/release/bundle/SHA256SUMS --clobber
+ run: node scripts/publish-native-release.mjs "$RUNNER_TEMP/signed-download" release/trust
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2e0a1bc5..5c403301 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,37 @@
# Changelog
+## Unreleased
+
+- **A calmer composer.** Model, Code / Plan / Ask, permissions and Web stay
+ in view; reasoning effort, Compare and Worktree move under **More**, which
+ closes with Escape or an outside click. A non-default effort stays marked
+ on the button, and `Ctrl+Shift+Enter` still starts a worktree task
+ directly. Compare and Worktree explain why they are unavailable without
+ closing the menu or pretending to run.
+- **Richer AppStream metadata** (feature list, bug tracker, developer,
+ content rating and release history). The package check now validates it
+ strictly and requires the newest listed release to be the packaged
+ version.
+- Refresh visible check evidence after editor saves, editor-observed disk changes and relevant same-session engine events. Hide old passing results immediately and discard refresh responses superseded by an edit. Visible task summaries refresh together through one batched request (`POST /api/jobs/verification-refresh`, up to 32 jobs sharing one workspace fingerprint) on a shared five-second cadence; offscreen cards and hidden windows don't poll. Not included in the published 0.33.1 packages.
+
+## 0.33.1: Reliability and daily workflow
+
+- Runtime builds and notices refreshes preserve tracked source pins and reference metadata; actual build provenance stays in generated runtime output. Regression coverage reproduces the source-integrity failure that stopped the unpublished 0.33.0 attempt.
+- Code highlighting, line numbers, undo/redo and recoverable editor drafts, with revision-checked saves and newline preservation.
+- Bounded attached-context inventory with exclusions, truncation notices and approximate token counts.
+- **Run a check…** from completed tasks: explicit commands, existing approvals, fresh output and verification receipts, no model selection or extra model turn, and preserved composer drafts.
+- Independent provider sign-in recovery after navigation; cancellation remains **Stopping…** until acknowledged. Cached account state survives uncertain progress reads, and refresh waits for ongoing sign-ins.
+- Safer Compare snapshots and recovery, unsaved-draft guards, and coordination of short workspace writes across cooperating ShadowCode processes.
+- Shared native event listeners prevent per-task listener growth. A real source-built X11 endurance run passed 100 tasks after bounded warm-up; this is not GPU or physical Wayland endurance evidence.
+- AppImage launch preserves an explicitly selected display backend; package normalization accepts only the exact expected linuxdeploy version field.
+- Authenticated installer recovery and staged release verification are strengthened. [Version 0.33.1](https://github.com/Shadowfetchapps/ShadowCode/releases/tag/v0.33.1) was published on September 28, 2026 from `e15c448`, after all 15 required build gates and protected signing passed. The workflow publication job failed on a transient draft-listing response; publication resumed locally with the same frozen publisher and signed artifact, with all seven remote assets compared before publication. See the release notes for the exact evidence and remaining limits.
+
+See [release notes](docs/RELEASE_NOTES.md) for scope and remaining qualification.
+
+## 0.33.0: Failed release attempt (unpublished)
+
+[Release run 36414378697](https://github.com/Shadowfetchapps/ShadowCode/actions/runs/36414378697) at `9e385cb` failed the managed-runtime source-integrity gate after the runtime built and its six tests passed. The builder rewrote tracked runtime pin/COMMIT timestamps. Signing and publication were skipped. The existing `v0.33.0` tag remains unchanged; the separately qualified successor is 0.33.1. Earlier local 0.33.0 package and latency results remain evidence for those exact bytes only.
+
## 0.32.0: The big upgrade
Everyday use:
diff --git a/Cargo.lock b/Cargo.lock
index 9e88fec1..a60c0935 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1795,8 +1795,6 @@ dependencies = [
[[package]]
name = "glib"
version = "0.18.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
dependencies = [
"bitflags 2.13.2",
"futures-channel",
@@ -4454,7 +4452,7 @@ dependencies = [
[[package]]
name = "shadowcode-core"
-version = "0.32.0"
+version = "0.33.1"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -4508,7 +4506,7 @@ dependencies = [
[[package]]
name = "shadowcode-desktop"
-version = "0.32.0"
+version = "0.33.1"
dependencies = [
"anyhow",
"notify-rust",
diff --git a/Cargo.toml b/Cargo.toml
index e4ea692d..58bfbed2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,15 +1,21 @@
[workspace]
members = ["native/core", "src-tauri"]
default-members = ["native/core"]
+exclude = ["vendor/glib-0.18.5"]
resolver = "2"
[workspace.package]
-version = "0.32.0"
+version = "0.33.1"
edition = "2021"
rust-version = "1.95"
license = "Apache-2.0"
repository = "https://github.com/Shadowfetchapps/ShadowCode"
+[patch.crates-io]
+# GTK3/WebKit still require 0.18. Preserve that API with the upstream UB fix.
+# See vendor/glib-0.18.5.README.md for provenance and verification.
+glib = { path = "vendor/glib-0.18.5" }
+
[profile.release]
lto = "thin"
codegen-units = 1
diff --git a/README.md b/README.md
index cd35b2b6..ba495249 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,9 @@
# ShadowCode
+
+
+
+
ShadowCode is a Linux desktop coding agent. Open a project, pick a model,
describe the change, watch the agent work, then review the diff.
@@ -12,7 +16,7 @@ need Ollama, LM Studio or any other model server.

-- **One picker.** The composer lists **Subscriptions**, **On this computer**
+- **One picker.** The composer lists **Subscriptions** (or **Vendor CLIs** when billing is unconfirmed), **On this computer**
and **API keys** in one menu. Each row shows whether it is ready, whether it
runs locally or in the cloud, and the usage figures the vendor reports.
- **Usage figures come from the vendor.** If a vendor exposes no usage, the row
@@ -25,43 +29,69 @@ need Ollama, LM Studio or any other model server.
Approval cards show the actual diff or command. After a task you review
just what it changed, keep or undo each change, and rewind shell commands
and subscription edits too.
-- **Shell commands are sandboxed.** They see an empty home folder, so SSH
- keys, cloud credentials and API keys stay out of reach; only the project is
- writable.
+- **Explicit execution boundaries.** ShadowCode's native agent uses the
+ configured command sandbox. Vendor agents and your own terminal follow
+ their own execution rules; their tools do not inherit that sandbox.
- **A capable agent.** Subagents, language-server error checking after each
edit, a repo map and code search, and support for `CLAUDE.md`, Cursor rules
and Claude Code skills.
+- **Edit and verify.** The editor highlights code and preserves drafts; attached
+ context shows bounded content and estimates. **Run a check…** starts an explicit
+ command from a completed task without another model turn, keeping your draft.
- **Everything in one window.** Run tasks side by side in worktrees, use your
own terminal, go from commit to pull request, preview the app you're
building, dictate with your voice, and follow along from your phone.
-Release history is in [CHANGELOG.md](CHANGELOG.md). What's new in this release:
-[0.32.0 release notes](docs/RELEASE_NOTES.md).
+ShadowCode **0.33.1** is available in [GitHub releases](https://github.com/Shadowfetchapps/ShadowCode/releases/tag/v0.33.1).
+See the [0.33.1 release notes](docs/RELEASE_NOTES.md) for changes and qualification
+limits, and the [release history](CHANGELOG.md).
## Install
Releases target x86_64 Linux with glibc 2.39 or newer (Ubuntu 24.04 or later).
-Download from [GitHub releases](https://github.com/Shadowfetchapps/ShadowCode/releases/latest):
+Check [GitHub releases](https://github.com/Shadowfetchapps/ShadowCode/releases)
+for published versions. The 0.33.1 packages and checksums are:
-- `ShadowCode_0.32.0_amd64.AppImage`
-- `ShadowCode_0.32.0_amd64.deb`
+- `ShadowCode_0.33.1_amd64.AppImage`
+- `ShadowCode_0.33.1_amd64.deb`
- `SHA256SUMS`
### AppImage (recommended)
-Put the AppImage and `SHA256SUMS` in the same folder, then run the installer
-from a checkout of this repository:
+The installer requires signed release packages. Published 0.32.0
+assets are checksum-only and cannot be installed by this new entry point; use
+the instructions shipped with that released version for those existing assets.
+The 0.33.0 release attempt failed before signing and publication. Its tag stays
+unchanged. Version 0.33.1 is the first signed release; its public trust policy
+and protected signing configuration authorize that version.
+For development, use [Build from source](#build-from-source).
+
+Obtain an independently authenticated installer bundle as described below,
+then put the signed release AppImage, `SHA256SUMS`, `RELEASE-MANIFEST.json`,
+`RELEASE-AUTH` and `RELEASE-AUTH.sig` in one folder. Run the installer from the
+reviewed bundle, which supplies its own verifier, public keys and installation
+policy. Never obtain those trusted keys from the candidate download itself.
+See [publisher authentication](docs/RELEASE_AUTHENTICATION.md) for the exact
+bundle and bootstrap requirements.
+
+From that independently authenticated installer bundle, run:
```bash
-sha256sum --ignore-missing -c SHA256SUMS
-git clone https://github.com/Shadowfetchapps/ShadowCode.git
-./ShadowCode/scripts/install-appimage.sh ~/Downloads/ShadowCode_0.32.0_amd64.AppImage
+bash /path/to/trusted-bundle/scripts/install-appimage.sh /path/to/downloads/ShadowCode_0.33.1_amd64.AppImage
```
+The installer takes the AppImage path (or `--recover`), resolves its own trusted
+policy and keys, and verifies the adjacent release metadata. Verifier flags such
+as `--trust-dir` are not installer options.
+
[`scripts/install-appimage.sh`](scripts/install-appimage.sh):
-- refuses the file unless it matches its `SHA256SUMS` entry. Pass
- `--unverified` only if you knowingly want to skip the check.
+- verifies the publisher signature, release identity and exact private AppImage
+ snapshot before executing it. Checksum-only packages, checksum overrides and
+ `--unverified` are refused; the flag is not a signature bypass.
+- records the highest authenticated release durably and refuses downgrades or
+ changed bytes under the same version. Restoring an older working app after
+ failure does not lower this record.
- starts the new AppImage (`--version`) before replacing anything.
- extracts the bundled llama.cpp runtime (`usr/lib/shadowcode`) and checks it:
no absolute or dangling symlinks, `COMMIT`, `architectures.txt` and `NOTICES`
@@ -73,18 +103,35 @@ git clone https://github.com/Shadowfetchapps/ShadowCode.git
and `shadowcode` launchers in `~/.local/bin`, and the desktop entry.
- leaves settings, history and project data alone. It removes older ShadowCode
AppImages only after a successful install.
-
-To run the AppImage without installing it:
-`./ShadowCode_0.32.0_amd64.AppImage --appimage-extract-and-run`. FUSE is not
+- records interrupted replacement before activation and restores the prior
+ AppImage/runtime on retry when all recorded identities still match. Run
+ `./ShadowCode/scripts/install-appimage.sh --recover` to attempt that recovery
+ without the original download, using the same trusted installer bundle. Changed, ambiguous or activation-stage states
+ are preserved for manual recovery; first-window and database rollback are
+ not yet qualified.
+
+After obtaining and verifying the intended package, run the AppImage without installing it:
+`./ShadowCode_0.33.1_amd64.AppImage --appimage-extract-and-run`. FUSE is not
required.
### Debian package
+Verify the Debian artifact using the independently trusted verifier bundle.
+`verified-deb-0.33.1` must not already exist; install the verified snapshot:
+
```bash
-sha256sum --ignore-missing -c SHA256SUMS
-sudo apt install ./ShadowCode_0.32.0_amd64.deb
+bash /path/to/trusted-bundle/scripts/verify-native-release.sh \
+ --bundle-dir /path/to/downloads \
+ --trust-dir /path/to/trusted-bundle/release/trust \
+ --artifact ShadowCode_0.33.1_amd64.deb \
+ --stage-dir ./verified-deb-0.33.1 \
+ --expect-version 0.33.1
+sudo apt install ./verified-deb-0.33.1/ShadowCode_0.33.1_amd64.deb
```
+This manual Debian verification does not provide the AppImage installer's durable
+accepted-version and rollback state. Checksums alone do not authenticate a publisher.
+
The deb installs `shadowcode` and the same llama.cpp runtime in
`/usr/lib/shadowcode`. It depends on `git`, `libgomp1` and `libssl3`, and
recommends `libvulkan1`, which is needed for GPU inference.
@@ -103,7 +150,7 @@ recommends `libvulkan1`, which is needed for GPU inference.

-The picker has three groups: **Subscriptions**, **On this computer** and **API
+The picker groups vendor routes under **Subscriptions** or **Vendor CLIs**, alongside **On this computer** and **API
keys** (OpenRouter, clearly marked as billed per token). Every row is a
concrete target with a stable ID, for example `cli:cursor:auto`,
`local:gguf:` or `api:openrouter:qwen/qwen3-coder`. Selecting it applies to the current
@@ -135,6 +182,12 @@ confirmation, because that signs the CLI out everywhere on this computer. It
then forgets ShadowCode's cached status, stored usage and resumable session IDs
for that vendor.
+Active sign-ins remain available when you leave Accounts and return. Each
+provider keeps its own progress and Cancel control, so connecting another
+provider does not hide the first. A connected account alone does not establish
+subscription billing; the app shows unknown billing when the vendor supplies
+no evidence.
+
| Vendor | Runtime ShadowCode starts | Connect runs | Models come from | Image input | Approvals reach ShadowCode | Usage shown |
| --- | --- | --- | --- | --- | --- | --- |
| Codex | `codex app-server` (JSON-RPC) | `codex login` | app-server `model/list` | Yes (`localImage`), per model | Yes: command and file-change requests. Codex runs in its own sandbox (`workspace-write`, or `read-only` for Plan/Review) | Rate-limit windows per quota pool (for example 5-hour and weekly), reset times, plan, and credits only when reported |
@@ -180,22 +233,32 @@ plan runs out** to **Ask me** to choose each time. Details:
## Compare
-**Compare** (next to **Send**) runs one task on 2 or 3 models at once, each
-in its own Git worktree that starts from your latest commit plus your
+**Compare** (in the composer's **More** menu) runs one task on 2 or 3
+models, each in its own Git worktree that starts from your latest commit plus your
uncommitted work. Your checkout is not touched while they work. The
**Comparisons** view shows the lanes side by side: files changed, checks run,
time and usage, and a link to each lane's conversation. **Keep** one result to
-apply its changes to your working tree (nothing is committed); every lane and
-its branch is then removed, and **Wins in this project** counts which model
-you kept. At most one local model per comparison, since only one fits in GPU
-memory. Details: [compare](docs/COMPARE.md).
+apply its changes to your working tree (nothing is committed), and **Wins in
+this project** counts which model you kept. Managed local models run one after
+another; cloud lanes can run concurrently. If cleanup cannot finish, retained
+copies remain visible for retry. Save unsaved app editor drafts before starting
+a comparison. Details: [compare](docs/COMPARE.md).
## Working in the window
- **Composer.** Type `@` to attach project files or folders (or a
subagent), ↑ for earlier prompts, and switch between **Code**, **Plan**
- and **Ask**. An effort control appears for models that support one. Your
- messages can be edited and resent, retried or copied.
+ and **Ask**. Model, mode, permission and web controls stay visible; **More**
+ holds reasoning effort, Compare and Worktree to keep the message area clear.
+ A non-default effort remains marked on the **More** button.
+ Your messages can be edited and resent, retried or copied.
+- **Editor.** Open project files with syntax highlighting, find/replace,
+ undo/redo and recovery drafts. Saving checks whether the file changed on
+ disk before replacing it.
+- **Run a check.** A completed task offers **Run a check…** for a command you
+ choose. It uses the usual approvals, keeps your composer draft and records
+ fresh output without another model turn. A passing check confirms that
+ command only.
- **Drawer.** Your own terminals (a real shell, never shown to the model), a
**Git** tab (branches, a suggested commit message, push, pull requests
through `gh` or `glab` with CI checks), a **Preview** of your dev server
@@ -229,9 +292,9 @@ subscription CLIs bring their own agents.
## Parallel tasks
-**Worktree** (next to **Send**, or `Ctrl+Shift+Enter`) starts a task in its
-own worktree of the project, so it runs while another task works in your
-checkout. When it is done, **Apply to project** (checked with `git apply
+Choose **Worktree** in the composer's **More** menu (or press
+`Ctrl+Shift+Enter`) to start a task in its own worktree of the project, so it
+runs while another task works in your checkout. When it is done, **Apply to project** (checked with `git apply
--check` first; conflicting files are listed and nothing is written), **Keep
as branch** or **Discard**. The sidebar marks conversations that are running,
need your approval, failed or finished while you were elsewhere; desktop
@@ -298,10 +361,16 @@ rate limits are retried automatically. Details: [OpenRouter](docs/OPENROUTER.md)
folder. It shows the runtime, the detected hardware and the loaded model.
ShadowCode never downloads weights. Removing a row never deletes the file.
-- **Everything is read from the file.** ShadowCode reads the GGUF header for
+- **Model metadata comes from the file.** ShadowCode reads the GGUF header for
architecture, trained context, chat template and tensors, never the file name.
A model whose architecture the bundled llama.cpp doesn't support is listed as
incompatible, with the reason.
+- **Tool-template compatibility.** The exact supported Hermes 2 Pro Llama-3
+ metadata and plain-chat template select a bundled upstream tool template.
+ ShadowCode confirms the runtime's reported template identity before enabling
+ tools and shows the applied template provenance. It does not rewrite model
+ files or Ollama settings. Other models keep their embedded template; a
+ compatibility profile alone does not establish coding quality.
- **Import from Ollama.** Models in an existing Ollama store can be imported
by reference: ShadowCode registers the store's blob paths, including any
vision projector. It never copies the blobs, never writes to the store and
diff --git a/SECURITY.md b/SECURITY.md
index 22ef8735..47c05eab 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -30,9 +30,11 @@ as your Linux user. ShadowCode's own `exec` tool limits what they can reach:
message. Otherwise they run under **Landlock** when the kernel supports it
(Linux 5.13+). Landlock allows reading only the system folders, the toolchain
folders and the project, and writing only the project and temporary folders.
- It blocks TCP when the shell network is off. Landlock doesn't cover UDP and
- gives no process isolation. If neither layer is available, commands run
- unrestricted. Either way, the conversation shows a warning once.
+ TCP blocking when the shell network is off needs Landlock ABI 4 or newer;
+ earlier supported kernels provide file limits only. ShadowCode's Landlock
+ rules don't cover UDP and give no process isolation. If neither layer is
+ available, commands run unrestricted. Either way, the conversation shows a
+ warning once.
Landlock isn't layered under bubblewrap: it forbids the mount calls
bubblewrap needs, and ShadowCode runs no helper inside the sandbox.
- **Network** for shell commands is `off`, `on`, or `allowlist`
@@ -51,12 +53,10 @@ as your Linux user. ShadowCode's own `exec` tool limits what they can reach:
Command classification (privileged, network, destructive Git) is a lexical
policy check. It is not containment. Background processes started with
-`background_start` and lifecycle hooks are not run in this sandbox.
-
-Vendor CLIs (Codex, Claude Code, Cursor, Antigravity, Grok) run as your user
-with their own tools and sandboxes. ShadowCode doesn't wrap them in bubblewrap,
-and its network and `sudo` rules don't apply to them. Use a container or a
-separate account when you need stronger isolation.
+`background_start`, lifecycle hooks and interactive terminal sessions are not
+run in this sandbox. The command sandbox settings, including "Require sandbox"
+and its network limits, apply to the built-in `exec` tool. Background-process
+approvals disclose this difference before execution.
Vendor CLIs (Codex, Claude Code, Cursor, Antigravity, Grok) run as your user
with their own tools and sandboxes. ShadowCode doesn't wrap them in bubblewrap,
diff --git a/assets/branding/README.md b/assets/branding/README.md
new file mode 100644
index 00000000..6a17993b
--- /dev/null
+++ b/assets/branding/README.md
@@ -0,0 +1,41 @@
+# ShadowCode logo provenance
+
+The supplied final logo reads **SHADOWCODE** and “ONE HARNESS. ALL MODELS.”
+It supersedes the earlier ShadowfetchCode artwork. The download filename does
+not change the product name, command names, application IDs, or storage paths.
+
+- `shadowcode-original.png` is the exact, unchanged final file supplied as
+ `ShadowfetchCodeLogo.png` (1254 × 1254, opaque PNG).
+ SHA-256: `653b36e0273c06dfc9ad36489954a6f9dc9ea1aa27fbf1d40195e4a06686920f`.
+- `shadowcode-emblem.png` is the selected built-in image generation extraction
+ from that final file (1254 × 1254, PNG with real alpha transparency). Original
+ generated filename: `exec-aa53c26a-bed2-47bf-9417-28eaa1247301.png`.
+ SHA-256: `3bdda4e1d0e8a7149d3aea358e08eea9d3338f345604c0da0344ae3f8a3fc573`.
+
+The image generation call used the final original as its reference and
+`transparent_background=true`, with this prompt:
+
+> Use case: background-extraction. This image is the user's FINAL replacement
+> ShadowCode logo, and supersedes the earlier ShadowfetchCode art. Extract ONLY
+> the exact central gold-left/dark-steel-right hexagonal S emblem from this image
+> into a square app icon with real alpha transparency. Remove the background,
+> floor reflection, SHADOWCODE wordmark and tagline. Preserve this version's
+> precise geometry, bevels, proportions, metallic texture and gold illumination;
+> do not redesign or add any elements. Internal dark surfaces of the emblem stay
+> dark. Center the whole emblem uncropped with about 8% transparent padding around
+> its extent. No text, extra outline, badge or checkerboard raster. High-fidelity
+> background cutout of this specific provided logo.
+
+Run `bash assets/branding/generate-icons.sh` from any directory to regenerate
+the desktop sizes, hicolor tree, web/PWA icons, Apple icon, favicon, SVG
+compatibility wrappers, and social preview. The script verifies source hashes
+before writing derivatives. It uses ImageMagick `convert` (generated with
+6.9.12-98 Q16), Lanczos resizing, fixed colors, and stripped metadata. It does
+not rebuild `ui/dist`, install the app, or alter either source PNG.
+
+Native and browser icons use the emblem without the tiny wordmark. Transparent
+PNG variants preserve its interior dark surfaces and its original padding.
+Apple and maskable icons have an opaque charcoal background; the maskable
+variant adds safe cropping space. Legacy SVG paths embed the 256 px PNG, with
+no external dependencies; they are raster compatibility wrappers, not newly
+drawn vector artwork. The README and social preview show the complete original.
diff --git a/assets/branding/generate-icons.sh b/assets/branding/generate-icons.sh
new file mode 100644
index 00000000..6eba3d1a
--- /dev/null
+++ b/assets/branding/generate-icons.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+# Rebuild packaging derivatives from the approved, preserved source images.
+set -euo pipefail
+ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$ROOT"
+export MAGICK_THREAD_LIMIT=2
+sha256sum -c assets/branding/sources.sha256
+EMBLEM=assets/branding/shadowcode-emblem.png
+
+icon() {
+ local size="$1" output="$2"
+ convert "$EMBLEM" -colorspace sRGB -filter Lanczos -resize "${size}x${size}" \
+ -strip -define png:exclude-chunks=date,time "PNG32:$output"
+}
+
+for size in 16 22 24 32 48 64 96 128 256 512; do
+ icon "$size" "assets/icons/shadow-agent-${size}.png"
+ directory="assets/icons/hicolor/${size}x${size}/apps"
+ mkdir -p "$directory"
+ cp "assets/icons/shadow-agent-${size}.png" "$directory/shadow-agent.png"
+done
+cp assets/icons/shadow-agent-512.png assets/icons/shadow-agent.png
+
+# Keep legacy SVG paths self-contained for desktop installers and old links.
+# 256 px is sufficient for this compatibility wrapper; native bundles also
+# retain dedicated 512 px PNGs. Do not duplicate the full-resolution master.
+svg=assets/icons/shadow-agent.svg
+{
+ printf '%s\n' ''
+} > "$svg"
+mkdir -p assets/icons/hicolor/scalable/apps
+for path in assets/icons/hicolor/scalable/apps/shadow-agent.svg assets/shadow-agent.svg icon.svg ui/public/icon.svg; do
+ cp "$svg" "$path"
+done
+
+icon 192 ui/public/icon-192.png
+cp assets/icons/shadow-agent-512.png ui/public/icon-512.png
+cp assets/icons/shadow-agent-32.png ui/public/icon-32.png
+convert assets/icons/shadow-agent-16.png assets/icons/shadow-agent-32.png \
+ assets/icons/shadow-agent-48.png ui/public/favicon.ico
+# Apple icons are opaque. The maskable icon keeps the emblem inside the
+# central safe circle, with a charcoal fill for platform cropping.
+convert "$EMBLEM" -colorspace sRGB -filter Lanczos -resize 180x180 \
+ -background '#0d1117' -alpha remove -alpha off -strip \
+ -define png:exclude-chunks=date,time PNG24:ui/public/apple-touch-icon.png
+convert "$EMBLEM" -colorspace sRGB -filter Lanczos -resize 384x384 \
+ -gravity center -background '#0d1117' -extent 512x512 -alpha remove -alpha off \
+ -strip -define png:exclude-chunks=date,time PNG24:ui/public/icon-maskable-512.png
+
+# Show the complete supplied logo, without cropping its wordmark or tagline.
+convert assets/branding/shadowcode-original.png -colorspace sRGB -filter Lanczos \
+ -resize 640x640 -gravity center -background '#080c11' -extent 1280x640 \
+ -strip -define png:exclude-chunks=date,time PNG24:assets/github-social-preview.png
diff --git a/assets/branding/shadowcode-emblem.png b/assets/branding/shadowcode-emblem.png
new file mode 100644
index 00000000..a15299ba
Binary files /dev/null and b/assets/branding/shadowcode-emblem.png differ
diff --git a/assets/branding/shadowcode-original.png b/assets/branding/shadowcode-original.png
new file mode 100644
index 00000000..c2ed92d4
Binary files /dev/null and b/assets/branding/shadowcode-original.png differ
diff --git a/assets/branding/sources.sha256 b/assets/branding/sources.sha256
new file mode 100644
index 00000000..75156623
--- /dev/null
+++ b/assets/branding/sources.sha256
@@ -0,0 +1,2 @@
+653b36e0273c06dfc9ad36489954a6f9dc9ea1aa27fbf1d40195e4a06686920f assets/branding/shadowcode-original.png
+3bdda4e1d0e8a7149d3aea358e08eea9d3338f345604c0da0344ae3f8a3fc573 assets/branding/shadowcode-emblem.png
diff --git a/assets/github-social-preview.png b/assets/github-social-preview.png
index db085c67..ffac77ff 100644
Binary files a/assets/github-social-preview.png and b/assets/github-social-preview.png differ
diff --git a/assets/icons/hicolor/128x128/apps/shadow-agent.png b/assets/icons/hicolor/128x128/apps/shadow-agent.png
index f1f3f267..5d009698 100644
Binary files a/assets/icons/hicolor/128x128/apps/shadow-agent.png and b/assets/icons/hicolor/128x128/apps/shadow-agent.png differ
diff --git a/assets/icons/hicolor/16x16/apps/shadow-agent.png b/assets/icons/hicolor/16x16/apps/shadow-agent.png
index e39af2ac..013b9c0c 100644
Binary files a/assets/icons/hicolor/16x16/apps/shadow-agent.png and b/assets/icons/hicolor/16x16/apps/shadow-agent.png differ
diff --git a/assets/icons/hicolor/22x22/apps/shadow-agent.png b/assets/icons/hicolor/22x22/apps/shadow-agent.png
index d2c0c9ef..3937cbda 100644
Binary files a/assets/icons/hicolor/22x22/apps/shadow-agent.png and b/assets/icons/hicolor/22x22/apps/shadow-agent.png differ
diff --git a/assets/icons/hicolor/24x24/apps/shadow-agent.png b/assets/icons/hicolor/24x24/apps/shadow-agent.png
index ce700282..911f01e6 100644
Binary files a/assets/icons/hicolor/24x24/apps/shadow-agent.png and b/assets/icons/hicolor/24x24/apps/shadow-agent.png differ
diff --git a/assets/icons/hicolor/256x256/apps/shadow-agent.png b/assets/icons/hicolor/256x256/apps/shadow-agent.png
index 3724aa38..0140b928 100644
Binary files a/assets/icons/hicolor/256x256/apps/shadow-agent.png and b/assets/icons/hicolor/256x256/apps/shadow-agent.png differ
diff --git a/assets/icons/hicolor/32x32/apps/shadow-agent.png b/assets/icons/hicolor/32x32/apps/shadow-agent.png
index 907a175e..e620074e 100644
Binary files a/assets/icons/hicolor/32x32/apps/shadow-agent.png and b/assets/icons/hicolor/32x32/apps/shadow-agent.png differ
diff --git a/assets/icons/hicolor/48x48/apps/shadow-agent.png b/assets/icons/hicolor/48x48/apps/shadow-agent.png
index 58b16fa0..b37ec399 100644
Binary files a/assets/icons/hicolor/48x48/apps/shadow-agent.png and b/assets/icons/hicolor/48x48/apps/shadow-agent.png differ
diff --git a/assets/icons/hicolor/512x512/apps/shadow-agent.png b/assets/icons/hicolor/512x512/apps/shadow-agent.png
index d013f2fe..d62be9de 100644
Binary files a/assets/icons/hicolor/512x512/apps/shadow-agent.png and b/assets/icons/hicolor/512x512/apps/shadow-agent.png differ
diff --git a/assets/icons/hicolor/64x64/apps/shadow-agent.png b/assets/icons/hicolor/64x64/apps/shadow-agent.png
index a31fea40..6bf08c2f 100644
Binary files a/assets/icons/hicolor/64x64/apps/shadow-agent.png and b/assets/icons/hicolor/64x64/apps/shadow-agent.png differ
diff --git a/assets/icons/hicolor/96x96/apps/shadow-agent.png b/assets/icons/hicolor/96x96/apps/shadow-agent.png
index 3ac047a0..f0037f3c 100644
Binary files a/assets/icons/hicolor/96x96/apps/shadow-agent.png and b/assets/icons/hicolor/96x96/apps/shadow-agent.png differ
diff --git a/assets/icons/hicolor/scalable/apps/shadow-agent.svg b/assets/icons/hicolor/scalable/apps/shadow-agent.svg
index 5b4fb52b..cd36f295 100644
--- a/assets/icons/hicolor/scalable/apps/shadow-agent.svg
+++ b/assets/icons/hicolor/scalable/apps/shadow-agent.svg
@@ -1,96 +1,3 @@
-
+
diff --git a/assets/icons/shadow-agent-128.png b/assets/icons/shadow-agent-128.png
index f1f3f267..5d009698 100644
Binary files a/assets/icons/shadow-agent-128.png and b/assets/icons/shadow-agent-128.png differ
diff --git a/assets/icons/shadow-agent-16.png b/assets/icons/shadow-agent-16.png
index e39af2ac..013b9c0c 100644
Binary files a/assets/icons/shadow-agent-16.png and b/assets/icons/shadow-agent-16.png differ
diff --git a/assets/icons/shadow-agent-22.png b/assets/icons/shadow-agent-22.png
index d2c0c9ef..3937cbda 100644
Binary files a/assets/icons/shadow-agent-22.png and b/assets/icons/shadow-agent-22.png differ
diff --git a/assets/icons/shadow-agent-24.png b/assets/icons/shadow-agent-24.png
index ce700282..911f01e6 100644
Binary files a/assets/icons/shadow-agent-24.png and b/assets/icons/shadow-agent-24.png differ
diff --git a/assets/icons/shadow-agent-256.png b/assets/icons/shadow-agent-256.png
index 3724aa38..0140b928 100644
Binary files a/assets/icons/shadow-agent-256.png and b/assets/icons/shadow-agent-256.png differ
diff --git a/assets/icons/shadow-agent-32.png b/assets/icons/shadow-agent-32.png
index 907a175e..e620074e 100644
Binary files a/assets/icons/shadow-agent-32.png and b/assets/icons/shadow-agent-32.png differ
diff --git a/assets/icons/shadow-agent-48.png b/assets/icons/shadow-agent-48.png
index 58b16fa0..b37ec399 100644
Binary files a/assets/icons/shadow-agent-48.png and b/assets/icons/shadow-agent-48.png differ
diff --git a/assets/icons/shadow-agent-512.png b/assets/icons/shadow-agent-512.png
index d013f2fe..d62be9de 100644
Binary files a/assets/icons/shadow-agent-512.png and b/assets/icons/shadow-agent-512.png differ
diff --git a/assets/icons/shadow-agent-64.png b/assets/icons/shadow-agent-64.png
index a31fea40..6bf08c2f 100644
Binary files a/assets/icons/shadow-agent-64.png and b/assets/icons/shadow-agent-64.png differ
diff --git a/assets/icons/shadow-agent-96.png b/assets/icons/shadow-agent-96.png
index 3ac047a0..f0037f3c 100644
Binary files a/assets/icons/shadow-agent-96.png and b/assets/icons/shadow-agent-96.png differ
diff --git a/assets/icons/shadow-agent.png b/assets/icons/shadow-agent.png
index d013f2fe..d62be9de 100644
Binary files a/assets/icons/shadow-agent.png and b/assets/icons/shadow-agent.png differ
diff --git a/assets/icons/shadow-agent.svg b/assets/icons/shadow-agent.svg
index 5b4fb52b..cd36f295 100644
--- a/assets/icons/shadow-agent.svg
+++ b/assets/icons/shadow-agent.svg
@@ -1,96 +1,3 @@
-
+
diff --git a/assets/shadow-agent.svg b/assets/shadow-agent.svg
index 5b4fb52b..cd36f295 100644
--- a/assets/shadow-agent.svg
+++ b/assets/shadow-agent.svg
@@ -1,96 +1,3 @@
-
+
diff --git a/docs/API_CONTRACT_0.28.md b/docs/API_CONTRACT_0.28.md
index 791aa359..76454054 100644
--- a/docs/API_CONTRACT_0.28.md
+++ b/docs/API_CONTRACT_0.28.md
@@ -3,10 +3,71 @@
The React window talks to the in-process Rust engine through
`invoke("api", {request: {method, path, body}})` (see `ui/src/lib/transport.ts`).
This file is the contract the UI and backend are built against. It started
-with 0.28 and keeps its name; it is current as of 0.30.1 (OpenRouter from
-0.29.0, Antigravity's agent server from 0.30.0). All shapes are JSON. Unknown
+with 0.28 and keeps its name as the app evolves. All shapes are JSON. Unknown
values are `null`, never invented.
+## Previewable diagnostics export
+
+`GET /api/doctor` retains the full local Health display and includes `diagnostic_export: {id, filename, content, mime, captured_at, byte_length}`. `content` is the exact UTF-8 JSON preview. It contains only reviewed check IDs, fixed labels and their original status (`pass`, `warn`, `fail`, `info`, `not_checked`), plus app/runtime metadata, scope, exclusions and an omitted-check count. Project maps, full paths, configured model names, detail/fix text, prompts and raw logs are excluded. Unknown check IDs do not enter the export. The projection accepts at most 96 checks, reports the rest as omitted, and is capped at 256 KiB.
+
+`GET /api/diagnostic-exports/{id}` retrieves the same immutable snapshot for ten minutes, with at most four retained per engine. Expired or unknown IDs fail; Doctor is not rerun. The native `export_diagnostics` command receives only the snapshot ID, then opens a JSON save dialog and writes the retained bytes as a private file. The remote browser retrieves the same snapshot and refuses download if its content differs from the preview. Previewing or closing the preview does not write or upload anything. This is a Doctor status export, not a crash report or a guarantee that all secret patterns are detectable.
+
+## Local editor recovery drafts
+
+The desktop keeps acknowledged unsaved file drafts in its private profile database, separate from the project and its Git index. These routes are refused over remote access. Every request names the canonical selected workspace; a request queued across a project switch fails instead of writing under the new selection.
+
+- `GET /api/workspace/editor-drafts?workspace=` → `{workspace, drafts: [{path, base, draft, base_hash, revision, updated_at}]}`.
+- `PUT /api/workspace/editor-draft?path=` with `{workspace, base, draft, base_hash, expected_revision}` → the saved draft record. `base_hash` is the SHA-256 of `base`; `expected_revision` is `missing` for creation or the 32-character record revision returned by the previous read/write. A different revision refuses the write.
+- `DELETE /api/workspace/editor-draft?path=` with `{workspace, expected_revision}` → `{removed:true}`. A changed revision refuses deletion.
+
+Paths use the workspace's confined writable-path validation. Only dirty UTF-8 text is stored: the original and draft each have the file editor's 4 MB limit, with at most 32 drafts and 32 MB of text per project. The app shows whether its latest recovery copy has finished saving; input still in flight at a crash is not claimed as durable. Actual file saves continue to use `PUT /api/workspace/file` with the disk content hash and existing trust, permission and reservation checks.
+App-owned workspace mutations that can change a Compare snapshot serialize with Compare's project admission: file saves, project instructions/skills and attachments, editor recovery drafts, project-map saves, and Git hunk/stage/commit actions. Git workspaces use both the in-process project mutex and the repository advisory lock, so another ShadowCode process cannot change these bytes while Compare takes or applies a snapshot; standalone folders, where Compare cannot run, use the in-process mutex only. Compare also checks the profile's persisted recovery-draft paths and refuses to snapshot when any draft differs from its saved base, including a draft saved from another window. The refusal names paths only; draft text is not returned by the check.
+Workspace and Git mutations bind to the selected project before waiting for admission. A project switch during that wait rejects the stale request instead of redirecting it to the newly selected folder.
+
+On Unix, native workspace mutations resolve each destination parent through pinned
+directory handles and reject symlink traversal when opening those handles. Atomic
+write conflict hashes and replacements use the same parent capability; move,
+delete, directory creation, mode changes, and empty-directory removal use
+handle-relative operations as well. Existing-leaf directory opens also refuse
+symlinks, and invalid move sources or stale writes are rejected before creating
+missing destination parents. These checks do not provide atomic compare-and-swap
+against a noncooperating external writer: a leaf can still change between a
+content/type check and rename or unlink. Concurrent replacement of a real
+directory, non-Unix behavior and approval-to-native-window qualification remain
+outside this contract's verified scope.
+
+## Explicit checks from task history
+
+The desktop's completed-task summary offers **Run a check…**. The user enters a
+fresh command; historical receipt text is never replayed as executable input.
+`POST /api/jobs/test` receives `{workspace, session_id, command, timeout:300,
+queue:false}` and creates a native command task against current files. No model
+selection or generation request is required. Trust, command permissions and
+approval policy still apply; a shorter configured tool timeout takes precedence.
+A successful exit verifies this user-selected check only, not arbitrary claims
+about the project.
+
+The action preserves the composer draft and earlier task receipts. Workspace,
+conversation and navigation-generation guards prevent a delayed start response
+from attaching to a different conversation. A refresh failure after acceptance
+does not make the accepted command retryable. Check output and the new receipt
+appear in the ordinary task transcript.
+
+`POST /api/jobs/verification-refresh` receives `{job_ids: string[]}` and returns
+`{verifications: {[job_id]: Verification}}`. A request must contain 1–32 unique,
+nonempty IDs of at most 128 bytes, all identifying existing jobs. Invalid or
+unknown IDs reject the whole request. The response uses the same assessment as
+`GET /api/jobs/{id}/verification`, sharing one current fingerprint per workspace
+within that batch. No fingerprint is cached across requests. Unreadable or
+missing workspace content cannot retain a passing verdict; original stored
+receipts remain unchanged. Vendor-owned and nonpassing receipts require no scan.
+
+Visible summaries use a shared five-second refresh cadence in addition to edit
+and engine notifications. Offscreen cards and hidden documents do not poll;
+pending reads are not overlapped by timer ticks. Refreshes are point-in-time
+observations with bounded detection delay, not an atomic filesystem watch or a
+claim that no file can change after assessment.
+
## Picker
`GET /api/picker` → `{ targets: PickerTarget[], local_engine: LocalCatalog, vendors: {[vendorId]: VendorStatus}, generated_at: number }`
@@ -137,7 +198,12 @@ A model row whose usage pool reports the plan limit is `availability:
`authenticate {methodId: "oauth-personal"}`, lets the server open the
browser and relays the sign-in link it prints. It fails with the install
hint when the server isn't installed.
-- `GET /api/accounts/{vendor}/login` → `{ vendor, running, started_at, lines: [{vendor, line, url}], done: {ok, detail, availability}|null }`.
+- `GET /api/accounts/{vendor}/login` → `{ vendor, running, cancellation_requested, started_at, lines: [{vendor, line, url}], done: {ok, detail, availability}|null }`.
+ This read-only progress snapshot retains buffered instructions independently
+ for each provider. `cancellation_requested` is true only while a running
+ operation's cancellation token is set; it becomes false after terminal
+ publication and for absent or newly retried operations. It allows a reopened
+ Accounts page to distinguish a sign-in awaiting the user from one stopping.
- `POST /api/accounts/{vendor}/disconnect {confirm: true}` → `{ ok, ran: string[], output, note, availability, availability_label }`; runs the
official logout command (never touches credential files), then forgets the
vendor's cached status, persisted usage and every conversation's
@@ -484,6 +550,16 @@ imports: [{path, mmproj, name, source}], excluded, llama_binary, context_size }`
in order; file names, word starts and runs rank higher), skipping
`.gitignore`d and hidden entries; an empty `q` lists the shallowest
entries. The listing is cached for 5 s per project.
+- `POST /api/workspace/context-preview {mentions: [{path, kind}]}` →
+ `{items: [{path, kind, included, reason, bytes, total_bytes, from_line,
+ to_line, entries, truncated}], included_bytes, estimated_tokens, truncated}`. The
+ token count is a rough byte-based estimate over the exact bounded attachment
+ prompt text, not a model-tokenizer measurement. This returns no
+ file contents; it resolves the same per-file 64 KiB and total 256 KiB
+ explicit @-attachment bounds as a native task. Folder mentions attach a
+ bounded list of names, not source contents. The preview is a snapshot; the
+ task re-reads selections when execution starts. It does not enumerate
+ provider-owned CLI context or the optional local repository map.
- `POST /api/sessions/{id}/fork {event_id, title?, before: true}` keeps only
the events before the task `event_id` belongs to (its prompt included):
Edit & resend forks there and sends the edited text. Before the first
@@ -1275,3 +1351,52 @@ so editor threads never change the desktop's selected project):
- When no desktop or server is running the agent owns the engine;
`GET /api/runtime` then reports `mode: "acp"` and `persistent: true`, and a
desktop attaches to it as a view.
+
+### Task timing observations (additive, schema version 1)
+
+`Job.timings`, `Job.result.timings`, `agent.completed.payload.timings` and a Compare lane's `timings` carry the same optional timing snapshot. Older records omit it. While a job is live, job lookup observes its current monotonic clock; snapshots saved on admission and foreground responses are partial. Final timings are persisted with the terminal job and event. Restart does not estimate missing durations or reconstruct a monotonic clock from wall-clock timestamps.
+
+`schema_version: 1` identifies the shape. `complete` means the task reached its observed terminal boundary, including failure or cancellation; it does not mean every category is measured or the task passed verification. Numeric durations are seconds. Unobserved durations are null, not invented zeroes.
+
+- `total_seconds`: elapsed from local job acceptance to the finish decision, or observation time while live. Includes queue time, but not the final terminal database commit.
+- `queue_seconds`: acceptance to engine admission; includes workspace/local admission and worker waiting. For a task cancelled before admission, it equals total time.
+- `active_seconds`: admission to the observation/finish boundary; includes preparation, approvals, pauses, tools, provider work and integration cleanup. Null if never admitted.
+- `preparation_seconds`: managed local preparation, including catalog checks and runtime acquisition. `runtime_wait_seconds` and `model_load_seconds` are subsets. Load includes stopping a previous runtime and starting/readiness checks for the new one; it is not isolated GPU weight-transfer time.
+- `model_reused`: true only after successfully acquiring an already-loaded managed model; false after starting a model for this task; null before readiness or for unmanaged routes. A reused model has no model-load interval.
+- `model_requests` / `model_requests_seconds`: count and cumulative duration of foreground native model attempts, including unsuccessful attempts and retries. Includes request preparation inside the model client, transport and decoding, but excludes image hydration, retry backoff, tools and context-compaction requests. No vendor-internal request durations are inferred.
+- `first_text_seconds` / `first_text_request`: request-relative delay to the first nonempty text callback and its one-based attempt number. Buffered JSON responses qualify; tool-only responses do not. This is not time to first generated token or first rendered UI text.
+- `tool_batches_seconds`: cumulative native tool-batch wall time, including approvals, hooks, checkpoints and result handling. Concurrent tools in one batch are not double-counted.
+- `final_checks_seconds`: completion hooks and final evidence refresh, including failed completion retries; for an explicit command/check job, its verification path. This can include approval waiting.
+- `check_process_seconds`: sum of the owned process durations in locally observed configured-check receipts, including failed/cancelled processes that return a result. Excludes approval waiting and file fingerprinting. It overlaps tool/final-check time.
+
+Do not add these overlapping measurements into a total or call model request time pure generation time. Subscription jobs currently expose total/queue/active measurements only.
+
+Each completed native foreground attempt also emits `model.request_timing` with `message_id`, `elapsed_seconds`, optional `first_text_seconds`, `success` and `cancelled`. The message ID associates timing with its response/retry in the existing transcript. `success` describes transport/response completion, not overall task acceptance. Verification receipts add optional `process_seconds`, sourced from the native owned-process result; older receipts remain readable.
+
+## Local runtime identity receipts
+
+`local.runtime_ready` includes optional `runtime.provenance` schema 1 and a task `request_policy`. The receipt is recorded after preparation and the effort override, remains on that task, and is cleared from a Compare lane when a new turn has no receipt yet. Old receipts are not reconstructed from current machine settings.
+
+`identity_kind: filesystem_metadata` distinguishes model/runtime/projector canonical paths, byte counts and nanosecond modification times, plus Unix device/inode/change times. Device and inode use decimal strings to retain exact values in JavaScript. These are snapshots, not full weight hashes or an immutability guarantee. Runtime identity covers the resolved executable/launcher, not all dependent libraries.
+
+GGUF provenance includes architecture, format version, numeric file type/quantization version, GGML tensor-type counts, exact parsed-header SHA-256 and byte count, and exact embedded-template SHA-256/byte count. The header hash excludes weights; the template hash covers the full string even when the parser retains only a display prefix. Runtime provenance includes its reported version/commit, reported template identity and an allowlist of reported sampling defaults. Missing observations stay absent.
+
+Context records requested/reported tokens. GPU fields distinguish requested mode, actual launch mode and the backend detected by the runtime probe; these do not measure layer offload. CPU fallback remains explicitly recorded. Request policy states use of runtime sampling defaults, no sampling overrides, per-request context-budgeted response limits, and the actual template thinking override when present. This does not establish identical sampling across models.
+
+Runtime reuse requires equal frozen files and launch settings. Changes during preparation/wait/loading refuse the lease. An incompatible same-ID request while the current model is leased fails promptly to prevent parent/child deadlock. Different models retain cancellable waiting. Blocking metadata probes may finish in the background after cancellation, but cannot launch an inference server afterward.
+
+## Subscription run resource limits
+
+`cli_agents.max_run_time_sec` defaults to 7200 and accepts integers from 1 through 86400. It limits one spawned run across its turns/steering using monotonic active time. Explicit approval waits and actual parked steering waits are excluded; ongoing protocol output cannot extend it. The independent idle timeout remains. Input writes obey the smaller of the existing write deadline and remaining active time.
+
+One run accepts at most 64 MiB of decoded protocol line content plus framing and 250,000 frames before adapter processing, and at most 8 MiB of accumulated assistant text. Exceeding a limit fails the task explicitly, retains earlier transcript/file changes and stops the owned process; it never reports a truncated successful result. Deadline, aggregate, framing and malformed-limit errors cannot trigger Codex exec fallback, including before readiness.
+
+Stderr retains at most 1000 warnings plus an omission notice, continues draining, and continues detecting sign-in failures. These are transport/retention bounds, not limits on total application RSS/history. Discovery/help helper output and complete protocol-ordering conformance remain separate work.
+
+## Native completion follow-through and steering
+
+For native `code` tasks with write-capable permissions, a no-tool reply that makes a concrete first-person workspace/command promise receives a bounded continuation request. `completion.retry` carries `reason: unperformed_action`, one-based `attempt`, and configured `max_attempts`. Command and edit promises share a cumulative task-wide budget of `agent.max_fix_retries`; intervening tool calls do not reset it. Zero refuses the first detected promise. Exhaustion fails the task and retains its observed command/check receipts. The transcript shows an explanatory continuation note.
+
+Detection is a conservative English heuristic, not a task-completeness or truthfulness oracle. Quoted/fenced examples, conditional offers, deferrals, negative commands and explanatory prose are excluded where recognized. Plan/review and read-only tasks are outside this action guard. Existing task, capability, permission, approval, step and token limits still apply. A continuation request executes no tool itself; the model must issue a fresh permitted call or provide an honest final answer. Unsupported past-tense success claims still need verification evidence and may remain unverified.
+
+Native steering is checked before final-answer acceptance, after completion hooks/freshness assessment, and before each remaining tool batch. A quick pause/steer/resume retains the instruction even if the worker never parked. A newer instruction supersedes pending tool proposals: their paired tool messages have `success: false`, `output.execution_status: not_run`, and `output.reason: superseded_by_steering`, inserted before the steering note. They produce no execution receipt or tool-start event. Completed calls remain recorded and are not replayed. The model receives the updated context on its next turn within the existing task limits. Failure evidence from completion hooks precedes a newer steering note; new user input can request reconsideration even when automatic hook retries are exhausted. A later completion candidate still runs configured completion checks.
diff --git a/docs/COMPARE.md b/docs/COMPARE.md
index 24dd495d..58d00383 100644
--- a/docs/COMPARE.md
+++ b/docs/COMPARE.md
@@ -1,12 +1,19 @@
# Compare
-Compare sends one task to 2 or 3 models at once. Each model works in its own
+Open **More › Compare** in the composer to send one task to 2 or 3 models.
+Each model works in its own
lane, which is a managed Git worktree on a `shadowcode/` branch. When the
lanes finish, you keep one result. Its changes are applied to your project's
working tree, and every lane worktree and its branch is removed.
## Starting state
+In 0.33.1, Compare refuses to start while app-owned editor drafts differ from
+their saved base. Save or discard the listed drafts first. The native snapshot
+also checks persisted recovery drafts and coordinates with short workspace
+writes from cooperating ShadowCode processes. Other editors' unsaved buffers
+and arbitrary terminal commands are not covered by that cooperative lock.
+
Every lane starts from the same commit: HEAD plus the work you have not
committed yet. That covers staged and unstaged changes to tracked files, and
untracked files that `.gitignore` does not exclude. Ignored files, such as
@@ -29,12 +36,11 @@ lanes.
Each lane is a normal ShadowCode job with its own conversation. The job uses
the chosen model, the same permission mode and permission level as the
project, and the chosen mode: `code`, `plan` (read-only) or `ask` (read-only
-review). Lanes run at the same time. While lanes exist, their worktrees are
+review). Cloud lanes can run concurrently; managed local lanes run sequentially. While lanes exist, their worktrees are
trusted for you. Keep and discard remove that trust again.
-- **One local model at most.** Only one local model fits in GPU memory at a
- time, so a comparison can include at most one `local:gguf:*` model. Pair it
- with cloud or subscription models.
+- **Local models run sequentially.** Choose two or three installed compatible GGUF models. Managed jobs share one runtime and run in submission order; queued lanes remain cancellable. Comparison runs refuse automatic GPU-to-CPU fallback, including reuse of a runtime that automatically fell back. Choose CPU settings deliberately or retry the GPU configuration. Runtime preparation time includes readiness/wait work and is not token-generation latency.
+
- **Offline mode** accepts only models that run on this computer.
- **Cost.** Every lane is a full task. Subscription lanes use your plan's
allowance, OpenRouter and other API-key lanes are billed per token, and a
diff --git a/docs/FLAGSHIP_ACCEPTANCE_SPEC.md b/docs/FLAGSHIP_ACCEPTANCE_SPEC.md
new file mode 100644
index 00000000..23c2364b
--- /dev/null
+++ b/docs/FLAGSHIP_ACCEPTANCE_SPEC.md
@@ -0,0 +1,289 @@
+
+
+# ShadowCode — Engineering Upgrade and Acceptance Specification
+
+Repository: Shadowfetchapps/ShadowCode
+Review date: September 26, 2026
+Source reference examined: `e371baaa4c690527173a322f7e0e1cfae4db329a`
+
+## Review scope and evidence qualification
+
+This specification follows a source and CI review, not a local execution of the native application or a live authentication test of paid providers. GitHub returned inconsistent snapshots during retrieval. Recheck every observation against the actual checkout and current CI before implementing a fix. Do not delete working functionality because an earlier report describes a different revision. No dependency-install failure or particular CLI timeout root cause should be assumed.
+
+The most recently retrieved job summary shows UI and Rust checks passing and the headless native CLI step failing; subsequent integration checks were skipped. The exact cause requires reproduction. The Compare excerpts were consistent across consecutive reads with blob SHA `58e342beb5263e31255af60b18dc1c858491c21a`. The final release-workflow excerpt had blob SHA `9079d8ea66390a64c7c24d469b251735408f487d`.
+
+The source-level observations below are narrow, not claims of observed data loss or a demonstrated sandbox escape:
+
+- In `native/core/src/compare.rs`, `keep` applies the selected checkout to the user's working tree, updates an in-memory record, attempts lane cleanup, then persists the record. `discard` also performs cleanup before its final persistence. The filesystem/database transition deserves explicit crash-recovery tests.
+- `keep` records an error returned by `stop_lanes` as a note and continues toward cleanup. Lower-level disposal might have additional protections; inspect it. Establish and test the invariant at every destructive boundary rather than assuming that cancellation was acknowledged.
+- Compare's `get`, `list`, `keep`, `cancel`, and other handlers acquire a shared `LOCK`; handlers can await Git or lane shutdown while holding it. A cancellation wait is bounded to 60 seconds per active lane. Measure cross-workspace blocking before changing synchronization.
+- `resolve_models` requires two or three distinct models, but rejects more than one ID beginning `local:gguf:`. This prevents comparing two downloaded GGUF models, even though they could run sequentially. It does not establish that every other offline provider combination is impossible.
+- `apply_checkout` already uses `git apply --check`, generates a binary-capable patch, and applies to the working tree rather than the user's index. Preserve those safeguards and semantics.
+- The release workflow already runs source, behavior, desktop, runtime, packaged-application, checksum, and installer checks before its final publication step. Do not replace that with weaker CI. Its final step creates a public release and then uploads artifacts with `--clobber`; strengthen atomic publication and immutable release assets.
+
+All proposed performance limits below are acceptance targets, not measurements of current performance.
+
+---
+
+# COPY-READY ENGINEERING PROMPT
+
+You are the lead engineer responsible for upgrading ShadowCode into a dependable, polished, standalone native coding application.
+
+Work in the existing Shadowfetchapps/ShadowCode repository. This is a production hardening and carefully scoped feature-extension task, not permission to rebuild the product.
+
+The desired experience is simple: open a project, select a supported subscription-backed coding runtime or a compatible local model, give it a task, see what is happening, review the actual changes and test evidence, and keep or discard the result without risking unrelated work. The user should not have to operate a separate server to use the application's managed local runtime.
+
+## Non-negotiable constraints
+
+1. Preserve the existing native architecture, editor, UI framework, provider adapters, persisted sessions, managed runtimes, worktrees, security boundaries, CLI/TUI, MCP interfaces, and useful tests. Read the actual manifests before naming their locations or proposing replacements.
+2. Do not add a required hosted backend, cloud database, ShadowCode account, marketplace, orchestration framework, or externally managed inference service. Optional external integrations must remain optional.
+3. Keep a single understandable provider/model picker. Do not turn every capability into another permanent dashboard or navigation destination.
+4. A subscription runtime, a direct paid API, and a local model are different execution and billing routes. Never silently substitute one for another. Never fabricate quota, cost, model capability, test success, or sandbox enforcement.
+5. Preserve existing working Antigravity integration and other provider-specific startup/authentication behavior. Investigate actual capability and protocol support instead of treating a functioning integration as a placeholder.
+6. Do not erase or overwrite unrelated source changes, editor buffers, staged content, user branches, credentials, or histories. Do not use destructive Git reset/clean operations as an automatic repair.
+7. Do not weaken CI, remove failing tests, increase timeouts blindly, introduce fake success states, or use mocks as proof that live authentication works.
+8. Work on a dedicated branch with focused commits. Do not force-push, publish a release, change billing, or initiate paid/live provider requests without explicit authorization. Routine implementation decisions do not need repeated clarification.
+9. Where a requested feature already exists, strengthen its acceptance tests and integration rather than creating a duplicate implementation.
+
+## Phase 0 — Establish the real baseline
+
+Inspect the current commit, working-tree status, package manifests, toolchain pins, lockfiles, native/UI boundaries, IPC contracts, session store, compare/worktree implementation, provider adapters, local inference manager, security documentation, and all relevant workflows.
+
+Read `native/core/src/compare.rs`, `.github/workflows/native.yml`, `.github/workflows/release.yml`, `SECURITY.md`, and the implementation report where those files exist. Locate the actual application and test entry points from the current tree. Specifically inspect `scripts/test-native-cli.mjs` if present; do not invent an obsolete CLI invocation.
+
+Create or extend an audit document containing: observed behavior, source location, reproduction, severity, proposed correction, regression test, and final result. Label entries CONFIRMED, RISK TO TEST, PROPOSED UPGRADE, ALREADY IMPLEMENTED, or NOT VERIFIED.
+
+Reproduce existing checks before edits where feasible. Record failures separately from environment limitations. Do not attribute failures to the lockfile or completion-event transport without evidence. Check the latest failing run for the exact checkout being tested.
+
+## Phase 1 — Make native task completion and cancellation reliable
+
+Reproduce the headless native CLI failure. Inspect argument parsing, profile/config loading, deterministic test provider selection, engine dispatch, event subscription, session/run identifiers, terminal state persistence, error propagation, and process exit.
+
+The application must provide these invariants:
+
+- Every accepted task reaches exactly one logical terminal state: completed, failed, or cancelled. Terminal events may be redelivered, but consumers must deduplicate them by stable identity.
+- Early completion cannot be lost because a subscriber was installed late. Choose buffering, durable state lookup, subscribe-before-dispatch, or an equivalent remedy only after establishing the actual failure mechanism.
+- A cancelled task cannot later become successful when buffered output arrives.
+- Events from a different session or attempt cannot complete the current task.
+- The CLI returns a meaningful exit code, flushes its final report, and terminates without leaked application-owned subprocesses.
+- A runtime deadline produces a persisted failure and bounded cleanup, not an indefinitely spinning UI.
+
+Make the basic smoke test hermetic: no real subscription, credentials, model download, internet access, or desktop display. Separate compilation from execution deadlines. Preserve the real integration suites and ensure formerly skipped checks actually execute successfully before claiming the pipeline is healthy.
+
+Add deterministic coverage for rapid completion, delayed completion, worker exception, malformed output, cancellation before startup, cancellation during streaming, SIGINT, repeated tasks, parallel sessions, and child processes that ignore the first termination signal.
+
+## Phase 2 — Make Compare keep/discard crash-recoverable
+
+Inspect `keep`, `discard`, `apply_checkout`, `commit_checkout`, `remove_lanes`, persistence, and worktree disposal together. Keep the existing source-workspace reservation and Git safety checks.
+
+Introduce an explicit durable operation journal using the existing storage architecture. A database transaction alone does not make Git/filesystem operations atomic. Record intent before external mutations, record the applied outcome before destructive cleanup, and reconcile uncertain transitions on restart.
+
+The operation record should identify the transaction, comparison, selected lane, project, expected branch/HEAD, relevant index state, relevant working-tree fingerprints, lane result commit or equivalent immutable result, planned changed paths, recoverable preimages, phase, and cleanup progress. Use redaction and bounded retention rather than copying secrets into diagnostics.
+
+Suggested internal phases are prepared, applying, applied, cleanup_pending, complete, and needs_review. Adapt names to existing public schemas and support migration; do not break existing sessions to force these exact labels.
+
+Required sequence:
+
+1. Acquire the appropriate project mutation reservation and validate the current record revision.
+2. Refresh the selected lane's actual files and proof state. Reject or re-review a changed result instead of applying stale preview data.
+3. Prepare a durable recovery record and recoverable material before touching the source working tree.
+4. Perform existing preflight validation and apply the selected result while preserving the user's index and unrelated modifications.
+5. Persist the applied outcome durably before removing recoverable lane material.
+6. Confirm other lane jobs and their managed descendants have stopped before deleting their worktrees.
+7. Perform idempotent cleanup. Persist each useful checkpoint and report cleanup_pending honestly when anything remains.
+8. Mark the operation complete only after its logical result is durable; cleanup failure must not cause the result to be applied twice.
+
+If the app crashes after filesystem application but before the applied record is committed, recovery must inspect recorded preimages/postimages and actual project state. Do not blindly reapply, blindly revert, or silently declare success. Ambiguous user-edited state requires a clear recovery review.
+
+Preserve the current working-tree-only application behavior. Do not start committing or staging on the user's branch as an incidental side effect. Preserve binary changes, deletes, renames, executable bits, path quoting, and unrelated staged/unstaged changes. Handle dirty editor buffers separately: an unsaved buffer is not the same as the file on disk.
+
+Inject failures before and after every durable state transition, patch application, and worktree cleanup. Cover disk-full/write refusal, concurrent project edits, process crash, missing worktree, restart, and duplicate Keep clicks. Confirm scoreboard updates cannot be counted twice.
+
+## Phase 3 — Correct cancellation and synchronization boundaries
+
+Treat cancellation request, cancellation acknowledgement, and process exit as separate states.
+
+In `keep`, do not merely append a stop error and continue destructive cleanup of that lane. Inspect lower-level disposal protections, add caller-level checks, and test both. Keep the already-applied winner recoverable while unresolved lanes remain explicitly pending cleanup.
+
+Replace unnecessary global Compare serialization only after proving the lock scope. Prefer project-scoped mutation coordination and comparison-scoped state synchronization. Preserve cross-process ownership checks and revision conflicts; an in-process mutex is not cross-process protection.
+
+Avoid holding the global metadata lock while awaiting a provider, Git subprocess, or long shutdown. Use a documented lock order. Preserve one writer per project and prevent concurrent Keep/Discard from racing. Cancellation and status inspection for project B must remain responsive when project A has a slow shutdown.
+
+Track process trees, not just the first child PID. Use platform-appropriate termination and a bounded escalation policy. Never kill an unrelated process or assume a recycled PID is still owned by the application.
+
+## Phase 4 — Add sequential local GGUF comparison
+
+Remove the blanket one-GGUF restriction only after adding explicit scheduling. The new feature is: compare two or three installed compatible GGUF models on the same task and project snapshot with the network disabled.
+
+Use one application-managed local inference slot by default. Queue Model B while Model A is loading/running; release or reuse resources safely before loading the next model. Do not load multiple large models simultaneously and hope memory is sufficient. Other application jobs must share the same local-resource admission control.
+
+Freeze equivalent task instructions, project snapshot, tool permissions, web policy, and verification commands. Record actual model identifier, file identity, quantization, template, context configuration, and relevant sampling settings. Do not pretend different tokenizers have identical token budgets or that hardware-dependent estimates are guarantees.
+
+Show queued, loading, generating, running_tool, verifying, stopping, and ready states. Report model-load time separately from time to first generated token, generation time, verification time, and total wall time.
+
+Cancellation must remove queued work and stop active work without accidentally loading the next model. A failed model must not destroy other lane results. An out-of-memory error must leave the UI responsive and the project intact. Offer an explicit lower-context or CPU fallback decision instead of silently changing the benchmark conditions.
+
+Prove the all-local mode makes no external network requests. Do not auto-download missing models while claiming a fully offline run. Preserve per-lane evidence and let the user choose; any recommended winner must explain its evidence and limitations.
+
+## Phase 5 — Upgrade verification from prose to typed evidence
+
+Extend the existing task-proof implementation instead of replacing it with another subsystem.
+
+A verification receipt should include task ID, attempt ID, tool invocation ID, workspace identity and relevant content fingerprint, verification kind, redacted command/arguments, working directory, start/end timestamps, exit code when available, termination reason, output references, and evidence provenance. Include parser/version details when extracting structured test counts.
+
+Use explicit states: not_run, running, passed, failed, cancelled, skipped, and stale. Add timeout as a termination reason. Execution completion and verification success are independent fields.
+
+A command name that resembles a test command is not by itself proof that tests ran. A model saying “all tests pass” is not equivalent to a recorded process result. Distinguish locally observed evidence, provider-reported evidence, and unsupported/unverified claims.
+
+Invalidate results when relevant source/configuration changes after verification, including uncommitted changes. Git HEAD alone is insufficient. Avoid hashing huge generated directories on every token; use the existing file-change/indexing architecture with a conservative final pre-acceptance check.
+
+Keep test-scope language precise. “Configured checks passed” does not mean the entire application is correct. A no-check task should display “Finished — verification not run,” not a green verified badge. Cancelled or skipped required checks must never count as passed.
+
+Make each result clickable to its exact command, output, files, and attempt. Re-running verification should not require re-running a paid generation task.
+
+## Phase 6 — Harden existing provider adapters
+
+Keep supported vendor-native session, authentication, approval, cancellation, and usage mechanisms. Read the installed provider version and current official protocol documentation. Do not assume one protocol or identical capabilities across all providers.
+
+Build a common adapter conformance suite covering startup, readiness, capability discovery, streaming, tool events, permission prompts, cancellation, session continuation, expired authentication, rate limits, process death, malformed protocol data, and shutdown.
+
+Track protocol version, detected capabilities, last check time, and supported/unsupported operations. Transport stdout must remain parseable; diagnostics must not corrupt protocol messages. Bound message size, request queues, and retained output.
+
+Expose support honestly for image input, tool calling, web access, model selection, continuation, and quota. Unsupported image attachments must be rejected with an explanation rather than silently discarded. Do not route them through a different paid/cloud provider without explicit consent.
+
+Keep subscription usage distinct from pay-per-token API estimates. Display provider-reported limits with their observation time and reset information when actually available. Use “Provider does not expose remaining usage” or “Usage unavailable” when necessary. Never derive remaining subscription quota from an invented token allowance.
+
+Use redacted fixtures for normal CI. Real authentication and paid-turn tests are opt-in and reported separately. A fixture passing must not be labelled a successful live provider session.
+
+## Phase 7 — Local runtime, context, and security UX
+
+Provide one in-app diagnostic path for model file validity, runtime identity, backend actually in use, driver/backend compatibility, measured allocation failures, context configuration, available memory, and owned process state. Reuse an existing diagnostics screen if present.
+
+Treat memory-fit estimates as estimates. Probe safely and report the backend actually running. A GPU-capable build is not proof that inference is executing on the GPU. Handle ports in use, runtime crash, partial model download, failed integrity checks, and interrupted startup without leaving stale running badges.
+
+For managed runtimes, bind internal services to the intended local interface, protect management endpoints, and clean up application-owned resources on quit. Do not treat every local process as trusted merely because it is on loopback. Preserve existing credential isolation.
+
+Add an inspectable context panel: included files/ranges, inclusion reason, token estimate, truncation/compaction notices, and excluded sensitive material. Respect repository/application ignore rules. Test rename/delete/branch-switch invalidation. Keep local search/indexing local unless the user explicitly chooses a remote service; do not add a mandatory vector database.
+
+Show the enforcement actually applied to each operation: enforced sandbox, best-effort sandbox, unsandboxed trusted runtime, or remote execution. Do not imply ShadowCode's local tool sandbox contains a vendor runtime's independent tools.
+
+Where strict enforcement is selected, fail closed when the required boundary is unavailable. Scope approval to the actual operation, project, paths, working directory, destination, and relevant parameters; changes invalidate stale approval. Background processes, hooks, MCP tools, downloads, and remote tools need explicit policy and truthful labels, not a decorative shield.
+
+Test secret redaction in tool receipts, logs, prompts, exported diagnostics, and crash reports. Treat repository text and tool output as untrusted instructions rather than permission grants. Never export credentials by default.
+
+## Phase 8 — Simplify the daily interface and improve performance
+
+Keep the main experience focused on project/files, editor, and agent conversation. Put changes, verification evidence, and terminal behind useful contextual tabs or panels. Do not make graphs, agent org charts, cost charts, or settings dominate routine work.
+
+The primary controls should expose the selected model plus execution/billing route, task mode, real web state, actual permission policy, and Run/Stop. Show a compact meaningful task state rather than an indefinite spinner. Make queued, loading, waiting_for_permission, generating, executing, verifying, stopping, failed, and complete distinguishable.
+
+Protect unsaved buffers when agent edits arrive. Show a conflict/merge choice instead of silently overwriting. Review hunks must be tied to the current revision and revalidated before application.
+
+Use incremental streamed events with session/run/sequence identity and bounded reducers. Avoid repeatedly sending or reparsing the whole transcript on every token. Support resynchronization after event gaps. Virtualize long histories and large diffs; load old output on demand.
+
+Performance acceptance targets, measured on documented hardware and fixture sizes:
+
+- Input response remains below 100 ms at the 95th percentile during a representative long streamed task.
+- Coalesce text updates to roughly 20 visible updates per second while allowing urgent cancellation/permission state through immediately.
+- A 10,000-message history loads in pages and does not require every message to remain mounted in the DOM.
+- Repeated completed/cancelled runs do not produce unbounded memory, file-descriptor, process, or event-listener growth.
+- Unrelated project status and cancellation remain responsive during slow Compare shutdown in another project.
+
+Report baseline and after measurements; these are targets, not claims about current performance. Test keyboard-only operation, focus restoration, copy/paste, readable errors, resize, high-DPI scaling, and light/dark themes. Include actual Linux Wayland acceptance, not just headless X11 screenshots. Add a redacted diagnostic export the user can preview before sharing.
+
+## Phase 9 — Strengthen release publication without discarding existing gates
+
+Preserve the current source, CLI, stress, terminal, MCP, native-window, runtime, packaging, checksum, and installer tests. Reconcile package-manager and toolchain configuration against the current checkout; do not create duplicate lockfiles or switch managers as an unrelated cleanup.
+
+Build from an immutable commit. Record version, commit, target architecture, dependency/runtime source identities, checksums, and verification result in the release manifest. Validate actual package contents, not only development builds.
+
+Stage release assets on a draft. Verify that the full required asset set and checksums are present before publication. Do not overwrite already-published versioned artifacts using --clobber as normal release practice. A failed upload must leave a draft or an explicitly failed attempt, not a partially complete advertised release. Make retry idempotent and fail on content mismatch under the same version.
+
+Audit signing and update support actually present in the repository. Where automatic updates are shipped, require authenticated update metadata and signature verification; do not confuse an unsigned checksum with publisher authentication. Preserve user configuration and prior valid runtime until replacement is validated. Test interruption, corrupted download, failed first launch, and rollback without destructive database downgrade.
+
+Publish support claims only for platforms actually built and exercised. A Windows/macOS compile result is not equivalent to hands-on acceptance. Keep Linux primary; do not claim full platform parity based on mock tests.
+
+## Required regression matrix
+
+Implement these through existing test frameworks where possible. Each item must have a test, an existing-test reference, or an explicit not-verified reason.
+
+| ID | Scenario | Required result |
+|---|---|---|
+| RUN-01 | Very fast hermetic task | Terminal state observed; CLI exits successfully |
+| RUN-02 | Engine throws before streaming | Failure propagated; no permanent spinner |
+| RUN-03 | Cancel before startup | Task never executes; cancelled state persists |
+| RUN-04 | Cancel during output | No late event changes state to success |
+| RUN-05 | Two sessions and duplicate events | No cross-session completion; duplicates deduplicated |
+| RUN-06 | Owned child ignores graceful stop | Bounded escalation; unrelated processes untouched |
+| CMP-01 | Crash before project mutation | Project unchanged; operation recoverable |
+| CMP-02 | Crash after apply before applied receipt | Recovery reconciles actual content; no blind reapply |
+| CMP-03 | Crash after receipt before cleanup | Applied result preserved; cleanup retries safely |
+| CMP-04 | Failure deleting one lane | Remaining lane retained and reported pending |
+| CMP-05 | Other lane refuses to stop | Its worktree is not destructively removed |
+| CMP-06 | Duplicate Keep or Keep/Discard race | One logical result; no double score or mutation |
+| CMP-07 | Project edited after preview | Revalidate/review conflict; no stale silent overwrite |
+| CMP-08 | Staged/unstaged changes and unsaved buffer | Index and unrelated work preserved |
+| CMP-09 | Binary, rename, delete, executable bit | Result faithful; review and recovery remain usable |
+| CMP-10 | Separate app processes target one project | Ownership/revision conflicts stop competing writes |
+| CMP-11 | Project A shutdown stalls | Project B status/cancellation remain responsive |
+| LOC-01 | Two installed GGUF models offline | Sequential runs complete without network access |
+| LOC-02 | Three-model queue cancelled mid-run | Pending models do not start |
+| LOC-03 | Local model exceeds safe allocation | Clear error; UI survives; project unchanged |
+| LOC-04 | Runtime crashes or port is occupied | Honest state and recoverable restart path |
+| LOC-05 | One model fails in Compare | Other lane results and verification stay intact |
+| PRF-01 | Model claims tests passed without execution | UI does not mark verified |
+| PRF-02 | Configured checks pass, then source changes | Receipt becomes stale |
+| PRF-03 | No checks configured | Completed but verification not run |
+| PRF-04 | Required check cancelled/skipped | Overall verification not passed |
+| PRF-05 | Provider self-reports an external result | Provenance differs from locally observed evidence |
+| PRV-01 | Expired auth or unavailable quota | Clear status; no fabricated usage or fallback billing |
+| PRV-02 | Malformed/out-of-order protocol data | Bounded failure/recovery; no mixed sessions |
+| PRV-03 | Unsupported image/tool capability | Explicit rejection or supported alternative with consent |
+| PRV-04 | Vendor runtime updates capabilities | Capability record refreshes; stale affordance removed |
+| SEC-01 | Strict sandbox unavailable | Operation refused, not silently downgraded |
+| SEC-02 | Approval parameters change | Old approval cannot authorize changed operation |
+| SEC-03 | Secret-containing logs/diagnostics | Redaction verified; preview before export |
+| SEC-04 | Repository text requests broader permissions | Text cannot grant permissions |
+| SEC-05 | Symlink/path/remote destination changes | Actual boundary policy rechecked |
+| UI-01 | Long transcript plus active stream | Input latency measured and bounded |
+| UI-02 | Event gap, duplicate event, reconnect | Resynchronize without duplicate output/tool effects |
+| UI-03 | 100 sequential fixture tasks | No unexplained resource-growth trend |
+| UI-04 | Keyboard and Wayland native session | Focus, copy/paste, resize, cancellation usable |
+| REL-01 | Asset upload fails | No newly advertised incomplete public release |
+| REL-02 | Retry same version with different binary | Refused; published version remains immutable |
+| REL-03 | Checksum/signature mismatch | Install/update rejected |
+| REL-04 | Interrupted replacement or failed first launch | Last valid app/config recoverable |
+| REL-05 | Fresh packaged install without dev tools | Intended standalone functions actually work |
+| REL-06 | Required integration test is skipped | Release cannot be labelled fully verified |
+
+## Final deliverables and completion rule
+
+Deliver the code, migrations, regression tests, updated documentation, and measured before/after performance evidence. Produce a final report with exact commit, changed files, defects fixed, features strengthened, commands executed, test counts/results, environment limitations, live-provider status, packaged-application status, and remaining risks.
+
+Separate implemented, tested in fixtures, tested in the native application, and tested against a live provider. Include recovery demonstrations for Compare and a fully offline two-GGUF comparison demonstration when the required models/hardware are available; otherwise identify the precise untested dependency without inventing evidence.
+
+Complete each phase's implementation and tests before declaring it done. Do not substitute a collection of TODOs or a polished summary for working code. Do not claim perfection or universal safety. The intended standard is a smaller-feeling application whose execution, review, recovery, and release behavior are supported by repeatable evidence.
+
+---
+
+## Source manifest for rechecking the review
+
+These are review references, not instructions to trust an old snapshot over the current checkout.
+
+```text
+https://github.com/Shadowfetchapps/ShadowCode/blob/e371baaa4c690527173a322f7e0e1cfae4db329a/native/core/src/compare.rs
+ resolve_models / remove_lanes / stop_lanes: retrieved source lines 640–815
+ get / list / commit_checkout / apply_checkout: retrieved source lines 900–1130
+ keep / discard / cancel / concurrency tests: retrieved source lines 1131–1390
+
+https://github.com/Shadowfetchapps/ShadowCode/blob/e371baaa4c690527173a322f7e0e1cfae4db329a/.github/workflows/release.yml
+ checked native build and final publication sequence
+
+https://github.com/Shadowfetchapps/ShadowCode/actions/runs/36210288819
+ most recent retrieved summary: UI/Rust passes; headless CLI fails; later checks skipped
+
+https://developers.openai.com/codex/app-server
+https://code.claude.com/docs/en/cli-reference
+https://v2.tauri.app/plugin/updater/
+ official integration/update references; verify installed/current versions
+```
diff --git a/docs/FLAGSHIP_IMPLEMENTATION.md b/docs/FLAGSHIP_IMPLEMENTATION.md
new file mode 100644
index 00000000..4bb58667
--- /dev/null
+++ b/docs/FLAGSHIP_IMPLEMENTATION.md
@@ -0,0 +1,882 @@
+# Flagship implementation ledger
+
+Goal: a dependable, easy-to-use native workspace combining supported vendor login/session routes and compatible local models, with trustworthy execution, review, recovery, and releases. Popularity is an aspiration, not an engineering acceptance result.
+
+Baseline: `e371baaa4c690527173a322f7e0e1cfae4db329a`. Implementation branch: `flagship/reliability-foundation`. Preserve the existing architecture and provider integrations. The complete [acceptance specification](FLAGSHIP_ACCEPTANCE_SPEC.md) remains in scope; this ledger is not a narrowed definition of completion.
+
+Current release checkpoint (September 28, 2026): signed **0.33.1 is published and installed**, with retained data and successful installed-window checks. See [the final release and installation evidence](#signed-0331-publication-and-installed-app-verification). Earlier pending statements below describe their historical checkpoints. Broader physical desktop, GPU endurance and live provider lifecycle acceptance remain open.
+
+## Implemented increments
+
+- REMOTE-01 — remote background commands respect the terminal switch. Commit `d488fd2`. Real HTTP regression trusts the project, proves denied requests register no process and write no marker, then proves enabling the switch permits execution. All nine remote integration tests and seven remote policy unit tests pass. Installed release is unchanged; the new test exercises source-built core services.
+- CLI-MCP-01 — distinguish enabled discovery from approved invocation. Commit `0286d36`. Smoke verifies initialize/list, no tools/call unattended or while approval is pending, exactly one approved call with exact arguments, and process cleanup. All 21 CLI scenario groups pass against the baseline debug executable (32 scripted model requests). This is test correction, not a claim of live provider authentication.
+- CONTEXT-01 — remove count-triggered compaction and the 20-byte keep-list truncation. Boundary checks cover 20, 69, 70, 71, and 200 messages. All 16 autonomy integration tests and ten model-quality tests pass, including real token-pressure fixtures, retained constraints, and summary fallback. Optional request excerpts are reduced when the summary itself exceeds the context budget. This is source-level verification, not an installed AppImage update.
+
+- PRF foundation — versioned native receipts now distinguish commands from configured checks and bind passing evidence to content fingerprints. Engine completion, explicit check tasks, required goal milestones, Compare counts, and UI use the distinction. Source tests cover the original false-positive, stale post-check edits, same-check recovery, and unrelated failure retention. [Semantics and remaining integration](VERIFICATION_RECEIPTS.md). Historical freshness now has a follow-up implementation described below; continuous invalidation remains open.
+
+Verification checkpoint validation: broad source run passed 325 native tests (two pre-existing ignored), including engine, goals, Compare, service commands, and core unit tests. The final execution-failure addition passed all four receipt tests and eight goal tests. UI: 309 tests and production build passed. Core all-target Clippy with warnings denied and native desktop build passed on the final source. Native CLI passed all 21 scenario groups. The final rebuilt executable reproduced `printf test` with `verified=false`, `status=not_run`, and `unverified_claim=true`. All provider responses were local fixtures; no paid/live authentication or release package was exercised.
+
+## Historical freshness increment
+
+Historical verification is reassessed without mutating original receipts. Visible UI summaries refresh on mount/focus/manual request and suppress prior passing status while checking or unavailable. Compare checks lane content before acceptance, requires explicit review of stale checks, and reserves the selected lane against new application-owned tasks. Explicit check tasks now select their session using the existing selection-generation guard so approval controls target the right task.
+
+Recorded validation: six Compare and fourteen service tests pass; 312 UI tests and production build pass. These exercise fixture/native service behavior, not live provider authentication or packaged Wayland acceptance. Recovery journaling, cross-process exclusion, unsaved buffers, continuous freshness and efficient shared indexing remain unfinished.
+
+## Compare outcome and cleanup boundary
+
+Keep now persists the applied result and scoreboard before removing lanes. Discard persists its logical outcome before disposal. An additive `cleanup_pending` field distinguishes a durable result from remaining cleanup. A stop failure retains all remaining lanes; each disposal also checks task state and reserves an existing lane against application-owned work. The UI reports retained copies and supports cleanup retry.
+
+A native regression holds a lane reservation during Keep, restarts the service with cleanup pending, edits the applied file, then retries cleanup twice. The result survives, later edits remain, and the win is counted once. This covers the post-receipt recovery path, not a crash immediately after patch application. The full intent/preimage/postimage journal, per-lane durable checkpoints, process-tree exit proof, cross-process mutation coordination and lock-scope redesign remain required.
+
+## Compare durable intent foundation
+
+Before source mutation, Keep captures the working-tree preimage using a temporary index, pins it under `refs/shadowcode/recovery/`, and persists an operation identity, selected model, result commit, source HEAD, changed paths and `applying` phase in the existing revision-checked record. The public state is `needs_review` until application succeeds and the applied outcome is persisted. A confirmed Git preflight refusal returns to `done` with `not_applied`; an uncertain failure retains recovery material and blocks Keep/Discard. The UI exposes the interrupted state and project Changes.
+
+Native coverage includes preimage pin/content checks and restart from seeded interrupted records with unchanged and edited files. Seeded interruption is not an OS-crash injection demonstration. Full reconciliation, expected postimages, index identity, guided recovery resolution, cross-process project ownership, per-path bounds/redaction and bounded recovery retention are still required. Pins are intentionally retained in this increment to avoid destroying recovery evidence; they may retain nonignored source content just as the existing Compare snapshot does. Do not label this foundation complete crash recovery.
+
+## Compare exact-state reconciliation
+
+New operations record the expected postimage tree (computed by applying the binary patch to an isolated index), source branch/ref and staged-content tree. The recovery action checks branch, HEAD and staged content, then compares current source content to the preimage and expected postimage. An exact postimage persists the applied outcome and win once; an exact preimage returns the comparison to a retryable state. Ambiguous content and incomplete legacy evidence stay blocked. No recovery action applies/reverts files or deletes lanes. The UI exposes Recheck recovery alongside Changes.
+
+Validation: nine native Compare tests cover before/after/ambiguous/staged-change restart states and repeated assessment; ten Compare UI tests cover the action; strict Clippy and production UI build pass. Restart cases seed journal states rather than killing a live application. Full crash injection, simultaneous external edits, guided ambiguous-state resolution, bounded recovery retention and cross-process ownership remain open. Whole-tree comparison is conservative: unrelated edits also require review. Index comparison uses staged-content identity, not all index flags or unsaved editor buffers.
+
+## Actual Compare process-crash coverage
+
+Linux integration tests now spawn an isolated service test process and intercept only the source Git apply/removal commands through a test-local PATH wrapper. The parent sends SIGKILL before patch application, after patch application, and after outcome persistence before the first worktree removal. Restart uses the actual journal written by Keep, not a seeded replacement. The test checks before/applied reconciliation without reapplication, preserved lane copies, binary bytes, rename/delete results and executable mode. Crash controls exist only in the test executable; production has no environment-triggered fault injection.
+
+The Linux suite reports eleven passing tests, including the no-op child entry used by the parent harness. This covers three real process-crash boundaries, not every journal write, disk-full failure, power-loss/fsync behavior or concurrent external edit. Those acceptance cases, ambiguous recovery UX and cross-project synchronization remain open.
+
+## Compare project coordination
+
+Compare metadata synchronization is now scoped to canonical project paths. A short registry mutex only manages weak lock references and is never held across async work. Start/Keep/Discard/recovery also acquire a nonblocking OS file lock in Git's common directory, shared across app profiles/processes and related worktrees. Existing revision-checked persistence and engine reservations remain. Lock order is documented; the repository lock file is never unlinked. Unix opens reject symlinks, nonregular files, other owners and extra hard links.
+
+The Compare suite covers ownership refusal before source changes and successful retry after release. The real-crash parent is a second process and verifies that it cannot acquire the child's repository ownership at all three mutation boundaries. A unit test holds project A's metadata lock while acquiring project B's. This establishes scoped-lock behavior, not a measured end-to-end UI latency target. Git/worktree subsystem locks, independent external editors, advisory-lock cooperation by older app versions and network-filesystem lock semantics remain limitations.
+
+## Release draft staging and immutable retry
+
+The release workflow preserves all existing gates and delegates publication to `scripts/publish-native-release.mjs`. It stages a draft, uploads only missing assets without clobber, downloads all expected assets to verify SHA-256 bytes and completeness, then publishes. Published retries are read-only; changed bytes, unexpected assets, incomplete published releases and authorization failures are refused. A deterministic manifest binds tag/source commit, architecture, Cargo/UI lockfiles, runtime pin and package hashes. Per-tag workflow concurrency serializes cooperating release runs. Native PR CI and release CI run the publisher fixture suite.
+
+Eight local Node tests exercise failure/retry and corruption paths; both workflow YAML files parse. No GitHub draft or public release was created to test this change. Remote tag protection, platform signing/authenticated updates and full workflow execution remain unverified. Manual concurrent repository edits are outside workflow concurrency. Draft discovery follows GitHub's authenticated [List releases API](https://docs.github.com/en/rest/releases/releases#list-releases); publication commands were checked against installed `gh` help.
+
+## Provider runtime capability enforcement
+
+The ACP execution adapter now checks the live initialize response before starting a session: only supported protocol version 1 is accepted, and queued image prompts require explicitly advertised image support. Follow-up images are checked at send time too. Missing, false or malformed image declarations cannot silently send attachments or trigger a provider/billing fallback. The image capability resets at process startup; cached catalog/vendor-name assumptions cannot authorize actual image transmission.
+
+Parameterized fixtures cover Cursor, Antigravity and Grok with queued/follow-up images and true/false/missing/malformed declarations, plus unsupported/missing protocol versions. Existing Antigravity Google authentication and session/model-switch fixtures remain exercised. This is protocol conformance evidence, not a live login test or a change to the supported vendor login routes. The existing Grok early vision restriction and broader capability/UI synchronization remain separate work. Reference: [ACP v1 initialization](https://agentclientprotocol.com/protocol/v1/initialization).
+
+## Managed local runtime admission foundation
+
+Different-model requests now wait cancellably for active model leases instead of failing immediately. The last lease wakes waiting requests, and notification registration precedes the lease check to avoid lost wakeups. Existing same-model sharing remains. Cancellation and runtime shutdown prevent a waiting request from starting another model. All existing callers of managed runtime acquisition share this boundary.
+
+The local-engine fixture suite checks no second launch while a lease is active, cancelled waiting requests, release-triggered switching, same-model sharing and shutdown with a queued request. This is not yet a FIFO multi-GGUF Compare scheduler: lineup restrictions remain until ordered scheduling, queue UI, timing/configuration evidence and actual offline acceptance are implemented. Long-lived same-model sharing can delay a different model. Existing CPU fallback behavior still needs an explicit Compare policy before benchmark use.
+
+## Ordered managed job scheduling
+
+Top-level managed-model jobs register in submission order across project workspaces and remain in persisted `queued` status until admitted. Admission precedes the general worker semaphore, avoiding worker exhaustion by local waiters. Completion/cancellation wakes the next eligible entry; weak references prevent completed task retention. A cancelled middle entry cannot start or let a later job overtake the active predecessor. Nested tasks continue to share the parent's model; a nested model/context switch while the runtime is leased is refused to avoid a parent-child wait cycle.
+
+A cross-project native fixture holds a runtime lease, submits three tasks, cancels the middle one, and verifies queued states, model launch counts, terminal states and start-after-predecessor-finish ordering. This is job scheduling, not yet the complete multi-GGUF Compare feature: setup-only runtime callers still use lease admission, ordered Compare launch metadata/timing and fallback policy remain, and real offline model acceptance has not run.
+
+## Sequential multi-GGUF Compare integration
+
+The native and picker one-GGUF restriction is removed. Two or three installed compatible local models now use the ordered managed-job scheduler from one comparison snapshot. Offline mode remains authoritative. Compare preparation disables automatic CPU fallback and rejects reuse of an automatically-fallen-back runtime; normal non-Compare fallback behavior remains unchanged. Each local lane records runtime ID, reported backend, effective context size, preparation duration and fallback policy; the lane card displays the useful runtime details. Preparation includes readiness/wait work and is not a measured time-to-first-token.
+
+End-to-end managed-runtime fixtures run two GGUF lanes in offline mode, verify sequential completion and shared snapshot, and cancel a three-model lineup while another lease occupies the runtime, proving no queued model is loaded. A failed-GPU fixture proves comparison policy does not launch a CPU retry. The full UI suite passes 314 tests and the production build. These simulated model/runtime files do not prove real inference or absence of external packets; actual two-model offline acceptance, complete model file/quantization/template identity, separated timing metrics and native UI acceptance remain required. Earlier entries describing the one-GGUF restriction are superseded by this increment.
+
+## Next implementation work
+
+1. Complete Compare journal/recovery and immutable preview binding; add project-scoped check configuration UI. The original arbitrary-command false verification is fixed in source with a native regression.
+2. Immutable draft release staging and required gates, preserving all current checks; dependency advisory remedy.
+3. Compare fault injection, durable journal, process-exit cleanup checks, cross-project synchronization.
+4. Provider capability/conformance and real coding evaluations, extending existing login integrations using supported vendor mechanisms.
+5. Shared local-resource scheduling and actual offline multiple-GGUF acceptance.
+6. UX, diagnostic export, measured performance, Wayland and package/recovery qualification.
+
+No paid/live provider turns, billing changes, release publication, or destructive Git repairs are authorized by the reference document itself. No new required hosted backend or account. Tests use isolated scratch profiles and projects.
+
+## Real single-model offline acceptance
+
+The opt-in `live_local_acceptance_from_explicit_models` harness accepts one to three explicit installed GGUF paths, creates an isolated profile/project and downloads nothing. On 2026-09-26 the one-model case completed on the installed Qwen2.5 0.5B Q4_K_M (491,400,032 bytes) and llama.cpp 0.4.1-dev / 18f9f7bef. A Linux user/network namespace exposed only loopback; a `strace -f -e trace=connect` trace observed ten connections to 127.0.0.1 and no nonloopback connects. The runtime reported Vulkan / RTX 5060 Ti and no CPU fallback.
+
+The supplied addition-function explanation completed in about 1.99 seconds, including 1.63 seconds of runtime preparation. This is one inference observation, not TTFT, a coding benchmark, independent GPU-offload measurement or multi-model acceptance. Verification correctly remained `not_run`. The real test and all-target Clippy with warnings denied pass. LOC-01 remains open: only one installed GGUF was found; the two-model scheduler is currently covered by fixtures. No installed app, model or account profile was modified.
+
+## Live subscription text/resume and Claude duplication fix
+
+After the user offered existing subscriptions for testing, source-built `live_vendor_turn` runs used isolated profiles/projects and installed official CLI logins. Codex 0.158.0-alpha.2.1 (ChatGPT Pro, GPT-6-Luna), Claude Code 2.1.278 (Claude.ai Max, Default), Cursor 2026.09.15-d2fe57e (Auto), Antigravity ACP 1.2.1 (Gemini 3.8 Flash High) and Grok 1.0.41 (Grok 4.7) each completed an initial one-word answer and native-session resume. Cursor's CLI reported Free; paid entitlement remains unconfirmed. Unknown plan allowance remains unknown.
+
+The real Claude run initially returned `ALPHAALPHA` twice. Its per-message streaming marker reset before `result`, allowing repeated final text. A separate per-turn emitted-text marker now suppresses that duplicate and resets on follow-up. Regression fixtures cover streamed and complete-message paths, later result-only turns, genuine repeated chunks, multiple messages and tool-then-result fallback. The real Claude recheck returned exactly `ALPHA` on both turns. The live example now fails on a wrong/duplicate answer and cancels timed-out jobs before shutdown.
+
+All 19 adapter tests and core all-target Clippy with warnings denied pass. These live tests establish basic text and resume behavior, not full provider conformance or coding-quality acceptance. No tool action, image, quota exhaustion or auth-expiry case was exercised live in this increment. The installed app and billing settings were not changed. Codex subscription authentication was cross-checked with [official OpenAI documentation](https://learn.chatgpt.com/docs/auth).
+
+## Bounded provider transport and split-frame correctness
+
+A native subprocess reproduction confirmed that the runner's 250 ms polling timeout discarded bytes already consumed into a temporary line buffer. A frame split across that boundary completed with missing reply text. A second reproduction sent more than 4,000,000 bytes without a newline; the old size check could not run until line completion, and the job failed the five-second acceptance deadline. Both regressions failed before the fix and passed afterwards. No actual out-of-memory incident is claimed.
+
+A shared bounded line reader retains partial bytes across cancelled polls and enforces the protocol limit while reading. Oversized protocol frames now fail the run or discovery probe immediately, rather than silently dropping a possibly essential event. Codex and ACP discovery use the same reader. Diagnostic lines have a separate 64 KiB bound; an oversized line is omitted entirely, then reading resumes at the following line. Stderr tasks are tied to the owning run/probe through abort-on-drop handles.
+
+Tests cover fragmented UTF-8, CRLF/EOF and exact size boundaries, cancellation of pending reads, oversized-line resynchronization, whole-line diagnostic omission, native job/terminal persistence and overflowing process reaping. A live readiness check caught a closed oneshot being polled again in the revised ACP probe; the corrected code disables that branch after channel closure, and a normal ACP handshake now has a regression test. Final validation: 24 provider unit tests plus 39 integration tests pass, with one opt-in live installer test ignored; all-target Clippy with warnings denied passes. Read-only discovery against all five installed provider versions reports Ready after the fix. No model turn was sent in this increment. This does not complete all transport conformance: stdin backpressure, aggregate retained output, absolute deadlines amid continuous irrelevant output, request-queue limits and the full shutdown/permission matrix remain to be qualified.
+
+## Responsive provider streaming and cancellable input
+
+Native fixtures reproduced two user-visible problems before correction: a short text delta stayed buffered until the provider finished, and cancelling a task waited for a vendor that had stopped reading its input pipe. Both regressions now pass. The provider runner flushes pending text on a 50 ms cadence during quiet and continuous streams; existing size, tool, approval and completion flushes remain immediate. A burst fixture verifies text is coalesced without duplication and arrives before the corresponding permission request. This is not a measured UI frame-rate or p95 input-latency result.
+
+Protocol writes now observe task cancellation and have a 30-second per-batch deadline. Partial writes are never retried. Stop and plan-limit interrupt delivery have a 250 ms best-effort budget before the existing process cleanup path. An interrupted prompt write exits through owned-process cleanup; the Linux regression checks both persisted cancelled state and process reaping. Codex readiness is recorded before attempting the prompt write, preventing an input failure from launching an exec fallback after a prompt may have begun. A deliberately closed pipe verifies prompt failure is immediate and no fallback runs.
+
+Validation: 26 provider unit tests and 46 focused integration tests pass (72 total), including native streaming/approval order, blocked input cancellation, write deadlines, cancellation-before-write, native steering, account/limit handling and existing adapters. One opt-in live installer test remains ignored. Core all-target Clippy with warnings denied passes. An initial unrelated ACP fixture launch returned Linux `ETXTBSY`; its isolated rerun and subsequent complete focused runs passed. Tests used fake CLIs/local fixtures, with no paid provider turns. Actual native UI latency, full descendant shutdown cases, aggregate output bounds and overall deadlines during continuous irrelevant output remain open.
+
+## Local startup status and current-turn Compare evidence
+
+Managed local tasks now record catalog preparation, runtime waiting and model loading before the existing runtime-ready receipt. Waiting is emitted only when the runtime lock or a different model's lease prevents acquisition; loading starts when the task can actually replace/start the runtime. Reusing an already loaded model does not invent a loading step. Existing non-task preparation callers retain their API and behavior. Compare still disables automatic CPU fallback.
+
+The conversation consumes these events before `agent.started`, promotes its queued job to running, and displays the recorded startup phase. Compare shows a queued local lane as “Queued for local model” and consumes the current task's startup events. Stop, Pause, pending approvals and terminal outcomes take precedence over startup labels. A follow-up now clears the prior turn's runtime metadata when its own runtime-ready receipt does not yet exist. Previously, it could retain the previous turn's backend/context/preparation details.
+
+Validation: 12 Compare and 9 local-engine native integration tests pass; two opt-in real-model tests remain ignored. The held-lease fixture asserts preparing → waiting → loading → ready, and no preparation event for the queued successor. A Compare follow-up fixture asserts its new job ID, waiting phase, cleared old receipt and persisted cancellation. All 318 UI tests pass. One browser fixture verifies the visible preparation/wait/loading/reading progression before and after `agent.started`; its loading screenshot was inspected. Production UI build and core all-target Clippy with warnings denied pass. Initial test-only corrections were a missing Rust import and an expectation of an empty active-task string instead of `undefined`. The Vite bundle-size warning remains.
+
+This is task-startup status, not a complete generation/check timing matrix, native Wayland verification, p95 latency measurement or a new real-model benchmark. No paid provider turns, installed-app replacement or remote publication occurred. Next: finish separated task timings and model provenance, then qualify the same user flows against real models and packaged desktop builds.
+
+## Observed task timings and warm-start disclosure
+
+CONFIRMED: Compare's prior duration excluded queue time, while local preparation combined catalog work, runtime waiting and model loading. The existing output did not separate those costs. Added versioned, monotonic timing observations on jobs, terminal events and Compare lanes. A finished conversation and each Compare lane expose one expandable timing section. Task acceptance does not depend on these metrics.
+
+Measurements distinguish queue/active/total time, local preparation and its wait/load subsets, whether the model was already loaded, foreground native model attempts, first returned text, tool batches, completion assessment and configured-check processes. First text is explicitly **not TTFT**: the callback can receive a buffered JSON reply and tool-only requests produce no text value. Model-request time includes request/transport/decoding rather than pure generation. Native process time is retained in configured-check receipts and excludes approval waiting and file fingerprinting; it overlaps tool/completion time. Unobserved vendor-internal measurements and old history stay absent. The API contract documents the boundaries and overlaps.
+
+Final timing evidence is persisted with the terminal job/event and survives restart. Live observations are partial. Crash recovery does not infer downtime or invent missing timing history. Cancel-before-admission has queue time without fake execution/load activity. A waiting follow-up cannot inherit a prior turn's timing snapshot. Warm model reuse is identified explicitly and carries no model-load interval.
+
+Validation across focused runs: 69 native integration cases (engine tasks, managed local runtime, Compare, subagents and service commands), 3 timing unit tests, all 322 UI tests, 2 browser cases, production UI build and core all-target Clippy with warnings denied passed. The affected engine/local/explicit-check paths were rerun after the final provenance and warm-reuse additions. The browser timing card was inspected and its final wording rechecked. One initial UI test found timing details missing from the compact no-files-changed summary; the component was corrected. Existing bundle-size warnings remain.
+
+One opt-in real single-model run also passed in a loopback-only Linux network namespace, with isolated profile/project and existing Qwen2.5 0.5B Q4_K_M weights (SHA256 `74a4da8c9fdbcd15bd1f6d01d621410d31c6fc00986f5eb687824e7b93d7a9db`). It recorded total 1.6959 s, queue 0.0024 s, preparation 1.4345 s, load phase 1.4261 s, model request 0.1922 s and first text 0.1019 s from that request. No verification command ran; verification remained not_run and process-check time stayed null. All 9 observed INET connect calls targeted 127.0.0.1. Runtime-reported backend was Vulkan with no automatic CPU fallback; independent GPU-offload measurement was not performed. This single short explanation task is not a throughput, coding-quality or repeatability benchmark. Two opt-in real-model tests remain ignored in ordinary fixture runs; the single-model acceptance test was invoked separately here.
+
+Remaining Phase 4 work includes true token-level latency/pure-generation measurements where supported, complete model-file/runtime/template/sampling provenance, and real two-model offline acceptance. Full provider, recovery, native UI and release qualification remain open. No paid provider turns, downloads, installed-app replacement or remote publication occurred in this increment.
+
+## Full acceptance register
+
+Each ID records the inspected test evidence and its limits. “Mapping pending” is not an assertion that functionality is missing. Unless an increment explicitly records a fresh run, mapping existing tests does not newly certify current source. Passing broad suites do not automatically prove each invariant.
+
+- **RUN-01 — Very fast hermetic task**: Partial native/fixture evidence: `engine_tasks::concurrent_sessions_and_repeated_followups_survive_restart_without_duplicate_completions`, CLI run/watch/events scenarios, and UI durable-page draining pass at prior checkpoints. A deliberate completion-before-observer-registration barrier and packaged replay remain open.
+- **RUN-02 — Engine throws before streaming**: Partial: `model_routing::provider_failure_does_not_silently_retry_against_the_default_endpoint` covers failed endpoint propagation. A forced pre-stream panic/preparation failure through the actual conversation spinner remains open.
+- **RUN-03 — Cancel before startup**: Partial: `engine_tasks::queued_cancel_is_immediate_and_shutdown_cancels_a_stalled_provider`, `control_owned::dropping_an_owner_cancels_queued_jobs_without_waiting_for_an_unrelated_task`, and managed local queue tests cover no execution after queued cancellation. Exact admission-race and reopened-store assertions remain open.
+- **RUN-04 — Cancel during output**: Background output-flood Stop and TERM-handler exit-zero cases preserve cancellation; blocked provider input cancellation reaps its process. A new conversation-hook regression streams partial text, loads the authoritative cancelled job, then delivers a late completed event from the old stream; cancellation and the partial output remain, with no completion callback. This verifies stale-stream rejection after cancellation is observed, not the full native Stop-click-to-cancellation-acknowledgement race or provider process behavior.
+- **RUN-05 — Two sessions and duplicate events**: Partial: native concurrent session/restart tests plus transcript deduplication and obsolete-session navigation tests pass at prior checkpoints. The combined native-window cross-session/reconnect case is not yet qualified.
+- **RUN-06 — Owned child ignores graceful stop**: Partial Linux native evidence: `background_processes::completion_failure_graceful_stop_and_forced_child_cleanup_are_truthful`, hook cancellation and process stress prove bounded stubborn-child termination. Unrelated PID protection is tested separately; a concurrent unrelated sentinel during escalation and all-provider parity remain open.
+- **CMP-01 — Crash before project mutation**: Native SIGKILL coverage: `compare::real_process_crash_before_and_after_apply_recovers_without_reapplication` kills before source apply and recovers the actual journal. This is one mutation boundary, not every journal-write/disk-full/fsync failure.
+- **CMP-02 — Crash after apply before applied receipt**: Native SIGKILL coverage: the same Compare crash test kills after apply before receipt, checks the postimage and reconciles without reapplication. Ambiguous external edits and every persistence boundary remain open.
+- **CMP-03 — Crash after receipt before cleanup**: Native SIGKILL plus restart coverage: the crash test kills before first cleanup after receipt; `applied_outcome_survives_pending_cleanup_and_restart_without_rescoring` preserves later user edits and scores once. Per-lane crash checkpoints remain open.
+- **CMP-04 — Failure deleting one lane**: Linux service regression now injects a real filesystem permission failure after quarantine relocation: Git partially removes the lane and unregisters it, while the applied winner, original index/user edits and once-only scores survive. A bounded ownership journal retains the exact Git administration/index and surviving lane identity; reopening and repairing permissions permits cleanup without reapplication/rescoring. Readonly-root relocation refusal, original-path replacement, symbolic branch aliases and crash recovery have separate regressions. Full package/UI and all persistence-failure boundaries remain open.
+- **CMP-05 — Other lane refuses to stop**: Partial: `cancel_keeps_lanes_and_discard_stops_running_lanes` covers cancellation of a cooperative slow lane. A resistant lane that cannot be proved stopped remains a required case.
+- **CMP-06 — Duplicate Keep or Keep/Discard race**: Four Linux native service races deliberately overlap at real Git apply/removal barriers: same-lane Keep/Keep, different-lane Keep/Keep, Keep/Discard and Discard/Keep. Source apply counts are 1/1/1/0; source HEAD, staged content, unrelated working files, winner and once-only scores survive reopening and cleanup retries. This is same-process dispatch concurrency; separate-app ownership has its own tests and packaged UI qualification remains open.
+- **CMP-07 — Project edited after preview**: Native fixtures: `keep_refuses_conflicts_and_discard_removes_every_lane` preserves an external conflicting edit; stale-check acceptance requires explicit review. External edits during the final apply window remain open.
+- **CMP-08 — Staged/unstaged changes and unsaved buffer**: Native fixtures preserve staged content and unrelated untracked/unstaged work. `snapshot_captures_flagged_files_and_preserves_the_source_index` additionally checks assume-unchanged, present/absent skip-worktree paths, intent-to-add, split indexes and a real sparse checkout without modifying source index bytes or timestamp. The full-service `compare_preserves_flagged_index_and_captures_hidden_working_edits` verifies both lane snapshots, Keep and reopening with the original index intact. UI-started Compare now blocks and names dirty editor drafts, verified by `requires open editor drafts to be saved before Compare snapshots the project`; direct native/API starts cannot inspect those in-memory buffers. Sparse-index extensions and packaged UI parity remain separate.
+- **CMP-09 — Binary, rename, delete, executable bit**: Native Linux crash fixture checks binary bytes, rename/delete results and executable mode through actual Keep and recovery. Full visual review/package parity remains separate.
+- **CMP-10 — Separate app processes target one project**: Native process/lock evidence: the real-crash parent cannot acquire the child repository lock at all three boundaries; `repository_ownership_refuses_keep_before_source_changes` permits retry after release. Noncooperating editors and network filesystems are outside this lock guarantee.
+- **CMP-11 — Project A shutdown stalls**: One actual Engine/Store serves two Git projects while A's owned fallback shell holds its TERM shutdown barrier. B's status/cancellation completed in 45/112 ms during the full Compare suite, within the unchanged one-second combined bound; A remained stopping and later exited. This is a bounded Linux core-service check of the fallback process path, not a percentile benchmark, Bubblewrap-enforcement or native-window qualification.
+- **LOC-01 — Two installed GGUF models offline**: real Qwen3 4B Instruct 2507 Q6_K and Qwen3 14B Q4_K_M completed sequential Compare lanes with valid addition explanations in a loopback-only Linux network namespace. All 37 observed INET connects targeted loopback; no automatic CPU fallback. This verifies basic two-model offline inference and sequencing, not coding quality or full desktop/package acceptance.
+- **LOC-02 — Three-model queue cancelled mid-run**: Native runtime fixture: `compare_runs_two_installed_gguf_models_sequentially_in_offline_mode` proves pending models do not load after cancellation. The opt-in `live_three_model_compare_cancels_queued_models` test additionally exercises installed Qwen3 4B, 8B and 14B GGUF files in an isolated profile and loopback-only network namespace. It waits for the first model's actual stream, cancels Compare, requires all three jobs to finish cancelled, and checks that the queued models have zero requests, no loading phase and no runtime-ready event. This is real local-runtime cancellation evidence, not a packaged GUI or broader model-quality result.
+- **LOC-03 — Local model exceeds safe allocation**: Preparation fixture `memory_too_large_is_marked_unavailable_and_refused` covers refusal. Actual GPU allocation exhaustion with responsive desktop and unchanged project remains open.
+- **LOC-04 — Runtime crashes or port is occupied**: Partial fixtures cover early runtime exit, cancellation during load and CPU fallback policy. Full runtime-crash/restart and occupied-port native-window matrix remains open.
+- **LOC-05 — One model fails in Compare**: Other lane results and verification stay intact. Status: mapping pending.
+- **PRF-01 — Model claims tests passed without execution**: engine/receipt/UI regressions pass; final native executable reproduction passes. No paid provider used.
+- **PRF-02 — Configured checks pass, then source changes**: post-check edits before task completion and uncommitted-content/rename unit tests pass. Native service tests cover external edits after completion and restart; Compare tests cover acceptance refresh and explicit stale-evidence review. The unreleased UI now invalidates visible summaries on successful editor saves, revisions observed by the open editor, and relevant same-session engine wake-ups. Generation-guarded reads discard an old passing response when another edit arrives. This does not continuously watch every file or another session mutating the same project; those cases still require focus/manual reassessment.
+- **PRF-03 — No checks configured**: native arbitrary-command reproduction returns not_run; UI displays verification not run and preserves command exit evidence.
+- **PRF-04 — Required check cancelled/skipped**: typed-state unit tests prevent passing; failed/cancelled task aggregation and goal gating are implemented. Terminal receipt regressions now cover budget exhaustion, malformed provider response, cancellation and seeded interrupted-job recovery without losing command evidence or cross-task isolation. Historical reassessment cannot restore verified/red-green claims for unsuccessful tasks. Complete cancellation-boundary native matrix remains open.
+- **PRF-05 — Provider self-reports an external result**: Provenance differs from locally observed evidence. Status: mapping pending.
+- **PRV-01 — Expired auth or unavailable quota**: Clear status; no fabricated usage or fallback billing. Status: mapping pending.
+- **PRV-02 — Malformed/out-of-order protocol data**: Native regressions now cover split/oversized framing, continuous valid traffic deadlines, aggregate text/protocol bounds, malformed limits and pre-ready no-fallback behavior. Request ordering, session isolation, discovery-helper retention and live updated-provider conformance remain open.
+- **PRV-03 — Unsupported image/tool capability**: Existing `acp_negotiated_image_capabilities_guard_queued_and_followup_prompts` covers absent/false/malformed/true negotiated image declarations for three ACP adapters. Unsupported queued/followup images fail before sending an image prompt; an intentional later text-only prompt remains possible. Managed llama.cpp vision uses prepared runtime/projector capability. The general cloud-name heuristic, full tool-capability matrix and native UI path remain unqualified.
+- **PRV-04 — Vendor runtime updates capabilities**: The catalog now compares the configured executable's selected path, length, modification time and (on Unix) device/inode before using a cached probe. Installation or atomic replacement bypasses the five-minute freshness window and failure backoff. A fake Codex runtime test atomically replaces the executable while keeping its bytes the same, changes the next model-list reply from image-capable to text-only, and proves the next catalog/picker request removes Vision from every affected row. The browser refreshes its picker on focus, and an already-open picker offers **Refresh models** to force a read-only reprobe. Capability changes are not pushed into an open picker automatically. Real updated-provider and native UI acceptance remain open.
+- **SEC-01 — Strict sandbox unavailable**: Native command-tool subprocess fixtures verify refusal when bubblewrap is missing or its probe fails, and no unsandboxed replay after launch failure. Settings and background approvals disclose the execution boundary. Native-window/package qualification and the wider execution-policy matrix remain open; these fixtures do not prove kernel containment.
+- **SEC-02 — Approval parameters change**: Two actual subprocess regressions reproduced a stale allow while changed command/edit frames waited unread. The runner now continues protocol processing during approval, reconciles readable complete frames before replying, waits for observed partial frames, and refuses stale/duplicate/retired requests. Seventeen Linux approval subprocess cases passed, alongside ACP state and private-identity units. This verifies observed protocol-state binding; fresh live Cursor and native-window qualification remain separate.
+- **SEC-03 — Secret-containing logs/diagnostics**: Complete private-key PEM payload redaction is integrated in `b66bb0e`; six failing baseline regressions now pass among 12 redaction tests. Truncated recognized armor hides the uncertain remainder. The Health screen now has a previewable Doctor status export: a fixed allowlist of check IDs/labels/statuses is projected before the renderer sees it, and the saved JSON is retrieved by immutable, ten-minute snapshot ID. Paths, configured model names, detail/fix strings, project maps, prompts and raw logs are excluded. The feature's pure projection/cache, remote exact-byte download, UI preview and native Doctor integration tests pass locally; native file-dialog and packaged-app qualification remain open. This is not a crash-report export or proof that arbitrary secret formats cannot escape elsewhere. No arbitrary model-supplied ID is exempted from redaction.
+- **SEC-04 — Repository text requests broader permissions**: The native engine integration fixture now puts a false owner authorization in `AGENTS.md`, confirms it reaches the model's system context with the non-authority label, then has the model request `write_file` during Plan mode. The tool produces a recorded read-only denial, no approval prompt and no file; the task can still finish with an honest plan. This proves the native tool boundary for that prompt-injection case. Vendor-owned tools, other repository instruction formats and policy changes during a live task remain separate.
+- **SEC-05 — Symlink/path/remote destination changes**: On Unix, workspace write, delete, move, mkdir, mode-change and empty-directory removal resolve mutation parents using directory handles with `O_DIRECTORY|O_NOFOLLOW`; mutation leaf reads use `O_NOFOLLOW`. Write hashes and atomic replacement share the pinned parent. Tests cover parent swaps with both parent-creation modes, leaf symlink/FIFO refusal, existing mkdir leaves, and stale write/invalid move refusal before destination directories are created. Foundations22, workspace unit3, and the broader full-undo6/native-tools19/service-commands18 suites pass. This is partial scope: noncooperating external writers can still change a leaf between a type/hash check and rename/unlink; this is not atomic compare-and-swap. Broader approval/UI, non-Unix and concurrent real-directory replacement qualification remain open.
+- **UI-01 — Long transcript plus active stream**: Two matched Chromium fixtures with 10,000 messages and a streaming 64 KiB response improved the paint-opportunity p95 proxy from 102.6/109.3 ms to 39.9/40.5 ms, without main-thread long tasks. Actual native input latency and remote worker fallback remain open. Native X11 WebKit confirmed the bundled worker and semantic output under the real CSP.
+- **UI-02 — Event gap, duplicate event, reconnect**: UI fixtures in `jobEvents.test.ts`, `transcript.test.ts` and `useConversation.test.ts` cover durable polling gaps, duplicate event replay, page draining, obsolete session response rejection and reattachment. Native-window reconnect/duplicate tool-effects acceptance remains open.
+- **UI-03 — 100 sequential fixture tasks (partial: native engine)**: The sustained-stress gate retains the 200-task four-project concurrent workload and adds 100 serial tasks, awaiting each task before the next. The current native binary run on 2026-09-28 passed 301 completed rows including warm-up, 100/100 distinct persisted serial completions, five cancellations, ten bounded output floods, no active jobs/owned children and SQLite integrity `ok`. Sequential RSS rose 2,740 KiB (121,296→124,036 KiB), descriptors stayed at 14, and threads stayed at 24. Both workloads assert thread-growth and absolute RSS limits. The receipt binds `/proc//exe` bytes to SHA-256 `93e33e395f8f2d56ed64257099b5e3b9fab4a2fcf6b8ce8e699f26682cb18da2`. Wired through native CI and `native-behavior`. This verifies the engine with a scripted provider, not 100 desktop WebKit tasks, GPU/model-server memory or a release package. Desktop resource qualification remains open.
+- **UI-04 — Keyboard and Wayland native session**: Clean `fd39f5a` desktop SHA-256 `4186ce5ef05c810d8f7cc4e26b67530d9146e272e883c51202c25490082070bb` passed all eight private Weston/Xvfb virtual-seat checks: native Wayland connection, worker/CSP, 18 keyboard/focus observations, same-WebView copy/paste, 1380→520→1380 resize, themes, one-time approval/keyboard cancellation and clean quit. Live/reloaded cancellation shows one prompt and neutral check status. All owned processes exited. This does not certify physical COSMIC input, IME, compositor shortcuts, cross-app clipboard, mixed DPI, performance or installed packages. Original COSMIC tiled-resize limitation, seatless clipboard SIGSEGV and first nested-display readiness failure remain preserved; the passing virtual-seat run supports but does not prove the missing-seat crash hypothesis.
+- **REL-01 — Asset upload fails**: Publisher fixtures prove interrupted upload remains draft and resumes without overwrites. Actual release 0.33.1 stopped on an unavailable draft listing, leaving an empty unpublished draft; the frozen publisher resumed after the draft appeared, verified all seven remote assets, then published. This exercises real safe publication recovery, but is not a deliberately interrupted GitHub upload experiment.
+- **REL-02 — Retry same version with different binary**: Publisher fixtures refuse changed draft/published bytes; installer fixture refuses changed bytes under an installed version and preserves the old AppImage. Remote tag protection remains unverified.
+- **REL-03 — Checksum/signature mismatch**: Offline verifiers and refusal regressions bind release identity, metadata and artifact bytes to independently trusted Ed25519 keys. Actual protected signing and seven-asset publication are complete for 0.33.1; fresh public downloads match the immutable signed artifact. The authenticated AppImage installer retained a durable accepted-state receipt, and the authenticated Debian snapshot passed fresh isolated installation. Independent trust-bootstrap distribution and future key rotation remain separate; older releases are not authenticated retroactively.
+- **REL-04 — Interrupted replacement or failed first launch**: Installer fixtures cover failed rename, SIGTERM, rollback failure and actual SIGKILL preservation. The versioned journal supports exact recorded pre-activation recovery through retry or `--recover`; schema3 additionally permits recovery at the activation marker only while the old link and all recorded launcher/desktop identities remain unchanged. An edited desktop entry and a crash after active-link replacement still refuse automatic recovery. Older schema2 activation intents remain manual. Full desktop metadata rollback after mutation and first-window/database downgrade qualification remain open.
+- **REL-05 — Fresh packaged install without dev tools**: Release 0.33.1 passed its required offline clean-host first-launch gate for both packages. Separately, the downloaded published Debian package was authenticated against the reviewed trust/source, then installed from its private verified snapshot in a network-disabled Debian runtime-only container without Node/Rust/Cargo. Package version/metadata, CLI status, MCP stdio discovery, visible first GUI window and owned-container cleanup passed. Debian SHA-256 is `4f93c63092e0a2e832839e5796afc1a208b29ea5dd18adbaae7cc9c6b17d30af`. The installed AppImage separately passed 20 native-window interaction groups on this machine. These scopes do not establish physical Wayland or GPU inference acceptance.
+- **REL-06 — Required integration test is skipped**: Required scoped receipts reject missing, failed, skipped, stale or mismatched gates before signing and GitHub calls. All 15 required build gates were verified for release 0.33.1, followed by successful protected signing. The later publication-listing failure remains recorded; publication recovery did not bypass receipt validation. Built-project cleanup has its own required gate. Explicit optional live-provider/model/microphone exclusions are retained and are not counted as passes.
+
+## Evidence boundaries
+
+Prior baseline audit: 737 Rust tests passed across completed binaries after one isolated timing rerun; eight ignored. UI: 308 unit and 44 browser E2E tests passed. Sustained native stress: 200 completions, five cancellations, ten output floods. These are baseline measurements, not verification of subsequent edits. Full live-provider, Wayland, offline multi-model, and release-package qualification remain open.
+
+At every checkpoint record changed commit, exact commands and results, fixture/native/live scope, unresolved risks, and the next dependency. Never mark a phase complete from a passing subset alone.
+
+## Parallel reliability and model qualification checkpoint
+
+User authorized practical parallel agent work and explicitly requested the Rhea GGUF plus four other model classes. Three agents owned independent provider, local-runtime and release work; root reviewed/integrated the changes and ran real models. No extra app chats were created. The reference document itself was not treated as permission for account changes or publication.
+
+- `4935ff4` fixes installer rollback intent before filesystem mutation, preserves the previous runtime/AppImage under rename errors and handled signals, excludes concurrent installers, refuses changed bytes at an installed version and preserves unresolved backups. Isolated fixtures include actual SIGKILL; recovery after SIGKILL still requires explicit action. The current installer fixture passes with its real packaged-runtime case explicitly skipped because the built bundle is absent. No installed app was replaced.
+- `58790fd` adds a configurable two-hour active provider budget, excluding explicit human waits, and aggregate protocol/text limits. Continuous valid/no-op traffic and a 9 MiB reply reproduced prior failures. Final adapter/config and native transport suites passed 19 + 15 cases; the prior focused unit/account/handoff/steering runs passed 30/12/4/3. Resource, framing and malformed-limit failures cannot cause exec fallback. Settings exposes the new limit. Discovery helper retention and complete transport conformance remain open.
+- `6de8f34` binds local reuse to model/projector/runtime metadata and launch settings, freezes/revalidates preparation identities, checks cancellation around blocking probes and prevents same-ID incompatible nested leases. The before-fix replacement regression was reproduced on archived HEAD with a forced fresh rebuild. Receipts and the expandable UI record exact header/template hashes, quantization codes and observed runtime/context/sampling/launch settings. Metadata identity does not hash weights or dependent runtime libraries. Final agent runs passed 13 native integration/sanity cases, 16 local-filtered unit cases, 4 GGUF cases, 30 focused UI cases and one inspected two-viewport browser case. Explicit live/Whisper tests remain excluded from fixture runs.
+- `28826c0` makes publication require per-stage receipts bound to commit, workflow attempt, command digest and package hashes. Twenty-five publication/gate fixtures pass, including missing/failed/skipped/stale evidence and changed bytes. Actual installer, runtime TAP, packaging TAP and Vitest 5 skip output was checked. Existing opt-in ignored Rust tests and cloud-consent scope are explicitly excluded rather than described as verified. No real GitHub mutation/full workflow/signing/fresh minimal-host installation was performed.
+
+Integrated root validation: 60 native cases across `cargo test -p shadowcode-core --test engine_tasks --test compare --test subagents --test service_commands` passed. All 328 UI tests passed. Production TypeScript/Vite build and `cargo clippy -p shadowcode-core --all-targets -- -D warnings` passed. The existing bundle-size warning remains. Three evaluator-only tests pass for genuine five-case grading, import-shadow refusal and premature exit-zero rejection. These are meaningful regression checks, not proof of adversarial-proof Python grading.
+
+### User-requested installed models and real results
+
+Ollama now contains Rhea 4B Coding Max Q6_K, Qwen3 8B Q8_0, Hermes 2 Pro Llama-3 8B Q6_K and Qwen3 4B Instruct 2507 Q6_K. Existing Qwen3 14B Q4_K_M was retained. Every full blob SHA-256 was independently checked against its content-addressed filename; the HF Q6_K blobs also match their published LFS digests. Exact tags, paths, sizes, hashes and receipts are in the user-facing inventory/evidence outputs. No model was installed as the application default.
+
+The original Rhea Ollama command produced repetitive text until a 120-second cutoff, with plain `{{ .Prompt }}` template and an empty upstream tokenizer template. Native Compare then loaded Rhea but returned only `<|im_end|>`. The old nonempty-answer assertion wrongly accepted that as inference success; the opt-in harness now persists answer-valid flags and rejects control tokens/irrelevant text. The earlier Rhea run is recorded as transport success and answer failure, never successful inference qualification.
+
+A new explicit-file `live_local_coding` example exercises actual service/catalog routing, separate temporary profiles/projects, required command sandbox and loopback-only network isolation. It asks each model to repair clamp, stable unique (including unhashable values), and chunks. It preserves evaluator-owned tests and independently copies only helpers.py into a fresh sandbox for five unittest cases. It bounds task/cancel/shutdown and retains failure/cleanup evidence. No paid provider or user project is used. Two initial harness errors (missing scratch trust and incorrect direct ModelConfig routing) ran no inference and are excluded from model results; switching to the actual service submission path corrected them.
+
+One run per model at 8192 context, 14 steps and 50000 task tokens produced:
+
+- Qwen3 4B Instruct 2507: successful edits and check, all five cases passed; total 38.17 s.
+- Qwen3 14B: edited and ran tests, four cases passed; unhashable unique failed and the model stopped after promising a repair; total 50.08 s.
+- Qwen3 8B: repeated read/search calls until token budget, no edit, zero cases passed; total 55.43 s.
+- Hermes 2 Pro Llama-3 8B: prose/code in the reply without tool execution, unchanged file, zero cases passed; total 16.11 s. Its unsupported passing claim remained unverified in the application.
+- Rhea 4B: response hit its length bound; incomplete tools were not executed, unchanged file, zero cases passed; total 48.75 s.
+
+These are application/configuration observations from one small repair fixture, not general model rankings or matched-sampling throughput benchmarks. The short times of failed/no-edit tasks are not speed wins. Startup defaults/templates differ; source validation work also ran on the host. Machine-wide sampled GPU memory rose from 693 MiB baseline to 10606 MiB peak over the suite, including desktop use; this is not per-model VRAM. All 204 observed INET connects during the coding suite targeted loopback. App verification remained separate from this external evaluator because no project check configuration was added.
+
+A subsequent stricter real two-model Compare with Qwen3 4B Instruct and Qwen3 14B passed both answer sanity checks in sequence with 4096 context, no CPU fallback and 37 loopback INET connections. LOC-01 now has real two-model basic inference evidence. A later isolated three-model run exercised live cancellation as recorded in LOC-02 above. Both current local packages subsequently passed one small-model inference task in the offline Debian runtime image, as recorded below. Native UI performance, runtime allocation/crash boundaries, full local-tool robustness and broader packaged inference qualification remain open.
+
+Priority exposed by the model test: strengthen local agent follow-through after a failing check and after promised-but-unperformed actions; inspect template/tool compatibility for Hermes/Rhea before recommending them as defaults; expand Qwen3 4B evaluation across representative repositories and repeated tasks. Complete release signing, crash recovery, minimal-host package and native Wayland/performance acceptance in parallel with that work.
+
+## Bounded follow-through and native steering boundaries
+
+The real Qwen3 14B repair failure motivated native regressions for unfinished edit promises. Three cases failed before correction: a failed check followed by a promise stopped before repair, a prose-only edit promise created no file, and repeated promises finished successfully. Native code tasks now request permitted follow-through within one cumulative `max_fix_retries` budget for edit and command promises. A harmless read cannot reset it. Persistent promises fail with existing command/check receipts retained; no command or edit is automatically replayed. Read-only planning, diagnosis of a failed check, quoted examples, conditions, deferrals and negative commands retain their intended behavior. This remains a conservative English heuristic, not proof that a model completed a task.
+
+Separate native regressions reproduced final-answer steering being ignored, pending edits surviving a newer instruction, and completion-hook steering being missed. Pending tools are now paired with explicit non-execution results before the steering note; the next model request uses the new instruction. Checks occur before each tool batch and final acceptance, including quick resume and failing completion hooks with no automatic retries remaining. Already completed actions are retained without replay. Cancellation remains a failed/cancelled verification outcome.
+
+Validation: 99 integration tests passed (35 engine, 12 Compare, 8 steering, 14 service-command, 8 subagent, 13 hook and 9 permission-mode), plus all 15 autonomy unit tests, 329 UI tests, production UI build and strict all-target core Clippy. These are source/fixture checks. Existing bundle-size warnings remain; packaged desktop acceptance and full live-provider conformance are still open.
+
+The initial live recheck of Qwen3 14B and Hermes on an intermediate build did not pass independent grading: Qwen repaired repeatedly but still failed unhashable-list deduplication and reached its token budget; Hermes made an unsupported past-tense success claim with no file change. Those observations do not establish a coding-quality improvement or a general model ranking. The full flagship acceptance specification remains active.
+
+## Committed-build local coding recheck
+
+The same isolated five-case Python fixture was rerun on clean source commit `89f97097a7f2234273035d8f5860efe69892c9f0`, with recorded executable SHA-256 `954e4272831f1d597292bc76fbcf00136b5fef2d7ce08e73fbf03ced56ad313a`. Qwen3 14B passed all five independent cases after a failed model-requested check, a further edit and a successful check (7 steps, 58.67 s). Its action-promise guard did not fire, so this pass is not causal evidence that the new heuristic improved coding quality.
+
+Qwen3 4B Instruct, which passed the initial run, now repeated read/search calls and hit the task-token budget without editing (10 steps, zero cases passed, 32.61 s). Hermes received one `completion.retry`, then made unsupported past-tense edit/test claims without tools; all five independent cases failed and its application answer remained unverified (2 steps, 18.67 s). These variations rule out promoting a default based on one successful fixture. Rhea and Qwen3 8B were not rerun in this increment; their original failures remain recorded.
+
+All 79 observed INET connect calls in the committed-build suite targeted loopback. Sampled machine-wide GPU memory peaked at 10633 MiB including desktop use; this is not per-model VRAM. All three isolated services shut down without reported cleanup errors and no test inference server remained. Sampling defaults were unchanged and are not matched across models. The suite deliberately returned failure because two model cases failed; failed cases are retained rather than treated as harness validation success.
+
+Next: improve repeatability and model/template/tool compatibility with varied tasks, explicit acceptance checks and recorded generation settings; compare supported subscription coding routes using the same fixture. General failure-path command-receipt aggregation, including token-budget exits, remains a separate gap from the new promise-refusal receipt preservation. Complete native-window, release and all original acceptance work remains in scope.
+
+## Terminal verification evidence preservation
+
+The model evaluation exposed a failure path in which recorded checks survived in history but disappeared from the terminal job result. Finalization and interrupted-job recovery now share a task-scoped evidence snapshot: retain the last assessment verbatim unless later receipt events exist, append only those later receipts, preserve assessed stale states and output references, and mark an unfinished assessment unverified. No command is replayed and no filesystem scan is added to finalization. The UI distinguishes unfinished final verification from a failed individual check. Historical reassessment and non-success finalization also clear verified claims and red-green status.
+
+Five native regressions cover actual failed/passing configured commands followed by budget exhaustion, malformed provider output or cancellation; same-session/different-session isolation; and seeded interrupted-job recovery with one durable terminal event across two reopens. Three Store unit tests cover stale-summary preservation, ordered new receipts and misfiled receipt rejection. Reassessment covers passing and fail-then-pass receipt sequences across completed, failed, cancelled, interrupted and limit-reached tasks. Seeded recovery is explicitly not a SIGKILL experiment.
+
+Validation on this increment: 302 library tests passed (two prerequisite-dependent microphone/Whisper tests ignored), 104 integration tests passed (40 engine, 12 Compare, 8 steering, 14 service, 8 subagent, 13 hook, 9 permission-mode), all 331 UI tests passed, production UI build passed, and `cargo clippy -p shadowcode-core --all-targets -- -D warnings` passed. Existing bundle-size warnings remain. This source increment does not qualify the installed AppImage or complete the flagship acceptance matrix.
+
+## Live coding across supported vendor CLIs
+
+A shared opt-in fixture now uses the same three-function Python repair task and five evaluator-owned tests for native local models and explicit subscription CLI routes. The vendor runner uses isolated projects and ShadowCode profiles, existing CLI authentication, narrow forwarded approvals, independent sandboxed grading, fixed deadlines/output bounds and retained failure evidence. It refuses detected API-key overrides and requests no route fallback. Vendor-owned tools retain the vendor's own sandbox and access; the isolated working directory does not contain them. ACP login success does not establish billing source. One small task is not a general coding benchmark.
+
+The initial live suite exposed application and evaluator defects. Claude read files but Write/Edit were refused without a host permission request; the adapter had selected host prompts without registering the stdio permission tool. Codex completed the task, but the evaluator did not recognize its exact official shell wrapper. Cursor permission requests omitted typed input already sent in earlier updates. Antigravity's typed command fields differed from the common command field. Grok initially reported generic tool metadata that gained an execute kind later. All initial failures remain in the evidence.
+
+Commit `1052fac` registers Claude's stdio permission handler and returns the exact original input for one approved request, without persisting permission rules or widening modes. Six new tests include a subprocess fixture that writes only after approval, denial, read-only denial and request/input binding; five cases failed before correction. The implementation follows [Anthropic's SDK subprocess transport](https://github.com/anthropics/claude-agent-sdk-python/blob/main/src/claude_agent_sdk/_internal/transport/subprocess_cli.py) and [permission response protocol](https://github.com/anthropics/claude-agent-sdk-python/blob/main/src/claude_agent_sdk/_internal/query.py).
+
+ACP now retains bounded current tool metadata, merging partial updates according to the [ACP tool-call contract](https://agentclientprotocol.com/protocol/v1/tool-calls). Explicit values replace prior fields; omission/null preserves them. Completed calls/new prompts clear cached data, and mismatched supplied session IDs cannot mutate or borrow it. Completion records include the latest input and kind. Six unit tests cover changes, resets, redaction, limits, session mismatch and late kind discovery. Missing session IDs remain a compatibility path; this is not complete protocol conformance. The 128-entry/64 KB-per-entry cache limit measures serialized metadata, not total heap use.
+
+The evaluator correlates exact command evidence by unique call ID and actual output, using current ACP completion metadata. It accepts only the observed finite Codex wrapper, preserves strict approvals, and uses stable report-only call aliases before secret redaction. Ten evaluator tests pass. Existing adapter tests (19), Antigravity fixture (1; one explicit live case ignored), transport (15), accounts (12), handoff (4), steering (8) and permission-mode (9) suites pass, as does strict all-target Clippy. Commit `d663045` updates existing UI tool rows from completed typed inputs; it does not create duplicate starts. Focused UI transcript/activity regressions passed.
+
+The next live suite used native source `1052fac` and executable SHA-256 `47f19963ca0f9688e770816915efa2661475608766b063aca8c61d01c2ba3b7f`. Codex (`gpt-6-luna`), Claude (default), and Grok (`grok-4.7`) passed both observed-command and independent grading gates. Cursor (`auto`) and Antigravity (`gemini-3.8-flash-high`) produced code passing all five independent cases and their host approvals were accepted, but complete typed test output was absent/conflicting; their acceptance results remain failed. All five jobs completed, retained the original tests and shut down without reported cleanup errors. Claude and Codex reported subscription authentication; ACP routes reported existing login modes with the billing limitation above. Model prose is not used to fill missing execution evidence.
+
+
+## Current ACP output and offline receipt replay
+
+Commit `ffea0a8` retains redacted ACP `rawOutput` separately from approval metadata: at most 128 entries, each bounded to 8000 serialized bytes. Complete JSON stays typed; oversized output is explicitly marked as a truncated JSON-text preview. Null/omitted updates preserve the current value, explicit values replace it, and prompt/tool lifecycle boundaries clear it. Antigravity's observed normalized `command_line`/`working_dir` fields are also recognized. Nine ACP unit tests and eleven evaluator tests passed; strict all-target Clippy passed.
+
+A new two-route live capture used source `ffea0a8`, executable SHA-256 `f73b6e92ccbb9064ce7959cc1f2e27602ebce7b119a85a7426231c5fb92cb477`. Both independent five-case graders passed, original tests remained unchanged, and jobs completed with no reported cleanup error. The original acceptance results remain failed because that evaluator did not yet support structured raw output. Antigravity's first no-cwd execution failed; its subsequent explicit-project execution returned matching exact command/cwd, two zero exit-code fields, and five-test output. The claimed default-directory cause appears only in model prose and is not established. Cursor returned structured exit zero and five-test stderr alongside retained `Not in allowlist: python3` content; that contradiction remains unresolved.
+
+Commit `a7347f4` adds strict Antigravity-only grading for the observed six-field result, correlated to the expected session/task and a unique started/completed call. It rejects wrong commands/directories/statuses, conflicting content, truncation and unqualified output shapes. Fourteen evaluator tests pass. An explicit offline replay accepts Antigravity's saved receipt and rejects Cursor's while preserving both original live failures and the original source-capture SHA-256. Replay performs no provider calls or fresh independent Python grading. This does not turn the original live suite into a passing run or establish full provider conformance.
+
+## Current live subscription coding recheck
+
+A fresh five-route run used source commit `52d8b772adaba18ce7b8e22f60da4f371c4175e6` and executable SHA-256 `503e63e53c645f4b7bbdac271b41f63a712d187fc0e681ddc2ac2cbb16032ead`. It ran the same isolated task sequentially against Cursor, Antigravity, Grok, Codex and Claude, with no observed route fallback or event truncation. Cursor, Grok, Codex and Claude completed the task, produced accepted exact test-command receipts and passed all five independent cases. Cursor's earlier structured-output contradiction is now resolved by adapter-owned provenance that distinguishes the terminal output from a prior permission message.
+
+Antigravity completed and passed all five independent cases, but acceptance remains failed: the exact test command's permission request omitted its working directory and its structured completion reported an empty `working_dir`/`workingDir`. The successful output is not enough to prove that this specific command ran in the isolated project. Earlier `pwd` calls in that task did reach the project, but are not treated as proof of the later command's cwd. Preserve this as a live adapter/evidence gap; do not infer the missing directory from model prose or neighboring calls. The report is `/tmp/shadowcode-vendor-coding-current.json` on the test host and contains redacted, bounded event evidence. Subscription auth modes for ACP routes still do not independently establish billing source; Cursor's CLI reports Free.
+
+The fixture prompt now explicitly asks providers to set the project cwd when their tool accepts it. With that instruction, Antigravity ACP 1.2.1 passed a fresh isolated live run: the correlated `antigravity.execute` completion recorded exact command `python3 -m unittest -q`, matching project path in both normalized input and structured raw output, completed status, two zero exit-code fields, and all five tests passing. Its independent sandboxed grader also passed, with no fallback, truncation or cleanup error. The report is `/tmp/shadowcode-antigravity-cwd-recheck.json`. The same changed example passes all 18 local tests, including strict wrong-directory and conflicting-output rejection. This closes the observed working-directory receipt gap for this run; it does not establish broad Antigravity conformance or billing entitlement. The executable hash for this recheck is `5ff1d2275db6ac5e5856a018987647ef7639cf5413c39841975ba6c74322526e`.
+
+To restore a same-prompt comparison, the changed fixture then ran against all five routes: Cursor, Antigravity, Grok, Codex and Claude. All five passed the accepted command-receipt gate and independent five-case grader, completed without fallback or truncated events, and reported no cleanup error. Antigravity's exact command receipt again named its temporary project cwd. The bounded report is `/tmp/shadowcode-vendor-coding-cwd-recheck.json`; its executable SHA-256 is `5ff1d2275db6ac5e5856a018987647ef7639cf5413c39841975ba6c74322526e`. These are five small live task observations, not a general quality ranking or proof of each subscription's billing source.
+
+## Responsive Markdown and native worker qualification
+
+Commit `d5f1fe7` moves complete-document Markdown parsing for long visible responses (at least 8 KiB) into one shared worker, retaining the existing renderer, safety behavior, complete-document references/GFM semantics, copy behavior and 512 KiB default preview. The queue retains one active parse and only the latest pending request per mounted response. Superseded documents and retired-worker errors cannot overwrite current state. Worker unavailability uses the existing synchronous formatted path; parser errors show readable source without immediately repeating the failed parse on the main thread.
+
+Two matched Chromium control/worker pairs with 10,000 saved messages and a streaming 64 KiB response measured input p95 of 102.6/109.3 ms synchronously versus 39.9/40.5 ms with the worker. Long main-thread tasks fell from 202/201 to zero. Worker runs continued producing 134/136 visible updates; four fragments were pending at capture. This measures trusted key-event timestamp to a second animation-frame callback, a paint-opportunity proxy, not physical display latency. The synthetic producer shares the browser main thread; no independent native-producer throughput claim is made. Remote CSP still forbids workers and retains synchronous fallback.
+
+All 356 UI unit tests and all 50 browser tests passed, along with production TypeScript/Vite build and the bundle check excluding test-only transport. The 10,000-message fixture traverses all 79 pages and preserves access to every saved ID with at most 128 rows per page. Exact desktop/remote CSP fixtures cover real worker execution or fallback and final formatted/copy correctness. Existing bundle-size warnings remain.
+
+Commit `a7347f4` also adds `scripts/test-native-markdown.mjs`. A fresh native build served the exact production worker through `tauri://localhost`, HTTP 200 JavaScript, 194730 bytes, SHA-256 `1681fa324377db582410f5871a4dbebff3245e7d24ec19880afc668b5cbacd9b`. The actual native CSP header retained self plus Tauri script hashes; the worker returned the expected heading/table/reference/code tree with no CSP violations or model jobs. Desktop executable SHA-256 was `ddc2a7b97ab90a8972f3cce355b22fc6895c4a9e71427fb9c34fb131069ea87f`. Matching WebKitGTK driver 2.52.6 was extracted into task tooling, not installed system-wide. A second normal run with hardened cleanup passed; focused SIGTERM and unowned-PID fixtures verified bounded process/profile cleanup and refusal to signal the unrelated process. The script isolates its profile/workspace, disables CLI agents, uses offline configuration and verifies PID ownership before signalling.
+
+This native evidence covers Xvfb/X11 WebKitGTK worker compatibility only. Actual Wayland interaction, native input latency, AppImage/deb packaging, extended resource-growth qualification and all other original acceptance requirements remain open. These UI asset hashes describe the pre-branding build; later branding requires a fresh embedding check.
+
+
+## Recorded installer recovery before activation
+
+Commit `8737291` adds a private, versioned intent record before destination replacement. It binds canonical install roots, prior/candidate AppImage hashes, the prior active link, runtime tree identities and an owned staging basename. Runtime fingerprints include names/types/modes/file bytes/symlink targets, excluding timestamps. The existing installer lock covers retry and `--recover`; the latter requires no original candidate or checksum file. Recovery mutates only fully identified `prepared` states, parks an owned candidate runtime before restoring the prior runtime, and retires the active journal by rename once restoration succeeds. Edited, malformed, legacy and activation-stage evidence remains preserved and refused. Custom nonstandard active symlink layouts are explicitly refused before replacement.
+
+Bash syntax, ShellCheck and the installer suite passed. Eight actual SIGKILL paths cover interruption between/after runtime renames, interruption during recovery, repeated recovery, automatic retry, same-version reinstall and first-install absence restoration. Failure and tampering fixtures cover failed restoration, concurrent recovery/install, changed roots/links/files/runtime content and malformed or symlinked journal fields. The old `--recover` path failed before implementation. The real packaged-runtime fixture remains explicitly skipped because its built runtime bundle is absent. No user installation was touched.
+
+This is a bounded REL-04 increment. Automatic recovery after activation starts, launcher/desktop metadata rollback, first-window/database downgrade behavior and power-loss qualification remain open. An interruption after journal retirement can leave harmless staging cleanup artifacts; no complete orphan-retention policy is claimed.
+
+## Approved branding
+
+Commit `7f89a19` applies the user's final **SHADOWCODE** image, which superseded the earlier SHADOWFETCHCODE selection. The original PNG is preserved byte-for-byte in `assets/branding/shadowcode-original.png` (SHA-256 `653b36e0273c06dfc9ad36489954a6f9dc9ea1aa27fbf1d40195e4a06686920f`). A built-in image-generation extraction supplies the transparent emblem (SHA-256 `3bdda4e1d0e8a7149d3aea358e08eea9d3338f345604c0da0344ae3f8a3fc573`); its exact prompt/provenance is recorded alongside the sources. Deterministic ImageMagick resizing generates native/hicolor/web/PWA/Apple/favicon derivatives and self-contained raster SVG compatibility wrappers. Product, CLI and storage identifiers remain intact.
+
+Production build and a targeted production-UI browser check passed for startup, sidebar, welcome, onboarding and remote pairing; six icon variants decoded with expected size/alpha and no page errors. The complete original is used in the README and social preview. No GitHub repository setting or installed desktop icon was changed; native embedding is rechecked after the final combined build.
+
+## Exact Hermes template confirmation
+
+Commit `b18fd8c` selects the bundled Hermes tool template only for exact architecture/tokenizer/model-name/default-template metadata, preserves model files and Ollama configuration, and requires runtime template confirmation before publishing a usable lease. The UI distinguishes the original embedded template from the applied profile. The first real offline qualification failed before inference: the pinned runtime reported 5,003 bytes rather than the 5,004-byte upstream asset, producing zero model requests or tool calls. The failure and unchanged source/tests remain retained.
+
+A separate loopback-only diagnostic made only health/props requests and proved the runtime removes exactly one final LF, matching the pinned llama.cpp Jinja lexer. Commit `098672f` accepts only the two complete known identities, never generic whitespace equivalence. It records the unchanged source hash separately from the actual reported hash and exposes only hashes/sizes/types in mismatch errors. Four profile unit and seven Hermes integration cases pass, including an agent tool round-trip for the normalized identity and refusal/reaping for missing, wrong-type, altered or unavailable template reports. Fresh coding qualification remains required.
+
+## Source-verified GLib backport
+
+Commit `c180da0` remedies RUSTSEC-2024-0429 in the GTK3/WebKit-required GLib 0.18.5 using the upstream two-line mutable-out-pointer correction. The published crate checksum and complete 121-file inventory are pinned; all other published bytes, actual package version, MIT license and copyright remain unchanged. Cargo resolves exactly one verified local copy outside workspace membership. Independent review compared the downloaded archive with the source tree and exact upstream correction.
+
+The original optimized iterator fixture crashed with SIGSEGV; the same fixture against the exact patched release library passed six cases. Thirty-five integrity, notice and release-gate tests passed with none skipped. Tampering with both source and adjacent hash manifest is refused by an independently pinned full inventory digest. Dependency notices include original archive identity, modified tree identity, patch provenance, license and copyright. Native and release workflows now require the backport integrity and optimized behavior gate.
+
+This is a source backport, not a clean version-only scanner result. No synthetic version or advisory suppression was introduced. Final application/package builds and native package/window qualification remain separate.
+
+## Approval reconciliation and Cursor execution provenance
+
+Commits `53fec09` and `b49d930` bind forwarded approvals to retained typed operation identity and continue reading the vendor protocol while a decision is pending. The prior runner reproduced stale command and edit grants in two real subprocess tests. The corrected runner bounds queued approvals and readable-frame reconciliation, holds observed partial frames until completion or failure, and retires dialogs on matching tool completion, turn/session transitions and pause. Identity uses a private non-serialized digest of the raw tool ID rather than display-redacted fields. These checks concern observed protocol state; they cannot authenticate an external vendor's eventual execution.
+
+Cursor completion now records host-owned field provenance and explicit command exit status separately from retained earlier approval reasons. Nonzero shell results fail even when the vendor labels the tool completed. Missing, evicted, reused or incomplete history cannot be reconstructed as a permission-free success. The strict coding grader requires an exact current command receipt with complete retained history and matching resolved approvals; historical ambiguous captures remain failed. This does not establish vendor sandbox enforcement or billing source.
+
+The combined native integration run passed 82 cases: 17 approval subprocess, 15 transport, 6 Claude permission, 19 CLI adapter, 12 account, 4 handoff and 9 permission-mode tests, with none ignored. Final focused units passed 21 ACP, one private-identity and three framing cases. An earlier unchanged aggregate-text test exceeded its five-second outer deadline once; its isolated rerun passed in 3.12 seconds and the combined run passed without a timeout increase. The failure remains recorded. Review then reproduced a malformed-line streak not resetting on a valid approval; a two-line follow-up restores the existing protocol bookkeeping, and all 18 approval subprocess tests pass afterward.
+
+The integrated UI passed 363 unit tests, production TypeScript/Vite build and production test-transport exclusion. A recent browser run passed all 50 cases; it predates the final vendor formatter additions. Production source at `b49d930` built both coding examples and the desktop and passed workspace/all-target strict Clippy. The final malformed-streak follow-up requires its own rebuilt executable before live qualification. Dependency GLib emits its preserved upstream warnings; no application warning suppression was added. Fresh Cursor/Hermes calls, native windows, packages and GitHub checks remain distinct qualification steps.
+
+## Synchronized Compare acceptance
+
+The additional tests at production source `5c7f30f` exercise CMP-06 and CMP-11 through real services, Git repositories and owned subprocesses. Keep/Discard overlap is established by polling the second dispatch while the first is held at an actual source apply or worktree removal. Both project content and durable comparison/score records are checked after completion, profile reopen and two cleanup retries. The responsiveness case proves project B remains usable while project A has a genuinely live stopping process; it does not infer responsiveness solely from separate mutexes.
+
+All 15 tests in the complete Compare binary passed, with no failures or ignored cases; the count includes child-entry harness tests. Strict Compare-target Clippy and formatting passed. Initial fixture failures are retained: a namespace-local PID was incorrectly interpreted as a host PID, then Bubblewrap teardown prevented a shell trap from holding the intended stop barrier. The final latency fixture runs in a separate test subprocess with an explicitly unavailable Bubblewrap probe and the existing fallback process path. Parent PATH, production source, timeouts and sandbox policy remain unchanged. No production defect was demonstrated by these failures. Unsaved external editor buffers, complete index flags/extensions, filesystem deletion faults and full native/package parity remain separate acceptance work.
+
+## Native integration fixture repairs and worker gate
+
+GitHub native run `36293706998` on `5c7f30f` passed source formatting, strict Clippy, desktop build and preceding test binaries before failing an obsolete ACP initialization fixture. Commit `0a78dc5` supplies the required protocol version and resume capability; the eight ecosystem and 19 CLI-adapter tests pass, including protocol-refusal checks. The full local workspace continuation then exposed another obsolete fixture: `model_routing` expected a successful file write and model prose to satisfy required verification without configuring a check. The corrected fixture records the write as ordinary command evidence and runs an exact nonmutating configured acceptance command. Its paired negative case proves the same commands and prose remain blocked/not_run without configuration. Ten routing and eight goals tests pass. Neither correction changes production verification rules or timeouts. The complete workspace and remaining downstream gates must still run on the integrated source.
+
+Native and release workflows now require the actual embedded Markdown worker probe and preserve its diagnostics. Release receipts bind the changed command and explicitly limit this gate to the X11 source binary; it does not certify Wayland or packages. All 26 release-gate/publication fixtures pass. The probe validates assets before creating a private profile and retains cleanup evidence when launch ownership is unknown. Real ELF startup-failure fixtures reproduced and fixed an app leak before API readiness and a TERM-ignoring child leak after its detached parent exited. Final cleanup uses exact executable/profile/workspace and PID/start identity, with live process-group anchors. Matching app/child groups are reaped; unrelated sentinels survive; wrong-workspace and unknown launches are refused and their profiles preserved. These fixtures do not establish containment of descendants that escape into an unobserved session. The successful earlier real X11 probe preceded this final cleanup change; fresh native qualification remains pending.
+
+## Fresh desktop and publisher-authentication foundation
+
+Clean `c756f8a` passed formatting, strict workspace/all-target Clippy and the desktop build; the pinned binary is SHA-256 `914d99eee9a0ab92d69a8e837841f395765e7ca600588294a18afe9b5ac4586f`. The final X11 embedded-worker probe passed against that binary and the current `index-BBQpeuSA.js` bundle; all tracked app/driver group members exited and its private profile was removed. The separate Wayland outcomes are recorded in UI-04. The full Rust suite initially stopped because the custom target directory was not supplied to its real-process fixtures; the corrected invocation explicitly supplies `SHADOW_DESKTOP_BINARY`. Preserve that invocation failure separately from application failures and require the complete rerun.
+
+`docs/RELEASE_AUTHENTICATION.md` specifies the bounded signed envelope, canonical public-key policy, offline verifier and integration boundaries. Independent review reproduced Node accepting a private-key PEM where Bash accepted only public keys; both verification paths now require canonical public SPKI PEM and refuse appended/duplicate blocks, while signing retains private-key support. The integrated helper and release-gate/publication suites pass all 77 tests. The shell verifier needs Bash, OpenSSL 3 and GNU runtime utilities, never executes the candidate, and retains only exact verified snapshots in a new private directory. No production keys were generated or installed. The current installer/publisher still need explicit integration, durable replay state, trusted bootstrap and a separate protected signing job; these helper tests do not make a release authenticated or ready to publish.
+
+The following secret-scanner review reproduced four Ed25519 leaks in disposable Git fixtures: the old PEM-body length threshold missed short private keys, and a zero-context staged diff omitted unchanged PEM delimiters. The scanner now recognizes the 64-character Ed25519 PKCS#8 body and inspects complete changed index blobs, even when the working file is sanitized or deleted. Eight regressions pass, preserving public SPKI/short fake fixture acceptance and checking that diagnostics never print key values. The combined scanner, authentication and existing release suites pass all 85 cases; normal and staged repository scans pass. These fixtures use ephemeral keys and persist no key material in this repository.
+
+GitHub run `36295410486` and the explicit-binary local workspace run both reached the same final observed failure in `tester_route_budget`: another obsolete arbitrary-command-as-verification fixture. The local run passed 789 tests and failed that one, with seven ignored, before later binaries were skipped. Commit `c1f436b` configures and asserts a real nonmutating check while preserving the 4096-token limit and eight-second deadline; all five budget tests pass, including context overflow refusal. All four real-engine process/restart/resource tests passed after the desktop path was supplied correctly. A full rerun remains required; separately, the new CMP-04 filesystem-error regression demonstrates a cleanup retry defect that is under repair and is not part of these passing counts.
+
+## Explicit sandbox scope and refusal evidence
+
+Settings now identify ShadowCode's built-in command tool as the scope of its sandbox. Background processes, lifecycle hooks, interactive terminals and vendor tools have their different boundaries stated explicitly. Every host-owned background-start approval, including special command classifications, adds the unsandboxed file/network disclosure. Native command results retain the historical requested `network` field and additionally report `network_requested` and `network_enforcement`. Landlock fallback reports TCP-only enforcement only when that restriction was requested and the kernel supports ABI 4 or newer; ShadowCode's V5 rules do not enforce UDP or process isolation. The settings no longer promise full network exclusion when fallback is allowed. This matches the [kernel's TCP ABI boundary](https://docs.kernel.org/userspace-api/landlock.html#tcp-bind-and-connect-abi-4).
+
+All 20 native-tools/background integration tests pass on the final sandbox source. A new actual ToolExecutor test uses separate subprocesses for missing bubblewrap, a failed availability probe and a failed launch; each refuses without writing the command marker, and the launch case proves only one attempt. Explicitly changing policy to permit unrestricted execution runs the same command once and reports requested network off with no enforcement. These stand-ins verify host refusal/reporting, not Linux namespace or Landlock containment. The initial launch fixture mistakenly relied on a test environment variable that the runner correctly removed; that failure is retained, and the wrapper now derives its marker from its own executable path. Two focused settings tests, production TypeScript/Vite build, formatting and test-transport exclusion pass. Fresh desktop/package and full integrated validation remain required after the concurrent cleanup repair stabilizes.
+
+The subsequent GitHub native run `36296234022` on `985d48f` passed the complete Rust workspace step, strict Clippy, desktop build, native CLI/stress/PTY checks, MCP transports and official-client interoperability, native window and embedded-worker checks. The workflow was still running its build/package stages at this checkpoint; it is not a complete green native workflow claim. UI and packaging-script workflow `36296233983` passed. This remote evidence precedes the new local sandbox and quarantine changes.
+
+A separate authenticated-installer prototype passes 23 installer fixtures and 51 authentication fixtures, including actual SIGKILL recovery, legacy schema1 recovery and verified-snapshot execution. Its adapted original suite exercises 17 scenarios and explicitly skips the real-runtime scenario. Production installer/workflow integration, trust bootstrap, signing-key policy, native packages and post-activation/database recovery remain open. These scratch results do not authenticate a production release.
+
+The reviewed Bash parser/signature routines are now shared through `scripts/native-release-auth-lib.sh`, loaded only from the verifier's trusted script directory. The production verifier retains its command-line interface and verification rules; the installer is not switched over by this extraction. Native CI triggers on changes to the library. All 85 authentication/scanner/release fixtures pass on the integrated helper, with Bash syntax and ShellCheck clean. Installer and publisher policy integration remain separate pending work.
+
+## Quarantined cleanup and authenticated publication checkpoint
+
+Commit `b4a6550` fixes actual Git disposal failure recovery on Linux. Compare/worktree/task integration passed 43 cases; focused cleanup checks, formatting and strict core/all-target Clippy pass. Independent review caught and corrected original-path replacement and symbolic-ref deletion hazards before integration. The journal retains bounded ownership, Git index/admin backup and surviving-entry evidence; retries never unconditionally delete ambiguous data. Actual subprocess crash recovery uses the journaled quarantine path.
+
+An unchanged optimized full-project fixture passed on 22,501 entries and 4,099,897,035 logical bytes: 2.512 seconds preparation and 6.073 seconds disposal, excluding build/copy time. The final required CI UI scope passed separately on 16,751 entries and 240,440,462 bytes: 0.562 seconds preparation, 2.105 seconds disposal, exactly one executed test and no ignores. These are warm local disposable copies, not cold-disk or universal latency guarantees. The full debug run exceeded the existing 30-second per-walk deadline and safely retained the checkout; its failure remains recorded.
+
+Cleanup still holds the process-global creation mutex during hashing, so unrelated repository creation can wait. Common-directory disposal locks do not yet cover every create/repair/remove caller. Each walk has cooperative cancellation/deadline checks and bounded content (8 GiB/131,072 entries), not an interruptible kernel-IO deadline. Oversized/slow/ambiguous cases remain pending for review. Same-user malicious modification of private quarantine is outside its guarantee. These limits are open work, not covered by the earlier two-project status/cancellation result.
+
+The reviewed publisher implementation and separated release workflow now require authenticated private snapshots and current gate receipts before publication. All 112 integrated release, authentication and secret-scanner tests passed, including mocked interrupted uploads, immutable retries, signer/publication refusal of missing cleanup evidence, and real OpenSSL checks with ephemeral keys. The shared Bash verifier library is committed separately as `cb9c880`. No production key, public trust, protected environment, release version or published asset changed. Provisioning and tracked-installer integration remain open; the workflow fails closed until required owner configuration exists. Full source/desktop/package/Wayland qualification must use a fresh integrated commit and binary rather than the previous desktop build.
+
+
+## Integrated signed installer and approval reselection repair
+
+The signed installer is now integrated with the reviewed verifier and durable accepted-release state. It resolves its actual trusted bundle directory before loading policy or helpers, rejects `--unverified`, rejects releases below the fixed authenticated-release boundary, and requires the signed AppImage artifact role before candidate execution. Signed historical receipts support interrupted-install recovery without lowering the highest accepted release. Production trust, private-key custody, public fingerprint distribution and the first signed release version remain unprovisioned; no published asset has changed.
+
+The combined installer, authentication, publication and scanner fixture run passed 144 tests with none skipped. The adapted shell installer suite exercised 17 scenarios successfully and explicitly skipped its real bundled-runtime case because `packaging/llama.cpp/bin` is absent. That skip remains fatal in required release qualification. These fixtures do not establish clean-host installation, first-window/database rollback, activation-stage recovery or a production authenticated release. README and release notes document the compatibility change and source-build development path.
+
+Fresh desktop source `4970345` passed formatting, strict workspace/all-target Clippy and desktop compilation. The full local Rust run completed 67 test binaries with 903 passing cases, one failure and nine ignored cases. The failure was a nondeterministic cleanup fixture: a newly created directory reused the deleted administration parent's inode, so the deeper content guard refused safely instead of the expected parent-identity guard. The corrected fixture retains a reused allocation before recreating its parent; all 24 worktree integration tests then passed. The original full run remains failed, and the combined source requires another complete run.
+
+Two preserved Wayland runs against the pinned `4970345` binary passed backend connection, embedded-worker/CSP checks, keyboard focus and clean quit. The diagnostic run also passed persisted themes after allowing the actual React render to settle. Resize still returned unchanged dimensions. Cancellation could not begin because selecting the already-selected conversation cleared its pending approval until the 15-second refresh. This actual UI defect is fixed in `415bdcb`: the feed owns session-scoped approval state, reselecting a conversation preserves its cards, and changing conversations hides old cards immediately. All 367 UI tests, production TypeScript/Vite build and test-transport exclusion pass. A fresh desktop must qualify the correction before native cancellation can be claimed; clipboard and resize remain open.
+
+GitHub native workflow `36296234022` on the earlier `985d48f` completed successfully, including its build/package stages. Checks workflow `36298819187` on `4970345` passed; its separate native workflow `36298819199` was still running when inspected. Neither remote checkpoint includes this installer/UI increment.
+
+
+## Complete source run, live model metadata and terminal readiness
+
+Clean source `7aaea06` passes formatting, strict workspace/all-target Clippy and desktop build. Its complete local Rust run reports 916 passed, zero failed and nine intentional ignores across 67 binaries, with the actual built-project cleanup fixture transferred to its separately required optimized gate. Desktop SHA-256 is `de572c94d67db4b42dd6d5234ecb7431f50ac29d3229a017cd57d8ca39acc3fb`. The runtime-backed disposable installer fixture now also passes all 18 shell scenario groups with no skips; it uses verified copied runtime bytes, synthetic AppImages and ephemeral fixture keys, not a new production package or clean host.
+
+Native coder requests now retain content-free request/response observations and account complete reported usage from failed attempts once before retry/budget admission. The 74 focused tests and strict core Clippy pass. A fresh bounded Hermes run on the same source reported two 2048-token-capped requests, 10,417 prompt tokens and 43 generated tokens; both responses were accepted, so the failed-attempt accounting branch remains fixture-tested rather than live-qualified. The only tool was `system_info`, followed by progress-only prose. No file changed and zero of five independent coding cases passed. Stored verification correctly stayed `not_run`/`verified=false`. Source review separately identifies a missed progress-only phrase and host information incorrectly counting as workspace inspection; their draft regressions are not implemented or run yet. No model default, template or budget changed.
+
+The fresh COSMIC/Wayland run passed exact UI approval and keyboard cancellation, with one cancelled terminal event and owned-child exit. Instrumentation observed all requested resize calls reach GTK while its window was tiled on all four edges. Matching GTK Wayland source retains fixed dimensions in that state. A separate private Weston 13 headless/Pixman session then passed all seven executed native checks together: backend, worker/CSP, focus, resize 1380→520→1380 with no overflow, themes, approval/cancellation and native quit. All owned app/driver/compositor/bus/helper processes exited. Clipboard remains intentionally skipped, so the full checklist still reports failure; physical desktop input, IME, mixed DPI, performance and packages remain separate. The first floating run's missing-sidebar setup failure and the original quit-parser SIGCHLD false positive remain preserved; neither historical report was relabeled.
+
+GitHub Checks on `7aaea06` passed. Native workflow `36300303104` passed its Rust suite but failed waiting for the real-PTY approval dialog. CI output and an accelerated Node 22.22.3 reproduction show the fixture pressing F4 before the terminal displays its backend-visible approval; the terminal polls every 350ms. The accelerated before/after pair on CI's Node 22.23.2 both passed because readiness could catch up before key handling, so the failure is timing-dependent rather than deterministic. The fixture now waits for the actual rendered pending indicator within its unchanged 15-second deadline, using owned-PTY width changes to request complete ANSI frames. It retains backend identity/no-grant checks and the explicit Deny→Allow→Enter flow. Normal and accelerated corrected runs pass all existing assertions with eight loopback fixture requests using CI's Node 22.23.2 and the pinned desktop. The applied repository script also passes locally with seven fixture model requests on that exact binary and Node version. A new GitHub run must still validate this correction.
+
+A separate timeline presentation fix has 389 passing scratch UI tests and TypeScript validation: cancelled/stale/incomplete checks no longer receive green completion icons, and ordinary successful commands remain unverified without configured-check receipts. It is not yet integrated or natively qualified. The reviewed worktree-lock refactor also remains scratch-only and uncompiled. These prepared changes and the full original acceptance specification remain open work.
+
+## Truthful execution evidence and fresh native qualification
+
+Commits `e9f6a1d` and `e66f356` integrate neutral cancelled/stale/unverified timeline states, retained failed tool attempts, and separate host/workspace inspection evidence. An arbitrary successful command cannot establish configured-check verification. A reproduced current-action progress phrase no longer completes authorized coding work; cumulative retry limits remain bounded, and prior shell commands are not replayed. Baseline completion regressions failed before correction; 389 UI tests, production TypeScript/Vite build and bundle exclusion pass.
+
+The complete local workspace at `e66f356` passed 929 tests, zero failures and ten ignores across 67 binaries. The actual built-project cleanup ignore belongs to its separately required optimized gate. Later production fixes `e804e83` and `b66bb0e` remove duplicate command-job user prompts and redact complete recognized private-key PEM payloads. The command regression verifies one stored/rendered prompt per job through history/reopen, while two explicit jobs still execute twice. Later combined validation passes 421 native tests with three ignores, plus five real-process recovery tests without ignores, formatting and strict workspace/all-target Clippy. This is scoped later validation, not a full-workspace pass on the latest source.
+
+GitHub `d295a03` Checks passed but Native failed a crash-fixture counter assertion compatible with a request-observation race. `d832dfc` makes the concurrent bounded fixture observe the complete held request before SIGKILL and includes a third-request replay negative control. All five real-process tests pass against the fresh desktop. GitHub `e66f356` Checks failed one stale browser expectation after the other 49 passed; `fd39f5a` requires the correct neutral unverified status while preserving command-output assertions. The corrected complete browser suite passes all 50 cases. Remote Native `36302732737` was still building release examples when last inspected; no full latest GitHub success is claimed.
+
+The fresh desktop at `d832dfc`, rebuilt unchanged at `fd39f5a`, has SHA-256 `4186ce5ef05c810d8f7cc4e26b67530d9146e272e883c51202c25490082070bb`; main UI hash is `fda7a6726a7958fcaceef5e4b599a77c682d7c54b38248ae8d949108b46e52d4`. The headless Weston copy action caused a real SIGSEGV following missing-keyboard-seat GTK warnings; no usable backtrace was available. A separate run with clipboard skipped passed seven executed stages and proved neutral cancellation status and one user prompt both live and after reload. Its full checklist remains failed.
+
+A subsequent private authenticated Xvfb/Weston-X11 setup supplies a virtual keyboard/pointer seat while the app remains an actual Wayland client with DISPLAY/XAUTHORITY removed. Its first readiness attempt failed before app launch because xdpyinfo emitted 83,619 bytes beyond the fixture's 65,536-byte bound. A private-display-only diagnostic reproduced ENOBUFS; a bounded 1MiB capture passed unchanged display assertions. The corrected full run `run-R7ZOLG/probe/run-8nwk66` passed all eight stages including the original keyboard clipboard round-trip. Pins remained unchanged; app/driver/compositor/bus/Xvfb/helper cleanup completed, authentication bytes and private profiles were removed, and logs were bounded/drained. The prior failures were not relabeled. This qualifies only the documented private virtual-seat setup and does not complete UI-04.
+
+One unchanged-budget/template Hermes coding run on clean `d832dfc` made two accepted requests with caps 2048 and 1740. Usage was 11,790 prompt plus 1,388 completion tokens, 13,178 total. Three edits failed; one real command ran five tests with five failures. Successful system_info and search_code correctly produced both inspection scopes. Files/tests stayed unchanged and independent grading passed zero of five cases. Final prose falsely claimed edits and misstated counts while acknowledging failure; durable verification remains not_run/false with execution_failed=true. All 26 INET connections were loopback; three host system-bus AF_UNIX connections are recorded separately. Normal runtime shutdown and prior artifact preservation passed. This run exercised neither follow-through retry nor failed-attempt accounting, and does not establish model quality improvement or eligibility as a default.
+
+That run exposed a further deterministic defect: redaction collapses distinct provider tool IDs to one placeholder. Synthetic replay proves wrong receipt output and swapped path/output association for reverse-completing reads; source inspection also finds TUI upsert collisions. Native executions now receive fresh engine-owned IDs for events, approval decisions and receipts, while model replies preserve their protocol IDs. Receipts bind directly to the emitted completion event. New identity cannot reconstruct missing historical associations. The worktree/task lock refactor and diagnostics preview/export remain in preparation. Production trust/signing, clean-host artifacts, database/activation rollback, complete provider conformance and all remaining original acceptance cases are still open; the flagship goal remains active.
+
+The identity repair reproduces two native correlation failures before correction and passes a combined 463 native tests with three existing ignores across six binaries: core library, engine tasks, model streams, native tools, permission modes and MCP application. Tests cover secret-bearing/reused provider IDs, concurrent reads, restart, manual/automatic alias approvals, and exact receipt completion ownership. Provider IDs still belong in the private model protocol tape; no claim is made that all stored data omits those IDs. Vendor CLI identity and ACP permission association remain separately scoped.
+
+Decoder review additionally reproduced two malformed-response execution paths: distinct indexed calls sharing an ID wrote both files before later context rejection; unindexed object arguments sharing an ID coalesced and wrote only the second file. The decoder now refuses duplicate response groups before exposing any executable calls. Same-array detection is retained until finish so later usage frames remain chargeable; valid continuation fragments and cross-turn ID reuse remain supported. Regressions preserve the original failures and prove zero writes, unchanged unrelated files, one charged failed attempt and durable restart evidence.
+
+Browser commit `38d677a` retains completion event IDs and resolves receipts only to an exact same-task exec completion. Missing/invalid explicit references cannot fall back to a possibly reused call ID; legacy fallback requires one non-redacted candidate. Historical ambiguous completions stay separate from unmatched starts rather than inheriting a false path. Event-derived row keys remain stable through streaming and unique on replay. Seven failing baseline UI assertions now pass. All 401 UI tests and production build/bundle checks pass; the full 50-case browser suite passed before the final non-string-reference guard, whose three additional tests and later production build pass. Historical TUI/ACP placeholder damage is not retroactively repaired.
+
+
+At clean `3ea99d3`, the fresh desktop SHA-256 `2c402008be00650b66e6b1f1ab4504018f388949d93de5653463114075de8e60` passed five real-process recovery tests, the actual PTY suite and all eight private virtual-seat Wayland checks. The final native run retains same-WebView clipboard success, neutral cancellation through reload, unchanged pins and complete owned-process cleanup. It does not qualify physical desktop or packaged behavior. GitHub Checks on `3ea99d3` passed; the earlier complete Native workflow on `e66f356` also passed including packages.
+
+The subsequent full local `3ea99d3` Rust run passed 376 cases and failed one, with four ignores across six completed binaries; later binaries were not reached. `autonomy_021::provider_chaos_does_not_invent_output` still expected a response with duplicate IDs to succeed. An isolated execution reproduced that exact stale assertion. Its correction requires duplicate rejection and explicitly verifies that a distinct two-call response, valid fragmentation and missing-ID synthesis still work. The corrected exact test, strict target Clippy and formatting pass. Only this test and the ledger changed after the fresh desktop qualification; production bytes are unchanged. A new full-workspace run remains required; the failed aggregate is preserved.
+
+## Repository-scoped worktree ownership and live task boundaries
+
+Core worktree mutations now serialize by canonical Git common directory, with a stable advisory lock shared across processes. Short profile admission retains the 64-record bound while allowing unrelated repositories to proceed. Creating/failed records remain durable recovery reservations. Task metadata uses repository-then-record locks; record-only reads and stopping remain possible when a source disappears. Failed startup cleanup retains its task/recovery link for retry.
+
+Discard reserves engine admission before cancelling and joining every unfinished job in the checkout, including queued turns and child jobs. Child admission honors the same reservation. The reservation remains held through filesystem cleanup and session relocation. Background processes are separately checked and reserved; a live background process keeps its checkout until stopped. Missing checkout reservations use a canonical existing parent and normalized final component. Compare cleanup and result application use the same background boundary.
+
+Keep/Apply validate live source, checkout root, common directory, Git administrative directory, registered branch and managed base before staging. A switched user branch or foreign Git pointer is refused without changing its index or HEAD. This covers observed pre-existing identity changes and cooperating app jobs; it does not make external Git/editor activity obey ShadowCode's reservations.
+
+Validation at this increment: core and task global-lock regressions each failed before their respective fixes. Five additional before failures reproduced task/Compare commits on a changed branch, a foreign Git directory commit, active-A/queued-B removal, and active-background removal. Afterward 27 worktree unit tests passed (one separately required optimized cleanup case ignored), plus 19 Compare, 3 task and 26 worktree integration tests. Held original-checkout scans prove new command/background admission is refused before rename; failed initial admission → failed abandonment → cleanup retry is exercised. Strict workspace/all-target Clippy and formatting pass; the 34 existing vendored GLib warnings remain. A Compare recovery test failed once in the first aggregate and passed in the subsequent full integration run. A separate deterministic index-timestamp experiment reproduces missed same-stat file edits; its production correction is the next increment. These results do not replace fresh whole-workspace, optimized built-project, native-window or package qualification.
+
+## Preserve Git's racy-index detection when capturing a working tree
+
+Snapshot creation now copies the index from one open source handle and retains its original modification time. Git uses that timestamp to decide whether cached file metadata is trustworthy; a fresh timestamp on the temporary copy could hide a same-size edit and produce a stale snapshot. Copy/timestamp failures now fail rather than quietly claiming a captured source index. The existing split-index fallback remains unchanged.
+
+A deterministic regression configures coarse stat comparison, creates a racily-clean index entry, edits bytes while retaining size/mtime, and includes a force-added ignored file. Before correction the captured content was `before` instead of `after!`; afterward the exact edit and force-added content are present, while source index bytes/mtime, HEAD and working bytes are unchanged. This reproduces the stale snapshot defect independently of the earlier intermittent recovery assertion; it does not prove the precise timing of that historical failure. Fresh combined workspace and optimized cleanup qualification follow this commit.
+
+## Manual file editing with live revision review
+
+The Files panel previously rendered only a 12,000-character read-only preview. It now opens complete UTF-8 files within the existing 4 MB workspace limit for manual editing. A save requires the exact hash returned when the file was opened or explicitly reviewed, plus the project's existing trust, permission and mutation reservation. The native route preserves executable mode and does not stage or commit. Its revision read uses a nonblocking, no-follow confined file inspection; a symlink or nonregular replacement cannot make the editor's polling read hang. A deleted file can be recreated only through an explicit action using the `missing` revision, which fails if another writer has recreated it first.
+
+Open files keep separate drafts in app memory across drawer tabs and project switches. The active editor checks disk revision periodically and on focus. If an agent or external editor changes the file, the draft remains intact and Save is disabled until the user reviews the original and current disk versions and chooses the disk version or explicitly adopts its revision as the new save base. Files that are deleted or become unreadable have separate states. Closing a dirty file hides its editor but keeps its draft; browser navigation or native window close asks before losing unsaved work. Tab indents text or selected lines without replacing them, Shift+Tab outdents lines, Escape returns keyboard focus to the editor controls, and Ctrl/Command+S saves.
+
+The new native integration test proves stale-save refusal, read-only and trust gates, missing-file recreation, no staged-index change, executable-bit preservation and no-follow revision reads. The browser fixture exercises the rendered conflict/review/save path; the complete UI run passed 402 unit tests and 51 Playwright cases, with production build and test-bundle exclusion also passing. This is a functional first editor, not yet a full IDE-grade editing surface: syntax support, long-file latency, crash/restart draft recovery, physical-desktop qualification and the rest of CMP-08's index flags remain open. External processes that ignore ShadowCode's workspace reservation can still race a filesystem write after its last hash check.
+
+The broad Rust workspace pass started before the final editor read-path revision and was not accepted as final qualification. It exposed one response discrepancy in a concurrent worktree discard fixture: the first response carried an in-memory timestamp while a repeated acknowledgement had traversed JSON persistence and differed by one f64 ULP. Discard now reloads the persisted record before returning, preserving the strict whole-record idempotence assertion. Three real-process fixtures also failed to start because the local qualification command omitted `SHADOW_DESKTOP_BINARY` while using a custom Cargo target directory. Those are command-environment failures, not demonstrated application regressions. Fresh exact-source native qualification is required before claiming a complete workspace pass.
+
+## Profile-backed editor draft recovery
+
+The next increment adds bounded editor recovery records to the existing profile SQLite store (schema 27). A record contains the project-relative path, original text and hash, current unsaved text, a random record revision and timestamp. It does not write source files or stage Git changes. The profile database is private to the local app; remote clients cannot call the recovery routes. Records are limited to 32 per project, 32 MB of text per project, and the editor's existing 4 MB limit for each original/draft. Writes and deletes require the previous record revision, so a second window's newer draft cannot be silently replaced or removed. Requests also carry the selected project identity, preventing a queued write from following a project switch.
+
+The UI restores acknowledged drafts after window reload, keeps the existing disk-revision conflict review, and reports whether the latest recovery copy has finished saving or failed. A new rendered browser case proves a draft survives reload and is cleared after a successful file save; the existing rendered conflict/save case also passes. The native service test closes and reopens the profile to prove the draft survives restart without touching project bytes, refuses stale overwrites and deletes, and checks project separation and path confinement. A migration fixture starts at schema 26 and opens the new table; the remote policy has a focused refusal test. These are focused passing results pending full current-source qualification.
+
+Recovery is acknowledged per completed write, not per keystroke: an in-flight edit at a hard crash can be lost. A failed recovery write remains in UI memory with a visible warning. Discard waits for an in-flight recovery write and confirms record deletion before clearing the visible draft; the rendered delayed-write fixture exercises this ordering. Closing a clean file also waits for pending recovery cleanup, keeping its retry control visible on failure. A crash between an actual file save and deletion of its recovery record can leave a stale copy to reconcile on reopening; the active editor detects identical disk content and clears such a copy when opened. A failed post-save cleanup is visible and retryable. At this earlier increment, multi-window review after a CAS refusal and exact preservation of the last in-flight keystroke remained open; the later UI increment below addresses the reviewed CAS path. This increment does not close all editor recovery or CMP-08 acceptance work.
+
+Queued recovery snapshots now skip a write if a newer edit or deletion superseded them while an earlier write was in flight. The last draft still writes through the revision-checked API, and discard still waits for the write chain. A rendered test delays the first request, makes two more edits, and confirms that only the first and final drafts are sent and that the final recovery record is saved. The complete UI run passes 405 unit tests and 55 browser cases, plus production build and fake-transport exclusion. This reduces redundant full-file writes; it does not promise that every keystroke is durable or remove the need for measured large-file latency testing.
+
+A competing window's recovery-record revision now opens an explicit saved-draft conflict in the editor. The local text remains in place while the other saved text can be inspected. Choosing the local copy refreshes the record revision before retrying its CAS write; choosing the saved copy adopts its text and base. If the other record changes again during review, the choice is deferred and the newer version is shown. Disk Save and Discard wait for this decision. A rendered test exercises both choices and the second-change race. At this source, 405 UI unit tests and all 56 browser cases pass, along with production build, bundle exclusion, type checking and formatting. This resolves the tested multi-window recovery conflict path; simultaneous filesystem edits remain subject to the separate disk-revision review, and physical multi-window desktop behavior has not yet been qualified.
+
+## Recovery revision after file Save
+
+An exact-source private Wayland/WebKit check of the preceding editor change exposed a false saved-draft conflict: after a successful file Save removed its recovery record, the next edit reused the removed record's old revision. The retained failed run showed the local text and a message that the other saved copy had been removed; the private profile database contained no recovery record. The editor now starts a newly created recovery entry with the required `missing` revision, and a completed deletion clears the obsolete buffer revision without clearing a newer in-flight draft. A rendered save-then-edit regression proves a new recovery record is saved without a conflict. The complete local UI pass is 405 unit tests and 57 browser cases, plus production build, test-bundle exclusion and formatting.
+
+At commit `99cbdc0`, a rebuilt native desktop (SHA-256 `509dec40fe87d1535d6ae17bca4f7b62e67c3c452c8aad60c32ae50456ec9f0f`) passed twelve pinned private Weston/Xvfb Wayland checks. The new check starts with a file Save, edits again, then uses the real native API to act as a competing profile writer while the WebKit editor is open. It proves the saved competing text is visible, Save is blocked pending review, keeping the local draft writes through the new record revision, adopting the second competing draft preserves its text, and explicit Discard removes the record. The earlier repaired-code attempt stopped on a fixture JavaScript selector typo after a screenshot confirmed the actual conflict; the corrected one-time fixture passed with unchanged source/binary/probe pins and complete private-process cleanup. This is native single-window UI plus a competing service writer, not a two-window or installed-package demonstration.
+
+## Runtime-only clean-host package gate
+
+The native CI workflow and required release verification now build a pinned Debian 13 image with the package runtime libraries, Git and a virtual X11/DBus test display, then run both built packages with container networking disabled and no repository, Node, Rust or compiler. The new gate checks the generated SHA256SUMS before mounting either artifact. It extracts the AppImage as an unprivileged user, installs the `.deb` with `dpkg` into a disposable container, checks its launcher/icon metadata, and runs each packaged CLI against a fresh isolated project/profile. The JSON status must show the exact project, offline policy and no jobs. Each exact package also performs an MCP stdio initialize/tool-discovery round trip with the fixture client waiting for the initialize reply before requesting tools. Each package opens a visible `ShadowCode` window in an isolated first-run profile within 55 seconds; the probe checks that the app process is still alive and the window has nonzero width. The release receipt binds this check to the exact package hashes and fails if a required clean-host check is omitted or fails. These steps supplement the existing in-runner package, CLI, native window, MCP, runtime and installer gates. On 2026-09-28 the gate passed against AppImage SHA-256 `6721739fac7ed22a4d4daf071ab68904379a20a32da9210e76a5b489dcce3e40` and Debian SHA-256 `475ad89b93c1528c0856a6ee0a30145e92f5b7e64f5c94a71762e9d77b457fc7`. Those local packages predate the current uncommitted source edits, so current-source packaging and frozen-commit release receipts remain outstanding.
+
+The gate was exercised locally against the immutable `61fbf7b` Native workflow artifact: AppImage SHA-256 `11e0fbd4befe9d7ffe6ccaa6b54e1e4e66603b00edd29fe7865bcd85d0da1a25`, `.deb` SHA-256 `f41acea7e467ccab0ad78588e240685d1f1127c152ce0126b42809bcda01f431`. Both passed the offline CLI and visible first-window checks in the pinned Debian 13 container. The first bare-image attempt failed at missing ALSA, and an ALSA-only image then failed at missing FriBidi; installing the declared runtime stack closed those environmental dependencies. This demonstrates packaged CLI startup, metadata and a first X11 window on one clean runtime distribution, not GUI interaction, physical Wayland, local inference, model downloads, activation rollback, or a current-head signed release.
+
+The later pushed `417326f` [Native workflow](https://github.com/Shadowfetchapps/ShadowCode/actions/runs/36331514687) completed successfully, including its packaged CLI, terminal, native window and MCP checks. Its downloaded AppImage SHA-256 `535c41d74193323a6e5db888422cb6c4f09e0c2dec0ffde52a92fdfff5537d3c` and `.deb` SHA-256 `510d6eb0cc477b35190129db984917ff92a32784d6546db931faba4a2b1e0a75` both passed the newer offline clean-host CLI and first-window gate locally. The gate itself was added after `417326f`, so that GitHub run did not execute it; local-only commits still require exact-source CI and package qualification.
+
+A visual inspection of that older packaged AppImage showed the real first-run project/permission screen and, after an isolated “Trust and open” click, the main workspace and composer. With no model selected, the welcome screen now offers a direct “Choose a model” action into the existing unified picker. The App integration and rendered browser paths open the picker, select a ready Codex row and enable Send; 405 UI unit tests, the focused browser case, production bundle exclusion and the TypeScript/Vite build pass. This local source change has not yet been qualified in an installed package, and it does not provision any provider login.
+
+An open picker now offers **Refresh models**, forcing a read-only vendor reprobe while keeping the search available. A component test covers pending state and focus, and an App integration test confirms the exact `/api/picker?refresh=1` request. The manual file editor now indents selected lines with Tab and outdents them with Shift+Tab without destroying selected code. Its component and rendered-browser tests pass. At local commit `aad3b4e`, the complete UI suite passes 408 tests, the production build and fake-transport exclusion pass, and the focused browser indentation case passes. These source changes have not yet been pushed or qualified in an installed package; syntax-aware editing and measured large-file performance remain open.
+
+## Real three-model Compare cancellation
+
+The opt-in `live_three_model_compare_cancels_queued_models` test used the installed Qwen3 4B Instruct Q6_K, Qwen3 8B Q8_0 and Qwen3 14B Q4_K_M files with the pinned real llama-server. A temporary user/network namespace had loopback as its only enabled interface; the test used an isolated profile and Git project. It observed the first model stream, cancelled the comparison, and recorded all three jobs as `cancelled`. The queued models each recorded zero model requests, no `loading` phase and no `local.runtime_ready` event. The final local receipt is `artifacts/live-local/three-model-cancel.json` (ignored build evidence). The focused live test passed in 5.94 seconds. The full `local_engine` integration binary passed 20 fixture tests with three opt-in real-model tests ignored when rerun with loopback access; its first sandboxed attempt failed 13 tests because port reservation was denied before application behavior could run. This qualifies cancellation of an active three-model local Compare, not complete generation by all three, packaged-app inference, an independently traced network audit, or repeated hardware-resource measurement.
+
+## Real packaged inference without development tools
+
+`scripts/probe-clean-host-gguf.mjs APPIMAGE DEB INSTALLED_GGUF` is an opt-in Podman qualification for an already installed small model. It binds that model read-only and runs the exact packages in separate disposable Debian runtime containers with `--network=none`. The AppImage runs unprivileged; the `.deb` is installed with `dpkg` before its CLI runs unprivileged. Both containers check that Node, Cargo and Rust are absent. A fresh isolated profile enables only offline local routing, then the probe inspects the model catalog, executes one read-only explanation task, and requires one `local.runtime_ready` event from the package's bundled CPU runtime with no fallback, one local model request, a completed result and honest `not_run` verification. It saves each event stream and a hash-bound summary under ignored `artifacts/live-local/clean-host-gguf-*`.
+
+The local run against AppImage SHA-256 `958d65b843cef1da4ea7d6363c319f4374973601613e86c4194848822d660d45` and `.deb` SHA-256 `096929664238a3818a2fa040bd486af2f097ee19c99c1e2288e262a0f30ebc0a` passed using Qwen2.5 0.5B Q4_K_M (491,400,032 bytes; SHA-256 `74a4da8c9fdbcd15bd1f6d01d621410d31c6fc00986f5eb687824e7b93d7a9db`). Both responses correctly described addition. The first probe implementation rejected the valid wording `return a + b`; its sanity check was corrected to accept that form, and the complete rerun passed. This is actual packaged CPU inference on one Debian 13 container image, not a production signed release, real tool-using coding evaluation, installed GUI inference, or physical COSMIC acceptance. The model is user-installed and is not downloaded by the probe or bundled in the packages.
+
+## Three-model offline Compare completion
+
+The opt-in `live_local_acceptance_from_explicit_models` test now requires each completed local lane to record its own runtime-ready event, exactly one model request, the selected model file's provenance, no automatic CPU fallback, and an unchanged source project. In a temporary user/network namespace with only loopback enabled, the source-built test binary used the pinned llama-server (SHA-256 `667fa3f95bf534e85cdbab337b4b7c2f528de67e4ec1f643d5871c2b19e59df8`) and three previously installed files: Qwen3 4B Instruct Q6_K (`cd7b21b38b3e71400587c184b6a9b04d3beb4d13fdae6464d4075dee4f1bc5ad`), Qwen3 8B Q8_0 (`d87f4a5a2f1a6051d9fac010c12f76f3ba2137b137d413ba8f4d3a3d06b3a25b`), and Qwen3 14B Q4_K_M (`a8cc1361f3145dc01f6d77c6c82c9116b9ffe3c97b34716fe20418455876c40e`). The full-content SHA-256 checks matched those file identities. All three jobs completed a simple Python addition-function explanation with one request each. Their recorded execution windows were sequential, each reported Vulkan with a 4,096-token context and no CPU fallback, and the test completed in 17.58 seconds. The ignored local report is `artifacts/live-local/three-model-compare-strong.json` (SHA-256 `88cf29b47e113384cde8f03c5b3e424e182984e62dbea6dbba07f0e69d2d4699`). This proves one source-built, offline, three-lane inference run and the tested invariants; it does not establish coding/tool-use quality, repeated performance, packaged GUI inference, or production release readiness.
+
+## Redundant inspection during local coding
+
+A fresh run of the existing five-case Python repair evaluator on Qwen3 8B Q8_0 with current source failed without editing: nine successful read/search observations consumed the 50,000-token task budget, no model-run check occurred, and all five independent cases failed. The engine correctly recorded `failed` and `verified=false`. The ignored report is `artifacts/live-local/qwen8b-coding-current.json` (SHA-256 `6c002e0a5e6d3dc5a2942428b45cf4d01ed93fd8fb7fd6b05d214a2b8142070d`).
+
+The engine now gives one bounded process note after three consecutive redundant reads or searches of a file it already read completely. It still executes and records every requested tool; successful mutations reset the observation record. In one repeat of the same isolated, loopback-only evaluator, the note was recorded and Qwen3 8B made one successful edit. Four independent cases then passed, while the unhashable-list case errored; the model still exhausted the task budget before running its own check. That ignored report is `artifacts/live-local/qwen8b-coding-observation-note.json` (SHA-256 `760f32097c11478774abf745b4140cf24f3a58ab463bcac95df0a487e1d461d7`). This pair of stochastic model runs does not establish that the note caused the improvement, that Qwen3 8B is qualified for coding, or that the app should change its default model. The focused observation-state test, 12 fake-provider model-quality tests and strict all-target core Clippy passed; the first model-quality attempt failed at sandbox-denied loopback socket creation before application behavior, and its authorized rerun passed.
+
+## Composer controls fit at ordinary desktop widths
+
+CONFIRMED: reviewing the real packaged WebKit screenshots at 1360 pixels exposed Compare and Send extending beyond the composer panel, with the model and effort controls competing for space. The old layout forced one row whenever the composer exceeded 700 pixels; the existing compact-page overflow check did not detect this. A new rendered regression failed on the original CSS because Compare and Send were outside the panel.
+
+The composer now wraps according to available space at every width, hides its shortcut hint below 1000 pixels of composer width, and keeps the final action aligned to the right. The existing compact picker row and permission labels retain their behavior. Two browser cases check control containment and pairwise overlap for local and subscription rows at 520, 760, 900, 1024, 1280, 1360 and 1440 pixels. The native desktop fixture now also checks idle, running/Stop and compact control bounds.
+
+Validation: all 408 UI unit tests and 60 browser tests pass, the production build excludes the fake transport, and the rebuilt native WebKit app passes its 16 desktop scenario groups including the new bounds assertions, accessibility, consent cancellation and owned-child cleanup. The corrected native running-task screenshot was inspected. No live cloud task ran. Packages at preceding commit `0445783` passed the full packaged-behavior gate after supplying the missing matching WebKitWebDriver, including CLI, TUI, desktop and official TypeScript v1/v2 MCP clients over stdio/HTTP; those package bytes do not include this CSS fix. Full source and installer gate attempts made during concurrent disk-heavy checks hit existing deadlines; the two worktree cases passed in their unchanged 12-test focused suite, and the timed-out installer fixture passed alone in 39.5 seconds with its unchanged 120-second deadline. Full isolated gate reruns and new package qualification remain required.
+
+The subsequent isolated `native-source` and `installer` gates both passed at `2dce4b6`, including the default Rust suites with zero required skips and all 34 installer authentication/recovery cases. Ten explicitly optional live/hardware tests remained excluded. No deadlines were increased. Final package qualification still remains open.
+
+## Live pause and resume state
+
+CONFIRMED: the conversation classified paused tasks as active, but its durable event reader omitted `paused` from that classification, emitted `job.done`, and closed the stream. The hook also ignored saved active-job status changes, so Pause/Resume controls could remain stale. Three new unit regressions failed on the original behavior.
+
+The reader now shares the active-state classification with the conversation and checkout queueing. It emits status changes from the saved job snapshot separately from durable transcript rows; paused tasks retain polling and notification recovery. Snapshot cursors do not advance transcript replay. The conversation accepts matching active snapshots, rejects completion messages for active or different jobs/sessions/tasks, and completes only after the stream drains the terminal task's events. New conversations in a checkout with a paused task continue to queue behind it.
+
+Validation: all 411 UI unit tests, all 61 browser tests, the production build and fake-transport exclusion pass. The browser suite includes the paused-checkout queue regression. The rebuilt native WebKit app passes 17 scenario groups, including actual engine Pause → Resume → Stop transitions with saved-job and visible-control assertions, accessibility and owned-child cleanup. The paused screenshot was inspected. This native fixture uses a scripted local runtime; no live vendor task ran. These source changes are not in the previously qualified package bytes.
+
+## Complete working-file capture with Git index flags
+
+CONFIRMED: the new snapshot regression captured `base` instead of a user's working edit when the tracked file had Git's assume-unchanged flag. Compare and write subagents share this capture path, so their isolated checkout could start from stale bytes. The source index copy carried optimization flags into `git add --all`.
+
+Capture now clears assume-unchanged in the temporary index and clears skip-worktree only for paths present on disk. Absent sparse paths retain their indexed contents. Git stages the temporary snapshot with sparse updates enabled. After flag normalization, the temporary index retains the original timestamp so same-stat edits still receive Git's racy-index checks. A failed copied-index operation no longer silently rebuilds from HEAD and loses staged state. The source working files, index and HEAD are preserved.
+
+Validation: two focused snapshot tests pass, covering ordinary/split/sparse configurations, present hidden edits, absent sparse content, intent-to-add, staged versus unstaged bytes, force-added ignored files and same-stat edits after temporary flag normalization. All 20 Compare integration tests, eight subagent tests and strict all-target core Clippy pass. The new full-service case checks both lanes, Keep, and reopening with byte-identical source index and unchanged HEAD. Its first run reached the reopen check while the test still held the first service's profile lock; releasing that service fixed the harness, and the full rerun passed. These are Linux core-service fixtures, not new packaged-window, unsaved-buffer or live-provider qualification.
+
+## Fresh AppDir launcher normalization and current package evidence
+
+GitHub Native run `36347913222` at approved source `2dce4b6` compiled and bundled its first AppImage, then failed because desktop-entry normalization called `readlink` on a regular file. A new regression reproduced that EINVAL. Normalization now accepts either the expected relative symlink or a regular copy with identical launcher contents, then produces the reverse-DNS launcher/link. Unexpected links, differing files and directories are rejected before modifying either launcher. Six metadata regressions and all 18 focused packaging/runtime tests pass. A complete fresh bundle and GitHub rerun of this fix remain required.
+
+Packages built at `5eed56e` passed the offline Debian 13 clean-host gate and full packaged-behavior gate with no required skips: 21 CLI groups, terminal PTY, 17 native desktop groups (including pause/resume), MCP HTTP and official TypeScript v1/v2 clients over stdio/HTTP. The packaged paused screenshot was inspected. Both package formats also completed real CPU inference with the installed Qwen2.5 0.5B GGUF in network-disabled runtime-only containers, with bundled runtime origin and no CPU fallback. Evidence: `artifacts/live-local/clean-host-gguf-vcljnH/result.json`. This small explanation probe proves packaged inference, not tool-using coding quality, physical Wayland or GPU behavior.
+
+Exact AppImage SHA-256: `4de32d0ef2a2e40b379a3bbfc49ce77c90331705408f53fdbe93eb62e647e3cb`; Debian SHA-256: `9e7e06bee4f0288622209a57be3c724992a9f3f63d450d612abd2a83f6cf991f`. These bytes predate the launcher-source correction and subsequent provider work. The required `packages` gate still **failed**: eight simultaneous private extractions exceeded its unchanged 20-second deadline, including an isolated rerun. A separate diagnostic found correct output/exit from all eight after 33.6–34.5 seconds. It does not replace the failed gate. No concurrency, timeout or host dirty-page setting was weakened. Release readiness remains unproven.
+
+## Refuse incomplete AppImage extraction after output errors
+
+The pinned runtime ignored `fwrite`, `fflush` and `fclose` failures and could proceed toward payload execution after incomplete extraction. The new fault harness passed only two of nine scenarios before correction. Patchset `isolated-extraction-v2` checks all three results, closes the stream exactly once, and returns through existing owned-directory cleanup on failure. It retains private extraction ownership and signal behavior.
+
+The required native/release package checks now compile mechanically extracted functions from the hash-verified, patched upstream source. Nine scenarios pass: normal payload execution, short/zero writes, flush/close errors, combined errors, actual `/dev/full` output in two buffering modes, and cancellation. Failure cases attempt no payload, remove their owned directory and preserve a sibling sentinel. This is a synthetic SquashFS reader with real stdio, not a complete disk-full AppImage acceptance case.
+
+The full pinned Docker/musl runtime builds successfully: SHA-256 `a21842c1c0dd594f9dd3b7cae7eae8d8ff6884400395e7cd0de213353a763669`, input hash `2d3b95515f0a81da0cb2224a3446e7d5cececfcd25d10b8deebb882c4a5f1e2b`. All nine fault scenarios also pass against the built image's actual patched functions and musl library in a network-disabled, read-only container. All 54 release-verification/staging/signing tests pass; workflow YAML parses. Full packages have not been rebuilt with this runtime. The separate concurrent-extraction performance failure remains unresolved.
+
+## Bound login output draining after the CLI exits
+
+A browser/helper retaining a login CLI's output pipes could keep sign-in running indefinitely: the old post-exit drain restarted a 300 ms timeout for every line and did not observe cancellation or the original deadline. Three fake-CLI regressions failed before the correction; ordinary delayed tail output already passed.
+
+The drain now has one fixed 300 ms budget, prioritizes cancellation and the original login deadline, and closes its owned output readers afterward. It does not signal an independently opened browser. Four final fake-CLI cases pass, checking parent reaping, continuous inherited output, cancellation/deadline, redacted delayed tail output, closed readers, surviving helper and exactly one terminal event. The tail fixture uses an explicit release barrier after parent reaping to avoid a timing race found in independent review. The combined auth/account suites pass 19 tests and scoped strict Clippy passes. No real account was used.
+
+This bounds output draining only. The existing subsequent catalog refresh remains separately timed and uncancellable; Antigravity's separate authentication path is unchanged. Packaged login and real-provider acceptance remain open.
+
+## Keep unknown CLI billing distinct from subscriptions
+
+Two fake-Codex regressions proved that a successful boolean login-status fallback after structured app-server failure was labeled as a subscription, including reuse of an earlier account's plan allowance. Authentication alone now establishes only readiness. Missing or unrecognized Codex account modes produce explicit unknown billing and no current plan percentage; confirmed ChatGPT and API-key modes retain their existing behavior.
+
+An additive billing field reaches picker and account responses. The UI displays unknown/API-key warnings in rows, selected model badges, Accounts and the matching pending consent destination. Mixed vendor groups use a neutral heading rather than claiming every CLI login is a subscription. Routing, the existing direct-API route classifier and explicit Cancel/Send actions remain unchanged. This is a cached account observation, not fresh-at-send billing authorization; a removed catalog row cannot supply warning metadata.
+
+Validation: 15 fake vendor-account tests and four login tests pass, with scoped strict Clippy. Five UI regressions failed before the display correction; all 49 focused UI tests and the complete 416-test UI suite now pass, along with TypeScript, the production build and fake-transport exclusion. Independent source review confirmed consent uses its stored destination model rather than the current composer selection. No real login, paid request or billing fallback ran. Complete package qualification of these source changes remains open.
+
+The full native-source gate at `71e694a` passed strict workspace Clippy and desktop compilation, then exposed an older quota unit fixture that supplied rate-limit numbers without an account identity. That fixture now explicitly identifies its known ChatGPT subscription and also checks that absent/unrecognized account modes cannot expose the same quota. All 371 default core unit tests pass afterward, with two optional voice exclusions and the built-project-cleanup fixture still assigned to its separate required gate. The failed full-gate receipt is retained; the remaining workspace integration suites are not covered by this focused recovery run.
+
+## Match ACP discovery to execution protocol support
+
+Discovery previously accepted missing or unsupported ACP versions and could show Ready/Vision even though execution rejected the same runtime. Three new fake-provider regressions failed before the fix; two authentication/fallback controls already passed. Discovery and execution now share the exact supported integer-version check. Discovery rejects incompatible initialize responses before accepting capabilities or sending authentication/session requests. A typed incompatibility also prevents Grok's model-list fallback from restoring Ready; ordinary probe failures retain their existing fallback behavior.
+
+Validation: 55 focused tests pass across the new five catalog cases, existing adapters, accounts, transport and fake Antigravity flow; one pre-existing live Antigravity opt-in remains excluded. The checks include invalid/missing/string/float versions, stale Ready/Vision removal, authentication-required versus unavailable states, and no fallback invocation after observed incompatibility. Scoped strict Clippy passes. This is protocol-fixture evidence, not a live provider update or new package qualification. Catalog freshness/backoff, historical model names and Grok's separate image restrictions remain unchanged.
+
+## Preserve native inspection evidence after task failure
+
+Five new regressions reproduced lost workspace observations when a model failed before its final verification summary or an active task was recovered after restart. The live engine and durable assessment now share the existing inspection-tool allowlist. Recovery projects only successful allowlisted tool names and latest event IDs for the same outer task, after its latest host-generated native start. Vendor tool reports, string/numeric success values, forged payload task IDs, and observations from other tasks cannot supply this evidence. Host inspection remains distinct from workspace inspection.
+
+A later completed summary is preserved unless newer evidence requires reconstruction. Reconstructed unfinished results retain command receipts and custom fields but remain unverified, with no red/green claim. Historical saved terminal results are not migrated. Validation reproduced five failing baselines, then passed 375 core unit tests, 54 engine-task tests and 20 foundation tests, plus formatting and strict all-target core Clippy. Three existing core exclusions remain: microphone capture, a Whisper model, and the built-project-cleanup fixture assigned to its separate required release gate. The loopback integration confirms successful map/read/search before a model error, persistence across reopening, and no evidence leakage to a follow-up task. No real-model or package qualification is claimed for this increment.
+
+## Distinguish local template hints from runtime observations
+
+Generic GGUF catalog entries previously claimed tool-call support based on a template substring, even when the only match was a comment or literal. The existing schema-offering policy now has an explicit basis and calls such matches file hints. Runtime receipts separately retain strictly typed, allowlisted capability observations: false, missing and malformed remain distinct. Default and named tool-use templates have separate full-byte hashes; raw template text and arbitrary runtime properties are not retained. The existing model-details panel and local settings show these distinctions without claiming successful tool execution or coding quality.
+
+Five UI regressions and two corrected native integration regressions failed before production changes. The final source passed all 422 UI tests, TypeScript/production build and test-transport exclusion on Node 22.23.2. Fifty focused native tests passed with four existing live/model-dependent exclusions, plus scoped strict Clippy and formatting. Fake-runtime evidence covers three separate tasks, original false reports, warm reuse retaining the launch report, and changed model-file identity causing a fresh true report. It also checks a named template longer than 64 KiB and retained tool schemas on unknown/false reports. The first baseline's session-continuation request-count mismatch is retained and was corrected before reproducing the actual missing-field failures. Exact Hermes template selection, Rhea policy and schema routing are unchanged; hard capability enforcement, real coding quality and current package qualification remain open.
+
+## Refuse release receipts for changed source
+
+The release recorder previously checked only the HEAD label against the workflow commit. Seven disposable-Git CLI regressions reproduced passing receipts for staged, unstaged, untracked and hidden-index edits, or source/HEAD changes during an otherwise successful check. The recorder now checks the expected commit and source before and after each gate. Hidden index flags are refused. A private index seeded from the expected commit compares actual tracked content without rewriting the caller's index; ignored build outputs remain permitted.
+
+Independent review reproduced two additional gaps in the first implementation: an ordinary Git status under `core.trustctime=false` could cache a same-size, restored-mtime edit as clean, and a HEAD mismatch before recorder entry could retain an older successful receipt. The private-index comparison handles the former; moving HEAD validation inside the recorder invalidates the old receipt before the latter failure. The first private-index attempt rejected clean files until the private stat entries were refreshed; that failed run is retained. Final validation passes all 65 release staging, verification, authentication and signing fixtures with zero skips, including 11 actual CLI source cases and shared-index byte preservation. Seven independent review cases also meet their expected outcomes. This observes Git source content before and after a gate; it is not an atomic checkout lease against transient concurrent edits or qualification of the entire release. All current-source gates and packages still need their own results.
+
+## Keep available models usable during slow provider discovery
+
+The composer previously waited for every provider probe before receiving any model rows; one stalled provider hid ready local models and other providers. An additive cached picker request now returns local metadata and existing vendor/OpenRouter observations without starting vendor or network probes. The UI publishes those rows while its original full refresh remains pending, polls snapshots serially for at most 60 seconds, and retires obsolete responses on completion, reload or unmount. The full request remains responsible for completion and errors; retiring a UI response does not cancel a backend process.
+
+Catalog publication now checks a probe's revision and configured executable identity. Disconnect, status invalidation, offline mode and provider settings changes prevent older probes from restoring stale Ready or persisted usage. A successful settings save updates that authority immediately. Full responses project one current vendor snapshot after the provider/local/OpenRouter waits, preventing an older per-probe result from overwriting a newer observation. Billing distinctions and existing explicit refresh/backoff behavior remain intact. These are cached observations, not fresh-at-send account authorization; local metadata work can still launch its existing runtime inspection helpers.
+
+Native baselines reproduced blocked cached reads, late publication after invalidation, an unwanted OpenRouter fetch and mismatched final target/account observations. Actual PUT-config tests also reproduced three failures until save-time invalidation was added. Final focused native validation passes 445 tests, including nine picker/race cases and existing account, auth, ACP and transport suites; scoped strict Clippy also passes. Three existing core exclusions remain microphone, Whisper model and the separately required built-project-cleanup gate. The UI baseline had nine failures; final validation passes all 431 UI tests, typecheck, production build and bundle exclusion. All 62 rendered browser cases pass without skips; the new explicit-barrier case selects and submits a cached local model before the full response is released and retains the exact selected ID afterward. These are source and fixture results; current packaged, live-provider and physical desktop qualification remain separate.
+
+The combined native-source gate at `9adeff4` exposed a missed call to the renamed template-hint method in the `probe_local` example. The example now uses the new method and labels its diagnostic field `tools_template_hint`. A scan of all tracked files found no remaining old-method references, and strict all-target core Clippy passes after the correction. The failed full-gate receipt is retained; its remaining desktop/workspace test stages still require a complete rerun. At `9adeff4`, the separate required release-tests and release-auth gates passed 65 and 59 tests plus the secret scan, with no exclusions; those receipts remain bound to that commit.
+
+## Bound release-recorder output after the command exits
+
+The complete native-source gate at `3fcc143` passed strict workspace Clippy, desktop compilation and 1,013 tests, with ten explicitly optional exclusions and the built-project cleanup fixture assigned to its separate required gate. Native behavior checks also passed. The subsequent native-window run completed 17 desktop assertions and the Markdown worker checks, but a private D-Bus portal retained an inherited diagnostic stream after the command exited. That run was interrupted before fixture cleanup and has no passing receipt.
+
+The recorder now permits one fixed 1,000 ms output drain after direct-child exit. Incomplete output fails the gate; continuing output cannot extend the deadline. It closes only its own read handles and does not signal descendants. Complete late tails still reach the required-skip parser. Two real-process fixtures reproduced indefinite waits before the change, while short-tail and exit-status controls passed. All four focused fixtures and all 69 release-script tests now pass without skips. A first after-run exposed an ESRCH race in the fixture's process observation; that log is retained, and treating an already-exited process correctly did not change the production deadline. This bounds the recorder, while private native-window session cleanup still needs qualification.
+
+## Keep sign-in cancellation pending until the engine finishes
+
+Accounts previously showed cancellation immediately after its request and stopped reading progress. A delayed response could also overwrite a terminal result or another vendor's login, and a successful command exit displayed “Signed in” even when the account probe reported signed out or unknown. Cancellation now shows “Stopping sign-in” with duplicate cancellation and reconnect disabled until a terminal observation arrives. Progress remains active, attempt identities reject obsolete responses, failed cancellation restores its controls, and authenticated success requires a Ready account observation.
+
+The terminal UI reads the existing cached accounts route instead of launching another provider check. Failed, cancelled and unconfirmed results also avoid the global catalog reload, so a user cancellation cannot immediately trigger another slow discovery and block retry. Successful Ready observations still refresh the picker. The fake backend preserves cancelled terminal records to exercise the same polling contract.
+
+Seven initial regressions failed with one passing control. Independent review found that the first UI correction still launched a fresh account probe after cancellation and that the fake backend erased the terminal record; four follow-up failures reproduced those gaps. Final validation passes 25 focused settings checks, all 442 UI tests, TypeScript/production build and production test-transport exclusion on Node 22.23.2. The actual fake account handlers are exercised by a rendered cancellation control. Independent source review found no remaining blocker in these four UI files. These results qualify the UI changes; backend login cancellation, explicit probe reaping and current package/native-window qualification remain separate work in progress.
+
+
+## Own the private X11 test session through process exit
+
+The source-window gate at `3fcc143` passed its app and Markdown assertions but remained open because an activated private D-Bus portal retained a diagnostic pipe. The earlier recorder correction makes incomplete output fail; the new X11 supervisor also fixes the test-session ownership boundary. It selects X11 before starting D-Bus, creates a private runtime directory with mode 0700, and preserves HOME and existing account configuration/data/state/cache paths. No particular GTK portal backend is required.
+
+Both native window probes validate their direct private launcher, daemon and bus socket, then clean only newly activated services with matching session markers and process identity. Bounded cleanup refuses changed or unrelated processes. After command/output closure, the outer supervisor verifies recorded session processes have exited and checks for surviving late activations before removing its runtime directory. Incomplete output, failed commands, missing reports or residual services fail qualification and retain diagnostics. Required source/package workflow calls use this wrapper, install their D-Bus prerequisites explicitly and upload diagnostics from both stages.
+
+Validation: all 29 focused session checks and all 98 combined release tests pass with zero skips; syntax, workflow YAML and diff checks pass. The real private-D-Bus fixture uses a generated service and a substituted Xvfb launcher, so it exercises session cleanup without opening a display. Independent review reproduced and closed a signal-time identity race, a late inventory deadline failure and missing package diagnostic retention. This is cooperative Linux process validation, not atomic pidfd protection against a hostile same-user race. Actual native-window and package qualification remain required; the original interrupted run has not been relabeled as passing. Evidence is retained in `work/native-x11-session-prep/` and `work/private-session-review/` outside the tracked source.
+
+
+## Retain login cancellation ownership through account discovery
+
+A successful login CLI could hand off to an uncancellable account check. Cancellation or the original deadline then failed to stop that check, and engine shutdown could return while its process was still alive. Antigravity setup/read failures could also strand an admitted login without its final outcome. Ten deterministic lifecycle cases failed before correction, while two controls already passed.
+
+Login admission now reserves a tracked worker before launch. Shutdown closes admission, cancels admitted operations and waits for their ownership to end. The final account observation shares the original cancellation token and deadline. Its Codex, ACP and short-command helpers retain each child outside cancellable I/O, explicitly stop and reap it, and finish their owned readers before returning. Cancelled or failed logins do not start another account probe. Antigravity handshake/read failures use the same tracked worker and publish exactly one terminal result; independently opened browsers are not signaled.
+
+Independent review also reproduced a cancellation accepted while refresh waited for the catalog lock, followed by a Ready cache entry and persisted quota. A per-login publication gate now orders that synchronous commit against cancellation acknowledgement. Cancellation winning the gate prevents publication; a commit that wins first completes before cancellation is acknowledged. Original-deadline eligibility is checked after waiting for the lock. No await is held under that gate, and catalog publication never acquires the login registry in reverse order.
+
+Validation: all 463 targeted native checks pass, with zero failures and four pre-existing optional exclusions. This includes all 12 lifecycle cases, actual held-lock cancellation/deadline regressions, account/auth/protocol/transport/picker controls, browser-helper survival and redacted output. Strict all-target core Clippy and pinned Rust formatting pass. Two older cancellation expectations now correctly require unavailable rather than a fresh sign-in probe. Exact source hashes and baseline/final evidence are retained in `work/provider-readiness-next/`, with independent review in `work/auth-refresh-review/`. These are fixture and source checks; fresh complete-source, native-window, live-provider and package qualification remain separate.
+
+
+## Notice repeated search results without requiring a path argument
+
+The retained Qwen coding attempt made six successful searches over files it had already read, but the progress warning relied on the optional search path argument. Five searches omitted that argument and reset the warning counter. A tests-only baseline reproduced the missing advisory and a separate error that counted a read starting at line eight as a complete file read.
+
+The advisory now evaluates actual nonempty search result paths and records only complete first-line reads from the bounded, redacted tool message sent to the model. Potential mutations invalidate historical coverage even when they fail. Tool messages remain unchanged, and the note explicitly permits rereading files after compaction, truncation or changes. It remains advice: no inspection is blocked, mutation authorized or result fabricated.
+
+Validation: 388 core tests and all 55 engine tests pass, with three existing optional exclusions, plus strict all-target core Clippy and pinned formatting. The new engine fixture observes one advisory, then successfully rereads, edits, executes one real shell assertion and reopens its task-owned verification receipt; all earlier tool results remain intact. The two-failure baseline and a separate sandbox socket-permission failure are preserved. Evidence is in `work/qwen-observation-loop-followup/`. No model inference was repeated, so the earlier Qwen coding failure remains a failure and model quality is not claimed improved by these fixtures.
+
+
+## Bind Codex task events and approvals to their active thread and turn
+
+Codex task notifications previously reached the current host job without checking their stored thread and turn identities. A fake-CLI regression reproduced a previous-turn completion ending the current task before its own completion was released. Foreign text, tool records, token usage and permission requests could likewise be attributed to the current job.
+
+The adapter now checks task-affecting identity envelopes against the matching start/resume responses. Valid early events wait in a bounded queue of 128 frames or 1 MiB until the turn/start response establishes ownership. Responses are consumed once, continuation clears prior item and approval state, and invalid start, overflow, completion or replacement retires pending approvals. A duplicate outstanding server request ID also retires approval authority before scope handling. Account-wide notices retain their existing behavior. Legacy approval messages provide only a conversation ID, so they require that conversation and a bound active turn but cannot independently prove exact upstream turn identity.
+
+The tests-only baseline preserved 25 passing existing controls and reproduced nine failures among ten new cases. After correction, all 77 focused Codex, composer, steering, account, handoff and transport tests pass with zero failures or exclusions. The real subprocess case remains running at its explicit barrier, retains only the owned nonestimated 3 input and 2 output tokens, completes exactly once and reaps its fixture process. Strict all-target core Clippy, pinned formatting and independent review pass. Existing fixtures were corrected to the installed CLI schema without weakening assertions. Evidence is retained in `work/provider-readiness-next/codex-scope-review/` and `work/codex-schema-fixture-prep/`. These are protocol and fake-CLI results; fresh full-source, desktop, live-provider and package qualification remain separate.
+
+## Qualify the current packages and correct the native picker expectation
+
+At `ddc97d1`, fresh source qualification passes 1,046 Rust tests, strict workspace Clippy, desktop compilation, 442 UI tests, native behavior/stress, 17 X11 desktop checks and the embedded Markdown worker. The four package gates also pass with no required or optional skips: packages, clean-host packages, packaged behavior and installer. The original disk-backed eight-client/20-second AppImage extraction check passes, as do actual extraction-output error controls and the offline rebuild of shipped runtime sources. The previous older-package failures remain preserved; this result does not establish a causal extraction-speed improvement.
+
+The AppImage SHA-256 is `a079d18b2b3a5b08b40453cbb71e3fc01916bb7a7c44de0ec71fd024f623b5c2`; Debian SHA-256 is `3702fc21822a3f412b2eb9d8b97b30604afdd96a1a5cb4b89d7dc7b84384f221`. Both open a first GUI window and answer offline CLI requests in a disposable Debian 13 runtime container. Packaged behavior passes CLI, PTY, all 17 desktop checks, HTTP MCP and official TypeScript v1/v2 clients over stdio/HTTP. All 16 recorded packaged-window process identities are subsequently absent. Installer qualification passes 34 authentication/recovery tests and the isolated-home shell scenarios, including the actual bundled managed runtime. These development 0.32.0 packages do not replace the published version or establish production signing, physical desktop acceptance or full provider conformance. Exact copies, logs and receipts are retained in `artifacts/qualification-ddc97d1/`.
+
+GitHub Native run `36361395826` then exposed a stale desktop assertion: it always expected “Subscriptions,” while the existing UI correctly showed “Vendor CLIs” for unconfirmed billing. The harness now reads the settled cached picker after manual refresh and requires the exact billing-derived heading for both the section and its vendor rows. Local/API rows cannot change that heading. Product billing behavior is unchanged. All 17 focused picker tests and 12 extracted assertion controls pass; the original assertion controls reproduce three failures. Syntax, formatting and TypeScript checks pass.
+
+A targeted native run with vendor CLIs absent from its private PATH passed the corrected “Vendor CLIs” boundary and the remaining desktop behaviors, with the existing explicit optional cloud-consent exclusion. That run remains **failed overall**: its private-session cleanup exceeded the unchanged five-second deadline. Output closed and all recorded processes subsequently exited, but this does not turn the cleanup failure into a pass. Evidence is retained in `work/native-picker-heading-prep/`; investigation of the cleanup inventory remains separate. No test deadline or ownership check was relaxed.
+
+## Bound parallel discovery in private-session cleanup
+
+Process discovery previously read every `/proc` entry sequentially, consuming a material part of the fixed cleanup budget on this host. Discovery now overlaps at most eight process-reader pipelines, preserves the complete inventory and input order, and joins admitted reads before reporting an error. Anchor validation, identity checks, signals, the 32-service cap and the five-second cleanup deadline remain unchanged. The original timeout did not identify its exact operation, so this change does not establish its precise cause.
+
+Validation passes all 104 combined release-fixture tests with zero skips, including held discovery reads, late resolutions, inventory completeness, ordering, errors and the service cap. One actual X11 recheck also passes with the older pinned `ddc97d1` desktop binary: the vendor heading and applicable desktop checks pass, with one explicit optional cloud-consent skip because no vendor CLI is available in the private PATH. Output closes, cleanup finishes within the unchanged deadline, all 16 recorded process identities are absent and the private runtime directory is removed. Before/after source and binary hashes match. This qualifies the changed helper against that binary; it is not qualification of newer runtime source or a clean release receipt. The original failed run is preserved. Evidence is in `work/native-session-inventory-diagnosis/`.
+
+## Expose an exited local runtime on the next status read
+
+A managed local runtime that exited after becoming ready remained marked Loaded until another acquisition. Three tests reproduced stale state through independent first observers: the catalog API, `loaded()` and the cached picker API. Status reads now try the existing runtime slot and inspect only its retained child handle. A confirmed exit clears the loaded snapshot and retains the bounded error output. Reads do not signal or restart the process; the next explicit load follows the existing replacement path and clears the old error after readiness succeeds. A busy slot is not awaited, while the existing state mutexes remain synchronous.
+
+All three tests failed before the fix. Afterward, 25 ordinary local-engine tests and five runtime unit tests pass, with three existing live-model exclusions; strict all-target core Clippy and workspace formatting also pass. The regressions check an actual fake runtime reaching readiness, explicit child exit, exactly one requested restart, unchanged project/index bytes and survival of an unrelated same-executable process. Independent review found no blocker. Evidence is in `work/local-runtime-post-ready-prep/`. This is status-read and service-API qualification, not a background exit notification, active-stream crash, real GPU/OOM or current-package qualification.
+
+## Refuse invalid local-runtime readiness responses
+
+An occupied-port regression reproduced false readiness: the managed child remained alive behind a pre-bind barrier while a separate listener answered health with 200 and the authenticated props request with 401. The public load succeeded, and the catalog and cached picker incorrectly reported Loaded. The runtime now requires successful HTTP and a JSON object from props before publishing readiness. Failed requests, invalid JSON and nonobject responses produce category/status-only diagnostics, then stop and reap the owned child through existing cleanup. They do not trigger CPU fallback. Successful objects with absent or null optional capability fields remain valid; existing template and context checks still apply.
+
+The regression fails before the change and passes afterward. All 26 ordinary local-engine tests and eight runtime unit tests pass, with three existing live-model exclusions. The port case verifies the rejected child is already gone before fixture unload, the unrelated listener survives, one explicit retry works, no inference occurs, and model/project/index bytes remain unchanged. Actual loopback HTTP controls cover status errors, malformed/nonobject JSON, private-response redaction and optional-field compatibility. The existing Hermes 503 case now expects the earlier readiness error; its remaining template, one-launch and cleanup assertions remain. Independent review, strict all-target core Clippy and workspace formatting pass. Evidence is in `work/local-runtime-port-readiness-prep/`, `work/local-runtime-props-readiness-prep/` and `work/provider-port-review/`. This rejects observed invalid readiness responses; a successful props object alone does not prove listener ownership. Real GPU/OOM, active-stream failure and new package qualification remain separate.
+
+## Preserve a checked Compare result when another candidate fails
+
+LOC-05 now has a direct regression through the public Compare API. One candidate edits files and runs a real configured Python check while the other writes partial work and waits behind an explicit request barrier. Releasing that barrier produces a nonretryable HTTP 400. The successful candidate's files, check summary and exact task-owned command completion remain intact through the failure, reopening, Keep and repeated cleanup. The failed candidate's partial work stays available until the user keeps the winner. The original Git HEAD/index and unrelated staged/working bytes remain unchanged; the result is scored once, and successful generation is not replayed.
+
+The first coverage test passed without changing Compare production. Independent review strengthened the receipt-to-command link and made fixture cleanup run before assertion panics propagate. The final complete Compare suite passes all 21 tests with zero exclusions; strict all-target core Clippy and formatting pass. The check receipt resolves to the exact task/session, command start, successful completion and Python output, and that evidence is preserved after reopening and Keep. Evidence is retained in `work/compare-lane-failure-prep/`. This qualifies admitted loopback-provider generation failure; managed-GGUF crash/OOM, admission failures and live model behavior remain separate. No Compare production defect was established by this case.
+
+## Managed-runtime failure during an active task
+
+A Linux fake managed runtime now completes a real file write, streams partial text and an incomplete tool call, then exits behind an observed barrier. The task reaches one durable failed terminal state with disconnected/unfinished response evidence, preserves the earlier file edit and model/project/index bytes, and never executes the incomplete tool or claims verification. Reopening preserves the failure. A separately requested retry starts one new runtime and completes a real Python check; both runtime children are reaped and an unrelated process survives. Production execution code is unchanged.
+
+The original focused case passed once in 8.37 seconds; independent review added explicit retry-child reaping proof. The final local-engine binary passes 27 tests with three existing live-model exclusions. Strict core all-target Clippy passes; formatting passes after correcting the expanded diagnostic layout in a separate provider fixture. This covers a controlled runtime process exit through the service API, not a real GPU/OOM event, native editor/window interaction or rebuilt release packages. Evidence: `work/local-runtime-active-exit-prep/` and `work/provider-readiness-1ba135f/native-after.log`.
+
+## ACP account billing observations
+
+A ready Cursor, Antigravity or Grok ACP connection no longer implies subscription billing. Their protocol does not supply a billing observation, so account and picker billing remains unknown while readiness, discovered models, image capability, route grouping and provider-specific usage notes remain unchanged. Authentication metadata records the method actually accepted by authenticate, rather than the first advertised option. No advertised method leaves that identity absent. Known Codex/Claude subscription and API-key observations remain distinct. The UI unknown notice asks users to check their provider account; it does not assert an API charge from missing ACP evidence.
+
+The original fake-ACP regression failed with selected cursor_login, an unrelated first advertised auth label and invented subscription billing. After correction, catalog7 + accounts15 + cli_agent59 tests pass, including actual/no-method controls, per-provider usage notes and known billing controls. The first combined after-run passed the new cases but failed an older fixture before process spawn completed; its OS cause was hidden by the old error formatting. The failure remains retained, diagnostic output now includes the error chain, and the complete catalog/accounts/adapter recheck passes without a retry/sleep workaround. Antigravity's existing ordinary integration passes with its one existing live exclusion. The integrated UI passed459 tests/78files and typecheck; strict core all-target Clippy and formatting pass. Evidence: `work/provider-readiness-1ba135f/{baseline.log,native-after.log,native-recheck.log,clippy.log}` and `work/editor-upgrade-prep/ui-all.log`. No real provider request, account change or billing transaction was performed.
+
+## Context inventory, native editor, and current source qualification
+
+The composer now offers an inspector for explicit `@` file and folder attachments. Its preview shares the bounded attachment builder used by native task context and reports included/excluded paths, line and byte bounds, folder entries and truncation without returning file contents. It explicitly says vendor CLI context remains opaque and that the task rereads files when it starts. The editor now has a CodeMirror-based editing surface, language modes, find/replace, undo/redo and a plain-text fallback for mixed newline files. Draft saves preserve original CRLF/CR bytes when possible. A delayed-save regression verifies that typing made after submission survives the successful response, remains dirty across an editor remount, and can be saved against the returned disk revision. Larger settings, compare, review and drawer tabs load on demand while keyboard-critical overlays remain eager.
+
+Compare now reads the current project's app-owned file drafts before launch. When any open buffer differs from its saved base, the dialog names those files, explains that Compare snapshots disk content, and disables Start; the handler also refuses a direct start while that state remains. Component coverage checks the disabled action and warning, and Playwright drives the app from an edited file through the blocked Compare dialog, saves the draft, and confirms launch becomes available. This protects UI-started comparisons; native/API callers do not own or receive the desktop editor's in-memory drafts.
+
+The native X11 smoke fixture initially failed before backend setup because it canonicalized isolated XDG roots that the fixture had not created. After creating config, data, state and cache roots, the real app reached the editor checks. That exposed a second test-only defect: the fixture assumed the open-file tab's accessible label used a basename, while the UI correctly uses the workspace-relative/absolute item path. The fixture now switches through the visible Files list. The rebuilt source desktop passes all 19 native window checks, including TypeScript highlighting, actual keyboard edits, undo, guarded save, exact CRLF persistence, reload recovery, accessibility, compact layout and owned child cleanup. The independent native Markdown worker/CSP run also passes with no CSP violations. Both use the controlled X11 source binary and do not qualify physical Wayland/COSMIC or installed packages.
+
+On this dirty source checkout, UI unit tests pass 477/477, including delayed-save, unsaved-draft Compare, and stale-completion-after-cancellation regressions; the Playwright suite passes 72/72, including the editor-draft Compare guard. Production and E2E builds pass, core context-bound tests pass, formatting and diff checks pass, and strict workspace/all-target Clippy passes. `cargo test --workspace --locked` now passes end to end when run with the installed Rust 1.95 toolchain first on `PATH` and local socket access; the default sandbox run had failed 13 loopback/socket cases with `Operation not permitted`, and the first rerun's doctest stage used a system `rustdoc` that could not load its pinned LLVM library. Some explicitly live provider/model and external-network tests remain ignored. No real vendor task, billing probe or account change was performed. The local edits remain uncommitted and these results are not GitHub CI receipts.
+
+Still required for release: live, user-authorized qualification of each provider's official sign-in/refresh/cancel/expiry path; real model quality and long-task checks on supported local/cloud routes; physical Wayland/COSMIC and GPU behavior; clean-host packaged install, upgrade/rollback and uninstall; release signing/key ceremony and protected GitHub workflows; and final candidate CI/package receipts on one frozen commit. Until those checks pass, the flagship application and release remain incomplete.
+
+## Cross-window draft guard and context estimate follow-up
+
+
+The context preview now exposes an approximate token count derived from the exact bounded attachment prompt bytes, and the UI labels it as an estimate alongside attached bytes. Native coverage confirms the reported estimate formula. Compare checks persisted app-owned editor draft paths while holding the project mutation lock immediately before snapshot creation; if another editor window has persisted an unsaved recovery draft, Compare refuses with its path rather than launching against stale disk content. The two focused browser scenarios for bounded context and a recovery draft saved by another window pass (2/2); the native context-preview and pending-draft tests pass (3 total); all UI unit tests pass (478/478), and UI typecheck passes. Changed-file Prettier, workspace Rust formatting, and `git diff --check` pass. These checks verify the covered API and UI paths, not arbitrary external editor buffers or a rebuilt package containing this follow-up. Full provider, physical Wayland/GPU, and frozen signed release gates remain open.
+
+## Coordinate app-owned workspace mutations across ShadowCode processes
+
+The prior in-process mutex did not serialize two application processes. The same repository advisory lock used by Compare now covers recovery-draft writes/deletes, source-file saves, project instructions and skills, attachments, saved project maps, and Git hunk/stage/commit actions; ownership is retained through each mutation. Mutations capture the selected project before waiting and reject a stale generation after admission rather than redirecting a queued write to a different project. This prevents a cooperating ShadowCode process from changing the source tree or index while Compare takes a snapshot or applies its selected result. Standalone folders continue to support local drafts and writes without a repository lock because Compare cannot operate there; only Git's explicit “not a Git repository” response takes that path, while other probe failures refuse the mutation. Terminal commands remain outside this short-mutation lock because they are arbitrary and can be long-running; unrelated programs that do not honor ShadowCode's lock are not covered.
+
+The service regression holds the repository lock from a separate file handle, confirms draft save, file save and `git add` are refused without changing file bytes, then releases the lock and confirms file and draft saves succeed. Core coverage checks the same lock boundary and standalone-folder behavior. All 18 service-command integration tests and all 10 project-inspection tests pass with local socket access, including saved project-map behavior. Strict core all-target Clippy, workspace formatting and `git diff --check` pass. This closes the app-owned short-write race covered here; it does not detect another application's unsaved buffers or protect against noncooperating external writes.
+
+## Current 0.32.0 development packages from the dirty flagship checkout
+
+Using the already pinned managed runtime, AppImage runtime and packaging tools, both Linux formats were rebuilt from the source tree described above. The package checker passed all declared package-content, runtime-origin, notice, metadata and checksum checks. The network-disabled Debian 13 clean-host gate passed offline CLI/status and first GUI-window launch for both AppImage and Debian packages. AppImage extraction/lifetime tests pass; its packaged CLI passes all 21 scenario groups with 32 fixture-model requests; packaged TUI passes its PTY checks with eight fixture requests; packaged native desktop passes all 19 WebKit/editor/accessibility/cleanup checks; packaged MCP stdio and HTTP each pass with six fixture-model requests; and independent official TypeScript MCP SDK v1/v2 clients pass over both transports. Nine runtime write/error cases pass, and a no-network rebuild of the included AppImage runtime source archive reproduces the pinned runtime hash. The artifact hashes are AppImage `22bb272cd0145e9c699f3cb3cdb14bf359cbc84ce7a13684d4db740f33c6d570`, Debian `5f168ddb9b3c8ab53a400290766f8dcde54e7346c8dc7e14f1ea3ebfe64b36e1`, and AppImage runtime sources `5ac573be7979f615894b10c99a1091698ce9b02c2768750ec4387aa8f948d131`.
+
+The separate native Markdown WebDriver probe cannot qualify an AppImage as currently written: it requires `/proc//exe` to equal the configured application path, but the AppImage wrapper launches a temporary extracted executable. Its failure occurred at that ownership assertion, before Markdown worker/CSP assertions, so it is neither a CSP failure nor a packaged Markdown pass. The source-binary Markdown/CSP gate passed separately. Package qualification above is local development-package evidence from a dirty checkout, not a signed or published release; version remains 0.32.0, no release key was used, and no GitHub CI receipt is bound to these bytes. The first interactive AppImage WebDriver attempt did not reach UI checks because the host lacked a system-installed driver; the clean-host container did open first windows for both formats, which is narrower evidence. The later fresh-package result below supersedes that initial limitation.
+
+## Fresh current-source package run (2026-09-28)
+
+Rebuilt the 0.32.0 AppImage and Debian package from commit `52d8b77` plus the uncommitted worktree. `check-native-package.mjs` passed for both: managed runtime pin and Vulkan+CPU metadata, bundled library closure, notice digests, matching binary/version, desktop/AppStream metadata and AppImage runtime-source identity. The network-disabled Debian 13 gate passed offline CLI status and first GUI-window launch for both formats. Against the exact fresh AppImage, packaged CLI passed 21 scenario groups/32 fixture model requests, TUI passed its PTY checks/eight requests, MCP stdio and HTTP each passed six fixture requests, and official TypeScript SDK v1/v2 clients passed over stdio/HTTP. The first interactive window attempt did not reach WebDriver startup; after pointing the test at the existing extracted WebKit driver, this exact AppImage passed all 19 interactive native desktop checks: first-run trust, welcome/picker/provider state, local-model task approval and execution, editor/highlighting/undo/save/CRLF, change review, reload, settings, consent cancellation, pause/resume/stop, compact layout, light/dark accessibility, and child-process cleanup. Screenshots and the test receipt are in `artifacts/native-appimage-desktop-current/`. AppImage SHA-256 is `6721739fac7ed22a4d4daf071ab68904379a20a32da9210e76a5b489dcce3e40`; Debian is `475ad89b93c1528c0856a6ee0a30145e92f5b7e64f5c94a71762e9d77b457fc7`; runtime source archive is unchanged at `5ac573be7979f615894b10c99a1091698ce9b02c2768750ec4387aa8f948d131`. These unsigned local artifacts are not release candidates. Clean signed build, installer upgrade/rollback/uninstall, physical Wayland/COSMIC/GPU, provider lifecycle/long-task qualification, protected GitHub workflow, and frozen-commit receipts remain open.
+
+On the same source, `bash scripts/test-install-appimage.sh` passed its fake publisher/checksum refusal, runtime validation, interruption and rollback/recovery cases, including the real bundled runtime. The authenticated installer suite initially failed because its helpers rejected Node 22.11's post-reap `EPERM` diagnostics and intentional SIGKILL results (`status: null`, `signal: SIGKILL`) before evaluating exit/signal evidence. The helpers now distinguish those cases: successful commands require exit status zero; refused commands require a nonzero exit or an observed signal, so a spawn error alone cannot satisfy refusal. The full installer-auth suite passes 34/34 tests with no skips under local process access, including signature checks, actual SIGKILL recovery, and the nested runtime/rollback suite. This is local test evidence; release receipts on a frozen commit remain outstanding.
+
+## Explicit checks without another model turn (2026-09-28)
+
+Completed-task summaries now offer **Run a check…**. The user enters a fresh command against the current project files; the desktop starts the existing native command-job API without choosing or invoking a model. Normal trust and command approval controls apply. The composer draft remains intact, output and a fresh check receipt appear in a new task, and earlier evidence remains in history. Historical receipt text is never reused as executable input. A shared submission guard prevents duplicate starts; workspace/session and navigation-generation checks keep a delayed response out of a different conversation, including navigation away and back. Once the command is admitted, a list-refresh failure cannot present it as a retryable submission failure.
+
+Integrated validation passes 490 UI tests across 81 files, TypeScript/production build, and all 74 Chromium browser scenarios. The new App/browser cases exercise an unset model, exact workspace/session request, approval before output, draft preservation and completion. The production bundle check confirms test transport/fake engine code is absent. These UI results alone do not prove native execution.
+
+The rebuilt debug desktop additionally passes all 20 real X11/WebKit smoke checks, including the new action. Its command verifies the actual working directory's device/inode, reads the existing project file and prints a known result. The test observes that exact stdout and exit code in the displayed receipt, one command task, zero engine/server model requests, preserved draft/model selection/earlier summary/file bytes, and clean exit of the app and children. Evidence: `artifacts/native-current-run-check-3/native-run-check.json`, screenshots, desktop `result.json`, and the outer `artifacts/native-x11/run-iShhjp/result.json`. Initial attempts are retained: one could not find the installed driver on PATH; another reached successful execution but incorrectly required an unredacted receipt path. Durable receipt paths intentionally pass through generic secret redaction, so the corrected fixture verifies directory identity in the executing process and exact `job.workspace`, while allowing only the actual or redacted displayed path. No redaction policy was weakened. This is source-built X11 evidence, not physical Wayland or a package containing this new action.
+
+## Clean-host gate response validation and bounded container cleanup
+
+The shell MCP probe previously used string matching that could accept an error response containing a tool name. It now emits raw tagged replies for structural validation on the host: JSON-RPC envelopes/IDs, negotiated protocol, server identity/capability, all 17 expected tools and their schema structure. The shell handshake has a hard kill deadline for an uncooperative server. Eighteen regression tests cover these cases without adding Node to the clean runtime image.
+
+The package runner now assigns each container a private name and ownership label. After the attached client returns or times out, bounded commands enumerate that label, verify name/label/full immutable ID, remove only the owned ID and check disappearance. Auto-removal between enumeration and inspection is accepted only after a successful empty ownership query. Twelve regressions cover timeouts, malformed/foreign identities, unrelated-container preservation and cleanup failures. A real Docker timeout experiment also removed the test container while preserving a separately created sentinel. Runs use the already-built image with `--pull=never`; an empty query after client termination observes daemon state but cannot prove that an already-submitted create will never finish later. Timeout diagnostics preserve the invocation identity for that case.
+
+Native CI and the required release-test gate include both new suites; path filters cover their helpers and native smoke fixtures. The complete wired release fixtures pass 134 tests with zero skips, followed by both executable-ownership tests. The ordinary sandbox attempt failed/stalled in subprocess fixtures; its identified wrapper was stopped before the successful local-process run. The rebuilt clean runtime image and final ownership-aware runner pass offline CLI/MCP and first-window checks for both exact package hashes listed above. Those package bytes predate the new checks UI and native mutation fixes: no current-source package, signed candidate or published release is claimed. Current changes remain local and uncommitted; final source freeze, rebuilt artifact receipts, live provider/long-task, physical Wayland/GPU and release trust/workflow qualification remain required.
+
+## Recover provider sign-in and bound native event listeners (2026-09-28)
+
+Accounts restores each vendor's active sign-in from the native progress API after navigation, keeps concurrent provider attempts separate, and shows cancellation as Stopping until terminal acknowledgement. Unknown progress cannot trigger automatic account probing. Catalog refresh waits for all active sign-ins and pending Connect requests to settle; stale replies cannot overwrite newer attempts. The native progress response exposes `cancellation_requested` only for a still-active cancelled attempt. Four native authentication units, 64 focused UI cases, the integrated 507-test UI suite, production build/typecheck and all 75 Chromium browser scenarios pass. These recovery fixtures do not establish real expired-token or browser-login lifecycle behavior.
+
+A real native 100-task run exposed retained Tauri event metadata growing from 11 to 211 records. The frontend now shares one document-lifetime registration for each of four fixed channels, with removable subscriber identities, retry after registration failure and isolated callback errors. It does not mutate Tauri's private registries. Fourteen focused event/job tests pass. A second real 100-task run retained exactly three registered records throughout and preserved database integrity, but failed its unchanged thread-growth allowance because its early baseline preceded a rise from 58 to roughly 75–76 threads. Both failed receipts remain in `artifacts/native-endurance-baseline` and `artifacts/native-endurance-fixed`; neither is an overall endurance pass.
+
+The endurance fixture now records actual per-process named thread identities, requires bounded warm-up convergence before 100 distinct qualification tasks, and applies memory/descriptor/listener limits during warm-up. The post-baseline thread-growth allowance remains eight. Native and release workflows both require this gate and retain its diagnostics. The corrected real run passes all 100 post-warm-up tasks after 25 warm-up tasks, with SQLite integrity intact and all 21 surrounding desktop checks plus private-session cleanup passing. The main samples retain 75 threads, 146 descriptors and three native listener records throughout; owned-tree PSS ranges from 481,232 to 529,360 KiB and finishes at 514,063 KiB. Evidence is in `artifacts/native-endurance-final`, bound to executable SHA-256 `6b75228f6362f3b7cc4280dfa228da35fa86d7258e3540e58cc49b5921311aa7`. This does not establish long-stream input latency, physical Wayland, GPU inference endurance or packaged-window endurance.
+
+The full current native workspace suite passed 1,069 tests with zero failures, ten ignored tests and one explicit built-project-cleanup exclusion; the latter remains a separate release gate. Strict core Clippy passed. Source and test evidence remains from an uncommitted development checkout, not a frozen release commit.
+
+## Live subscription coding and stricter installer evidence (2026-09-28)
+
+Official Codex and Claude adapters each completed one isolated five-case Python repair task. Before each turn, observed official authentication confirmed ChatGPT or claude.ai routing; no API fallback was allowed. Exact-workspace metadata resolved gpt-6-astra and claude-opus-5 respectively. Codex completed in 22.39 seconds and Claude in 30.32 seconds. Both preserved the fixture tests, produced the exact test-command receipt, and passed the independent offline five-case grader with no truncation, timeout, cancellation or cleanup error. Reports and unchanged input hashes are in `artifacts/live-subscription-current`. An initial custom Codex preflight timed out; the established native adapter subsequently confirmed authentication before any turn. These two small tasks are not a model ranking, real login-expiry test or full provider lifecycle qualification.
+
+The installer-auth refusal helper now requires a normal nonzero exit, excluding timeout/spawn-error and unrelated signal termination as false success. Intentional crash cases separately require SIGKILL and the exact expected interruption boundary. The updated suite passes all 37 tests, including actual timeout and signal controls. This supersedes the broader refusal classification described earlier in this ledger. Production publisher signing and frozen-commit release receipts remain outstanding.
+
+## Final development package qualification (2026-09-28)
+
+The final package build succeeds with all 770 recorded product/build inputs unchanged throughout. AppImage SHA-256 is `079ad39e92718f5041360e5cd12f2c6e547b816ac520cc916d85656b04e3341a`; Debian is `9f1a3c25a619d622dcfd9bf1083e0a6d85e61bd3f0e420c458003783e33c3766`. Package content/runtime/notices checks pass. Both exact artifacts pass offline CLI/MCP and fresh-profile first-window checks in the network-disabled runtime-only Debian 13 container. The exact AppImage also passes the real Markdown worker/CSP check and private X11 cleanup. This closes the earlier wrapper-executable mismatch limitation. The exact AppImage additionally passes all 20 interactive desktop checks and cleanup, nine runtime write/error cases, extraction/process-lifetime checks, and a no-network source rebuild reproducing its AppImage runtime hash. Workspace formatting and strict workspace/all-target Clippy pass. Final package CLI passes 21 scenario groups, TUI passes its PTY checks, MCP stdio and HTTP pass, and independent official SDK v1/v2 clients pass all four transport/version combinations. AppImage bytes remain unchanged before and after the batch. These are still unsigned development artifacts built before the subsequent local commits.
+
+## Close the observed CI packaging mismatch and built-project gate
+
+GitHub Native desktop run `36366642769` at `1ba135f` failed during packaging because CI's exported VERSION caused linuxdeploy to append `X-AppImage-Version=0.32.0` to the AppDir root launcher. The normalizer required byte-identical launchers and rejected that exact additional field. It now permits only one trailing field matching the checked-in Tauri package version; incorrect versions, duplicates, extra fields and changed Exec remain refused. The normalized shipped launcher remains canonical. The original behavior reproduced in a fixture, then all 11 focused metadata tests passed. This corrects the observed build-input variant; a new remote workflow receipt remains required.
+
+The separately required release-profile built-project cleanup test also passes on actual installed/built UI content: 17,661 entries, 261,762,614 logical bytes, 0.625 seconds to prepare and 1.704 seconds to dispose. The copied lane and quarantine are both removed; no source dependencies are deleted. Evidence: `artifacts/final-built-project-cleanup.log`. Final release-auth fixtures pass 59/59, installer-auth passes 37/37, executable-ownership checks pass 2/2, and the actual installer shell exercises the real bundled runtime. A final session-fixture run passed 34/35 with one fixed-deadline D-Bus cleanup failure during parallel compilation; that failure is retained. After compilation ended, the exact isolated case passed in 4.16 seconds with no deadline change. This recheck supports the observed scope; it does not erase the intermittent failure or establish its precise cause. The original 134-test batch passed 133 cases and failed only the stale workflow wrapper-count assertion. The corrected 35-test session suite passed that assertion and 33 other cases, with only the D-Bus failure described here. Together the targeted reruns exercise the failed cases; no all-green repeated full-batch receipt is claimed.
+
+## Explicit Wayland selection and physical COSMIC interaction evidence
+
+The original qualified AppImage could not start with Wayland explicitly selected and X11 unavailable: its bundled GTK hook unconditionally reset GDK_BACKEND to x11. The custom AppRun now saves an explicit caller choice before sourcing that hook and restores it afterward; unset/empty choices retain the upstream X11 default. Four actual-shell fixtures verify backend behavior, the caller working directory and literal argument preservation. All 37 combined launcher/release-verification tests pass, and the new launcher fixture is required by native/release checks. The previously qualified AppImage bytes remain unchanged and do not yet include this launcher correction.
+
+The revised native probe explicitly opts into physical Wayland, validates the user-owned compositor socket, removes DISPLAY fallback, verifies the launched app's GDK_BACKEND and records executable identity. The socket peer was `/usr/bin/cosmic-comp`; the active local seat was seat0 (logind reports the containing login as tty, so that observation alone is not used to infer Wayland). With the corrected launcher in the existing AppDir, 18 real interaction groups pass: onboarding, picker refresh, model settings, synthetic local task approvals, native keyboard editing/Undo/save/CRLF, change review, reload, Run a check, Accounts/permissions, cloud consent cancellation without a paid turn, Pause/Resume/Stop and owned-process cleanup. The app executable hash is `647cd740e23e09284d231bf4d0c2bc6a420a74217b05c7319ff6f454180d1a76`. No DMA-BUF disable override was applied. These checks use a synthetic inference runtime and do not qualify GPU model inference.
+
+The run remains failed overall: both requested 1360-pixel and 520-pixel window widths remained 1280×673 under COSMIC, although the display reports 1920×1080 at scale1. Initial interpretation as insufficient screen width was contradicted by that measurement. Compact-layout assertions are not counted as passes when resizing fails, and the full result exits nonzero. No compositor settings were changed. Physical floating-window resize, broader keyboard/clipboard/IME/DPI acceptance and a rebuilt AppImage containing the launcher correction remain open. Evidence and all failed attempts are retained under `artifacts/physical-wayland/`.
+
+## Version 0.33.0 and protected signing preparation (2026-09-28)
+
+The owner authorized pushing the update, updating current version documentation and replacing the local app after verification. Version 0.32.0 is already published, so the new candidate is 0.33.0. Workspace/package metadata, lockfiles, launcher metadata and current integration examples now agree on 0.33.0; historical evidence retains its original version and scope. Locked Cargo metadata and all 15 focused launcher/desktop-metadata tests pass. This version change has not yet produced a qualified 0.33.0 package.
+
+GitHub environment `release-signing` was applied and read back with Shadowfetchapps (user ID 209457103) as required reviewer, self-review allowed and exactly one deployment rule, tag `v0.33.0`. GitHub's default administrator override remains unchanged. A new Ed25519 private key is stored outside Git with owner-only permissions and uploaded only as that environment's `NATIVE_RELEASE_SIGNING_KEY_PEM` secret. Repository variable `NATIVE_RELEASE_SIGNING_ENVIRONMENT` names the environment. The reviewed tooling commit pin is pending the final committed/pushed source. No tag, signature or release was created by this configuration step.
+
+The repository now supplies public trust for key ID `f0c60ff8228314616985712f8918c9798c0ecf72b7271e19343a041c25663b7f`, epoch 1, valid only for version 0.33.0. The immutable first-authenticated-install boundary is 0.33.0, allowing an existing unsigned 0.32.0 installation to migrate through the authenticated installer. Public policy is not proof that a candidate has passed gates or been signed.
+
+Read-only inventory found the installed AppImage at version 0.32.0, SHA-256 `5c02e9e6f1938a9cf9264cd3f04d872c3ad98a039a7e3a5647b783762854954d`, without authenticated receipts. Replacement remains pending a verified signed candidate. Projects, settings, conversation history and model stores are preserved; only superseded application files are eligible for removal after successful replacement.
+
+## Frozen 0.33.0 package preflight and GitHub submission (2026-09-28)
+
+Commit `7a38fcc6196f04cae846ed0dbb6cc2a14a3afb4b` was pushed to `flagship/reliability-foundation` and confirmed as draft PR #4's head. GitHub's `NATIVE_RELEASE_TOOLING_COMMIT` was set to that exact reviewed remote commit and read back. The PR description now states the configured signing controls, scoped evidence and remaining work. The Checks workflow `36397403434` passed both interface and packaging-script jobs. Native workflow `36397403388` passed its Rust suite and reached its separately required built-project cleanup gate; it is not recorded as complete.
+
+The local 0.33.0 release build completed with all 961 recorded tracked inputs unchanged from that commit. AppImage SHA-256 is `cab6f61c5c4667c67dd8c9cd04969d6f349d42bce1ca198878e6f9be08429c62`; Debian is `741e700bbf49fe892cad2d696ed1dafd254d3fd1cc3094a5f0735250bdc32c46`; runtime-source archive is `5ac573be7979f615894b10c99a1091698ce9b02c2768750ec4387aa8f948d131`. Both packages pass contents/runtime/notices checks, offline CLI/MCP and first-window checks in the isolated runtime container. The exact AppImage passes all 20 X11/WebKit interaction groups and owned cleanup, native Markdown worker/CSP checks, all 21 CLI scenario groups, runtime write/error cases, concurrent extraction/process lifetime checks and the network-disabled runtime-source rebuild. AppImage and Debian hashes remain unchanged after the batch. Evidence is under `artifacts/release-0.33.0/`.
+
+These are unsigned local preflight packages. They include the explicit Wayland launcher fix but do not establish full physical Wayland acceptance, GPU endurance or the complete protected GitHub release workflow. Production signing/publication and the requested installed-app replacement remain pending; the existing 0.32.0 installation and all user data remain intact. A documentation-only follow-up corrects the GitHub Action README's default to match its already-updated 0.33.0 action metadata.
+
+## Correct a reproduced process-exit race in CI ownership discovery
+
+Native run `36399332104` at documentation head `f14287b` failed 1 of 138 release/session fixtures: the real isolated D-Bus test observed EACCES reading a candidate environment during cleanup discovery. The original failure remains in `artifacts/release-0.33.0/ci-f14287b-failure.log`. A bounded local kernel probe reproduced two cases where an initially live process returned EACCES from `/proc/PID/environ`, followed by either disappearance or the same PID/start/UID in zombie state. This establishes a real exit race; the original CI log did not record the denied PID's subsequent state.
+
+Discovery now rechecks basic identity after EACCES/EPERM and disregards only a missing process or the same PID/start/UID in terminal Z/X state. Live unreadable candidates, changed identities, failed rechecks and other IO errors remain fatal before any signal. The new read uses the existing fixed-deadline client; no retry loop, deadline extension or ownership relaxation was added. Seventeen deterministic cases produced six expected failures before the fix and all 17 passed afterward. The complete required release/session fixture set then passed all 155 tests with no skips, including the real D-Bus activation and external wrapper boundary; both separate executable-ownership tests passed. Independent diff review found no actionable issue. Evidence is in `artifacts/release-0.33.0/proc-race/`.
+
+The already-built 0.33.0 AppImage additionally passes native TUI, MCP stdio/HTTP and all four official TypeScript v1/v2 transport combinations. This helper/test correction does not change those package bytes; their original build identity remains `7a38fcc`. A new current-head CI receipt is required. The earlier proposed release tag at `f14287b` must be superseded by the corrected source before release; no tag has been created.
+
+
+## Controlled long-stream keyboard latency on the local 0.33.0 package
+
+On 2026-09-28, the exact ShadowCode 0.33.0 AppImage SHA-256 cab6f61c5c4667c67dd8c9cd04969d6f349d42bce1ca198878e6f9be08429c62 passed a controlled native WebKit/Xvfb long-stream keyboard probe on an AMD Ryzen 7 5700G host with approximately 64 GiB RAM. A retained 88,810-byte Markdown/code answer preceded a 90.25-second, 1,200-chunk, 120,330-byte SSE answer from an isolated authenticated llama-server test double. During 70 seconds of the active stream, all 329 trusted WebDriver keyboard events produced input and preserved the draft. Event-timestamp-to-second-animation-frame p95 was 42 ms (max 52 ms), compared with 24 ms baseline across 40 keys; the prior independent run measured 44 ms p95 across 328 keys. WebKit Event Timing separately reported 88 keydowns meeting its 16 ms observation threshold: reported-event p95 was 24 ms, maximum 32 ms. The full-population figure uses two animation frames as a next-paint-opportunity proxy; the Event Timing distribution covers only browser-reported events, so these are not identical metrics. This supports the 100 ms target for this controlled workload; it does not prove physical keyboard-to-display latency, final 20 seconds of the stream, GPU/Wayland behavior, other hardware, or full UI acceptance. App, owned children, private D-Bus activations, and external X11 cleanup all passed. Both complete raw receipts and the failed undersized-context attempt remain under `artifacts/release-0.33.0/stream-latency/`. The probe changed no tracked source, tests or tags. This ledger entry subsequently records its result; it does not qualify a 0.33.1 package.
+
+## Failed v0.33.0 release and next 0.33.1 candidate
+
+[Release run 36414378697](https://github.com/Shadowfetchapps/ShadowCode/actions/runs/36414378697) at `9e385cb` / `v0.33.0` failed the managed-runtime source-integrity check. The runtime built and six runtime tests passed, but the builder rewrote tracked timestamps in `tools/llama.cpp.pin` and `packaging/llama.cpp/COMMIT`. Signing and publication were skipped. This is a failed, unpublished release attempt; the existing tag remains unchanged.
+
+Version 0.33.1 is the next candidate. Authoritative manifests, lockfiles and current documentation are being updated independently of historical 0.33.0 package/evidence identities. The source-integrity fix and regression are tracked separately. The existing public trust range and protected deployment rule authorize only 0.33.0; reviewed changes plus a new exact tooling pin are required before signing 0.33.1. No new candidate build, signature, publication or local installation is claimed here.
+
+## 0.33.1 runtime build correction
+
+The ordinary and notices-only builder paths now leave tracked source pins and reference metadata unchanged. Actual compile time/backend remain in ignored generated `bin/COMMIT`; notices-only retains the existing compile time. Three isolated real-shell regressions reproduce the previous behavior (two failures before the fix) and pass after it. The tests run early in packaging CI, native CI and the release receipt gate. The source-integrity guard is unchanged. Public trust is prepared for the same key through 0.33.1, with repository identity, epoch and installation floor preserved; remote deployment-rule/tooling updates and the new tag still require review.
+
+Focused local validation passed 164 release/runtime/session tests, 107 authentication/installer/metadata/scanner tests and two executable-ownership tests, with no skips. A fresh real Vulkan/CPU runtime build is separate pending evidence; these fixture passes do not claim a completed 0.33.1 package, signed publication or installation.
+
+
+## Signed 0.33.1 publication and installed-app verification
+
+On September 28, 2026, [v0.33.1](https://github.com/Shadowfetchapps/ShadowCode/releases/tag/v0.33.1) was published from frozen commit `e15c4480e65db5650af012bb2a9773dbe89acf84`. Current-source Checks and Native desktop CI passed. [Release run 36427024374](https://github.com/Shadowfetchapps/ShadowCode/actions/runs/36427024374), attempt 1, passed all 15 required build gates and protected signing after owner approval. Signed artifact `10987931982` has archive SHA-256 `eedf78bf720daef56e3e1228a5e45f004fed9d38099e58bf0ae05dd8986cf7e1`.
+
+The publication job failed because its newly created draft was absent from the immediate authenticated listing. That failed workflow remains visible. Once the same draft became visible, publication resumed locally using the exact frozen publisher, original run/attempt receipts and signed artifact. It verified repository/tag identity, uploaded missing assets without replacement, downloaded and compared all seven assets, then published. A fresh public download independently matched every signed-artifact file, and offline AppImage verification confirmed publisher signature, version and full source commit. Neither tag nor package bytes changed. A subsequent tooling-only fix bounds retries of successful empty listings after draft creation; it never repeats creation or retries API errors. All 70 focused publisher/signing/verification tests passed, including delayed visibility, retry exhaustion and authorization failure.
+
+Published package SHA-256 values:
+
+- AppImage: `434853525c1df842d5b8a749989dccd32d3b8ff3df34bdee94c857f26646519b`.
+- Debian: `4f93c63092e0a2e832839e5796afc1a208b29ea5dd18adbaae7cc9c6b17d30af`.
+- Runtime sources: `909ee4d387f09e587be3d7a48bfa13ad6e5277b01c2d75d701c22cf1a53a5ec7`.
+
+The reviewed installer from the frozen release commit authenticated the public AppImage and replaced this machine's 0.32.0 installation while holding the native profile lock. The active `~/Applications/ShadowCode.AppImage` link now selects `ShadowCode-0.33.1-x86_64.AppImage` with the exact published hash. The normal launcher reports 0.33.1; desktop version/source metadata and the durable accepted-signature receipt match. The superseded AppImage, prior runtime backup and installation journal are absent after successful activation.
+
+Both user databases pass integrity checks; all protected original identities and immutable history rows remain. Four saved settings files are unchanged. Existing model-directory entries and profile-directory identities are preserved. Private backups remain outside Git. The installed AppImage passes all 20 isolated X11/WebKit interaction groups and owned-process/private-session cleanup. Its normal desktop launcher also opens a visible window with the existing user profile, after which the preservation check still passes. No paid provider task was run by this installation check.
+
+This establishes one authenticated local upgrade and installed-window qualification. It does not establish physical Wayland resizing, broader clipboard/IME/DPI acceptance, GPU inference endurance, complete live provider lifecycle conformance, or universal upgrade/rollback behavior. Evidence is retained locally in `artifacts/release-0.33.1/`; the release workflow, tag and public assets provide the public release identities.
+
+
+## Authenticated published Debian installation
+
+The public 0.33.1 Debian package was verified offline with the trusted installer bundle from `e15c448`, requiring the exact version, full source commit and publisher signature before retaining a private snapshot. Only that verified snapshot was mounted into a disposable Podman container, with `--network=none`, `--pull=never` and no-new-privileges. Runtime image ID was `1caaf41a912300bc41c3728bef96a3884f794300d9f696fcdcfbc089d8800998`; current reviewed smoke helpers were mounted read-only. No host package installation occurred.
+
+The actual package is named `shadow-code`, version 0.33.1. Package metadata, installed launcher/icon, CLI version/status, isolated project with network disabled, MCP stdio initialization/discovery, and a visible first X11 window all passed. Node, Rust and Cargo were absent. Exact Debian bytes remained unchanged; invocation-owned container cleanup passed. Earlier probe setup failures (an assumed package name, an absent cached-image helper, and non-executable mounted GUI helper) were retained, then corrected using inspected package metadata and explicit Bash execution. None was relabelled as a product failure or passing attempt.
+
+Evidence: `artifacts/release-0.33.1/authenticated-debian-result.json`, the probe and retained logs. This is fresh authenticated installation and first-window evidence, not Debian upgrade/rollback, paid provider use, physical Wayland or GPU inference qualification.
+
+
+## Unreleased live verification refresh after editor saves
+
+A new integration regression against `d4d9aa8` reproduced a misleading visible verdict: the actual FileEditor save completed, but TaskSummary retained “Configured checks passed” without another focus/manual refresh. The editor now emits a project-scoped invalidation after successful saves and after observing changed disk revisions. Task summaries also use the existing shared native event transport for relevant same-session mutation/completion wake-ups, excluding token events.
+
+Invalidation immediately clears the prior passing display. An edit during an unresolved backend assessment advances a generation and coalesces one follow-up read; the older response cannot restore green. Project/session/read-function identity is checked before displaying cached results. Hidden summaries defer backend reads and subscribers are removed on unmount. This adds local subscribers to the existing shared native registration, not one backend event registration per summary.
+
+Five new regressions cover editor integration, in-flight invalidation, project/session changes, hidden/unmounted summaries and token-event filtering. All 512 UI tests across 83 files passed, along with TypeScript checking, production build and production test-transport exclusion. The original-code failing reproduction and final logs are retained under `artifacts/prf-02-live-evidence/`.
+
+This is an unreleased source change, absent from signed 0.33.1 packages. It is not a general filesystem watcher: another session modifying the same workspace and external edits outside the open editor still require focus/manual reassessment. Rebuilt native/package qualification remains pending.
diff --git a/docs/IMPLEMENTATION_REPORT.md b/docs/IMPLEMENTATION_REPORT.md
index fa06b412..20d914a2 100644
--- a/docs/IMPLEMENTATION_REPORT.md
+++ b/docs/IMPLEMENTATION_REPORT.md
@@ -1,5 +1,9 @@
# ShadowCode 0.28 implementation report
+Historical report with later addenda. For current 0.33.1 preparation, see
+[release notes](RELEASE_NOTES.md) and the [evidence ledger](FLAGSHIP_IMPLEMENTATION.md).
+Versioned counts and provider observations below describe their original snapshots.
+
Date: 2026-09-23. Base: `main` at 0.27.0 (`d1b685c`). Machine: Linux, RTX 5060 Ti
16 GB (Vulkan), 62 GB RAM, 16 threads.
diff --git a/docs/NATIVE_DESKTOP.md b/docs/NATIVE_DESKTOP.md
index 9aa593c9..352a1c98 100644
--- a/docs/NATIVE_DESKTOP.md
+++ b/docs/NATIVE_DESKTOP.md
@@ -152,6 +152,7 @@ node scripts/check-native-package.mjs \
target/release/bundle/deb/ShadowCode_VERSION_amd64.deb
node scripts/test-native-runtime.mjs
node --test scripts/test-native-source-fetch.mjs
+node scripts/test-native-runtime-write-errors.mjs
node scripts/test-native-runtime-sources.mjs
```
@@ -236,8 +237,48 @@ and `tauri-driver` 2.0.6, then run:
```sh
cargo install tauri-driver --version 2.0.6 --locked
-xvfb-run -a -s '-screen 0 1440x1100x24' dbus-run-session -- \
- node scripts/test-native-desktop.mjs
+node scripts/run-native-x11.mjs node scripts/test-native-desktop.mjs
+node scripts/run-native-x11.mjs node scripts/test-native-markdown.mjs
+```
+
+The wrapper declares X11 before creating the private D-Bus session and uses a
+fresh runtime directory with mode 0700. HOME and the original XDG config/data/state/cache
+roots remain available for the existing read-only provider probes. Runtime
+service discovery therefore differs from the physical desktop. No GTK portal
+backend is mandated; this qualifies the controlled X11 app and Markdown/CSP
+behavior, not physical Wayland or all portal features.
+
+For an explicit test on the current physical Wayland desktop, run the desktop
+probe directly under a private bus instead of the X11 wrapper:
+
+```sh
+SHADOW_NATIVE_DISPLAY=wayland dbus-run-session -- node scripts/test-native-desktop.mjs
+```
+
+This opens a test window on your current display, uses disposable application
+state, and removes X11 fallback from the launched process. It records the
+compositor socket, display dimensions and executable identity. Resize failures
+remain failures even when the probe continues other interactions; an overall
+nonzero result must not be presented as complete Wayland qualification. Some
+tiling compositors control window dimensions independently of application
+requests. The AppImage launcher keeps the bundled X11 default when no backend
+is chosen and preserves an explicit `GDK_BACKEND=wayland` selection.
+
+Both probes capture their direct private session identity and clean only newly
+activated services bound to that exact bus, with PID/start/executable/user
+revalidation. Cleanup is bounded and fails closed. The outer wrapper then
+requires command/output closure, disappearance of recorded session processes,
+and no surviving late activation before removing its runtime directory. It
+never performs a general process kill. Incomplete output, unknown ownership,
+a residual process or missing cleanup report fails the gate. Identity checks
+are not atomic pidfd protection against a hostile PID-reuse race.
+
+Session diagnostics are retained under `artifacts/native-x11/run-*` (override
+`SHADOW_X11_ARTIFACTS`). The generated private-bus fixture exercises lifecycle
+checks without opening a display:
+
+```sh
+node --test scripts/test-native-test-session.mjs
```
Optional environment variables `SHADOW_DESKTOP_BINARY`, `SHADOW_TAURI_DRIVER`,
diff --git a/docs/RELEASE_AUTHENTICATION.md b/docs/RELEASE_AUTHENTICATION.md
new file mode 100644
index 00000000..2870e48f
--- /dev/null
+++ b/docs/RELEASE_AUTHENTICATION.md
@@ -0,0 +1,160 @@
+# Publisher authentication contract
+
+The helpers in `scripts/native-release-auth.mjs` and `scripts/verify-native-release.sh` verify offline publisher signatures. They do not install, execute, download or publish an application, update trusted keys, or generate production keys. CI tests their behavior with disposable fixture keys. Public trust for 0.33.0–0.33.1 is tracked and the protected signing environment is configured. The installer uses these helpers before executing a candidate and explicitly refuses `--unverified`. [Version 0.33.1](https://github.com/Shadowfetchapps/ShadowCode/releases/tag/v0.33.1), published on September 28, 2026, is the first signed release.
+
+The Bash verifier loads `scripts/native-release-auth-lib.sh` from its own script directory. Keep both reviewed code files together in the trusted tooling bundle; do not obtain the library from the candidate download or source metadata as shell code. The installer uses the same reviewed routines for current candidates and signed historical receipts; it never sources release metadata as code.
+
+## Published release: 0.33.1
+
+The `v0.33.0` release attempt at `9e385cb` failed its source-integrity gate
+before signing/publication ([run 36414378697](https://github.com/Shadowfetchapps/ShadowCode/actions/runs/36414378697)).
+Its tag remains unchanged. The 0.33.1 release and reviewed tooling are pinned to
+`e15c4480e65db5650af012bb2a9773dbe89acf84`. [Run 36427024374](https://github.com/Shadowfetchapps/ShadowCode/actions/runs/36427024374),
+attempt 1, passed all 15 required build gates and protected signing. Its
+publication job failed on a transient draft-listing response; the frozen
+publisher then resumed locally with the same signed artifact `10987931982`, compared
+all seven downloaded remote assets and published the release. The workflow
+failure remains recorded. Publication does not establish a local installation
+or qualification beyond the recorded gates.
+
+## Trust and security contract
+
+The authorized canonical publisher is `Shadowfetchapps/ShadowCode`, repository ID `1377099349`, owner ID `209457103`. A read-only GitHub API query resolved the historical `ShadowfetchLinux/ShadowCode` origin to that identity. Only stable `MAJOR.MINOR.PATCH`, `x86_64-unknown-linux-gnu` releases are supported by this slice.
+
+SHA-256 establishes byte integrity. The detached Ed25519 signature establishes that the holder of an already trusted publisher key signed the release envelope. It does not independently prove which workflow ran, that code is safe, or that the release is the newest available. The workflow identity is currently a publisher assertion; a future GitHub attestation can add an OIDC-backed workflow identity proof.
+
+The trust directory must come from an independently trusted installer/policy bundle, not from the candidate download. There is no adjacent-key discovery or default test key. Explicitly pointing `--trust-dir` at attacker-provided material defeats the premise; the production installer must choose its own pinned trust path, not accept one from release metadata or an environment override.
+
+The initial installer/key bootstrap requires an authenticated distribution channel: use a reviewed canonical repository revision and independently published public-key fingerprint, or another authenticated distribution channel. A verifier/key distributed beside an arbitrary download does not authenticate itself. This prototype is not a new automatic updater.
+
+## Exact envelope and bindings
+
+`RELEASE-AUTH` is at most 4096 bytes, printable ASCII plus LF/TAB, with a final LF and exactly 16 ordered lines. Unknown, missing, duplicate and reordered fields are rejected. Scalar values cannot contain `=` or TAB. Numeric version components and key epochs are bounded to nine decimal digits with no unnecessary leading zeroes. Commit is exactly 40 lowercase hexadecimal digits; hashes are exactly 64.
+
+The line order is:
+
+1. Literal `ShadowCode-Release-Auth-v1`.
+2. `repository=Shadowfetchapps/ShadowCode`.
+3. `repository-id=1377099349`.
+4. `owner-id=209457103`.
+5. `version=MAJOR.MINOR.PATCH`.
+6. `tag=vMAJOR.MINOR.PATCH`, exactly matching version.
+7. `commit=COMMIT_SHA`.
+8. `target=x86_64-unknown-linux-gnu`.
+9. `channel=stable`.
+10. `key-id=SHA256_OF_ED25519_SPKI_DER`.
+11. `key-epoch=POSITIVE_INTEGER`.
+12. `manifest-sha256=SHA256_OF_EXACT_RELEASE_MANIFEST_JSON_BYTES`.
+13. `checksums-sha256=SHA256_OF_EXACT_SHA256SUMS_BYTES`.
+14. `asset=appimageShadowCode_VERSION_amd64.AppImageSIZESHA256`.
+15. `asset=debShadowCode_VERSION_amd64.debSIZESHA256`.
+16. `asset=runtime-sourcesShadowCode_VERSION_appimage-runtime-sources.tar.gzSIZESHA256`.
+
+`RELEASE-AUTH.sig` is exactly the 64-byte raw Ed25519 signature over all envelope bytes. There is no home-grown cryptography, prehash signature variant, URL field, arbitrary asset path or shell-sourced content. The domain marker prevents reuse as a different application statement.
+
+The complete three-asset list is always authenticated. `SHA256SUMS` must contain exactly those entries in that order, matching the current `check-native-package.mjs` producer: each `HASH`, two spaces, basename, LF. Duplicate, extra, path-bearing and contradictory entries fail even when the checksum-file digest is correctly signed. Each artifact is nonempty and at most 32 GiB. The exact JSON manifest is hash-bound and limited to 1 MiB. Its audit contents are opaque to the shell verifier; the envelope is the authoritative install identity. The Node construction API additionally checks the manifest's current schema/tag/commit/target and exact asset map and requires the existing pretty-printed JSON serialization, which rejects duplicate JSON fields. Publisher receipt validation remains the responsibility of the existing release gates and publisher before signing.
+
+Consumers may download only their selected package plus the envelope, signature, manifest and checksums; they do not need both package formats or the source archive. The selected basename must exactly match one authenticated entry.
+
+## Trust policy and rotation
+
+The explicit trust directory contains `policy` and one `KEY_ID.pem` public key per accepted/history key. PEM public keys are bounded to 1024 bytes and must be the canonical public SPKI serialization produced by Node/OpenSSL, with no extra PEM blocks. Both verifiers reject a matching private-key container, including a private key appended after a valid public block; only the signing API accepts private keys. The decoded key must be Ed25519, and the SHA-256 of its DER bytes must match the authenticated key ID. The shell additionally checks the exact Ed25519 SPKI encoding.
+
+`policy` is at most 4096 bytes with these nine fixed lines, followed by one to eight unique key lines:
+
+1. `ShadowCode-Release-Trust-v1`.
+2. `repository=Shadowfetchapps/ShadowCode`.
+3. `repository-id=1377099349`.
+4. `owner-id=209457103`.
+5. `target=x86_64-unknown-linux-gnu`.
+6. `channel=stable`.
+7. `minimum-epoch=POSITIVE_INTEGER`.
+8. `minimum-version=MAJOR.MINOR.PATCH`.
+9. `keys=ed25519-spki-sha256`.
+10. Each `key=EPOCHKEY_IDMINIMUM_VERSIONMAXIMUM_VERSION`.
+
+Each key has an inclusive version range and exactly one epoch. Unknown keys, different public bytes under a known ID, retired epochs and versions outside the trusted range/floor fail. No policy/key update can be authorized merely by placing new keys beside a release.
+
+For a reviewed rotation, distribute a new authenticated trust bundle with the new public key and higher minimum epoch/version. Retain the old public key with its historical version range only when needed to verify an existing accepted-state receipt. The internal history check may ignore current minimum floors, but it still verifies the old signature, identity, key epoch and historical key range. This exception is not exposed as an install-verification API. Deleting an old key outright can make old state unverifiable; the installer must then fail pending explicit migration, not erase the state or assume a fresh install.
+
+The tracked `release/trust` bundle contains the public Ed25519 key with ID `f0c60ff8228314616985712f8918c9798c0ecf72b7271e19343a041c25663b7f`. Its epoch is 1, minimum version is 0.33.0, and its inclusive key range is 0.33.0–0.33.1. It authorizes no later version without a reviewed policy update. The private key is not distributed in the repository or installer bundle. Fixture keys remain temporary and separate. Public fingerprint distribution, custody and future rotation/revocation remain owner responsibilities.
+
+## Downgrade, replay and accepted state
+
+Both verifiers can compare the candidate with a supplied prior accepted `RELEASE-AUTH` plus signature. They cryptographically verify that prior receipt, then reject a lower version, lower signing epoch, or any different envelope bytes under the same version. Identical retries are accepted. Comparison is numeric by stable version component, not lexicographic or a GitHub run number.
+
+The stateless verifier module does **not** discover or persist installed state. The installer now supplies its durable highest accepted receipt under the installation lock/journal, refuses missing/corrupt state for an existing authenticated installation, and never decrements it during rollback/recovery. Supplying an older genuinely signed receipt or deleting local state cannot be detected by a stateless verifier. Local same-user compromise is outside this trust boundary.
+
+The trust policy's minimum version limits first-install replay to a known floor. A fresh offline host still cannot know the latest release or learn that a signing key was revoked after its last trusted policy update. There is no timestamp/expiry freshness claim. Unattended update discovery, trusted-clock freshness rules and database downgrade behavior are not implemented.
+
+## APIs and exact staged bytes
+
+`native-release-auth.mjs` exports:
+
+- `createEnvelope({bundleDir, version, commit, keyId, keyEpoch}) -> Buffer`: validates all three actual package files, canonical checksums and current manifest bindings; emits deterministic envelope bytes.
+- `signEnvelope(bytes, privateKeyPem) -> Buffer`: checks the envelope and signing-key fingerprint, then uses Node's Ed25519 implementation. It never creates a key or writes one.
+- `verifyEnvelope({bytes, signature, trustDir}) -> metadata`: validates current trust policy and the Ed25519 signature. This verifies metadata only, not package contents.
+- `verifyBundle({bundleDir, trustDir, artifact, previousDir?, expectedVersion?, expectedCommit?}) -> metadata`: verifies signature, previous-state constraints, exact metadata/checksum bindings and selected package bytes. This read-only Node API is for CI; it does not retain an execution-safe snapshot.
+- Strict parser/identity helpers and explicit limits used by fixtures.
+
+The end-user path is Bash plus OpenSSL 3 and ordinary GNU coreutils/diffutils; it requires no Node, Python, Rust, GitHub CLI or compiler:
+
+```text
+bash verify-native-release.sh \
+ --bundle-dir DOWNLOADED_FILES \
+ --trust-dir INDEPENDENTLY_TRUSTED_KEYS \
+ --artifact ShadowCode_0.33.1_amd64.AppImage \
+ --stage-dir NEW_PRIVATE_STAGE \
+ --expect-version 0.33.1 \
+ --expect-commit EXPECTED_COMMIT
+```
+
+`--previous-dir` optionally supplies the durable prior accepted envelope/signature. Every option is unique; missing mandatory arguments and unexpected options fail. There is no bypass/unsigned option.
+
+The shell snapshots all relevant inputs into a private 0700 directory. GNU `dd` uses `nofollow,nonblock,count_bytes` and copies at most the observed, bounded file size plus one byte. This closes a post-check symlink swap and prevents an injected FIFO from blocking the input open/read. Exact snapshot size/hash/signature are checked. The selected artifact remains mode 0400 and is **never executed**. On success a same-filesystem, non-overwriting rename retains only the five verified files at the requested new stage path. Failure removes private scratch data and leaves any pre-existing stage untouched.
+
+The installer executes and installs only this verified private snapshot, never the original mutable download path. The read-only mode prevents accidental writes; it is not immutable against an attacker already controlling the same user. Existing checksum mismatch, managed-runtime and journal checks must remain. The previous `--unverified` flag is explicitly refused before destination creation; it has not become a signature bypass.
+
+## Release workflow and provisioning boundary
+
+The release workflow now separates source build/qualification, signing, and publication. `prepare-native-release.mjs` creates an unsigned handoff containing the exact three packages, canonical checksums/manifest and required gate receipts. A fresh signing runner downloads that build's immutable Actions artifact ID as data. It checks out signing code and public trust from an independently reviewed commit, validates the current repository/tag/commit/run/attempt and all required receipts, and signs private snapshots. It does not execute packages, install source dependencies, or restore build caches. Only its signing step receives the private key.
+
+A separate publisher runner receives the signing job's immutable artifact ID and repository write permission, but no signing secret. `publish-native-release.mjs` requires the exact seven public assets (three packages plus the four metadata/signature files), verifies private snapshots before any GitHub call, checks remote repository and tag identity, stages a draft, and compares every remote asset's bytes before publication. Identical published retries are read-only; changed bytes under an existing version are refused. `VERIFICATION.json` is bounded CI transport data, not a release asset. Run IDs qualify each attempt without changing immutable release bytes.
+
+The workflow deliberately refuses missing owner configuration. The following records the 0.33.1 setup; recheck it before dispatching a release:
+
+- The tracked `release/trust` public policy and key bundle must be included in the reviewed tooling commit and distributed through an authenticated channel. It contains public material only; no default fixture key is trusted.
+- Repository variable `NATIVE_RELEASE_TOOLING_COMMIT` must pin the exact 40-character reviewed and pushed commit containing the current gate definitions, signing/publisher code, public trust and release-appropriate notes. The reviewed pin for 0.33.1 is `e15c4480e65db5650af012bb2a9773dbe89acf84`. Never infer this pin from an unreviewed tag.
+- Repository variable `NATIVE_RELEASE_SIGNING_ENVIRONMENT` is set to `release-signing`. Its verified configuration requires the `Shadowfetchapps` User reviewer (ID `209457103`), allows self-review and retains GitHub's default administrator override. Its sole deployment rule allows the tag `v0.33.1`. This is an owner-reviewed gate, not a two-person approval guarantee; a later release needs an explicit rule/policy update.
+- Environment secret `NATIVE_RELEASE_SIGNING_KEY_PEM` has been uploaded to `release-signing`. It is scoped to the signing environment, not repository-wide secrets or the build job. Its presence does not prove that a candidate has been signed.
+
+Owner review of the release workflow/source and repository protections remains necessary: another authorized workflow could request the same environment secret. Local gate receipts are validated assertions, not independent attestations that an adversarial producer executed the commands. Workflow dependencies and exact artifact IDs bind the proposed job flow; local fixtures do not prove GitHub control-plane behavior. Remote checks are observations, not atomic compare-and-swap against independently authorized concurrent changes.
+
+All existing required gates remain, with a new required `built-project-cleanup` gate running the exact release-profile cleanup test over actual installed UI dependencies and output. The default Rust suite transfers only this test to that gate; missing, failed or ignored qualification rejects signing and publication. This scope does not claim full Cargo-cache cleanup qualification.
+
+The tracked AppImage installer authenticates candidates and retains durable accepted-state receipts. Production signing and publication of the seven authenticated 0.33.1 assets are complete, with the workflow failure and publication recovery recorded above. The published .deb was also authenticated into a private snapshot and installed in a network-disabled runtime-only container; version, CLI/MCP and first-window checks passed (see the implementation ledger). Independently authenticated installer bootstrap distribution and the broader installation/upgrade/rollback lifecycle remain separate qualification requirements. Existing installed applications and older public releases are not authenticated retroactively.
+
+## Trusted installer and durable accepted state
+
+Distribute a complete, independently authenticated bundle containing `scripts/install-appimage.sh`, `scripts/install-release-state.sh`, both Bash verifier files, `release/install-policy`, the `release/trust` policy/public keys, `assets/icons/shadow-agent.svg` and `packaging/shadow-agent.desktop`. A development checkout is not a runtime dependency when this bundle is complete. The installer resolves its actual script file before choosing sibling code/trust, so a convenience launcher symlink cannot choose an adjacent substitute bundle. The candidate, working directory and environment cannot override this trust root. Path resolution does not authenticate the initial bundle or protect it from its owner modifying it.
+
+The tracked `release/install-policy` has exactly these two lines:
+
+```text
+ShadowCode-Install-Policy-v1
+first-authenticated-version=0.33.0
+```
+
+This immutable boundary distinguishes old unsigned installations from an authenticated installation whose state disappeared. It is not the current minimum-version floor and must not be advanced during key rotation. Incoming candidates below it are refused before execution, even if the trust policy's floor is lower. The tracked boundary is 0.33.0; setting it does not establish that this release has been published.
+
+The installer accepts only the authenticated AppImage role. It checks the private snapshot's reported version and bundled runtime before changing installed files. Signed metadata is retained in private, content-addressed receipt generations under `~/.local/lib/.shadowcode-release-state`. A schema3 intent binds the prior/candidate receipt identities, exact app/runtime fingerprints, installation roots and pre-activation launcher/desktop identities. The accepted pointer becomes durable before app/runtime replacement; rollback can restore an earlier working application without lowering the highest accepted release.
+
+Recovery accepts exact recorded pre-activation states. With a schema3 intent, it also recovers a crash at the activation marker or immediately after active AppImage link replacement when the link points to the exact recorded prior or candidate AppImage and the recorded launcher, symlink, icon and desktop-entry identities and their installation roots are unchanged. A changed integration, unexpected active link, or changed package is preserved for manual review. During a handled installation failure, the installer restores launcher, symlink, icon and desktop-entry files from staged copies when the live identities still match the recorded prior or candidate files; an unrelated edit is preserved for review. A crash after these integration files change still requires manual recovery. Older schema1/schema2 intents retain their original recovery limits; schema2 activation-started intents are not promoted without the new evidence. Historical receipt checks retain signature/identity/key-range verification while allowing old current floors. First-window/database downgrade qualification remains incomplete. Restoring a complete old application/state backup cannot be detected by this offline local high-water record.
+
+Compatibility change: the signed entry point rejects checksum-only installation, `SHADOWCODE_SHA256SUMS` overrides and `--unverified`. Use the documented source build with a separate profile for development; a separate unsigned packaged-development installer has not been implemented. Existing published unsigned assets must not be silently repackaged or overwritten under the same version.
+
+## Evidence and limitations
+
+Run `node --test scripts/test-native-release-auth.mjs` from the repository root. Tests use ephemeral Ed25519 keys, real OpenSSL verification, malicious-but-unexecuted executable fixtures, signed malformed metadata, wrong-key/identity/version/target cases, jointly changed package/checksums, history tampering, rotation and downgrade checks, bounds, path traversal, symlinks/FIFOs, and deterministic post-validation symlink/FIFO swaps through a test-only PATH wrapper. A restricted PATH fixture proves the shell verifier operates with only its declared runtime utilities. This is helper-level evidence, not a clean-host application install, native desktop qualification, production key ceremony or workflow execution.
+
+References: [OpenSSL Ed25519 verification](https://docs.openssl.org/3.0/man1/openssl-pkeyutl/), [GitHub attestation identity verification](https://cli.github.com/manual/gh_attestation_verify), [offline attestation bundles and trusted roots](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/verify-attestations-offline), [GitHub protected environments](https://docs.github.com/en/actions/reference/workflows-and-actions/deployments-and-environments).
diff --git a/docs/RELEASE_NOTES.md b/docs/RELEASE_NOTES.md
index 891625a1..3b6a10ae 100644
--- a/docs/RELEASE_NOTES.md
+++ b/docs/RELEASE_NOTES.md
@@ -1,3 +1,41 @@
+# ShadowCode 0.33.1
+
+[ShadowCode 0.33.1](https://github.com/Shadowfetchapps/ShadowCode/releases/tag/v0.33.1) was published on September 28, 2026 from `e15c4480e65db5650af012bb2a9773dbe89acf84`. It improves editing, explicit verification, provider sign-in recovery and long-running desktop reliability. Historical notes below retain their original versions and scope.
+
+## Everyday workflow
+
+- **Editor.** Code highlighting, line numbers, undo/redo and recoverable drafts. Saves check the original disk revision, preserve supported newline formats and retain edits typed while a save is pending.
+- **Context.** Inspect the files and bounded excerpts attached to the next task, including exclusions, truncation and approximate token counts. Estimates are not provider-reported usage.
+- **Run a check…** Completed-task summaries can start a fresh, user-entered command against current project files without selecting a model or spending another model turn. Existing trust and command approvals apply. Output and a new receipt appear in a new task while your composer draft and earlier evidence remain intact. A successful command proves that check's result, not overall application correctness.
+- **Accounts.** Returning to Settings restores each provider's active login independently. Cancellation shows **Stopping…** until acknowledged. Unknown progress preserves cached information instead of silently starting fresh probes. A successful login process alone does not establish Ready or subscription billing.
+
+## Workspace and desktop reliability
+
+- Compare refuses app-owned unsaved editor drafts before taking its snapshot. Short writes coordinate across cooperating ShadowCode processes, and interrupted cleanup retains ownership evidence and recoverable work. Arbitrary terminal commands and external applications remain outside that cooperative lock.
+- Shared native event registrations avoid growth on every task. A source-built X11 run passed 100 qualification tasks after 25 warm-up tasks with stable listener, descriptor and thread counts and intact SQLite integrity.
+- AppImage launch preserves an explicit display-backend choice. The package normalizer accepts linuxdeploy's single exact matching version field while rejecting unrelated launcher changes.
+- Approval restoration, provider event ownership, cancellation, local-model scheduling and installer recovery have additional regression coverage.
+
+## Release and qualification status
+
+The runtime builder now preserves tracked source pins and reference metadata during ordinary builds and notices-only refreshes. Generated runtime metadata retains the actual compilation time. Shell-flow regressions cover the two input mutations that stopped the unpublished 0.33.0 release; the immutable-source guard remains enforced.
+
+Release jobs separate build, signing and publication, verify private artifact snapshots and require complete scoped receipts. The AppImage installer requires publisher signatures and durable accepted-state checks; checksum overrides and `--unverified` do not bypass authentication. Previously published unsigned artifacts are not retroactively authenticated.
+
+Recorded source evidence includes 507 UI unit tests, 75 browser scenarios and the 100-task native endurance run. These counts describe the tested source snapshots, not automatically the final 0.33.1 package. Small live coding tasks have passed across Codex, Claude, Cursor, Grok and Antigravity; full live login-expiry/cancellation/continuation qualification and ACP billing entitlement remain separate.
+
+[Checks 36418625715](https://github.com/Shadowfetchapps/ShadowCode/actions/runs/36418625715) and [Native desktop 36418625663](https://github.com/Shadowfetchapps/ShadowCode/actions/runs/36418625663) passed on the release commit. [Release run 36427024374](https://github.com/Shadowfetchapps/ShadowCode/actions/runs/36427024374), attempt 1, passed all 15 required build gates and protected signing. Its publication job failed when the new draft was briefly absent from the release listing, so the workflow retains a failure conclusion. Publication resumed locally using the same frozen publisher at `e15c448` and immutable signed artifact `10987931982`; all seven remote asset files were downloaded and compared before the release became public. The tag and package bytes were preserved. A fresh download of all seven public assets matched the signed artifact byte for byte, and offline verification of the public AppImage passed against the independently reviewed trust bundle, expected version and full commit.
+
+Physical COSMIC/Wayland interaction coverage is partial: the overall run failed its requested window-resize assertions. GPU inference endurance and broader keyboard/clipboard/IME/DPI acceptance are not established. See the [implementation evidence ledger](FLAGSHIP_IMPLEMENTATION.md) for exact snapshots, retained failures and later results.
+
+The [published Linux release](https://github.com/Shadowfetchapps/ShadowCode/releases/tag/v0.33.1) includes `ShadowCode_0.33.1_amd64.AppImage`, `ShadowCode_0.33.1_amd64.deb` and `ShadowCode_0.33.1_appimage-runtime-sources.tar.gz`, together with `SHA256SUMS`, `RELEASE-MANIFEST.json`, `RELEASE-AUTH` and `RELEASE-AUTH.sig`. Use the [release procedure](RELEASING.md) and [publisher authentication contract](RELEASE_AUTHENTICATION.md) to verify packages with independently trusted installer tooling.
+
+## Failed 0.33.0 release attempt (unpublished)
+
+[Release run 36414378697](https://github.com/Shadowfetchapps/ShadowCode/actions/runs/36414378697), from `9e385cb` / `v0.33.0`, failed the managed-runtime source-integrity gate: the builder changed tracked `tools/llama.cpp.pin` and `packaging/llama.cpp/COMMIT` timestamps. The runtime build and six runtime tests passed, but signing and publication were skipped. The tag is retained unchanged; 0.33.1 was built and qualified separately. Local package and latency evidence for 0.33.0 is not relabelled as 0.33.1 evidence.
+
+## Published 0.32.0
+
ShadowCode 0.32.0 is the biggest update yet: it's easier to use every day,
the agent is smarter, commands are properly sandboxed, and you can use
ShadowCode from your phone, your editor, or on a schedule. The 0.31.1 notes
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index e87b6af1..28dcfb6d 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -1,8 +1,13 @@
# Release procedure
-Pushing a `v*` tag runs `.github/workflows/release.yml`, which publishes the
-release. It builds, tests, packages and uploads the AppImage, the deb, the
-AppImage runtime source archive and `SHA256SUMS`, with
+The most recent documented release is [**0.33.1**](https://github.com/Shadowfetchapps/ShadowCode/releases/tag/v0.33.1). Updating version files or
+these instructions does not publish or sign a release. Bind final build, test
+and authentication receipts to the exact candidate before publication.
+
+Pushing a `v*` tag starts `.github/workflows/release.yml`. Publication follows
+build/qualification, protected signing review and verification of all seven
+public assets. The current environment permits only `v0.33.1`; later versions
+require reviewed trust-policy, deployment-rule and tooling-pin updates. The workflow uses
`docs/RELEASE_NOTES.md` as the release text.
## 1. Version files
@@ -17,7 +22,9 @@ All four must carry the same version, and the tag must be `v`.
| `ui/package.json` | `version` |
| `packaging/shadow-agent.desktop` | `X-ShadowCode-Version=` |
-Update `CHANGELOG.md` and `docs/RELEASE_NOTES.md`. The notes file becomes the
+Update the workspace package entries in `Cargo.lock` and the root package
+versions in `ui/package-lock.json` alongside those four files; keep locked
+builds reproducible. Update `CHANGELOG.md` and `docs/RELEASE_NOTES.md`. The notes file becomes the
GitHub release text as it is, so it should start with this release. Notes of
earlier releases may follow below it, as they do now.
@@ -84,6 +91,7 @@ node scripts/build-native.mjs
node scripts/check-native-package.mjs \
target/release/bundle/appimage/ShadowCode_VERSION_amd64.AppImage \
target/release/bundle/deb/ShadowCode_VERSION_amd64.deb
+node scripts/test-native-runtime-write-errors.mjs
node scripts/test-native-runtime-sources.mjs
node scripts/test-native-runtime.mjs
bash scripts/test-install-appimage.sh
@@ -106,7 +114,34 @@ bash scripts/test-install-appimage.sh
runtime swap and a changed download. If `packaging/llama.cpp/bin` exists, it
also runs the real `llama-server`.
-## 5. Tag
+## 5. Protected signing review and tag
+
+The `v0.33.0` attempt at `9e385cb` failed before signing/publication in
+[run 36414378697](https://github.com/Shadowfetchapps/ShadowCode/actions/runs/36414378697).
+Do not move or overwrite that tag. Version 0.33.1 was published from
+`e15c4480e65db5650af012bb2a9773dbe89acf84`, with all 15 required build gates and
+protected signing passing in [run 36427024374](https://github.com/Shadowfetchapps/ShadowCode/actions/runs/36427024374).
+The workflow publication job failed on a transient draft-listing response;
+publication resumed locally with the same frozen publisher and signed artifact, verifying
+all seven remote asset files before publication. This does not make that workflow
+run green. Existing 0.33.0 receipts do not qualify the new package bytes.
+The first-authenticated install boundary remains 0.33.0; do not silently move
+this durable boundary.
+
+Public trust in `release/trust` currently allows epoch 1 and versions 0.33.0–0.33.1;
+`release/install-policy` fixes the first authenticated version at 0.33.0.
+The `release-signing` environment has the signing secret and requires review by
+`Shadowfetchapps` (User ID `209457103`). Self-review is allowed and GitHub's
+default administrator override remains unchanged. Its sole deployment rule is
+`v0.33.1`; `NATIVE_RELEASE_SIGNING_ENVIRONMENT` points to that environment.
+The reviewed `NATIVE_RELEASE_TOOLING_COMMIT` used for this release is
+`e15c4480e65db5650af012bb2a9773dbe89acf84`.
+
+Before tagging, finish review and push the tooling commit, then set
+`NATIVE_RELEASE_TOOLING_COMMIT` to that exact 40-character SHA. Read back this pin from GitHub configuration before dispatching the release. Verify the environment protections and source/candidate
+receipts before approving signing. Neither the public key nor configured secret
+proves that a package has been built, signed or published. See the
+[authentication contract](RELEASE_AUTHENTICATION.md) for bootstrap and custody.
```sh
git tag -a vVERSION -m "ShadowCode VERSION"
@@ -124,22 +159,41 @@ git push origin vVERSION
6. Rebuilds the AppImage runtime sources without network access.
7. Runs the packaged CLI, TUI, window and MCP checks.
8. Writes `SHA256SUMS` and runs the installer test.
-9. Creates the release from `docs/RELEASE_NOTES.md` and uploads:
+9. Prepares an unsigned handoff with required receipts. After protected review,
+ a fresh runner verifies that immutable handoff using pinned tooling and signs
+ private snapshots; the build job never receives the key.
+10. A separate publisher verifies and stages these seven assets in a draft,
+ compares every remote asset's bytes, then publishes:
- `ShadowCode_VERSION_amd64.AppImage`
- `ShadowCode_VERSION_amd64.deb`
- `ShadowCode_VERSION_appimage-runtime-sources.tar.gz`
- `SHA256SUMS`
+ - `RELEASE-MANIFEST.json`
+ - `RELEASE-AUTH`
+ - `RELEASE-AUTH.sig`
+
+Identical published retries are read-only; changed bytes under an existing
+version are refused. Do not overwrite published versioned assets.
Don't announce a tag until this job succeeds. Afterwards, compare the uploaded
checksums and the release text with what you expect.
## 6. Install check
+Use the complete independently authenticated installer bundle, including its
+`release/trust`, install policy, verifier helpers, icon and desktop entry. Keep
+the downloaded AppImage beside `SHA256SUMS`, `RELEASE-MANIFEST.json`,
+`RELEASE-AUTH` and `RELEASE-AUTH.sig`.
+
```sh
-sha256sum --ignore-missing -c SHA256SUMS
-./scripts/install-appimage.sh /path/to/ShadowCode_VERSION_amd64.AppImage
+bash /path/to/trusted-bundle/scripts/install-appimage.sh /path/to/downloads/ShadowCode_0.33.1_amd64.AppImage
```
+The installer accepts a single AppImage path or `--recover`; it does not accept
+`--bundle-dir`, `--trust-dir` or `--expect-version`. Those belong to the separate
+read-only verifier. No checksum-only or `--unverified` bypass is supported.
+For manual Debian verification/install, see the [README](../README.md#debian-package).
+
Then open ShadowCode from the desktop entry and check:
- Settings, conversations and accounts are still there.
diff --git a/docs/SUBSCRIPTIONS.md b/docs/SUBSCRIPTIONS.md
index 0a02adcb..92a5b305 100644
--- a/docs/SUBSCRIPTIONS.md
+++ b/docs/SUBSCRIPTIONS.md
@@ -27,6 +27,12 @@ server is installed from **Settings › Accounts** (see
**Settings › Accounts** shows those lines. One sign-in per vendor can run at
a time. It can be cancelled and stops after 10 minutes. When it finishes,
ShadowCode checks the account again.
+- **Returning to Accounts.** Active sign-ins are recovered without starting
+ another login. Each provider retains its own instructions and Cancel
+ control. If cancellation is already in progress, the card continues to
+ show **Stopping…** until the provider operation finishes. An unavailable
+ progress read keeps cached account information; use **Refresh** to request
+ another explicit check.
- **Disconnect** asks for confirmation, then runs `codex logout`,
`claude auth logout`, `cursor-agent logout` or `grok logout`. Those sign the
CLI out everywhere on this computer, not only in ShadowCode. ShadowCode then
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index 60227ab1..314d5410 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -1,6 +1,6 @@
# ShadowCode user guide
-This guide covers ShadowCode 0.30 and follows the usual workflow: open a
+This guide describes the 0.33.1 source workflow: open a
project, pick a model, describe the task, watch the agent work, then review
what changed. Installation and a feature overview are in the
[README](../README.md).
@@ -19,13 +19,34 @@ On first launch, onboarding asks for the project and a permission mode.
The sidebar groups conversations by project. **New task** (`Ctrl+N`) starts a
new conversation in the current project.
+## Edit files and inspect context
+
+Open a file from the project Files list to edit it with code highlighting, line
+numbers and undo/redo. Drafts survive switching files and reopening the editor.
+Save checks the disk revision so a stale editor does not silently replace a
+newer file. Compare asks you to save or discard app-owned unsaved drafts before
+it snapshots project files.
+
+Inspect attached context before sending: the inventory shows bounded excerpts,
+excluded content, truncation and an approximate token count. These estimates do
+not establish the provider's final context size or billed usage.
+
+## Run a check without another model turn
+
+On a completed task summary, choose **Run a check…**, enter the command and
+submit it. The command uses the current project and conversation, follows normal
+trust and approval controls, and creates its own output and verification
+receipt. No model selection is required; the existing composer draft stays
+intact. A passing check covers that command and observed snapshot, not every
+requirement in the project. See [verification receipts](VERIFICATION_RECEIPTS.md).
+
## Pick a model
Open the picker (`Ctrl+M`, or the model button in the composer). It has three
groups:
-- **Subscriptions**: Codex, Claude Code, Cursor, Antigravity and Grok, run
- through the vendor's official CLI. See [subscriptions](SUBSCRIPTIONS.md).
+- **Subscriptions / Vendor CLIs**: Codex, Claude Code, Cursor, Antigravity and Grok, run
+ through the vendor's official CLI. Unconfirmed billing is labelled Vendor CLIs. See [subscriptions](SUBSCRIPTIONS.md).
- **On this computer**: GGUF models run by the bundled llama.cpp. See
[local models](LOCAL_MODELS.md).
- **API keys**: OpenRouter models, billed per token to your OpenRouter
@@ -50,6 +71,14 @@ default for new conversations.
Type in the composer and press `Enter`. `Shift+Enter` adds a line.
+The composer keeps your model, **Code / Plan / Ask**, permission and **Web**
+choices in view. Open **More** for reasoning effort, **Compare** and
+**Worktree**; press `Escape` or click outside to close it. `Ctrl+Shift+Enter`
+starts a worktree task without opening the menu, and a non-default reasoning
+effort stays marked on the **More** button.
+
+
+
- **Attachments.** Attach text files or up to four images per message. Images
are accepted only when the selected row is marked Vision. Attachments are
copied into `/.shadow/attachments/`.
@@ -76,9 +105,9 @@ Type in the composer and press `Enter`. `Shift+Enter` adds a line.
mode for the next message. *Plan* and *Ask* are read-only: the agent reads
the project and answers or writes a plan, and nothing is changed.
`/plan` and `/review` do the same for one message.
-- **Reasoning effort.** Where the chosen model has an effort setting, a
- *Effort* control appears next to the model: *default*, *low*, *medium* or
- *high*, remembered per model. It maps to OpenRouter's `reasoning.effort`,
+- **Reasoning effort.** Where the chosen model has an effort setting, choose
+ *More › Effort*: *default*, *low*, *medium* or *high*, remembered per
+ model. It maps to OpenRouter's `reasoning.effort`,
the thinking switch of local models whose chat template has one (off for
*low*), Codex's `model_reasoning_effort` and Claude Code's thinking budget.
Cursor, Grok and Antigravity don't take one, so the control is hidden.
@@ -171,13 +200,22 @@ Cursor rules and Claude Code skills. See [Subagents](SUBAGENTS.md).
## Review the result
When the task ends, a summary lists the changed files with line counts and any
-test or build commands with their results. A task counts as verified only from
-recorded command results. An answer that claims success without a recorded
-check is marked as unverified. A task that finished or was stopped without
+test or build commands with their results. Ordinary successful commands are
+separate from configured or explicitly chosen checks. Use **Refresh check
+evidence** to reassess whether passing evidence still matches the current
+files. An answer that claims success without a recorded check is marked as
+unverified. A task that finished or was stopped without
changing files or running checks shows one quiet line instead, for example
*Finished · 7s · No files were changed.* A stopped task keeps its partial
reply.
+- **Run a check…** opens a blank command field, such as `npm test`. It runs
+ against the conversation's current project files with your normal command
+ approvals, without selecting a model or sending another model request.
+ Your unsent message stays in the composer. Output and a new check result
+ appear in the conversation; earlier results remain in history. Commands
+ have a five-minute maximum, or your shorter configured tool timeout. A
+ passing exit code confirms that check, not every requirement of the task.
- **Review changes** appears only when files changed. It opens the task's
review across the whole window: only the files this task changed, each
compared with how it was before the task. Switch between a **Unified** and
@@ -374,8 +412,8 @@ reached its limit. **Refresh** checks the vendor accounts again.
## Compare models
-Not sure which model suits a task? Type it, then press **Compare** next to
-**Send** and choose 2 or 3 models (at most one on this computer). Each works
+Not sure which model suits a task? Type it, open **More › Compare**, and
+choose 2 or 3 models (at most one on this computer). Each works
in its own copy of the project, starting from your latest commit plus any
uncommitted work, so your files stay as they are until you choose.
@@ -400,8 +438,8 @@ lanes use your plan, OpenRouter lanes are billed per token. Details:
## Run tasks side by side
A project runs one task at a time in its folder. To start another while one
-is working, type it and press **Worktree** next to **Send** (or
-`Ctrl+Shift+Enter`). While a task is running, the button reads **Run now in
+is working, type it and choose **More › Worktree** (or press
+`Ctrl+Shift+Enter`). While a task is running, the menu item reads **Run now in
worktree** instead of queueing.
The new task gets its own conversation and its own copy of the project,
diff --git a/docs/VERIFICATION_RECEIPTS.md b/docs/VERIFICATION_RECEIPTS.md
new file mode 100644
index 00000000..0ce893ca
--- /dev/null
+++ b/docs/VERIFICATION_RECEIPTS.md
@@ -0,0 +1,62 @@
+# Recorded checks and verification receipts
+
+Task execution and verification are independent. A finished model turn or successful shell process does not prove task acceptance. The native engine extends the existing `verification.summary` event with versioned receipts; it does not create a second task store.
+
+## Run an explicit check
+
+In 0.33.1, **Run a check…** on a completed task summary starts a fresh
+user-entered command through the existing test-task action. It preserves the
+composer draft, requires no selected model, and follows normal trust and
+command approval controls. The result gets its own task and receipt; historical
+redacted command text is never silently replayed. Navigation away from the
+originating project/conversation prevents stale submission or attachment.
+
+## What counts
+
+A command is a configured check only when it is launched through the existing explicit test-task action, exactly matches a command discovered from project manifests in the project root, or exactly matches a user-configured `verification.commands` entry in application configuration. Execution still uses the existing trust, shell approval, sandbox, and lifecycle-hook path. Merely containing `test`, `lint`, or a runner name is not sufficient. Options and subdirectory commands that do not exactly match remain ordinary execution evidence; they can be run explicitly as checks through the test-task action.
+
+The optional application configuration field is:
+
+```yaml
+verification:
+ commands:
+ - "cargo test --offline"
+```
+
+These entries are application-level root commands. Repository configuration overlays cannot grant this setting. They do not start a process or grant shell approval. The project-inspection path identifies exact manifest commands; this is not a claim that a package's `test` script truly exercises every requirement.
+
+A passing status means **the configured checks passed**, not “the application is correct” or “all tests in the repository passed.” No test count is inferred or parsed. Custom checks may validate other acceptance conditions.
+
+## Receipt and state
+
+Each receipt records schema version, task and attempt identity, tool-call identity, stable check identity, command, working directory, workspace, timestamps, exit status, termination reason, provenance, scope, content fingerprint, and a reference to the exact `tool.completed` event. Persisted command text is redacted. Check identity is computed before redaction so two commands with different hidden arguments cannot silently supersede one another.
+
+States are `not_run`, `running`, `passed`, `failed`, `cancelled`, `skipped`, and `stale`. Timeout is a termination reason and cannot pass. Current receipts are emitted at completion; the existing task/tool stream provides running state. Missing/incomplete fingerprint evidence is skipped rather than green.
+
+Only the newest receipt for the same command identity and working directory supersedes its previous result. A different passing command cannot erase a failed check. Goal milestones requiring verification require the shared `passed` status. Compare counts typed check receipts rather than arbitrary commands, and reports stale/incomplete checks separately. The UI retains general command exit results without calling them checks; receipt details expose provenance, attempt, fingerprint, and recorded output.
+
+## Freshness and scope
+
+Fingerprints include non-ignored file names, content, and executable modes, including uncommitted changes. Generated directories `.git`, `node_modules`, `target`, `dist`, `build`, `.venv`, and `__pycache__` are outside the scope. Reading is confined and nonblocking; unsupported/nonregular files, oversized inputs, traversal errors, and budget exhaustion prevent a fingerprint. Limits are 100,000 entries, 128 MB total, the existing 4 MB per-file read bound, and a five-second scan budget.
+
+The engine captures before/after check state and rechecks passing receipts before publishing its final summary, after completion hooks. Changed content makes evidence stale. Hashing occurs around checks and final validation, not every streamed token. A receipt is evidence of that observed snapshot; it is not continuous filesystem monitoring.
+
+Historical results are reassessed through `GET /api/jobs/{id}/verification` without rewriting recorded receipts. Visible task summaries request an assessment on mount, focus, and explicit refresh. Pending or unavailable assessments cannot reuse the old green status. External edits and service restart are covered by a native service regression. This is point-in-time validation, not continuous watching.
+
+Compare reassesses completed lanes and repeats validation before Keep. A stale or incomplete configured check requires explicit Keep-without-current-checks review. Keep reserves the selected lane against new application-owned jobs during validation/capture; checks recorded at acceptance remain distinguishable after cleanup. This reservation does not prove cross-process or external-editor exclusion.
+
+Remaining integration work includes efficiently sharing change-index fingerprints, continuous invalidation, project-scoped check configuration UI and broader packaged/live-provider qualification. Compare now guards app-owned unsaved drafts and has ownership-journal cleanup recovery; these do not establish protection against arbitrary external edits. Explicit test tasks rerun commands without a model request.
+
+## Compatibility and evidence
+
+Finalization preserves task-specific command receipts even if a token limit, provider error, cancellation or interrupted-process recovery prevents a final assessment. An existing assessment is retained, including stale states; only newer receipt events are appended. The aggregate is unverified, with `final_assessment=not_completed` (or `interrupted` on recovery). Individual states and output references still describe what actually ran. The UI reports that final verification did not finish instead of claiming a previously successful check failed. Historical reassessment cannot promote a failed, cancelled or interrupted task to a verified claim or red-green success.
+
+This fallback reads only typed verification events and performs no workspace hashing, commands or automatic replay. It does not establish current file freshness. Startup tests seed persisted active jobs and reopen twice; they are recovery-state coverage, not an OS-crash injection demonstration.
+
+Receipt events and summary fields are additive to existing persisted events. Legacy command-only rows remain readable but cannot establish verification. Vendor-owned verification retains its distinct provenance and is not promoted into local evidence. A cancelled or failed task cannot publish an aggregate verified result.
+
+Regression coverage includes the original `printf test` false-positive, no-command model claims, explicit configuration, edits after a check, exact-command recovery, unrelated failures, cancellation/skipped/stale states, missing fingerprints, uncommitted content, rename behavior, ignored scope, goal acceptance, and UI labels. See `engine_tasks`, `goals`, `verification::tests`, `autonomy::tests`, and `ui/src/lib/activity.test.ts` for executable cases.
+
+## 0.33.0 local package preflight
+
+At `7a38fcc6196f04cae846ed0dbb6cc2a14a3afb4b`, both Linux packages passed content/runtime/notices checks, offline CLI/MCP and fresh-profile first windows. The exact AppImage passed 20 X11 desktop groups, native Markdown checks, 21 CLI scenario groups and runtime extraction/lifetime/source-rebuild checks. All 961 tracked build inputs and both package hashes were unchanged across the build/checks. These unsigned preflight artifacts are not a signed release or proof of completed remote native CI. Exact hashes, receipts and remaining scope are in the implementation ledger.
diff --git a/icon.svg b/icon.svg
index 5b4fb52b..cd36f295 100644
--- a/icon.svg
+++ b/icon.svg
@@ -1,96 +1,3 @@
-
+
diff --git a/integrations/github-action/README.md b/integrations/github-action/README.md
index 74526f0e..114ada56 100644
--- a/integrations/github-action/README.md
+++ b/integrations/github-action/README.md
@@ -49,7 +49,7 @@ command line runs directly.
| `github-token` | `github.token` | Used only for the comment or pull request, after ShadowCode finished. |
| `issue-number` | the event's issue or pull request | Where to comment. |
| `base` | the checked-out branch | Target branch of the pull request. |
-| `version` | `0.32.0` | ShadowCode release to run. `approval: approve` needs the release that ships this action or later. |
+| `version` | `0.33.1` | ShadowCode release to run. `approval: approve` needs the release that ships this action or later. |
| `appimage-sha256` | empty | Optional extra pin for the AppImage's SHA-256. |
| `working-directory` | the workspace | Project folder. |
diff --git a/integrations/github-action/action.yml b/integrations/github-action/action.yml
index 019460d5..5a3ddc7b 100644
--- a/integrations/github-action/action.yml
+++ b/integrations/github-action/action.yml
@@ -73,7 +73,7 @@ inputs:
version:
description: ShadowCode release to download (without the leading v).
required: false
- default: 0.32.0
+ default: 0.33.1
appimage-sha256:
description: >-
Optional SHA-256 of the AppImage. When set it must match as well as the
diff --git a/integrations/github-action/examples/issue-comment.yml b/integrations/github-action/examples/issue-comment.yml
index ae0dda4c..e0e8ace8 100644
--- a/integrations/github-action/examples/issue-comment.yml
+++ b/integrations/github-action/examples/issue-comment.yml
@@ -39,7 +39,7 @@ jobs:
persist-credentials: false
# Pin to a release tag or, better, a full commit SHA.
- - uses: Shadowfetchapps/ShadowCode/integrations/github-action@v0.32.0
+ - uses: Shadowfetchapps/ShadowCode/integrations/github-action@v0.33.1
with:
task: ${{ github.event.comment.body }}
api-key: ${{ secrets.OPENROUTER_API_KEY }}
diff --git a/integrations/github-action/examples/pr-label.yml b/integrations/github-action/examples/pr-label.yml
index bc07402c..070873d4 100644
--- a/integrations/github-action/examples/pr-label.yml
+++ b/integrations/github-action/examples/pr-label.yml
@@ -35,7 +35,7 @@ jobs:
persist-credentials: false
fetch-depth: 0
- - uses: Shadowfetchapps/ShadowCode/integrations/github-action@v0.32.0
+ - uses: Shadowfetchapps/ShadowCode/integrations/github-action@v0.33.1
with:
task: >-
Review the changes on this branch against
diff --git a/native/core/examples/live_local_coding.rs b/native/core/examples/live_local_coding.rs
new file mode 100644
index 00000000..b9e5588b
--- /dev/null
+++ b/native/core/examples/live_local_coding.rs
@@ -0,0 +1,167 @@
+//! Opt-in coding evaluation with explicit installed GGUF files. No downloads,
+//! user project changes, account access or network tools. Run under a loopback-
+//! only network namespace to enforce offline inference. Reports failures too.
+//! Usage: live_local_coding [model.gguf ...]
+use anyhow::{ensure, Result};
+use serde_json::{json, Value};
+use shadowcode_core::{
+ config::Config,
+ engine::Job,
+ paths::AppPaths,
+ service::{Request, Service},
+};
+use std::{fs, time::Duration};
+
+#[path = "support/coding_fixture.rs"]
+mod coding_fixture;
+use coding_fixture::{assertions_passed, check, CHECK_COMMAND, SOURCE, TASK, TESTS};
+
+async fn evaluate(file: &str, server: &str) -> Result {
+ let root = tempfile::tempdir()?;
+ let project = root.path().join("project");
+ fs::create_dir(&project)?;
+ fs::write(project.join("helpers.py"), SOURCE)?;
+ fs::write(project.join("test_helpers.py"), TESTS)?;
+ let baseline = check(&project).await?;
+ ensure!(!baseline.ok && baseline.stdout.contains("SHADOWCODE_EVALUATOR:FAIL") && baseline.stderr.contains("Ran 5 tests"), "The evaluator must actually run and fail all five-case fixture tests before model execution: {}", baseline.stderr);
+ let paths = AppPaths::isolated(&root.path().join("profile"))?;
+ Config::patch(
+ &paths,
+ json!({
+ "model":{"provider":"local","endpoint":"http://127.0.0.1:9/v1","name":"unused","context_limit":8192},
+ "local_engine":{"llama_binary":server,"files":[file],"context_size":8192},
+ "network":{"mode":"offline"},"cli_agents":{"enabled":false},
+ "trusted_workspaces":[project],
+ "sandbox":{"require":true,"home_binds":[]},
+ "agent":{"max_steps":14,"max_task_tokens":50000,"tool_timeout_sec":30}
+ }),
+ )?;
+ let service = Service::open(paths, Some(project.clone()))?;
+ let catalog = service
+ .dispatch(Request {
+ method: "GET".into(),
+ path: "/api/local-models".into(),
+ body: Value::Null,
+ })
+ .await?;
+ let model = catalog["models"]
+ .as_array()
+ .and_then(|m| m.iter().find(|m| m["path"] == file))
+ .ok_or_else(|| anyhow::anyhow!("Explicit model missing from catalog"))?;
+ let id = model["id"]
+ .as_str()
+ .ok_or_else(|| anyhow::anyhow!("Model ID missing"))?;
+ let controller = service.engine.clone();
+ let approved_project = project.clone();
+ let approvals = tokio::spawn(async move {
+ loop {
+ for approval in controller.approvals().list(None) {
+ let allow = approval.tool == "exec"
+ && approval.arguments["command"] == CHECK_COMMAND
+ && (matches!(approval.arguments["cwd"].as_str(), None | Some("" | "."))
+ || approval.arguments["cwd"].as_str() == approved_project.to_str());
+ let _ = controller
+ .approvals()
+ .decide(&approval.id, &approval.session_id, allow);
+ }
+ tokio::time::sleep(Duration::from_millis(30)).await;
+ }
+ });
+ let outcome = async {
+ let job: Job = serde_json::from_value(
+ service
+ .dispatch(Request {
+ method: "POST".into(),
+ path: "/api/jobs".into(),
+ body: json!({
+ "workspace":project,"model":id,"mode":"code","web":false,
+ "task":TASK
+ }),
+ })
+ .await?,
+ )?;
+ let done = match tokio::time::timeout(
+ Duration::from_secs(240),
+ service.engine.wait(&job.id),
+ )
+ .await
+ {
+ Ok(done) => done?,
+ Err(_) => {
+ tokio::time::timeout(Duration::from_secs(15), service.engine.cancel(&job.id))
+ .await??;
+ tokio::time::timeout(Duration::from_secs(5), service.engine.wait(&job.id)).await??
+ }
+ };
+ let preserved = fs::read_to_string(project.join("test_helpers.py"))? == TESTS;
+ let independent = check(&project).await?;
+ let assertions_passed = assertions_passed(&independent);
+ let events = service
+ .engine
+ .store()
+ .recent_events(&job.session_id, 2000)?;
+ let ran_check = events.iter().any(|e| {
+ e["type"] == "tool.completed"
+ && e["payload"]["tool"] == "exec"
+ && e["payload"]["success"] == true
+ });
+ Ok::(json!({
+ "model":model,"hardware":catalog["hardware"],"runtime":catalog["runtime"],
+ "fixture_provenance":coding_fixture::provenance(),
+ "job":done,"tests_preserved":preserved,"model_ran_successful_command":ran_check,
+ "independent_check":independent,"baseline_check":baseline,
+ "independent_assertions_passed":assertions_passed,
+ "passed":done.status=="completed" && preserved && assertions_passed && ran_check,
+ "source_after":fs::read_to_string(project.join("helpers.py"))?,"events":events
+ }))
+ }
+ .await;
+ approvals.abort();
+ let cleanup = tokio::time::timeout(Duration::from_secs(15), service.engine.shutdown()).await;
+ let cleanup_error = match cleanup {
+ Ok(Ok(())) => None,
+ Ok(Err(e)) => Some(format!("{e:#}")),
+ Err(_) => Some("shutdown exceeded 15 seconds".into()),
+ };
+ let mut report =
+ outcome.unwrap_or_else(|e| json!({"file":file,"passed":false,"error":format!("{e:#}")}));
+ if let Some(error) = cleanup_error {
+ report["cleanup_error"] = json!(error);
+ report["passed"] = json!(false);
+ }
+ Ok(report)
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ let args: Vec = std::env::args().skip(1).collect();
+ ensure!(
+ args.len() >= 2,
+ "usage: live_local_coding report.json model.gguf [model.gguf ...]"
+ );
+ let server = std::env::var("SHADOWCODE_LLAMA_SERVER")?;
+ let mut reports = Vec::new();
+ for file in &args[1..] {
+ let result = match evaluate(file, &server).await {
+ Ok(report) => report,
+ Err(error) => json!({"file":file,"passed":false,"error":format!("{error:#}")}),
+ };
+ println!(
+ "{} passed={} status={} error={}",
+ file, result["passed"], result["job"]["status"], result["error"]
+ );
+ reports.push(result);
+ fs::write(
+ &args[0],
+ serde_json::to_vec_pretty(&json!({
+ "scope":"One small Python repair fixture per model; not a general coding benchmark",
+ "context_tokens":8192,"max_steps":14,"timeout_seconds":240,"results":reports
+ }))?,
+ )?;
+ }
+ ensure!(
+ reports.iter().all(|r| r["passed"] == true),
+ "One or more models did not pass the coding fixture; inspect the report"
+ );
+ Ok(())
+}
diff --git a/native/core/examples/live_vendor_coding.rs b/native/core/examples/live_vendor_coding.rs
new file mode 100644
index 00000000..51dd8684
--- /dev/null
+++ b/native/core/examples/live_vendor_coding.rs
@@ -0,0 +1,1508 @@
+//! Opt-in subscription CLI coding acceptance fixture; never an automatic test.
+//! Usage: cargo run -p shadowcode-core --example live_vendor_coding -- report.json cli:codex [cli:claude ...]
+//! Offline receipt replay: live_vendor_coding --replay saved-report.json new-replay.json
+//! Supports explicit cli:{codex,claude,cursor,antigravity,grok}[:model] targets.
+//! Uses existing vendor login and vendor-owned tools/sandbox, with isolated
+//! ShadowCode profiles/projects. No login, install, user-config patch or API
+//! route fallback. Vendor login/session caches may be updated by the CLI.
+use anyhow::{ensure, Context, Result};
+use serde_json::{json, Value};
+use shadowcode_core::{
+ approvals::Approval,
+ cli_agent::{self, antigravity_server, picker::Availability, Vendor},
+ config::Config,
+ engine::Job,
+ paths::AppPaths,
+ service::{Request, Service},
+};
+use std::{
+ collections::{HashMap, HashSet},
+ fs,
+ path::Path,
+ sync::{Arc, Mutex},
+ time::{Duration, Instant},
+};
+
+#[path = "support/coding_fixture.rs"]
+mod coding_fixture;
+use coding_fixture::{
+ assertions_passed, bounded_regular_text, check, CHECK_COMMAND, SOURCE, TASK, TESTS,
+};
+
+const TASK_SECONDS: u64 = 240;
+const EVENT_BYTES: usize = 1_000_000;
+const EVENT_COUNT: usize = 2_000;
+const LIMITATIONS: &str = "Vendor CLIs own their tools, sandbox, home access and network. ShadowCode's Bubblewrap sandbox applies only to independent grading. Only approval requests actually forwarded by the vendor can be filtered here; an isolated working directory is not a vendor filesystem sandbox. Existing CLI login/session caches may change. ACP advertised auth methods and successful session initialization do not prove billing source. No fallback or API route is requested. Missing exact test-command receipts fail acceptance even when independent tests pass. This fixed Python grader is not adversarial-proof.";
+
+fn vendor_for(target: &str) -> Result {
+ let config = cli_agent::resolve_vendor(target)
+ .context("Expected an explicit supported cli:[:model] target")?;
+ Vendor::from_provider(&config.provider).context("Not a supported vendor route")
+}
+
+fn preflight_environment(vendor: Vendor) -> Result<()> {
+ // Inspect presence only, never credential contents. Refuse ambiguous key
+ // overrides instead of mutating the process environment or user settings.
+ let names: &[&str] = match vendor {
+ Vendor::Codex => &["OPENAI_API_KEY", "CODEX_API_KEY", "OPENAI_BASE_URL"],
+ Vendor::Claude => &[
+ "ANTHROPIC_API_KEY",
+ "ANTHROPIC_AUTH_TOKEN",
+ "ANTHROPIC_BASE_URL",
+ "CLAUDE_CODE_USE_BEDROCK",
+ "CLAUDE_CODE_USE_VERTEX",
+ ],
+ Vendor::Cursor => &["CURSOR_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"],
+ Vendor::Antigravity => &[
+ "GEMINI_API_KEY",
+ "GOOGLE_API_KEY",
+ "GOOGLE_APPLICATION_CREDENTIALS",
+ "GOOGLE_GENAI_USE_VERTEXAI",
+ ],
+ Vendor::Grok => &["GROK_API_KEY", "XAI_API_KEY", "OPENAI_API_KEY"],
+ };
+ for name in names {
+ ensure!(
+ std::env::var_os(name).is_none(),
+ "Refusing live subscription fixture while {name} is set; no value was inspected"
+ );
+ }
+ if vendor == Vendor::Antigravity {
+ // The adapter initializes personal auth settings if absent/different.
+ // Require them to exist already, so this fixture never requests setup.
+ let settings = antigravity_server::profile_dir().join("antigravity-acp/settings.json");
+ let value: Value = serde_json::from_str(&bounded_regular_text(&settings, 100_000)?)?;
+ ensure!(value["auth"]["type"] == antigravity_server::AUTH_METHOD,
+ "Antigravity must already have its managed personal-login profile configured; fixture does not set it up");
+ }
+ Ok(())
+}
+
+fn fixture_path(project: &Path, candidate: &str, edit: bool) -> bool {
+ let allowed = |name: &str| name == "helpers.py" || (!edit && name == "test_helpers.py");
+ let path = Path::new(candidate);
+ let relative = if path.is_absolute() {
+ let Ok(relative) = path.strip_prefix(project) else {
+ return false;
+ };
+ relative
+ } else {
+ path
+ };
+ let mut components = relative
+ .components()
+ .filter(|c| !matches!(c, std::path::Component::CurDir));
+ matches!((components.next(), components.next()), (Some(std::path::Component::Normal(name)), None) if name.to_str().is_some_and(allowed))
+ && fs::symlink_metadata(project.join(relative)).is_ok_and(|m| m.is_file())
+}
+
+fn cwd_matches(arguments: &Value, tool: &str, project: &Path) -> bool {
+ [
+ arguments.get("cwd"),
+ arguments.pointer("/input/cwd"),
+ arguments.pointer("/input/Cwd"),
+ arguments.pointer("/input/working_directory"),
+ (tool == "antigravity.execute")
+ .then(|| arguments.pointer("/input/working_dir"))
+ .flatten(),
+ ]
+ .into_iter()
+ .flatten()
+ .all(|v| v.is_null() || matches!(v.as_str(), Some("" | ".")) || v.as_str() == project.to_str())
+}
+
+fn tool_command<'a>(arguments: &'a Value, tool: &str) -> Option<&'a str> {
+ arguments["command"]
+ .as_str()
+ .or(arguments["input"]["command"].as_str())
+ .or_else(|| {
+ (tool == "antigravity.execute")
+ .then(|| {
+ arguments["input"]["CommandLine"]
+ .as_str()
+ .or(arguments["input"]["command_line"].as_str())
+ })
+ .flatten()
+ })
+}
+
+fn approval_allowed(approval: &Approval, project: &Path) -> bool {
+ if !cwd_matches(&approval.arguments, &approval.tool, project) {
+ return false;
+ }
+ let tool = approval.tool.as_str();
+ let command_tool = tool == "exec"
+ || tool.ends_with(".command_execution")
+ || tool.ends_with(".Bash")
+ || tool.ends_with(".execute");
+ if command_tool {
+ return tool_command(&approval.arguments, tool).is_some_and(|command| {
+ matches!(
+ command.trim(),
+ CHECK_COMMAND
+ | "cat helpers.py"
+ | "cat test_helpers.py"
+ | "cat helpers.py test_helpers.py"
+ | "pwd"
+ | "ls"
+ )
+ });
+ }
+ let edit = tool.ends_with(".edit")
+ || tool.ends_with(".file_change")
+ || matches!(tool, "claude.Edit" | "claude.Write" | "claude.MultiEdit");
+ let read = tool.ends_with(".read") || tool == "claude.Read";
+ if !edit && !read {
+ return false;
+ }
+ let mut paths = Vec::new();
+ collect_paths(&approval.arguments, &mut paths);
+ // Opaque grant-root/permission requests and unrecognized tools are denied.
+ !paths.is_empty() && paths.iter().all(|path| fixture_path(project, path, edit))
+}
+
+fn collect_paths<'a>(value: &'a Value, paths: &mut Vec<&'a str>) {
+ match value {
+ Value::Object(map) => {
+ for (key, value) in map {
+ if matches!(
+ key.as_str(),
+ "path" | "file_path" | "filePath" | "target_file" | "TargetFile"
+ ) {
+ if let Some(path) = value.as_str() {
+ paths.push(path);
+ }
+ } else if key == "changes" {
+ if let Some(changes) = value.as_object() {
+ paths.extend(changes.keys().map(String::as_str));
+ }
+ } else if matches!(key.as_str(), "input" | "locations" | "content" | "edits") {
+ collect_paths(value, paths);
+ }
+ }
+ }
+ Value::Array(items) => {
+ for item in items {
+ collect_paths(item, paths);
+ }
+ }
+ _ => {}
+ }
+}
+
+fn bounded_value(value: &Value) -> Value {
+ // Reports are evidence snapshots, not an unbounded copy of provider output.
+ match value {
+ Value::String(text) => json!(if text.len() > 8_000 {
+ format!(
+ "{} [report string truncated]",
+ text.chars().take(2_000).collect::()
+ )
+ } else {
+ text.clone()
+ }),
+ Value::Array(items) => Value::Array(items.iter().take(128).map(bounded_value).collect()),
+ Value::Object(map) => Value::Object(
+ map.iter()
+ .take(128)
+ .map(|(key, value)| (key.clone(), bounded_value(value)))
+ .collect(),
+ ),
+ other => other.clone(),
+ }
+}
+
+fn events(service: &Service, session: &str) -> Result<(Vec, bool)> {
+ let mut result = Vec::new();
+ let mut cursor = 0;
+ let mut bytes = 0;
+ loop {
+ let page = service
+ .engine
+ .store()
+ .events_after(session, cursor, None, 64)?;
+ if page.is_empty() {
+ return Ok((result, false));
+ }
+ for event in page {
+ let size = serde_json::to_vec(&event)?.len();
+ if result.len() == EVENT_COUNT || bytes + size > EVENT_BYTES {
+ return Ok((result, true));
+ }
+ cursor = event["id"].as_i64().context("Event cursor missing")?;
+ bytes += size;
+ result.push(event);
+ }
+ }
+}
+
+fn antigravity_raw_test_output<'a>(output: &'a Value, project: &Path) -> Option<&'a str> {
+ // Observed in the 2026-09-26 capture, not inferred from model prose. Keep
+ // this narrow until another real provider shape is independently checked.
+ if output["raw_output_format"] != "json"
+ || output["raw_output_truncated"] != false
+ || output["input"]["command_line"] != CHECK_COMMAND
+ || output["input"]["working_dir"].as_str() != project.to_str()
+ {
+ return None;
+ }
+ let raw = output["raw_output"].as_object()?;
+ if raw.len() != 6
+ || raw.get("exitCode")?.as_i64() != Some(0)
+ || raw.get("exit_code")?.as_i64() != Some(0)
+ || raw.get("commandLine")?.as_str() != Some(CHECK_COMMAND)
+ || raw.get("workingDir")?.as_str() != project.to_str()
+ {
+ return None;
+ }
+ let text = raw.get("combinedOutput")?.as_str()?;
+ if raw.get("formatted_output")?.as_str() != Some(text)
+ || !output["content"].as_array()?.iter().all(|content| {
+ content
+ .as_str()
+ .is_some_and(|content| content.is_empty() || content == text)
+ })
+ {
+ // Retained denials or other conflicting content cannot be silently
+ // replaced by a successful-looking structured result.
+ return None;
+ }
+ unittest_five_summary(text).then_some(text)
+}
+
+fn unittest_five_summary(text: &str) -> bool {
+ let lines: Vec<_> = text.lines().filter(|line| !line.is_empty()).collect();
+ if lines.len() != 3
+ || lines[0].len() < 3
+ || !lines[0].bytes().all(|byte| byte == b'-')
+ || lines[2] != "OK"
+ {
+ return false;
+ }
+ let Some(seconds) = lines[1]
+ .strip_prefix("Ran 5 tests in ")
+ .and_then(|s| s.strip_suffix('s'))
+ else {
+ return false;
+ };
+ seconds
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || byte == b'.')
+ && seconds
+ .parse::()
+ .is_ok_and(|s| s.is_finite() && s >= 0.0)
+}
+
+fn cursor_raw_test_output(output: &Value) -> Option<&str> {
+ // Cursor 2026.09.15's presenter omits shell content on completion. Its
+ // earlier permission reason is retained by ACP; ONLY new adapter-owned
+ // provenance can distinguish that reason from a terminal contradiction.
+ // Historical merged captures remain ambiguous and cannot be upgraded.
+ let provenance = &output["acp_provenance"];
+ let raw_origin = &provenance["raw_output"];
+ if provenance["schema_version"] != 1
+ || provenance["history_complete"] != true
+ || !matches!(
+ raw_origin["source"].as_str(),
+ Some("tool_update" | "tool_call")
+ )
+ || raw_origin["phase"] != "completed"
+ || raw_origin["explicit_terminal"] != true
+ || output["cursor_execution"]["exit_code"] != 0
+ || output["raw_output_format"] != "json"
+ || output["raw_output_truncated"] != false
+ {
+ return None;
+ }
+ let permission = &provenance["permission"];
+ let allowed = permission["state"] == "resolved"
+ && matches!(
+ permission["decision"].as_str(),
+ Some("allow_once" | "allow_always")
+ )
+ && permission["current_operation_matches"] == true
+ && raw_origin["after_permission_decision"] == true;
+ if !permission.is_null() && !allowed {
+ return None;
+ }
+ let content = output["content"].as_array()?;
+ if !content.is_empty() {
+ let origin = &provenance["content"];
+ if !allowed
+ || origin["source"] != "permission_request"
+ || origin["phase"] != "pending"
+ || origin["before_permission_decision"] != true
+ || origin["explicit_terminal"] != false
+ || content.as_slice() != [json!("Not in allowlist: python3")]
+ {
+ return None;
+ }
+ }
+ let raw = output["raw_output"].as_object()?;
+ if raw.len() != 3
+ || raw.get("exitCode")?.as_i64() != Some(0)
+ || !raw.get("stdout")?.as_str()?.is_empty()
+ {
+ return None;
+ }
+ let text = raw.get("stderr")?.as_str()?;
+ unittest_five_summary(text).then_some(text)
+}
+
+fn test_receipt(
+ events: &[Value],
+ vendor: Vendor,
+ project: &Path,
+ session_id: &str,
+ task_id: &str,
+) -> Option {
+ if session_id.is_empty() || task_id.is_empty() {
+ return None;
+ }
+ let acp = matches!(vendor, Vendor::Cursor | Vendor::Grok | Vendor::Antigravity);
+ let mut started = HashMap::new();
+ let mut seen = HashSet::new();
+ for event in events {
+ if event["session_id"].as_str() != Some(session_id)
+ || event["task_id"].as_str() != Some(task_id)
+ {
+ continue;
+ }
+ let payload = &event["payload"];
+ let id = payload["call_id"].as_str().unwrap_or("");
+ let tool = payload["tool"].as_str().unwrap_or("");
+ if id.is_empty()
+ || id.contains(shadowcode_core::redaction::placeholder())
+ || !tool.starts_with(&format!("{}.", vendor.id()))
+ {
+ continue;
+ }
+ if event["type"] == "tool.started" {
+ if !seen.insert(id) {
+ // Reused identifiers cannot establish an unambiguous pair.
+ started.remove(id);
+ continue;
+ }
+ let command_tool = tool.ends_with(".command_execution") || tool.ends_with(".Bash");
+ if acp
+ || (command_tool
+ && cwd_matches(&payload["arguments"], tool, project)
+ && tool_command(&payload["arguments"], tool)
+ .is_some_and(|command| is_test_command(command, vendor)))
+ {
+ started.insert(id, tool);
+ }
+ } else if event["type"] == "tool.completed" {
+ let Some(initial_tool) = started.remove(id) else {
+ continue;
+ };
+ if payload["success"] != true
+ || payload
+ .get("error")
+ .is_some_and(|error| !error.is_null() && error != "")
+ {
+ continue;
+ }
+ if acp {
+ // ACP initially permits generic tool kinds and partial input.
+ // Completion carries the adapter's merged current metadata;
+ // never attribute an old start command to a changed operation.
+ let latest = json!({"input":payload["output"]["input"]});
+ if tool != format!("{}.execute", vendor.id())
+ || payload["output"]["tool_kind"] != "execute"
+ || payload["output"]["status"] != "completed"
+ || (vendor != Vendor::Cursor
+ && payload["output"]["content"]
+ .as_array()
+ .is_some_and(|content| {
+ content.iter().filter_map(Value::as_str).any(|text| {
+ let text = text.to_ascii_lowercase();
+ [
+ "not in allowlist:",
+ "denied by user",
+ "rejected by user",
+ "tool execution failed",
+ ]
+ .iter()
+ .any(|denial| text.contains(denial))
+ })
+ }))
+ || !cwd_matches(&latest, tool, project)
+ || tool_command(&latest, tool)
+ .is_none_or(|command| command.trim() != CHECK_COMMAND)
+ {
+ continue;
+ }
+ } else if initial_tool != tool {
+ continue;
+ }
+ // Only captured tool output is execution evidence. Typed input,
+ // descriptions and titles can also contain apparent test results.
+ let mut source = "captured_content";
+ let output = if vendor == Vendor::Cursor {
+ source = "cursor_provenanced_structured_raw_output";
+ let Some(text) = cursor_raw_test_output(&payload["output"]) else {
+ continue;
+ };
+ text.to_owned()
+ } else if vendor == Vendor::Antigravity && payload["output"].get("raw_output").is_some()
+ {
+ source = "antigravity_structured_raw_output";
+ let Some(text) = antigravity_raw_test_output(&payload["output"], project) else {
+ continue;
+ };
+ text.to_owned()
+ } else if acp {
+ payload["output"]["content"]
+ .as_array()
+ .into_iter()
+ .flatten()
+ .filter_map(Value::as_str)
+ .collect::>()
+ .join("\n")
+ } else {
+ payload["output"]["output"]
+ .as_str()
+ .unwrap_or("")
+ .to_owned()
+ };
+ // A typed completion plus recognizable unittest output, not the
+ // model's final prose. Independent grading is still required.
+ if output.contains("Ran 5 tests") && output.contains("OK") && !output.contains("FAILED")
+ {
+ return Some(
+ json!({"call_id":id,"session_id":session_id,"task_id":task_id,"tool":tool,"command":CHECK_COMMAND,"source":source,"completion":bounded_value(payload)}),
+ );
+ }
+ }
+ }
+ None
+}
+
+fn is_test_command(command: &str, vendor: Vendor) -> bool {
+ let command = command.trim();
+ command == CHECK_COMMAND
+ // Exact official Codex wrapper observed in the live fixture. This is
+ // a finite string match, not shell parsing or permission to run it.
+ // Approval matching intentionally remains on the bare command only.
+ || (vendor == Vendor::Codex
+ && command == "/bin/bash -lc 'python3 -m unittest -q'")
+}
+
+fn alias_report_call_ids(report: &mut Value) {
+ fn visit(value: &mut Value, aliases: &mut HashMap, collapsed: &mut usize) {
+ match value {
+ Value::Object(map) => {
+ for (key, value) in map {
+ if matches!(key.as_str(), "call_id" | "tool_call_id" | "item_id") {
+ if let Some(id) = value.as_str().filter(|id| !id.is_empty()) {
+ if id.contains(shadowcode_core::redaction::placeholder()) {
+ *collapsed += 1;
+ } else {
+ let next = format!("fixture-call-{}", aliases.len() + 1);
+ let alias = aliases.entry(id.to_owned()).or_insert(next);
+ *value = json!(alias);
+ }
+ }
+ } else {
+ visit(value, aliases, collapsed);
+ }
+ }
+ }
+ Value::Array(items) => {
+ for item in items {
+ visit(item, aliases, collapsed);
+ }
+ }
+ _ => {}
+ }
+ }
+ let mut aliases = HashMap::new();
+ let mut collapsed = 0;
+ visit(report, &mut aliases, &mut collapsed);
+ report["call_id_handling"] = json!({
+ "internal_matching":"original store identifiers before report sanitization",
+ "report_identifiers":"stable distinct fixture-call-N aliases within this result",
+ "distinct_identifiers":aliases.len(), "already_redacted_fields":collapsed
+ });
+}
+
+async fn evaluate(target: &str) -> Result {
+ let root = tempfile::tempdir()?;
+ let project = root.path().join("project");
+ let mut report = json!({"target":target,"passed":false,"fixture_task":TASK,"fixture_provenance":coding_fixture::provenance(),"limitations":LIMITATIONS});
+ let mut service: Option = None;
+ let mut approvals = None;
+ let decisions = Arc::new(Mutex::new(Vec::new()));
+ let started = Instant::now();
+ let outcome: Result<()> = async {
+ let vendor = vendor_for(target)?;
+ preflight_environment(vendor)?;
+ fs::create_dir(&project)?;
+ fs::write(project.join("helpers.py"), SOURCE)?;
+ fs::write(project.join("test_helpers.py"), TESTS)?;
+ let baseline = check(&project).await?;
+ let baseline_valid = !baseline.ok && !baseline.truncated && !baseline.timed_out
+ && baseline.stdout.contains("SHADOWCODE_EVALUATOR:FAIL") && baseline.stderr.contains("Ran 5 tests");
+ report["baseline_check"] = json!(baseline);
+ ensure!(baseline_valid, "Baseline did not demonstrably execute and fail the fixed five-test suite");
+ let paths = AppPaths::isolated(&root.path().join("profile"))?;
+ Config::patch(&paths, json!({
+ "model":{"provider":"local","endpoint":"http://127.0.0.1:9/v1","name":"unused"},
+ "trusted_workspaces":[project], "network":{"mode":"online"},
+ "permissions":{"mode":"ask","approve_shell":true,"network":false},
+ "sandbox":{"require":true,"home_binds":[]},
+ "limits":{"on_limit":"ask","fallback_model":""},
+ "cli_agents":{"enabled":true,"approval_timeout_sec":30,"stall_timeout_sec":60,"max_run_time_sec":TASK_SECONDS},
+ "agent":{"max_steps":14,"max_task_tokens":50000,"tool_timeout_sec":30}
+ }))?;
+ let config = Config::load(&paths, Some(&project))?;
+ report["configured_cli_binary"] = json!(config.cli_agents.binary(vendor));
+ service = Some(Service::open(paths, Some(project.clone()))?);
+ let service = service.as_ref().expect("opened service");
+ // Refresh only the requested vendor; a full picker probes unrelated
+ // accounts and may initialize Antigravity settings for another route.
+ let status = tokio::time::timeout(Duration::from_secs(60), service.engine.vendors().refresh(vendor, &config.cli_agents, false)).await.context("Vendor preflight exceeded 60 seconds")?;
+ report["vendor_status"] = json!({"vendor":status.vendor,"availability":status.availability,"version":status.version,"binary":status.binary,"detail":status.detail,"error":status.error,"auth_mode":status.account.as_ref().and_then(|a| a.auth_mode.as_deref()),"models":status.models});
+ ensure!(status.availability == Availability::Ready, "Requested vendor is not ready; no login or setup attempted");
+ ensure!(!status.api_key_login(), "Refusing an API-key CLI login for a subscription acceptance fixture");
+ let auth = status.account.as_ref().and_then(|a| a.auth_mode.as_deref());
+ ensure!(match vendor { Vendor::Codex => auth == Some("chatgpt"), Vendor::Claude => auth == Some("claude.ai"), _ => !auth.is_some_and(|a| a.to_ascii_lowercase().contains("api")) }, "Subscription authentication could not be established for this route");
+ let model = cli_agent::resolve_vendor(target).expect("validated target");
+ ensure!(model.name == "default" || status.models.iter().any(|m| m.id == model.name), "Requested model is absent from the vendor catalog");
+ let usage = status.usage_for(&model.name, shadowcode_core::now());
+ ensure!(!usage.limit_reached, "Requested model is at its reported subscription limit");
+ report["selected_route"] = cli_agent::picker::vendor_target(vendor, &model.name, &model.name, status.availability, &status.detail, usage, false).to_json();
+ report["auth_evidence"] = json!(if matches!(vendor, Vendor::Codex | Vendor::Claude) { "official CLI reports subscription auth mode" } else { "ACP session initialized with existing login; advertised auth method is not billing proof" });
+ let controller = service.engine.clone();
+ let approved_project = project.clone();
+ let recorded = decisions.clone();
+ approvals = Some(tokio::spawn(async move {
+ loop {
+ for approval in controller.approvals().list(None) {
+ let allow = approval_allowed(&approval, &approved_project);
+ let result = controller.approvals().decide(&approval.id, &approval.session_id, allow);
+ let mut records = recorded.lock().expect("approval records");
+ if records.len() < 128 { records.push(json!({"tool":approval.tool,"command":approval.command.chars().take(1000).collect::(),"allowed":allow,"decision_ok":result.is_ok()})); }
+ }
+ tokio::time::sleep(Duration::from_millis(40)).await;
+ }
+ }));
+ let job: Job = serde_json::from_value(tokio::time::timeout(Duration::from_secs(30), service.dispatch(Request {
+ method:"POST".into(),path:"/api/jobs".into(),body:json!({"workspace":project,"model":target,"purpose":"code","web":false,"task":TASK})
+ })).await.context("Job submission exceeded 30 seconds")??)?;
+ report["submitted_job"] = json!({"id":job.id,"model":job.model,"session_id":job.session_id});
+ let deadline = tokio::time::sleep(Duration::from_secs(TASK_SECONDS));
+ tokio::pin!(deadline);
+ let done = tokio::select! {
+ done = service.engine.wait(&job.id) => Some(done),
+ _ = &mut deadline => { report["deadline_exceeded"] = json!(true); None },
+ _ = tokio::signal::ctrl_c() => { report["interrupted"] = json!(true); None }
+ };
+ let done = if let Some(done) = done { done } else {
+ let cancel = tokio::time::timeout(Duration::from_secs(15), service.engine.cancel(&job.id)).await;
+ report["cancel_result"] = json!(match cancel { Ok(Ok(job)) => job.status, Ok(Err(e)) => format!("{e:#}"), Err(_) => "cancel exceeded 15 seconds".into() });
+ tokio::time::timeout(Duration::from_secs(5), service.engine.wait(&job.id)).await.context("Cancelled job did not settle within 5 seconds").and_then(|r| r)
+ };
+ let completed = match done {
+ Ok(done) => { let completed = done.status == "completed" && done.model == job.model; report["job"] = bounded_value(&json!(done)); completed },
+ Err(error) => { report["job_wait_error"] = json!(format!("{error:#}")); false }
+ };
+ let (observed, truncated) = events(service, &job.session_id)?;
+ let receipt = test_receipt(&observed, vendor, &project, &job.session_id, &job.task_id);
+ let routed = observed.iter().any(|e| e["type"] == "agent.started" && e["payload"]["vendor_agent"] == vendor.id());
+ let fallback = observed.iter().any(|e| matches!(e["type"].as_str(), Some("routing.fallback" | "limit.fallback")));
+ report["vendor_test_receipt"] = json!(receipt);
+ report["events_truncated"] = json!(truncated);
+ report["events"] = json!(observed);
+ report["observed_vendor_route"] = json!(routed);
+ report["fallback_observed"] = json!(fallback);
+ let preserved = bounded_regular_text(&project.join("test_helpers.py"), 100_000).is_ok_and(|text| text == TESTS);
+ report["tests_preserved"] = json!(preserved);
+ let independent = check(&project).await?;
+ let passed = assertions_passed(&independent);
+ report["independent_check"] = json!(independent);
+ report["independent_assertions_passed"] = json!(passed);
+ report["source_after"] = json!(bounded_regular_text(&project.join("helpers.py"), 100_000)?);
+ report["passed"] = json!(completed && routed && !fallback && !truncated && preserved && passed && receipt.is_some() && report["deadline_exceeded"] != true && report["interrupted"] != true);
+ Ok(())
+ }.await;
+ if let Some(task) = approvals {
+ task.abort();
+ let _ = tokio::time::timeout(Duration::from_secs(1), task).await;
+ }
+ if let Some(service) = service {
+ let cleanup =
+ tokio::time::timeout(Duration::from_secs(15), service.engine.shutdown()).await;
+ let error = match cleanup {
+ Ok(Ok(())) => None,
+ Ok(Err(e)) => Some(format!("{e:#}")),
+ Err(_) => Some("shutdown exceeded 15 seconds".into()),
+ };
+ if let Some(error) = error {
+ report["cleanup_error"] = json!(error);
+ report["passed"] = json!(false);
+ }
+ }
+ if let Err(error) = outcome {
+ report["error"] = json!(format!("{error:#}"));
+ report["passed"] = json!(false);
+ }
+ report["approval_decisions"] = json!(*decisions.lock().expect("approval records"));
+ report["elapsed_seconds"] = json!(started.elapsed().as_secs_f64());
+ alias_report_call_ids(&mut report);
+ shadowcode_core::redaction::redact_value(&mut report);
+ Ok(report)
+}
+
+fn replay_receipts(capture: &Value) -> Result {
+ let runs = capture["results"]
+ .as_array()
+ .context("Missing captured results")?;
+ ensure!(
+ !runs.is_empty() && runs.len() <= 5,
+ "Expected one to five recorded results"
+ );
+ let mut results = Vec::new();
+ for run in runs {
+ let target = run["target"].as_str().context("Missing captured target")?;
+ let vendor = vendor_for(target)?;
+ ensure!(
+ run["fixture_task"] == TASK,
+ "Captured fixture task differs from this grader"
+ );
+ for key in [
+ "task_sha256",
+ "source_sha256",
+ "tests_sha256",
+ "grader_source_sha256",
+ ] {
+ ensure!(
+ run["fixture_provenance"][key] == coding_fixture::provenance()[key],
+ "Captured fixture provenance differs: {key}"
+ );
+ }
+ ensure!(
+ run["events_truncated"] == false,
+ "Cannot replay a truncated event capture"
+ );
+ ensure!(
+ run["call_id_handling"]["report_identifiers"]
+ == "stable distinct fixture-call-N aliases within this result",
+ "Captured call-ID alias provenance is missing"
+ );
+ let events = run["events"]
+ .as_array()
+ .context("Missing captured events")?;
+ ensure!(
+ events.len() <= EVENT_COUNT && serde_json::to_vec(events)?.len() <= EVENT_BYTES,
+ "Captured events exceed fixture bounds"
+ );
+ let job = &run["job"];
+ let project = Path::new(
+ job["workspace"]
+ .as_str()
+ .context("Missing captured workspace")?,
+ );
+ ensure!(project.is_absolute(), "Captured workspace must be absolute");
+ let session = job["session_id"]
+ .as_str()
+ .context("Missing captured job session")?;
+ let task = job["task_id"]
+ .as_str()
+ .context("Missing captured job task")?;
+ ensure!(
+ !session.is_empty() && !task.is_empty(),
+ "Captured job scope is empty"
+ );
+ ensure!(
+ job["session_id"] == run["submitted_job"]["session_id"]
+ && job["id"] == run["submitted_job"]["id"],
+ "Captured submitted/final job scope differs"
+ );
+ let receipt = test_receipt(events, vendor, project, session, task);
+ results.push(json!({
+ "target":target, "original_live_passed":run["passed"],
+ "receipt_accepted":receipt.is_some(), "replayed_receipt":receipt,
+ "recorded_independent_assertions_passed":run["independent_assertions_passed"],
+ // This count can include unrelated nested approval IDs. Receipt
+ // matching itself rejects collapsed or duplicate event call IDs.
+ "recorded_collapsed_id_fields":run["call_id_handling"]["already_redacted_fields"],
+ "original_live_result":run,
+ }));
+ }
+ Ok(json!({
+ "mode":"offline_receipt_replay", "provider_calls":0,
+ "scope":"Re-evaluates saved tool receipts only. Original live verdicts are preserved; no new model turn or independent Python grading was run.",
+ "grader_example_sha256":shadowcode_core::workspace::hash(include_str!("live_vendor_coding.rs").as_bytes()),
+ "results":results,
+ }))
+}
+
+fn replay_file(source: &Path, destination: &Path) -> Result<()> {
+ let text = bounded_regular_text(source, 12_000_000)?;
+ let mut replay = replay_receipts(&serde_json::from_str(&text)?)?;
+ replay["source_capture_sha256"] = json!(shadowcode_core::workspace::hash(text.as_bytes()));
+ replay["source_capture_path"] = json!(source.canonicalize()?);
+ // A replay is a new artifact. Never overwrite a live report or an earlier
+ // replay, even when the caller accidentally gives the same path twice.
+ let mut file = fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(destination)?;
+ std::io::Write::write_all(&mut file, &serde_json::to_vec_pretty(&replay)?)?;
+ for result in replay["results"].as_array().expect("replay results") {
+ println!(
+ "{} original_live_passed={} offline_receipt_accepted={}",
+ result["target"], result["original_live_passed"], result["receipt_accepted"]
+ );
+ }
+ Ok(())
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ let args: Vec = std::env::args().skip(1).collect();
+ if args.first().is_some_and(|arg| arg == "--replay") {
+ ensure!(
+ args.len() == 3,
+ "usage: live_vendor_coding --replay saved-report.json new-replay.json"
+ );
+ return replay_file(Path::new(&args[1]), Path::new(&args[2]));
+ }
+ ensure!(
+ (2..=6).contains(&args.len()),
+ "usage: live_vendor_coding report.json cli:[:model] [up to five explicit targets]"
+ );
+ for target in &args[1..] {
+ vendor_for(target)?;
+ }
+ let mut reports = Vec::new();
+ for target in &args[1..] {
+ let report = evaluate(target).await.unwrap_or_else(
+ |error| json!({"target":target,"passed":false,"error":format!("{error:#}")}),
+ );
+ println!(
+ "{target} passed={} status={} error={}",
+ report["passed"], report["job"]["status"], report["error"]
+ );
+ let interrupted = report["interrupted"] == true;
+ reports.push(report);
+ let report = json!({"scope":"One shared small Python repair fixture per explicit subscription CLI route; not a general coding benchmark", "task_timeout_seconds":TASK_SECONDS,"grader_timeout_seconds":30,"max_event_bytes":EVENT_BYTES,"max_events":EVENT_COUNT,"limitations":LIMITATIONS,"results":reports});
+ fs::write(&args[0], serde_json::to_vec_pretty(&report)?)?;
+ if interrupted {
+ break;
+ }
+ }
+ ensure!(
+ reports.iter().all(|r| r["passed"] == true),
+ "One or more routes did not pass; inspect the report"
+ );
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ fn fixture_receipt(events: &[Value], vendor: Vendor, project: &Path) -> Option {
+ let events: Vec<_> = events
+ .iter()
+ .map(|event| {
+ let mut event = event.clone();
+ if event.get("session_id").is_none() {
+ event["session_id"] = json!("fixture-session");
+ }
+ if event.get("task_id").is_none() {
+ event["task_id"] = json!("fixture-task");
+ }
+ event
+ })
+ .collect();
+ test_receipt(&events, vendor, project, "fixture-session", "fixture-task")
+ }
+
+ fn captured_receipts(vendor: &str) -> Vec {
+ let fixture: Value =
+ serde_json::from_str(include_str!("support/acp_coding_receipts.json")).unwrap();
+ fixture[vendor].as_array().unwrap().clone()
+ }
+
+ fn cursor_protocol_receipt(scenario: &str) -> Vec {
+ use shadowcode_core::cli_agent::{acp::AcpAdapter, CliAdapter, Update};
+ // Replay the observed protocol shape through the real adapter. This
+ // is synthetic unit evidence, never a rewritten live capture.
+ let captured = captured_receipts("cursor");
+ let prior = &captured[1]["payload"]["output"];
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ let initial = json!({"method":"session/update","params":{"update":{
+ "sessionUpdate":"tool_call","toolCallId":"cursor-fixture","kind":"execute",
+ "status":"pending","rawInput":prior["input"]}}});
+ let permission = json!({"id":1,"method":"session/request_permission","params":{
+ "toolCall":{"toolCallId":"cursor-fixture","kind":"execute","rawInput":prior["input"],"status":"pending",
+ "content":[{"type":"content","content":{"type":"text","text":"Not in allowlist: python3"}}]},
+ "options":if scenario == "unusable" {json!([])} else {json!([{"optionId":"allow","kind":"allow_once"},{"optionId":"reject","kind":"reject_once"}])}}});
+ let permission_first =
+ scenario == "permission_first" || scenario == "permission_first_evicted";
+ let mut start = None;
+ if !permission_first {
+ start = Some(adapter.on_line(&initial.to_string()).unwrap());
+ }
+ if scenario != "no_permission" {
+ adapter.on_line(&permission.to_string()).unwrap();
+ }
+ if !matches!(scenario, "pending" | "unusable" | "no_permission") {
+ adapter.approve("1", scenario == "allowed").unwrap();
+ }
+ if scenario == "reject_then_pending" {
+ let mut second = permission.clone();
+ second["id"] = json!(2);
+ adapter.on_line(&second.to_string()).unwrap();
+ }
+ if scenario == "evicted" || scenario == "permission_first_evicted" {
+ adapter.on_line(&json!({"method":"session/update","params":{"update":{
+ "sessionUpdate":"tool_call_update","toolCallId":"cursor-fixture",
+ "content":[{"type":"content","content":{"type":"text","text":"x".repeat(64_000)}}]}}}).to_string()).unwrap();
+ adapter
+ .on_line(
+ &json!({"method":"session/update","params":{"update":{
+ "sessionUpdate":"tool_call_update","toolCallId":"cursor-fixture",
+ "kind":"execute","rawInput":prior["input"],"content":[]}}})
+ .to_string(),
+ )
+ .unwrap();
+ }
+ if permission_first {
+ start = Some(adapter.on_line(&initial.to_string()).unwrap());
+ }
+ let mut terminal = json!({"method":"session/update","params":{"update":{
+ "sessionUpdate":"tool_call_update","toolCallId":"cursor-fixture","status":"completed",
+ "rawOutput":prior["raw_output"]}}});
+ if scenario != "allowed" {
+ terminal["params"]["update"]["content"] = json!([]);
+ }
+ let done = adapter.on_line(&terminal.to_string()).unwrap();
+ let start = start.unwrap();
+ start.updates.into_iter().chain(done.updates).filter_map(|u| match u {
+ Update::ToolStarted {id,name,detail} => Some(json!({"type":"tool.started","payload":{"call_id":id,"tool":name,"arguments":detail}})),
+ Update::ToolCompleted {id,name,success,output} => Some(json!({"type":"tool.completed","payload":{"call_id":id,"tool":name,"success":success,"output":output}})),
+ _ => None,
+ }).collect()
+ }
+
+ #[test]
+ fn new_cursor_protocol_provenance_qualifies_without_relabeling_historical_capture() {
+ let events = cursor_protocol_receipt("allowed");
+ let receipt = fixture_receipt(&events, Vendor::Cursor, Path::new("/fixture")).unwrap();
+ assert_eq!(
+ receipt["source"],
+ "cursor_provenanced_structured_raw_output"
+ );
+ assert!(fixture_receipt(
+ &captured_receipts("cursor"),
+ Vendor::Cursor,
+ Path::new("/fixture")
+ )
+ .is_none());
+ let no_permission = cursor_protocol_receipt("no_permission");
+ assert!(no_permission[1]["payload"]["output"]["acp_provenance"]["permission"].is_null());
+ assert!(fixture_receipt(&no_permission, Vendor::Cursor, Path::new("/fixture")).is_some());
+ }
+
+ #[test]
+ fn cursor_receipt_refuses_reconstructed_history_after_denial_and_eviction() {
+ let events = cursor_protocol_receipt("evicted");
+ let output = &events[1]["payload"]["output"];
+ assert_eq!(output["acp_provenance"]["history_complete"], false);
+ assert!(output["acp_provenance"]["permission"].is_null());
+ assert_eq!(output["raw_output"]["exitCode"], 0);
+ assert!(fixture_receipt(&events, Vendor::Cursor, Path::new("/fixture")).is_none());
+ }
+
+ #[test]
+ fn cursor_receipt_refuses_pending_unusable_and_permission_first_histories() {
+ for scenario in [
+ "pending",
+ "reject_then_pending",
+ "unusable",
+ "permission_first",
+ "permission_first_evicted",
+ ] {
+ let events = cursor_protocol_receipt(scenario);
+ let output = &events[1]["payload"]["output"];
+ assert_eq!(output["content"], json!([]));
+ assert_eq!(output["raw_output"]["exitCode"], 0);
+ assert!(
+ fixture_receipt(&events, Vendor::Cursor, Path::new("/fixture")).is_none(),
+ "accepted {scenario}"
+ );
+ if scenario.starts_with("permission_first") {
+ assert_eq!(output["acp_provenance"]["history_complete"], false);
+ } else {
+ assert_eq!(
+ output["acp_provenance"]["permission"]["state"],
+ if scenario == "unusable" {
+ "declined"
+ } else {
+ "pending"
+ }
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn cursor_provenance_never_excuses_terminal_denial_stale_approval_or_wrong_result() {
+ let events = cursor_protocol_receipt("allowed");
+ for (pointer, value) in [
+ ("/success", json!(false)),
+ ("/output/status", json!("failed")),
+ ("/output/input/command", json!("other")),
+ ("/output/input/cwd", json!("/foreign")),
+ ("/output/cursor_execution/exit_code", json!(7)),
+ ("/output/raw_output/exitCode", json!(7)),
+ ("/output/raw_output/exitCode", json!("0")),
+ (
+ "/output/raw_output/stderr",
+ json!("Model says Ran 5 tests and OK"),
+ ),
+ ("/output/raw_output/stdout", json!("unexpected output")),
+ ("/output/raw_output_truncated", json!(true)),
+ ("/output/raw_output_format", json!("json_text_preview")),
+ ("/output/acp_provenance", Value::Null),
+ ("/output/acp_provenance/history_complete", json!(false)),
+ ("/output/acp_provenance/permission/state", json!("pending")),
+ (
+ "/output/acp_provenance/raw_output/explicit_terminal",
+ json!(false),
+ ),
+ (
+ "/output/acp_provenance/raw_output/after_permission_decision",
+ json!(false),
+ ),
+ (
+ "/output/acp_provenance/raw_output/source",
+ json!("permission_request"),
+ ),
+ (
+ "/output/acp_provenance/content/source",
+ json!("tool_update"),
+ ),
+ ("/output/acp_provenance/content/phase", json!("completed")),
+ (
+ "/output/acp_provenance/content/before_permission_decision",
+ json!(false),
+ ),
+ (
+ "/output/acp_provenance/permission/decision",
+ json!("reject_once"),
+ ),
+ (
+ "/output/acp_provenance/permission/current_operation_matches",
+ json!(false),
+ ),
+ ("/output/content", json!(["Denied by user"])),
+ (
+ "/output/content",
+ json!(["Not in allowlist: python3", "Ran 5 tests\nOK"]),
+ ),
+ ] {
+ let mut bad = events.clone();
+ // cwd is absent in the observed Cursor input, so insert it.
+ if pointer == "/output/input/cwd" {
+ bad[1]["payload"]["output"]["input"]["cwd"] = value;
+ } else {
+ *bad[1]["payload"].pointer_mut(pointer).unwrap() = value;
+ }
+ assert!(
+ fixture_receipt(&bad, Vendor::Cursor, Path::new("/fixture")).is_none(),
+ "accepted {pointer}"
+ );
+ }
+ let mut cross_task = events.clone();
+ cross_task[1]["task_id"] = json!("foreign");
+ assert!(fixture_receipt(&cross_task, Vendor::Cursor, Path::new("/fixture")).is_none());
+ assert!(fixture_receipt(
+ &[events[0].clone(), events[0].clone(), events[1].clone()],
+ Vendor::Cursor,
+ Path::new("/fixture")
+ )
+ .is_none());
+ }
+
+ #[test]
+ fn observed_antigravity_structured_receipt_passes_but_cursor_conflict_does_not() {
+ let events = captured_receipts("antigravity");
+ let receipt = fixture_receipt(&events, Vendor::Antigravity, Path::new("/fixture")).unwrap();
+ assert_eq!(receipt["source"], "antigravity_structured_raw_output");
+ assert_eq!(receipt["session_id"], "fixture-session");
+ let mut cursor = captured_receipts("cursor");
+ assert!(fixture_receipt(&cursor, Vendor::Cursor, Path::new("/fixture")).is_none());
+ cursor[1]["payload"]["output"]["content"]
+ .as_array_mut()
+ .unwrap()
+ .push(json!("Ran 5 tests\nOK"));
+ assert!(fixture_receipt(&cursor, Vendor::Cursor, Path::new("/fixture")).is_none());
+ // Model narration and a generic JSON stdout field never substitute
+ // for the observed provider-specific typed result.
+ for value in [
+ json!("Ran 5 tests in 0.000s\n\nOK"),
+ json!({"stdout":"Ran 5 tests\nOK"}),
+ ] {
+ let mut prose = events.clone();
+ prose[1]["payload"]["output"]["raw_output"] = value;
+ assert!(fixture_receipt(&prose, Vendor::Antigravity, Path::new("/fixture")).is_none());
+ }
+ }
+
+ #[test]
+ fn structured_receipt_rejects_wrong_scope_status_exit_cwd_and_contradictions() {
+ let events = captured_receipts("antigravity");
+ for (pointer, value) in [
+ ("/session_id", json!("foreign-session")),
+ ("/task_id", json!("foreign-task")),
+ ("/payload/call_id", json!("foreign-call")),
+ ("/payload/success", json!(false)),
+ ("/payload/error", json!("execution denied")),
+ ("/payload/output/status", json!("failed")),
+ ("/payload/output/raw_output_truncated", json!(true)),
+ ("/payload/output/raw_output_truncated", Value::Null),
+ (
+ "/payload/output/raw_output_format",
+ json!("json_text_preview"),
+ ),
+ ("/payload/output/raw_output/exitCode", json!(1)),
+ ("/payload/output/raw_output/exit_code", json!(1)),
+ ("/payload/output/raw_output/exitCode", json!("0")),
+ (
+ "/payload/output/raw_output/commandLine",
+ json!("echo fabricated-result"),
+ ),
+ ("/payload/output/raw_output/workingDir", json!("/foreign")),
+ ("/payload/output/input/working_dir", json!("/foreign")),
+ ("/payload/output/input/working_dir", Value::Null),
+ (
+ "/payload/output/raw_output/formatted_output",
+ json!("FAILED"),
+ ),
+ (
+ "/payload/output/content",
+ json!(["Not in allowlist: python3"]),
+ ),
+ ] {
+ let mut bad = events.clone();
+ *bad[1].pointer_mut(pointer).unwrap() = value;
+ assert!(
+ fixture_receipt(&bad, Vendor::Antigravity, Path::new("/fixture")).is_none(),
+ "accepted {pointer}"
+ );
+ }
+ for summary in [
+ "Ran 5 tests\nOK",
+ "The model says Ran 5 tests in 0.000s\nOK",
+ "---\nRan 0 tests in 0.000s\nOK",
+ "---\nRan 5 tests in 0.000s\nOK\nFAILED",
+ ] {
+ let mut bad = events.clone();
+ bad[1]["payload"]["output"]["raw_output"]["combinedOutput"] = json!(summary);
+ bad[1]["payload"]["output"]["raw_output"]["formatted_output"] = json!(summary);
+ assert!(fixture_receipt(&bad, Vendor::Antigravity, Path::new("/fixture")).is_none());
+ }
+ let mut cross_start = events.clone();
+ cross_start[0]["task_id"] = json!("foreign-task");
+ assert!(
+ fixture_receipt(&cross_start, Vendor::Antigravity, Path::new("/fixture")).is_none()
+ );
+ assert!(fixture_receipt(
+ &[events[0].clone(), events[0].clone(), events[1].clone()],
+ Vendor::Antigravity,
+ Path::new("/fixture")
+ )
+ .is_none());
+ assert!(test_receipt(
+ &events,
+ Vendor::Antigravity,
+ Path::new("/fixture"),
+ "",
+ "fixture-task"
+ )
+ .is_none());
+ }
+
+ #[test]
+ fn replay_preserves_original_failure_and_never_overwrites_source() {
+ let capture = json!({"results":[{
+ "target":"cli:antigravity:gemini-3.8-flash-high", "passed":false,
+ "fixture_task":TASK,"fixture_provenance":coding_fixture::provenance(),
+ "events_truncated":false,"call_id_handling":{"already_redacted_fields":1,"report_identifiers":"stable distinct fixture-call-N aliases within this result"},
+ "events":captured_receipts("antigravity"),
+ "job":{"id":"job","workspace":"/fixture","session_id":"fixture-session","task_id":"fixture-task"},
+ "submitted_job":{"id":"job","session_id":"fixture-session"},
+ "independent_assertions_passed":true,"vendor_test_receipt":null
+ }]});
+ let replay = replay_receipts(&capture).unwrap();
+ assert_eq!(replay["mode"], "offline_receipt_replay");
+ assert_eq!(replay["provider_calls"], 0);
+ assert_eq!(replay["results"][0]["original_live_passed"], false);
+ assert_eq!(replay["results"][0]["receipt_accepted"], true);
+ assert_eq!(
+ replay["results"][0]["original_live_result"],
+ capture["results"][0]
+ );
+ assert!(replay["results"][0].get("passed").is_none());
+ let root = tempfile::tempdir().unwrap();
+ let path = root.path().join("capture.json");
+ let original = serde_json::to_vec(&capture).unwrap();
+ fs::write(&path, &original).unwrap();
+ assert!(replay_file(&path, &path).is_err());
+ assert_eq!(fs::read(&path).unwrap(), original);
+ let mut wrong = capture;
+ wrong["results"][0]["job"]["task_id"] = json!("foreign-task");
+ assert_eq!(
+ replay_receipts(&wrong).unwrap()["results"][0]["receipt_accepted"],
+ false
+ );
+ wrong["results"][0]["events_truncated"] = json!(true);
+ assert!(replay_receipts(&wrong).is_err());
+ }
+
+ fn approval(tool: &str, arguments: Value) -> Approval {
+ serde_json::from_value(json!({"id":"a","session_id":"s","task_id":"t","tool":tool,"arguments":arguments,"command":"display only","reason":"fixture","pending":true,"created_at":0,"expires_at":0})).unwrap()
+ }
+ #[test]
+ fn approvals_are_narrow_and_never_grant_opaque_or_external_actions() {
+ let project = tempfile::tempdir().unwrap();
+ fs::write(project.path().join("helpers.py"), SOURCE).unwrap();
+ fs::write(project.path().join("test_helpers.py"), TESTS).unwrap();
+ assert!(approval_allowed(
+ &approval("claude.Bash", json!({"input":{"command":CHECK_COMMAND}})),
+ project.path()
+ ));
+ assert!(approval_allowed(
+ &approval(
+ "claude.Edit",
+ json!({"input":{"file_path":project.path().join("helpers.py")}})
+ ),
+ project.path()
+ ));
+ assert!(approval_allowed(
+ &approval(
+ "cursor.read",
+ json!({"locations":[{"path":"test_helpers.py"}]})
+ ),
+ project.path()
+ ));
+ for (tool, args) in [
+ (
+ "claude.Bash",
+ json!({"input":{"command":"python3 -m unittest -q; curl example.com"}}),
+ ),
+ (
+ "codex.command_execution",
+ json!({"command":CHECK_COMMAND,"cwd":"/tmp"}),
+ ),
+ (
+ "claude.Edit",
+ json!({"input":{"file_path":"test_helpers.py"}}),
+ ),
+ (
+ "cursor.edit",
+ json!({"locations":[{"path":"../helpers.py"}]}),
+ ),
+ ("codex.file_change", json!({"grant_root":project.path()})),
+ ("codex.permissions", json!({"permissions":{"network":true}})),
+ ] {
+ assert!(
+ !approval_allowed(&approval(tool, args), project.path()),
+ "{tool}"
+ );
+ }
+ }
+ #[test]
+ fn exact_successful_correlated_command_receipt_is_required() {
+ let project = Path::new("/fixture");
+ let start = json!({"type":"tool.started","payload":{"tool":"claude.Bash","call_id":"check","arguments":{"input":{"command":CHECK_COMMAND}}}});
+ let complete = json!({"type":"tool.completed","payload":{"tool":"claude.Bash","call_id":"check","success":true,"output":{"output":"Ran 5 tests in 0.001s\n\nOK"}}});
+ assert!(
+ fixture_receipt(&[start.clone(), complete.clone()], Vendor::Claude, project).is_some()
+ );
+ assert!(fixture_receipt(&[complete.clone()], Vendor::Claude, project).is_none());
+ let mut failed = complete.clone();
+ failed["payload"]["success"] = json!(false);
+ assert!(fixture_receipt(&[start.clone(), failed], Vendor::Claude, project).is_none());
+ let mut wrong = start.clone();
+ wrong["payload"]["arguments"]["input"]["command"] = json!("echo tests passed");
+ assert!(fixture_receipt(&[wrong, complete.clone()], Vendor::Claude, project).is_none());
+ assert!(fixture_receipt(&[start, complete], Vendor::Codex, project).is_none());
+ }
+
+ #[test]
+ fn acp_receipt_requires_unique_id_and_current_execute_metadata() {
+ let project = Path::new("/fixture");
+ let start = json!({"type":"tool.started","payload":{"tool":"grok.tool","call_id":"check","arguments":{"input":{"command":"echo obsolete-start-input"}}}});
+ let done = json!({"type":"tool.completed","payload":{"tool":"grok.execute","call_id":"check","success":true,"output":{"status":"completed","tool_kind":"execute","input":{"command":CHECK_COMMAND,"cwd":"/fixture"},"content":["Ran 5 tests in 0.001s\n\nOK"]}}});
+ assert!(fixture_receipt(&[start.clone(), done.clone()], Vendor::Grok, project).is_some());
+ let mut claimed_start = start.clone();
+ claimed_start["payload"]["arguments"]["input"]["command"] = json!(CHECK_COMMAND);
+ for (pointer, value) in [
+ (
+ "/payload/output/input/command",
+ json!("echo different-command"),
+ ),
+ ("/payload/output/input", Value::Null),
+ ("/payload/output/input/cwd", json!("/elsewhere")),
+ ("/payload/output/tool_kind", json!("read")),
+ ("/payload/tool", json!("grok.tool")),
+ ("/payload/tool", json!("cursor.execute")),
+ ("/payload/call_id", json!("different-call")),
+ ("/payload/success", json!(false)),
+ ] {
+ let mut bad = done.clone();
+ *bad.pointer_mut(pointer).unwrap() = value;
+ assert!(
+ fixture_receipt(&[claimed_start.clone(), bad], Vendor::Grok, project).is_none(),
+ "accepted invalid completion field {pointer}"
+ );
+ }
+ assert!(fixture_receipt(
+ &[start.clone(), start.clone(), done.clone()],
+ Vendor::Grok,
+ project
+ )
+ .is_none());
+ assert!(fixture_receipt(&[done.clone()], Vendor::Grok, project).is_none());
+ let mut fabricated_input = done.clone();
+ fabricated_input["payload"]["output"]["content"] = json!([]);
+ fabricated_input["payload"]["output"]["input"]["description"] = json!("Ran 5 tests\nOK");
+ assert!(
+ fixture_receipt(&[start, fabricated_input], Vendor::Grok, project).is_none(),
+ "input descriptions are not captured output"
+ );
+ }
+
+ #[test]
+ fn codex_receipt_accepts_only_the_observed_exact_shell_wrapper() {
+ let wrapped = "/bin/bash -lc 'python3 -m unittest -q'";
+ let start = json!({"type":"tool.started","payload":{"tool":"codex.command_execution","call_id":"check","arguments":{"command":wrapped,"cwd":"/fixture"}}});
+ let done = json!({"type":"tool.completed","payload":{"tool":"codex.command_execution","call_id":"check","success":true,"output":{"output":"Ran 5 tests in 0.001s\n\nOK"}}});
+ assert!(fixture_receipt(
+ &[start.clone(), done.clone()],
+ Vendor::Codex,
+ Path::new("/fixture")
+ )
+ .is_some());
+ for command in [
+ "/bin/bash -lc 'python3 -m unittest -q; echo unsafe'",
+ "/bin/bash -lc 'python3 -m unittest -q && echo unsafe'",
+ "/bin/bash -lc 'python3 -m unittest -q' ; echo unsafe",
+ "/bin/bash -lc 'echo python3 -m unittest -q'",
+ ] {
+ let mut bad = start.clone();
+ bad["payload"]["arguments"]["command"] = json!(command);
+ assert!(
+ fixture_receipt(&[bad, done.clone()], Vendor::Codex, Path::new("/fixture"))
+ .is_none()
+ );
+ }
+ assert!(!is_test_command(wrapped, Vendor::Claude));
+ assert!(!approval_allowed(
+ &approval("codex.command_execution", json!({"command":wrapped})),
+ Path::new("/fixture")
+ ));
+ }
+
+ #[test]
+ fn acp_approval_fields_require_current_typed_commands() {
+ let project = Path::new("/fixture");
+ assert!(approval_allowed(
+ &approval(
+ "antigravity.execute",
+ json!({"input":{"CommandLine":CHECK_COMMAND,"Cwd":"/fixture"}})
+ ),
+ project
+ ));
+ assert!(!approval_allowed(
+ &approval(
+ "antigravity.execute",
+ json!({"input":{"CommandLine":CHECK_COMMAND,"Cwd":"/elsewhere"}})
+ ),
+ project
+ ));
+ assert!(!approval_allowed(
+ &approval("antigravity.execute", json!({"title":CHECK_COMMAND})),
+ project
+ ));
+ assert!(approval_allowed(
+ &approval("cursor.execute", json!({"input":{"command":CHECK_COMMAND}})),
+ project
+ ));
+ // Partial ACP permissions must be normalized by the adapter. The
+ // harness never authorizes from display text or stale event history.
+ for input in [Value::Null, json!({}), json!({"command":"echo unsafe"})] {
+ let pending = approval(
+ "cursor.execute",
+ json!({"tool_call_id":"fixture-call","input":input,"title":format!("`{CHECK_COMMAND}`")}),
+ );
+ assert!(!approval_allowed(&pending, project));
+ }
+ }
+
+ #[test]
+ fn antigravity_normalized_completion_fields_stay_vendor_specific() {
+ let project = Path::new("/fixture");
+ let arguments = json!({"input":{"command_line":CHECK_COMMAND,"working_dir":"/fixture"}});
+ assert!(approval_allowed(
+ &approval("antigravity.execute", arguments.clone()),
+ project
+ ));
+ for tool in [
+ "cursor.execute",
+ "grok.execute",
+ "claude.Bash",
+ "codex.command_execution",
+ ] {
+ assert!(tool_command(&arguments, tool).is_none(), "{tool}");
+ assert!(
+ !approval_allowed(&approval(tool, arguments.clone()), project),
+ "{tool}"
+ );
+ }
+ let start = json!({"type":"tool.started","payload":{"tool":"antigravity.execute","call_id":"test","arguments":{"input":{"CommandLine":CHECK_COMMAND,"Cwd":"/fixture"}}}});
+ let done = json!({"type":"tool.completed","payload":{"tool":"antigravity.execute","call_id":"test","success":true,"output":{
+ "status":"completed","tool_kind":"execute","input":arguments["input"],"content":["Ran 5 tests in 0.001s\n\nOK"]}}});
+ assert!(
+ fixture_receipt(&[start.clone(), done.clone()], Vendor::Antigravity, project).is_some()
+ );
+ let mut wrong_directory = arguments.clone();
+ wrong_directory["input"]["working_dir"] = json!("/elsewhere");
+ assert!(!approval_allowed(
+ &approval("antigravity.execute", wrong_directory.clone()),
+ project
+ ));
+ let mut bad = done.clone();
+ bad["payload"]["output"]["input"] = wrong_directory["input"].clone();
+ assert!(fixture_receipt(&[start.clone(), bad], Vendor::Antigravity, project).is_none());
+ // ACP capture now retains rawOutput, but no real provider shape has
+ // yet been validated for grading. A plausible field is not evidence.
+ let mut raw_only = done;
+ raw_only["payload"]["output"]["content"] = json!([]);
+ raw_only["payload"]["output"]["raw_output"] = json!({"stdout":"Ran 5 tests\nOK"});
+ assert!(fixture_receipt(&[start, raw_only], Vendor::Antigravity, project).is_none());
+ }
+
+ #[test]
+ fn adapter_store_ids_stay_distinct_and_report_aliases_preserve_correlation() {
+ use cli_agent::Update;
+ let root = tempfile::tempdir().unwrap();
+ let store = shadowcode_core::store::Store::open(&root.path().join("store.db")).unwrap();
+ let session = store
+ .create_session(root.path(), "cli:codex", "fixture")
+ .unwrap();
+ let session = session["id"].as_str().unwrap();
+ // Deliberately fake token-shaped identifiers exercise the entropy
+ // heuristic without using credentials or a real provider turn.
+ let a = "call_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6";
+ let b = "call_Z9y8X7w6V5u4T3s2R1q0P9o8N7m6L5k4";
+ let mut adapter = cli_agent::adapter_for(Vendor::Codex, false);
+ adapter.on_start(&cli_agent::LaunchOptions {
+ workspace: Path::new("/fixture").to_owned(),
+ ..Default::default()
+ });
+ adapter.prompt("fixture", &[]).unwrap();
+ adapter
+ .on_line(r#"{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":2}}"#)
+ .unwrap();
+ adapter
+ .on_line(r#"{"jsonrpc":"2.0","id":2,"result":{"thread":{"id":"fixture-thread"}}}"#)
+ .unwrap();
+ adapter
+ .on_line(r#"{"jsonrpc":"2.0","id":3,"result":{"turn":{"id":"fixture-turn"}}}"#)
+ .unwrap();
+ for (method, id, command) in [
+ ("item/started", a, CHECK_COMMAND),
+ ("item/started", b, "echo unrelated"),
+ ("item/completed", b, "echo unrelated"),
+ ] {
+ let frame = json!({"jsonrpc":"2.0","method":method,"params":{"threadId":"fixture-thread","turnId":"fixture-turn","item":{"type":"commandExecution","id":id,"command":command,"cwd":"/fixture","status":"completed","exitCode":0,"aggregatedOutput":"Ran 5 tests in 0.001s\n\nOK"}}});
+ for update in adapter.on_line(&frame.to_string()).unwrap().updates {
+ let (kind, payload) = match update {
+ Update::ToolStarted { id, name, detail } => (
+ "tool.started",
+ json!({"call_id":id,"tool":name,"arguments":detail}),
+ ),
+ Update::ToolCompleted {
+ id,
+ name,
+ success,
+ output,
+ } => (
+ "tool.completed",
+ json!({"call_id":id,"tool":name,"success":success,"output":output}),
+ ),
+ _ => continue,
+ };
+ store
+ .add_event(kind, &payload, Some(session), Some("fixture-task"))
+ .unwrap();
+ }
+ }
+ let events = store.events_after(session, 0, None, 10).unwrap();
+ assert_eq!(events[0]["payload"]["call_id"], a);
+ assert_eq!(events[1]["payload"]["call_id"], b);
+ assert!(
+ test_receipt(
+ &events,
+ Vendor::Codex,
+ Path::new("/fixture"),
+ session,
+ "fixture-task"
+ )
+ .is_none(),
+ "unrelated successful completion must not close the test command"
+ );
+ let mut blanket = json!({"events":events});
+ shadowcode_core::redaction::redact_value(&mut blanket);
+ assert_eq!(
+ blanket["events"][0]["payload"]["call_id"],
+ shadowcode_core::redaction::placeholder()
+ );
+ assert_eq!(
+ blanket["events"][1]["payload"]["call_id"],
+ shadowcode_core::redaction::placeholder()
+ );
+ assert!(test_receipt(
+ blanket["events"].as_array().unwrap(),
+ Vendor::Codex,
+ Path::new("/fixture"),
+ session,
+ "fixture-task"
+ )
+ .is_none());
+ let mut report = json!({"events":events,"vendor_test_receipt":{"call_id":a}});
+ alias_report_call_ids(&mut report);
+ shadowcode_core::redaction::redact_value(&mut report);
+ let first = &report["events"][0]["payload"]["call_id"];
+ let second = &report["events"][1]["payload"]["call_id"];
+ assert_ne!(first, second);
+ assert_eq!(second, &report["events"][2]["payload"]["call_id"]);
+ assert_eq!(first, &report["vendor_test_receipt"]["call_id"]);
+ assert!(first.as_str().unwrap().starts_with("fixture-call-"));
+ assert_eq!(report["call_id_handling"]["distinct_identifiers"], 2);
+ assert_eq!(report["call_id_handling"]["already_redacted_fields"], 0);
+ }
+
+ #[test]
+ fn only_explicit_supported_cli_routes_are_accepted() {
+ for target in [
+ "cli:codex",
+ "cli:claude:sonnet",
+ "cli:cursor",
+ "cli:antigravity",
+ "cli:grok:grok-4.7",
+ ] {
+ assert!(vendor_for(target).is_ok());
+ }
+ for target in ["openai:gpt-5", "local:gguf:abc", "auto", "cli:unknown"] {
+ assert!(vendor_for(target).is_err());
+ }
+ }
+}
diff --git a/native/core/examples/live_vendor_turn.rs b/native/core/examples/live_vendor_turn.rs
index cf55c8b4..0f5ae7fa 100644
--- a/native/core/examples/live_vendor_turn.rs
+++ b/native/core/examples/live_vendor_turn.rs
@@ -242,7 +242,11 @@ async fn main() -> anyhow::Result<()> {
let done = loop {
tokio::select! {
done = &mut waiting => break done?,
- _ = &mut deadline => anyhow::bail!("turn did not finish in 240 s"),
+ _ = &mut deadline => {
+ let _ = tokio::time::timeout(Duration::from_secs(10), service.engine.cancel(&id)).await;
+ service.engine.shutdown().await?;
+ anyhow::bail!("turn did not finish in 240 s");
+ },
_ = tokio::time::sleep(Duration::from_millis(300)), if command => {
let pending = call(&service, "GET", "/api/approvals", json!({})).await?;
for approval in pending["approvals"].as_array().into_iter().flatten() {
@@ -281,6 +285,23 @@ async fn main() -> anyhow::Result<()> {
}
}
}
+ // A completed job is not enough: the minimal text/resume smoke must
+ // preserve the exact answer. In particular, duplicate stream/result
+ // text must make this executable fail rather than merely print it.
+ let expected_word = !web && !image && !command;
+ let answer = done["result"]["summary"]
+ .as_str()
+ .or(done["result"]["text"].as_str())
+ .unwrap_or("")
+ .trim();
+ if done["status"] != "completed" || (expected_word && answer != "ALPHA") {
+ service.engine.shutdown().await?;
+ anyhow::bail!(
+ "live smoke failed: status={}, exact-answer check={}",
+ done["status"],
+ !expected_word || answer == "ALPHA"
+ );
+ }
}
// --switch : continue the same conversation on another
// provider; the first attempt must ask for consent, the second hands off.
diff --git a/native/core/examples/probe_local.rs b/native/core/examples/probe_local.rs
index 9687452b..e5e7391c 100644
--- a/native/core/examples/probe_local.rs
+++ b/native/core/examples/probe_local.rs
@@ -34,13 +34,13 @@ fn main() {
.as_ref()
.and_then(|p| local_engine::header(p).ok());
println!(
- "{} arch={:?} ctx_train={:?} embd={:?} template={} tools={} thinking_switch={} projector={:?}",
+ "{} arch={:?} ctx_train={:?} embd={:?} template={} tools_template_hint={} thinking_switch={} projector={:?}",
model.tag,
header.architecture(),
header.arch_u64("context_length"),
header.arch_u64("embedding_length"),
header.chat_template().map(str::len).unwrap_or(0),
- header.template_supports_tools(),
+ header.template_mentions_tools(),
header.template_has_thinking_switch(),
projector.map(|p| (
p.architecture().map(str::to_owned),
diff --git a/native/core/examples/support/acp_coding_receipts.json b/native/core/examples/support/acp_coding_receipts.json
new file mode 100644
index 00000000..a6a1b77e
--- /dev/null
+++ b/native/core/examples/support/acp_coding_receipts.json
@@ -0,0 +1,105 @@
+{
+ "source_capture_sha256": "2b9e5c9dc27184378a8a8a971bfa03fe7dc7579cccd3ff7763173dcd0f740d0f",
+ "source_build": "ffea0a8",
+ "normalization": "Saved tool payloads; temporary workspace and identifiers replaced, event timestamps omitted.",
+ "cursor": [
+ {
+ "type": "tool.started",
+ "session_id": "fixture-session",
+ "task_id": "fixture-task",
+ "payload": {
+ "arguments": {
+ "input": {
+ "command": "python3 -m unittest -q"
+ },
+ "locations": null,
+ "title": "`python3 -m unittest -q`"
+ },
+ "call_id": "check",
+ "tool": "cursor.execute"
+ }
+ },
+ {
+ "type": "tool.completed",
+ "session_id": "fixture-session",
+ "task_id": "fixture-task",
+ "payload": {
+ "call_id": "check",
+ "error": "",
+ "output": {
+ "content": [
+ "Not in allowlist: python3"
+ ],
+ "input": {
+ "command": "python3 -m unittest -q"
+ },
+ "locations": [],
+ "raw_output": {
+ "exitCode": 0,
+ "stderr": "----------------------------------------------------------------------\nRan 5 tests in 0.000s\n\nOK\n",
+ "stdout": ""
+ },
+ "raw_output_format": "json",
+ "raw_output_truncated": false,
+ "status": "completed",
+ "title": "`python3 -m unittest -q`",
+ "tool_kind": "execute"
+ },
+ "success": true,
+ "tool": "cursor.execute"
+ }
+ }
+ ],
+ "antigravity": [
+ {
+ "type": "tool.started",
+ "session_id": "fixture-session",
+ "task_id": "fixture-task",
+ "payload": {
+ "arguments": {
+ "input": {
+ "CommandLine": "python3 -m unittest -q",
+ "Cwd": "/fixture",
+ "WaitMsBeforeAsync": 5000
+ },
+ "locations": null,
+ "title": "python3 -m unittest -q"
+ },
+ "call_id": "check",
+ "tool": "antigravity.execute"
+ }
+ },
+ {
+ "type": "tool.completed",
+ "session_id": "fixture-session",
+ "task_id": "fixture-task",
+ "payload": {
+ "call_id": "check",
+ "error": "",
+ "output": {
+ "content": [],
+ "input": {
+ "command_line": "python3 -m unittest -q",
+ "working_dir": "/fixture"
+ },
+ "locations": [],
+ "raw_output": {
+ "combinedOutput": "----------------------------------------------------------------------\nRan 5 tests in 0.000s\n\nOK\n",
+ "commandLine": "python3 -m unittest -q",
+ "exitCode": 0,
+ "exit_code": 0,
+ "formatted_output": "----------------------------------------------------------------------\nRan 5 tests in 0.000s\n\nOK\n",
+ "workingDir": "/fixture"
+ },
+ "raw_output_format": "json",
+ "raw_output_truncated": false,
+ "status": "completed",
+ "title": "python3 -m unittest -q",
+ "tool_kind": "execute"
+ },
+ "success": true,
+ "tool": "antigravity.execute"
+ }
+ }
+ ]
+}
diff --git a/native/core/examples/support/coding_fixture.rs b/native/core/examples/support/coding_fixture.rs
new file mode 100644
index 00000000..f59e616e
--- /dev/null
+++ b/native/core/examples/support/coding_fixture.rs
@@ -0,0 +1,216 @@
+//! Shared fixed Python repair task and evaluator-owned five-case grader.
+//! The task, initial source and tests must stay identical across live routes.
+use anyhow::{ensure, Result};
+use sha2::{Digest, Sha256};
+use shadowcode_core::{
+ config::ShellNetwork,
+ process::{self, ProcessSpec},
+ sandbox::{self, ShellPolicy},
+};
+use std::{fs, io::Read, path::Path, time::Duration};
+use tokio_util::sync::CancellationToken;
+
+pub const CHECK_COMMAND: &str = "python3 -m unittest -q";
+pub const TASK: &str = "Fix helpers.py. clamp(x, lo, hi) must constrain x to the inclusive bounds and raise ValueError when lo > hi. unique(items) must preserve first-occurrence order, support unhashable elements, and leave the input unchanged. chunks(items, size) must return a list of consecutive chunks with a possibly shorter final chunk, return [] for empty input, leave the input unchanged, and raise ValueError for size <= 0. Inspect the files with tools. Edit only helpers.py; preserve test_helpers.py unchanged. Then run exactly `python3 -m unittest -q` with exec from the project root; explicitly set the project as cwd when the tool accepts a cwd/working-directory argument. Report the observed result.";
+pub const SOURCE: &str = "def clamp(x, lo, hi):\n return x\n\ndef unique(items):\n return items\n\ndef chunks(items, size):\n return [items]\n";
+pub const TESTS: &str = r#"import unittest
+from helpers import clamp, unique, chunks
+
+class HelpersTest(unittest.TestCase):
+ def test_clamp_bounds(self):
+ self.assertEqual([clamp(x, -2, 4) for x in [-9, -2, 0, 4, 9]], [-2, -2, 0, 4, 4])
+ self.assertEqual(clamp(3.5, 0.5, 2.5), 2.5)
+ self.assertEqual(clamp(99, 2, 2), 2)
+ def test_clamp_invalid(self):
+ with self.assertRaises(ValueError): clamp(1, 4, 2)
+ def test_unique(self):
+ values = [3, 1, 3, 2, 1]
+ self.assertEqual(unique(values), [3, 1, 2])
+ self.assertEqual(values, [3, 1, 3, 2, 1])
+ self.assertEqual(unique([]), [])
+ self.assertEqual(unique([[1], [2], [1]]), [[1], [2]])
+ def test_chunks(self):
+ values = [1, 2, 3, 4, 5]
+ self.assertEqual(chunks(values, 2), [[1, 2], [3, 4], [5]])
+ self.assertEqual(chunks(values, 9), [values])
+ self.assertEqual(chunks([], 3), [])
+ self.assertEqual(values, [1, 2, 3, 4, 5])
+ def test_chunks_invalid(self):
+ for size in [0, -1]:
+ with self.assertRaises(ValueError): chunks([1, 2], size)
+
+if __name__ == '__main__': unittest.main()
+"#;
+
+pub fn provenance() -> serde_json::Value {
+ let hash = |text: &str| format!("{:x}", Sha256::digest(text.as_bytes()));
+ serde_json::json!({
+ "task_sha256":hash(TASK), "source_sha256":hash(SOURCE),
+ "tests_sha256":hash(TESTS), "grader_source_sha256":hash(include_str!("coding_fixture.rs")),
+ "core_version":env!("CARGO_PKG_VERSION"), "test_command":CHECK_COMMAND,
+ "test_cases":5
+ })
+}
+
+pub async fn check(project: &Path) -> Result {
+ ensure!(
+ fs::symlink_metadata(project)?.is_dir(),
+ "Expected a regular project directory"
+ );
+ let source = bounded_regular_text(&project.join("helpers.py"), 100_000)?;
+ let verifier = tempfile::tempdir()?;
+ // No model-created tests, import shadows, startup files or caches enter
+ // the independent verifier. The model-requested check is separate.
+ fs::write(verifier.path().join("helpers.py"), source)?;
+ fs::write(verifier.path().join("test_helpers.py"), TESTS)?;
+ fs::write(
+ verifier.path().join("grade.py"),
+ r#"import sys, pathlib, unittest
+sys.path.insert(0, str(pathlib.Path(__file__).parent))
+import test_helpers
+suite = unittest.defaultTestLoader.loadTestsFromModule(test_helpers)
+result = unittest.TextTestRunner(verbosity=1).run(suite)
+passed = result.testsRun == 5 and result.wasSuccessful()
+print('SHADOWCODE_EVALUATOR:5:PASS' if passed else 'SHADOWCODE_EVALUATOR:FAIL')
+sys.exit(0 if passed else 1)
+"#,
+ )?;
+ let prepared = sandbox::prepare_shell(
+ verifier.path(),
+ verifier.path(),
+ "/usr/bin/python3 -I grade.py",
+ &ShellPolicy {
+ network: ShellNetwork::Off,
+ allow: Vec::new(),
+ allow_text: Vec::new(),
+ home_binds: Vec::new(),
+ require: true,
+ landlock: true,
+ },
+ )?;
+ let mut spec = ProcessSpec::command(&prepared.program, &[], verifier.path().to_owned());
+ spec.timeout = Duration::from_secs(30);
+ spec.output_limit = 256_000;
+ spec.args = prepared.args;
+ spec.child = prepared.child;
+ let result = process::run(spec, CancellationToken::new(), None).await;
+ if let Some(scratch) = prepared.scratch {
+ sandbox::discard_scratch(&scratch)?;
+ }
+ result
+}
+
+// Consistency checks for this fixed fixture, not an adversarial-proof grader:
+// generated Python still executes in the same process as the unittest runner.
+pub fn assertions_passed(result: &process::ProcessResult) -> bool {
+ result.ok
+ && !result.timed_out
+ && !result.cancelled
+ && !result.truncated
+ && result.stderr.contains("Ran 5 tests")
+ && result
+ .stdout
+ .lines()
+ .any(|line| line == "SHADOWCODE_EVALUATOR:5:PASS")
+}
+
+pub fn bounded_regular_text(path: &Path, limit: usize) -> Result {
+ let mut options = fs::OpenOptions::new();
+ options.read(true);
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::OpenOptionsExt;
+ options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
+ }
+ let file = options.open(path)?;
+ let metadata = file.metadata()?;
+ ensure!(
+ metadata.is_file() && metadata.len() <= limit as u64,
+ "Expected a bounded regular file: {}",
+ path.display()
+ );
+ let mut text = String::new();
+ file.take(limit as u64 + 1).read_to_string(&mut text)?;
+ ensure!(text.len() <= limit, "File exceeded evaluator byte limit");
+ Ok(text)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const CORRECT: &str = r#"def clamp(x, lo, hi):
+ if lo > hi:
+ raise ValueError("invalid bounds")
+ return min(max(x, lo), hi)
+
+def unique(items):
+ result = []
+ for item in items:
+ if item not in result:
+ result.append(item)
+ return result
+
+def chunks(items, size):
+ if size <= 0:
+ raise ValueError("invalid chunk size")
+ return [items[start:start + size] for start in range(0, len(items), size)]
+"#;
+
+ #[tokio::test]
+ async fn independent_verifier_accepts_correct_code_after_running_all_five_tests() {
+ let project = tempfile::tempdir().unwrap();
+ fs::write(project.path().join("helpers.py"), CORRECT).unwrap();
+ let result = check(project.path()).await.unwrap();
+ assert!(assertions_passed(&result), "{result:?}");
+ assert!(result.stderr.contains("Ran 5 tests"), "{result:?}");
+ assert!(!result.timed_out && !result.cancelled && !result.truncated);
+ }
+
+ #[tokio::test]
+ async fn project_import_shadows_cannot_make_broken_helpers_pass() {
+ let project = tempfile::tempdir().unwrap();
+ fs::write(project.path().join("helpers.py"), SOURCE).unwrap();
+ // These could turn `python3 -m unittest` in the submitted project into
+ // a forged success. Neither file belongs in the independent grader.
+ fs::write(
+ project.path().join("unittest.py"),
+ "print('SHADOWCODE_EVALUATOR:5:PASS')\n",
+ )
+ .unwrap();
+ fs::write(
+ project.path().join("sitecustomize.py"),
+ "import os\nos._exit(0)\n",
+ )
+ .unwrap();
+ fs::write(
+ project.path().join("test_helpers.py"),
+ "# assertions removed\n",
+ )
+ .unwrap();
+ let result = check(project.path()).await.unwrap();
+ assert!(!assertions_passed(&result), "{result:?}");
+ assert!(!result.ok, "{result:?}");
+ assert!(result.stderr.contains("Ran 5 tests"), "{result:?}");
+ assert!(
+ result.stdout.contains("SHADOWCODE_EVALUATOR:FAIL"),
+ "{result:?}"
+ );
+ assert!(!result.stdout.contains("SHADOWCODE_EVALUATOR:5:PASS"));
+ }
+
+ #[tokio::test]
+ async fn exit_zero_before_running_assertions_is_not_a_passing_grade() {
+ let project = tempfile::tempdir().unwrap();
+ for helper in [
+ "import os\nos._exit(0)\n",
+ "import os\nprint('SHADOWCODE_EVALUATOR:5:PASS', flush=True)\nos._exit(0)\n",
+ ] {
+ fs::write(project.path().join("helpers.py"), helper).unwrap();
+ let result = check(project.path()).await.unwrap();
+ assert!(result.ok && result.exit_code == 0, "{result:?}");
+ assert!(!assertions_passed(&result), "{result:?}");
+ assert!(!result.stderr.contains("Ran 5 tests"), "{result:?}");
+ }
+ }
+}
diff --git a/native/core/src/autonomy.rs b/native/core/src/autonomy.rs
index e23be09d..1c0b4fc7 100644
--- a/native/core/src/autonomy.rs
+++ b/native/core/src/autonomy.rs
@@ -4,7 +4,98 @@
use crate::{context, permissions, tools};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
-use std::collections::{BTreeMap, BTreeSet};
+use std::collections::{BTreeMap, BTreeSet, HashMap};
+
+/// Notice repeated inspection of files already read in full during a code task.
+/// This only adds a process note; it never hides a tool result or grants edits.
+#[derive(Default)]
+pub struct ObservationLoop {
+ complete_reads: HashMap,
+ redundant: usize,
+ warned: bool,
+}
+
+impl ObservationLoop {
+ /// Use the exact bounded/redacted tool message sent to the model. Backend
+ /// output alone does not establish that the model received a full read.
+ pub fn record_message(&mut self, name: &str, arguments: &Value, message: &Value) -> bool {
+ let payload = message["content"]
+ .as_str()
+ .and_then(|text| serde_json::from_str::(text).ok())
+ .unwrap_or(Value::Null);
+ let visible = message["redacted"] != true
+ && payload["redacted"] != true
+ && payload["truncated"] != true;
+ self.record(
+ name,
+ arguments,
+ if visible {
+ &payload["output"]
+ } else {
+ &Value::Null
+ },
+ visible && payload["success"] == true,
+ )
+ }
+
+ pub fn record(&mut self, name: &str, arguments: &Value, output: &Value, success: bool) -> bool {
+ // Even an unsuccessful mutation or command may have changed files.
+ if tool_class(name) != ToolClass::ReadOnly {
+ self.complete_reads.clear();
+ self.redundant = 0;
+ return false;
+ }
+ let path = arguments["path"].as_str();
+ let full_read = success
+ && output["offset"] == 1
+ && output["truncated"] == false
+ && output["next_offset"].is_null()
+ && output["redacted"] == false
+ && output["hash"].is_string();
+ if name == "read_file" && !full_read {
+ if let Some(path) = path {
+ self.complete_reads.remove(path);
+ }
+ self.redundant = 0;
+ return false;
+ }
+ if !success {
+ self.redundant = 0;
+ return false;
+ }
+ let repeated = match (name, path) {
+ ("read_file", Some(path)) => {
+ let hash = output["hash"].as_str().expect("full read checked");
+ let previous = self.complete_reads.insert(path.to_owned(), hash.to_owned());
+ previous.as_deref() == Some(hash)
+ }
+ ("search_code", _) => {
+ output.get("truncated").is_none_or(|value| value == false)
+ && output.get("ok").is_none_or(|value| value == true)
+ && output["hits"].as_array().is_some_and(|hits| {
+ !hits.is_empty()
+ && hits.iter().all(|hit| {
+ hit["path"]
+ .as_str()
+ .is_some_and(|path| self.complete_reads.contains_key(path))
+ })
+ })
+ }
+ ("search_text" | "search_symbol", Some(path)) => self.complete_reads.contains_key(path),
+ _ => false,
+ };
+ if repeated {
+ self.redundant += 1;
+ } else {
+ self.redundant = 0;
+ }
+ if self.redundant >= 3 && !self.warned {
+ self.warned = true;
+ return true;
+ }
+ false
+ }
+}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -352,28 +443,279 @@ pub fn text_loop_stats(text: &str) -> Option<(String, usize)> {
best
}
-/// First-person claims that a shell/desktop command is being run in prose
-/// without a structured tool call. Advice directed at the user is ignored.
+/// Conservative command subset of `promises_tool_action`, sharing its quote,
+/// conditional and negative filters. This is a heuristic for replies without
+/// structured tool calls, never evidence that a command actually ran.
pub fn claims_command_execution(text: &str) -> bool {
- let lower = text.to_ascii_lowercase();
- let first_person = regex::Regex::new(
- r"(?i)\b(?:i(?:'m| am)?(?:\s+\w+){0,3}\s+(?:going\s+to|about\s+to)|i(?:'ll| will)|let\s+me|now\s+i(?:'ll| will)?)\s+(?:run|execute|do|set|call|invoke|issue)\b",
- )
- .expect("prose claim regex");
- if first_person.is_match(&lower) {
- return true;
+ has_unperformed_action(text, true)
+}
+
+/// Conservative English-language signal for an unfinished, first-person
+/// commitment to use workspace/process tools. Call only for a reply with no
+/// structured tool calls. This is not an intent oracle, a success check, or
+/// permission to execute anything: callers must retain task mode, capability,
+/// approval and retry limits. Ambiguous wording and unfamiliar phrasing can be
+/// missed deliberately; quoted examples, advice and limitations are not work.
+/// A later unsupported success claim does not erase an earlier action promise.
+pub fn promises_tool_action(text: &str) -> bool {
+ has_unperformed_action(text, false)
+}
+
+fn has_unperformed_action(text: &str, commands_only: bool) -> bool {
+ use std::sync::LazyLock;
+ static SENTENCE: LazyLock = LazyLock::new(|| {
+ regex::Regex::new(r"[.!?](?:\s+|$)|\n\s*\n").expect("action sentence regex")
+ });
+ static FUTURE: LazyLock = LazyLock::new(|| {
+ regex::Regex::new(r"\b(?:i(?:'ll| will)|i(?:'m| am) (?:going|about) to|let me)\s+(?:(?:first|now|next|then|also|just)\s+){0,2}(make|apply|edit|modify|patch|fix|repair|update|rewrite|implement|create|delete|remove|rename|run|execute|invoke|launch|rerun|do|set|call|issue|inspect|read|open|search|examine|check|verify|test)\b")
+ .expect("action promise regex")
+ });
+ static CURRENT: LazyLock = LazyLock::new(|| {
+ // Explicit first-person status, not arbitrary progressive narration.
+ // Targets and quote/conditional/negative exclusions still apply below.
+ regex::Regex::new(r"\bi(?:'m| am)\s+(?:now|currently)\s+(making|applying|editing|modifying|patching|fixing|repairing|updating|rewriting|implementing|creating|deleting|removing|renaming|running|executing|invoking|launching|rerunning|inspecting|reading|opening|searching|examining|checking|verifying|testing)\b")
+ .expect("current action status regex")
+ });
+ static RUNNING_COMMAND: LazyLock = LazyLock::new(|| {
+ // A bare progressive phrase is often explanatory prose. Accept only
+ // an entire status sentence with an opaque inline command target.
+ regex::Regex::new(r"^(?:i(?:'m| am)\s+)?(?:running|executing|issuing)\s+__code__(?:\s+(?:now|next|again))?\s*[.!]?\s*$")
+ .expect("running command claim regex")
+ });
+ static INTRO: LazyLock = LazyLock::new(|| {
+ regex::Regex::new(r"^(?:(?:okay|ok|sure|first|next|now|then|finally|so|also|actually)[,:]?\s*|(?:here(?:'s| is) (?:the |my )?plan|plan):\s*|to (?:fix|repair|update|implement|investigate|resolve|verify)[^;!?]{1,160},\s*)?$")
+ .expect("action introduction regex")
+ });
+ static EXCLUDED: LazyLock = LazyLock::new(|| {
+ regex::Regex::new(r"\b(?:if|unless|whether|would|could|might|can't|cannot|won't|unable|hypothetical|hypothetically|example|suppose|imagine|pretend|mentally|conceptually)\b|\b(?:do not|don't) have\b|\b(?:no access|not permitted|not allowed)\b|\b(?:after|once|when|until|upon|pending|with|subject to)[^.;!?]{0,64}\b(?:approv\w*|permission|confirm\w*|ask\w*|ready)\b")
+ .expect("conditional action regex")
+ });
+ static WORKSPACE: LazyLock = LazyLock::new(|| {
+ regex::Regex::new(r"\b(?:file|files|function|functions|code|repository|repo|workspace|project|test|tests|implementation|bug|script|config|module|component|class|method)\b|__code__|\b[a-z0-9_/-]+\.[a-z0-9]{1,10}\b")
+ .expect("workspace action target regex")
+ });
+ static COMMAND: LazyLock = LazyLock::new(|| {
+ regex::Regex::new(r"\b(?:tests?|suite|checks?|commands?|build|scripts?|pytest|unittest|cargo|npm|pnpm|yarn|python3?|git|make|cmake|xset|xpaper|feh)\b|__code__")
+ .expect("command action target regex")
+ });
+ static SHELL_TARGET: LazyLock = LazyLock::new(|| {
+ regex::Regex::new(
+ r"^\s*(?:[./~][a-z0-9_./~-]+(?:\s|$)|[a-z0-9_./-]+\s+--?[a-z][a-z0-9-]*\b)",
+ )
+ .expect("shell command target regex")
+ });
+ static CHANGES: LazyLock = LazyLock::new(|| {
+ regex::Regex::new(r"^(?:\s+(?:the|these|those|necessary|required|requested|following|needed|appropriate|proposed)){0,5}\s+(?:changes?|patch|fix)\b")
+ .expect("promised changes regex")
+ });
+ static NO_ACTION_OBJECT: LazyLock = LazyLock::new(|| {
+ regex::Regex::new(
+ r"^\s*(?:(?:exactly|absolutely|literally)\s+)?(?:no|none|nothing|neither|zero|not)\b",
+ )
+ .expect("negated action object regex")
+ });
+ static PROSE_OBJECT: LazyLock = LazyLock::new(|| {
+ regex::Regex::new(r"^\s*(?:(?:the|this|my|your|our|an?)\s+)?(?:explanation|answer|response|wording|description|plan|summary|user|you|reader|example|analogy|idea|comparison)\b")
+ .expect("non-tool action target regex")
+ });
+
+ let prose = unquoted_action_prose(text).to_lowercase();
+ let mut start = 0;
+ for end in SENTENCE
+ .find_iter(&prose)
+ .map(|m| m.end())
+ .chain(std::iter::once(prose.len()))
+ {
+ let sentence = prose[start..end]
+ .trim_start_matches(|c: char| c.is_whitespace() || matches!(c, '*' | '_' | '#' | '-'));
+ start = end;
+ if sentence.contains('?') || EXCLUDED.is_match(sentence) {
+ continue;
+ }
+ if RUNNING_COMMAND.is_match(sentence.trim()) {
+ return true;
+ }
+ let Some(action) = FUTURE
+ .captures(sentence)
+ .or_else(|| CURRENT.captures(sentence))
+ else {
+ continue;
+ };
+ let found = action.get(0).expect("action match");
+ if !INTRO.is_match(sentence[..found.start()].trim()) {
+ continue;
+ }
+ // A nearby concrete target is required; an unrelated later sentence
+ // or lengthy narrative must not turn abstract prose into a tool action.
+ let raw_target = &sentence[found.end()..];
+ let target: String = raw_target
+ .trim_start_matches(|c: char| c.is_whitespace() || c == ':')
+ .chars()
+ .take(320)
+ .collect();
+ if NO_ACTION_OBJECT.is_match(&target) || PROSE_OBJECT.is_match(&target) {
+ continue;
+ }
+ let verb = match action.get(1).expect("action verb").as_str() {
+ "making" => "make",
+ "applying" => "apply",
+ "running" => "run",
+ "executing" => "execute",
+ "invoking" => "invoke",
+ "launching" => "launch",
+ "rerunning" => "rerun",
+ "checking" => "check",
+ "verifying" => "verify",
+ "testing" => "test",
+ verb => verb,
+ };
+ let command_target = COMMAND.is_match(&target) || SHELL_TARGET.is_match(&target);
+ let command_action = match verb {
+ "run" | "execute" | "invoke" | "launch" | "rerun" | "call" | "issue" | "set" => {
+ command_target
+ }
+ // Retain the observed "Actually, I'll do: xpaper …" form without
+ // treating a generic promise to "do" something as a tool action.
+ "do" => raw_target.trim_start().starts_with(':') && command_target,
+ _ => false,
+ };
+ if command_action {
+ return true;
+ }
+ if commands_only {
+ continue;
+ }
+ let concrete = match verb {
+ "run" | "execute" | "invoke" | "launch" | "rerun" | "call" | "issue" | "set" | "do" => {
+ false
+ }
+ "make" | "apply" => CHANGES.is_match(raw_target) && WORKSPACE.is_match(&target),
+ "test" | "verify" | "check" => COMMAND.is_match(&target) || WORKSPACE.is_match(&target),
+ _ => WORKSPACE.is_match(&target),
+ };
+ if concrete {
+ return true;
+ }
}
- let running = regex::Regex::new(
- r"(?i)\b(?:running|executing|issuing)\s+(?:`[^`]+`|[a-z0-9._/-]+(?:\s+-[a-z0-9-]+)*)",
- )
- .expect("running claim regex");
- if running.is_match(&lower) {
- return true;
+ false
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum InspectionTarget {
+ Workspace,
+ Host,
+}
+
+/// Keep the existing anchored inspection requirement, while distinguishing
+/// explicit host questions from project inspection. Tool choice cannot weaken
+/// the request; project/path cues take precedence over a host noun.
+pub(crate) fn inspection_target(task: &str) -> Option {
+ use std::sync::LazyLock;
+ static REQUEST: LazyLock =
+ LazyLock::new(|| regex::Regex::new(r"(?i)^(?:please\s+)?(?:read|inspect|open)\b").unwrap());
+ static HOST: LazyLock = LazyLock::new(|| {
+ regex::Regex::new(r"(?i)^(?:please\s+)?(?:read|inspect|open)\s+(?:(?:the|my|our|this|current)\s+)?(?:computer|machine|host|os|operating system|(?:connected\s+)?(?:screens?|monitors?|displays?))\b").unwrap()
+ });
+ static PROJECT: LazyLock = LazyLock::new(|| {
+ // Host configuration/version words are not project evidence. Paths,
+ // explicit project/file nouns and recognizable source/config filenames
+ // override a host noun; a numeric OS version such as 6.8 does not.
+ regex::Regex::new(r"(?i)\b(?:files?|paths?|directories|directory|folders?|repository|repo|workspace|project|codebase|database)\b|\bsource\s+code\b|[/\\]|\b[\w-]+\.(?:rs|py|js|jsx|ts|tsx|json|toml|ya?ml|md|txt|sh|c|h|cpp|go|java)\b").unwrap()
+ });
+ let task = task.trim();
+ REQUEST.is_match(task).then(|| {
+ if HOST.is_match(task) && !PROJECT.is_match(task) {
+ InspectionTarget::Host
+ } else {
+ InspectionTarget::Workspace
+ }
+ })
+}
+
+/// Remove Markdown examples before looking for commitments. Inline code is
+/// kept as an opaque possible *target*, never interpreted as a promise itself.
+fn unquoted_action_prose(text: &str) -> String {
+ let mut visible = String::new();
+ let mut fence = None;
+ let mut quoted_block = false;
+ for line in text.lines() {
+ let trimmed = line.trim_start();
+ let delimiter = trimmed.chars().next().filter(|c| matches!(c, '`' | '~'));
+ let run = delimiter.map(|c| trimmed.chars().take_while(|&x| x == c).count());
+ if let Some((kind, length)) = fence {
+ if delimiter == Some(kind) && run.is_some_and(|n| n >= length) {
+ fence = None;
+ }
+ visible.push('\n');
+ continue;
+ }
+ if let (Some(kind), Some(length)) = (delimiter, run) {
+ if length >= 3 {
+ fence = Some((kind, length));
+ visible.push('\n');
+ continue;
+ }
+ }
+ if trimmed.is_empty() {
+ quoted_block = false;
+ } else if trimmed.starts_with('>') {
+ quoted_block = true;
+ }
+ if !quoted_block && !line.starts_with(" ") && !line.starts_with('\t') {
+ visible.push_str(line);
+ }
+ visible.push('\n');
}
- // Bare "Actually, I'll do: cmd …" lines seen from abliterated local models.
- let do_colon =
- regex::Regex::new(r"(?i)\bi(?:'ll| will)\s+do\s*:").expect("do-colon claim regex");
- do_colon.is_match(&lower)
+
+ let mut output = String::new();
+ let mut chars = visible.chars().peekable();
+ let mut previous = '\n';
+ while let Some(c) = chars.next() {
+ let quote = match c {
+ '"' => Some('"'),
+ '“' => Some('”'),
+ '‘' => Some('’'),
+ '\'' if !previous.is_alphanumeric() => Some('\''),
+ _ => None,
+ };
+ if let Some(close) = quote {
+ let mut escaped = false;
+ let mut inside_previous = '\n';
+ while let Some(next) = chars.next() {
+ if !escaped
+ && next == close
+ && !(close == '\''
+ && inside_previous.is_alphanumeric()
+ && chars.peek().is_some_and(|c| c.is_alphanumeric()))
+ {
+ break;
+ }
+ escaped = next == '\\' && !escaped;
+ inside_previous = next;
+ }
+ output.push(' ');
+ } else if c == '`' {
+ let mut length = 1;
+ while chars.peek() == Some(&'`') {
+ chars.next();
+ length += 1;
+ }
+ let mut closing = 0;
+ for next in chars.by_ref() {
+ closing = if next == '`' { closing + 1 } else { 0 };
+ if closing == length {
+ break;
+ }
+ }
+ if closing == length {
+ output.push_str(" __code__ ");
+ }
+ } else {
+ output.push(if c == '’' { '\'' } else { c });
+ }
+ previous = c;
+ }
+ output
}
pub fn capability_profile(provider: &str, context_limit: usize) -> CapabilityProfile {
@@ -614,56 +956,8 @@ fn collect_paths(value: &Value, files: &mut BTreeSet) {
}
}
-fn command_failed(command: &Value) -> bool {
- command["success"] == false || command["timed_out"] == true
-}
-
pub fn classify_verification(model_text: &str, commands: &[Value], inspected: bool) -> Value {
- let claims = looks_like_success_claim(model_text);
- let any_cmd = !commands.is_empty();
- // The last verification-like command decides. A failing test that is
- // deliberately observed first (bug-fix policy) must not block a later
- // passing run from counting as verified; a failure after the last passing
- // check, or a passing check that is not the final word, still blocks it.
- let last_verification = commands.iter().rposition(looks_like_verification_command);
- let evidence = last_verification.is_some();
- let red_green = last_verification.is_some_and(|last| {
- commands[..last]
- .iter()
- .any(|c| looks_like_verification_command(c) && command_failed(c))
- });
- let final_check_passed = last_verification.is_some_and(|last| {
- commands[last]["success"] == true
- && !command_failed(&commands[last])
- && !commands[last + 1..].iter().any(command_failed)
- });
- let level = if final_check_passed {
- ClaimLevel::Verified
- } else if inspected || any_cmd {
- ClaimLevel::Observed
- } else {
- ClaimLevel::ModelClaim
- };
- // Model prose never upgrades Observed → Verified.
- let level = if claims && level != ClaimLevel::Verified && !evidence {
- ClaimLevel::ModelClaim
- } else {
- level
- };
- json!({
- "claim": level,
- "verified": level == ClaimLevel::Verified,
- "model_claimed_success": claims,
- "inspected_workspace": inspected,
- "red_green": red_green && level == ClaimLevel::Verified,
- "commands": commands,
- "unverified_claim": claims && level != ClaimLevel::Verified,
- "note": if claims && level != ClaimLevel::Verified {
- "Model text is not verification. Tests, compiler, lint, diff, or a recorded command result are required."
- } else {
- "Claim level is derived from recorded tool evidence, not prose."
- }
- })
+ crate::verification::classify(model_text, commands, inspected)
}
/// When the user goal is a bug/fix, ask for a failing test first. Not applied
@@ -712,7 +1006,7 @@ pub fn bugfix_policy(task: &str) -> Option<&'static str> {
}
}
-fn looks_like_success_claim(text: &str) -> bool {
+pub(crate) fn looks_like_success_claim(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
lower.contains("tests passed")
|| lower.contains("all tests pass")
@@ -721,25 +1015,6 @@ fn looks_like_success_claim(text: &str) -> bool {
|| lower.contains("correctly implemented")
}
-fn looks_like_verification_command(command: &Value) -> bool {
- let text = command["command"]
- .as_str()
- .unwrap_or("")
- .to_ascii_lowercase();
- [
- "test",
- "pytest",
- "cargo test",
- "npm test",
- "lint",
- "clippy",
- "tsc",
- "cargo check",
- ]
- .iter()
- .any(|needle| text.contains(needle))
-}
-
pub fn parse_git_status(branch_line: &str, entries: &str) -> GitSafety {
let detached = branch_line.contains("detached") || branch_line.starts_with("## HEAD");
let rebase_or_merge = branch_line.contains("rebasing")
@@ -955,6 +1230,150 @@ pub fn narrow_verify_command(task: &str, workspace: &std::path::Path) -> Option<
mod tests {
use super::*;
+ #[test]
+ fn current_workspace_status_is_unfinished_but_explanations_are_not() {
+ for text in [
+ "I am now inspecting the files and applying the necessary fixes according to the requirements provided. Please wait while I make the changes.",
+ "I'm currently editing helpers.py.",
+ "I am now running the tests.",
+ ] {
+ assert!(promises_tool_action(text), "{text}");
+ }
+ for text in [
+ "I am now explaining the file format.",
+ "I am not modifying the files.",
+ "If approved, I am now editing helpers.py would describe the next step.",
+ "The sample says \"I am now inspecting the files.\"",
+ "I am now reporting the OS and display facts returned by system_info.",
+ "I am currently reading the answer aloud to explain the file layout.",
+ ] {
+ assert!(!promises_tool_action(text), "{text}");
+ }
+ assert!(!claims_command_execution(
+ "I'm currently editing helpers.py."
+ ));
+ assert!(claims_command_execution("I am now running the tests."));
+ }
+
+ #[test]
+ fn inspection_scope_comes_from_explicit_request_not_tool_choice() {
+ use InspectionTarget::{Host, Workspace};
+ for task in [
+ "Inspect my computer's OS and connected displays.",
+ "Inspect my computer's display configuration.",
+ "Inspect my computer running Linux 6.8 and connected displays.",
+ "Please inspect the operating system.",
+ ] {
+ assert_eq!(inspection_target(task), Some(Host), "{task}");
+ }
+ for task in [
+ "Inspect my computer OS and project files.",
+ "Inspect my computer and helpers.py.",
+ "Inspect host.rs.",
+ "Inspect my computer and /tmp/project.",
+ "Inspect the project database billing.db",
+ "Read whichever file contains the current value",
+ ] {
+ assert_eq!(inspection_target(task), Some(Workspace), "{task}");
+ }
+ assert_eq!(inspection_target("How many screens do I have?"), None);
+ // This narrow change does not add arbitrary later-clause parsing.
+ assert_eq!(
+ inspection_target("Fix helpers.py. Inspect the files."),
+ None
+ );
+ }
+
+ #[test]
+ fn tool_action_promises_catch_observed_edit_stalls_and_concrete_next_actions() {
+ for text in [
+ "I will make the necessary changes to the unique function in helpers.py and then run the tests again to verify the fix.",
+ "To fix helpers.py, I'll make the necessary changes and test them using the provided unittest command. Here's the plan: update unique. All tests passed.",
+ "I'll edit helpers.py and run the tests.",
+ "I’ll inspect `helpers.py` first.",
+ "I am going to read the source file.",
+ "I'm about to run `python3 -m unittest -q`.",
+ "Let me apply the patch to the function.",
+ "Next, I will run the test suite.",
+ "I'll create regression tests for the function.",
+ "I'll verify the build.",
+ "**I'll update the config file.**",
+ "I will make the necessary\nchanges to the function and then run the tests.",
+ "> I cannot edit this file.\n\nI'll edit helpers.py now.",
+ "```text\nI cannot edit this file.\n```\nI'll edit helpers.py now.",
+ ] {
+ assert!(promises_tool_action(text), "missed action promise: {text}");
+ }
+ }
+
+ #[test]
+ fn tool_action_promises_exclude_suggestions_deferrals_and_limitations() {
+ for text in [
+ "If you want, I can edit helpers.py.",
+ "If you want, I will edit helpers.py.",
+ "I'll edit helpers.py if you ask.",
+ "I would edit helpers.py and run tests.",
+ "I will not edit anything.",
+ "I will edit no files.",
+ "I will delete nothing from the repository.",
+ "I'll edit none of the source files.",
+ "I'll run zero tests.",
+ "I will edit exactly no files.",
+ "I will make no changes to the function.",
+ "I won't edit helpers.py.",
+ "I cannot edit files or run commands here.",
+ "I will run tests, but I do not have access to your runtime.",
+ "I will edit helpers.py after you approve.",
+ "I'll edit the file once approval is granted.",
+ "I will run tests upon your confirmation.",
+ "I will edit the file with your permission.",
+ "I will edit the file subject to approval.",
+ "Should I edit helpers.py?",
+ "I'll edit helpers.py, okay?",
+ "You should edit helpers.py and run tests.",
+ "Please run python3 -m unittest -q.",
+ "I edited helpers.py and ran the tests.",
+ "I was going to edit helpers.py, but the file was unavailable.",
+ "Earlier I said I will edit helpers.py.",
+ "The user asked me to say I'll edit the file.",
+ "I'll explain how to edit helpers.py.",
+ "I will summarize the test results.",
+ "I'll update you about the test results.",
+ "I will read your question carefully.",
+ "I'll create a comparison of the available choices.",
+ "I'll run through the tests mentally.",
+ "I'll test the idea conceptually.",
+ "All tests passed.",
+ ] {
+ assert!(!promises_tool_action(text), "misclassified prose: {text}");
+ }
+ }
+
+ #[test]
+ fn tool_action_promises_do_not_read_quoted_or_fenced_examples_as_commitments() {
+ for text in [
+ "The model said: \"I'll edit helpers.py.\"",
+ "\"I will make the necessary changes to the function.\"",
+ "'I'll edit helpers.py and run the tests.'",
+ "“I’ll run the tests.”",
+ "‘I will edit helpers.py.’",
+ "An example reply:\n\"\nI will edit helpers.py and run tests.\n\"",
+ "`I'll edit helpers.py` is an example promise.",
+ "``I will run tests`` is example code.",
+ "```text\nI'll edit helpers.py and run tests.\n```",
+ "~~~text\nI will make the necessary changes to the function.\n~~~",
+ "````text\n```\nI'll edit helpers.py.\n```\n````",
+ "> I'll edit helpers.py and run tests.",
+ "> Example reply:\nI'll edit helpers.py and run tests.\n\nThis is quoted advice.",
+ " I'll edit helpers.py and run tests.",
+ "\tI will run tests.",
+ "For example, I'll edit helpers.py and run tests.",
+ "Imagine I'll edit helpers.py and run tests.",
+ ] {
+ assert!(!promises_tool_action(text), "misclassified example: {text}");
+ }
+ }
+
#[test]
fn never_auto_replays_mutations_or_destructive_git() {
assert_eq!(replay_class("read_file"), ReplayClass::SafeToReplay);
@@ -971,6 +1390,180 @@ mod tests {
assert_eq!(runaway_action(5), RunawayAction::Pause);
}
+ #[test]
+ fn repeated_inspection_prompts_once_and_resets_after_a_write() {
+ let mut observations = ObservationLoop::default();
+ let read = json!({"path":"helpers.py"});
+ let full = json!({"hash":"first","offset":1,"truncated":false,"next_offset":null,"redacted":false});
+ assert!(!observations.record("read_file", &read, &full, true));
+ for query in ["clamp", "unique"] {
+ assert!(!observations.record(
+ "search_code",
+ &json!({"path":"helpers.py","query":query}),
+ &json!({"hits":[{"path":"helpers.py"}]}),
+ true
+ ));
+ }
+ assert!(observations.record("read_file", &read, &full, true));
+ assert!(!observations.record("read_file", &read, &full, true));
+ assert!(!observations.record("write_file", &read, &json!({}), true));
+ assert!(!observations.record(
+ "read_file",
+ &read,
+ &json!({"hash":"second","truncated":false,"next_offset":null}),
+ true
+ ));
+ }
+
+ #[test]
+ fn prior_file_coverage_detects_recorded_qwen_searches_without_a_path() {
+ let mut observations = ObservationLoop::default();
+ for path in ["helpers.py", "test_helpers.py"] {
+ assert!(!observations.record("read_file", &json!({"path":path}),
+ &json!({"hash":path,"offset":1,"truncated":false,"next_offset":null,"redacted":false}), true));
+ }
+ let searches = [
+ json!({"path":"helpers.py","query":"clamp"}),
+ json!({"query":"unique"}),
+ json!({"query":"chunks"}),
+ json!({"query":"clamp"}),
+ json!({"query":"unique"}),
+ json!({"query":"chunks"}),
+ ];
+ let output =
+ json!({"ok":true,"count":2,"hits":[{"path":"helpers.py"},{"path":"test_helpers.py"}]});
+ let original = output.clone();
+ let warnings: Vec<_> = searches
+ .iter()
+ .enumerate()
+ .filter_map(|(index, args)| {
+ observations
+ .record("search_code", args, &output, true)
+ .then_some(index)
+ })
+ .collect();
+ assert_eq!(
+ warnings,
+ vec![2],
+ "advice appears once after three covered searches"
+ );
+ assert_eq!(
+ output, original,
+ "the advisory never hides or rewrites evidence"
+ );
+ }
+
+ #[test]
+ fn new_empty_or_unknown_search_results_do_not_imply_redundant_inspection() {
+ for output in [
+ json!({"hits":[]}),
+ json!({}),
+ json!({"hits":[{}]}),
+ json!({"hits":[{"path":"helpers.py"},{"path":"new.py"}]}),
+ json!({"hits":[{"path":"new.py"}]}),
+ ] {
+ let mut observations = ObservationLoop::default();
+ observations.record("read_file", &json!({"path":"helpers.py"}),
+ &json!({"hash":"h","offset":1,"truncated":false,"next_offset":null,"redacted":false}), true);
+ for _ in 0..6 {
+ assert!(
+ !observations.record("search_code", &json!({"query":"name"}), &output, true),
+ "{output}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn incomplete_or_redacted_read_is_not_full_file_coverage() {
+ for output in [
+ json!({"hash":"h","offset":8,"truncated":false,"next_offset":null,"redacted":false}),
+ json!({"hash":"h","truncated":false,"next_offset":null,"redacted":false}),
+ json!({"hash":"h","offset":1,"truncated":true,"next_offset":null,"redacted":false}),
+ json!({"hash":"h","offset":1,"truncated":false,"next_offset":8,"redacted":false}),
+ json!({"hash":"h","offset":1,"truncated":false,"next_offset":null,"redacted":true}),
+ ] {
+ let mut observations = ObservationLoop::default();
+ observations.record("read_file", &json!({"path":"helpers.py"}), &output, true);
+ for _ in 0..6 {
+ assert!(
+ !observations.record(
+ "search_code",
+ &json!({"path":"helpers.py","query":"name"}),
+ &json!({"hits":[{"path":"helpers.py"}]}),
+ true
+ ),
+ "{output}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn visible_tool_message_and_failed_mutations_bound_historical_coverage() {
+ use crate::tools::ToolResult;
+ let args = json!({"path":"helpers.py"});
+ let full = ToolResult {
+ id: "read".into(),
+ execution: None,
+ success: true,
+ output: json!({"hash":"h","offset":1,"truncated":false,"next_offset":null,"redacted":false,"content":"x".repeat(1000)}),
+ error: String::new(),
+ };
+ for limit in [80, 8000] {
+ let mut observations = ObservationLoop::default();
+ let message = full.message("read_file", limit);
+ observations.record_message("read_file", &args, &message);
+ let mut warnings = 0;
+ for _ in 0..3 {
+ warnings += usize::from(observations.record(
+ "search_code",
+ &json!({"query":"x"}),
+ &json!({"hits":[{"path":"helpers.py"}]}),
+ true,
+ ));
+ }
+ assert_eq!(warnings, usize::from(limit == 8000));
+ }
+ for operation in ["write_file", "edit_file", "exec"] {
+ let mut observations = ObservationLoop::default();
+ observations.record_message("read_file", &args, &full.message("read_file", 8000));
+ observations.record(operation, &args, &json!({}), false);
+ for _ in 0..6 {
+ assert!(!observations.record(
+ "search_code",
+ &args,
+ &json!({"hits":[{"path":"helpers.py"}]}),
+ true
+ ));
+ }
+ }
+ }
+
+ #[test]
+ fn explicit_path_cannot_override_unknown_or_truncated_search_hits() {
+ for output in [
+ json!({"hits":[]}),
+ json!({"hits":[{}]}),
+ json!({"hits":[{"path":"new.py"}]}),
+ json!({"truncated":true,"hits":[{"path":"helpers.py"}]}),
+ json!({"ok":false,"hits":[{"path":"helpers.py"}]}),
+ json!({"ok":"true","hits":[{"path":"helpers.py"}]}),
+ json!({"truncated":"false","hits":[{"path":"helpers.py"}]}),
+ ] {
+ let mut observations = ObservationLoop::default();
+ let args = json!({"path":"helpers.py"});
+ observations.record("read_file", &args,
+ &json!({"hash":"h","offset":1,"truncated":false,"next_offset":null,"redacted":false}), true);
+ for _ in 0..6 {
+ assert!(
+ !observations.record("search_code", &args, &output, true),
+ "{output}"
+ );
+ }
+ }
+ }
+
#[test]
fn thinking_channels_are_stripped_but_answers_remain() {
let raw = "thought I'll set all three monitors to green.\n\nAll three displays are solid green.";
@@ -998,21 +1591,92 @@ mod tests {
#[test]
fn prose_command_claims_are_detected() {
- assert!(claims_command_execution(
- "Actually, I'll do: xpaper -bg green on each monitor"
- ));
- assert!(claims_command_execution(
- "I'll run xset root solid green now."
- ));
- assert!(claims_command_execution(
- "Running `feh --bg-fill green.png`."
- ));
- assert!(!claims_command_execution(
- "You can run cargo test after reviewing the diff."
- ));
- assert!(!claims_command_execution(
- "Updated README with install steps."
- ));
+ for text in [
+ "Actually, I'll do: xpaper -bg green on each monitor",
+ "Actually, I'll do: xset root solid green",
+ "I'll run xset root solid green now.",
+ "I'll run custom-tool --check.",
+ "I'll execute ./scripts/check.sh now.",
+ "I am going to run cargo test.",
+ "Let me run the tests.",
+ "Running `feh --bg-fill green.png`.",
+ "I’m executing `cargo test` now.",
+ "Issuing `xset root solid green`.",
+ ] {
+ assert!(
+ claims_command_execution(text),
+ "missed command claim: {text}"
+ );
+ assert!(
+ promises_tool_action(text),
+ "command absent from shared action detector: {text}"
+ );
+ }
+ assert!(!claims_command_execution("I'll edit helpers.py now."));
+ assert!(promises_tool_action("I'll edit helpers.py now."));
+ }
+
+ #[test]
+ fn prose_commands_share_example_conditional_and_negative_exclusions() {
+ for text in [
+ "\"I'll run xset root solid green.\"",
+ "‘Actually, I’ll do: xset root solid green.’",
+ "The model said:\n\"Running `cargo test`.\"",
+ "`I'll run xset root solid green` is an example response.",
+ "```text\nRunning `xset root solid green`.\n```",
+ "> Actually, I'll do: xset root solid green",
+ " I'll run xset root solid green.",
+ "For example, I'll run xset root solid green.",
+ "If you want, I'll run xset root solid green.",
+ "I'll run xset root solid green if you ask.",
+ "I'll execute `cargo test` after you approve.",
+ "I'll do: xset root solid green once approval is granted.",
+ "I will not run xset root solid green.",
+ "I won't execute `cargo test`.",
+ "I'll run no tests.",
+ "I'll issue zero commands.",
+ "I'll do: no commands.",
+ "I'll do: nothing with `xset root solid green`.",
+ "I will run tests, but I do not have access to your runtime.",
+ "Should I run `cargo test`?",
+ "I'll run xset root solid green, okay?",
+ ] {
+ assert!(
+ !claims_command_execution(text),
+ "misclassified command prose: {text}"
+ );
+ assert!(
+ !promises_tool_action(text),
+ "misclassified shared action prose: {text}"
+ );
+ }
+ }
+
+ #[test]
+ fn prose_commands_do_not_treat_general_advice_or_reports_as_running_tools() {
+ for text in [
+ "Running tests is useful.",
+ "Running `cargo test` is useful.",
+ "Executing commands requires care.",
+ "Running `xset root solid green` can change your desktop.",
+ "I recommend running `cargo test`.",
+ "You can run cargo test after reviewing the diff.",
+ "Earlier I said I'll run xset root solid green.",
+ "I ran `cargo test` successfully.",
+ "Updated README with install steps.",
+ "I'll run a marathon.",
+ "I'll run you through the test results.",
+ "I'll do: a summary of the test results.",
+ ] {
+ assert!(
+ !claims_command_execution(text),
+ "misclassified command prose: {text}"
+ );
+ assert!(
+ !promises_tool_action(text),
+ "misclassified shared action prose: {text}"
+ );
+ }
}
#[test]
@@ -1038,6 +1702,14 @@ mod tests {
assert_eq!(unlimited.max_steps, 64);
}
+ fn check_receipt(command: &str, success: bool) -> Value {
+ json!({"schema_version":1,"task_id":"task","attempt_id":"attempt","tool_call_id":"call","check_id":crate::workspace::hash(command.as_bytes()),
+ "workspace":"/project","cwd":"/project","command":command,"kind":"configured_check",
+ "state":if success {"passed"}else{"failed"},"provenance":"locally_observed","scope":"configured check",
+ "started_at":1.0,"finished_at":2.0,"exit_code":if success{0}else{1},"termination_reason":"exited",
+ "workspace_fingerprint":"content-hash","output_ref":"tool.completed:call","success":success,"timed_out":false})
+ }
+
#[test]
fn model_prose_is_not_verification() {
let report = classify_verification("All tests passed.", &[], false);
@@ -1050,11 +1722,8 @@ mod tests {
true,
);
assert_eq!(observed["claim"], "observed");
- let verified = classify_verification(
- "Done",
- &[json!({"command":"cargo test --offline","success":true})],
- true,
- );
+ let verified =
+ classify_verification("Done", &[check_receipt("cargo test --offline", true)], true);
assert_eq!(verified["claim"], "verified");
assert_eq!(verified["verified"], true);
}
@@ -1066,8 +1735,8 @@ mod tests {
let red_green = classify_verification(
"Fixed",
&[
- json!({"command":"cargo test -p demo","success":false}),
- json!({"command":"cargo test -p demo","success":true}),
+ check_receipt("cargo test -p demo", false),
+ check_receipt("cargo test -p demo", true),
],
true,
);
@@ -1077,8 +1746,8 @@ mod tests {
let regressed = classify_verification(
"All tests passed",
&[
- json!({"command":"cargo test","success":true}),
- json!({"command":"cargo test","success":false}),
+ check_receipt("cargo test", true),
+ check_receipt("cargo test", false),
],
true,
);
@@ -1088,7 +1757,7 @@ mod tests {
let later_failure = classify_verification(
"Done",
&[
- json!({"command":"npm test","success":true}),
+ check_receipt("npm test", true),
json!({"command":"node scripts/broken.js","success":false}),
],
true,
@@ -1097,7 +1766,11 @@ mod tests {
// A timed-out final check is not a passing check.
let timed_out = classify_verification(
"Done",
- &[json!({"command":"pytest","success":true,"timed_out":true})],
+ &[{
+ let mut receipt = check_receipt("pytest", true);
+ receipt["timed_out"] = json!(true);
+ receipt
+ }],
true,
);
assert_eq!(timed_out["verified"], false);
diff --git a/native/core/src/background.rs b/native/core/src/background.rs
index c3b2c390..20da7773 100644
--- a/native/core/src/background.rs
+++ b/native/core/src/background.rs
@@ -104,7 +104,7 @@ impl Drop for IdleWorkspace {
}
impl BackgroundManager {
pub(crate) fn reserve_idle_workspace(self: &Arc, path: &Path) -> Result {
- let path = path.canonicalize()?;
+ let path = crate::workspace::reservation_path(path)?;
let mut state = self
.state
.lock()
diff --git a/native/core/src/cli_agent/acp.rs b/native/core/src/cli_agent/acp.rs
index 2aa5204a..3033ade2 100644
--- a/native/core/src/cli_agent/acp.rs
+++ b/native/core/src/cli_agent/acp.rs
@@ -16,9 +16,82 @@ use super::{
};
use anyhow::{bail, Result};
use serde_json::{json, Value};
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
const OUTPUT_PREVIEW: usize = 8000;
+const TOOL_METADATA_COUNT: usize = 128;
+const TOOL_METADATA_BYTES: usize = 64_000;
+const RAW_OUTPUT_BYTES: usize = 8000;
+const OBSERVED_TOOL_IDS: usize = 4096;
+
+#[derive(Clone)]
+struct RawToolOutput {
+ value: Value,
+ truncated: bool,
+ cursor_exit_code: Option,
+}
+
+impl RawToolOutput {
+ fn capture(value: &Value) -> Self {
+ // Redact before clipping, including known credential keys: clipping
+ // first could leave a token prefix that no longer matches redaction.
+ // Installed Cursor ACP exposes shell success AND failure as this
+ // exact shape, with status "completed" in both cases. Capture the
+ // integer before clipping so a large failing output cannot turn green.
+ let cursor_exit_code = value.as_object().filter(|o| o.len() == 3).and_then(|o| {
+ o.get("stdout")?.as_str()?;
+ o.get("stderr")?.as_str()?;
+ o.get("exitCode")?.as_i64()
+ });
+ let mut value = value.clone();
+ crate::redaction::redact_known_secrets(&mut value);
+ let value = redact_value(value);
+ let encoded = value.to_string();
+ if encoded.len() <= RAW_OUTPUT_BYTES {
+ return Self {
+ value,
+ truncated: false,
+ cursor_exit_code,
+ };
+ }
+ // Oversize JSON becomes explicitly labeled JSON text, not a partial
+ // structured result. Each source byte needs at most two bytes when
+ // this already-escaped JSON prefix is serialized as a JSON string.
+ let mut end = (RAW_OUTPUT_BYTES - 2) / 2;
+ while !encoded.is_char_boundary(end) {
+ end -= 1;
+ }
+ Self {
+ value: Value::String(encoded[..end].to_owned()),
+ truncated: true,
+ cursor_exit_code,
+ }
+ }
+}
+
+// An approval displays proposed changes as well as typed input. Bind those
+// bytes/targets for non-execute operations, including generic tool kinds.
+// Ordinary shell text is diagnostic/output (Cursor retains its pre-approval
+// allowlist reason); it must not turn a later shell result into a new command.
+// A shell's explicit diff proposal is still approval-relevant if present.
+fn operation_identity(tool: &Value) -> Value {
+ let proposal = if tool["kind"] == "execute" {
+ let diffs: Vec<_> = tool["content"]
+ .as_array()
+ .into_iter()
+ .flatten()
+ .filter(|block| block["type"] == "diff")
+ .collect();
+ if diffs.is_empty() {
+ Value::Null
+ } else {
+ json!({"content":diffs,"locations":tool["locations"]})
+ }
+ } else {
+ json!({"content":tool["content"],"locations":tool["locations"]})
+ };
+ json!([tool["kind"], tool["rawInput"], proposal])
+}
/// Shown when the Antigravity server has no valid Google sign-in.
pub const ANTIGRAVITY_SIGN_IN: &str = "Antigravity isn't signed in, or its sign-in expired. Choose Connect for Antigravity in Settings › Accounts.";
@@ -36,9 +109,105 @@ fn error(id: &Value, code: i64, message: &str) -> String {
json!({"jsonrpc":"2.0","id":id,"error":{"code":code,"message":message}}).to_string()
}
+#[derive(Clone, Copy, PartialEq)]
+enum ToolOrigin {
+ Initial,
+ Update,
+ Permission,
+}
+impl ToolOrigin {
+ fn label(self) -> &'static str {
+ match self {
+ Self::Initial => "tool_call",
+ Self::Update => "tool_update",
+ Self::Permission => "permission_request",
+ }
+ }
+}
+#[derive(Clone)]
+struct FieldOrigin {
+ source: ToolOrigin,
+ phase: Option,
+ revision: u64,
+ explicit_terminal: bool,
+}
+#[derive(Clone)]
+struct PermissionBinding {
+ tool_id: String,
+ generation: u64,
+ operation_revision: u64,
+ request_revision: u64,
+}
+#[derive(Clone)]
+struct PermissionDecision {
+ binding: PermissionBinding,
+ kind: &'static str,
+ revision: u64,
+}
+#[derive(Clone)]
+struct ToolEvidence {
+ // Only an explicit initial call starts complete history. Eviction must
+ // never make an earlier denied permission look like no permission asked.
+ history_complete: bool,
+ generation: u64,
+ revision: u64,
+ operation_hash: Option,
+ operation_revision: u64,
+ permission_revision: Option,
+ permission_usable: bool,
+ content: Option,
+ raw_output: Option,
+ decision: Option,
+}
+impl ToolEvidence {
+ fn matches(&self, binding: &PermissionBinding) -> bool {
+ self.operation_hash.is_some()
+ && self.generation == binding.generation
+ && self.operation_revision == binding.operation_revision
+ && self.permission_revision == Some(binding.request_revision)
+ }
+ fn json(&self) -> Value {
+ let decision = self.decision.as_ref();
+ let field = |origin: &Option| {
+ origin.as_ref().map(|origin| json!({
+ "source": origin.source.label(), "phase": origin.phase,
+ "explicit_terminal": origin.explicit_terminal && origin.revision == self.revision,
+ "before_permission_decision": decision.is_some_and(|d| origin.revision < d.revision),
+ "after_permission_decision": decision.is_some_and(|d| origin.revision > d.revision),
+ }))
+ };
+ json!({
+ "schema_version": 1,
+ "history_complete": self.history_complete,
+ "content": field(&self.content),
+ "raw_output": field(&self.raw_output),
+ "permission": decision.map(|d| json!({
+ "state": "resolved", "decision": d.kind,
+ "current_operation_matches": self.matches(&d.binding),
+ })).or_else(|| self.permission_revision.map(|_| json!({
+ "state": if self.permission_usable { "pending" } else { "declined" },
+ "decision": if self.permission_usable { Value::Null } else { json!("unusable_options") },
+ "current_operation_matches": false,
+ }))),
+ })
+ }
+}
+// This typed sidecar is created only by the adapter; protocol JSON cannot
+// supply provenance, execution status, or permission-correlation flags.
+struct RememberedTool {
+ metadata: Value,
+ raw_output: Option,
+ evidence: Option,
+}
+struct PermissionChoice {
+ id: String,
+ kind: &'static str,
+}
struct PendingPermission {
- allow: Option,
- reject: Option,
+ allow: Option,
+ reject: Option,
+ binding: Option,
+ require_binding: bool,
}
#[derive(Debug, Default, PartialEq)]
@@ -63,6 +232,24 @@ pub struct AcpAdapter {
pending_prompt: Option<(String, Vec)>,
pending_permissions: HashMap,
tool_names: HashMap,
+ // ACP permission requests are partial tool updates. Keep a bounded view
+ // of current pending tools so omitted input cannot erase the operation
+ // the user is being asked to approve.
+ tool_metadata: HashMap,
+ // Kept separately: a large result must not evict approval metadata.
+ // At most 128 values of 8,000 serialized bytes; IDs have the existing
+ // metadata byte ceiling. These are payload bounds, not heap estimates.
+ tool_outputs: HashMap,
+ // Small provenance sidecars share the metadata count and ID bounds.
+ // Decisions retain the bounded call ID; revisions distinguish a changed-
+ // then-restored operation. No input or result payload is duplicated.
+ tool_evidence: HashMap,
+ evidence_revision: u64,
+ // Hashed IDs are bounded independently of live metadata. A permission-
+ // first or evicted ID must never become a permission-free initial call.
+ // Once full, new histories remain incomplete for the rest of the prompt.
+ observed_tool_ids: HashSet,
+ tool_history_exhausted: bool,
options: Option,
prompt_active: bool,
/// A model switch was sent and not yet answered. Prompts wait for it:
@@ -71,6 +258,8 @@ pub struct AcpAdapter {
switching: bool,
/// The agent accepts HTTP MCP servers (`mcpCapabilities.http`).
mcp_http: bool,
+ /// Negotiated per process, never inferred from a vendor name or cache.
+ images_supported: bool,
}
impl AcpAdapter {
pub fn new(vendor: Vendor) -> Self {
@@ -88,12 +277,25 @@ impl AcpAdapter {
pending_prompt: None,
pending_permissions: HashMap::new(),
tool_names: HashMap::new(),
+ tool_metadata: HashMap::new(),
+ tool_outputs: HashMap::new(),
+ tool_evidence: HashMap::new(),
+ evidence_revision: 0,
+ observed_tool_ids: HashSet::new(),
+ tool_history_exhausted: false,
options: None,
prompt_active: false,
switching: false,
mcp_http: false,
+ images_supported: false,
}
}
+ fn check_images(&self, images: &[PromptImage]) -> Result<()> {
+ if !images.is_empty() && !self.images_supported {
+ bail!("{} runtime did not advertise image support. Remove the attachment or explicitly choose an image-capable model. No image prompt was sent.", self.vendor.product_label());
+ }
+ Ok(())
+ }
/// The project's enabled MCP servers in ACP form.
fn mcp_servers(&self) -> Value {
json!(self
@@ -111,12 +313,20 @@ impl AcpAdapter {
self.next_id
}
fn start_prompt(&mut self, text: &str, images: &[PromptImage]) -> Result> {
+ self.check_images(images)?;
let Some(session) = self.session_id.clone() else {
bail!("ACP session is not ready")
};
let id = self.id();
self.prompt_id = Some(id);
self.prompt_active = true;
+ self.tool_outputs.clear();
+ self.tool_evidence.clear();
+ self.observed_tool_ids.clear();
+ self.tool_history_exhausted = false;
+ self.tool_metadata.clear();
+ self.tool_names.clear();
+ self.pending_permissions.clear();
let mut prompt = vec![json!({"type":"text","text":text})];
for image in images {
prompt.push(json!({
@@ -277,6 +487,11 @@ impl AcpAdapter {
}
if Some(id) == self.prompt_id {
self.prompt_active = false;
+ self.tool_outputs.clear();
+ self.tool_evidence.clear();
+ self.tool_metadata.clear();
+ self.tool_names.clear();
+ self.pending_permissions.clear();
return Ok(Step::update(Update::TurnFailed(format!(
"{} could not run the prompt: {text}",
self.vendor.id()
@@ -289,6 +504,16 @@ impl AcpAdapter {
}
let res = &message["result"];
if Some(id) == self.init_id {
+ if let Err(error) = super::acp_probe::require_protocol_v1(res) {
+ bail!(
+ "{} runtime returned an {error}",
+ self.vendor.product_label()
+ );
+ }
+ self.images_supported = res["agentCapabilities"]["promptCapabilities"]["image"] == true;
+ if let Some((_, images)) = &self.pending_prompt {
+ self.check_images(images)?;
+ }
self.phase = Phase::Initialized;
self.mcp_http = res["agentCapabilities"]["mcpCapabilities"]["http"] == true;
// Documented Cursor flow: `authenticate {methodId:"cursor_login"}`
@@ -350,6 +575,11 @@ impl AcpAdapter {
}
if Some(id) == self.prompt_id {
self.prompt_active = false;
+ self.tool_outputs.clear();
+ self.tool_evidence.clear();
+ self.tool_metadata.clear();
+ self.tool_names.clear();
+ self.pending_permissions.clear();
let stop = res["stopReason"].as_str().unwrap_or("end_turn");
return Ok(match stop {
"cancelled" => Step::update(Update::TurnCompleted {
@@ -381,6 +611,138 @@ impl AcpAdapter {
}
Ok(Step::default())
}
+ fn evidence_id(&mut self) -> u64 {
+ self.evidence_revision = self.evidence_revision.saturating_add(1);
+ self.evidence_revision
+ }
+ fn remember_tool(&mut self, update: &Value, source: ToolOrigin) -> RememberedTool {
+ let initial = source == ToolOrigin::Initial;
+ let id = update["toolCallId"].as_str().filter(|id| !id.is_empty());
+ let first_observation = id.is_some_and(|id| {
+ let digest = crate::workspace::hash(id.as_bytes());
+ if self.observed_tool_ids.contains(&digest) {
+ false
+ } else if self.observed_tool_ids.len() < OBSERVED_TOOL_IDS {
+ self.observed_tool_ids.insert(digest);
+ !self.tool_history_exhausted
+ } else {
+ self.tool_history_exhausted = true;
+ false
+ }
+ });
+ let mut current = if initial {
+ json!({"toolCallId":id})
+ } else {
+ id.and_then(|id| self.tool_metadata.get(id))
+ .cloned()
+ .unwrap_or_else(|| json!({"toolCallId":id}))
+ };
+ // ToolCallUpdate omission/null leaves previous metadata unchanged
+ // (ACP v1). Explicit empty values replace it. Only protocol fields
+ // enter this view: a peer cannot forge our sidecar or output flags.
+ for key in [
+ "kind",
+ "title",
+ "rawInput",
+ "locations",
+ "content",
+ "status",
+ ] {
+ if let Some(value) = update.get(key).filter(|v| !v.is_null()) {
+ current[key] = value.clone();
+ }
+ }
+ let retained = id.is_some_and(|id| {
+ serde_json::to_vec(¤t).is_ok_and(|bytes| bytes.len() <= TOOL_METADATA_BYTES)
+ && (self.tool_metadata.contains_key(id)
+ || self.tool_metadata.len() < TOOL_METADATA_COUNT)
+ });
+ let revision = self.evidence_id();
+ let mut evidence = if retained {
+ let hash = (!current["rawInput"].is_null() && current["kind"].is_string()).then(|| {
+ crate::workspace::hash(operation_identity(¤t).to_string().as_bytes())
+ });
+ let mut evidence = (!initial)
+ .then(|| id.and_then(|id| self.tool_evidence.get(id)).cloned())
+ .flatten()
+ .unwrap_or(ToolEvidence {
+ history_complete: initial && first_observation,
+ generation: revision,
+ revision,
+ operation_hash: None,
+ operation_revision: revision,
+ permission_revision: None,
+ permission_usable: true,
+ content: None,
+ raw_output: None,
+ decision: None,
+ });
+ if evidence.operation_hash != hash {
+ evidence.operation_revision = revision;
+ evidence.operation_hash = hash;
+ }
+ evidence.revision = revision;
+ let origin = FieldOrigin {
+ source,
+ revision,
+ explicit_terminal: source != ToolOrigin::Permission
+ && matches!(update["status"].as_str(), Some("completed" | "failed")),
+ // Bound this host-retained string; arbitrary status text is
+ // never meaningful provenance for a command result.
+ phase: current["status"]
+ .as_str()
+ .filter(|s| matches!(*s, "pending" | "in_progress" | "completed" | "failed"))
+ .map(str::to_owned),
+ };
+ if update.get("content").is_some_and(|v| !v.is_null()) {
+ evidence.content = Some(origin.clone());
+ }
+ if update.get("rawOutput").is_some_and(|v| !v.is_null()) {
+ evidence.raw_output = Some(origin);
+ }
+ if source == ToolOrigin::Permission {
+ evidence.permission_revision = Some(revision);
+ evidence.permission_usable = true;
+ evidence.decision = None;
+ }
+ Some(evidence)
+ } else {
+ None
+ };
+ if let Some(id) = id {
+ if retained {
+ self.tool_metadata.insert(id.to_owned(), current.clone());
+ self.tool_evidence
+ .insert(id.to_owned(), evidence.as_ref().unwrap().clone());
+ } else {
+ // Never recover obsolete input/provenance after eviction.
+ self.tool_metadata.remove(id);
+ self.tool_evidence.remove(id);
+ evidence = None;
+ }
+ if initial {
+ self.tool_outputs.remove(id);
+ }
+ }
+ let output = update
+ .get("rawOutput")
+ .filter(|v| !v.is_null())
+ .map(RawToolOutput::capture)
+ .or_else(|| id.and_then(|id| self.tool_outputs.get(id)).cloned());
+ if let (Some(id), Some(output)) = (id, &output) {
+ if id.len() <= TOOL_METADATA_BYTES
+ && (self.tool_outputs.contains_key(id)
+ || self.tool_outputs.len() < TOOL_METADATA_COUNT)
+ {
+ self.tool_outputs.insert(id.to_owned(), output.clone());
+ }
+ }
+ RememberedTool {
+ metadata: current,
+ raw_output: output,
+ evidence,
+ }
+ }
fn session_update(&mut self, update: &Value) -> Step {
match update["sessionUpdate"].as_str().unwrap_or("") {
"agent_message_chunk" => match update["content"]["text"].as_str() {
@@ -388,6 +750,8 @@ impl AcpAdapter {
_ => Step::default(),
},
"tool_call" => {
+ let remembered = self.remember_tool(update, ToolOrigin::Initial);
+ let update = &remembered.metadata;
let id = update["toolCallId"].as_str().unwrap_or("").to_owned();
let name = format!(
"{}.{}",
@@ -408,19 +772,23 @@ impl AcpAdapter {
update["status"].as_str(),
Some("completed") | Some("failed")
) {
- step = step.merge(self.tool_finished(&id, &name, update));
+ step = step.merge(self.tool_finished(&id, &name, &remembered));
}
step
}
"tool_call_update" => {
+ let remembered = self.remember_tool(update, ToolOrigin::Update);
+ let update = &remembered.metadata;
let id = update["toolCallId"].as_str().unwrap_or("").to_owned();
- let name = self
- .tool_names
- .get(&id)
- .cloned()
+ let name = update["kind"]
+ .as_str()
+ .map(|kind| format!("{}.{kind}", self.vendor.id()))
+ .or_else(|| self.tool_names.get(&id).cloned())
.unwrap_or_else(|| format!("{}.tool", self.vendor.id()));
match update["status"].as_str() {
- Some("completed") | Some("failed") => self.tool_finished(&id, &name, update),
+ Some("completed") | Some("failed") => {
+ self.tool_finished(&id, &name, &remembered)
+ }
_ => Step::default(),
}
}
@@ -440,8 +808,22 @@ impl AcpAdapter {
_ => Step::default(),
}
}
- fn tool_finished(&mut self, id: &str, name: &str, update: &Value) -> Step {
- let success = update["status"].as_str() == Some("completed");
+ fn tool_finished(&mut self, id: &str, name: &str, remembered: &RememberedTool) -> Step {
+ let update = &remembered.metadata;
+ self.tool_metadata.remove(id);
+ self.tool_outputs.remove(id);
+ self.tool_evidence.remove(id);
+ self.tool_names.remove(id);
+ let cursor_exit = (self.vendor == Vendor::Cursor && name == "cursor.execute")
+ .then(|| {
+ remembered
+ .raw_output
+ .as_ref()
+ .and_then(|o| o.cursor_exit_code)
+ })
+ .flatten();
+ let success = update["status"].as_str() == Some("completed")
+ && cursor_exit.is_none_or(|code| code == 0);
let paths: Vec = update["locations"]
.as_array()
.into_iter()
@@ -458,12 +840,29 @@ impl AcpAdapter {
}
}
}
- let output = redact_value(json!({
+ let mut output = redact_value(json!({
"status": update["status"],
+ "input": update["rawInput"],
+ "tool_kind": update["kind"],
"title": update["title"],
"locations": paths,
"content": content,
}));
+ if let Some(evidence) = &remembered.evidence {
+ output["acp_provenance"] = evidence.json();
+ }
+ if let Some(code) = cursor_exit {
+ output["cursor_execution"] = json!({"exit_code":code});
+ }
+ if let Some(captured) = &remembered.raw_output {
+ output["raw_output"] = captured.value.clone();
+ output["raw_output_truncated"] = json!(captured.truncated);
+ output["raw_output_format"] = json!(if captured.truncated {
+ "json_text_preview"
+ } else {
+ "json"
+ });
+ }
let mut step = Step::update(Update::ToolCompleted {
id: id.to_owned(),
name: name.to_owned(),
@@ -480,6 +879,23 @@ impl AcpAdapter {
step
}
fn permission_request(&mut self, id: &Value, params: &Value) -> Step {
+ let key = id.to_string();
+ if self.pending_permissions.remove(&key).is_some() {
+ // The outstanding user decision belongs to the original request.
+ // A malformed/question duplicate may not emit Approval at all, so
+ // reject it here before any early return or replacement binding.
+ return Step {
+ send: vec![error(
+ id,
+ -32600,
+ "Duplicate outstanding permission request ID",
+ )],
+ updates: vec![Update::TurnFailed(format!(
+ "{} reused an outstanding permission request ID; no approval was granted",
+ self.vendor.id()
+ ))],
+ };
+ }
// Antigravity sends questions for the user through the permission
// method with an `interaction_` tool call id; their options are
// answers, not approvals. ShadowCode can't show them yet.
@@ -497,20 +913,49 @@ impl AcpAdapter {
))],
};
}
- let key = id.to_string();
+ // Record even an unusable request: declining malformed options must
+ // not later look as if the call never required permission.
+ let remembered = self.remember_tool(¶ms["toolCall"], ToolOrigin::Permission);
let mut allow = None;
let mut reject = None;
for option in params["options"].as_array().into_iter().flatten() {
- let option_id = option["optionId"].as_str().map(str::to_owned);
+ let Some(option_id) = option["optionId"].as_str() else {
+ continue;
+ };
match option["kind"].as_str() {
- Some("allow_once") => allow = option_id.or(allow),
- Some("allow_always") if allow.is_none() => allow = option_id,
- Some("reject_once") => reject = option_id.or(reject),
- Some("reject_always") if reject.is_none() => reject = option_id,
+ Some("allow_once") => {
+ allow = Some(PermissionChoice {
+ id: option_id.into(),
+ kind: "allow_once",
+ })
+ }
+ Some("allow_always") if allow.is_none() => {
+ allow = Some(PermissionChoice {
+ id: option_id.into(),
+ kind: "allow_always",
+ })
+ }
+ Some("reject_once") => {
+ reject = Some(PermissionChoice {
+ id: option_id.into(),
+ kind: "reject_once",
+ })
+ }
+ Some("reject_always") if reject.is_none() => {
+ reject = Some(PermissionChoice {
+ id: option_id.into(),
+ kind: "reject_always",
+ })
+ }
_ => {}
}
}
if allow.is_none() && reject.is_none() {
+ if let Some(tool_id) = params["toolCall"]["toolCallId"].as_str() {
+ if let Some(evidence) = self.tool_evidence.get_mut(tool_id) {
+ evidence.permission_usable = false;
+ }
+ }
return Step {
send: vec![error(
id,
@@ -522,17 +967,41 @@ impl AcpAdapter {
)],
};
}
- self.pending_permissions
- .insert(key.clone(), PendingPermission { allow, reject });
- let tool = ¶ms["toolCall"];
+ let tool = &remembered.metadata;
let title = tool["title"].as_str().unwrap_or("tool call");
let kind = tool["kind"].as_str().unwrap_or("tool");
+ let binding = remembered
+ .evidence
+ .as_ref()
+ .filter(|e| e.operation_hash.is_some())
+ .map(|e| PermissionBinding {
+ tool_id: tool["toolCallId"].as_str().unwrap_or_default().to_owned(),
+ generation: e.generation,
+ operation_revision: e.operation_revision,
+ request_revision: e.revision,
+ });
+ self.pending_permissions.insert(
+ key.clone(),
+ PendingPermission {
+ allow,
+ reject,
+ binding,
+ // Cursor approvals must refer to a retained typed operation.
+ // Requiring this for every kind prevents a generic/read call
+ // from changing into execute while its approval is open.
+ require_binding: self.vendor == Vendor::Cursor,
+ },
+ );
let command = tool["rawInput"]["command"]
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| title.to_owned());
Step::update(Update::Approval(ApprovalPrompt {
request_id: key,
+ tool_identity: tool["toolCallId"]
+ .as_str()
+ .filter(|id| !id.is_empty())
+ .map(super::approval_tool_identity),
kind: match kind {
"execute" => "command".into(),
"edit" | "delete" | "move" => "file_change".into(),
@@ -585,6 +1054,7 @@ impl CliAdapter for AcpAdapter {
}
fn on_start(&mut self, options: &LaunchOptions) -> Vec {
self.options = Some(options.clone());
+ self.images_supported = false;
let id = self.id();
self.init_id = Some(id);
vec![request(
@@ -631,6 +1101,24 @@ impl CliAdapter for AcpAdapter {
}
let method = message["method"].as_str();
let id = message.get("id").filter(|id| !id.is_null());
+ if matches!(
+ method,
+ Some("session/update" | "session/request_permission")
+ ) && self
+ .session_id
+ .as_deref()
+ .zip(message["params"]["sessionId"].as_str())
+ .is_some_and(|(expected, received)| expected != received)
+ {
+ return Ok(Step {
+ send: id
+ .map(|id| vec![error(id, -32602, "Unexpected ACP session")])
+ .unwrap_or_default(),
+ updates: vec![Update::Warning(
+ "Ignored a tool/permission update for a different ACP session".into(),
+ )],
+ });
+ }
match (method, id) {
(Some("session/request_permission"), Some(id)) => {
Ok(self.permission_request(id, &message["params"]))
@@ -677,18 +1165,43 @@ impl CliAdapter for AcpAdapter {
};
let id: Value = serde_json::from_str(request_id)
.unwrap_or_else(|_| Value::String(request_id.to_owned()));
- let choice = if approve {
+ let current = pending
+ .binding
+ .as_ref()
+ .and_then(|b| self.tool_evidence.get(&b.tool_id));
+ let matches = pending
+ .binding
+ .as_ref()
+ .zip(current)
+ .is_some_and(|(b, e)| e.matches(b));
+ let choice = if approve && pending.require_binding && !matches {
+ // Approval covered the displayed operation, never a new command,
+ // repeated call ID, evicted input, or a newer permission request.
+ None
+ } else if approve {
pending.allow
} else {
pending.reject
};
+ let revision = self.evidence_id();
+ if let Some(binding) = pending.binding {
+ if let Some(evidence) = self.tool_evidence.get_mut(&binding.tool_id) {
+ if evidence.generation == binding.generation
+ && evidence.permission_revision == Some(binding.request_revision)
+ {
+ evidence.decision = Some(PermissionDecision {
+ binding,
+ revision,
+ kind: choice.as_ref().map(|c| c.kind).unwrap_or("cancelled"),
+ });
+ }
+ }
+ }
Ok(vec![match choice {
Some(option) => result(
&id,
- json!({"outcome":{"outcome":"selected","optionId":option}}),
+ json!({"outcome":{"outcome":"selected","optionId":option.id}}),
),
- // The agent offered no option matching the decision; a cancelled
- // outcome is the protocol's safe refusal.
None => result(&id, json!({"outcome":{"outcome":"cancelled"}})),
}])
}
@@ -701,3 +1214,744 @@ impl CliAdapter for AcpAdapter {
}
}
}
+
+#[cfg(test)]
+mod metadata_tests {
+ use super::*;
+
+ fn finished_output(adapter: &mut AcpAdapter, update: Value) -> Value {
+ adapter
+ .session_update(&update)
+ .updates
+ .into_iter()
+ .find_map(|update| match update {
+ Update::ToolCompleted { output, .. } => Some(output),
+ _ => None,
+ })
+ .expect("completed tool output")
+ }
+
+ fn cursor_start(adapter: &mut AcpAdapter) {
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call", "toolCallId":"shell-one",
+ "kind":"execute", "status":"pending", "rawInput":{"command":"python3 -m unittest -q"}}),
+ );
+ }
+ fn cursor_permission(adapter: &mut AcpAdapter) {
+ permission(
+ adapter,
+ json!({"toolCallId":"shell-one", "status":"pending",
+ "content":[{"type":"content","content":{"type":"text","text":"Not in allowlist: python3"}}]}),
+ );
+ }
+ fn cursor_terminal(adapter: &mut AcpAdapter, code: i64, large: bool) -> (bool, Value) {
+ adapter.session_update(&json!({"sessionUpdate":"tool_call_update", "toolCallId":"shell-one",
+ "status":"completed", "rawOutput":{"exitCode":code,"stdout":if large {"x".repeat(RAW_OUTPUT_BYTES * 2)} else {String::new()},
+ "stderr":"----------------------------------------------------------------------\nRan 5 tests in 0.000s\n\nOK\n"}}))
+ .updates.into_iter().find_map(|u| match u {
+ Update::ToolCompleted { success, output, .. } => Some((success, output)), _ => None,
+ }).unwrap()
+ }
+
+ #[test]
+ fn cursor_shell_provenance_separates_earlier_permission_reason_from_terminal_result() {
+ // Sequence reproduced by the installed Cursor 2026.09.15 presenter:
+ // permission reason in content; approved shell result in rawOutput;
+ // terminal update omits content, so ACP correctly retains the reason.
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ cursor_start(&mut adapter);
+ cursor_permission(&mut adapter);
+ assert!(adapter.approve("1", true).unwrap()[0].contains("selected"));
+ let (success, output) = cursor_terminal(&mut adapter, 0, false);
+ assert!(success);
+ assert_eq!(output["content"], json!(["Not in allowlist: python3"]));
+ assert_eq!(output["cursor_execution"]["exit_code"], 0);
+ assert_eq!(
+ output["acp_provenance"],
+ json!({
+ "schema_version":1,
+ "history_complete":true,
+ "content":{"source":"permission_request", "phase":"pending", "explicit_terminal":false,
+ "before_permission_decision":true,"after_permission_decision":false},
+ "raw_output":{"source":"tool_update", "phase":"completed", "explicit_terminal":true,
+ "before_permission_decision":false,"after_permission_decision":true},
+ "permission":{"state":"resolved","decision":"allow_once","current_operation_matches":true}
+ })
+ );
+ assert!(adapter.tool_evidence.is_empty());
+ }
+
+ #[test]
+ fn cursor_nonzero_shell_exit_fails_without_permission_even_when_output_is_truncated() {
+ for large in [false, true] {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ cursor_start(&mut adapter);
+ let (success, output) = cursor_terminal(&mut adapter, 7, large);
+ assert!(!success, "completed ACP status must not hide exit 7");
+ assert_eq!(output["cursor_execution"]["exit_code"], 7);
+ assert_eq!(output["raw_output_truncated"], large);
+ assert!(output["acp_provenance"]["permission"].is_null());
+ }
+ let mut other = AcpAdapter::new(Vendor::Grok);
+ cursor_start(&mut other);
+ let (success, output) = cursor_terminal(&mut other, 7, false);
+ assert!(success, "Cursor shell shape is not a cross-vendor contract");
+ assert!(output.get("cursor_execution").is_none());
+ }
+
+ #[test]
+ fn cursor_approval_cannot_authorize_changed_then_restored_operation() {
+ for decision_before_change in [false, true] {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ cursor_start(&mut adapter);
+ cursor_permission(&mut adapter);
+ if decision_before_change {
+ adapter.approve("1", true).unwrap();
+ }
+ for command in ["other", "python3 -m unittest -q"] {
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call_update", "toolCallId":"shell-one",
+ "rawInput":{"command":command}}),
+ );
+ }
+ if !decision_before_change {
+ assert!(adapter.approve("1", true).unwrap()[0].contains("cancelled"));
+ }
+ let (_, output) = cursor_terminal(&mut adapter, 0, false);
+ assert_eq!(
+ output["acp_provenance"]["permission"]["current_operation_matches"],
+ false
+ );
+ assert_eq!(
+ output["acp_provenance"]["permission"]["decision"],
+ if decision_before_change {
+ "allow_once"
+ } else {
+ "cancelled"
+ }
+ );
+ }
+ }
+
+ #[test]
+ fn cursor_approval_binds_non_execute_kinds_and_refuses_missing_input() {
+ for kind in ["read", "tool", "execute"] {
+ for input in [json!({"path":"helpers.py"}), Value::Null] {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call","toolCallId":"shell-one",
+ "kind":kind,"rawInput":input}),
+ );
+ cursor_permission(&mut adapter);
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call_update","toolCallId":"shell-one",
+ "kind":"execute","rawInput":{"command":"different command"}}),
+ );
+ assert!(adapter.approve("1", true).unwrap()[0].contains("cancelled"));
+ }
+ }
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ permission(
+ &mut adapter,
+ json!({"toolCallId":"unknown","kind":"tool","title":"Just a title"}),
+ );
+ assert!(adapter.approve("1", true).unwrap()[0].contains("cancelled"));
+ permission(
+ &mut adapter,
+ json!({"toolCallId":"read-one","kind":"read","rawInput":{"path":"helpers.py"}}),
+ );
+ assert!(adapter.approve("1", true).unwrap()[0].contains("selected"));
+ }
+
+ #[test]
+ fn cursor_approval_binds_displayed_edit_proposal_and_targets() {
+ // Adapter-level guarantee: these updates have already been delivered
+ // to on_line/session_update before the approval response is selected.
+ for kind in ["edit", "delete", "move", "other"] {
+ let content =
+ json!([{"type":"diff","path":"helpers.py","oldText":"old","newText":"approved"}]);
+ let locations = json!([{"path":"helpers.py"}]);
+ for mutation in [
+ Value::Null,
+ json!({"content":[{"type":"diff","path":"helpers.py","oldText":"old","newText":"changed"}]}),
+ json!({"locations":[{"path":"outside.py"}]}),
+ ] {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ adapter.session_update(&json!({"sessionUpdate":"tool_call","toolCallId":"edit-one",
+ "kind":kind,"rawInput":{"path":"helpers.py"},"content":content,"locations":locations}));
+ permission(
+ &mut adapter,
+ json!({"toolCallId":"edit-one","status":"pending"}),
+ );
+ let mut update =
+ json!({"sessionUpdate":"tool_call_update","toolCallId":"edit-one"});
+ if let Some(fields) = mutation.as_object() {
+ for (key, value) in fields {
+ update[key] = value.clone();
+ }
+ }
+ adapter.session_update(&update);
+ let response = adapter.approve("1", true).unwrap();
+ assert!(
+ response[0].contains(if mutation.is_null() {
+ "selected"
+ } else {
+ "cancelled"
+ }),
+ "{kind}: {mutation}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn cursor_shell_diagnostics_are_not_identity_but_displayed_diffs_are() {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ cursor_start(&mut adapter);
+ cursor_permission(&mut adapter);
+ adapter.session_update(&json!({"sessionUpdate":"tool_call_update","toolCallId":"shell-one",
+ "content":[{"type":"content","content":{"type":"text","text":"Updated permission explanation"}}]}));
+ assert!(adapter.approve("1", true).unwrap()[0].contains("selected"));
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call_update","toolCallId":"shell-one",
+ "content":[{"type":"content","content":{"type":"text","text":"Command output"}}]}),
+ );
+ let (_, output) = cursor_terminal(&mut adapter, 0, false);
+ assert_eq!(
+ output["acp_provenance"]["permission"]["current_operation_matches"],
+ true
+ );
+
+ cursor_start(&mut adapter);
+ permission(
+ &mut adapter,
+ json!({"toolCallId":"shell-one",
+ "content":[{"type":"diff","path":"helpers.py","newText":"approved"}]}),
+ );
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call_update","toolCallId":"shell-one",
+ "content":[{"type":"diff","path":"helpers.py","newText":"different"}]}),
+ );
+ assert!(adapter.approve("1", true).unwrap()[0].contains("cancelled"));
+ }
+
+ #[test]
+ fn cursor_permission_binding_cannot_cross_reused_ids_new_prompts_or_requests() {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ cursor_start(&mut adapter);
+ cursor_permission(&mut adapter);
+ cursor_start(&mut adapter);
+ assert!(adapter.approve("1", true).unwrap()[0].contains("cancelled"));
+ cursor_permission(&mut adapter);
+ adapter.session_id = Some("session-one".into());
+ adapter.start_prompt("new task", &[]).unwrap();
+ assert!(adapter.approve("1", true).is_err());
+ assert!(adapter.tool_evidence.is_empty());
+
+ cursor_start(&mut adapter);
+ cursor_permission(&mut adapter);
+ adapter.permission_request(
+ &json!(2),
+ &json!({"toolCall":{"toolCallId":"shell-one"},
+ "options":[{"optionId":"yes","kind":"allow_once"}]}),
+ );
+ assert!(adapter.approve("1", true).unwrap()[0].contains("cancelled"));
+ assert!(adapter.approve("2", true).unwrap()[0].contains("selected"));
+ }
+
+ #[test]
+ fn cursor_evicted_permission_history_cannot_reappear_as_never_requested() {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ cursor_start(&mut adapter);
+ cursor_permission(&mut adapter);
+ assert!(adapter.approve("1", false).unwrap()[0].contains("selected"));
+ adapter.session_update(&json!({"sessionUpdate":"tool_call_update","toolCallId":"shell-one",
+ "content":[{"type":"content","content":{"type":"text","text":"x".repeat(TOOL_METADATA_BYTES)}}]}));
+ assert!(!adapter.tool_evidence.contains_key("shell-one"));
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call_update","toolCallId":"shell-one",
+ "kind":"execute","rawInput":{"command":"python3 -m unittest -q"},"content":[]}),
+ );
+ let (_, output) = cursor_terminal(&mut adapter, 0, false);
+ assert_eq!(output["tool_kind"], "execute");
+ assert_eq!(output["acp_provenance"]["history_complete"], false);
+ assert!(output["acp_provenance"]["permission"].is_null());
+ assert_eq!(output["raw_output"]["exitCode"], 0);
+ // An initial call for the old ID cannot launder earlier history.
+ cursor_start(&mut adapter);
+ let (_, output) = cursor_terminal(&mut adapter, 0, false);
+ assert_eq!(output["acp_provenance"]["history_complete"], false);
+ }
+
+ #[test]
+ fn duplicate_permission_id_cannot_keep_old_authority_through_early_returns() {
+ for (vendor, tool, options) in [
+ (
+ Vendor::Cursor,
+ json!({"toolCallId":"other","kind":"execute","rawInput":{"command":"different"}}),
+ json!([]),
+ ),
+ (
+ Vendor::Cursor,
+ json!({"toolCallId":"other","kind":"execute","rawInput":{"command":"different"}}),
+ json!([{"optionId":"yes","kind":"allow_once"}]),
+ ),
+ (
+ Vendor::Antigravity,
+ json!({"toolCallId":"interaction_other","title":"A question"}),
+ json!([{"optionId":"yes","kind":"allow_once"}]),
+ ),
+ ] {
+ let mut adapter = AcpAdapter::new(vendor);
+ permission(
+ &mut adapter,
+ json!({"toolCallId":"original","kind":"execute","rawInput":{"command":"original"}}),
+ );
+ let duplicate =
+ adapter.permission_request(&json!(1), &json!({"toolCall":tool,"options":options}));
+ assert!(duplicate
+ .updates
+ .iter()
+ .any(|update| matches!(update, Update::TurnFailed(_))));
+ assert!(duplicate.send[0].contains("Duplicate outstanding permission request ID"));
+ assert!(adapter.approve("1", true).is_err());
+ }
+ }
+
+ #[test]
+ fn cursor_unanswered_or_unusable_permission_never_looks_unrequested() {
+ for scenario in ["pending", "reject_then_pending", "unusable"] {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ cursor_start(&mut adapter);
+ if scenario == "unusable" {
+ let step = adapter.permission_request(
+ &json!(1),
+ &json!({
+ "toolCall":{"toolCallId":"shell-one","status":"pending"},"options":[]}),
+ );
+ assert!(step.send[0].contains("No usable permission options"));
+ } else {
+ cursor_permission(&mut adapter);
+ if scenario == "reject_then_pending" {
+ adapter.approve("1", false).unwrap();
+ cursor_permission(&mut adapter);
+ }
+ }
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call_update","toolCallId":"shell-one","content":[]}),
+ );
+ let (_, output) = cursor_terminal(&mut adapter, 0, false);
+ let permission = &output["acp_provenance"]["permission"];
+ assert_eq!(
+ permission["state"],
+ if scenario == "unusable" {
+ "declined"
+ } else {
+ "pending"
+ }
+ );
+ assert_eq!(permission["current_operation_matches"], false);
+ assert_eq!(output["acp_provenance"]["history_complete"], true);
+ }
+ }
+
+ #[test]
+ fn cursor_permission_first_and_evicted_ids_cannot_start_complete_history() {
+ for evict in [false, true] {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ permission(
+ &mut adapter,
+ json!({"toolCallId":"shell-one","kind":"execute",
+ "rawInput":{"command":"python3 -m unittest -q"}}),
+ );
+ adapter.approve("1", false).unwrap();
+ if evict {
+ adapter.session_update(&json!({"sessionUpdate":"tool_call_update","toolCallId":"shell-one",
+ "content":[{"type":"content","content":{"type":"text","text":"x".repeat(TOOL_METADATA_BYTES)}}]}));
+ }
+ cursor_start(&mut adapter);
+ let (_, output) = cursor_terminal(&mut adapter, 0, false);
+ assert_eq!(output["acp_provenance"]["history_complete"], false);
+ }
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ for i in 0..OBSERVED_TOOL_IDS {
+ adapter
+ .observed_tool_ids
+ .insert(crate::workspace::hash(format!("seen-{i}").as_bytes()));
+ }
+ cursor_start(&mut adapter);
+ let (_, output) = cursor_terminal(&mut adapter, 0, false);
+ assert_eq!(output["acp_provenance"]["history_complete"], false);
+ assert_eq!(adapter.observed_tool_ids.len(), OBSERVED_TOOL_IDS);
+ assert!(adapter.tool_history_exhausted);
+ adapter.session_id = Some("session".into());
+ adapter.start_prompt("new prompt", &[]).unwrap();
+ cursor_start(&mut adapter);
+ let (_, output) = cursor_terminal(&mut adapter, 0, false);
+ assert_eq!(output["acp_provenance"]["history_complete"], true);
+ }
+
+ #[test]
+ fn cursor_provenance_rejects_forged_and_cached_terminal_flags_and_bounds_retention() {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ cursor_start(&mut adapter);
+ cursor_permission(&mut adapter);
+ adapter.approve("1", true).unwrap();
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call_update","toolCallId":"shell-one",
+ "rawOutput":{"exitCode":0,"stdout":"","stderr":"early"}}),
+ );
+ let output = finished_output(
+ &mut adapter,
+ json!({"sessionUpdate":"tool_call_update","toolCallId":"shell-one",
+ "status":"completed","rawOutput":null,
+ "acp_provenance":{"raw_output":{"explicit_terminal":true}},"cursor_execution":{"exit_code":99},
+ "content":[{"type":"content","content":{"type":"text","text":"Not in allowlist: python3"}}]}),
+ );
+ assert_eq!(
+ output["acp_provenance"]["raw_output"]["explicit_terminal"],
+ false
+ );
+ assert_eq!(output["acp_provenance"]["content"]["source"], "tool_update");
+ assert_eq!(output["acp_provenance"]["content"]["phase"], "completed");
+ assert_eq!(output["cursor_execution"]["exit_code"], 0);
+ let output = finished_output(
+ &mut adapter,
+ json!({"sessionUpdate":"tool_call","status":"completed",
+ "acp_provenance":{"schema_version":1},"cursor_execution":{"exit_code":99}}),
+ );
+ assert!(output.get("acp_provenance").is_none());
+ assert!(output.get("cursor_execution").is_none());
+ for i in 0..TOOL_METADATA_COUNT + 5 {
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call","toolCallId":format!("bounded-{i}"),
+ "kind":"execute","rawInput":{"command":"true"}}),
+ );
+ }
+ assert_eq!(adapter.tool_evidence.len(), TOOL_METADATA_COUNT);
+ assert!(adapter
+ .tool_evidence
+ .keys()
+ .all(|id| adapter.tool_metadata.contains_key(id)));
+ }
+
+ #[test]
+ fn acp_raw_output_merges_partial_updates_and_preserves_content_independently() {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ adapter.session_update(&json!({"sessionUpdate":"tool_call","toolCallId":"output-1",
+ "kind":"execute","rawOutput":{"stdout":"first"},
+ "content":[{"type":"content","content":{"type":"text","text":"display content"}}]}));
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call_update","toolCallId":"output-1",
+ "rawOutput":{"stdout":"replacement","exitCode":0}}),
+ );
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call_update","toolCallId":"output-1","rawOutput":null}),
+ );
+ let output = finished_output(
+ &mut adapter,
+ json!({"sessionUpdate":"tool_call_update","toolCallId":"output-1","status":"completed"}),
+ );
+ assert_eq!(
+ output["raw_output"],
+ json!({"stdout":"replacement","exitCode":0})
+ );
+ assert_eq!(output["raw_output_truncated"], false);
+ assert_eq!(output["raw_output_format"], "json");
+ assert_eq!(output["content"], json!(["display content"]));
+ assert!(adapter.tool_outputs.is_empty());
+
+ for replacement in [json!({}), json!([]), json!(""), json!(false), json!(0)] {
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call","toolCallId":"output-1","rawOutput":"old"}),
+ );
+ // Permission toolCall is also a partial update of the same tool.
+ permission(
+ &mut adapter,
+ json!({"toolCallId":"output-1","rawOutput":replacement}),
+ );
+ let output = finished_output(
+ &mut adapter,
+ json!({"sessionUpdate":"tool_call_update","toolCallId":"output-1","status":"completed","rawOutput":null}),
+ );
+ assert_eq!(output["raw_output"], replacement);
+ }
+ let output = finished_output(
+ &mut adapter,
+ json!({"sessionUpdate":"tool_call","toolCallId":"no-output","status":"completed","rawOutput":null}),
+ );
+ assert!(output.get("raw_output").is_none());
+ }
+
+ #[test]
+ fn acp_raw_output_redacts_before_bounding_without_evicting_approval_input() {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ let secret = "sk-proj-fixtureABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
+ let initial = json!({"sessionUpdate":"tool_call","toolCallId":"output-1","kind":"execute",
+ "rawInput":{"command":"keep-this-input"},"rawOutput":{"access_token":"local-secret","stdout":format!("key={secret}")}});
+ adapter.session_update(&initial);
+ let output = finished_output(
+ &mut adapter,
+ json!({"sessionUpdate":"tool_call_update","toolCallId":"output-1","status":"failed"}),
+ );
+ assert_eq!(
+ output["raw_output"]["access_token"],
+ crate::redaction::placeholder()
+ );
+ assert!(!output.to_string().contains(secret));
+ assert!(!output.to_string().contains("local-secret"));
+
+ adapter.session_update(&initial);
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call_update","toolCallId":"output-1",
+ "rawOutput":format!("key={secret}\n{}", "\n\\\"é".repeat(RAW_OUTPUT_BYTES))}),
+ );
+ assert_eq!(
+ permission(&mut adapter, json!({"toolCallId":"output-1"})).arguments["input"]
+ ["command"],
+ "keep-this-input"
+ );
+ let output = finished_output(
+ &mut adapter,
+ json!({"sessionUpdate":"tool_call_update","toolCallId":"output-1","status":"completed"}),
+ );
+ assert_eq!(output["raw_output_truncated"], true);
+ assert_eq!(output["raw_output_format"], "json_text_preview");
+ assert!(output["raw_output"].is_string());
+ assert!(output["raw_output"].to_string().len() <= RAW_OUTPUT_BYTES);
+ assert!(!output.to_string().contains(secret));
+ assert!(output["raw_output"]
+ .as_str()
+ .unwrap()
+ .contains(crate::redaction::placeholder()));
+
+ // A peer cannot spoof our internal truncation flag to bypass bounds,
+ // including malformed tool notifications without an ID.
+ let output = finished_output(
+ &mut adapter,
+ json!({"sessionUpdate":"tool_call","status":"completed",
+ "rawOutput":secret.repeat(RAW_OUTPUT_BYTES),"rawOutputTruncated":false}),
+ );
+ assert!(output["raw_output"].to_string().len() <= RAW_OUTPUT_BYTES);
+ assert!(!output.to_string().contains(secret));
+ }
+
+ #[test]
+ fn acp_raw_output_cache_is_bounded_and_does_not_leak_across_tool_lifecycles() {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ let initial =
+ json!({"sessionUpdate":"tool_call","toolCallId":"output-1","rawOutput":"old"});
+ adapter.session_update(&initial);
+ adapter.session_update(&json!({"sessionUpdate":"tool_call","toolCallId":"output-1"}));
+ let output = finished_output(
+ &mut adapter,
+ json!({"sessionUpdate":"tool_call_update","toolCallId":"output-1","status":"completed"}),
+ );
+ assert!(output.get("raw_output").is_none());
+ adapter.session_update(&initial);
+ adapter.session_id = Some("current".into());
+ adapter.start_prompt("fresh prompt", &[]).unwrap();
+ assert!(adapter.tool_outputs.is_empty());
+ adapter.session_update(&initial);
+ adapter.on_line(&json!({"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"foreign","update":{
+ "sessionUpdate":"tool_call_update","toolCallId":"output-1","rawOutput":"foreign"}}}).to_string()).unwrap();
+ let output = finished_output(
+ &mut adapter,
+ json!({"sessionUpdate":"tool_call_update","toolCallId":"output-1","status":"completed"}),
+ );
+ assert_eq!(output["raw_output"], "old");
+
+ for i in 0..TOOL_METADATA_COUNT + 5 {
+ adapter.session_update(&json!({"sessionUpdate":"tool_call","toolCallId":format!("output-{i}"),"rawOutput":"x".repeat(RAW_OUTPUT_BYTES * 2)}));
+ }
+ assert_eq!(adapter.tool_outputs.len(), TOOL_METADATA_COUNT);
+ assert!(adapter
+ .tool_outputs
+ .values()
+ .all(|output| output.value.to_string().len() <= RAW_OUTPUT_BYTES));
+ // A terminal frame's own output still works when retention is full.
+ let output = finished_output(
+ &mut adapter,
+ json!({"sessionUpdate":"tool_call_update","toolCallId":"excess","status":"completed","rawOutput":{"stdout":"current"}}),
+ );
+ assert_eq!(output["raw_output"]["stdout"], "current");
+ let output = finished_output(
+ &mut adapter,
+ json!({"sessionUpdate":"tool_call_update","toolCallId":"output-132","status":"completed"}),
+ );
+ assert!(output.get("raw_output").is_none());
+ let prompt_id = adapter.prompt_id.unwrap();
+ adapter
+ .handle_response(prompt_id, &json!({"result":{"stopReason":"end_turn"}}))
+ .unwrap();
+ assert!(adapter.tool_outputs.is_empty());
+ }
+
+ fn permission(adapter: &mut AcpAdapter, tool: Value) -> ApprovalPrompt {
+ let id = adapter
+ .pending_permissions
+ .keys()
+ .filter_map(|key| key.parse::().ok())
+ .max()
+ .unwrap_or(0)
+ + 1;
+ adapter.permission_request(&json!(id), &json!({
+ "toolCall":tool,
+ "options":[{"optionId":"yes","kind":"allow_once"},{"optionId":"no","kind":"reject_once"}]
+ })).updates.into_iter().find_map(|u| match u {
+ Update::Approval(prompt) => Some(prompt), _ => None,
+ }).unwrap()
+ }
+
+ #[test]
+ fn acp_partial_permission_uses_latest_typed_input_not_initial_command_or_title() {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ adapter.session_update(&json!({"sessionUpdate":"tool_call","toolCallId":"call-1",
+ "kind":"execute","title":"Run original","rawInput":{"command":"original","cwd":"/old"}}));
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call_update","toolCallId":"call-1",
+ "rawInput":{"command":"replacement","cwd":"/new"}}),
+ );
+ let prompt = permission(
+ &mut adapter,
+ json!({"toolCallId":"call-1","title":"Just a display label","rawInput":null}),
+ );
+ assert_eq!(prompt.tool, "cursor.execute");
+ assert_eq!(prompt.command, "replacement");
+ assert_eq!(
+ prompt.arguments["input"],
+ json!({"command":"replacement","cwd":"/new"})
+ );
+ assert_eq!(prompt.arguments["title"], "Just a display label");
+ let override_prompt = permission(
+ &mut adapter,
+ json!({"toolCallId":"call-1","rawInput":{"command":"third"}}),
+ );
+ assert_eq!(
+ override_prompt.arguments["input"],
+ json!({"command":"third"})
+ );
+ let empty = permission(&mut adapter, json!({"toolCallId":"call-1","rawInput":{}}));
+ assert_eq!(empty.arguments["input"], json!({}));
+ assert_ne!(empty.command, "third");
+ }
+
+ #[test]
+ fn acp_metadata_does_not_survive_completion_new_prompt_or_duplicate_initial_call() {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ let initial = json!({"sessionUpdate":"tool_call","toolCallId":"call-1","kind":"execute","rawInput":{"command":"old"}});
+ adapter.session_update(&initial);
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed"}),
+ );
+ assert!(
+ permission(&mut adapter, json!({"toolCallId":"call-1"})).arguments["input"].is_null()
+ );
+ adapter.session_update(&initial);
+ adapter.session_id = Some("session".into());
+ adapter.start_prompt("new task", &[]).unwrap();
+ assert!(
+ permission(&mut adapter, json!({"toolCallId":"call-1"})).arguments["input"].is_null()
+ );
+ adapter.session_update(&initial);
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call","toolCallId":"call-1","kind":"execute"}),
+ );
+ assert!(
+ permission(&mut adapter, json!({"toolCallId":"call-1"})).arguments["input"].is_null()
+ );
+ assert!(
+ permission(&mut adapter, json!({"toolCallId":"unknown"})).arguments["input"].is_null()
+ );
+ }
+
+ #[test]
+ fn acp_metadata_is_bounded_and_drops_old_input_after_oversize_replacement() {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ adapter.remember_tool(
+ &json!({"toolCallId":"kept","rawInput":{"command":"old"}}),
+ ToolOrigin::Initial,
+ );
+ adapter.remember_tool(
+ &json!({"toolCallId":"kept","rawInput":{"command":"x".repeat(TOOL_METADATA_BYTES)}}),
+ ToolOrigin::Update,
+ );
+ assert!(
+ permission(&mut adapter, json!({"toolCallId":"kept"})).arguments["input"].is_null()
+ );
+ for i in 0..TOOL_METADATA_COUNT + 5 {
+ adapter.remember_tool(
+ &json!({"toolCallId":format!("call-{i}"),"rawInput":{"command":"test"}}),
+ ToolOrigin::Initial,
+ );
+ }
+ assert_eq!(adapter.tool_metadata.len(), TOOL_METADATA_COUNT);
+ assert!(
+ permission(&mut adapter, json!({"toolCallId":"call-132"})).arguments["input"].is_null()
+ );
+ assert!(adapter
+ .tool_metadata
+ .values()
+ .all(|v| serde_json::to_vec(v).unwrap().len() <= TOOL_METADATA_BYTES));
+ }
+
+ #[test]
+ fn acp_cached_permission_input_is_still_redacted_before_display() {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ let secret = "sk-proj-fixtureABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890";
+ adapter.session_update(
+ &json!({"sessionUpdate":"tool_call","toolCallId":"secret-check",
+ "kind":"execute","rawInput":{"command":format!("echo {secret}")}}),
+ );
+ let prompt = permission(&mut adapter, json!({"toolCallId":"secret-check"}));
+ assert!(!prompt.command.contains(secret));
+ assert!(!prompt.arguments.to_string().contains(secret));
+ assert!(prompt.arguments["input"]["command"]
+ .as_str()
+ .unwrap()
+ .contains("[redacted secret]"));
+ }
+
+ #[test]
+ fn acp_completion_retains_latest_input_and_permission_kind() {
+ let mut adapter = AcpAdapter::new(Vendor::Grok);
+ adapter.session_update(&json!({"sessionUpdate":"tool_call","toolCallId":"call-1","rawInput":{"command":"old"}}));
+ permission(
+ &mut adapter,
+ json!({"toolCallId":"call-1","kind":"execute","rawInput":{"command":"actual","cwd":"/project"}}),
+ );
+ let completed = adapter.session_update(
+ &json!({"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed"}),
+ );
+ assert!(
+ matches!(&completed.updates[0], Update::ToolCompleted { id, name, output, .. }
+ if id == "call-1" && name == "grok.execute" && output["input"] == json!({"command":"actual","cwd":"/project"}) && output["tool_kind"] == "execute")
+ );
+ }
+
+ #[test]
+ fn acp_foreign_session_cannot_change_or_borrow_current_tool_metadata() {
+ let mut adapter = AcpAdapter::new(Vendor::Cursor);
+ adapter.session_id = Some("current".into());
+ adapter.session_update(&json!({"sessionUpdate":"tool_call","toolCallId":"same-id","kind":"execute","rawInput":{"command":"current-command"}}));
+ let foreign = adapter.on_line(&json!({"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"foreign","update":{"sessionUpdate":"tool_call_update","toolCallId":"same-id","rawInput":{"command":"foreign-command"}}}}).to_string()).unwrap();
+ assert!(foreign
+ .updates
+ .iter()
+ .all(|u| matches!(u, Update::Warning(_))));
+ let denied = adapter.on_line(&json!({"jsonrpc":"2.0","id":42,"method":"session/request_permission","params":{"sessionId":"foreign","toolCall":{"toolCallId":"same-id"},"options":[{"optionId":"yes","kind":"allow_once"}]}}).to_string()).unwrap();
+ assert_eq!(
+ serde_json::from_str::(&denied.send[0]).unwrap()["error"]["code"],
+ -32602
+ );
+ assert!(denied
+ .updates
+ .iter()
+ .all(|u| matches!(u, Update::Warning(_))));
+ assert!(adapter.approve("42", true).is_err());
+ assert_eq!(
+ permission(&mut adapter, json!({"toolCallId":"same-id"})).arguments["input"]["command"],
+ "current-command"
+ );
+ }
+}
diff --git a/native/core/src/cli_agent/acp_probe.rs b/native/core/src/cli_agent/acp_probe.rs
index 666529d1..dfd988fc 100644
--- a/native/core/src/cli_agent/acp_probe.rs
+++ b/native/core/src/cli_agent/acp_probe.rs
@@ -6,15 +6,40 @@
//! accepted on the wire), available modes, and the model catalog. No prompt is
//! sent, so no plan allowance is consumed. The agent keeps its own login; the
//! probe never reads a credential.
+use super::{
+ lines::{BoundedLines, Line, MAX_DIAGNOSTIC_BYTES},
+ MAX_LINE_BYTES,
+};
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use std::{ffi::OsStr, path::Path, process::Stdio, time::Duration};
-use tokio::{
- io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
- process::Command,
-};
+use tokio::{io::AsyncWriteExt, process::Command};
+
+use tokio_util::task::AbortOnDropHandle;
+
+/// A negotiated incompatibility is different from a transient probe failure:
+/// neither a login check nor a model-list fallback can make this runtime usable.
+#[derive(Debug)]
+pub(super) struct UnsupportedProtocolVersion;
+
+impl std::fmt::Display for UnsupportedProtocolVersion {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str("unsupported or missing ACP protocol version; ShadowCode supports version 1. No session was started.")
+ }
+}
-const MAX_LINE: usize = 4_000_000;
+impl std::error::Error for UnsupportedProtocolVersion {}
+
+/// Keep discovery and execution on the same exact supported wire version.
+/// In particular, string/float/null declarations do not establish ACP v1.
+pub(super) fn require_protocol_v1(
+ result: &Value,
+) -> std::result::Result {
+ match result["protocolVersion"].as_u64() {
+ Some(1) => Ok(1),
+ _ => Err(UnsupportedProtocolVersion),
+ }
+}
#[derive(Clone, Debug, PartialEq)]
pub struct AcpModel {
@@ -32,6 +57,9 @@ pub struct AcpProbe {
/// `authenticate` result: `Some(true)` accepted, `Some(false)` rejected,
/// `None` when the agent advertised no auth method.
pub authenticated: Option,
+ /// The method accepted by `authenticate`, not an account billing identity.
+ /// Advertised methods alone never populate this observation.
+ pub authenticated_method: Option,
pub session_started: bool,
pub session_error: Option,
/// `promptCapabilities.image` from `initialize`.
@@ -176,9 +204,20 @@ pub async fn probe_vendor(
deadline: Duration,
antigravity: bool,
) -> Result {
+ if let Some(control) = super::probe_lifecycle::current() {
+ return probe_inner(
+ binary,
+ args,
+ workspace,
+ path_env,
+ antigravity,
+ Some((&control, deadline)),
+ )
+ .await;
+ }
tokio::time::timeout(
deadline,
- probe_inner(binary, args, workspace, path_env, antigravity),
+ probe_inner(binary, args, workspace, path_env, antigravity, None),
)
.await
.map_err(|_| {
@@ -196,7 +235,11 @@ async fn probe_inner(
workspace: &Path,
path_env: Option<&OsStr>,
antigravity: bool,
+ control: Option<(&super::probe_lifecycle::ProbeControl, Duration)>,
) -> Result {
+ if let Some((control, _)) = control {
+ control.check()?;
+ }
let mut command = sanitized(binary, args, workspace, path_env);
let _run_dir = if antigravity {
command.stderr(Stdio::piped());
@@ -212,182 +255,212 @@ async fn probe_inner(
} else {
None
};
+ if let Some((control, _)) = control {
+ control.check()?;
+ }
let mut child = command
.spawn()
.with_context(|| format!("Could not start {}", binary.display()))?;
// Antigravity prints a Google sign-in link on stderr when it has no
// valid sign-in, then waits; that answer is "not signed in".
let (sign_in_tx, mut sign_in_rx) = tokio::sync::oneshot::channel::<()>();
- if let Some(stderr) = child.stderr.take() {
- tokio::spawn(async move {
- let mut lines = BufReader::new(stderr).lines();
+ let stderr_drain = child.stderr.take().map(|stderr| {
+ AbortOnDropHandle::new(tokio::spawn(async move {
+ let mut lines = BoundedLines::new(stderr, MAX_DIAGNOSTIC_BYTES);
let mut tx = Some(sign_in_tx);
while let Ok(Some(line)) = lines.next_line().await {
+ let Line::Text(line) = line else {
+ continue;
+ };
if super::antigravity_server::is_sign_in_prompt(&line) {
if let Some(tx) = tx.take() {
let _ = tx.send(());
}
}
}
- });
- }
- let mut stdin = child.stdin.take().context("ACP stdin missing")?;
- let stdout = child.stdout.take().context("ACP stdout missing")?;
- let mut reader = BufReader::new(stdout);
- let mut probe = AcpProbe::default();
- let init = rpc(
- 1,
- "initialize",
- json!({
- "protocolVersion": 1,
- "clientCapabilities": {"fs": {"readTextFile": false, "writeTextFile": false}, "terminal": false},
- "clientInfo": {"name": "shadowcode", "title": "ShadowCode", "version": crate::VERSION}
- }),
- );
- stdin.write_all(init.as_bytes()).await?;
- stdin.write_all(b"\n").await?;
- stdin.flush().await?;
- let mut pending = std::collections::HashSet::from([1u64]);
- let mut buf = Vec::new();
- let cwd = workspace.display().to_string();
- loop {
- buf.clear();
- let read = tokio::select! {
- read = reader.read_until(b'\n', &mut buf) => read?,
- Ok(()) = &mut sign_in_rx => {
- probe.authenticated = Some(false);
- probe.session_error = Some("Google sign-in required".into());
- break;
- }
- };
- if read == 0 {
- if pending.is_empty() {
- break;
- }
- bail!("{} exited during the ACP handshake", binary.display());
- }
- if buf.len() > MAX_LINE {
- continue;
- }
- let Ok(message) = serde_json::from_slice::(&buf) else {
- continue;
- };
- let Some(id) = message.get("id").and_then(Value::as_u64) else {
- continue;
- };
- if message.get("method").is_some() {
- let reply = json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"ShadowCode probe declared no client capabilities"}}).to_string();
- stdin.write_all(reply.as_bytes()).await?;
- stdin.write_all(b"\n").await?;
- stdin.flush().await?;
- continue;
- }
- if !pending.remove(&id) {
- continue;
- }
- let error = message.get("error").filter(|e| !e.is_null()).map(|e| {
- e["data"]["message"]
- .as_str()
- .or_else(|| e["message"].as_str())
- .unwrap_or("unknown error")
- .to_owned()
- });
- let result = &message["result"];
- match id {
- 1 => {
- if let Some(error) = error {
- bail!("{} rejected initialize: {error}", binary.display());
- }
- probe.protocol_version = result["protocolVersion"].as_u64();
- probe.agent_version = result["_meta"]["agentVersion"]
- .as_str()
- .or_else(|| result["agentInfo"]["version"].as_str())
- .map(str::to_owned);
- probe.accepts_images = result["agentCapabilities"]["promptCapabilities"]["image"]
- .as_bool()
- .unwrap_or(false);
- probe.load_session = result["agentCapabilities"]["loadSession"]
- .as_bool()
- .unwrap_or(false);
- probe.auth_methods = result["authMethods"]
- .as_array()
- .into_iter()
- .flatten()
- .filter_map(|m| m["id"].as_str().map(str::to_owned))
- .collect();
- if let Some(state) = result["_meta"].get("modelState") {
- let (models, current) = models_from_state(state);
- if !models.is_empty() {
- probe.models = models;
- probe.current_model = current;
+ }))
+ });
+ let exchange = async {
+ let mut sign_in_waiting = stderr_drain.is_some();
+ let mut stdin = child.stdin.take().context("ACP stdin missing")?;
+ let stdout = child.stdout.take().context("ACP stdout missing")?;
+ let mut reader = BoundedLines::new(stdout, MAX_LINE_BYTES);
+ let mut probe = AcpProbe::default();
+ let mut requested_auth_method = None;
+ let init = rpc(
+ 1,
+ "initialize",
+ json!({
+ "protocolVersion": 1,
+ "clientCapabilities": {"fs": {"readTextFile": false, "writeTextFile": false}, "terminal": false},
+ "clientInfo": {"name": "shadowcode", "title": "ShadowCode", "version": crate::VERSION}
+ }),
+ );
+ stdin.write_all(init.as_bytes()).await?;
+ stdin.write_all(b"\n").await?;
+ stdin.flush().await?;
+ let mut pending = std::collections::HashSet::from([1u64]);
+ let cwd = workspace.display().to_string();
+ loop {
+ let line = tokio::select! {
+ line = reader.next_protocol_line() => line?,
+ signal = &mut sign_in_rx, if sign_in_waiting => {
+ // A oneshot cannot be polled again after either delivery or
+ // sender closure (for example stderr reached EOF).
+ sign_in_waiting = false;
+ if signal.is_ok() {
+ probe.authenticated = Some(false);
+ probe.session_error = Some("Google sign-in required".into());
+ break;
}
+ continue;
}
- let next = if probe.auth_methods.is_empty() {
- rpc(3, "session/new", json!({"cwd": cwd, "mcpServers": []}))
- } else {
- // Prefer the vendor's own login over a cached-token
- // method so an expired cache is reported, not assumed.
- let method = probe
- .auth_methods
- .iter()
- .find(|m| {
- m.as_str() == "cursor_login"
- || m.as_str() == "cached_token"
- || (antigravity
- && m.as_str() == super::antigravity_server::AUTH_METHOD)
- })
- .cloned()
- .unwrap_or_else(|| probe.auth_methods[0].clone());
- rpc(2, "authenticate", json!({"methodId": method}))
- };
- pending.insert(if probe.auth_methods.is_empty() { 3 } else { 2 });
- stdin.write_all(next.as_bytes()).await?;
- stdin.write_all(b"\n").await?;
- stdin.flush().await?;
- }
- 2 => {
- probe.authenticated = Some(error.is_none());
- if let Some(error) = error {
- probe.session_error = Some(format!("authenticate failed: {error}"));
+ };
+ let Some(line) = line else {
+ if pending.is_empty() {
break;
}
- let next = rpc(3, "session/new", json!({"cwd": cwd, "mcpServers": []}));
- pending.insert(3);
- stdin.write_all(next.as_bytes()).await?;
+ bail!("{} exited during the ACP handshake", binary.display());
+ };
+ let Ok(message) = serde_json::from_str::(&line) else {
+ continue;
+ };
+ let Some(id) = message.get("id").and_then(Value::as_u64) else {
+ continue;
+ };
+ if message.get("method").is_some() {
+ let reply = json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"ShadowCode probe declared no client capabilities"}}).to_string();
+ stdin.write_all(reply.as_bytes()).await?;
stdin.write_all(b"\n").await?;
stdin.flush().await?;
+ continue;
}
- 3 => {
- match error {
- Some(error) => probe.session_error = Some(error),
- None => {
- probe.session_started = result["sessionId"].as_str().is_some();
- probe.modes = result["modes"]["availableModes"]
- .as_array()
- .into_iter()
- .flatten()
- .filter_map(|m| m["id"].as_str().map(str::to_owned))
- .collect();
- let (mut models, mut current) = models_from_state(&result["models"]);
- if models.is_empty() {
- (models, current) =
- models_from_config_options(&result["configOptions"]);
- }
+ if !pending.remove(&id) {
+ continue;
+ }
+ let error = message.get("error").filter(|e| !e.is_null()).map(|e| {
+ e["data"]["message"]
+ .as_str()
+ .or_else(|| e["message"].as_str())
+ .unwrap_or("unknown error")
+ .to_owned()
+ });
+ let result = &message["result"];
+ match id {
+ 1 => {
+ if let Some(error) = error {
+ bail!("{} rejected initialize: {error}", binary.display());
+ }
+ // Validate before trusting capabilities or sending authenticate/session/new.
+ probe.protocol_version = Some(require_protocol_v1(result)?);
+ probe.agent_version = result["_meta"]["agentVersion"]
+ .as_str()
+ .or_else(|| result["agentInfo"]["version"].as_str())
+ .map(str::to_owned);
+ probe.accepts_images = result["agentCapabilities"]["promptCapabilities"]
+ ["image"]
+ .as_bool()
+ .unwrap_or(false);
+ probe.load_session = result["agentCapabilities"]["loadSession"]
+ .as_bool()
+ .unwrap_or(false);
+ probe.auth_methods = result["authMethods"]
+ .as_array()
+ .into_iter()
+ .flatten()
+ .filter_map(|m| m["id"].as_str().map(str::to_owned))
+ .collect();
+ if let Some(state) = result["_meta"].get("modelState") {
+ let (models, current) = models_from_state(state);
if !models.is_empty() {
probe.models = models;
probe.current_model = current;
}
}
+ let next = if probe.auth_methods.is_empty() {
+ rpc(3, "session/new", json!({"cwd": cwd, "mcpServers": []}))
+ } else {
+ // Prefer the vendor's own login over a cached-token
+ // method so an expired cache is reported, not assumed.
+ let method = probe
+ .auth_methods
+ .iter()
+ .find(|m| {
+ m.as_str() == "cursor_login"
+ || m.as_str() == "cached_token"
+ || (antigravity
+ && m.as_str() == super::antigravity_server::AUTH_METHOD)
+ })
+ .cloned()
+ .unwrap_or_else(|| probe.auth_methods[0].clone());
+ requested_auth_method = Some(method.clone());
+ rpc(2, "authenticate", json!({"methodId": method}))
+ };
+ pending.insert(if probe.auth_methods.is_empty() { 3 } else { 2 });
+ stdin.write_all(next.as_bytes()).await?;
+ stdin.write_all(b"\n").await?;
+ stdin.flush().await?;
}
- break;
+ 2 => {
+ probe.authenticated = Some(error.is_none());
+ if let Some(error) = error {
+ probe.session_error = Some(format!("authenticate failed: {error}"));
+ break;
+ }
+ probe.authenticated_method = requested_auth_method.take();
+ let next = rpc(3, "session/new", json!({"cwd": cwd, "mcpServers": []}));
+ pending.insert(3);
+ stdin.write_all(next.as_bytes()).await?;
+ stdin.write_all(b"\n").await?;
+ stdin.flush().await?;
+ }
+ 3 => {
+ match error {
+ Some(error) => probe.session_error = Some(error),
+ None => {
+ probe.session_started = result["sessionId"].as_str().is_some();
+ probe.modes = result["modes"]["availableModes"]
+ .as_array()
+ .into_iter()
+ .flatten()
+ .filter_map(|m| m["id"].as_str().map(str::to_owned))
+ .collect();
+ let (mut models, mut current) = models_from_state(&result["models"]);
+ if models.is_empty() {
+ (models, current) =
+ models_from_config_options(&result["configOptions"]);
+ }
+ if !models.is_empty() {
+ probe.models = models;
+ probe.current_model = current;
+ }
+ }
+ }
+ break;
+ }
+ _ => {}
}
- _ => {}
}
+ drop(stdin);
+ Ok(probe)
+ };
+ let result = match control {
+ Some((control, timeout)) => control.run(timeout, exchange).await,
+ None => exchange.await,
+ };
+ if control.is_some() {
+ // Retain the drainer handle through cancellation, then join its abort.
+ // Dropping it alone would only request abort and could outlive the login.
+ if let Some(drain) = stderr_drain {
+ drain.abort();
+ let _ = drain.await;
+ }
+ super::probe_lifecycle::reap(&mut child).await?;
+ } else if result.is_ok() {
+ let _ = child.start_kill();
+ let _ = tokio::time::timeout(Duration::from_secs(2), child.wait()).await;
}
- drop(stdin);
- let _ = child.start_kill();
- let _ = tokio::time::timeout(Duration::from_secs(2), child.wait()).await;
- Ok(probe)
+ result
}
/// `grok models` prints a login line and a bulleted model list.
diff --git a/native/core/src/cli_agent/auth.rs b/native/core/src/cli_agent/auth.rs
index 0855db37..90f7f760 100644
--- a/native/core/src/cli_agent/auth.rs
+++ b/native/core/src/cli_agent/auth.rs
@@ -12,7 +12,10 @@
//! Disconnect runs the official logout command, which signs the CLI out for
//! the whole user account (shared with terminal use), then forgets cached
//! status, persisted usage, and stored native session ids for that vendor.
-use super::{catalog::VendorCatalog, clip, redact, resolve_binary, CliAgentsConfig, Vendor};
+use super::{
+ catalog::VendorCatalog, clip, probe_lifecycle::PublicationGate, redact, resolve_binary,
+ CliAgentsConfig, Vendor,
+};
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use std::{
@@ -23,47 +26,156 @@ use std::{
time::Duration,
};
use tokio::io::{AsyncBufReadExt, BufReader};
-use tokio_util::sync::CancellationToken;
+use tokio_util::{
+ sync::CancellationToken,
+ task::{task_tracker::TaskTrackerToken, TaskTracker},
+};
pub const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
const LOGOUT_TIMEOUT: Duration = Duration::from_secs(60);
const MAX_LOGIN_LINES: usize = 200;
+const LOGIN_DRAIN_TIMEOUT: Duration = Duration::from_millis(300);
#[derive(Default)]
struct LoginSession {
+ generation: u64,
started_at: f64,
lines: Vec,
done: Option,
cancel: CancellationToken,
+ publication: PublicationGate,
+}
+
+#[derive(Default)]
+struct LoginState {
+ sessions: HashMap,
+ closing: bool,
+ next_generation: u64,
}
-/// In-memory login sessions, one per vendor. Dropping it (engine shutdown)
-/// cancels every running login, which stops the login child.
+/// One admitted login per vendor. Tracking starts atomically with admission,
+/// before a child is spawned, and ends only after its worker future returns.
#[derive(Default)]
pub struct Logins {
- sessions: Mutex>,
+ state: Mutex,
+ workers: TaskTracker,
+}
+
+struct LoginOperation {
+ catalog: Arc,
+ vendor: Vendor,
+ generation: u64,
+ cancel: CancellationToken,
+ publication: PublicationGate,
+ deadline: tokio::time::Instant,
+ timeout: Duration,
+ tracking: Option,
+ finished: bool,
+}
+
+impl LoginOperation {
+ fn finish(&mut self, mut done: Value) {
+ let published = if let Ok(mut state) = self.catalog.logins().state.lock() {
+ match state.sessions.get_mut(&self.vendor) {
+ Some(session)
+ if session.generation == self.generation && session.done.is_none() =>
+ {
+ // Cancel and terminal publication use the same mutex. A
+ // cancel acknowledged before publication must win.
+ if session.cancel.is_cancelled() {
+ done["ok"] = json!(false);
+ done["detail"] = json!("Sign-in cancelled");
+ } else if tokio::time::Instant::now() >= self.deadline {
+ done["ok"] = json!(false);
+ done["detail"] = json!(format!(
+ "Sign-in timed out after {} seconds",
+ self.timeout.as_secs()
+ ));
+ }
+ session.done = Some(done.clone());
+ true
+ }
+ _ => false,
+ }
+ } else {
+ false
+ };
+ self.finished = true;
+ if published {
+ self.catalog.broadcast("account.login.done", done);
+ }
+ }
+ fn fail(&mut self, detail: &str) {
+ self.finish(json!({"vendor":self.vendor.id(),"ok":false,"detail":detail,
+ "availability":"unavailable","availability_label":"Unavailable"}));
+ }
+}
+impl Drop for LoginOperation {
+ fn drop(&mut self) {
+ if !self.finished {
+ self.fail("Sign-in interrupted before completion");
+ }
+ }
}
impl Logins {
- fn begin(&self, vendor: Vendor) -> Option {
- let mut sessions = self.sessions.lock().ok()?;
- if sessions.get(&vendor).is_some_and(|s| s.done.is_none()) {
- return None;
+ fn begin(
+ &self,
+ vendor: Vendor,
+ timeout: Duration,
+ catalog: &Arc,
+ ) -> Result